Compare commits
3
Commits
v1.2.1
...
feature/brands
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
062bdbd16e | ||
|
|
7f7ae8e1ac | ||
|
|
c816af19a5 |
@@ -2,8 +2,6 @@ name: Build dev docker image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
tags:
|
||||||
- '*'
|
- '*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -84,6 +82,6 @@ jobs:
|
|||||||
tag_name: ${{ gitea.ref_name }}
|
tag_name: ${{ gitea.ref_name }}
|
||||||
name: ${{ gitea.ref_name }}
|
name: ${{ gitea.ref_name }}
|
||||||
body: |
|
body: |
|
||||||
"## Docker images"
|
## Docker images
|
||||||
"`gitea.furb.it/${{ gitea.repository }}:${{ gitea.ref_name }}`"
|
`gitea.furb.it/${{ gitea.repository }}:${{ gitea.ref_name }}`
|
||||||
"`gitea.furb.it/${{ gitea.repository }}:latest`"
|
`gitea.furb.it/${{ gitea.repository }}:latest`
|
||||||
@@ -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
|
||||||
+2
-1
@@ -13,9 +13,10 @@ COPY . .
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV DATABASE_URL="REQUIRED"
|
ENV DATABASE_URL="REQUIRED"
|
||||||
|
|
||||||
|
RUN chmod +x ./migrate.sh
|
||||||
RUN npx zenstack generate
|
RUN npx zenstack generate
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
VOLUME /usr/src/app/public
|
VOLUME /usr/src/app/public
|
||||||
|
|
||||||
CMD ["/bin/sh", "-c", "npx prisma db push && npm start"]
|
CMD ["/bin/sh", "-c", "./migrate.sh && npm start"]
|
||||||
@@ -69,10 +69,10 @@ function DrinkFilter(arg : Arguments) {
|
|||||||
["name", "Name"],
|
["name", "Name"],
|
||||||
["new", "New"],
|
["new", "New"],
|
||||||
["popular", "Popularity"],
|
["popular", "Popularity"],
|
||||||
["abv-asc", "ABV Low -> High"],
|
["abv-asc", "Alcohol (Low -> High)"],
|
||||||
["abv-desc", "ABV High -> Low"],
|
["abv-desc", "Alcohol (High -> Low)"],
|
||||||
["inv-asc", "Inventoy Low -> High"],
|
["inv-asc", "Inventory (Low -> High)"],
|
||||||
["inv-desc", "Inventory High -> Low"]
|
["inv-desc", "Inventory (High -> Low)"]
|
||||||
].map((pair) => (
|
].map((pair) => (
|
||||||
<Form.Check
|
<Form.Check
|
||||||
id={"sort-" + pair[0]}
|
id={"sort-" + pair[0]}
|
||||||
@@ -86,7 +86,7 @@ function DrinkFilter(arg : Arguments) {
|
|||||||
|
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Group controlId="drink-type" className='bg-body-secondary rounded p-3 mt-3'>
|
<Form.Group controlId="drink-type" className='bg-body-secondary rounded p-3 mt-3'>
|
||||||
<Form.Label className='fw-bold'>Drink type</Form.Label>
|
<Form.Label className='fw-bold'>Drink Type</Form.Label>
|
||||||
{ ["Beer", "Wine", "Soda", "Cocktail"].map(key => (
|
{ ["Beer", "Wine", "Soda", "Cocktail"].map(key => (
|
||||||
<Form.Check
|
<Form.Check
|
||||||
id={"drink-" + key}
|
id={"drink-" + key}
|
||||||
@@ -100,8 +100,8 @@ function DrinkFilter(arg : Arguments) {
|
|||||||
|
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'>
|
<Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'>
|
||||||
<Form.Label className='fw-bold'>ABV</Form.Label>
|
<Form.Label className='fw-bold'>Alcohol Percentage</Form.Label>
|
||||||
<div className='d-grid gap-2'>{abvOptionsMarkup}</div>
|
<div className='d-grid'>{abvOptionsMarkup}</div>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
{/* <Form.Group controlId="manufacturer" className='bg-body-secondary rounded p-3 mt-3'>
|
{/* <Form.Group controlId="manufacturer" className='bg-body-secondary rounded p-3 mt-3'>
|
||||||
<Form.Label className='fw-bold'>Manufacturer</Form.Label>
|
<Form.Label className='fw-bold'>Manufacturer</Form.Label>
|
||||||
|
|||||||
+78
-1
@@ -8,7 +8,7 @@ import {
|
|||||||
isRouteErrorResponse,
|
isRouteErrorResponse,
|
||||||
useRouteError
|
useRouteError
|
||||||
} from "@remix-run/react";
|
} 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";
|
import stylesheet from "bootstrap/dist/css/bootstrap.min.css?url";
|
||||||
|
|
||||||
@@ -53,6 +53,45 @@ export default function App() {
|
|||||||
export function ErrorBoundary() {
|
export function ErrorBoundary() {
|
||||||
const error = useRouteError();
|
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)) {
|
if (isRouteErrorResponse(error)) {
|
||||||
return (
|
return (
|
||||||
<Document
|
<Document
|
||||||
@@ -64,6 +103,25 @@ export function ErrorBoundary() {
|
|||||||
<div className="h-100 p-5 text-bg-dark rounded-3">
|
<div className="h-100 p-5 text-bg-dark rounded-3">
|
||||||
<h1>{error.status}</h1>
|
<h1>{error.status}</h1>
|
||||||
{error.statusText}
|
{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>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -84,6 +142,25 @@ export function ErrorBoundary() {
|
|||||||
<div className="h-100 p-5 text-bg-dark rounded-3">
|
<div className="h-100 p-5 text-bg-dark rounded-3">
|
||||||
<h1>App Error</h1>
|
<h1>App Error</h1>
|
||||||
{errorMessage}
|
{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>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||||
import { json } from "@remix-run/node";
|
import { json } from "@remix-run/node";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import { useLoaderData } from "@remix-run/react";
|
import { useLoaderData } from "@remix-run/react";
|
||||||
import { Button, Form } from "react-bootstrap";
|
import { Button, Form } from "react-bootstrap";
|
||||||
import { containerTypeToString } from "~/models/types";
|
import { containerTypeToString } from "~/models/types";
|
||||||
|
import { volume } from '~/utils/conversions';
|
||||||
|
|
||||||
import { enhance } from "~/utils/db.server";
|
import { enhance } from "~/utils/db.server";
|
||||||
|
|
||||||
@@ -12,7 +14,7 @@ export async function loader({
|
|||||||
}: LoaderFunctionArgs) {
|
}: LoaderFunctionArgs) {
|
||||||
const { dbe } = await enhance(request);
|
const { dbe } = await enhance(request);
|
||||||
|
|
||||||
const containers = await dbe.container.findMany({select: {id: true, drink: true, type: true, inventory: true}, orderBy: {drink: {name: "asc"}}});
|
const containers = await dbe.container.findMany({select: {id: true, drink: true, type: true, inventory: true, volume: true}, orderBy: {drink: {name: "asc"}}});
|
||||||
|
|
||||||
return json({ containers });
|
return json({ containers });
|
||||||
};
|
};
|
||||||
@@ -44,16 +46,37 @@ export async function action({
|
|||||||
|
|
||||||
export default function EditInventoryRoute() {
|
export default function EditInventoryRoute() {
|
||||||
const lData = useLoaderData<typeof loader>();
|
const lData = useLoaderData<typeof loader>();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const filteredContainers = useMemo(() => {
|
||||||
|
const searchText = search.trim().toLowerCase();
|
||||||
|
if (!searchText) return lData.containers;
|
||||||
|
|
||||||
|
return lData.containers.filter((container) => {
|
||||||
|
const label = `${container.drink.name} ${containerTypeToString(container.type)} ${container.inventory}`.toLowerCase();
|
||||||
|
return label.includes(searchText);
|
||||||
|
});
|
||||||
|
}, [lData.containers, search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2>Update inventory</h2>
|
<h2>Update inventory</h2>
|
||||||
<Form method="post">
|
<Form method="post">
|
||||||
|
<Form.Group className='mb-2'>
|
||||||
|
<Form.Label>Filter containers</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||||
|
placeholder="Type drink name, type or inventory"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
<Form.Group className='mb-2'>
|
<Form.Group className='mb-2'>
|
||||||
<Form.Label>Drink container</Form.Label>
|
<Form.Label>Drink container</Form.Label>
|
||||||
<Form.Select name="id">
|
<Form.Select name="id">
|
||||||
{ lData.containers.map((container) => (
|
{ filteredContainers.map((container) => (
|
||||||
<option value={container.id}>{container.drink.name} - {containerTypeToString(container.type)} ({container.inventory})</option>
|
<option key={container.id} value={container.id}>{container.drink.name} - {containerTypeToString(container.type)} - {volume(container.volume)} ({container.inventory})</option>
|
||||||
))}
|
))}
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|||||||
+150
-44
@@ -1,6 +1,7 @@
|
|||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||||
import { json, redirect } from "@remix-run/node";
|
import { json, redirect } from "@remix-run/node";
|
||||||
import { useActionData, useLoaderData } from "@remix-run/react";
|
import { useActionData, useLoaderData } from "@remix-run/react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Col, Container, Form, Row, Table } from "react-bootstrap";
|
import { Col, Container, Form, Row, Table } from "react-bootstrap";
|
||||||
import timeAgo from "~/utils/datetime";
|
import timeAgo from "~/utils/datetime";
|
||||||
|
|
||||||
@@ -28,8 +29,12 @@ export async function action({
|
|||||||
const barcode = form.get("barcode")?.toString() || "";
|
const barcode = form.get("barcode")?.toString() || "";
|
||||||
|
|
||||||
const container = await dbe.container.findUnique({
|
const container = await dbe.container.findUnique({
|
||||||
select: {id: true, inventory: true, drink_id: true},
|
select: {
|
||||||
where: {barcode: barcode}
|
id: true,
|
||||||
|
inventory: true,
|
||||||
|
drink: { select: { name: true, image: true } },
|
||||||
|
},
|
||||||
|
where: { barcode: barcode },
|
||||||
});
|
});
|
||||||
|
|
||||||
// No drink found for barcode, do nothing
|
// No drink found for barcode, do nothing
|
||||||
@@ -48,58 +53,159 @@ export async function action({
|
|||||||
// Log entry
|
// Log entry
|
||||||
await dbe.history.create({data: {container: {connect: {id: container.id}}, inventoryAfter: newInventory}});
|
await dbe.history.create({data: {container: {connect: {id: container.id}}, inventoryAfter: newInventory}});
|
||||||
|
|
||||||
return json({error: ""});
|
return json({
|
||||||
|
error: "",
|
||||||
|
lastScanned: {
|
||||||
|
name: container.drink.name,
|
||||||
|
image: container.drink.image ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScanRoute() {
|
export default function ScanRoute() {
|
||||||
const aData = useActionData<typeof action>();
|
const aData = useActionData<typeof action>();
|
||||||
const lData = useLoaderData<typeof loader>();
|
const lData = useLoaderData<typeof loader>();
|
||||||
|
const 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 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 (
|
return (
|
||||||
<Container>
|
<Container fluid className="mt-3">
|
||||||
<Row className="mt-3">
|
<Row>
|
||||||
<Col xs={4}>
|
<Col xs={12} md={6} className="mb-3">
|
||||||
<Form method="post" action="/scan?index" noValidate>
|
<h3 onClick={() => fullscreenBtnRef.current?.click()} style={{cursor: 'pointer'}}>History</h3>
|
||||||
<Form.Group>
|
<div>
|
||||||
<Form.Label>
|
<Table striped size="sm">
|
||||||
Scan barcode:
|
<thead>
|
||||||
</Form.Label>
|
<tr>
|
||||||
<Form.Control
|
<th>Drink</th>
|
||||||
type="text"
|
<th>When</th>
|
||||||
name="barcode"
|
<th>Amount left</th>
|
||||||
isInvalid={hasError}
|
</tr>
|
||||||
autoFocus
|
</thead>
|
||||||
/>
|
<tbody>
|
||||||
<Form.Control.Feedback type="invalid">
|
{lData.history.map((entry) => (
|
||||||
{aData?.error}
|
<tr key={entry.id}>
|
||||||
</Form.Control.Feedback>
|
<td>{entry.container.drink.name}</td>
|
||||||
</Form.Group>
|
<td>{timeAgo(new Date(entry.checkoutAt))}</td>
|
||||||
</Form>
|
<td>{entry.inventoryAfter}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
<Col xs={12} md={6}>
|
||||||
<Row className="mt-3">
|
<h3 onClick={() => fullscreenBtnRef.current?.click()} style={{cursor: 'pointer'}}>Scan Barcode</h3>
|
||||||
<Col xs={12}>
|
<Row className="mb-3">
|
||||||
<h3>History</h3>
|
<Col>
|
||||||
<Table striped>
|
{/* Hidden fullscreen toggle button - triggered by clicking the h3 headers */}
|
||||||
<thead>
|
<button
|
||||||
<tr>
|
ref={fullscreenBtnRef}
|
||||||
<th>Drink</th>
|
onClick={toggleFullscreen}
|
||||||
<th>When</th>
|
style={{ display: 'none' }}
|
||||||
<th>Amount left</th>
|
aria-hidden="true"
|
||||||
</tr>
|
/>
|
||||||
</thead>
|
<Form method="post" action="/scan?index" noValidate>
|
||||||
<tbody>
|
<Form.Group>
|
||||||
{lData.history.map((entry) => (
|
<Form.Control
|
||||||
<tr key={entry.id}>
|
ref={inputRef}
|
||||||
<td>{entry.container.drink.name}</td>
|
type="text"
|
||||||
<td>{timeAgo(new Date(entry.checkoutAt))}</td>
|
name="barcode"
|
||||||
<td>{entry.inventoryAfter}</td>
|
isInvalid={hasError}
|
||||||
</tr>
|
autoComplete="off"
|
||||||
))}
|
spellCheck={false}
|
||||||
</tbody>
|
inputMode="text"
|
||||||
</Table>
|
style={{ height: '3rem', fontSize: '1.8rem', textAlign: 'center' }}
|
||||||
|
/>
|
||||||
|
<Form.Control.Feedback type="invalid">
|
||||||
|
{aData?.error}
|
||||||
|
</Form.Control.Feedback>
|
||||||
|
</Form.Group>
|
||||||
|
</Form>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<div className="p-4 border rounded" style={{ minHeight: '50vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', textAlign: 'center' }}>
|
||||||
|
{showLastScanned && lastScanned ? (
|
||||||
|
<>
|
||||||
|
<h1 style={{ fontSize: '2rem', marginBottom: '1rem' }}>{lastScanned.name}</h1>
|
||||||
|
{lastScanned.image ? (
|
||||||
|
<img
|
||||||
|
src={lastScanned.image}
|
||||||
|
alt={lastScanned.name}
|
||||||
|
style={{ width: '100%', maxHeight: '40vh', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted" style={{ fontSize: '1.5rem' }}>No image available</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted" style={{ fontSize: '2rem', fontWeight: 600 }}>
|
||||||
|
<h1>Please scan your drink</h1>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
npx prisma migrate resolve --applied 0_init
|
||||||
|
npx prisma migrate deploy
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
@@ -40,6 +40,8 @@ model Drink {
|
|||||||
slug String @unique()
|
slug String @unique()
|
||||||
manufacturer_id Int
|
manufacturer_id Int
|
||||||
manufacturer Manufacturer @relation(fields: [manufacturer_id], references: [id])
|
manufacturer Manufacturer @relation(fields: [manufacturer_id], references: [id])
|
||||||
|
brand_id Int
|
||||||
|
brand Brand @relation(fields: [brand_id], references: [id])
|
||||||
type DrinkType
|
type DrinkType
|
||||||
name String @unique()
|
name String @unique()
|
||||||
description String
|
description String
|
||||||
@@ -137,6 +139,13 @@ model Manufacturer {
|
|||||||
drinks Drink[]
|
drinks Drink[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Brand {
|
||||||
|
id Int @id() @default(autoincrement())
|
||||||
|
name String @unique()
|
||||||
|
image String?
|
||||||
|
drinks Drink[]
|
||||||
|
}
|
||||||
|
|
||||||
model Country {
|
model Country {
|
||||||
code String @id()
|
code String @id()
|
||||||
name String
|
name String
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ model Drink {
|
|||||||
manufacturer_id Int
|
manufacturer_id Int
|
||||||
manufacturer Manufacturer @relation(fields: [manufacturer_id], references: [id])
|
manufacturer Manufacturer @relation(fields: [manufacturer_id], references: [id])
|
||||||
|
|
||||||
|
brand_id Int
|
||||||
|
brand Brand @relation(fields: [brand_id], references: [id])
|
||||||
|
|
||||||
type DrinkType
|
type DrinkType
|
||||||
|
|
||||||
name String @unique
|
name String @unique
|
||||||
@@ -177,6 +180,18 @@ model Manufacturer {
|
|||||||
@@allow('all', auth().type == Admin)
|
@@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 {
|
model Country {
|
||||||
code String @id
|
code String @id
|
||||||
name String
|
name String
|
||||||
|
|||||||
Reference in New Issue
Block a user