Add sorting and scanner checkout

This commit is contained in:
2024-05-11 15:40:17 +02:00
parent c5e8f6e2e5
commit 3b19804537
17 changed files with 463 additions and 215 deletions
+8 -11
View File
@@ -1,12 +1,12 @@
# Welcome to Remix + Vite!
📖 See the [Remix docs](https://remix.run/docs) and the [Remix Vite docs](https://remix.run/docs/en/main/guides/vite) for details on supported features.
# Beer inventory
## Development
Run the Vite dev server:
```shellscript
npm zenstack generate
npm prisma db push
npm run dev
```
@@ -24,13 +24,10 @@ Then run the app in production mode:
npm start
```
Now you'll need to pick a host to deploy it to.
## Todo
- Filtering
- Easy inventory updating for admin
- Mobile styling frontpage
- Undo checkout for admin
### DIY
If you're familiar with deploying Node applications, the built-in Remix app server is production-ready.
Make sure to deploy the output of `npm run build`
- `build/server`
- `build/client`
+2 -2
View File
@@ -24,10 +24,10 @@ function createDatabaseSessionStorage({
},
async updateData(id, data, expires) {
if(data && data.id){
await db.session.update({data:{data: JSON.stringify({ data }), user:{connect:{id: data.id}}}, where: {id: id}});
await db.session.upsert({create:{data: JSON.stringify({ data }), user:{connect:{id: data.id}}}, update:{data: JSON.stringify({ data }), user:{connect:{id: data.id}}}, where: {id: id}});
}
else {
await db.session.update({data:{data: JSON.stringify({ data })}, where: {id: id}});
await db.session.upsert({create:{data: JSON.stringify({ data })}, update:{data: JSON.stringify({ data })}, where: {id: id}});
}
},
async deleteData(id) {
+6 -2
View File
@@ -35,7 +35,12 @@ function DrinkCard(arg:Arguments) {
var inventory = "";
if(isDrinkWithContainers(arg.drink)){
inventory = "Inventory: " + arg.drink.containers.length;
let inventoryNum = 0;
arg.drink.containers.map((container) => {
inventoryNum += container.inventory;
})
inventory = "Inventory: " + inventoryNum;
}
return (
@@ -53,7 +58,6 @@ function DrinkCard(arg:Arguments) {
{ isDrinkWithStyle(arg.drink) ? (
<ListGroup.Item>Style: {arg.drink.style.name}</ListGroup.Item>
) : ""}
<ListGroup.Item>Allergy: {arg.drink.gluten ? "G" : ""} {arg.drink.lactose ? "L" : ""} {arg.drink.organic ? "B" : ""}</ListGroup.Item>
</ListGroup>
<Card.Footer className="text-muted">{inventory}</Card.Footer>
</Card>
+20 -27
View File
@@ -1,42 +1,31 @@
import { Link } from '@remix-run/react';
import { Card, Col, Container, Image, ListGroup, Row } from 'react-bootstrap';
import { LinkContainer } from 'react-router-bootstrap';
import { DrinkComposite, isDrinkWithContainers, isDrinkWithManufacturer, isDrinkWithStyle } from '~/models/types';
import { Manufacturer } from '@zenstackhq/runtime/models';
import { Col, Container, Image, Row } from 'react-bootstrap';
import { ContainerWithSection, DrinkComposite, isDrinkWithContainersAndSection, isDrinkWithManufacturer, isDrinkWithStyle } from '~/models/types';
interface Arguments {
drink: DrinkComposite
}
function DrinkPage(arg:Arguments) {
var trimmedDescription = arg.drink.description.length > 120 ? arg.drink.description.substring(0, 120) + "..." : arg.drink.description;
var link = "/drink/";
switch (arg.drink.type) {
case 'Beer':
link = "/beer/";
break;
case 'Wine':
link = "/wine/";
break;
case 'Soda':
link = "/soda/";
break;
}
var borderColor = "#bbb";
if(isDrinkWithStyle(arg.drink)){
borderColor = "#" + arg.drink.style.color;
}
var manufacturer = "";
var manufacturer: Manufacturer | undefined = undefined;
if(isDrinkWithManufacturer(arg.drink)){
manufacturer = arg.drink.manufacturer.name + " (" + arg.drink.manufacturer.country_id + ")";
manufacturer = arg.drink.manufacturer;
}
var inventory = "";
if(isDrinkWithContainers(arg.drink)){
inventory = "Inventory: " + arg.drink.containers.length;
var containers : ContainerWithSection[] = [];
var totalinventory = 0;
if(isDrinkWithContainersAndSection(arg.drink)){
containers = arg.drink.containers;
containers.map((container) => {
totalinventory += container.inventory;
})
}
return (
@@ -60,18 +49,22 @@ function DrinkPage(arg:Arguments) {
<Col md={6}>
<div className="h-100 p-5 text-bg-dark rounded-3">
<h2>Inventory</h2>
{arg.drink.containers.map((container) => (
{totalinventory == 0 ? (
"No inventory"
) : ""}
{containers.map((container) => (
<p>{container.section.name} | {container.type} | {container.inventory}</p>
))}
</div>
</Col>
{ manufacturer ? (
<Col md={6}>
<div className="h-100 p-5 bg-body-tertiary border rounded-3">
<h2>{arg.drink.manufacturer.name}</h2>
<p>{arg.drink.manufacturer.description}</p>
<h2>{manufacturer.name}</h2>
<p>{manufacturer.description}</p>
</div>
</Col>
) : ""}
</Row>
</Container>
);
+30 -8
View File
@@ -47,11 +47,33 @@ function DrinkFilter(arg : Arguments) {
</div>);
const sortKey = arg.searchParams.get("sort") || "name";
return (
<Form noValidate method="GET" ref={form} onChange={(event) => {submit(event.currentTarget); }}>
<Row>
<Col xs={6} md={12}>
<Form.Group controlId="drink-type">
<Form.Group controlId="sort" className='mb-2'>
<Form.Label>Sort</Form.Label>
{ [
["name", "Name"],
["abv-asc", "ABV Low -> High"],
["abv-desc", "ABV High -> Low"],
["inv-asc", "Inventoy Low -> High"],
["inv-desc", "Inventory High -> Low"]
].map((pair) => (
<Form.Check
id={"sort-" + pair[0]}
type="radio"
label={pair[1]}
name="sort"
value={pair[0]}
defaultChecked={sortKey == pair[0]}
/>
))}
</Form.Group>
<Form.Group controlId="drink-type" className='mb-2'>
<Form.Label>Drink type</Form.Label>
{ ["Beer", "Wine", "Soda", "Cocktail"].map(key => (
<Form.Check
@@ -65,7 +87,7 @@ function DrinkFilter(arg : Arguments) {
))}
</Form.Group>
<Form.Group controlId="drink-modifiers">
<Form.Group controlId="drink-modifiers" className='mb-2'>
<Form.Label>Drink</Form.Label>
{ ["Gluten-free", "Lactose-free", "Organic"].map(key => (
<Form.Check
@@ -80,7 +102,7 @@ function DrinkFilter(arg : Arguments) {
<Form.Label>ABV</Form.Label>
{slider()}
</Form.Group>
<Form.Group controlId="manufacturer">
<Form.Group controlId="manufacturer" className='mb-2'>
<Form.Label>Manufacturer</Form.Label>
{ arg.manufacturers.map(manufacturer => (
<Form.Check
@@ -96,7 +118,7 @@ function DrinkFilter(arg : Arguments) {
</Col>
<Col>
{ showBeerFilter ? (
<Form.Group controlId="beer-style">
<Form.Group controlId="beer-style" className='mb-2'>
<Form.Label>Beer style</Form.Label>
{ arg.beerStyles.map(style => (
<Form.Check
@@ -111,7 +133,7 @@ function DrinkFilter(arg : Arguments) {
</Form.Group>
) : ""}
{ showWineFilter ? (
<Form.Group controlId="wine-style">
<Form.Group controlId="wine-style" className='mb-2'>
<Form.Label>Wine style</Form.Label>
{ arg.wineStyles.map(style => (
<Form.Check
@@ -126,7 +148,7 @@ function DrinkFilter(arg : Arguments) {
</Form.Group>
) : ""}
{ showSodaFilter ? (
<Form.Group controlId="soda-carbonated">
<Form.Group controlId="soda-carbonated" className='mb-2'>
<Form.Label>Soda</Form.Label>
<Form.Check
type="checkbox"
@@ -138,7 +160,7 @@ function DrinkFilter(arg : Arguments) {
</Form.Group>
) : ""}
{ showCocktailFilter ? (
<Form.Group controlId="cocktail-mix">
<Form.Group controlId="cocktail-mix" className='mb-2'>
<Form.Label>Cocktail</Form.Label>
<Form.Check
type="checkbox"
@@ -149,7 +171,7 @@ function DrinkFilter(arg : Arguments) {
/>
</Form.Group>
) : ""}
<Form.Group controlId="cocktail-mix">
<Form.Group controlId="inventory" className='mb-2'>
<Form.Label>No Inventory</Form.Label>
<Form.Check
type="checkbox"
+16 -9
View File
@@ -2,6 +2,7 @@ import {LinkContainer} from 'react-router-bootstrap'
import {Container, Nav, Navbar, NavDropdown} from 'react-bootstrap';
import { User } from '@zenstackhq/runtime/models';
import { Link } from '@remix-run/react';
import { UserType } from '@prisma/client';
export type HeaderData = {user?: (User)} & {loggedin: boolean};
@@ -11,8 +12,13 @@ function Header(data: Arguments) {
var loggedin = data.data.loggedin;
var username = "";
if(loggedin){
username = data.data.user!.username;
var showScanButtons = false;
var showAdminButtons = false;
if(loggedin && data.data.user){
username = data.data.user.username;
showScanButtons = (data.data.user.type == "Scanner");
showAdminButtons = (data.data.user.type == "Admin");
}
return (
@@ -24,17 +30,18 @@ function Header(data: Arguments) {
<Navbar.Toggle />
<Navbar.Collapse>
<Nav className="me-auto">
<LinkContainer to="/beers">
<Nav.Link>Beers</Nav.Link>
</LinkContainer>
<LinkContainer to="/search">
<Nav.Link>Search</Nav.Link>
</LinkContainer>
{ showScanButtons ? (
<LinkContainer to="/scan">
<Nav.Link>Scan</Nav.Link>
</LinkContainer>
) : "" }
{ showAdminButtons ? (
<LinkContainer to="/new/beer">
<Nav.Link>Add Beer</Nav.Link>
</LinkContainer>
) : "" }
</Nav>
{ data.data.loggedin ? (
{ loggedin ? (
<Nav>
<LinkContainer to="/logout">
<Nav.Link>Logout ({username})</Nav.Link>
+105
View File
@@ -0,0 +1,105 @@
import { enhance } from "@zenstackhq/runtime";
import { db } from "~/utils/db.server";
import { DrinkComposite } from "./types";
import lodash from "lodash"
export async function findDrinksFromSearch(searchParams: URLSearchParams) : Promise<DrinkComposite[]> {
const dbe = enhance(db);
var result : DrinkComposite[] = [];
var noDrinkSelected = searchParams.getAll("drink").length == 0;
let whereBase = {delegate_aux_drink: {AND: <any>[]}};
if(searchParams.has("gluten-free")){
whereBase.delegate_aux_drink.AND.push({NOT: {gluten: true}});
}
if(searchParams.has("lactose-free")){
whereBase.delegate_aux_drink.AND.push({NOT: {lactose: true}});
}
if(searchParams.has("organic")){
whereBase.delegate_aux_drink.AND.push({organic: true});
}
if(searchParams.has("min-abv")){
let minabv = Number(searchParams.get("min-abv"));
whereBase.delegate_aux_drink.AND.push({abv: {gte: minabv}});
}
if(searchParams.has("max-abv")){
let maxabv = Number(searchParams.get("max-abv"));
whereBase.delegate_aux_drink.AND.push({abv: {lte: maxabv}});
}
if(searchParams.has("manufacturer")){
let whereManufacturer = {manufacturer: {id: {in: searchParams.getAll("manufacturer").map(Number)}}};
whereBase = lodash.merge(whereBase, whereManufacturer);
}
if(!searchParams.has("inventory")){
let whereInventory = {containers: {some: {inventory: {gt: 0}}}};
whereBase = lodash.merge(whereBase, whereInventory);
}
// Beers
if(searchParams.has("drink", "Beer") || noDrinkSelected){
let where = Object.assign({}, whereBase, {style: {}});
// Beer styles
if(searchParams.has("beerstyle")){
where.style = {id: {in: searchParams.getAll("beerstyle").map(Number)}};
}
result = result.concat(await dbe.beer.findMany({
include: {style: true, manufacturer: true, containers: {include: {section: true}}},
where: where
}));
}
if(searchParams.has("drink", "Wine") || noDrinkSelected){
let where = Object.assign({}, whereBase, {style: {}});
// Beer styles
if(searchParams.has("winestyle")){
where.style = {id: {in: searchParams.getAll("winestyle").map(Number)}};
}
result = result.concat(await dbe.wine.findMany({
include: {style: true, manufacturer: true, containers: {include: {section: true}}},
where: where
}));
}
if(searchParams.has("drink", "Soda") || noDrinkSelected){
let where = whereBase;
// Carbonated
if(searchParams.has("carbonated")){
where = Object.assign({}, whereBase, {carbonated: true});
}
result = result.concat(await dbe.soda.findMany({
include: {manufacturer: true, containers: {include: {section: true}}},
where: where
}));
}
if(searchParams.has("drink", "Cocktail") || noDrinkSelected){
let where = whereBase;
// Carbonated
if(searchParams.has("mix")){
where = Object.assign({}, whereBase, {mix: true});
}
result = result.concat(await dbe.cocktail.findMany({
include: {manufacturer: true, containers: {include: {section: true}}},
where: where
}));
}
return result;
}
-101
View File
@@ -1,8 +1,6 @@
import { enhance } from "@zenstackhq/runtime";
import { db } from "~/utils/db.server";
import { DrinkComposite } from "./types";
import lodash from "lodash"
import { DrinkType, Manufacturer } from "@prisma/client";
export async function findIdFromSlug(slug:string | undefined) : Promise<number | undefined> {
@@ -27,104 +25,5 @@ export async function findManufacturersFromSearch(searchParams: URLSearchParams)
result = result.concat(await dbe.manufacturer.findMany());
}
return result;
}
export async function findDrinksFromSearch(searchParams: URLSearchParams) : Promise<DrinkComposite[]> {
const dbe = enhance(db);
var result : DrinkComposite[] = [];
var noDrinkSelected = searchParams.getAll("drink").length == 0;
let whereBase = {delegate_aux_drink: {AND: <any>[]}};
if(searchParams.has("gluten-free")){
whereBase.delegate_aux_drink.AND.push({NOT: {gluten: true}});
}
if(searchParams.has("lactose-free")){
whereBase.delegate_aux_drink.AND.push({NOT: {lactose: true}});
}
if(searchParams.has("organic")){
whereBase.delegate_aux_drink.AND.push({organic: true});
}
if(searchParams.has("min-abv")){
let minabv = Number(searchParams.get("min-abv"));
whereBase.delegate_aux_drink.AND.push({abv: {gte: minabv}});
}
if(searchParams.has("max-abv")){
let maxabv = Number(searchParams.get("max-abv"));
whereBase.delegate_aux_drink.AND.push({abv: {lte: maxabv}});
}
if(searchParams.has("manufacturer")){
let whereManufacturer = {manufacturer: {id: {in: searchParams.getAll("manufacturer").map(Number)}}};
whereBase = lodash.merge(whereBase, whereManufacturer);
}
if(!searchParams.has("inventory")){
let whereInventory = {containers: {some: {inventory: {gt: 0}}}};
whereBase = lodash.merge(whereBase, whereInventory);
}
// Beers
if(searchParams.has("drink", "Beer") || noDrinkSelected){
let where = Object.assign({}, whereBase, {style: {}});
// Beer styles
if(searchParams.has("beerstyle")){
where.style = {id: {in: searchParams.getAll("beerstyle").map(Number)}};
}
result = result.concat(await dbe.beer.findMany({
include: {style: true, manufacturer: true, containers: {include: {section: true}}},
where: where
}));
}
if(searchParams.has("drink", "Wine") || noDrinkSelected){
let where = Object.assign({}, whereBase, {style: {}});
// Beer styles
if(searchParams.has("winestyle")){
where.style = {id: {in: searchParams.getAll("winestyle").map(Number)}};
}
result = result.concat(await dbe.wine.findMany({
include: {style: true, manufacturer: true, containers: {include: {section: true}}},
where: where
}));
}
if(searchParams.has("drink", "Soda") || noDrinkSelected){
let where = whereBase;
// Carbonated
if(searchParams.has("carbonated")){
where = Object.assign({}, whereBase, {carbonated: true});
}
result = result.concat(await dbe.soda.findMany({
include: {manufacturer: true, containers: {include: {section: true}}},
where: where
}));
}
if(searchParams.has("drink", "Cocktail") || noDrinkSelected){
let where = whereBase;
// Carbonated
if(searchParams.has("mix")){
where = Object.assign({}, whereBase, {mix: true});
}
result = result.concat(await dbe.cocktail.findMany({
include: {manufacturer: true, containers: {include: {section: true}}},
where: where
}));
}
return result;
}
+69
View File
@@ -0,0 +1,69 @@
import { DrinkComposite, isDrinkWithContainers } from "./types";
function sortName(n1:DrinkComposite, n2:DrinkComposite) : number{
if (n1.name > n2.name) {
return 1;
}
if (n1.name < n2.name) {
return -1;
}
return 0;
}
function sortAbvLowHigh(n1:DrinkComposite, n2:DrinkComposite) : number{
return n1.abv - n2.abv;
}
function sortAbvHighLow(n1:DrinkComposite, n2:DrinkComposite) : number{
return n2.abv - n1.abv;
}
function totalInventory(n:DrinkComposite) : number{
if(isDrinkWithContainers(n)){
let total = 0;
n.containers.map((container) =>{
total += container.inventory;
});
return total;
}
return 0;
}
function sortInventoryLowHigh(n1:DrinkComposite, n2:DrinkComposite) : number{
return totalInventory(n1) - totalInventory(n2);
}
function sortInventoryHighLow(n1:DrinkComposite, n2:DrinkComposite) : number{
return totalInventory(n2) - totalInventory(n1);
}
export function sortDrinksFromSearch(searchParams: URLSearchParams, searchResult: DrinkComposite[]) : DrinkComposite[] {
var callback = sortName;
if(searchParams.has("sort", "name")){
callback = sortName;
}
if(searchParams.has("sort", "abv-asc")){
callback = sortAbvLowHigh;
}
if(searchParams.has("sort", "abv-desc")){
callback = sortAbvHighLow;
}
if(searchParams.has("sort", "inv-asc")){
callback = sortInventoryLowHigh;
}
if(searchParams.has("sort", "inv-desc")){
callback = sortInventoryHighLow;
}
var sortedArray: DrinkComposite[] = searchResult.sort(callback);
return sortedArray;
}
+5
View File
@@ -1,6 +1,7 @@
import { Drink, Beer, BeerStyle, Wine, WineStyle, Soda, Manufacturer, Container, Cocktail, Section } from '@zenstackhq/runtime/models';
export type ContainerWithSection = Container & { section: Section}
export type DrinkWithContainersAndSection = (Drink & { containers: ContainerWithSection[]})
export type DrinkWithContainers = (Drink & { containers: ContainerWithSection[]}) | (Drink & { containers: Container[]})
export type DrinkWithManufacturer = Drink & { manufacturer: Manufacturer}
@@ -13,6 +14,10 @@ export function isDrinkWithContainers(drink:DrinkComposite) : drink is DrinkWith
return ((drink as { containers: Container[]}).containers != undefined) || ((drink as { containers: ContainerWithSection[]}).containers != undefined);
}
export function isDrinkWithContainersAndSection(drink:DrinkComposite) : drink is DrinkWithContainersAndSection{
return ((drink as { containers: ContainerWithSection[]}).containers != undefined);
}
export function isDrinkWithManufacturer(drink:DrinkComposite) : drink is DrinkWithManufacturer{
return (drink as { manufacturer: Manufacturer}).manufacturer != undefined;
}
+36 -10
View File
@@ -1,10 +1,12 @@
import { LoaderFunctionArgs, json } from "@remix-run/node";
import { useLoaderData, useSearchParams } from "@remix-run/react";
import { enhance } from "@zenstackhq/runtime";
import { Col, Container, Row } from "react-bootstrap";
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, findManufacturersFromSearch } from "~/models/drinks.server";
import { findDrinksFromSearch } from "~/models/drinks.filter.server";
import { findManufacturersFromSearch } from "~/models/drinks.server";
import { sortDrinksFromSearch } from "~/models/drinks.sort.server";
import { db } from "~/utils/db.server";
@@ -17,7 +19,8 @@ export const loader = async ({request} : LoaderFunctionArgs) => {
const wineStyles = await dbe.wineStyle.findMany();
const manufacturers = await findManufacturersFromSearch(url.searchParams);
const drinkResults = await findDrinksFromSearch(url.searchParams);
const drinkResultsUnsorted = await findDrinksFromSearch(url.searchParams);
const drinkResults = sortDrinksFromSearch(url.searchParams, drinkResultsUnsorted);
return json({beerStyles, wineStyles, manufacturers, drinkResults});
};
@@ -27,16 +30,39 @@ export default function BeersRoute() {
const [searchParams, setSearchParams] = useSearchParams();
function CustomToggle({ children, eventKey }) {
const decoratedOnClick = useAccordionButton(eventKey, () =>
console.log('totally custom!'),
);
return (
<Button
variant="primary"
onClick={decoratedOnClick}
>
{children}
</Button>
);
}
return (
<Container>
<Row className="mt-3">
<Col xs={12} md={4} xl={3}>
<DrinkFilter
beerStyles={loadData.beerStyles}
wineStyles={loadData.wineStyles}
manufacturers={loadData.manufacturers}
searchParams={searchParams} >
</DrinkFilter>
<Col xs={12} md={4} xl={3} className="mb-3">
<Accordion>
<CustomToggle eventKey="0">Sorting and Filtering</CustomToggle>
<Accordion.Collapse eventKey="0">
<div className="h-100 p-3 mt-3 text-bg-dark rounded-3">
<DrinkFilter
beerStyles={loadData.beerStyles}
wineStyles={loadData.wineStyles}
manufacturers={loadData.manufacturers}
searchParams={searchParams} >
</DrinkFilter>
</div>
</Accordion.Collapse>
</Accordion>
</Col>
<Col>
<Row xs={2} md={3} lg={4} className="g-4">
+91 -41
View File
@@ -1,65 +1,115 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { useActionData } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useActionData, useLoaderData } from "@remix-run/react";
import { enhance } from "@zenstackhq/runtime";
import { Col, Container, Form, Row } from "react-bootstrap";
import { Col, Container, Form, Row, Table } from "react-bootstrap";
import { getSession } from "~/auth/session";
import timeAgo from "~/utils/datetime";
import { db } from "~/utils/db.server";
import { badRequest } from "~/utils/request.server";
export async function loader({
request,
}: LoaderFunctionArgs) {
const session = await getSession(
request.headers.get("Cookie")
);
if(!session.has("user_id")){
return redirect("/");
}
const dbe = session.has("userid") ? enhance(db) : enhance(db, {user: session.get("user")});
const history = await dbe.history.findMany({select: {checkoutAt: true ,container: {select: {drink: true}}}, take: 10, orderBy: {checkoutAt: "desc"}});
return json({history});
}
export const action = async ({
request,
}: ActionFunctionArgs) => {
const dbe = enhance(db);
const session = await getSession(
request.headers.get("Cookie")
);
const dbe = session.has("userid") ? enhance(db) : enhance(db, {user: session.get("user")});
const form = await request.formData();
const barcode = form.get("barcode");
// we do this type check to be extra sure and to make TypeScript happy
// we'll explore validation next!
if (
typeof barcode !== "string"
) {
return badRequest({
fieldErrors: null,
fields: null,
formError: "Form not submitted correctly.",
});
const barcode = form.get("barcode")?.toString() || "";
const container = await dbe.container.findUnique({
select: {id: true, inventory: true},
where: {barcode: barcode}
});
// No drink found for barcode, do nothing
if(!container){
return json({error: "Drink not found!"});
}
// No inventory, do nothing
if(container.inventory <= 0){
return json({error: "No inventory!"});
}
const drink = await dbe.drink.findFirst({
select: {slug: true},
where: {containers: {some: {barcode: barcode}}}
});
if(drink) return redirect(`/beer/${drink.slug}`);
// 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}});
return json({error: ""});
};
export default function ScanRoute() {
const aData = useActionData<typeof action>();
const lData = useLoaderData<typeof loader>();
const hasError = aData?.error ? aData.error.length > 0 : false;
return (
<Container>
<Row className="mt-3">
<Col xs={4}>
<Form method="post">
<div>
<label>
Scan barcode:
<input
className="form-control"
type="text"
name="barcode"
/>
</label>
</div>
<Form method="post" noValidate>
<Form.Group>
<Form.Label>
Scan barcode:
</Form.Label>
<Form.Control
type="text"
name="barcode"
isInvalid={hasError}
autoFocus
/>
<Form.Control.Feedback type="invalid">
{aData?.error}
</Form.Control.Feedback>
</Form.Group>
</Form>
</Col>
</Row>
<Row className="mt-3">
<Col xs={12}>
<h3>History</h3>
<Table striped>
<thead>
<tr>
<th>Beer</th>
<th>When</th>
</tr>
</thead>
<tbody>
{lData.history.map((entry) => (
<tr key={entry.container.drink.slug}>
<td>{entry.container.drink.name}</td>
<td>{timeAgo(new Date(entry.checkoutAt))}</td>
</tr>
))}
</tbody>
</Table>
</Col>
</Row>
</Container>
);
}
export function ErrorBoundary() {
return (
<div className="error-container">
Something unexpected went wrong. Sorry about that.
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
export default function timeAgo(previous: Date) {
var msPerMinute = 60 * 1000;
var msPerHour = msPerMinute * 60;
var msPerDay = msPerHour * 24;
var msPerMonth = msPerDay * 30;
var msPerYear = msPerDay * 365;
const now = new Date();
const elapsed : number = (now.getTime() - previous.getTime());
if (elapsed < msPerMinute) {
return Math.round(elapsed/1000) + ' seconds ago';
}
else if (elapsed < msPerHour) {
return Math.round(elapsed/msPerMinute) + ' minutes ago';
}
else if (elapsed < msPerDay ) {
return Math.round(elapsed/msPerHour ) + ' hours ago';
}
else if (elapsed < msPerMonth) {
return 'approximately ' + Math.round(elapsed/msPerDay) + ' days ago';
}
else if (elapsed < msPerYear) {
return 'approximately ' + Math.round(elapsed/msPerMonth) + ' months ago';
}
else {
return 'approximately ' + Math.round(elapsed/msPerYear ) + ' years ago';
}
}
+11
View File
@@ -139,6 +139,7 @@ model Container {
portions Int?
price Float @default(0.0)
inventory Int @default(0)
checkouts History[]
}
/// @@allow('read', true)
@@ -169,6 +170,16 @@ model Country {
manufacturers Manufacturer[]
}
/// @@allow('read', true)
/// @@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())
}
/// @@allow('all', auth() == this)
/// @@allow('all', auth().type == Admin)
model User {
+5 -4
View File
@@ -11,10 +11,11 @@ async function seed() {
const user = await pr.user.findUnique({where: {username: "kenneth"}}) || undefined;
// Delete all sessions
pr.session.deleteMany({});
await pr.session.deleteMany({});
const db = enhance(pr, {user});
await db.history.deleteMany({});
await db.container.deleteMany({});
await db.beer.deleteMany({});
await db.wine.deleteMany({});
@@ -843,7 +844,7 @@ function getContainers() : ContainerQuery[] {
portions: 1,
type: ContainerType.BeerBottle,
volume: 330,
inventory: 1
inventory: 3
},
{
barcode: "5411858100067",
@@ -852,7 +853,7 @@ function getContainers() : ContainerQuery[] {
portions: 1,
type: ContainerType.BeerBottle,
volume: 330,
inventory: 1
inventory: 2
},
{
barcode: "5412186000098",
@@ -864,4 +865,4 @@ function getContainers() : ContainerQuery[] {
inventory: 1
}
];
}
}
+15
View File
@@ -138,6 +138,8 @@ model Container {
inventory Int @default(0)
checkouts History[]
@@allow('read', true)
@@allow('update', auth().type == Scanner)
@@allow('all', auth().type == Admin)
@@ -179,6 +181,19 @@ model Country {
@@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())
@@allow('read', true)
@@allow('create', auth().type == Scanner)
@@allow('all', auth().type == Admin)
}
// Authentication
model User {
id Int @id @default(autoincrement())
Executable
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
set -e
npx zenstack generate
npx prisma db push
npx remix vite:build
npx remix-serve build/server/index.js