8 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
kennyboy55 b8a20204c7 AI Changes: updated look, recently added, search bar, easy admin checkout
Build dev docker image / build (push) Successful in 36s
Build dev docker image / release (push) Successful in 1s
2026-07-31 21:12:57 +02:00
kennyboy55 4d191c448d Add gitea workflow 2026-07-31 20:30:47 +02:00
kennyboy55 45b655e2b4 Copy images instead of move to fix docker EXDEV error 2025-12-30 11:09:54 +01:00
46 changed files with 1621 additions and 14143 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
node_modules
npm-debug.log
npm-debug.log
.gitea/
+87
View File
@@ -0,0 +1,87 @@
name: Build dev docker image
on:
push:
tags:
- '*'
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
# Login to registry
- name: Gitea package registry login
run: |
echo "${{ secrets.ACCESS_TOKEN }}" | docker login gitea.furb.it \
-u "${{ secrets.USERNAME }}" \
--password-stdin
# Build image
- name: Build Docker image - gitea.furb.it/${{ gitea.repository }}:${{ gitea.ref_name }}
run: |
docker build \
-f Dockerfile \
--label beer-inventory.ref=${{ gitea.ref_name }} \
-t gitea.furb.it/${{ gitea.repository }}:${{ gitea.ref_name }} \
.
- name: Tag Docker image - gitea.furb.it/${{ gitea.repository }}:latest
if: startsWith(gitea.ref, 'refs/tags/')
run: |
docker image tag \
gitea.furb.it/${{ gitea.repository }}:${{ gitea.ref_name }} \
gitea.furb.it/${{ gitea.repository }}:latest
# Push images
- name: Push Docker image
run: |
docker push gitea.furb.it/${{ gitea.repository }}:${{ gitea.ref_name }}
- name: Push Docker image :latest
if: startsWith(gitea.ref, 'refs/tags/')
run: |
docker push gitea.furb.it/${{ gitea.repository }}:latest
release:
runs-on: ubuntu-latest
needs: build
if: startsWith(gitea.ref, 'refs/tags/')
steps:
# Create release using gitea-release-action
- name: Create release
uses: akkuman/gitea-release-action@v1
with:
server: "https://gitea.furb.it"
token: ${{ secrets.ACCESS_TOKEN }}
repository: ${{ gitea.repository }}
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`
+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
+25
View File
@@ -0,0 +1,25 @@
Beer-inventory project for home use.
Used technology:
- remix.run (now known as react router)
- prisma
- zenstack
Limitations:
- Dependencies cannot be updated
- Project needs to work locally without online dependencies
- Project needs to work with existing database
- The project is turned off when not in use, so needs to be started quickly when needed
Architecture:
- server-client together in the same file, as per remix-run
- react for frontend
- three levels
- 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
+3 -2
View File
@@ -1,5 +1,5 @@
FROM node:22-alpine
MAINTAINER Kenneth van Ewijk (kennyboy55)
LABEL maintainer="Kenneth van Ewijk (kennyboy55)"
WORKDIR /usr/src/app
@@ -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;
}
+55 -23
View File
@@ -7,6 +7,15 @@ interface Arguments {
drink: DrinkComposite
}
function toDate(value: Date | string | null | undefined): Date | null {
if (!value) {
return null;
}
const parsed = value instanceof Date ? value : new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function DrinkCard(arg:Arguments) {
var trimmedDescription = arg.drink.description.length > 120 ? arg.drink.description.substring(0, 120) + "..." : arg.drink.description;
var link = "/inventory/";
@@ -39,35 +48,58 @@ function DrinkCard(arg:Arguments) {
ibu = (<Badge bg={arg.drink.ibu > 45 ? "warning" : "secondary"}>IBU: {arg.drink.ibu}</Badge>);
}
var manufacturer = "";
if(isDrinkWithManufacturer(arg.drink)){
manufacturer = arg.drink.manufacturer.name + " (" + arg.drink.manufacturer.country_id + ")";
}
var inventory = "";
let inventoryNum = 0;
let latestActivity: Date | null = null;
if(isDrinkWithContainers(arg.drink)){
let inventoryNum = 0;
arg.drink.containers.map((container) => {
arg.drink.containers.forEach((container: { inventory: number; lastAdded?: Date | string | null }) => {
inventoryNum += container.inventory;
})
inventory = "Inventory: " + inventoryNum;
const candidate = toDate(container.lastAdded);
if (candidate && (!latestActivity || candidate.getTime() > latestActivity.getTime())) {
latestActivity = candidate;
}
});
}
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 isRecentlyRestocked = latestActivity ? (Date.now() - latestActivity.getTime()) <= 14 * 24 * 60 * 60 * 1000 : false;
return (
<Card key={arg.drink.slug} style={{ borderColor: `${borderColor}`, borderWidth: '2px' }} className='mb-2'>
<Card.Header className="text-muted">{manufacturer}</Card.Header>
<Card.Img variant="top" className='image-fluid' src={arg.drink.image ? arg.drink.image : ""} />
<Card.Body>
<Card.Title><Link to={link + arg.drink.slug.toString()} className='stretched-link text-reset text-decoration-none'>{arg.drink.name}</Link></Card.Title>
<Card.Text>
{trimmedDescription}
</Card.Text>
<Card key={arg.drink.slug} style={{ borderColor: `${borderColor}`, borderWidth: '2px' }} className='mb-2 h-100'>
<div style={{ position: 'relative', width: '100%', aspectRatio: '1 / 1', overflow: 'hidden' }}>
{ (isNewlyAdded || isRecentlyRestocked) ? (
<Badge
bg={isRecentlyRestocked ? "success" : "primary"}
className='position-absolute top-0 start-0 m-2 text-wrap'
style={{ zIndex: 2 }}
>
{isNewlyAdded && isRecentlyRestocked ? "New · Restocked" : isNewlyAdded ? "New" : "Recently added"}
</Badge>
) : null }
<Card.Img
variant="top"
className='image-fluid'
src={arg.drink.image ? arg.drink.image : ""}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
<Card.Body className='d-flex flex-column justify-content-between py-3'>
<Card.Title className='h6 mb-2'><Link to={link + arg.drink.slug.toString()} className='stretched-link text-reset text-decoration-none'>{arg.drink.name}</Link></Card.Title>
<div className='d-flex flex-wrap gap-1 align-items-center'>
<Badge bg={arg.drink.abv > 0 ? "warning" : "secondary"} className='py-1'>ABV: {arg.drink.abv}%</Badge>
{style}
{ibu}
{arg.drink.sugar ? "" : <Badge bg="success" className='py-1'>Sugar-free</Badge>}
{arg.drink.gluten ? "" : <Badge bg="success" className='py-1'>Gluten-free</Badge>}
{arg.drink.organic ? <Badge bg="success" className='py-1'>Organic</Badge> : ""}
</div>
</Card.Body>
<ListGroup variant="flush">
<ListGroup.Item><Badge bg={arg.drink.abv > 0 ? "warning" : "secondary"}>ABV: {arg.drink.abv}%</Badge> {style} {ibu} {arg.drink.sugar ? "" : <Badge bg="success">Sugar-free</Badge>} {arg.drink.gluten ? "" : <Badge bg="success">Gluten-free</Badge>} {arg.drink.organic ? <Badge bg="success">Organic</Badge> : ""}</ListGroup.Item>
</ListGroup>
<Card.Footer className="text-muted">{inventory}</Card.Footer>
<Card.Footer className="text-center py-2">
<Badge bg="dark" className='px-3 py-2 fs-6'>Inventory: {inventoryNum}</Badge>
</Card.Footer>
</Card>
);
}
+4 -2
View File
@@ -53,7 +53,6 @@ function DrinkPage(arg:Arguments) {
}
})
}
var link = (<></>);
if(arg.drink.link){
var href = arg.drink.link;
@@ -184,7 +183,10 @@ function DrinkPage(arg:Arguments) {
{arg.isAdmin ? (
<>
<td>{container.price}</td>
<td><LinkContainer to={"/admin/edit/container/" + container.id}><Button variant='primary'>Edit</Button></LinkContainer></td>
<td>
<LinkContainer to={"/admin/edit/container/" + container.id}><Button variant='primary'>Edit</Button></LinkContainer>
<LinkContainer to={"/admin/manage/manual-checkout/" + container.id}><Button variant='secondary'>Checkout</Button></LinkContainer>
</td>
</>
) : ""}
</tr>
+39 -53
View File
@@ -2,9 +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, Button } from 'react-bootstrap';
import ReactSlider from 'react-slider';
import { ManufacturerWithDrinks } from '~/models/types';
import { Form, Col, Row } from 'react-bootstrap';
interface Arguments {
beerStyles: BeerStyle[];
@@ -16,8 +14,6 @@ interface Arguments {
function DrinkFilter(arg : Arguments) {
const submit = useSubmit();
const [abv, setAbv] = useState({min: Number(arg.searchParams.get("min-abv") || 0), max: Number(arg.searchParams.get("max-abv") || 50)})
const [ibu, setIbu] = useState({min: Number(arg.searchParams.get("min-ibu") || 0), max: Number(arg.searchParams.get("max-ibu") || 150)})
const showBeerFilter = arg.searchParams.has("drink", "Beer");
const showWineFilter = arg.searchParams.has("drink", "Wine");
@@ -30,59 +26,53 @@ function DrinkFilter(arg : Arguments) {
setTimeout(() => submit(form.current), 10);
}
const sliderAbv = () =>
(<div>
<ReactSlider
className="form-range"
thumbClassName="bg-primary text-light rounded dummy"
trackClassName="bg-secondary h-100 rounded dummy"
min={0}
max={50}
defaultValue={[abv.min, abv.max]}
renderThumb={(props, state) => <div {...props}>{state.valueNow}%</div>}
pearling
minDistance={2}
onAfterChange={(number, index) => {setAbv({min: number[0], max: number[1]}); submitAfterRender()}}
/>
<input type="hidden" name="min-abv" value={abv.min} />
<input type="hidden" name="max-abv" value={abv.max} />
</div>);
const handleFormChange = (event: React.ChangeEvent<HTMLFormElement>) => {
const formData = new FormData(event.currentTarget);
const sliderIbu = () =>
(<div>
<ReactSlider
className="form-range"
thumbClassName="bg-primary text-light rounded dummy"
trackClassName="bg-secondary h-100 rounded dummy"
min={0}
max={150}
defaultValue={[ibu.min, ibu.max]}
renderThumb={(props, state) => <div {...props}>{state.valueNow}</div>}
pearling
minDistance={2}
onAfterChange={(number, index) => {setIbu({min: number[0], max: number[1]}); submitAfterRender()}}
/>
<input type="hidden" name="min-ibu" value={ibu.min} />
<input type="hidden" name="max-ibu" value={ibu.max} />
</div>);
if (formData.get("abv") === "all") {
formData.delete("abv");
}
submit(formData, { method: "get", replace: true });
}
const abvSelection = arg.searchParams.get("abv") || "all";
const abvOptions = [
{ value: "all", label: "All" },
{ value: "alcohol-free", label: "Alcohol free" },
{ value: "1-5", label: "1-5%" },
{ value: "5-10", label: "5-10%" },
{ value: "10-plus", label: "10% +" },
];
const abvOptionsMarkup = abvOptions.map((option) => (
<Form.Check
key={option.value}
id={"abv-" + option.value}
type="radio"
label={option.label}
name="abv"
value={option.value}
defaultChecked={abvSelection === option.value}
/>
));
const sortKey = arg.searchParams.get("sort") || "name";
return (
<Form noValidate method="GET" ref={form} className='pe-2' onChange={(event) => {submit(event.currentTarget); }}>
<Form noValidate method="GET" ref={form} className='pe-2' onChange={handleFormChange}>
<Row>
<Col>
<Form.Group controlId="sort" className='bg-body-secondary rounded p-3'>
<Form.Label className='fw-bold'>Sort</Form.Label>
{ [
["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]}
@@ -96,7 +86,7 @@ const sliderIbu = () =>
</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}
@@ -110,8 +100,8 @@ const sliderIbu = () =>
</Form.Group>
<Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>ABV</Form.Label>
{sliderAbv()}
<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>
@@ -141,10 +131,6 @@ const sliderIbu = () =>
/>
))}
</Form.Group>
<Form.Group controlId="beer-ibu" className='bg-body-secondary rounded p-3 mt-3'>
<Form.Label className='fw-bold'>IBU</Form.Label>
{sliderIbu()}
</Form.Group>
</>
) : ""}
{ showWineFilter ? (
+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>
) : (
+39 -9
View File
@@ -8,9 +8,14 @@ export async function findDrinksFromSearch(searchParams: URLSearchParams) : Prom
const dbe = enhance(db);
var result : DrinkComposite[] = [];
var noDrinkSelected = searchParams.getAll("drink").length == 0;
let whereBase = {delegate_aux_drink: {AND: <any>[]}};
var noDrinkSelected = searchParams.getAll("drink").length == 0;
const searchQuery = searchParams.get("q")?.trim();
const searchClauses = searchQuery ? [
{ name: {contains: searchQuery, mode: "insensitive"} },
{ manufacturer: { name: {contains: searchQuery, mode: "insensitive"} } },
{ manufacturer: { country: { name: {contains: searchQuery, mode: "insensitive"} } } },
] : [];
let whereBase: any = {delegate_aux_drink: {AND: []}};
if(searchParams.has("sugar-free")){
whereBase.delegate_aux_drink.AND.push({NOT: {sugar: true}});
@@ -25,13 +30,18 @@ export async function findDrinksFromSearch(searchParams: URLSearchParams) : Prom
whereBase.delegate_aux_drink.AND.push({organic: true});
}
if(searchParams.has("min-abv")){
let minabv = Number(searchParams.get("min-abv"));
whereBase.delegate_aux_drink.AND.push({abv: {gte: minabv}});
const abvFilter = searchParams.get("abv");
if(abvFilter === "alcohol-free"){
whereBase.delegate_aux_drink.AND.push({abv: {lt: 1}});
}
if(searchParams.has("max-abv")){
let maxabv = Number(searchParams.get("max-abv"));
whereBase.delegate_aux_drink.AND.push({abv: {lte: maxabv}});
if(abvFilter === "1-5"){
whereBase.delegate_aux_drink.AND.push({abv: {gte: 1, lt: 5}});
}
if(abvFilter === "5-10"){
whereBase.delegate_aux_drink.AND.push({abv: {gte: 5, lt: 10}});
}
if(abvFilter === "10-plus"){
whereBase.delegate_aux_drink.AND.push({abv: {gte: 10}});
}
if(searchParams.has("manufacturer")){
@@ -49,6 +59,14 @@ export async function findDrinksFromSearch(searchParams: URLSearchParams) : Prom
let where = Object.assign({}, whereBase, {style: {}, OR: <any>[]});
if(searchQuery){
const beerSearchClauses = [...searchClauses];
if(searchParams.has("drink", "Beer")){
beerSearchClauses.push({ style: { name: {contains: searchQuery, mode: "insensitive"} } });
}
where.AND = [{ OR: beerSearchClauses }];
}
// Beer styles
if(searchParams.has("beerstyle")){
where.style = {id: {in: searchParams.getAll("beerstyle").map(Number)}};
@@ -81,6 +99,10 @@ export async function findDrinksFromSearch(searchParams: URLSearchParams) : Prom
let where = Object.assign({}, whereBase, {style: {}});
if(searchQuery){
where.AND = [{ OR: searchClauses }];
}
// Wine styles
if(searchParams.has("winestyle")){
where.style = {id: {in: searchParams.getAll("winestyle").map(Number)}};
@@ -96,6 +118,10 @@ export async function findDrinksFromSearch(searchParams: URLSearchParams) : Prom
let where = whereBase;
if(searchQuery){
where.AND = [{ OR: searchClauses }];
}
// Carbonated
if(searchParams.get("carbonated") == "1"){
where = Object.assign({}, whereBase, {carbonated: true});
@@ -113,6 +139,10 @@ export async function findDrinksFromSearch(searchParams: URLSearchParams) : Prom
if(searchParams.has("drink", "Cocktail") || noDrinkSelected){
let where = whereBase;
if(searchQuery){
where.AND = [{ OR: searchClauses }];
}
// Mix
if(searchParams.has("mix")){
where = Object.assign({}, whereBase, {mix: true});
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { sortDrinksFromSearch } from './drinks.sort.server';
test('sorts drinks by newest activity when using the new sort option', () => {
const params = new URLSearchParams('sort=new');
const olderDrink = {
id: 1,
name: 'Zeta',
type: 'Beer',
abv: 5,
description: '',
slug: 'zeta',
manufacturer_id: 1,
image: null,
link: null,
gluten: false,
lactose: false,
organic: false,
sugar: true,
addedAt: new Date('2024-01-01T00:00:00.000Z'),
containers: [],
};
const newerDrink = {
id: 2,
name: 'Alpha',
type: 'Beer',
abv: 4.5,
description: '',
slug: 'alpha',
manufacturer_id: 1,
image: null,
link: null,
gluten: false,
lactose: false,
organic: false,
sugar: true,
addedAt: new Date('2024-02-01T00:00:00.000Z'),
containers: [
{
inventory: 3,
lastAdded: new Date('2024-03-01T00:00:00.000Z'),
},
],
};
const sorted = sortDrinksFromSearch(params, [olderDrink, newerDrink] as any);
assert.equal(sorted[0].id, 2);
assert.equal(sorted[1].id, 1);
});
+42
View File
@@ -1,5 +1,25 @@
import { DrinkComposite, isDrinkWithContainers, isDrinkWithContainersAndHistory } from "./types";
function getLatestActivityTimestamp(n: DrinkComposite): Date | null {
const drinkTimestamp = (n as { addedAt?: Date | null }).addedAt ?? null;
if (isDrinkWithContainers(n)) {
const containerTimestamps = n.containers
.map((container) => (container as { lastAdded?: Date | null }).lastAdded)
.filter((value): value is Date => value != null);
if (containerTimestamps.length > 0) {
const latestContainerTimestamp = containerTimestamps.reduce((latest, current) => current > latest ? current : latest, containerTimestamps[0]);
if (drinkTimestamp == null) {
return latestContainerTimestamp;
}
return drinkTimestamp > latestContainerTimestamp ? drinkTimestamp : latestContainerTimestamp;
}
}
return drinkTimestamp;
}
function sortName(n1:DrinkComposite, n2:DrinkComposite) : number{
if (n1.name > n2.name) {
return 1;
@@ -56,6 +76,24 @@ function sortPopularity(n1:DrinkComposite, n2:DrinkComposite) : number{
return totalPopularity(n2) - totalPopularity(n1);
}
function sortNewest(n1:DrinkComposite, n2:DrinkComposite) : number{
const left = getLatestActivityTimestamp(n1);
const right = getLatestActivityTimestamp(n2);
if (!left && !right) {
return 0;
}
if (!left) {
return 1;
}
if (!right) {
return -1;
}
return right.getTime() - left.getTime();
}
export function sortDrinksFromSearch(searchParams: URLSearchParams, searchResult: DrinkComposite[]) : DrinkComposite[] {
@@ -85,6 +123,10 @@ 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();
}, 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>
+4
View File
@@ -29,6 +29,9 @@ export const action = async ({
const inventory = Number(form.get("inventory"));
try{
const currentContainer = await dbe.container.findUnique({where: {id: id}, select: {inventory: true, lastAdded: true}});
const shouldSetLastAdded = currentContainer != null && inventory > currentContainer.inventory;
const updatedContainer = await dbe.container.update({ data: {
barcode: barcode,
type: type,
@@ -36,6 +39,7 @@ export const action = async ({
portions: portions,
price: price,
inventory: inventory,
lastAdded: shouldSetLastAdded ? new Date() : currentContainer?.lastAdded ?? null,
//drink: {connect: {id: drink}},
section: {connect: {id: section}}
}, select: {drink: {select: {id: true, type: true}}},
+4 -3
View File
@@ -1,6 +1,6 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { LoaderFunctionArgs, NodeOnDiskFile, json, redirect, unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler, unstable_parseMultipartFormData } from "@remix-run/node";
import { renameSync, rmSync } from "node:fs";
import { copyFileSync, rmSync } from "node:fs";
import { useLoaderData } from "@remix-run/react";
import { Container, Row, Button, Form, Image } from "react-bootstrap";
@@ -38,8 +38,9 @@ export const action = async ({
// Remove old image
if(beer.image) rmSync("public"+ beer.image, {force: true});
// Move the file to the beers folder with the slug as name
renameSync(image.getFilePath(), "public" + newFilename);
// Copy the file to the beers folder with the slug as name
copyFileSync(image.getFilePath(), "public" + newFilename);
rmSync(image.getFilePath(), {force: true});
await dbe.beer.update({data: {image: newFilename}, where: {id: id}});
+4 -3
View File
@@ -1,6 +1,6 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { LoaderFunctionArgs, NodeOnDiskFile, json, redirect, unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler, unstable_parseMultipartFormData } from "@remix-run/node";
import { renameSync, rmSync } from "node:fs";
import { copyFileSync, rmSync } from "node:fs";
import { useLoaderData } from "@remix-run/react";
import { Container, Row, Button, Form, Image } from "react-bootstrap";
@@ -38,8 +38,9 @@ export const action = async ({
// Remove old image
if(cocktail.image) rmSync("public"+ cocktail.image, {force: true});
// Move the file to the cocktails folder with the slug as name
renameSync(image.getFilePath(), "public" + newFilename);
// Copy the file to the cocktails folder with the slug as name
copyFileSync(image.getFilePath(), "public" + newFilename);
rmSync(image.getFilePath(), {force: true});
await dbe.cocktail.update({data: {image: newFilename}, where: {id: id}});
@@ -1,6 +1,6 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { LoaderFunctionArgs, NodeOnDiskFile, json, redirect, unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler, unstable_parseMultipartFormData } from "@remix-run/node";
import { renameSync, rmSync } from "node:fs";
import { copyFileSync, rmSync } from "node:fs";
import { useLoaderData } from "@remix-run/react";
import { Container, Row, Button, Form, Image } from "react-bootstrap";
@@ -38,8 +38,9 @@ export const action = async ({
// Remove old image
if(manufacturer.image) rmSync("public"+ manufacturer.image, {force: true});
// Move the file to the manufacturers folder with the slug as name
renameSync(image.getFilePath(), "public" + newFilename);
// Copy the file to the manufacturers folder with the slug as name
copyFileSync(image.getFilePath(), "public" + newFilename);
rmSync(image.getFilePath(), {force: true});
await dbe.manufacturer.update({data: {image: newFilename}, where: {id: id}});
+4 -3
View File
@@ -1,6 +1,6 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { LoaderFunctionArgs, NodeOnDiskFile, json, redirect, unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler, unstable_parseMultipartFormData } from "@remix-run/node";
import { renameSync, rmSync } from "node:fs";
import { copyFileSync, rmSync } from "node:fs";
import { useLoaderData } from "@remix-run/react";
import { Container, Row, Button, Form, Image } from "react-bootstrap";
@@ -38,8 +38,9 @@ export const action = async ({
// Remove old image
if(soda.image) rmSync("public"+ soda.image, {force: true});
// Move the file to the sodas folder with the slug as name
renameSync(image.getFilePath(), "public" + newFilename);
// Copy the file to the sodas folder with the slug as name
copyFileSync(image.getFilePath(), "public" + newFilename);
rmSync(image.getFilePath(), {force: true});
await dbe.soda.update({data: {image: newFilename}, where: {id: id}});
+4 -3
View File
@@ -1,6 +1,6 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { LoaderFunctionArgs, NodeOnDiskFile, json, redirect, unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler, unstable_parseMultipartFormData } from "@remix-run/node";
import { renameSync, rmSync } from "node:fs";
import { copyFileSync, rmSync } from "node:fs";
import { useLoaderData } from "@remix-run/react";
import { Container, Row, Button, Form, Image } from "react-bootstrap";
@@ -38,8 +38,9 @@ export const action = async ({
// Remove old image
if(wine.image) rmSync("public"+ wine.image, {force: true});
// Move the file to the wines folder with the slug as name
renameSync(image.getFilePath(), "public" + newFilename);
// Copy the file to the wines folder with the slug as name
copyFileSync(image.getFilePath(), "public" + newFilename);
rmSync(image.getFilePath(), {force: true});
await dbe.wine.update({data: {image: newFilename}, where: {id: id}});
+36 -4
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 });
};
@@ -28,23 +30,53 @@ export async function action({
if(inventory <= 0) inventory = 0;
await dbe.container.update({data: { inventory: inventory}, where: {id: containerid}});
const currentContainer = await dbe.container.findUnique({where: {id: containerid}, select: {inventory: true}});
const shouldSetLastAdded = currentContainer != null && inventory > currentContainer.inventory;
await dbe.container.update({
data: {
inventory: inventory,
lastAdded: shouldSetLastAdded ? new Date() : undefined,
},
where: {id: containerid}
});
return null;
};
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>
@@ -0,0 +1,45 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { enhance } from "~/utils/db.server";
export async function loader({ request, params }: LoaderFunctionArgs) {
const { dbe, session } = await enhance(request);
const user = session.get("user");
if (!user || user.type !== "Admin") {
return redirect("/");
}
const containerId = Number(params.id);
if (!containerId) {
return redirect("/");
}
const container = await dbe.container.findUnique({
select: { id: true, inventory: true, drink: true },
where: { id: containerId },
});
if (!container){
return redirect("/");
} else if(container.inventory <= 0) {
return redirect("/inventory/drink/" + container.drink.id);
}
const newInventory = Math.max(container.inventory - 1, 0);
await dbe.container.update({
where: { id: container.id },
data: { inventory: newInventory },
});
await dbe.history.create({
data: {
container: { connect: { id: container.id } },
inventoryAfter: newInventory,
},
});
return redirect("/inventory/drink/" + container.drink.id);
}
+7 -2
View File
@@ -1,7 +1,7 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { LoaderFunctionArgs, NodeOnDiskFile, json, redirect, unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler, unstable_parseMultipartFormData } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { renameSync, rmSync } from "node:fs";
import { copyFileSync, rmSync } from "node:fs";
import { useState } from "react";
import { Button, Container, Form, InputGroup, Row } from "react-bootstrap";
@@ -53,7 +53,12 @@ export const action = async ({
try{
// Move the file to the beers folder with the slug as name
if(newFilename) renameSync(image.getFilePath(), "public" + newFilename);
if(newFilename)
{
// Copy is required for docker container to work
copyFileSync(image.getFilePath(), "public" + newFilename);
rmSync(image.getFilePath(), {force: true});
}
const createdBeer = await dbe.beer.create({ data: {
slug: slug,
+7 -2
View File
@@ -1,7 +1,7 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { LoaderFunctionArgs, NodeOnDiskFile, json, redirect, unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler, unstable_parseMultipartFormData } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { renameSync, rmSync } from "node:fs";
import { copyFileSync, rmSync } from "node:fs";
import { useState } from "react";
import { Button, Container, Form, InputGroup, Row } from "react-bootstrap";
@@ -50,7 +50,12 @@ export const action = async ({
try{
// Move the file to the beers folder with the slug as name
if(newFilename) renameSync(image.getFilePath(), "public" + newFilename);
if(newFilename)
{
// Copy is required for docker container to work
copyFileSync(image.getFilePath(), "public" + newFilename);
rmSync(image.getFilePath(), {force: true});
}
const createdCocktail = await dbe.cocktail.create({ data: {
slug: slug,
+1
View File
@@ -28,6 +28,7 @@ export const action = async ({
portions: portions,
price: price,
inventory: inventory,
lastAdded: inventory > 0 ? new Date() : null,
drink: {connect: {id: drink}},
section: {connect: {id: section}}
}, select: {drink: {select: {slug: true, type: true}}}
+7 -2
View File
@@ -1,6 +1,6 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { LoaderFunctionArgs, NodeOnDiskFile, json, redirect, unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler, unstable_parseMultipartFormData } from "@remix-run/node";
import { renameSync, rmSync } from "node:fs";
import { copyFileSync, rmSync } from "node:fs";
import { useActionData, useLoaderData } from "@remix-run/react";
import { Container, Row, Button, Form, Col, ListGroup } from "react-bootstrap";
@@ -34,7 +34,12 @@ export const action = async ({
try{
// Move the file to the manufacturers folder with the slug as name
if(newFilename) renameSync(image.getFilePath(), "public" + newFilename);
if(newFilename)
{
// Copy is required for docker container to work
copyFileSync(image.getFilePath(), "public" + newFilename);
rmSync(image.getFilePath(), {force: true});
}
const createdManufacturer = await dbe.manufacturer.create({ data: {name: name, description: description, image: newFilename, country: {connect: {code: country}}} });
return redirect("/inventory/manufacturer/" + createdManufacturer.id);
+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}
});
}
+7 -2
View File
@@ -1,7 +1,7 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { LoaderFunctionArgs, NodeOnDiskFile, json, redirect, unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler, unstable_parseMultipartFormData } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { renameSync, rmSync } from "node:fs";
import { copyFileSync, rmSync } from "node:fs";
import { useState } from "react";
import { Button, Container, Form, InputGroup, Row } from "react-bootstrap";
@@ -50,7 +50,12 @@ export const action = async ({
try{
// Move the file to the beers folder with the slug as name
if(newFilename) renameSync(image.getFilePath(), "public" + newFilename);
if(newFilename)
{
// Copy is required for docker container to work
copyFileSync(image.getFilePath(), "public" + newFilename);
rmSync(image.getFilePath(), {force: true});
}
const createdSoda = await dbe.soda.create({ data: {
slug: slug,
+7 -2
View File
@@ -1,7 +1,7 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { LoaderFunctionArgs, NodeOnDiskFile, json, redirect, unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler, unstable_parseMultipartFormData } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { renameSync, rmSync } from "node:fs";
import { copyFileSync, rmSync } from "node:fs";
import { useState } from "react";
import { Button, Container, Form, InputGroup, Row } from "react-bootstrap";
@@ -61,7 +61,12 @@ export const action = async ({
try{
// Move the file to the wines folder with the slug as name
if(newFilename) renameSync(image.getFilePath(), "public" + newFilename);
if(newFilename)
{
// Copy is required for docker container to work
copyFileSync(image.getFilePath(), "public" + newFilename);
rmSync(image.getFilePath(), {force: true});
}
const createdWine = await dbe.wine.create({ data: {
slug: slug,
+238 -5
View File
@@ -1,21 +1,254 @@
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>
<ListGroup.Item><Link to="/admin/manage/inventory">Change inventory numbers</Link></ListGroup.Item>
<ListGroup.Item><Link to="/admin/manage/checkout">Undo checkout</Link></ListGroup.Item>
<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 (
<>
+74 -9
View File
@@ -1,8 +1,8 @@
import { LoaderFunctionArgs, json, redirect } from "@remix-run/node";
import { Link, useLoaderData, useNavigate, useSearchParams } from "@remix-run/react";
import { useLoaderData, useNavigate, useSearchParams } from "@remix-run/react";
import { enhance } from "@zenstackhq/runtime";
import { useState } from "react";
import { Accordion, Button, Col, Container, Offcanvas, Row, useAccordionButton } from "react-bootstrap";
import { useEffect, useState } from "react";
import { Button, Col, Container, Form, Offcanvas, Row } from "react-bootstrap";
import DrinkCard from "~/components/cards/drink.card";
import DrinkFilter from "~/components/filters/drink.filter";
import { findDrinksFromSearch } from "~/models/drinks.filter.server";
@@ -34,12 +34,63 @@ export default function BeersRoute() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [searchQuery, setSearchQuery] = useState(searchParams.get("q") || "");
const [show, setShow] = useState(false);
useEffect(() => {
setSearchQuery(searchParams.get("q") || "");
}, [searchParams]);
useEffect(() => {
const timer = window.setTimeout(() => {
const nextParams = new URLSearchParams(searchParams);
const trimmedQuery = searchQuery.trim();
if (trimmedQuery) {
nextParams.set("q", trimmedQuery);
} else {
nextParams.delete("q");
}
const nextSearch = nextParams.toString();
const currentSearch = searchParams.toString();
if (nextSearch !== currentSearch) {
setSearchParams(nextParams, { replace: true });
}
}, 250);
return () => window.clearTimeout(timer);
}, [searchQuery, searchParams, setSearchParams]);
const handleClose = () => setShow(false);
const handleShow = () => setShow(true);
const activeFilterKeys = new Set<string>();
if (searchQuery.trim()) {
activeFilterKeys.add("q");
}
searchParams.forEach((value, key) => {
if (!value) {
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")) {
return;
}
activeFilterKeys.add(key);
});
const activeFilterCount = activeFilterKeys.size;
const hasActiveFilters = activeFilterCount > 0;
const numResults = loadData.drinkResults.length;
let randomDrink = function(){
@@ -52,12 +103,26 @@ export default function BeersRoute() {
<Container>
<Row key="filter-sort-bar" className="pt-1 sticky-top bg-white mb-2" style={{boxShadow: "0px 4px 4px -5px rgba(0,0,0,.5)"}}>
<Col key="filtering" className="mb-3">
<div>
<Button variant="primary" onClick={handleShow}>Filters</Button>
<LinkContainer to="/inventory">
<Button variant="warning" className="mx-3">Clear</Button>
</LinkContainer>
<span className="float-end">{numResults} Results</span>
<div className="d-flex flex-nowrap align-items-center gap-2" style={{ overflowX: 'auto' }}>
<Button
variant={hasActiveFilters ? "warning" : "primary"}
onClick={handleShow}
className="d-flex align-items-center gap-2 flex-shrink-0"
title={hasActiveFilters ? `${activeFilterCount} active filters` : "No active filters"}
>
<span>Filters</span>
{hasActiveFilters ? <span className="badge text-bg-light">{activeFilterCount}</span> : null}
</Button>
<Form.Control
type="search"
placeholder="Search drinks"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
className="flex-grow-1"
style={{ minWidth: 0, maxWidth: "420px" }}
/>
<Button variant="warning" onClick={() => navigate("/inventory")} className="flex-shrink-0">Clear</Button>
<span className="ms-auto text-nowrap">{numResults} Results</span>
</div>
<Offcanvas show={show} onHide={handleClose}>
+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";
+6 -1
View File
@@ -6,4 +6,9 @@ set -e
npx zenstack generate
npx remix vite:build
docker build -t beer-inventory .
docker build -t gitea.furb.it/kennyboy55/beer-inventory:latest .
## How to tag:
# sudo docker build -t gitea.furb.it/kennyboy55/beer-inventory:latest .
# sudo docker push gitea.furb.it/kennyboy55/beer-inventory:latest
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
npx prisma migrate resolve --applied 0_init
npx prisma migrate deploy
-13921
View File
File diff suppressed because it is too large Load Diff
+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"
+12 -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
@@ -50,6 +52,7 @@ model Drink {
lactose Boolean
organic Boolean
sugar Boolean @default(true)
addedAt DateTime @default(now())
containers Container[]
delegate_aux_beer Beer?
delegate_aux_wine Wine?
@@ -116,6 +119,7 @@ model Container {
portions Int?
price Float @default(0.0)
inventory Int @default(0)
lastAdded DateTime?
checkouts History[]
}
@@ -135,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
@@ -179,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
+19 -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
@@ -52,6 +55,7 @@ model Drink {
lactose Boolean
organic Boolean
sugar Boolean @default(true)
addedAt DateTime @default(now())
containers Container[]
@@ -141,6 +145,7 @@ model Container {
price Float @default(0.0)
inventory Int @default(0)
lastAdded DateTime?
checkouts History[]
@@ -175,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
@@ -203,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[]
@@ -241,7 +258,7 @@ model Report {
dateStart DateTime
dateEnd DateTime
file String
file String?
@@allow('all', auth().type == Admin)
}
+2 -1
View File
@@ -120,7 +120,8 @@ export default defineConfig({
route("manage/inventory", "routes/admin/manage/inventory.tsx");
route("manage/checkout", "routes/admin/manage/checkout.tsx");
route("manage/undo-checkout", "routes/admin/manage/undo-checkout.tsx");
route("manage/manual-checkout/:id", "routes/admin/manage/manual-checkout.$id.tsx");
});
});
},