Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f7ae8e1ac | ||
|
|
c816af19a5 | ||
|
|
734c52c898 |
@@ -2,8 +2,6 @@ name: Build dev docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
@@ -84,6 +82,6 @@ jobs:
|
||||
tag_name: ${{ gitea.ref_name }}
|
||||
name: ${{ gitea.ref_name }}
|
||||
body: |
|
||||
"## Docker images"
|
||||
"`gitea.furb.it/${{ gitea.repository }}:${{ gitea.ref_name }}`"
|
||||
"`gitea.furb.it/${{ gitea.repository }}:latest`"
|
||||
## Docker images
|
||||
`gitea.furb.it/${{ gitea.repository }}:${{ gitea.ref_name }}`
|
||||
`gitea.furb.it/${{ gitea.repository }}:latest`
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Build dev docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
# Checkout repository for build
|
||||
- name: Checkout beer-inventory repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ gitea.ref }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
# Install NPM packages
|
||||
- name: Install NPM packages
|
||||
run: npm ci
|
||||
|
||||
# Build project
|
||||
- name: Build project
|
||||
run: |
|
||||
npx zenstack generate
|
||||
npx remix vite:build
|
||||
@@ -69,10 +69,10 @@ function DrinkFilter(arg : Arguments) {
|
||||
["name", "Name"],
|
||||
["new", "New"],
|
||||
["popular", "Popularity"],
|
||||
["abv-asc", "ABV Low -> High"],
|
||||
["abv-desc", "ABV High -> Low"],
|
||||
["inv-asc", "Inventoy Low -> High"],
|
||||
["inv-desc", "Inventory High -> Low"]
|
||||
["abv-asc", "Alcohol (Low -> High)"],
|
||||
["abv-desc", "Alcohol (High -> Low)"],
|
||||
["inv-asc", "Inventory (Low -> High)"],
|
||||
["inv-desc", "Inventory (High -> Low)"]
|
||||
].map((pair) => (
|
||||
<Form.Check
|
||||
id={"sort-" + pair[0]}
|
||||
@@ -86,7 +86,7 @@ function DrinkFilter(arg : Arguments) {
|
||||
|
||||
</Form.Group>
|
||||
<Form.Group controlId="drink-type" className='bg-body-secondary rounded p-3 mt-3'>
|
||||
<Form.Label className='fw-bold'>Drink type</Form.Label>
|
||||
<Form.Label className='fw-bold'>Drink Type</Form.Label>
|
||||
{ ["Beer", "Wine", "Soda", "Cocktail"].map(key => (
|
||||
<Form.Check
|
||||
id={"drink-" + key}
|
||||
@@ -100,8 +100,8 @@ function DrinkFilter(arg : Arguments) {
|
||||
|
||||
</Form.Group>
|
||||
<Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'>
|
||||
<Form.Label className='fw-bold'>ABV</Form.Label>
|
||||
<div className='d-grid gap-2'>{abvOptionsMarkup}</div>
|
||||
<Form.Label className='fw-bold'>Alcohol Percentage</Form.Label>
|
||||
<div className='d-grid'>{abvOptionsMarkup}</div>
|
||||
</Form.Group>
|
||||
{/* <Form.Group controlId="manufacturer" className='bg-body-secondary rounded p-3 mt-3'>
|
||||
<Form.Label className='fw-bold'>Manufacturer</Form.Label>
|
||||
|
||||
+78
-1
@@ -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>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json } from "@remix-run/node";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { containerTypeToString } from "~/models/types";
|
||||
import { volume } from '~/utils/conversions';
|
||||
|
||||
import { enhance } from "~/utils/db.server";
|
||||
|
||||
@@ -12,7 +14,7 @@ export async function loader({
|
||||
}: LoaderFunctionArgs) {
|
||||
const { dbe } = await enhance(request);
|
||||
|
||||
const containers = await dbe.container.findMany({select: {id: true, drink: true, type: true, inventory: true}, orderBy: {drink: {name: "asc"}}});
|
||||
const containers = await dbe.container.findMany({select: {id: true, drink: true, type: true, inventory: true, volume: true}, orderBy: {drink: {name: "asc"}}});
|
||||
|
||||
return json({ containers });
|
||||
};
|
||||
@@ -44,16 +46,37 @@ export async function action({
|
||||
|
||||
export default function EditInventoryRoute() {
|
||||
const lData = useLoaderData<typeof loader>();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredContainers = useMemo(() => {
|
||||
const searchText = search.trim().toLowerCase();
|
||||
if (!searchText) return lData.containers;
|
||||
|
||||
return lData.containers.filter((container) => {
|
||||
const label = `${container.drink.name} ${containerTypeToString(container.type)} ${container.inventory}`.toLowerCase();
|
||||
return label.includes(searchText);
|
||||
});
|
||||
}, [lData.containers, search]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Update inventory</h2>
|
||||
<Form method="post">
|
||||
<Form.Group className='mb-2'>
|
||||
<Form.Label>Filter containers</Form.Label>
|
||||
<Form.Control
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
placeholder="Type drink name, type or inventory"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className='mb-2'>
|
||||
<Form.Label>Drink container</Form.Label>
|
||||
<Form.Select name="id">
|
||||
{ lData.containers.map((container) => (
|
||||
<option value={container.id}>{container.drink.name} - {containerTypeToString(container.type)} ({container.inventory})</option>
|
||||
{ filteredContainers.map((container) => (
|
||||
<option key={container.id} value={container.id}>{container.drink.name} - {containerTypeToString(container.type)} - {volume(container.volume)} ({container.inventory})</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UserType } from "@prisma/client";
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { Form, Link, useActionData, useLoaderData } from "@remix-run/react";
|
||||
@@ -82,7 +81,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
return json({ error: "Username and password are required." });
|
||||
}
|
||||
|
||||
const normalizedType = type === UserType.Admin || type === UserType.Scanner ? type : UserType.User;
|
||||
const normalizedType = (type === "Admin" || type === "Scanner") ? type : "User";
|
||||
|
||||
try {
|
||||
await dbe.user.create({
|
||||
@@ -201,10 +200,10 @@ export default function AdminRoute() {
|
||||
</BootstrapForm.Group>
|
||||
<BootstrapForm.Group className="mb-3">
|
||||
<BootstrapForm.Label>Role</BootstrapForm.Label>
|
||||
<BootstrapForm.Select name="type" defaultValue={UserType.User}>
|
||||
<option value={UserType.User}>User</option>
|
||||
<option value={UserType.Scanner}>Scanner</option>
|
||||
<option value={UserType.Admin}>Admin</option>
|
||||
<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>
|
||||
|
||||
+151
-45
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user