Layout improvements, stats page extra data and checkout graph on drinks page

This commit is contained in:
2024-06-16 15:21:28 +02:00
parent b121abf102
commit 0f272211e8
26 changed files with 545 additions and 180 deletions
+52 -13
View File
@@ -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 (
<Container>
{glass ? (
@@ -92,7 +109,7 @@ function DrinkPage(arg:Arguments) {
</Row>
) : ""}
<div className="p-5 mb-4 bg-body-tertiary rounded-3 border" style={{ borderColor: `${borderColor}`, borderWidth: '2px' }} >
<div className="p-md-5 p-3 mb-4 bg-body-tertiary rounded-3 border" style={{ borderColor: `${borderColor}`, borderWidth: '2px' }} >
<Container fluid>
<Row>
<Col md={10}>
@@ -113,12 +130,17 @@ function DrinkPage(arg:Arguments) {
<Row className="align-items-md-stretch">
<Col md={6} className='mb-4'>
<div className="h-100 p-5 text-bg-dark rounded-3">
<div className="h-100 p-md-5 p-3 text-bg-dark rounded-3">
{arg.isAdmin ? (
<>
<LinkContainer to={{pathname:"/admin/new/container/", search:"?drink=" + arg.drink.id}}><Button variant='success' className='float-end'>Add</Button></LinkContainer>
</>
) : ""}
<h2>Inventory</h2>
{totalinventory == 0 && !arg.isAdmin ? (
"No inventory"
) : (
<Table data-bs-theme="dark">
<Table data-bs-theme="dark" responsive>
<thead>
<tr>
<th>Section</th>
@@ -169,7 +191,7 @@ function DrinkPage(arg:Arguments) {
</Col>
</Row>
) : ""}
<Row className="p-5 bg-body-tertiary border rounded-3">
<Row className="p-md-5 p-3 bg-body-tertiary border rounded-3">
<Col md={10}>
<h2>{manufacturer.name} ({manufacturer.country_id})</h2>
<p>{manufacturer.description}</p>
@@ -177,7 +199,7 @@ function DrinkPage(arg:Arguments) {
</Col>
{manufacturer.image ? (
<Col md={2}>
<Image src={manufacturer.image} fluid rounded/>
<Image src={manufacturer.image} className='mt-3' fluid rounded/>
</Col>
) : ""}
</Row>
@@ -185,20 +207,37 @@ function DrinkPage(arg:Arguments) {
</Col>
) : ""}
</Row>
<Row className="mt-3 mb-3">
<Col col={12}>
<h3>Checkouts</h3>
<ClientOnly fallback={<Fallback />}>
{() => <Chart options={chartOptions} data={arg.chartData} type={"bar"} />}
</ClientOnly>
</Col>
</Row>
{arg.recommendations.length > 0 ? (
<>
<h3>Maybe also try</h3>
<Row xs={2} md={3} lg={4} className="g-4">
{arg.recommendations.map((drink) => (
<Col >
<DrinkCard drink={drink}></DrinkCard>
</Col>
))}
</Row>
<Row className="mt-3 mb-3">
<Col col={12}>
<h3>Maybe also try</h3>
<Row xs={2} md={3} lg={4} className="g-4">
{arg.recommendations.map((drink) => (
<Col >
<DrinkCard drink={drink}></DrinkCard>
</Col>
))}
</Row>
</Col>
</Row>
</>
) : ""}
</Container>
);
}
function Fallback() {
return <div>Generating Chart</div>;
}
export default DrinkPage;
+2 -1
View File
@@ -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';
+2 -2
View File
@@ -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) {
<Navbar collapseOnSelect expand="md" className="bg-body-tertiary mb-3">
<Container>
<LinkContainer to="/">
<Navbar.Brand>K-FRIDGE</Navbar.Brand>
<Navbar.Brand><Image src="/favicon.ico" width="25" height="25" className="d-inline-block align-middle"></Image>FRIDGE</Navbar.Brand>
</LinkContainer>
<Navbar.Toggle />
<Navbar.Collapse>
+82
View File
@@ -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;
}
+14 -18
View File
@@ -52,35 +52,31 @@ export async function getMostCommonSection(containers : ContainerWithSection[])
export async function findRecommendations(sectionId: number, numRecommendations: number, notId: number) : Promise<DrinkComposite[]> {
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;
+9 -2
View File
@@ -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<typeof loader>();
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 (
<Container>
<Row>
@@ -95,12 +102,12 @@ export default function EditBeerRoute() {
<Form.Group className="mb-3">
<Form.Label>Slug</Form.Label>
<Form.Control type="text" name="slug" pattern="[a-z0-9_]+" defaultValue={loaderData.beer.slug} required/>
<Form.Control type="text" name="slug" placeholder="Auto generated" value={slug} plaintext readOnly/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Name</Form.Label>
<Form.Control type="text" name="name" defaultValue={loaderData.beer.name} required/>
<Form.Control type="text" name="name" defaultValue={loaderData.beer.name} required onChange={(e) => {setSlug(calcSlug(e.target.value))}}/>
</Form.Group>
<Form.Group className="mb-3">
+9 -2
View File
@@ -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<typeof loader>();
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 (
<Container>
<Row>
@@ -89,12 +96,12 @@ export default function EditCocktailRoute() {
<Form.Group className="mb-3">
<Form.Label>Slug</Form.Label>
<Form.Control type="text" name="slug" pattern="[a-z0-9_]+" defaultValue={loaderData.cocktail.slug} required/>
<Form.Control type="text" name="slug" placeholder="Auto generated" value={slug} plaintext readOnly/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Name</Form.Label>
<Form.Control type="text" name="name" defaultValue={loaderData.cocktail.name} required/>
<Form.Control type="text" name="name" defaultValue={loaderData.cocktail.name} required onChange={(e) => {setSlug(calcSlug(e.target.value))}}/>
</Form.Group>
<Form.Group className="mb-3">
+9 -2
View File
@@ -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<typeof loader>();
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 (
<Container>
<Row>
@@ -89,12 +96,12 @@ export default function EditSodaRoute() {
<Form.Group className="mb-3">
<Form.Label>Slug</Form.Label>
<Form.Control type="text" name="slug" pattern="[a-z0-9_]+" defaultValue={loaderData.soda.slug} required/>
<Form.Control type="text" name="slug" placeholder="Auto generated" value={slug} plaintext readOnly/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Name</Form.Label>
<Form.Control type="text" name="name" defaultValue={loaderData.soda.name} required/>
<Form.Control type="text" name="name" defaultValue={loaderData.soda.name} required onChange={(e) => {setSlug(calcSlug(e.target.value))}}/>
</Form.Group>
<Form.Group className="mb-3">
+10 -2
View File
@@ -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<typeof loader>();
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 (
<Container>
<Row>
@@ -107,12 +115,12 @@ export default function EditWineRoute() {
<Form.Group className="mb-3">
<Form.Label>Slug</Form.Label>
<Form.Control type="text" name="slug" pattern="[a-z0-9_]+" defaultValue={loaderData.wine.slug} required/>
<Form.Control type="text" name="slug" placeholder="Auto generated" value={slug} plaintext readOnly/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Name</Form.Label>
<Form.Control type="text" name="name" defaultValue={loaderData.wine.name} required/>
<Form.Control type="text" name="name" defaultValue={loaderData.wine.name} required onChange={(e) => {setSlug(calcSlug(e.target.value))}}/>
</Form.Group>
<Form.Group className="mb-3">
+1 -1
View File
@@ -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 });
};
+9 -2
View File
@@ -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<typeof loader>();
var [slug, setSlug] = useState("");
var calcSlug = function(name:string) : string{
return name.toLowerCase().trim().replaceAll(" ", "_").replaceAll(/[^a-zA-Z0-9_]/g, "");
}
return (
<Container>
<Row>
@@ -102,12 +109,12 @@ export default function NewBeerRoute() {
<Form.Group className="mb-3">
<Form.Label>Slug</Form.Label>
<Form.Control type="text" placeholder="blond_beer" name="slug" pattern="[a-z0-9_]+" required/>
<Form.Control type="text" placeholder="Auto generated" value={slug} name="slug" plaintext readOnly/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Name</Form.Label>
<Form.Control type="text" placeholder="Blond" name="name" required/>
<Form.Control type="text" placeholder="Blond" name="name" required onChange={(e) => {setSlug(calcSlug(e.target.value))}}/>
</Form.Group>
<Form.Group className="mb-3">
+8 -2
View File
@@ -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<typeof loader>();
var [slug, setSlug] = useState("");
var calcSlug = function(name:string) : string{
return name.toLowerCase().trim().replaceAll(" ", "_").replaceAll(/[^a-zA-Z0-9_]/g, "");
}
return (
<Container>
@@ -96,12 +102,12 @@ export default function NewCocktailRoute() {
<Form.Group className="mb-3">
<Form.Label>Slug</Form.Label>
<Form.Control type="text" placeholder="some_cocktail_drink" name="slug" pattern="[a-z0-9_]+" required/>
<Form.Control type="text" placeholder="Auto generated" value={slug} name="slug" plaintext readOnly/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Name</Form.Label>
<Form.Control type="text" placeholder="Mojito" name="name" required/>
<Form.Control type="text" placeholder="Mojito" name="name" required onChange={(e) => {setSlug(calcSlug(e.target.value))}}/>
</Form.Group>
<Form.Group className="mb-3">
+3 -2
View File
@@ -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<typeof loader>();
var [searchParams] = useSearchParams();
return (
<Container>
@@ -63,7 +64,7 @@ export default function NewContainerRoute() {
<Form.Label>Drink</Form.Label>
<Form.Select name="drink" required>
{loaderData.drinks.map((drink) => (
<option value={drink.id}>
<option value={drink.id} selected={("" + drink.id)==searchParams.get("drink") }>
{drink.name}
</option>
))}
+9 -2
View File
@@ -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<typeof loader>();
var [slug, setSlug] = useState("");
var calcSlug = function(name:string) : string{
return name.toLowerCase().trim().replaceAll(" ", "_").replaceAll(/[^a-zA-Z0-9_]/g, "");
}
return (
<Container>
@@ -96,12 +103,12 @@ export default function NewSodaRoute() {
<Form.Group className="mb-3">
<Form.Label>Slug</Form.Label>
<Form.Control type="text" placeholder="some_soda_drink" name="slug" pattern="[a-z0-9_]+" required/>
<Form.Control type="text" placeholder="Auto generated" value={slug} name="slug" plaintext readOnly/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Name</Form.Label>
<Form.Control type="text" placeholder="Fanta" name="name" required/>
<Form.Control type="text" placeholder="Fanta" name="name" required onChange={(e) => {setSlug(calcSlug(e.target.value))}}/>
</Form.Group>
<Form.Group className="mb-3">
+9 -2
View File
@@ -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<typeof loader>();
var [slug, setSlug] = useState("");
var calcSlug = function(name:string) : string{
return name.toLowerCase().trim().replaceAll(" ", "_").replaceAll(/[^a-zA-Z0-9_]/g, "");
}
return (
<Container>
<Row>
@@ -113,12 +120,12 @@ export default function NewWineRoute() {
<Form.Group className="mb-3">
<Form.Label>Slug</Form.Label>
<Form.Control type="text" placeholder="sauvignon_blanc" name="slug" pattern="[a-z0-9_]+" required/>
<Form.Control type="text" placeholder="Auto generated" value={slug} name="slug" plaintext readOnly/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Name</Form.Label>
<Form.Control type="text" placeholder="White delicious" name="name" required/>
<Form.Control type="text" placeholder="White delicious" name="name" required onChange={(e) => {setSlug(calcSlug(e.target.value))}}/>
</Form.Group>
<Form.Group className="mb-3">
+5 -2
View File
@@ -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<typeof loader>();
return (
<DrinkPage drink={data.beer} recommendations={data.recommendations} isAdmin={data.isAdmin}></DrinkPage>
<DrinkPage drink={data.beer} recommendations={data.recommendations} chartData={data.chartData} isAdmin={data.isAdmin}></DrinkPage>
);
}
@@ -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<typeof loader>();
return (
<DrinkPage drink={data.cocktail} recommendations={data.recommendations} isAdmin={data.isAdmin}></DrinkPage>
<DrinkPage drink={data.cocktail} recommendations={data.recommendations} chartData={data.chartData} isAdmin={data.isAdmin}></DrinkPage>
);
}
+6 -1
View File
@@ -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() {
<Row className="mt-3">
<Col key="filtering" className="mb-3">
<div>
<Button variant="primary" onClick={handleShow}>Sorting and Filtering</Button> <span className="float-end">{numResults} Results</span>
<Button variant="primary" onClick={handleShow}>Filters</Button>
<LinkContainer to="/inventory">
<Button variant="warning" className="mx-3">Clear</Button>
</LinkContainer>
<span className="float-end">{numResults} Results</span>
</div>
<Offcanvas show={show} onHide={handleClose}>
+5 -2
View File
@@ -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<typeof loader>();
return (
<DrinkPage drink={data.soda} recommendations={data.recommendations} isAdmin={data.isAdmin}></DrinkPage>
<DrinkPage drink={data.soda} recommendations={data.recommendations} chartData={data.chartData} isAdmin={data.isAdmin}></DrinkPage>
);
}
+187 -115
View File
@@ -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 (
<Container>
<Row className="mt-3">
<Col key="stats-total" className="mb-3" xs={12} sm={6}>
<h2>Total inventory</h2>
<Table className="pe-5">
<thead>
<tr>
<th>Item</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td># Drinks</td>
<td>{loadData.total.drinksInventory}</td>
</tr>
<tr>
<td># Beers</td>
<td>{loadData.total.beersInventory}</td>
</tr>
<tr>
<td># Wines</td>
<td>{loadData.total.wineInventory}</td>
</tr>
<tr>
<td># Sodas</td>
<td>{loadData.total.sodaInventory}</td>
</tr>
<tr>
<td># Cocktails</td>
<td>{loadData.total.cocktailInventory}</td>
</tr>
</tbody>
<thead>
<tr>
<th>Statistic</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pure alcohol</td>
<td>{loadData.total.totalLiterAlcohol} Liter</td>
</tr>
<tr>
<td>Price</td>
<td>{loadData.total.totalPrice}</td>
</tr>
<tr>
<td>Average alcohol percentage</td>
<td>{loadData.total.averageAlcoholPercentage}%</td>
</tr>
</tbody>
</Table>
</Col>
<Col key="stats-24h" className="mb-3">
<h2>Last 24H checkouts</h2>
<Table className="pe-5">
<thead>
<tr>
<th>Item</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td># Drinks</td>
<td>{loadData.statsLast24H.drinksInventory}</td>
</tr>
<tr>
<td># Beers</td>
<td>{loadData.statsLast24H.beersInventory}</td>
</tr>
<tr>
<td># Wines</td>
<td>{loadData.statsLast24H.wineInventory}</td>
</tr>
<tr>
<td># Sodas</td>
<td>{loadData.statsLast24H.sodaInventory}</td>
</tr>
<tr>
<td># Cocktails</td>
<td>{loadData.statsLast24H.cocktailInventory}</td>
</tr>
</tbody>
<thead>
<tr>
<th>Statistic</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pure alcohol</td>
<td>{loadData.statsLast24H.totalLiterAlcohol} Liter</td>
</tr>
<tr>
<td>Price</td>
<td>{loadData.statsLast24H.totalPrice}</td>
</tr>
<tr>
<td>Average alcohol percentage</td>
<td>{loadData.statsLast24H.averageAlcoholPercentage}%</td>
</tr>
</tbody>
</Table>
<Col key="stats" className="mb-3" col={12}>
<Tabs variant="pills">
<Tab eventKey="total" title="Total">
<h2 className="mt-3">Total inventory</h2>
<Table className="pe-5">
<thead>
<tr>
<th>Item</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td># Drinks</td>
<td>{loadData.total.drinksInventory}</td>
</tr>
<tr>
<td># Beers</td>
<td>{loadData.total.beersInventory}</td>
</tr>
<tr>
<td># Wines</td>
<td>{loadData.total.wineInventory}</td>
</tr>
<tr>
<td># Sodas</td>
<td>{loadData.total.sodaInventory}</td>
</tr>
<tr>
<td># Cocktails</td>
<td>{loadData.total.cocktailInventory}</td>
</tr>
</tbody>
<thead>
<tr>
<th>Statistic</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pure alcohol</td>
<td>{loadData.total.totalLiterAlcohol} Liter</td>
</tr>
<tr>
<td>Price</td>
<td>{loadData.total.totalPrice}</td>
</tr>
<tr>
<td>Average alcohol percentage</td>
<td>{loadData.total.averageAlcoholPercentage}%</td>
</tr>
</tbody>
</Table>
</Tab>
<Tab eventKey="month" title="Last Month">
<h2 className="mt-3">Last Month checkouts</h2>
<Table className="pe-5">
<thead>
<tr>
<th>Item</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td># Drinks</td>
<td>{loadData.statsLastMonth.drinksInventory}</td>
</tr>
<tr>
<td># Beers</td>
<td>{loadData.statsLastMonth.beersInventory}</td>
</tr>
<tr>
<td># Wines</td>
<td>{loadData.statsLastMonth.wineInventory}</td>
</tr>
<tr>
<td># Sodas</td>
<td>{loadData.statsLastMonth.sodaInventory}</td>
</tr>
<tr>
<td># Cocktails</td>
<td>{loadData.statsLastMonth.cocktailInventory}</td>
</tr>
</tbody>
<thead>
<tr>
<th>Statistic</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pure alcohol</td>
<td>{loadData.statsLastMonth.totalLiterAlcohol} Liter</td>
</tr>
<tr>
<td>Price</td>
<td>{loadData.statsLastMonth.totalPrice}</td>
</tr>
<tr>
<td>Average alcohol percentage</td>
<td>{loadData.statsLastMonth.averageAlcoholPercentage}%</td>
</tr>
</tbody>
</Table>
</Tab>
<Tab eventKey="24h" title="Last 24H">
<h2 className="mt-3">Last 24H checkouts</h2>
<Table className="pe-5">
<thead>
<tr>
<th>Item</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td># Drinks</td>
<td>{loadData.statsLast24H.drinksInventory}</td>
</tr>
<tr>
<td># Beers</td>
<td>{loadData.statsLast24H.beersInventory}</td>
</tr>
<tr>
<td># Wines</td>
<td>{loadData.statsLast24H.wineInventory}</td>
</tr>
<tr>
<td># Sodas</td>
<td>{loadData.statsLast24H.sodaInventory}</td>
</tr>
<tr>
<td># Cocktails</td>
<td>{loadData.statsLast24H.cocktailInventory}</td>
</tr>
</tbody>
<thead>
<tr>
<th>Statistic</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pure alcohol</td>
<td>{loadData.statsLast24H.totalLiterAlcohol} Liter</td>
</tr>
<tr>
<td>Price</td>
<td>{loadData.statsLast24H.totalPrice}</td>
</tr>
<tr>
<td>Average alcohol percentage</td>
<td>{loadData.statsLast24H.averageAlcoholPercentage}%</td>
</tr>
</tbody>
</Table>
</Tab>
</Tabs>
</Col>
</Row>
<Row className="mt-3">
<Col xs={12}>
<h3>History</h3>
<Table striped>
<Table striped responsive>
<thead>
<tr>
<th>Drink</th>
@@ -211,7 +285,7 @@ export default function StatsRoute() {
<tbody>
{loadData.history.map((entry) => (
<tr key={entry.container.drink.slug}>
<td>{entry.container.drink.name}</td>
<td>{entry.container.drink.name} <Badge bg="secondary">{volume(entry.container.volume)} {containerTypeToString(entry.container.type)}</Badge> </td>
<td><LinkContainer to={"/inventory/" + entry.container.drink.type.toLowerCase() + "/" + entry.container.drink.slug}><Button variant="primary">View</Button></LinkContainer></td>
<td>{timeAgo(new Date(entry.checkoutAt))}</td>
<td>{entry.inventoryAfter}</td>
@@ -223,6 +297,4 @@ export default function StatsRoute() {
</Row>
</Container>
);
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ export default function SuggestionsRoute() {
{loadData.suggestions.length > 0 ? (
<Col xs={12}>
<h3>Suggestions</h3>
<Table striped>
<Table striped responsive>
<thead>
<tr>
<th>Name</th>
+5 -2
View File
@@ -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<typeof loader>();
return (
<DrinkPage drink={data.wine} recommendations={data.recommendations} isAdmin={data.isAdmin}></DrinkPage>
<DrinkPage drink={data.wine} recommendations={data.recommendations} chartData={data.chartData} isAdmin={data.isAdmin}></DrinkPage>
);
}
+6 -2
View File
@@ -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: ""});
};
+95
View File
@@ -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",
+2
View File
@@ -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": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB