7 Commits
Author SHA1 Message Date
kennyboy55 c08a035416 Bugfix: radio buttons, new badges, sort
Build dev docker image / build (push) Successful in 23s
Build dev docker image / build (push) Successful in 35s
Build dev docker image / release (push) Successful in 1s
2026-08-11 12:17:33 +02:00
kennyboy55 0f90972bcb Fix bug with new/restock labels
Build dev docker image / build (push) Successful in 40s
Build dev docker image / build (push) Successful in 36s
Build dev docker image / release (push) Successful in 2s
2026-08-11 11:56:17 +02:00
kennyboy55 36881293ed AI Changes: scan page fix, filters improvements
Build dev docker image / build (push) Successful in 23s
Build dev docker image / build (push) Successful in 36s
Build dev docker image / release (push) Successful in 1s
2026-08-07 11:53:20 +02:00
kennyboy55 7f7ae8e1ac AI Changes: offline handling, fullscreen handling
Build dev docker image / build (push) Successful in 32s
Build dev docker image / build (push) Successful in 47s
Build dev docker image / release (push) Successful in 1s
2026-08-03 17:56:28 +02:00
kennyboy55 c816af19a5 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
2026-08-03 12:48:09 +02:00
kennyboy55 734c52c898 Bugfix with usertype
Build dev docker image / release (push) Successful in 1s
Build dev docker image / build (push) Successful in 36s
2026-08-01 12:04:21 +02:00
kennyboy55 e4c93eb024 AI Changes: add user management and hashed passwords, admin dashboard, report fixes
Build dev docker image / build (push) Successful in 34s
Build dev docker image / release (push) Successful in 1s
2026-08-01 11:47:21 +02:00
21 changed files with 905 additions and 167 deletions
+3 -5
View File
@@ -2,8 +2,6 @@ name: Build dev docker image
on:
push:
branches:
- main
tags:
- '*'
workflow_dispatch:
@@ -84,6 +82,6 @@ jobs:
tag_name: ${{ gitea.ref_name }}
name: ${{ gitea.ref_name }}
body: |
"## Docker images"
"`gitea.furb.it/${{ gitea.repository }}:${{ gitea.ref_name }}`"
"`gitea.furb.it/${{ gitea.repository }}:latest`"
## Docker images
`gitea.furb.it/${{ gitea.repository }}:${{ gitea.ref_name }}`
`gitea.furb.it/${{ gitea.repository }}:latest`
+35
View File
@@ -0,0 +1,35 @@
name: Build dev docker image
on:
push:
branches:
- main
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
# Checkout repository for build
- name: Checkout beer-inventory repository
uses: actions/checkout@v4
with:
ref: ${{ gitea.ref }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
# Install NPM packages
- name: Install NPM packages
run: npm ci
# Build project
- name: Build project
run: |
npx zenstack generate
npx remix vite:build
+10
View File
@@ -1,5 +1,10 @@
Beer-inventory project for home use.
The project has three aspects.
- The website, which people can visit to see the current inventory of drinks. They can see in which section a drink is stored.
- The Admin website, which is part of the main website but only to logged in admin users. Which allows updating drinks, inventory, and adding or editing data.
- The scanner, which is a raspberry pi with touchscreen display and a USB barcode scanner. This is next to the fridge, and people use it to scan a drink to remove it from the inventory.
Used technology:
- remix.run (now known as react router)
- prisma
@@ -18,3 +23,8 @@ Architecture:
- user (can view the website on their phone)
- scanner (the physical raspberry pi with barcode scanner at the fridge)
- admin (me, the developer that can access and update everything)
Building and validation:
`npx remix vite:build` to build the project and check for errors
`npx remix vite:dev` to run the project in live dev mode
`npx remix routes` to validate all routes are present and correct
+60 -2
View File
@@ -1,9 +1,67 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { db } from "~/utils/db.server";
function normalizePassword(password: string) {
return password.trim();
}
export function hashPassword(password: string) {
const normalizedPassword = normalizePassword(password);
return createHash("sha256").update(normalizedPassword).digest("hex");
}
function isHashedPassword(value: string) {
return /^[a-f0-9]{64}$/i.test(value);
}
export function verifyPassword(password: string, storedPassword: string | null | undefined) {
if (!storedPassword) {
return false;
}
const normalizedPassword = normalizePassword(password);
const hashedPassword = hashPassword(normalizedPassword);
if (isHashedPassword(storedPassword)) {
const storedBuffer = Buffer.from(storedPassword);
const hashedBuffer = Buffer.from(hashedPassword);
if (hashedBuffer.length !== storedBuffer.length) {
return false;
}
return timingSafeEqual(hashedBuffer, storedBuffer);
}
return normalizePassword(storedPassword) === normalizedPassword;
}
export async function validateCredentials(
username: string,
password: string
) {
var user = await db.user.findUnique({where:{username: username, password: password}, select:{id: true}});
return user?.id;
const user = await db.user.findUnique({
where: { username },
select: { id: true, password: true },
});
if (!user) {
return null;
}
const passwordMatches = verifyPassword(password, user.password);
if (!passwordMatches) {
return null;
}
if (!isHashedPassword(user.password ?? "")) {
await db.user.update({
where: { id: user.id },
data: { password: hashPassword(password) },
});
}
return user.id;
}
+3 -7
View File
@@ -17,7 +17,6 @@ function toDate(value: Date | string | null | undefined): Date | null {
}
function DrinkCard(arg:Arguments) {
var trimmedDescription = arg.drink.description.length > 120 ? arg.drink.description.substring(0, 120) + "..." : arg.drink.description;
var link = "/inventory/";
switch (arg.drink.type) {
@@ -61,10 +60,7 @@ function DrinkCard(arg:Arguments) {
}
const drinkAddedAt = toDate((arg.drink as { addedAt?: Date | string | null }).addedAt ?? null);
const latestTimestamp = drinkAddedAt && (!latestActivity || drinkAddedAt.getTime() > latestActivity.getTime())
? drinkAddedAt
: latestActivity;
const isNewlyAdded = latestTimestamp ? (Date.now() - latestTimestamp.getTime()) <= 30 * 24 * 60 * 60 * 1000 : false;
const isNewlyAdded = drinkAddedAt ? (Date.now() - drinkAddedAt.getTime()) <= 30 * 24 * 60 * 60 * 1000 : false;
const isRecentlyRestocked = latestActivity ? (Date.now() - latestActivity.getTime()) <= 14 * 24 * 60 * 60 * 1000 : false;
return (
@@ -72,11 +68,11 @@ function DrinkCard(arg:Arguments) {
<div style={{ position: 'relative', width: '100%', aspectRatio: '1 / 1', overflow: 'hidden' }}>
{ (isNewlyAdded || isRecentlyRestocked) ? (
<Badge
bg={isRecentlyRestocked ? "success" : "primary"}
bg={isNewlyAdded ? "primary" : "success"}
className='position-absolute top-0 start-0 m-2 text-wrap'
style={{ zIndex: 2 }}
>
{isNewlyAdded && isRecentlyRestocked ? "New · Restocked" : isNewlyAdded ? "New" : "Recently added"}
{isNewlyAdded ? "New" : (isRecentlyRestocked ? "Restocked" : "")}
</Badge>
) : null }
<Card.Img
+109 -47
View File
@@ -2,7 +2,7 @@
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 { Button, Form, Col, Row } from 'react-bootstrap';
interface Arguments {
beerStyles: BeerStyle[];
@@ -20,11 +20,28 @@ function DrinkFilter(arg : Arguments) {
const showSodaFilter = arg.searchParams.has("drink", "Soda");
const showCocktailFilter = arg.searchParams.has("drink", "Cocktail");
const form = useRef(null);
const form = useRef<HTMLFormElement | null>(null);
const drinkTypes = ["Beer", "Wine", "Soda", "Cocktail"];
const [beerStyleExpanded, setBeerStyleExpanded] = useState(false);
const [wineStyleExpanded, setWineStyleExpanded] = useState(false);
const submitAfterRender = function(){
setTimeout(() => submit(form.current), 10);
}
const toggleDrinkType = (drinkType: string) => {
const params = new URLSearchParams(arg.searchParams.toString());
const values = params.getAll("drink");
if (values.includes(drinkType)) {
params.delete("drink");
values.filter((value) => value !== drinkType).forEach((value) => params.append("drink", value));
} else {
params.append("drink", drinkType);
}
if (params.get("abv") === "all") {
params.delete("abv");
}
submit(params, { method: "get", replace: true });
};
const handleFormChange = (event: React.ChangeEvent<HTMLFormElement>) => {
const formData = new FormData(event.currentTarget);
@@ -37,6 +54,8 @@ function DrinkFilter(arg : Arguments) {
}
const abvSelection = arg.searchParams.get("abv") || "all";
const selectedBeerStyleCount = arg.searchParams.getAll("beerstyle").length;
const selectedWineStyleCount = arg.searchParams.getAll("winestyle").length;
const abvOptions = [
{ value: "all", label: "All" },
{ value: "alcohol-free", label: "Alcohol free" },
@@ -57,7 +76,7 @@ function DrinkFilter(arg : Arguments) {
/>
));
const sortKey = arg.searchParams.get("sort") || "name";
const sortKey = arg.searchParams.get("sort") || "new";
return (
<Form noValidate method="GET" ref={form} className='pe-2' onChange={handleFormChange}>
@@ -66,13 +85,13 @@ function DrinkFilter(arg : Arguments) {
<Form.Group controlId="sort" className='bg-body-secondary rounded p-3'>
<Form.Label className='fw-bold'>Sort</Form.Label>
{ [
["name", "Name"],
["new", "New"],
["name", "Name"],
["popular", "Popularity"],
["abv-asc", "ABV Low -> High"],
["abv-desc", "ABV High -> Low"],
["inv-asc", "Inventoy Low -> High"],
["inv-desc", "Inventory High -> Low"]
["abv-asc", "Alcohol (Low -> High)"],
["abv-desc", "Alcohol (High -> Low)"],
["inv-asc", "Inventory (Low -> High)"],
["inv-desc", "Inventory (High -> Low)"]
].map((pair) => (
<Form.Check
id={"sort-" + pair[0]}
@@ -86,22 +105,29 @@ function DrinkFilter(arg : Arguments) {
</Form.Group>
<Form.Group controlId="drink-type" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>Drink type</Form.Label>
{ ["Beer", "Wine", "Soda", "Cocktail"].map(key => (
<Form.Check
id={"drink-" + key}
type="checkbox"
label={key}
name="drink"
value={key}
defaultChecked={arg.searchParams.has("drink", key) ? true : false}
/>
<Form.Label className='fw-bold'>Drink Type</Form.Label>
<div className='row row-cols-2 g-2'>
{drinkTypes.map((key) => (
<div className='col' key={key}>
<Button
type="button"
variant={arg.searchParams.has("drink", key) ? "primary" : "outline-primary"}
className='w-100 text-start'
aria-pressed={arg.searchParams.has("drink", key)}
onClick={() => toggleDrinkType(key)}
>
{key}
</Button>
</div>
))}
</div>
{arg.searchParams.getAll("drink").map((drinkValue) => (
<input key={drinkValue} type="hidden" name="drink" value={drinkValue} />
))}
</Form.Group>
<Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>ABV</Form.Label>
<div className='d-grid gap-2'>{abvOptionsMarkup}</div>
<Form.Label className='fw-bold'>Alcohol Percentage</Form.Label>
<div className='d-grid'>{abvOptionsMarkup}</div>
</Form.Group>
{/* <Form.Group controlId="manufacturer" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>Manufacturer</Form.Label>
@@ -119,34 +145,67 @@ function DrinkFilter(arg : Arguments) {
{ showBeerFilter ? (
<>
<Form.Group controlId="beer-style" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold mt-3'>Beer style</Form.Label>
{ arg.beerStyles.map(style => (
<Form.Check
id={"beerstyle-" + style.name}
type="checkbox"
label={style.name}
name="beerstyle"
value={style.id}
defaultChecked={arg.searchParams.has("beerstyle", style.id.toString()) ? true : false}
/>
))}
<div className='d-flex justify-content-between align-items-center'>
<Form.Label className='fw-bold mb-0'>Beer style {selectedBeerStyleCount > 0 ? ( <span className="badge text-bg-dark">{selectedBeerStyleCount} selected</span>) : ''}</Form.Label>
<Button
type='button'
variant='outline-secondary'
size='sm'
onClick={() => setBeerStyleExpanded((current) => !current)}
>
{beerStyleExpanded ? 'Collapse' : 'Expand'}
</Button>
</div>
{beerStyleExpanded ? (
<div className='mt-3'>
{ arg.beerStyles.map(style => (
<Form.Check
key={style.id}
id={"beerstyle-" + style.name}
type="checkbox"
label={style.name}
name="beerstyle"
value={style.id}
defaultChecked={arg.searchParams.has("beerstyle", style.id.toString()) ? true : false}
/>
))}
</div>
) : (
<div className='text-muted mt-3'>Tap expand to show beer styles</div>
)}
</Form.Group>
</>
) : ""}
{ showWineFilter ? (
<Form.Group controlId="wine-style" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>Wine style</Form.Label>
{ arg.wineStyles.map(style => (
<Form.Check
id={"winestyle-" + style.name}
type="checkbox"
label={style.name}
name="winestyle"
value={style.id}
defaultChecked={arg.searchParams.has("winestyle", style.id.toString()) ? true : false}
/>
))}
</Form.Group>
<div className='d-flex justify-content-between align-items-center'>
<Form.Label className='fw-bold mb-0'>Wine style {selectedWineStyleCount > 0 ? ( <span className="badge text-bg-dark">{selectedWineStyleCount} selected</span>) : ''}</Form.Label>
<Button
type='button'
variant='outline-secondary'
size='sm'
onClick={() => setWineStyleExpanded((current) => !current)}
>
{wineStyleExpanded ? 'Collapse' : 'Expand'}
</Button>
</div>
{wineStyleExpanded ? (
<div className='mt-3'>
{ arg.wineStyles.map(style => (
<Form.Check
id={"winestyle-" + style.name}
type="checkbox"
label={style.name}
name="winestyle"
value={style.id}
defaultChecked={arg.searchParams.has("winestyle", style.id.toString()) ? true : false}
/>
))}
</div>
) : (
<div className='text-muted mt-3'>Tap expand to show wine styles</div>
)}
</Form.Group>
) : ""}
{ showSodaFilter ? (
<Form.Group controlId="soda-carbonated" className='bg-body-secondary rounded p-3 mt-3'>
@@ -154,6 +213,7 @@ function DrinkFilter(arg : Arguments) {
<Form.Check
type="radio"
label="Both"
id="carbonated-both"
name="carbonated"
value={0}
defaultChecked={(arg.searchParams.get("carbonated") == "0" || !arg.searchParams.has("carbonated")) ? true : false}
@@ -161,6 +221,7 @@ function DrinkFilter(arg : Arguments) {
<Form.Check
type="radio"
label="Sparkling"
id="carbonated-sparkling"
name="carbonated"
value={1}
defaultChecked={arg.searchParams.get("carbonated") == "1" ? true : false}
@@ -168,6 +229,7 @@ function DrinkFilter(arg : Arguments) {
<Form.Check
type="radio"
label="Non-sparkling"
id="carbonated-flat"
name="carbonated"
value={2}
defaultChecked={arg.searchParams.get("carbonated") == "2" ? true : false}
+1
View File
@@ -41,6 +41,7 @@ function Header(data: Arguments) {
</Nav>
{ loggedin ? (
<Nav>
<Nav.Link as={Link} to="/users">Users</Nav.Link>
<Nav.Link as={Link} to="/logout">Logout ({username})</Nav.Link>
</Nav>
) : (
+5 -5
View File
@@ -97,7 +97,11 @@ function sortNewest(n1:DrinkComposite, n2:DrinkComposite) : number{
export function sortDrinksFromSearch(searchParams: URLSearchParams, searchResult: DrinkComposite[]) : DrinkComposite[] {
var callback = sortName;
var callback = sortNewest;
if(searchParams.has("sort", "new")){
callback = sortNewest;
}
if(searchParams.has("sort", "name")){
callback = sortName;
@@ -123,10 +127,6 @@ export function sortDrinksFromSearch(searchParams: URLSearchParams, searchResult
callback = sortPopularity;
}
if(searchParams.has("sort", "new")){
callback = sortNewest;
}
var sortedArray: DrinkComposite[] = searchResult.sort(callback);
return sortedArray;
+78 -1
View File
@@ -8,7 +8,7 @@ import {
isRouteErrorResponse,
useRouteError
} from "@remix-run/react";
import type { PropsWithChildren } from "react";
import React, { useEffect, useState, type PropsWithChildren } from "react";
import stylesheet from "bootstrap/dist/css/bootstrap.min.css?url";
@@ -53,6 +53,45 @@ export default function App() {
export function ErrorBoundary() {
const error = useRouteError();
const [isOnline, setIsOnline] = useState<boolean>(
typeof navigator !== "undefined" ? navigator.onLine : true
);
const tryRefresh = async () => {
if (typeof window === "undefined") return;
try {
const res = await fetch(window.location.href, { method: "GET", cache: "no-store" });
if (res && res.ok) {
window.location.reload();
}
} catch (e) {
// network still down; ignore, we'll retry on the next interval or when online
}
};
useEffect(() => {
if (typeof window === "undefined") return;
const handleOnline = () => {
setIsOnline(true);
tryRefresh();
};
const handleOffline = () => setIsOnline(false);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
const hourly = window.setInterval(() => {
tryRefresh();
}, 15 * 60 * 1000); // Every 15 minutes
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
window.clearInterval(hourly);
};
}, []);
if (isRouteErrorResponse(error)) {
return (
<Document
@@ -64,6 +103,25 @@ export function ErrorBoundary() {
<div className="h-100 p-5 text-bg-dark rounded-3">
<h1>{error.status}</h1>
{error.statusText}
<div className="mt-3">
<button
className="btn btn-primary me-2"
onClick={() => {
if (typeof window !== "undefined") window.location.reload();
}}
>
Reload page
</button>
<button
className="btn btn-outline-secondary"
onClick={() => {
tryRefresh();
}}
>
Try refresh now
</button>
<div className="mt-2">Status: {isOnline ? "Online" : "Offline"}. Will retry automatically every 15 minutes.</div>
</div>
</div>
</Col>
</Row>
@@ -84,6 +142,25 @@ export function ErrorBoundary() {
<div className="h-100 p-5 text-bg-dark rounded-3">
<h1>App Error</h1>
{errorMessage}
<div className="mt-3">
<button
className="btn btn-primary me-2"
onClick={() => {
if (typeof window !== "undefined") window.location.reload();
}}
>
Reload page
</button>
<button
className="btn btn-outline-secondary"
onClick={() => {
tryRefresh();
}}
>
Try refresh now
</button>
<div className="mt-2">Status: {isOnline ? "Online" : "Offline"}. Will retry automatically every 15 minutes.</div>
</div>
</div>
</Col>
</Row>
+26 -3
View File
@@ -1,8 +1,10 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useMemo, useState } from "react";
import { useLoaderData } from "@remix-run/react";
import { Button, Form } from "react-bootstrap";
import { containerTypeToString } from "~/models/types";
import { volume } from '~/utils/conversions';
import { enhance } from "~/utils/db.server";
@@ -12,7 +14,7 @@ export async function loader({
}: LoaderFunctionArgs) {
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 });
};
@@ -44,16 +46,37 @@ export async function action({
export default function EditInventoryRoute() {
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 (
<div>
<h2>Update inventory</h2>
<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.Label>Drink container</Form.Label>
<Form.Select name="id">
{ lData.containers.map((container) => (
<option value={container.id}>{container.drink.name} - {containerTypeToString(container.type)} ({container.inventory})</option>
{ filteredContainers.map((container) => (
<option key={container.id} value={container.id}>{container.drink.name} - {containerTypeToString(container.type)} - {volume(container.volume)} ({container.inventory})</option>
))}
</Form.Select>
</Form.Group>
+11 -7
View File
@@ -17,23 +17,27 @@ export const action = async ({
const dateEnd = String(form.get("dateEnd")) + ":00.000z" || Date.now().toString();
try{
const lastIdEntries = await dbe.report.findMany({select: {id: true}, take: 1, orderBy:{createdAt: "desc"}})
const containersWithHistoryAndDrink = await dbe.container.findMany({
where: {checkouts: {some:{AND: [{checkoutAt: {gte: dateStart}}, {checkoutAt: {lte: dateEnd}}]}}},
include: {drink: true, checkouts: {where:{AND: [{checkoutAt: {gte: dateStart}}, {checkoutAt: {lte: dateEnd}}]}}}
});
const reportId = String(lastIdEntries[0] ? lastIdEntries[0].id + 1 : 0).padStart(8, "0");
const report = await dbe.report.create({ data: {
name: name,
dateStart: dateStart,
dateEnd: dateEnd
}
});
const reportId = String(report.id).padStart(8, "0");
const filepath = await createPdf(slugify(name), reportId, dateStart, dateEnd, containersWithHistoryAndDrink);
await dbe.report.create({ data: {
name: name,
dateStart: dateStart,
dateEnd: dateEnd,
await dbe.report.update({ data: {
file: filepath
}
},
where: {id: report.id}
});
}
+237 -4
View File
@@ -1,10 +1,173 @@
import { Link } from "@remix-run/react";
import { ListGroup } from "react-bootstrap";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, Link, useActionData, useLoaderData } from "@remix-run/react";
import { Alert, Button, Card, Col, Form as BootstrapForm, ListGroup, Row } from "react-bootstrap";
import { hashPassword } from "~/auth/validate";
import { enhance } from "~/utils/db.server";
export async function loader({ request }: LoaderFunctionArgs) {
const { dbe } = await enhance(request);
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
const [containersWithoutBarcode, checkoutsLastHour, latestCheckout, users] = await Promise.all([
dbe.container.count({ where: { barcode: null } }),
dbe.history.count({ where: { checkoutAt: { gte: oneHourAgo } } }),
dbe.history.findFirst({
orderBy: { checkoutAt: "desc" },
select: {
id: true,
checkoutAt: true,
container: {
select: {
drink: {
select: {
name: true,
},
},
},
},
},
}),
dbe.user.findMany({
select: { id: true, username: true, type: true },
orderBy: { username: "asc" },
}),
]);
return json({
containersWithoutBarcode,
checkoutsLastHour,
latestCheckout,
users,
});
}
export async function action({ request }: ActionFunctionArgs) {
const { dbe } = await enhance(request);
const form = await request.formData();
const intent = form.get("intent")?.toString();
if (intent === "undo-last-checkout") {
const checkoutId = Number(form.get("checkoutId"));
if (!checkoutId) {
return json({ error: "No checkout selected." });
}
const checkout = await dbe.history.findUnique({
where: { id: checkoutId },
select: { container_id: true },
});
if (checkout) {
await dbe.container.update({
where: { id: checkout.container_id },
data: { inventory: { increment: 1 } },
});
await dbe.history.delete({ where: { id: checkoutId } });
}
return redirect("/admin");
}
if (intent === "add-user") {
const username = form.get("username")?.toString().trim();
const password = form.get("password")?.toString() ?? "";
const type = form.get("type")?.toString();
if (!username || !password) {
return json({ error: "Username and password are required." });
}
const normalizedType = (type === "Admin" || type === "Scanner") ? type : "User";
try {
await dbe.user.create({
data: {
username,
password: hashPassword(password),
type: normalizedType,
},
});
return json({ success: "User added" });
} catch {
return json({ error: "That username is already in use." });
}
}
if (intent === "set-password") {
const userId = Number(form.get("userId"));
const password = form.get("password")?.toString() ?? "";
if (!userId || !password) {
return json({ error: "A user and a password are required." });
}
const existingUser = await dbe.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!existingUser) {
return json({ error: "User not found." });
}
await dbe.user.update({
where: { id: userId },
data: { password: hashPassword(password) },
});
return json({ success: "Password set" });
}
return json({ error: "Unknown action." });
}
export default function AdminRoute() {
const loaderData = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
return (
<div>
<h1>Admin</h1>
<h2 className="mt-4">Dashboard</h2>
<Row className="g-3">
<Col md={4}>
<Card className="h-100">
<Card.Body>
<Card.Title>Containers without barcode</Card.Title>
<Card.Text className="display-6">{loaderData.containersWithoutBarcode}</Card.Text>
</Card.Body>
</Card>
</Col>
<Col md={4}>
<Card className="h-100">
<Card.Body>
<Card.Title>Checkouts in the last hour</Card.Title>
<Card.Text className="display-6">{loaderData.checkoutsLastHour}</Card.Text>
</Card.Body>
</Card>
</Col>
<Col md={4}>
<Card className="h-100">
<Card.Body>
<Card.Title>Last checked out</Card.Title>
<Card.Text>
{loaderData.latestCheckout?.container.drink.name ?? "No recent checkouts"}
</Card.Text>
{loaderData.latestCheckout ? (
<Form method="post">
<input type="hidden" name="intent" value="undo-last-checkout" />
<input type="hidden" name="checkoutId" value={loaderData.latestCheckout.id} />
<Button type="submit" variant="outline-danger">Undo</Button>
</Form>
) : null}
</Card.Body>
</Card>
</Col>
</Row>
<h2 className="mt-4">Quick actions</h2>
<ListGroup>
<ListGroup.Item><Link to="/admin/new">Add new items</Link></ListGroup.Item>
<ListGroup.Item><Link to="/admin/edit">Edit or remove items</Link></ListGroup.Item>
@@ -12,10 +175,80 @@ export default function AdminRoute() {
<ListGroup.Item><Link to="/admin/manage/undo-checkout">Undo checkout</Link></ListGroup.Item>
</ListGroup>
<h1>Reports</h1>
<h2 className="mt-4">Reports</h2>
<ListGroup>
<ListGroup.Item><Link to="/admin/reports">Reports</Link></ListGroup.Item>
</ListGroup>
<h2 className="mt-4">User management</h2>
{actionData?.error ? <Alert variant="danger" className="mb-3">{actionData.error}</Alert> : null}
{actionData?.success ? <Alert variant="success" className="mb-3">{actionData.success}</Alert> : null}
<Row className="g-3 mb-4">
<Col lg={6}>
<Card>
<Card.Body>
<Card.Title>Add user</Card.Title>
<Form method="post">
<input type="hidden" name="intent" value="add-user" />
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Username</BootstrapForm.Label>
<BootstrapForm.Control name="username" required />
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="password" required />
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Role</BootstrapForm.Label>
<BootstrapForm.Select name="type" defaultValue="User">
<option value="User">User</option>
<option value="Scanner">Scanner</option>
<option value="Admin">Admin</option>
</BootstrapForm.Select>
</BootstrapForm.Group>
<Button type="submit">Create user</Button>
</Form>
</Card.Body>
</Card>
</Col>
<Col lg={6}>
<Card>
<Card.Body>
<Card.Title>Set password</Card.Title>
<Form method="post">
<input type="hidden" name="intent" value="set-password" />
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>User</BootstrapForm.Label>
<BootstrapForm.Select name="userId" defaultValue={loaderData.users[0]?.id ?? ""}>
{loaderData.users.map((user) => (
<option key={user.id} value={user.id}>{user.username} ({user.type})</option>
))}
</BootstrapForm.Select>
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>New password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="password" required />
</BootstrapForm.Group>
<Button type="submit" variant="outline-primary">Update password</Button>
</Form>
</Card.Body>
</Card>
</Col>
</Row>
<Card className="mb-4">
<Card.Body>
<Card.Title>Existing users</Card.Title>
<ListGroup variant="flush">
{loaderData.users.map((user) => (
<ListGroup.Item key={user.id} className="d-flex justify-content-between align-items-center">
<span>{user.username}</span>
<span className="text-muted">{user.type}</span>
</ListGroup.Item>
))}
</ListGroup>
</Card.Body>
</Card>
</div>
);
}
}
+34 -23
View File
@@ -1,7 +1,6 @@
import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Outlet, json, useFetcher, useLoaderData, useRevalidator } from "@remix-run/react";
import { useEffect, useState } from "react";
import { getSession } from "~/auth/session";
import { Outlet, json, useLoaderData, useRevalidator } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import Header, { HeaderData } from "~/components/header";
import { enhance } from "~/utils/db.server";
@@ -32,18 +31,16 @@ export default function InventoryLayout() {
let revalidator = useRevalidator();
const [checkouts, setCheckouts] = useState(data.checkouts);
let fetcher = useFetcher();
let shouldFetch = true;
const shouldFetchRef = useRef(true);
// User has switched back to the tab
const onFocus = () => {
shouldFetch = true;
shouldFetchRef.current = true;
};
// User has switched away from the tab (AKA tab is hidden)
const onBlur = () => {
shouldFetch = false;
shouldFetchRef.current = false;
};
useEffect(() => {
@@ -59,30 +56,44 @@ export default function InventoryLayout() {
}, []);
useEffect(() => {
const timer = setTimeout(() => {
const pollCheckouts = async () => {
if (!shouldFetchRef.current) {
return;
}
if(shouldFetch){
fetcher.load("/resource/checkouts");
try {
const response = await fetch("/resource/checkouts", { credentials: "same-origin" });
if (!response.ok) {
return;
}
if(fetcher.state === "idle"){
if(!fetcher.data) return;
const payload = await response.json() as { checkouts?: number | string | null };
const newCheckouts = Number(payload.checkouts);
let newCheckouts = Number(fetcher.data.checkouts);
if(newCheckouts != checkouts){
if (!Number.isFinite(newCheckouts)) {
return;
}
setCheckouts(newCheckouts);
if (newCheckouts !== checkouts) {
setCheckouts(newCheckouts);
if (revalidator.state === "idle") {
revalidator.revalidate();
}
if (revalidator.state === "idle") {
revalidator.revalidate();
}
}
} catch {
// Ignore transient network errors while the connection is down.
}
};
const timer = window.setInterval(() => {
void pollCheckouts();
}, 5000);
return () => clearTimeout(timer);
});
void pollCheckouts();
return () => window.clearInterval(timer);
}, [checkouts, revalidator]);
return (
<>
+1 -5
View File
@@ -78,11 +78,7 @@ export default function BeersRoute() {
return;
}
if (key === "sort" && value === "name") {
return;
}
if ((key === "min-abv" && value === "0") || (key === "max-abv" && value === "50") || (key === "min-ibu" && value === "0") || (key === "max-ibu" && value === "150")) {
if (key === "sort" && value === "new") {
return;
}
+1 -1
View File
@@ -41,7 +41,7 @@ export default function InventoryLayout() {
setCheckouts(newCheckouts);
}
}, 5000);
}, 30000);
return () => clearTimeout(timer);
});
+159 -48
View File
@@ -1,7 +1,8 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useActionData, useLoaderData } from "@remix-run/react";
import { Col, Container, Form, Row, Table } from "react-bootstrap";
import { Form as RemixForm, useActionData, useLoaderData } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import { Col, Container, Form as BootstrapForm, Row, Table } from "react-bootstrap";
import timeAgo from "~/utils/datetime";
import { enhance } from "~/utils/db.server";
@@ -15,7 +16,7 @@ export async function loader({
return redirect("/");
}
const history = await dbe.history.findMany({select: {id: true, checkoutAt: true, inventoryAfter: true, container: {select: {drink: true}}}, take: 10, orderBy: {checkoutAt: "desc"}});
const history = await dbe.history.findMany({select: {id: true, checkoutAt: true, inventoryAfter: true, container: {select: {drink: true}}}, take: 5, orderBy: {checkoutAt: "desc"}});
return json({history});
}
@@ -27,9 +28,13 @@ export async function action({
const form = await request.formData();
const barcode = form.get("barcode")?.toString() || "";
const container = await dbe.container.findUnique({
select: {id: true, inventory: true, drink_id: true},
where: {barcode: barcode}
const container = await dbe.container.findUnique({
select: {
id: true,
inventory: true,
drink: { select: { name: true, image: true } },
},
where: { barcode: barcode },
});
// No drink found for barcode, do nothing
@@ -48,58 +53,164 @@ export async function action({
// Log entry
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() {
const aData = useActionData<typeof action>();
const lData = useLoaderData<typeof loader>();
const fullscreenBtnRef = useRef<HTMLButtonElement | null>(null);
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 focusInput = () => {
window.setTimeout(() => inputRef.current?.focus(), 0);
};
const toggleFullscreen = async () => {
if (typeof document === "undefined") return;
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await document.documentElement.requestFullscreen();
}
} catch (e) {
// ignore errors (user gesture required in some contexts)
}
};
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;
}
if (inputRef.current) {
inputRef.current.value = "";
}
setLastScanned(aData.lastScanned);
setShowLastScanned(true);
const timer = window.setTimeout(() => {
setShowLastScanned(false);
}, 5000);
return () => window.clearTimeout(timer);
}, [aData?.lastScanned]);
return (
<Container>
<Row className="mt-3">
<Col xs={4}>
<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>
<Container fluid className="mt-3">
<Row>
<Col xs={12} md={6} className="mb-3">
<h3 onClick={() => fullscreenBtnRef.current?.click()} style={{cursor: 'pointer'}}>History</h3>
<div>
<Table striped size="sm">
<thead>
<tr>
<th>Drink</th>
<th>When</th>
<th># Left</th>
</tr>
</thead>
<tbody>
{lData.history.map((entry) => (
<tr key={entry.id}>
<td>{entry.container.drink.name}</td>
<td>{timeAgo(new Date(entry.checkoutAt))}</td>
<td>{entry.inventoryAfter}</td>
</tr>
))}
</tbody>
</Table>
</div>
</Col>
</Row>
<Row className="mt-3">
<Col xs={12}>
<h3>History</h3>
<Table striped>
<thead>
<tr>
<th>Drink</th>
<th>When</th>
<th>Amount left</th>
</tr>
</thead>
<tbody>
{lData.history.map((entry) => (
<tr key={entry.id}>
<td>{entry.container.drink.name}</td>
<td>{timeAgo(new Date(entry.checkoutAt))}</td>
<td>{entry.inventoryAfter}</td>
</tr>
))}
</tbody>
</Table>
<Col xs={12} md={6}>
<h3 onClick={() => fullscreenBtnRef.current?.click()} style={{cursor: 'pointer'}}>Scan Barcode</h3>
<Row className="mb-3">
<Col>
{/* Hidden fullscreen toggle button - triggered by clicking the h3 headers */}
<button
type="button"
ref={fullscreenBtnRef}
onClick={toggleFullscreen}
style={{ display: 'none' }}
aria-hidden="true"
/>
<RemixForm method="post" action="/scan?index" noValidate>
<BootstrapForm.Group>
<BootstrapForm.Control
ref={inputRef}
type="text"
name="barcode"
isInvalid={hasError}
autoComplete="off"
spellCheck={false}
inputMode="text"
style={{ height: '3rem', fontSize: '1.8rem', textAlign: 'center' }}
/>
<BootstrapForm.Control.Feedback type="invalid">
{aData?.error}
</BootstrapForm.Control.Feedback>
</BootstrapForm.Group>
</RemixForm>
</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>
</Row>
</Container>
+118
View File
@@ -0,0 +1,118 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useLoaderData } from "@remix-run/react";
import { Alert, Button, Card, Col, Container, Form as BootstrapForm, Row } from "react-bootstrap";
import { hashPassword, verifyPassword } from "~/auth/validate";
import { enhance } from "~/utils/db.server";
export async function loader({ request }: LoaderFunctionArgs) {
const { session, dbe } = await enhance(request);
if (!session.has("user_id")) {
return redirect("/login");
}
const user = await dbe.user.findUnique({
where: { id: Number(session.get("user_id")) },
select: { id: true, username: true, type: true },
});
if (!user) {
return redirect("/logout");
}
return json({ user });
}
export async function action({ request }: ActionFunctionArgs) {
const { session, dbe } = await enhance(request);
if (!session.has("user_id")) {
return redirect("/login");
}
const userId = Number(session.get("user_id"));
const form = await request.formData();
const currentPassword = form.get("currentPassword")?.toString() ?? "";
const newPassword = form.get("newPassword")?.toString() ?? "";
const confirmPassword = form.get("confirmPassword")?.toString() ?? "";
const user = await dbe.user.findUnique({
where: { id: userId },
select: { id: true, password: true },
});
if (!user) {
return json({ error: "User not found." }, { status: 404 });
}
const passwordMatches = verifyPassword(currentPassword, user.password);
if (!passwordMatches) {
return json({ error: "Current password is incorrect." });
}
if (newPassword.length < 4) {
return json({ error: "New password must be at least 4 characters long." });
}
if (newPassword !== confirmPassword) {
return json({ error: "New passwords do not match." });
}
await dbe.user.update({
where: { id: user.id },
data: { password: hashPassword(newPassword) },
});
return json({ success: "Password updated successfully." });
}
export default function UsersRoute() {
const loaderData = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
return (
<Container className="py-3">
<Row className="justify-content-center">
<Col md={8} lg={6}>
<Card>
<Card.Body>
<Card.Title>Account settings</Card.Title>
<Card.Subtitle className="mb-3 text-muted">
Manage the password for {loaderData.user.username}
</Card.Subtitle>
{actionData?.error ? (
<Alert variant="danger">{actionData.error}</Alert>
) : null}
{actionData?.success ? (
<Alert variant="success">{actionData.success}</Alert>
) : null}
<Form method="post">
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Current password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="currentPassword" required />
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>New password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="newPassword" required />
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Confirm new password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="confirmPassword" required />
</BootstrapForm.Group>
<Button type="submit">Save password</Button>
</Form>
</Card.Body>
</Card>
</Col>
</Row>
</Container>
);
}
+4 -4
View File
@@ -34,15 +34,15 @@ export async function createPdf(filename: string, reportId: string, dateStart: s
doc.setFontSize(10);
doc.text("Selected date range: " + dateStart.slice(0, -8).replace("T", "") + " - " + dateEnd.slice(0,-8).replace("T", ""), 15, 35);
doc.autoTable({
const table = doc.autoTable({
startY: 40,
head: [['Drink', 'Price', 'Amount', 'Total']],
body: body,
foot: [["", "", "Total", "€" + (Math.round(total*100)/100)]]
});
let finalY = doc.autoTable.previous.finalY;
const finalY = table?.lastAutoTable?.finalY ?? 40;
doc.text("Generated on " + new Date(Date.now()).toString(), 15, finalY + 10);
const filepath = "/reports/" + filename + ".pdf";
+1 -1
View File
@@ -181,5 +181,5 @@ model Report {
name String @unique()
dateStart DateTime
dateEnd DateTime
file String
file String?
}
+7 -2
View File
@@ -1,13 +1,18 @@
import { ContainerType, PrismaClient, UserType } from "@prisma/client";
import { enhance } from "@zenstackhq/runtime";
import { BeerStyle, Country, Manufacturer, Section, Soda, User, Wine, WineStyle } from "@zenstackhq/runtime/models";
import { hashPassword } from "../app/auth/validate";
const pr = new PrismaClient();
async function seed() {
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 initialPassword = hashPassword("asdf1239");
await pr.user.upsert({ create: {username: "kenneth", password: initialPassword, type: UserType.Admin}, update: {password: initialPassword}, where: {username: "kenneth"} });
await pr.user.upsert({ create: {username: "scanner", password: initialPassword, type: UserType.Scanner}, update: {password: initialPassword}, where: {username: "scanner"} });
const user = await pr.user.findUnique({where: {username: "kenneth"}}) || undefined;
// Delete all sessions
+2 -2
View File
@@ -205,7 +205,7 @@ model History {
model User {
id Int @id @default(autoincrement())
username String @unique
password String @password @omit
password String
type UserType
sessions Session[]
@@ -243,7 +243,7 @@ model Report {
dateStart DateTime
dateEnd DateTime
file String
file String?
@@allow('all', auth().type == Admin)
}