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 8830cf6..a508aa9 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ
Name