5 Commits
Author SHA1 Message Date
kennyboy55 062bdbd16e Add support for Brands: introduce prisma migrate
Build dev docker image / build (push) Successful in 25s
2026-08-07 11:01:27 +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
22 changed files with 1086 additions and 107 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`
+36
View File
@@ -0,0 +1,36 @@
name: Build dev docker image
on:
push:
branches:
- main
- feature/*
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
+5
View File
@@ -18,3 +18,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
+2 -1
View File
@@ -13,9 +13,10 @@ COPY . .
ENV NODE_ENV=production
ENV DATABASE_URL="REQUIRED"
RUN chmod +x ./migrate.sh
RUN npx zenstack generate
EXPOSE 3000
VOLUME /usr/src/app/public
CMD ["/bin/sh", "-c", "npx prisma db push && npm start"]
CMD ["/bin/sh", "-c", "./migrate.sh && npm start"]
+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;
}
+7 -7
View File
@@ -69,10 +69,10 @@ function DrinkFilter(arg : Arguments) {
["name", "Name"],
["new", "New"],
["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,7 +86,7 @@ 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>
<Form.Label className='fw-bold'>Drink Type</Form.Label>
{ ["Beer", "Wine", "Soda", "Cocktail"].map(key => (
<Form.Check
id={"drink-" + key}
@@ -100,8 +100,8 @@ function DrinkFilter(arg : Arguments) {
</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>
+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>
) : (
+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();
}, 60 * 60 * 1000);
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 hour.</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 hour.</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 (
<>
+151 -45
View File
@@ -1,6 +1,7 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useActionData, useLoaderData } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import { Col, Container, Form, Row, Table } from "react-bootstrap";
import timeAgo from "~/utils/datetime";
@@ -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,159 @@ 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;
}
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>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>
</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
ref={fullscreenBtnRef}
onClick={toggleFullscreen}
style={{ display: 'none' }}
aria-hidden="true"
/>
<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>
</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";
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
npx prisma migrate resolve --applied 0_init
npx prisma migrate deploy
+245
View File
@@ -0,0 +1,245 @@
-- CreateEnum
CREATE TYPE "ContainerType" AS ENUM ('BeerBottle', 'WineBottle', 'PlasticBottle', 'Can', 'Carton', 'Keg');
-- CreateEnum
CREATE TYPE "DrinkType" AS ENUM ('Drink', 'Beer', 'Wine', 'Soda', 'Cocktail');
-- CreateEnum
CREATE TYPE "UserType" AS ENUM ('User', 'Scanner', 'Admin');
-- CreateTable
CREATE TABLE "Beer" (
"id" INTEGER NOT NULL,
"style_id" INTEGER NOT NULL,
"ibu" DOUBLE PRECISION,
"glass" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "Beer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BeerStyle" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL,
CONSTRAINT "BeerStyle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Cocktail" (
"id" INTEGER NOT NULL,
"mix" BOOLEAN NOT NULL,
CONSTRAINT "Cocktail_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Container" (
"id" SERIAL NOT NULL,
"barcode" TEXT,
"drink_id" INTEGER NOT NULL,
"section_id" INTEGER NOT NULL,
"type" "ContainerType" NOT NULL,
"volume" INTEGER NOT NULL,
"portions" INTEGER,
"price" DOUBLE PRECISION NOT NULL DEFAULT 0.0,
"inventory" INTEGER NOT NULL DEFAULT 0,
"lastAdded" TIMESTAMP(3),
CONSTRAINT "Container_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Country" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Country_pkey" PRIMARY KEY ("code")
);
-- CreateTable
CREATE TABLE "Drink" (
"id" SERIAL NOT NULL,
"slug" TEXT NOT NULL,
"manufacturer_id" INTEGER NOT NULL,
"type" "DrinkType" NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"abv" DOUBLE PRECISION NOT NULL,
"image" TEXT,
"link" TEXT,
"gluten" BOOLEAN NOT NULL,
"lactose" BOOLEAN NOT NULL,
"organic" BOOLEAN NOT NULL,
"sugar" BOOLEAN NOT NULL DEFAULT true,
"addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Drink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "History" (
"id" SERIAL NOT NULL,
"container_id" INTEGER NOT NULL,
"checkoutAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"inventoryAfter" INTEGER NOT NULL,
CONSTRAINT "History_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Manufacturer" (
"id" SERIAL NOT NULL,
"country_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"image" TEXT,
CONSTRAINT "Manufacturer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Report" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"dateStart" TIMESTAMP(3) NOT NULL,
"dateEnd" TIMESTAMP(3) NOT NULL,
"file" TEXT,
"name" TEXT NOT NULL,
CONSTRAINT "Report_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Section" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Section_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"user_id" INTEGER,
"data" TEXT NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Soda" (
"id" INTEGER NOT NULL,
"carbonated" BOOLEAN NOT NULL,
CONSTRAINT "Soda_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Suggestion" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT,
"content" TEXT NOT NULL,
"resolved" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "Suggestion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"type" "UserType" NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Wine" (
"id" INTEGER NOT NULL,
"style_id" INTEGER NOT NULL,
"heavy_score" INTEGER,
"tannine_score" INTEGER,
"dry_score" INTEGER,
"fresh_score" INTEGER,
"notes" TEXT,
CONSTRAINT "Wine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WineStyle" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL,
CONSTRAINT "WineStyle_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BeerStyle_name_key" ON "BeerStyle"("name" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "Container_barcode_key" ON "Container"("barcode" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "Drink_name_key" ON "Drink"("name" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "Drink_slug_key" ON "Drink"("slug" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "Manufacturer_name_key" ON "Manufacturer"("name" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "Report_name_key" ON "Report"("name" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "Section_name_key" ON "Section"("name" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "WineStyle_name_key" ON "WineStyle"("name" ASC);
-- AddForeignKey
ALTER TABLE "Beer" ADD CONSTRAINT "Beer_id_fkey" FOREIGN KEY ("id") REFERENCES "Drink"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Beer" ADD CONSTRAINT "Beer_style_id_fkey" FOREIGN KEY ("style_id") REFERENCES "BeerStyle"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Cocktail" ADD CONSTRAINT "Cocktail_id_fkey" FOREIGN KEY ("id") REFERENCES "Drink"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Container" ADD CONSTRAINT "Container_drink_id_fkey" FOREIGN KEY ("drink_id") REFERENCES "Drink"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Container" ADD CONSTRAINT "Container_section_id_fkey" FOREIGN KEY ("section_id") REFERENCES "Section"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Drink" ADD CONSTRAINT "Drink_manufacturer_id_fkey" FOREIGN KEY ("manufacturer_id") REFERENCES "Manufacturer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "History" ADD CONSTRAINT "History_container_id_fkey" FOREIGN KEY ("container_id") REFERENCES "Container"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Manufacturer" ADD CONSTRAINT "Manufacturer_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "Country"("code") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Soda" ADD CONSTRAINT "Soda_id_fkey" FOREIGN KEY ("id") REFERENCES "Drink"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Wine" ADD CONSTRAINT "Wine_id_fkey" FOREIGN KEY ("id") REFERENCES "Drink"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Wine" ADD CONSTRAINT "Wine_style_id_fkey" FOREIGN KEY ("style_id") REFERENCES "WineStyle"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,28 @@
/*
Warnings:
- Added the required column `brand_id` to the `Drink` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
CREATE TABLE "Brand" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"image" TEXT,
CONSTRAINT "Brand_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Brand_name_key" ON "Brand"("name");
-- Add default brand
INSERT INTO "Brand" ("name") VALUES ('Default');
-- AlterTable
ALTER TABLE "Drink" ADD COLUMN "brand_id" INTEGER;
UPDATE "Drink" SET "brand_id" = (SELECT "id" FROM "Brand" WHERE "name" = 'Default');
ALTER TABLE "Drink" ALTER COLUMN "brand_id" SET NOT NULL;
-- AddForeignKey
ALTER TABLE "Drink" ADD CONSTRAINT "Drink_brand_id_fkey" FOREIGN KEY ("brand_id") REFERENCES "Brand"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+10 -1
View File
@@ -40,6 +40,8 @@ model Drink {
slug String @unique()
manufacturer_id Int
manufacturer Manufacturer @relation(fields: [manufacturer_id], references: [id])
brand_id Int
brand Brand @relation(fields: [brand_id], references: [id])
type DrinkType
name String @unique()
description String
@@ -137,6 +139,13 @@ model Manufacturer {
drinks Drink[]
}
model Brand {
id Int @id() @default(autoincrement())
name String @unique()
image String?
drinks Drink[]
}
model Country {
code String @id()
name String
@@ -181,5 +190,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
+17 -2
View File
@@ -40,6 +40,9 @@ model Drink {
manufacturer_id Int
manufacturer Manufacturer @relation(fields: [manufacturer_id], references: [id])
brand_id Int
brand Brand @relation(fields: [brand_id], references: [id])
type DrinkType
name String @unique
@@ -177,6 +180,18 @@ model Manufacturer {
@@allow('all', auth().type == Admin)
}
model Brand {
id Int @id @default(autoincrement())
name String @unique
image String?
drinks Drink[]
@@allow('read', true)
@@allow('all', auth().type == Admin)
}
model Country {
code String @id
name String
@@ -205,7 +220,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 +258,7 @@ model Report {
dateStart DateTime
dateEnd DateTime
file String
file String?
@@allow('all', auth().type == Admin)
}