From 0f272211e8d57e514651411631d5af9e9c60e236 Mon Sep 17 00:00:00 2001 From: kennyboy55 Date: Sun, 16 Jun 2024 15:21:28 +0200 Subject: [PATCH] Layout improvements, stats page extra data and checkout graph on drinks page --- app/components/cards/drink.page.tsx | 65 +++- app/components/filters/drink.filter.tsx | 3 +- app/components/header.tsx | 4 +- app/models/charts.server.ts | 82 +++++ app/models/drinks.server.ts | 32 +- app/routes/admin/edit/beer.$id.tsx | 11 +- app/routes/admin/edit/cocktail.$id.tsx | 11 +- app/routes/admin/edit/soda.$id.tsx | 11 +- app/routes/admin/edit/wine.$id.tsx | 12 +- app/routes/admin/manage/checkout.tsx | 2 +- app/routes/admin/new/beer.tsx | 11 +- app/routes/admin/new/cocktail.tsx | 10 +- app/routes/admin/new/container.tsx | 5 +- app/routes/admin/new/soda.tsx | 11 +- app/routes/admin/new/wine.tsx | 11 +- app/routes/inventory/beer.$beerslug.tsx | 7 +- .../inventory/cocktail.$cocktailslug.tsx | 7 +- app/routes/inventory/route.tsx | 7 +- app/routes/inventory/soda.$sodaslug.tsx | 7 +- app/routes/inventory/stats/route.tsx | 302 +++++++++++------- app/routes/inventory/suggestions/route.tsx | 2 +- app/routes/inventory/wine.$wineslug.tsx | 7 +- app/routes/scan/route.tsx | 8 +- package-lock.json | 95 ++++++ package.json | 2 + public/favicon.ico | Bin 16958 -> 16958 bytes 26 files changed, 545 insertions(+), 180 deletions(-) create mode 100644 app/models/charts.server.ts diff --git a/app/components/cards/drink.page.tsx b/app/components/cards/drink.page.tsx index 41cde27..f9ecd9a 100644 --- a/app/components/cards/drink.page.tsx +++ b/app/components/cards/drink.page.tsx @@ -7,9 +7,14 @@ import { volume } from '~/utils/conversions'; import DrinkCard from './drink.card'; import { Link } from '@remix-run/react'; +import { Bar, Chart } from 'react-chartjs-2'; +import { ClientOnly } from 'remix-utils/client-only'; +import 'chart.js/auto'; + interface Arguments { drink: DrinkComposite recommendations: DrinkComposite[] + chartData: any isAdmin: boolean } @@ -71,6 +76,18 @@ function DrinkPage(arg:Arguments) { } + const chartOptions = { + responsive: true, + plugins: { + legend: { + position: 'top' as const, + }, + title: { + display: false + }, + }, + }; + return ( {glass ? ( @@ -92,7 +109,7 @@ function DrinkPage(arg:Arguments) { ) : ""} -
+
@@ -113,12 +130,17 @@ function DrinkPage(arg:Arguments) { -
+
+ {arg.isAdmin ? ( + <> + Inventory {totalinventory == 0 && !arg.isAdmin ? ( "No inventory" ) : ( - +
@@ -169,7 +191,7 @@ function DrinkPage(arg:Arguments) { ) : ""} - +

{manufacturer.name} ({manufacturer.country_id})

{manufacturer.description}

@@ -177,7 +199,7 @@ function DrinkPage(arg:Arguments) { {manufacturer.image ? ( - + ) : ""} @@ -185,20 +207,37 @@ function DrinkPage(arg:Arguments) { ) : ""} + + +

Checkouts

+ }> + {() => } + + + {arg.recommendations.length > 0 ? ( <> -

Maybe also try

- - {arg.recommendations.map((drink) => ( - - - - ))} - + + +

Maybe also try

+ + {arg.recommendations.map((drink) => ( + + + + ))} + + + ) : ""} ); } +function Fallback() { + return
Generating Chart
; +} + + export default DrinkPage; \ No newline at end of file diff --git a/app/components/filters/drink.filter.tsx b/app/components/filters/drink.filter.tsx index ee5d1c7..403757f 100644 --- a/app/components/filters/drink.filter.tsx +++ b/app/components/filters/drink.filter.tsx @@ -2,7 +2,8 @@ import { useSubmit } from '@remix-run/react'; import { BeerStyle, Manufacturer, WineStyle } from '@zenstackhq/runtime/models'; import { useRef, useState } from 'react'; -import { Form, Col, Row } from 'react-bootstrap'; +import { Form, Col, Row, Button } from 'react-bootstrap'; +import { LinkContainer } from 'react-router-bootstrap'; import ReactSlider from 'react-slider' import { ManufacturerWithDrinks } from '~/models/types'; diff --git a/app/components/header.tsx b/app/components/header.tsx index 615bd83..add42c8 100644 --- a/app/components/header.tsx +++ b/app/components/header.tsx @@ -1,5 +1,5 @@ import {LinkContainer} from 'react-router-bootstrap' -import {Container, Nav, Navbar, NavDropdown} from 'react-bootstrap'; +import {Container, Image, Nav, Navbar, NavDropdown} from 'react-bootstrap'; import { User } from '@zenstackhq/runtime/models'; import { UserType } from '@prisma/client'; @@ -24,7 +24,7 @@ function Header(data: Arguments) { - K-FRIDGE + FRIDGE diff --git a/app/models/charts.server.ts b/app/models/charts.server.ts new file mode 100644 index 0000000..d2f02d7 --- /dev/null +++ b/app/models/charts.server.ts @@ -0,0 +1,82 @@ +import { enhance } from "@zenstackhq/runtime"; +import timeAgo from "~/utils/datetime"; +import { db } from "~/utils/db.server"; + +export async function generateDrinksChartData(id: number){ + const dbe = enhance(db); + + // Get the previous month in dates + var dates = []; + + const now = Date.now(); + const subtracter = (1000 * 60 * 60 * 24); + + for(let i = 0; i <= 30; i++){ + let date = now - (subtracter * i); + dates.push(new Date(date)); + } + + dates.reverse(); + + // History of specific drink + const history = await dbe.history.findMany({where: {container: {drink_id: id}, checkoutAt: {gte: dates[0]}}, orderBy: {checkoutAt: "asc"}}); + const containersAggregate = await dbe.container.aggregate({_sum:{inventory: true}, where: {drink_id: id}}); + + const totalInventory = containersAggregate._sum.inventory; + + // Generate labels + const labels = dates.map((date)=> timeAgo(date)); + + // Generate arrays of data + var checkouts = []; + var inventoryAfter = []; + + var lastInventoryAfter = history.length > 0 ? history[0].inventoryAfter + 1 : 0; + + var lastChange = 0; + for(let i = 0; i <= dates.length - 1; i++){ + let startDate = dates[i]; + let endDate = dates[i+1]; + + let totalCheckouts = 0; + + for(let entry of history){ + if(entry.checkoutAt > startDate && entry.checkoutAt < endDate){ + totalCheckouts++; + lastInventoryAfter = entry.inventoryAfter; + lastChange = i; + } + } + + checkouts[i] = totalCheckouts; + inventoryAfter[i] = lastInventoryAfter; + } + + // Force the remaining inventory left values after the last checkout to be equal to + // the actual remaining inventory + for(let i = lastChange; i <= dates.length - 1; i++){ + inventoryAfter[i] = totalInventory; + } + + const data = { + labels, + datasets: [ + { + type: "line", + label: 'Inventory left', + data: inventoryAfter, + borderColor: 'rgb(255, 99, 132)', + backgroundColor: 'rgba(255, 99, 132, 0.5)', + }, + { + type: 'bar', + label: 'Checkouts', + data: checkouts, + borderColor: 'rgb(53, 162, 235)', + backgroundColor: 'rgba(53, 162, 235, 0.5)', + }, + ] + }; + + return data; + } \ No newline at end of file diff --git a/app/models/drinks.server.ts b/app/models/drinks.server.ts index 8a7fd6c..5b578ae 100644 --- a/app/models/drinks.server.ts +++ b/app/models/drinks.server.ts @@ -52,35 +52,31 @@ export async function getMostCommonSection(containers : ContainerWithSection[]) export async function findRecommendations(sectionId: number, numRecommendations: number, notId: number) : Promise { const dbe = enhance(db); + // Drinks with the same section const drinksWithSameSection = await dbe.drink.findMany({include: {manufacturer: true, containers: true}, where: {id: {not: notId}, containers: {some: {section_id: sectionId}, every: {inventory: {gt: 0}}}}}); var result : DrinkComposite[] = []; - if(drinksWithSameSection.length > 0){ - shuffleArray(drinksWithSameSection); + // Shuffle first recommendations array + shuffleArray(drinksWithSameSection); - if(drinksWithSameSection.length <= numRecommendations){ - result = drinksWithSameSection; - } - else{ - for(var i = 0; i < numRecommendations; i++){ - result.push(drinksWithSameSection[i]) - } - } + var loopLength : number = Math.min(numRecommendations, drinksWithSameSection.length); + + for(var i = 0; i < loopLength; i++){ + result.push(drinksWithSameSection[i]) } - else { + + if(result.length < numRecommendations){ const completelyRandomDrinks = await dbe.drink.findMany({include: {manufacturer: true, containers: true}, where: {id: {not: notId}, containers: {every: {inventory: {gt: 0}}}}}); + // Shuffle second recommendations array shuffleArray(completelyRandomDrinks); - if(completelyRandomDrinks.length <= numRecommendations){ - result = completelyRandomDrinks; + loopLength = Math.min(numRecommendations, completelyRandomDrinks.length) - result.length; + + for(var i = 0; i < loopLength; i++){ + result.push(completelyRandomDrinks[i]) } - else{ - for(var i = 0; i < numRecommendations; i++){ - result.push(completelyRandomDrinks[i]) - } - } } return result; diff --git a/app/routes/admin/edit/beer.$id.tsx b/app/routes/admin/edit/beer.$id.tsx index cab9be0..611beaf 100644 --- a/app/routes/admin/edit/beer.$id.tsx +++ b/app/routes/admin/edit/beer.$id.tsx @@ -1,6 +1,7 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { LoaderFunctionArgs, json, redirect } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; +import { useState } from "react"; import { Container, Row, Button, Form, InputGroup } from "react-bootstrap"; import { enhance } from "~/utils/db.server"; @@ -82,6 +83,12 @@ export const loader = async ({ export default function EditBeerRoute() { var loaderData = useLoaderData(); + var [slug, setSlug] = useState(loaderData.beer.slug); + + var calcSlug = function(name:string) : string{ + return name.toLowerCase().trim().replaceAll(" ", "_").replaceAll(/[^a-zA-Z0-9_]/g, ""); + } + return ( @@ -95,12 +102,12 @@ export default function EditBeerRoute() { Slug - + Name - + {setSlug(calcSlug(e.target.value))}}/> diff --git a/app/routes/admin/edit/cocktail.$id.tsx b/app/routes/admin/edit/cocktail.$id.tsx index 4e5dd5d..71616ac 100644 --- a/app/routes/admin/edit/cocktail.$id.tsx +++ b/app/routes/admin/edit/cocktail.$id.tsx @@ -1,6 +1,7 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { LoaderFunctionArgs, json, redirect } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; +import { useState } from "react"; import { Container, Row, Button, Form, InputGroup } from "react-bootstrap"; import { enhance } from "~/utils/db.server"; @@ -76,6 +77,12 @@ export const loader = async ({ export default function EditCocktailRoute() { var loaderData = useLoaderData(); + var [slug, setSlug] = useState(loaderData.cocktail.slug); + + var calcSlug = function(name:string) : string{ + return name.toLowerCase().trim().replaceAll(" ", "_").replaceAll(/[^a-zA-Z0-9_]/g, ""); + } + return ( @@ -89,12 +96,12 @@ export default function EditCocktailRoute() { Slug - + Name - + {setSlug(calcSlug(e.target.value))}}/> diff --git a/app/routes/admin/edit/soda.$id.tsx b/app/routes/admin/edit/soda.$id.tsx index 3b529c0..ddb2f1c 100644 --- a/app/routes/admin/edit/soda.$id.tsx +++ b/app/routes/admin/edit/soda.$id.tsx @@ -1,6 +1,7 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { LoaderFunctionArgs, json, redirect } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; +import { useState } from "react"; import { Container, Row, Button, Form, InputGroup } from "react-bootstrap"; import { enhance } from "~/utils/db.server"; @@ -76,6 +77,12 @@ export const loader = async ({ export default function EditSodaRoute() { var loaderData = useLoaderData(); + var [slug, setSlug] = useState(loaderData.soda.slug); + + var calcSlug = function(name:string) : string{ + return name.toLowerCase().trim().replaceAll(" ", "_").replaceAll(/[^a-zA-Z0-9_]/g, ""); + } + return ( @@ -89,12 +96,12 @@ export default function EditSodaRoute() { Slug - + Name - + {setSlug(calcSlug(e.target.value))}}/> diff --git a/app/routes/admin/edit/wine.$id.tsx b/app/routes/admin/edit/wine.$id.tsx index 5e223ae..ef08b89 100644 --- a/app/routes/admin/edit/wine.$id.tsx +++ b/app/routes/admin/edit/wine.$id.tsx @@ -1,6 +1,7 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { LoaderFunctionArgs, json, redirect } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; +import { useState } from "react"; import { Container, Row, Button, Form, InputGroup } from "react-bootstrap"; import { enhance } from "~/utils/db.server"; @@ -94,6 +95,13 @@ export const loader = async ({ export default function EditWineRoute() { var loaderData = useLoaderData(); + var [slug, setSlug] = useState(loaderData.wine.slug); + + var calcSlug = function(name:string) : string{ + return name.toLowerCase().trim().replaceAll(" ", "_").replaceAll(/[^a-zA-Z0-9_]/g, ""); + } + + return ( @@ -107,12 +115,12 @@ export default function EditWineRoute() { Slug - + Name - + {setSlug(calcSlug(e.target.value))}}/> diff --git a/app/routes/admin/manage/checkout.tsx b/app/routes/admin/manage/checkout.tsx index 6ed646b..e201bd4 100644 --- a/app/routes/admin/manage/checkout.tsx +++ b/app/routes/admin/manage/checkout.tsx @@ -11,7 +11,7 @@ export async function loader({ }: LoaderFunctionArgs) { const { dbe } = await enhance(request); - const checkouts = await dbe.history.findMany({select: {id: true, checkoutAt: true, container: {include: {drink: true}}}, orderBy: {checkoutAt: "desc"}, take: 20}); + const checkouts = await dbe.history.findMany({select: {id: true, checkoutAt: true, container: {include: {drink: true}}}, orderBy: {checkoutAt: "desc"}, take: 50}); return json({ checkouts }); }; diff --git a/app/routes/admin/new/beer.tsx b/app/routes/admin/new/beer.tsx index a2f365e..9601e38 100644 --- a/app/routes/admin/new/beer.tsx +++ b/app/routes/admin/new/beer.tsx @@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { LoaderFunctionArgs, NodeOnDiskFile, json, redirect, unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler, unstable_parseMultipartFormData } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import { renameSync, rmSync } from "node:fs"; +import { useState } from "react"; import { Button, Container, Form, InputGroup, Row } from "react-bootstrap"; import { enhance } from "~/utils/db.server"; @@ -94,6 +95,12 @@ export const loader = async ({ export default function NewBeerRoute() { var loaderData = useLoaderData(); + var [slug, setSlug] = useState(""); + + var calcSlug = function(name:string) : string{ + return name.toLowerCase().trim().replaceAll(" ", "_").replaceAll(/[^a-zA-Z0-9_]/g, ""); + } + return ( @@ -102,12 +109,12 @@ export default function NewBeerRoute() { Slug - + Name - + {setSlug(calcSlug(e.target.value))}}/> diff --git a/app/routes/admin/new/cocktail.tsx b/app/routes/admin/new/cocktail.tsx index 1580b3d..c2d49fd 100644 --- a/app/routes/admin/new/cocktail.tsx +++ b/app/routes/admin/new/cocktail.tsx @@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { LoaderFunctionArgs, NodeOnDiskFile, json, redirect, unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler, unstable_parseMultipartFormData } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import { renameSync, rmSync } from "node:fs"; +import { useState } from "react"; import { Button, Container, Form, InputGroup, Row } from "react-bootstrap"; import { enhance } from "~/utils/db.server"; @@ -87,6 +88,11 @@ export const loader = async ({ export default function NewCocktailRoute() { var loaderData = useLoaderData(); + var [slug, setSlug] = useState(""); + + var calcSlug = function(name:string) : string{ + return name.toLowerCase().trim().replaceAll(" ", "_").replaceAll(/[^a-zA-Z0-9_]/g, ""); + } return ( @@ -96,12 +102,12 @@ export default function NewCocktailRoute() { Slug - + Name - + {setSlug(calcSlug(e.target.value))}}/> diff --git a/app/routes/admin/new/container.tsx b/app/routes/admin/new/container.tsx index 35a059b..dad4cb0 100644 --- a/app/routes/admin/new/container.tsx +++ b/app/routes/admin/new/container.tsx @@ -1,6 +1,6 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { LoaderFunctionArgs, json, redirect } from "@remix-run/node"; -import { useLoaderData } from "@remix-run/react"; +import { useLoaderData, useSearchParams } from "@remix-run/react"; import { Button, Container, Form, InputGroup, Row } from "react-bootstrap"; import { ContainerType } from "@prisma/client"; @@ -53,6 +53,7 @@ export const loader = async ({ export default function NewContainerRoute() { var loaderData = useLoaderData(); + var [searchParams] = useSearchParams(); return ( @@ -63,7 +64,7 @@ export default function NewContainerRoute() { Drink {loaderData.drinks.map((drink) => ( - ))} diff --git a/app/routes/admin/new/soda.tsx b/app/routes/admin/new/soda.tsx index 6dc2b1c..b4a3bfc 100644 --- a/app/routes/admin/new/soda.tsx +++ b/app/routes/admin/new/soda.tsx @@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { LoaderFunctionArgs, NodeOnDiskFile, json, redirect, unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler, unstable_parseMultipartFormData } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import { renameSync, rmSync } from "node:fs"; +import { useState } from "react"; import { Button, Container, Form, InputGroup, Row } from "react-bootstrap"; import { enhance } from "~/utils/db.server"; @@ -87,6 +88,12 @@ export const loader = async ({ export default function NewSodaRoute() { var loaderData = useLoaderData(); + + var [slug, setSlug] = useState(""); + + var calcSlug = function(name:string) : string{ + return name.toLowerCase().trim().replaceAll(" ", "_").replaceAll(/[^a-zA-Z0-9_]/g, ""); + } return ( @@ -96,12 +103,12 @@ export default function NewSodaRoute() { Slug - + Name - + {setSlug(calcSlug(e.target.value))}}/> diff --git a/app/routes/admin/new/wine.tsx b/app/routes/admin/new/wine.tsx index 7b953b4..2032840 100644 --- a/app/routes/admin/new/wine.tsx +++ b/app/routes/admin/new/wine.tsx @@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/node"; import { LoaderFunctionArgs, NodeOnDiskFile, json, redirect, unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler, unstable_parseMultipartFormData } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import { renameSync, rmSync } from "node:fs"; +import { useState } from "react"; import { Button, Container, Form, InputGroup, Row } from "react-bootstrap"; import { enhance } from "~/utils/db.server"; @@ -105,6 +106,12 @@ export const loader = async ({ export default function NewWineRoute() { var loaderData = useLoaderData(); + var [slug, setSlug] = useState(""); + + var calcSlug = function(name:string) : string{ + return name.toLowerCase().trim().replaceAll(" ", "_").replaceAll(/[^a-zA-Z0-9_]/g, ""); + } + return ( @@ -113,12 +120,12 @@ export default function NewWineRoute() { Slug - + Name - + {setSlug(calcSlug(e.target.value))}}/> diff --git a/app/routes/inventory/beer.$beerslug.tsx b/app/routes/inventory/beer.$beerslug.tsx index 3d56dc4..e064fed 100644 --- a/app/routes/inventory/beer.$beerslug.tsx +++ b/app/routes/inventory/beer.$beerslug.tsx @@ -9,6 +9,7 @@ import { useRouteError } from "@remix-run/react"; import DrinkPage from "~/components/cards/drink.page"; +import { generateDrinksChartData } from "~/models/charts.server"; import { findIdFromSlug, findRecommendations, getMostCommonSection } from "~/models/drinks.server"; @@ -34,17 +35,19 @@ export const loader = async ({ const recommendations = await findRecommendations(await getMostCommonSection(beer.containers), 2, beer.id); + const chartData = await generateDrinksChartData(beer.id); + let user = session.get("user"); let isAdmin = user?.type == "Admin"; - return json({ beer , recommendations, isAdmin: isAdmin}); + return json({ beer , recommendations, chartData, isAdmin: isAdmin}); }; export default function BeerRoute() { const data = useLoaderData(); return ( - + ); } diff --git a/app/routes/inventory/cocktail.$cocktailslug.tsx b/app/routes/inventory/cocktail.$cocktailslug.tsx index 7f8cf63..f24ad5a 100644 --- a/app/routes/inventory/cocktail.$cocktailslug.tsx +++ b/app/routes/inventory/cocktail.$cocktailslug.tsx @@ -9,6 +9,7 @@ import { useRouteError } from "@remix-run/react"; import DrinkPage from "~/components/cards/drink.page"; +import { generateDrinksChartData } from "~/models/charts.server"; import { findIdFromSlug, findRecommendations, getMostCommonSection } from "~/models/drinks.server"; import { enhance } from "~/utils/db.server"; @@ -33,17 +34,19 @@ params, const recommendations = await findRecommendations(await getMostCommonSection(cocktail.containers), 2, cocktail.id); + const chartData = await generateDrinksChartData(cocktail.id); + let user = session.get("user"); let isAdmin = user?.type == "Admin"; - return json({ cocktail, recommendations, isAdmin: isAdmin}); + return json({ cocktail, recommendations, chartData, isAdmin: isAdmin}); }; export default function CocktailRoute() { const data = useLoaderData(); return ( - + ); } diff --git a/app/routes/inventory/route.tsx b/app/routes/inventory/route.tsx index b371914..670075b 100644 --- a/app/routes/inventory/route.tsx +++ b/app/routes/inventory/route.tsx @@ -3,6 +3,7 @@ import { useLoaderData, useSearchParams } from "@remix-run/react"; import { enhance } from "@zenstackhq/runtime"; import { useState } from "react"; import { Accordion, Button, Col, Container, Offcanvas, Row, useAccordionButton } from "react-bootstrap"; +import { LinkContainer } from "react-router-bootstrap"; import DrinkCard from "~/components/cards/drink.card"; import DrinkFilter from "~/components/filters/drink.filter"; import { findDrinksFromSearch } from "~/models/drinks.filter.server"; @@ -43,7 +44,11 @@ export default function BeersRoute() {
- {numResults} Results + + + + + {numResults} Results
diff --git a/app/routes/inventory/soda.$sodaslug.tsx b/app/routes/inventory/soda.$sodaslug.tsx index a5d5bfe..b72fa24 100644 --- a/app/routes/inventory/soda.$sodaslug.tsx +++ b/app/routes/inventory/soda.$sodaslug.tsx @@ -9,6 +9,7 @@ import { useRouteError } from "@remix-run/react"; import DrinkPage from "~/components/cards/drink.page"; +import { generateDrinksChartData } from "~/models/charts.server"; import { findIdFromSlug, findRecommendations, getMostCommonSection } from "~/models/drinks.server"; import { enhance } from "~/utils/db.server"; @@ -33,17 +34,19 @@ params, const recommendations = await findRecommendations(await getMostCommonSection(soda.containers), 2, soda.id); + const chartData = await generateDrinksChartData(soda.id); + let user = session.get("user"); let isAdmin = user?.type == "Admin"; - return json({ soda , recommendations, isAdmin: isAdmin}); + return json({ soda , recommendations, chartData, isAdmin: isAdmin}); }; export default function SodaRoute() { const data = useLoaderData(); return ( - + ); } diff --git a/app/routes/inventory/stats/route.tsx b/app/routes/inventory/stats/route.tsx index 711cc2c..a29a358 100644 --- a/app/routes/inventory/stats/route.tsx +++ b/app/routes/inventory/stats/route.tsx @@ -1,9 +1,12 @@ import { LoaderFunctionArgs, json } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import { enhance } from "@zenstackhq/runtime"; -import { Button, Col, Container, Row, Table } from "react-bootstrap"; +import { Badge, Button, Col, Container, Nav, Row, Tab, Table, Tabs } from "react-bootstrap"; +import { Line } from "react-chartjs-2"; import { LinkContainer } from "react-router-bootstrap"; -import { ContainerWithDrink } from "~/models/types"; +import { ClientOnly } from "remix-utils/client-only"; +import { ContainerWithDrink, containerTypeToString } from "~/models/types"; +import { volume } from "~/utils/conversions"; import timeAgo from "~/utils/datetime"; import { db } from "~/utils/db.server"; @@ -64,7 +67,7 @@ export const loader = async ({request} : LoaderFunctionArgs) => { const containers = await dbe.container.findMany({where: {inventory: {gt: 0}}, include: {drink: true}}); const total = statsForContainers(containers); - // Checkout statistics + // Checkout 24H statistics let lastDayDate = Date.now() - (24 * 60 * 60 * 1000); let lastDay = new Date(lastDayDate).toISOString(); @@ -78,9 +81,23 @@ export const loader = async ({request} : LoaderFunctionArgs) => { const statsLast24H = statsForContainers(containersLast24); - const history = await dbe.history.findMany({select: {checkoutAt: true, inventoryAfter: true, container: {select: {drink: true}}}, take: 10, orderBy: {checkoutAt: "desc"}}); + // Checkout 24H statistics + let lastMonthDate = Date.now() - (31 * 24 * 60 * 60 * 1000); + let lastMonth = new Date(lastMonthDate).toISOString(); - return json({total, statsLast24H, history}); + const historyLastMonth = await dbe.history.findMany({where: {checkoutAt: {gte: lastMonth}}, include: {container: {include: {drink: true}}}}); + + let containersLastMonth : ContainerWithDrink[] = []; + historyLastMonth.forEach((entry) => { + entry.container.inventory = 1; + containersLastMonth.push(entry.container); + }); + + const statsLastMonth = statsForContainers(containersLastMonth); + + const history = await dbe.history.findMany({select: {checkoutAt: true, inventoryAfter: true, container: {select: {volume: true, type: true, drink: true}}}, take: 10, orderBy: {checkoutAt: "desc"}}); + + return json({total, statsLast24H, statsLastMonth, history}); }; export default function StatsRoute() { @@ -89,117 +106,174 @@ export default function StatsRoute() { return ( - -

Total inventory

-
Section
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ItemAmount
# Drinks{loadData.total.drinksInventory}
# Beers{loadData.total.beersInventory}
# Wines{loadData.total.wineInventory}
# Sodas{loadData.total.sodaInventory}
# Cocktails{loadData.total.cocktailInventory}
StatisticAmount
Pure alcohol{loadData.total.totalLiterAlcohol} Liter
Price€{loadData.total.totalPrice}
Average alcohol percentage{loadData.total.averageAlcoholPercentage}%
- - -

Last 24H checkouts

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ItemAmount
# Drinks{loadData.statsLast24H.drinksInventory}
# Beers{loadData.statsLast24H.beersInventory}
# Wines{loadData.statsLast24H.wineInventory}
# Sodas{loadData.statsLast24H.sodaInventory}
# Cocktails{loadData.statsLast24H.cocktailInventory}
StatisticAmount
Pure alcohol{loadData.statsLast24H.totalLiterAlcohol} Liter
Price€{loadData.statsLast24H.totalPrice}
Average alcohol percentage{loadData.statsLast24H.averageAlcoholPercentage}%
+ + + +

Total inventory

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemAmount
# Drinks{loadData.total.drinksInventory}
# Beers{loadData.total.beersInventory}
# Wines{loadData.total.wineInventory}
# Sodas{loadData.total.sodaInventory}
# Cocktails{loadData.total.cocktailInventory}
StatisticAmount
Pure alcohol{loadData.total.totalLiterAlcohol} Liter
Price€{loadData.total.totalPrice}
Average alcohol percentage{loadData.total.averageAlcoholPercentage}%
+
+ +

Last Month checkouts

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemAmount
# Drinks{loadData.statsLastMonth.drinksInventory}
# Beers{loadData.statsLastMonth.beersInventory}
# Wines{loadData.statsLastMonth.wineInventory}
# Sodas{loadData.statsLastMonth.sodaInventory}
# Cocktails{loadData.statsLastMonth.cocktailInventory}
StatisticAmount
Pure alcohol{loadData.statsLastMonth.totalLiterAlcohol} Liter
Price€{loadData.statsLastMonth.totalPrice}
Average alcohol percentage{loadData.statsLastMonth.averageAlcoholPercentage}%
+
+ +

Last 24H checkouts

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemAmount
# Drinks{loadData.statsLast24H.drinksInventory}
# Beers{loadData.statsLast24H.beersInventory}
# Wines{loadData.statsLast24H.wineInventory}
# Sodas{loadData.statsLast24H.sodaInventory}
# Cocktails{loadData.statsLast24H.cocktailInventory}
StatisticAmount
Pure alcohol{loadData.statsLast24H.totalLiterAlcohol} Liter
Price€{loadData.statsLast24H.totalPrice}
Average alcohol percentage{loadData.statsLast24H.averageAlcoholPercentage}%
+
+

History

- +
@@ -211,7 +285,7 @@ export default function StatsRoute() { {loadData.history.map((entry) => ( - + @@ -223,6 +297,4 @@ export default function StatsRoute() { ); -} - - +} \ No newline at end of file diff --git a/app/routes/inventory/suggestions/route.tsx b/app/routes/inventory/suggestions/route.tsx index b634d6e..f75b61a 100644 --- a/app/routes/inventory/suggestions/route.tsx +++ b/app/routes/inventory/suggestions/route.tsx @@ -87,7 +87,7 @@ export default function SuggestionsRoute() { {loadData.suggestions.length > 0 ? (

Suggestions

-
Drink
{entry.container.drink.name}{entry.container.drink.name} {volume(entry.container.volume)} {containerTypeToString(entry.container.type)} {timeAgo(new Date(entry.checkoutAt))} {entry.inventoryAfter}
+
diff --git a/app/routes/inventory/wine.$wineslug.tsx b/app/routes/inventory/wine.$wineslug.tsx index 939d626..9f03c1d 100644 --- a/app/routes/inventory/wine.$wineslug.tsx +++ b/app/routes/inventory/wine.$wineslug.tsx @@ -9,6 +9,7 @@ import { useRouteError } from "@remix-run/react"; import DrinkPage from "~/components/cards/drink.page"; +import { generateDrinksChartData } from "~/models/charts.server"; import { findIdFromSlug, findRecommendations, getMostCommonSection } from "~/models/drinks.server"; import { enhance } from "~/utils/db.server"; @@ -33,17 +34,19 @@ params, const recommendations = await findRecommendations(await getMostCommonSection(wine.containers), 2, wine.id); + const chartData = await generateDrinksChartData(wine.id); + let user = session.get("user"); let isAdmin = user?.type == "Admin"; - return json({ wine , recommendations, isAdmin: isAdmin}); + return json({ wine , recommendations, chartData, isAdmin: isAdmin}); }; export default function WineRoute() { const data = useLoaderData(); return ( - + ); } diff --git a/app/routes/scan/route.tsx b/app/routes/scan/route.tsx index 156ac9a..8fbbef6 100644 --- a/app/routes/scan/route.tsx +++ b/app/routes/scan/route.tsx @@ -28,7 +28,7 @@ export async function action({ const barcode = form.get("barcode")?.toString() || ""; const container = await dbe.container.findUnique({ - select: {id: true, inventory: true}, + select: {id: true, inventory: true, drink_id: true}, where: {barcode: barcode} }); @@ -45,8 +45,12 @@ export async function action({ const newInventory = Math.max(container.inventory - 1, 0); await dbe.container.update({data: {inventory: newInventory}, where: {id: container.id}}); + // Get the new total inventory + const containersAggregate = await dbe.container.aggregate({_sum:{inventory: true}, where: {drink_id: container.drink_id}}); + const newTotalInventory = containersAggregate._sum.inventory || newInventory; + // Log entry - await dbe.history.create({data: {container: {connect: {id: container.id}}, inventoryAfter: newInventory}}); + await dbe.history.create({data: {container: {connect: {id: container.id}}, inventoryAfter: newTotalInventory}}); return json({error: ""}); }; diff --git a/package-lock.json b/package-lock.json index 487bae7..da1e04d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,11 @@ "lodash": "^4.17.21", "react": "^18.2.0", "react-bootstrap": "^2.10.2", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-router-bootstrap": "^0.26.2", "react-slider": "^2.0.6", + "remix-utils": "^7.6.0", "tsx": "^4.7.3" }, "devDependencies": { @@ -1368,6 +1370,12 @@ "integrity": "sha512-Lg3PnLp0QXpxwLIAuuJboLeRaIhrgJjeuh797QADg3xz8wGLugQOS5DpsE8A6i6Adgzf+bacllkKZG3J0tGfDw==", "dev": true }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==", + "peer": true + }, "node_modules/@mdx-js/mdx": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-2.3.0.tgz", @@ -3720,6 +3728,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chart.js": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.3.tgz", + "integrity": "sha512-qK1gkGSRYcJzqrrzdR6a+I0vQ4/R+SoODXyAjscQ/4mzuNzySaMCd+hyVxitSY1+L2fjPD1Gbn+ibNqRmwQeLw==", + "peer": true, + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chevrotain": { "version": "10.4.2", "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.4.2.tgz", @@ -9912,6 +9932,15 @@ } } }, + "node_modules/react-chartjs-2": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "18.3.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.0.tgz", @@ -10206,6 +10235,72 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remix-utils": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/remix-utils/-/remix-utils-7.6.0.tgz", + "integrity": "sha512-BPhCUEy+nwrhDDDg2v3+LFSszV6tluMbeSkbffj2o4tqZxt5Kn69Y9sNpGxYLAj8gjqeYDuxjv55of+gYnnykA==", + "dependencies": { + "type-fest": "^4.3.3" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@remix-run/cloudflare": "^2.0.0", + "@remix-run/deno": "^2.0.0", + "@remix-run/node": "^2.0.0", + "@remix-run/react": "^2.0.0", + "@remix-run/router": "^1.7.2", + "crypto-js": "^4.1.1", + "intl-parse-accept-language": "^1.0.0", + "is-ip": "^5.0.1", + "react": "^18.0.0", + "zod": "^3.22.4" + }, + "peerDependenciesMeta": { + "@remix-run/cloudflare": { + "optional": true + }, + "@remix-run/deno": { + "optional": true + }, + "@remix-run/node": { + "optional": true + }, + "@remix-run/react": { + "optional": true + }, + "@remix-run/router": { + "optional": true + }, + "crypto-js": { + "optional": true + }, + "intl-parse-accept-language": { + "optional": true + }, + "is-ip": { + "optional": true + }, + "react": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/remix-utils/node_modules/type-fest": { + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.20.0.tgz", + "integrity": "sha512-MBh+PHUHHisjXf4tlx0CFWoMdjx8zCMLJHOjnV1prABYZFHqtFOyauCIK2/7w4oIfwkF8iNhLtnJEfVY2vn3iw==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/require-like": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz", diff --git a/package.json b/package.json index 3d69121..dbca928 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,11 @@ "lodash": "^4.17.21", "react": "^18.2.0", "react-bootstrap": "^2.10.2", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-router-bootstrap": "^0.26.2", "react-slider": "^2.0.6", + "remix-utils": "^7.6.0", "tsx": "^4.7.3" }, "devDependencies": { diff --git a/public/favicon.ico b/public/favicon.ico index 8830cf6821b354114848e6354889b8ecf6d2bc61..a508aa998f2c38d6519d4aacfbd8fb230d96ea0d 100644 GIT binary patch literal 16958 zcmeI4du$X%9LIm#o)!dzLV;M7msE-vc`1*GX(5L26q`VT!~{gp;2%IBq}3SiP)hHP zq9K70)R5ro4-yRch%x*@4gNs_i5iXZ6>LnzKqRR&&}+et-{0I$yWZ{2?cD9Q*Tf^! z-|Xzn@Av!6%+Ad2&O&72Uu`Yn?*yt|L^Otosu7SxF$7MRzgnVmcc()-A^lezpux_t zd|Kq;i6d$5#8EUSGgy{2okM0iA6He-+_keJRZd+TciIXwE}}HZDA>MrCa4`j+-M^4 z;ygx5H#+-C&NS67-F-=~u$HceyL|oNGL(Qoq56LPssXWGok%D;`E7MW|a@@7sLrFWv3MX;wq~kZm8-hzjcAt z(+5HWhhlIXR!n{+%3Kz48mIhX{`P$&s>k&D?_|j*zG7l2^r=%PEK;sAdMPI!3{L6V!<^wPGfy57%k4)JyPPFLQ2MT{jf({z9C+N59 z%~Uba`7v(oG-by)-`&|n({r&Gd;6!FsLqs~^W&Od=7+tQ`8$&IKtAkP4{!F%Ud;Tw zPR)ZqX2M>q{9r$C%8ow&jwyRF@^>cbgfV-Z&g8>hZ2Xw#4w$lIj{muk=Pa<--X8S* zPaf-kwEKoU*i&GioNwC~OZ$U+mN7f#Ijjw83Sln>etv$H4?FtHx5NroK6MJ>7_4hep7bT*VkUYhLK)m{MXu=s5}f#FP&lY zW6bX`>;wC~d<`Q#Y<~9Hke{F5)`!9Brn4{j#eN!NS_fDsZgTTC=(N7zXP`(KGVD|s;{-@K=dGoVjyw}=oR0naoa=np8_c$G3MDcSU z*zhO`_8;o=3Z*5M*t58I?-Mh6xr%2QM@;Gfd4CWB+ZhA?DSqY4TdPTo9cIqY`=>tM z*xT+(pUz#>!D*Gxr#z?Am(MxamHppo|LhhLkrMxPFm@=3Uti8n z-fy1HrPJg*>}Bz@4^KVhe>|vP_}@P7Wboq)`oSO0GP&Xy6OZ}P>n9Ivx$^H_;*X!l zi<9dM;};1UmWw&-yk<|6A8wV$i~qfKmfQo3eA=;)uaomz-TOZKtnB2HqKPJDF`~RK z4gH|tb+Q-#moHfu19JC)@E^g=f8*!Sa<8?h3r{|qIOygesKh={MV($fq zPR~A2HHt*r-eCCIcdQLox^imzLMlnCOu1ElFaEz?BQbu2H#f@5I;olD&CVRkj)_FM zjiz;QGtYIh7eD4DF@Icc{`Iqi=jXcOYuls_;P2n5M)P&D7eCuxJ3Tjl=6WyFXOni% z%!_%kIa8nZI8K*@k3D}*JJ9Dk|FNA0%U=KGxy1l}{Y+_>XIuVZ(VI z{otaB9hCLtKe}2!2l9IV?3Qr(nFame3nydFIP&|g10Db8n=S4A5PvK5BjyR6KYsnt zB^Zg4m$JT8QNAan>v2hv2v>S9I&gl@A;Vng6$p5Fn-g@N}%D$-! zhwA7Q=76?R+~=$Be)*n%@+i9U=|*~ASC-EyJ7G%LiD6859ib8NCd^4m#GCQXD^Q+hwti(-`4P4KiN@0YMO$otTWtSZZJsupPjRby=!OFLgZfI>lYk<53`J>a*C!Oo dV9YJpaf7TUbu)A5fv|h7OMfR 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 EKbET!0{{R3
Name