Add statistics page

This commit is contained in:
2024-05-18 18:48:59 +02:00
parent c91eca0239
commit d6db0209f4
14 changed files with 262 additions and 75 deletions
+47 -12
View File
@@ -1,6 +1,6 @@
import { Manufacturer } from '@zenstackhq/runtime/models';
import { Col, Container, Image, Row } from 'react-bootstrap';
import { Col, Container, Image, Row, Table } from 'react-bootstrap';
import { ContainerWithSection, DrinkComposite, isDrinkWithContainersAndSection, isDrinkWithManufacturer, isDrinkWithStyle } from '~/models/types';
interface Arguments {
@@ -30,39 +30,74 @@ function DrinkPage(arg:Arguments) {
return (
<Container>
<div className="p-5 mb-4 bg-body-tertiary rounded-3" style={{ borderColor: `${borderColor}`, borderWidth: '2px' }} >
<div className="p-5 mb-4 bg-body-tertiary rounded-3 border" style={{ borderColor: `${borderColor}`, borderWidth: '2px' }} >
<Container fluid>
<Row>
<Col md={11}>
<h1 className="display-5 fw-bold">{arg.drink.name}</h1>
ABV: {arg.drink.abv}% {arg.drink.gluten ? "" : " | Gluten-free"} {arg.drink.organic ? " | Organic" : ""}
<p className="col-md-8 fs-4">{arg.drink.description}</p>
</Col>
<Col md={1}>
<Image src={arg.drink.image ?? undefined} fluid rounded/>
</Col>
</Row>
<Row>
<Col>
</Col>
<Col>
</Col>
<Col>
</Col>
</Row>
</Container>
</div>
<Row className="align-items-md-stretch">
<Col md={6}>
<Col md={6} className='mb-4'>
<div className="h-100 p-5 text-bg-dark rounded-3">
<h2>Inventory</h2>
{totalinventory == 0 ? (
"No inventory"
) : ""}
{containers.map((container) => (
<p>{container.section.name} | {container.type} | {container.inventory}</p>
))}
) : (
<Table data-bs-theme="dark">
<thead>
<tr>
<th>Section</th>
<th>Container</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
{containers.map((container) => (
<tr key={container.barcode}>
<td>{container.section.name}</td>
<td>{container.type}</td>
<td>{container.inventory}</td>
</tr>
))}
</tbody>
</Table>
)}
</div>
</Col>
{ manufacturer ? (
<Col md={6}>
<div className="h-100 p-5 bg-body-tertiary border rounded-3">
<h2>{manufacturer.name}</h2>
<p>{manufacturer.description}</p>
</div>
<Col md={6} className='mb-4'>
<Container>
<Row className="p-5 bg-body-tertiary border rounded-3">
<Col md={10}>
<h2>{manufacturer.name} ({manufacturer.country_id})</h2>
<p>{manufacturer.description}</p>
</Col>
<Col md={2}>
<Image src={manufacturer.image ?? undefined} fluid rounded/>
</Col>
</Row>
</Container>
</Col>
) : ""}
</Row>
+2 -1
View File
@@ -57,6 +57,7 @@ function DrinkFilter(arg : Arguments) {
<Form.Label>Sort</Form.Label>
{ [
["name", "Name"],
["popular", "Popularity"],
["abv-asc", "ABV Low -> High"],
["abv-desc", "ABV High -> Low"],
["inv-asc", "Inventoy Low -> High"],
@@ -89,7 +90,7 @@ function DrinkFilter(arg : Arguments) {
</Form.Group>
<Form.Group controlId="drink-modifiers" className='mb-2'>
<Form.Label>Drink</Form.Label>
{ ["Gluten-free", "Lactose-free", "Organic"].map(key => (
{ ["Gluten-free", "Organic"].map(key => (
<Form.Check
id={"drinkmod-" + key}
type="checkbox"
+8 -7
View File
@@ -29,14 +29,15 @@ function Header(data: Arguments) {
</LinkContainer>
<Navbar.Toggle />
<Navbar.Collapse>
{ loggedin ? (
<Nav>
<LinkContainer to="/inventory">
<Nav.Link>Inventory</Nav.Link>
</LinkContainer>
</Nav>
) : "" }
<Nav className="me-auto">
{ loggedin ? (
<LinkContainer to="/inventory">
<Nav.Link>Inventory</Nav.Link>
</LinkContainer>
) : "" }
<LinkContainer to="/inventory/stats">
<Nav.Link>Stats</Nav.Link>
</LinkContainer>
{ showScanButtons ? (
<LinkContainer to="/scan">
<Nav.Link>Scan</Nav.Link>
+4 -4
View File
@@ -52,7 +52,7 @@ export async function findDrinksFromSearch(searchParams: URLSearchParams) : Prom
}
result = result.concat(await dbe.beer.findMany({
include: {style: true, manufacturer: true, containers: {include: {section: true}}},
include: {style: true, manufacturer: true, containers: {include: {section: true, checkouts: true}}},
where: where
}));
}
@@ -67,7 +67,7 @@ export async function findDrinksFromSearch(searchParams: URLSearchParams) : Prom
}
result = result.concat(await dbe.wine.findMany({
include: {style: true, manufacturer: true, containers: {include: {section: true}}},
include: {style: true, manufacturer: true, containers: {include: {section: true, checkouts: true}}},
where: where
}));
}
@@ -82,7 +82,7 @@ export async function findDrinksFromSearch(searchParams: URLSearchParams) : Prom
}
result = result.concat(await dbe.soda.findMany({
include: {manufacturer: true, containers: {include: {section: true}}},
include: {manufacturer: true, containers: {include: {section: true, checkouts: true}}},
where: where
}));
}
@@ -96,7 +96,7 @@ export async function findDrinksFromSearch(searchParams: URLSearchParams) : Prom
}
result = result.concat(await dbe.cocktail.findMany({
include: {manufacturer: true, containers: {include: {section: true}}},
include: {manufacturer: true, containers: {include: {section: true, checkouts: true}}},
where: where
}));
}
+5 -1
View File
@@ -18,11 +18,15 @@ export async function findManufacturersFromSearch(searchParams: URLSearchParams)
if(searchParams.has("drink")){
result = result.concat(await dbe.manufacturer.findMany({
orderBy: {name: "asc"},
where: {drinks: { some: { type: { in: searchParams.getAll("drink").map((str) => {return DrinkType[str as keyof typeof DrinkType]})}}}}
}));
}
else {
result = result.concat(await dbe.manufacturer.findMany());
result = result.concat(await dbe.manufacturer.findMany({
orderBy: {name: "asc"},
where: {NOT: {drinks: {none: {}}}}
}));
}
return result;
+23 -1
View File
@@ -1,4 +1,4 @@
import { DrinkComposite, isDrinkWithContainers } from "./types";
import { DrinkComposite, isDrinkWithContainers, isDrinkWithContainersAndHistory } from "./types";
function sortName(n1:DrinkComposite, n2:DrinkComposite) : number{
if (n1.name > n2.name) {
@@ -39,6 +39,24 @@ function sortInventoryHighLow(n1:DrinkComposite, n2:DrinkComposite) : number{
return totalInventory(n2) - totalInventory(n1);
}
function totalPopularity(n:DrinkComposite) : number{
if(isDrinkWithContainersAndHistory(n)){
let total = 0;
n.containers.map((container) =>{
total += container.checkouts.length;
});
return total;
}
return 0;
}
function sortPopularity(n1:DrinkComposite, n2:DrinkComposite) : number{
return totalPopularity(n2) - totalPopularity(n1);
}
export function sortDrinksFromSearch(searchParams: URLSearchParams, searchResult: DrinkComposite[]) : DrinkComposite[] {
var callback = sortName;
@@ -63,6 +81,10 @@ export function sortDrinksFromSearch(searchParams: URLSearchParams, searchResult
callback = sortInventoryHighLow;
}
if(searchParams.has("sort", "popular")){
callback = sortPopularity;
}
var sortedArray: DrinkComposite[] = searchResult.sort(callback);
return sortedArray;
+15 -2
View File
@@ -1,9 +1,14 @@
import { ContainerType } from '@prisma/client';
import { Drink, Beer, BeerStyle, Wine, WineStyle, Soda, Manufacturer, Container, Cocktail, Section } from '@zenstackhq/runtime/models';
import { Drink, Beer, BeerStyle, Wine, WineStyle, Soda, Manufacturer, Container, Cocktail, Section, History } from '@zenstackhq/runtime/models';
export type ContainerWithDrink = Container & { drink: Drink}
export type ContainerWithSection = Container & { section: Section}
export type ContainerWithHistory = Container & { checkouts: History[]}
export type ContainerWithSectionAndHistory = ContainerWithSection & ContainerWithHistory
export type DrinkWithContainersAndSection = (Drink & { containers: ContainerWithSection[]})
export type DrinkWithContainers = (Drink & { containers: ContainerWithSection[]}) | (Drink & { containers: Container[]})
export type DrinkWithContainersAndHistory = (Drink & { containers: ContainerWithHistory[]})
export type DrinkWithContainersAndSectionAndHistory = (Drink & { containers: ContainerWithSectionAndHistory[]})
export type DrinkWithContainers = DrinkWithContainersAndSection | DrinkWithContainersAndHistory | DrinkWithContainersAndSectionAndHistory | (Drink & { containers: Container[]})
export type DrinkWithManufacturer = Drink & { manufacturer: Manufacturer}
export type BeerWithStyle = Beer & { style : BeerStyle }
@@ -19,6 +24,14 @@ export function isDrinkWithContainersAndSection(drink:DrinkComposite) : drink is
return ((drink as { containers: ContainerWithSection[]}).containers != undefined);
}
export function isDrinkWithContainersAndHistory(drink:DrinkComposite) : drink is DrinkWithContainersAndHistory{
return ((drink as { containers: ContainerWithHistory[]}).containers != undefined);
}
export function isDrinkWithContainersAndSectionAndHistory(drink:DrinkComposite) : drink is DrinkWithContainersAndSectionAndHistory{
return ((drink as { containers: ContainerWithSectionAndHistory[]}).containers != undefined);
}
export function isDrinkWithManufacturer(drink:DrinkComposite) : drink is DrinkWithManufacturer{
return (drink as { manufacturer: Manufacturer}).manufacturer != undefined;
}
+10 -6
View File
@@ -15,8 +15,8 @@ export const loader = async ({request} : LoaderFunctionArgs) => {
const url = new URL(request.url);
const beerStyles = await dbe.beerStyle.findMany();
const wineStyles = await dbe.wineStyle.findMany();
const beerStyles = await dbe.beerStyle.findMany({orderBy: {name: "asc"}});
const wineStyles = await dbe.wineStyle.findMany({orderBy: {name: "asc"}});
const manufacturers = await findManufacturersFromSearch(url.searchParams);
const drinkResultsUnsorted = await findDrinksFromSearch(url.searchParams);
@@ -45,13 +45,17 @@ export default function BeersRoute() {
);
}
const numResults = loadData.drinkResults.length;
return (
<Container>
<Row className="mt-3">
<Col xs={12} md={4} xl={3} className="mb-3">
<Col key="filtering" xs={12} md={4} xl={3} className="mb-3">
<Accordion>
<CustomToggle eventKey="0">Sorting and Filtering</CustomToggle>
<div>
<CustomToggle eventKey="0">Sorting and Filtering</CustomToggle> <span className="float-end">{numResults} Results</span>
</div>
<Accordion.Collapse eventKey="0">
<div className="h-100 p-3 mt-3 text-bg-dark rounded-3">
<DrinkFilter
@@ -64,7 +68,7 @@ export default function BeersRoute() {
</Accordion.Collapse>
</Accordion>
</Col>
<Col>
<Col key="results">
<Row xs={2} md={3} lg={4} className="g-4">
{loadData.drinkResults.map((drink) => (
<Col >
+114
View File
@@ -0,0 +1,114 @@
import { LoaderFunctionArgs, json } from "@remix-run/node";
import { useLoaderData, useSearchParams } from "@remix-run/react";
import { enhance } from "@zenstackhq/runtime";
import { includes } from "lodash";
import { Accordion, Button, Col, Container, Row, useAccordionButton } from "react-bootstrap";
import DrinkCard from "~/components/cards/drink.card";
import DrinkFilter from "~/components/filters/drink.filter";
import { findDrinksFromSearch } from "~/models/drinks.filter.server";
import { findManufacturersFromSearch } from "~/models/drinks.server";
import { sortDrinksFromSearch } from "~/models/drinks.sort.server";
import { ContainerWithDrink } from "~/models/types";
import { db } from "~/utils/db.server";
function statsForContainers(containers : ContainerWithDrink[]){
var drinksInventory = 0;
var beersInventory = 0;
var wineInventory = 0;
var sodaInventory = 0;
var cocktailInventory = 0;
var totalMilliliterAlcohol = 0.0;
var averageAlcoholPercentage = 0.0;
var divider = 0;
containers.forEach((container) => {
drinksInventory += container.inventory;
switch(container.drink.type){
case "Beer":
beersInventory += container.inventory;
break;
case "Wine":
wineInventory += container.inventory;
break;
case "Soda":
sodaInventory += container.inventory;
break;
case "Cocktail":
cocktailInventory += container.inventory;
break;
}
totalMilliliterAlcohol += (container.inventory * (container.volume * (container.drink.abv / 100)));
if(container.drink.abv > 0){
averageAlcoholPercentage += container.drink.abv;
divider += 1;
}
});
const totalLiterAlcohol = totalMilliliterAlcohol / 1000;
averageAlcoholPercentage /= divider;
return {drinksInventory, beersInventory, wineInventory, sodaInventory, cocktailInventory, totalLiterAlcohol, averageAlcoholPercentage};
}
export const loader = async ({request} : LoaderFunctionArgs) => {
const dbe = enhance(db);
// Drink statistics
const containers = await dbe.container.findMany({where: {inventory: {gt: 0}}, include: {drink: true}});
const total = statsForContainers(containers);
// Checkout statistics
let lastDayDate = Date.now() - (24 * 60 * 60 * 1000);
let lastDay = new Date(lastDayDate).toISOString();
const historyLast24H = await dbe.history.findMany({where: {checkoutAt: {gte: lastDay}}, include: {container: {include: {drink: true}}}});
let containersLast24 : ContainerWithDrink[] = [];
historyLast24H.forEach((entry) => {
entry.container.inventory = 1;
containersLast24.push(entry.container);
});
const statsLast24H = statsForContainers(containersLast24);
return json({total, statsLast24H});
};
export default function StatsRoute() {
const loadData = useLoaderData<typeof loader>();
return (
<Container>
<Row className="mt-3">
<Col key="stats-total" className="mb-3">
<h2>Total inventory</h2>
Total drinks in inventory: {loadData.total.drinksInventory} <br />
Total beers in inventory: {loadData.total.beersInventory} <br />
Total wines in inventory: {loadData.total.wineInventory} <br />
Total sodas in inventory: {loadData.total.sodaInventory} <br />
Total cocktails in inventory: {loadData.total.cocktailInventory} <br /><br />
Total alcohol in inventory: {loadData.total.totalLiterAlcohol} Liter <br />
Average alcohol percentage: {loadData.total.averageAlcoholPercentage}% <br />
</Col>
<Col key="stats-24h" className="mb-3">
<h2>Last 24H checkouts</h2>
Drinks checked out: {loadData.statsLast24H.drinksInventory} <br />
Beer checked out: {loadData.statsLast24H.beersInventory} <br />
Wines checked out: {loadData.statsLast24H.wineInventory} <br />
Sodas checked out: {loadData.statsLast24H.sodaInventory} <br />
Cocktails checked out: {loadData.statsLast24H.cocktailInventory} <br /><br />
Alcohol checked out: {loadData.statsLast24H.totalLiterAlcohol} Liter <br />
Average alcohol percentage: {loadData.statsLast24H.averageAlcoholPercentage}% <br />
</Col>
</Row>
</Container>
);
}
+6 -4
View File
@@ -16,7 +16,7 @@ export async function loader({
return redirect("/");
}
const history = await dbe.history.findMany({select: {checkoutAt: true ,container: {select: {drink: true}}}, take: 10, orderBy: {checkoutAt: "desc"}});
const history = await dbe.history.findMany({select: {checkoutAt: true, inventoryAfter: true, container: {select: {drink: true}}}, take: 10, orderBy: {checkoutAt: "desc"}});
return json({history});
}
@@ -42,13 +42,13 @@ export async function action({
return json({error: "No inventory!"});
}
// Log entry
await dbe.history.create({data: {container: {connect: {id: container.id}}}});
// Update inventory
const newInventory = Math.max(container.inventory - 1, 0);
await dbe.container.update({data: {inventory: newInventory}, where: {id: container.id}});
// Log entry
await dbe.history.create({data: {container: {connect: {id: container.id}}, inventoryAfter: newInventory}});
return json({error: ""});
};
@@ -88,6 +88,7 @@ export default function ScanRoute() {
<tr>
<th>Beer</th>
<th>When</th>
<th>Amount left</th>
</tr>
</thead>
<tbody>
@@ -95,6 +96,7 @@ export default function ScanRoute() {
<tr key={entry.container.drink.slug}>
<td>{entry.container.drink.name}</td>
<td>{timeAgo(new Date(entry.checkoutAt))}</td>
<td>{entry.inventoryAfter}</td>
</tr>
))}
</tbody>
+6 -5
View File
@@ -126,7 +126,7 @@ model Cocktail {
/// @@allow('read', true)
/// @@allow('update', auth() != null)
/// @@allow('create,delete', auth().type == Admin)
/// @@allow('all', auth().type == Admin)
model Container {
id Int @id() @default(autoincrement())
barcode String? @unique()
@@ -174,10 +174,11 @@ model Country {
/// @@allow('create', auth().type == Scanner)
/// @@allow('all', auth().type == Admin)
model History {
id Int @id() @default(autoincrement())
container_id Int
container Container @relation(fields: [container_id], references: [id])
checkoutAt DateTime @default(now())
id Int @id() @default(autoincrement())
container_id Int
container Container @relation(fields: [container_id], references: [id])
checkoutAt DateTime @default(now())
inventoryAfter Int
}
/// @@allow('all', auth() == this)
+18 -32
View File
@@ -6,8 +6,8 @@ const pr = new PrismaClient();
async function seed() {
await pr.user.upsert({ create: {username: "kenneth", password: "asdf1239", type: UserType.Admin}, update: {}, where: {username: "kenneth"} });
await pr.user.upsert({ create: {username: "scanner", password: "asdf1239", type: UserType.Scanner}, update: {}, where: {username: "scanner"} });
await pr.user.upsert({ create: {username: "kenneth", password: "asdf1239", type: UserType.Admin}, update: {password: "asdf1239"}, where: {username: "kenneth"} });
await pr.user.upsert({ create: {username: "scanner", password: "asdf1239", type: UserType.Scanner}, update: {password: "asdf1239"}, where: {username: "scanner"} });
const user = await pr.user.findUnique({where: {username: "kenneth"}}) || undefined;
// Delete all sessions
@@ -518,52 +518,52 @@ function getBeerStyles() : BeerStyle[] {
{
id: 10,
name: "Tarwe",
color: "NA" //nog opzoeken
color: "4a1615"
},
{
id: 11,
name: "Radler",
color: "NA" //nog opzoeken
color: "fde8a1"
},
{
id: 12,
name: "Pilsner",
color: "NA" //nog opzoeken
color: "fad96f"
},
{
id: 13,
name: "Fruit",
color: "NA" //nog opzoeken
color: "d81946"
},
{
id: 14,
name: "Cider",
color: "NA" //nog opzoeken
color: "e7c899"
},
{
id: 15,
name: "Wit",
color: "NA" //nog opzoeken
color: "f8c059"
},
{
id: 16,
name: "IPA",
color: "NA" //nog opzoeken
color: "d36429"
},
{
id: 17,
name: "Bock",
color: "NA" //nog opzoeken
color: "983b22"
},
{
id: 18,
name: "Dark Ale",
color: "NA" //nog opzoeken
color: "4a1615"
},
{
id: 19,
name: "Ale",
color: "NA" //nog opzoeken
color: "f5ad3a"
}
];
}
@@ -577,7 +577,7 @@ function getBeers() : BeerQuery[] {
ibu: 0,
manufacturer: {connect: { id: 16}},
style: {connect: { id: 1}},
style: {connect: { id: 12}},
description: "Kordaat Premium Pilsener heeft een toegankelijke, frisse smaak met een aangename bittere afdronk.",
image: "/beers/kordaat_0.jpeg",
@@ -1240,7 +1240,7 @@ function getBeers() : BeerQuery[] {
ibu: 22,
manufacturer: {connect: { id: 2}},
style: {connect: { id: 1}},
style: {connect: { id: 12}},
description: "Well balanced in taste, refreshing with a light bitterness. A 0.0 for every beerlover with the same characteristics as our Hertog Jan Pilsener.",
image: "/beers/hertog_jan_0.jpeg",
@@ -1698,20 +1698,6 @@ function getSodas() : SodaQuery[] {
function getCocktails() : CocktailQuery[] {
return [
{
name: "Some coktail mix",
slug: "cocktail_mix",
image: null,
description: "Lekker lekker cocktail",
abv: 3,
manufacturer: {connect: {id: 15}},
mix: true,
gluten: true,
lactose: false,
organic: false
},
{
name: "Prosecco Rosato",
slug: "prosecco_rosato",
@@ -1720,7 +1706,7 @@ function getCocktails() : CocktailQuery[] {
abv: 6.9,
manufacturer: {connect: {id: 16}},
mix: false,
mix: true,
gluten: false,
lactose: false,
@@ -1734,7 +1720,7 @@ function getCocktails() : CocktailQuery[] {
abv: 6.9,
manufacturer: {connect: {id: 16}},
mix: false,
mix: true,
gluten: false,
lactose: false,
@@ -1748,7 +1734,7 @@ function getCocktails() : CocktailQuery[] {
abv: 6.9,
manufacturer: {connect: {id: 16}},
mix: false,
mix: true,
gluten: false,
lactose: false,
@@ -1762,7 +1748,7 @@ function getCocktails() : CocktailQuery[] {
abv: 6.9,
manufacturer: {connect: {id: 16}},
mix: false,
mix: true,
gluten: false,
lactose: false,
+1
View File
@@ -188,6 +188,7 @@ model History {
container Container @relation(fields: [container_id], references: [id])
checkoutAt DateTime @default(now())
inventoryAfter Int
@@allow('read', true)
@@allow('create', auth().type == Scanner)
+3
View File
@@ -18,6 +18,9 @@ export default defineConfig({
route("beer/:beerslug", "routes/inventory/beer.$beerslug.tsx");
route("wine/:wineslug", "routes/inventory/wine.$wineslug.tsx");
route("soda/:sodaslug", "routes/inventory/soda.$sodaslug.tsx");
route("stats", "routes/inventory/stats/route.tsx");
});
// Scanner