AI Changes: new scan page, add inventory filter
Build dev docker image / build (push) Successful in 43s
Build dev docker image / release (push) Successful in 1s

This commit is contained in:
2026-08-03 12:48:09 +02:00
parent 734c52c898
commit c816af19a5
3 changed files with 163 additions and 55 deletions
+7 -7
View File
@@ -69,10 +69,10 @@ function DrinkFilter(arg : Arguments) {
["name", "Name"], ["name", "Name"],
["new", "New"], ["new", "New"],
["popular", "Popularity"], ["popular", "Popularity"],
["abv-asc", "ABV Low -> High"], ["abv-asc", "Alcohol (Low -> High)"],
["abv-desc", "ABV High -> Low"], ["abv-desc", "Alcohol (High -> Low)"],
["inv-asc", "Inventoy Low -> High"], ["inv-asc", "Inventory (Low -> High)"],
["inv-desc", "Inventory High -> Low"] ["inv-desc", "Inventory (High -> Low)"]
].map((pair) => ( ].map((pair) => (
<Form.Check <Form.Check
id={"sort-" + pair[0]} id={"sort-" + pair[0]}
@@ -86,7 +86,7 @@ function DrinkFilter(arg : Arguments) {
</Form.Group> </Form.Group>
<Form.Group controlId="drink-type" className='bg-body-secondary rounded p-3 mt-3'> <Form.Group controlId="drink-type" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>Drink type</Form.Label> <Form.Label className='fw-bold'>Drink Type</Form.Label>
{ ["Beer", "Wine", "Soda", "Cocktail"].map(key => ( { ["Beer", "Wine", "Soda", "Cocktail"].map(key => (
<Form.Check <Form.Check
id={"drink-" + key} id={"drink-" + key}
@@ -100,8 +100,8 @@ function DrinkFilter(arg : Arguments) {
</Form.Group> </Form.Group>
<Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'> <Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>ABV</Form.Label> <Form.Label className='fw-bold'>Alcohol Percentage</Form.Label>
<div className='d-grid gap-2'>{abvOptionsMarkup}</div> <div className='d-grid'>{abvOptionsMarkup}</div>
</Form.Group> </Form.Group>
{/* <Form.Group controlId="manufacturer" className='bg-body-secondary rounded p-3 mt-3'> {/* <Form.Group controlId="manufacturer" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>Manufacturer</Form.Label> <Form.Label className='fw-bold'>Manufacturer</Form.Label>
+26 -3
View File
@@ -1,8 +1,10 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node"; import { json } from "@remix-run/node";
import { useMemo, useState } from "react";
import { useLoaderData } from "@remix-run/react"; import { useLoaderData } from "@remix-run/react";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { containerTypeToString } from "~/models/types"; import { containerTypeToString } from "~/models/types";
import { volume } from '~/utils/conversions';
import { enhance } from "~/utils/db.server"; import { enhance } from "~/utils/db.server";
@@ -12,7 +14,7 @@ export async function loader({
}: LoaderFunctionArgs) { }: LoaderFunctionArgs) {
const { dbe } = await enhance(request); const { dbe } = await enhance(request);
const containers = await dbe.container.findMany({select: {id: true, drink: true, type: true, inventory: true}, orderBy: {drink: {name: "asc"}}}); const containers = await dbe.container.findMany({select: {id: true, drink: true, type: true, inventory: true, volume: true}, orderBy: {drink: {name: "asc"}}});
return json({ containers }); return json({ containers });
}; };
@@ -44,16 +46,37 @@ export async function action({
export default function EditInventoryRoute() { export default function EditInventoryRoute() {
const lData = useLoaderData<typeof loader>(); const lData = useLoaderData<typeof loader>();
const [search, setSearch] = useState("");
const filteredContainers = useMemo(() => {
const searchText = search.trim().toLowerCase();
if (!searchText) return lData.containers;
return lData.containers.filter((container) => {
const label = `${container.drink.name} ${containerTypeToString(container.type)} ${container.inventory}`.toLowerCase();
return label.includes(searchText);
});
}, [lData.containers, search]);
return ( return (
<div> <div>
<h2>Update inventory</h2> <h2>Update inventory</h2>
<Form method="post"> <Form method="post">
<Form.Group className='mb-2'>
<Form.Label>Filter containers</Form.Label>
<Form.Control
type="search"
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder="Type drink name, type or inventory"
autoComplete="off"
/>
</Form.Group>
<Form.Group className='mb-2'> <Form.Group className='mb-2'>
<Form.Label>Drink container</Form.Label> <Form.Label>Drink container</Form.Label>
<Form.Select name="id"> <Form.Select name="id">
{ lData.containers.map((container) => ( { filteredContainers.map((container) => (
<option value={container.id}>{container.drink.name} - {containerTypeToString(container.type)} ({container.inventory})</option> <option key={container.id} value={container.id}>{container.drink.name} - {containerTypeToString(container.type)} - {volume(container.volume)} ({container.inventory})</option>
))} ))}
</Form.Select> </Form.Select>
</Form.Group> </Form.Group>
+129 -44
View File
@@ -1,6 +1,7 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node"; import { json, redirect } from "@remix-run/node";
import { useActionData, useLoaderData } from "@remix-run/react"; import { useActionData, useLoaderData } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import { Col, Container, Form, Row, Table } from "react-bootstrap"; import { Col, Container, Form, Row, Table } from "react-bootstrap";
import timeAgo from "~/utils/datetime"; import timeAgo from "~/utils/datetime";
@@ -28,8 +29,12 @@ export async function action({
const barcode = form.get("barcode")?.toString() || ""; const barcode = form.get("barcode")?.toString() || "";
const container = await dbe.container.findUnique({ const container = await dbe.container.findUnique({
select: {id: true, inventory: true, drink_id: true}, select: {
where: {barcode: barcode} id: true,
inventory: true,
drink: { select: { name: true, image: true } },
},
where: { barcode: barcode },
}); });
// No drink found for barcode, do nothing // No drink found for barcode, do nothing
@@ -48,58 +53,138 @@ export async function action({
// Log entry // 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: newInventory}});
return json({error: ""}); return json({
error: "",
lastScanned: {
name: container.drink.name,
image: container.drink.image ?? null,
},
});
}; };
export default function ScanRoute() { export default function ScanRoute() {
const aData = useActionData<typeof action>(); const aData = useActionData<typeof action>();
const lData = useLoaderData<typeof loader>(); const lData = useLoaderData<typeof loader>();
const [lastScanned, setLastScanned] = useState<{name:string;image:string|null} | null>(null);
const [showLastScanned, setShowLastScanned] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const hasError = aData?.error ? aData.error.length > 0 : false; const hasError = aData?.error ? aData.error.length > 0 : false;
const focusInput = () => {
window.setTimeout(() => inputRef.current?.focus(), 0);
};
useEffect(() => {
focusInput();
}, []);
useEffect(() => {
focusInput();
}, [aData]);
useEffect(() => {
const handleBlur = () => {
focusInput();
};
const input = inputRef.current;
input?.addEventListener("blur", handleBlur);
return () => {
input?.removeEventListener("blur", handleBlur);
};
}, []);
useEffect(() => {
if (!aData?.lastScanned) {
return;
}
setLastScanned(aData.lastScanned);
setShowLastScanned(true);
const timer = window.setTimeout(() => {
setShowLastScanned(false);
}, 5000);
return () => window.clearTimeout(timer);
}, [aData?.lastScanned]);
return ( return (
<Container> <Container fluid className="mt-3">
<Row className="mt-3"> <Row>
<Col xs={4}> <Col xs={12} md={6} className="mb-3">
<Form method="post" action="/scan?index" 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> <h3>History</h3>
<Table striped> <div style={{ maxHeight: '70vh', overflowY: 'auto' }}>
<thead> <Table striped size="sm">
<tr> <thead>
<th>Drink</th> <tr>
<th>When</th> <th>Drink</th>
<th>Amount left</th> <th>When</th>
</tr> <th>Amount left</th>
</thead> </tr>
<tbody> </thead>
{lData.history.map((entry) => ( <tbody>
<tr key={entry.id}> {lData.history.map((entry) => (
<td>{entry.container.drink.name}</td> <tr key={entry.id}>
<td>{timeAgo(new Date(entry.checkoutAt))}</td> <td>{entry.container.drink.name}</td>
<td>{entry.inventoryAfter}</td> <td>{timeAgo(new Date(entry.checkoutAt))}</td>
</tr> <td>{entry.inventoryAfter}</td>
))} </tr>
</tbody> ))}
</Table> </tbody>
</Table>
</div>
</Col>
<Col xs={12} md={6}>
<h3>Scan Barcode</h3>
<Row className="mb-3">
<Col>
<Form method="post" action="/scan?index" noValidate>
<Form.Group>
<Form.Control
ref={inputRef}
type="text"
name="barcode"
isInvalid={hasError}
autoComplete="off"
spellCheck={false}
inputMode="text"
style={{ height: '3rem', fontSize: '1.8rem', textAlign: 'center' }}
/>
<Form.Control.Feedback type="invalid">
{aData?.error}
</Form.Control.Feedback>
</Form.Group>
</Form>
</Col>
</Row>
<Row>
<Col>
<div className="p-4 border rounded" style={{ minHeight: '50vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', textAlign: 'center' }}>
{showLastScanned && lastScanned ? (
<>
<h1 style={{ fontSize: '2rem', marginBottom: '1rem' }}>{lastScanned.name}</h1>
{lastScanned.image ? (
<img
src={lastScanned.image}
alt={lastScanned.name}
style={{ width: '100%', maxHeight: '40vh', objectFit: 'contain' }}
/>
) : (
<div className="text-muted" style={{ fontSize: '1.5rem' }}>No image available</div>
)}
</>
) : (
<div className="text-muted" style={{ fontSize: '2rem', fontWeight: 600 }}>
<h1>Please scan your drink</h1>
</div>
)}
</div>
</Col>
</Row>
</Col> </Col>
</Row> </Row>
</Container> </Container>