Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c08a035416 | ||
|
|
0f90972bcb | ||
|
|
36881293ed | ||
|
|
7f7ae8e1ac | ||
|
|
c816af19a5 | ||
|
|
734c52c898 | ||
|
|
e4c93eb024 | ||
|
|
b8a20204c7 | ||
|
|
4d191c448d | ||
|
|
45b655e2b4 |
+2
-1
@@ -1,2 +1,3 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
npm-debug.log
|
||||
.gitea/
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -0,0 +1,30 @@
|
||||
Beer-inventory project for home use.
|
||||
|
||||
The project has three aspects.
|
||||
- The website, which people can visit to see the current inventory of drinks. They can see in which section a drink is stored.
|
||||
- The Admin website, which is part of the main website but only to logged in admin users. Which allows updating drinks, inventory, and adding or editing data.
|
||||
- The scanner, which is a raspberry pi with touchscreen display and a USB barcode scanner. This is next to the fridge, and people use it to scan a drink to remove it from the inventory.
|
||||
|
||||
Used technology:
|
||||
- remix.run (now known as react router)
|
||||
- prisma
|
||||
- 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
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
FROM node:22-alpine
|
||||
MAINTAINER Kenneth van Ewijk (kennyboy55)
|
||||
LABEL maintainer="Kenneth van Ewijk (kennyboy55)"
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
|
||||
+60
-2
@@ -1,9 +1,67 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
function normalizePassword(password: string) {
|
||||
return password.trim();
|
||||
}
|
||||
|
||||
export function hashPassword(password: string) {
|
||||
const normalizedPassword = normalizePassword(password);
|
||||
return createHash("sha256").update(normalizedPassword).digest("hex");
|
||||
}
|
||||
|
||||
function isHashedPassword(value: string) {
|
||||
return /^[a-f0-9]{64}$/i.test(value);
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, storedPassword: string | null | undefined) {
|
||||
if (!storedPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedPassword = normalizePassword(password);
|
||||
const hashedPassword = hashPassword(normalizedPassword);
|
||||
|
||||
if (isHashedPassword(storedPassword)) {
|
||||
const storedBuffer = Buffer.from(storedPassword);
|
||||
const hashedBuffer = Buffer.from(hashedPassword);
|
||||
|
||||
if (hashedBuffer.length !== storedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(hashedBuffer, storedBuffer);
|
||||
}
|
||||
|
||||
return normalizePassword(storedPassword) === normalizedPassword;
|
||||
}
|
||||
|
||||
export async function validateCredentials(
|
||||
username: string,
|
||||
password: string
|
||||
) {
|
||||
var user = await db.user.findUnique({where:{username: username, password: password}, select:{id: true}});
|
||||
return user?.id;
|
||||
const user = await db.user.findUnique({
|
||||
where: { username },
|
||||
select: { id: true, password: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const passwordMatches = verifyPassword(password, user.password);
|
||||
|
||||
if (!passwordMatches) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isHashedPassword(user.password ?? "")) {
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hashPassword(password) },
|
||||
});
|
||||
}
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,16 @@ 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/";
|
||||
|
||||
switch (arg.drink.type) {
|
||||
@@ -39,35 +47,55 @@ 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 isNewlyAdded = drinkAddedAt ? (Date.now() - drinkAddedAt.getTime()) <= 30 * 24 * 60 * 60 * 1000 : false;
|
||||
const isRecentlyRestocked = latestActivity ? (Date.now() - latestActivity.getTime()) <= 14 * 24 * 60 * 60 * 1000 : false;
|
||||
|
||||
return (
|
||||
<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={isNewlyAdded ? "primary" : "success"}
|
||||
className='position-absolute top-0 start-0 m-2 text-wrap'
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
{isNewlyAdded ? "New" : (isRecentlyRestocked ? "Restocked" : "")}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 { Button, Form, Col, Row } from 'react-bootstrap';
|
||||
|
||||
interface Arguments {
|
||||
beerStyles: BeerStyle[];
|
||||
@@ -16,73 +14,84 @@ 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");
|
||||
const showSodaFilter = arg.searchParams.has("drink", "Soda");
|
||||
const showCocktailFilter = arg.searchParams.has("drink", "Cocktail");
|
||||
|
||||
const form = useRef(null);
|
||||
const form = useRef<HTMLFormElement | null>(null);
|
||||
const drinkTypes = ["Beer", "Wine", "Soda", "Cocktail"];
|
||||
const [beerStyleExpanded, setBeerStyleExpanded] = useState(false);
|
||||
const [wineStyleExpanded, setWineStyleExpanded] = useState(false);
|
||||
|
||||
const submitAfterRender = function(){
|
||||
setTimeout(() => submit(form.current), 10);
|
||||
const toggleDrinkType = (drinkType: string) => {
|
||||
const params = new URLSearchParams(arg.searchParams.toString());
|
||||
const values = params.getAll("drink");
|
||||
|
||||
if (values.includes(drinkType)) {
|
||||
params.delete("drink");
|
||||
values.filter((value) => value !== drinkType).forEach((value) => params.append("drink", value));
|
||||
} else {
|
||||
params.append("drink", drinkType);
|
||||
}
|
||||
|
||||
if (params.get("abv") === "all") {
|
||||
params.delete("abv");
|
||||
}
|
||||
|
||||
submit(params, { method: "get", replace: true });
|
||||
};
|
||||
|
||||
const handleFormChange = (event: React.ChangeEvent<HTMLFormElement>) => {
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
if (formData.get("abv") === "all") {
|
||||
formData.delete("abv");
|
||||
}
|
||||
|
||||
submit(formData, { method: "get", replace: true });
|
||||
}
|
||||
|
||||
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 abvSelection = arg.searchParams.get("abv") || "all";
|
||||
const selectedBeerStyleCount = arg.searchParams.getAll("beerstyle").length;
|
||||
const selectedWineStyleCount = arg.searchParams.getAll("winestyle").length;
|
||||
const abvOptions = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "alcohol-free", label: "Alcohol free" },
|
||||
{ value: "1-5", label: "1-5%" },
|
||||
{ value: "5-10", label: "5-10%" },
|
||||
{ value: "10-plus", label: "10% +" },
|
||||
];
|
||||
|
||||
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>);
|
||||
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";
|
||||
const sortKey = arg.searchParams.get("sort") || "new";
|
||||
|
||||
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>
|
||||
{ [
|
||||
["new", "New"],
|
||||
["name", "Name"],
|
||||
["popular", "Popularity"],
|
||||
["abv-asc", "ABV Low -> High"],
|
||||
["abv-desc", "ABV High -> Low"],
|
||||
["inv-asc", "Inventoy Low -> High"],
|
||||
["inv-desc", "Inventory High -> Low"]
|
||||
["abv-asc", "Alcohol (Low -> High)"],
|
||||
["abv-desc", "Alcohol (High -> Low)"],
|
||||
["inv-asc", "Inventory (Low -> High)"],
|
||||
["inv-desc", "Inventory (High -> Low)"]
|
||||
].map((pair) => (
|
||||
<Form.Check
|
||||
id={"sort-" + pair[0]}
|
||||
@@ -96,22 +105,29 @@ 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>
|
||||
{ ["Beer", "Wine", "Soda", "Cocktail"].map(key => (
|
||||
<Form.Check
|
||||
id={"drink-" + key}
|
||||
type="checkbox"
|
||||
label={key}
|
||||
name="drink"
|
||||
value={key}
|
||||
defaultChecked={arg.searchParams.has("drink", key) ? true : false}
|
||||
/>
|
||||
<Form.Label className='fw-bold'>Drink Type</Form.Label>
|
||||
<div className='row row-cols-2 g-2'>
|
||||
{drinkTypes.map((key) => (
|
||||
<div className='col' key={key}>
|
||||
<Button
|
||||
type="button"
|
||||
variant={arg.searchParams.has("drink", key) ? "primary" : "outline-primary"}
|
||||
className='w-100 text-start'
|
||||
aria-pressed={arg.searchParams.has("drink", key)}
|
||||
onClick={() => toggleDrinkType(key)}
|
||||
>
|
||||
{key}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{arg.searchParams.getAll("drink").map((drinkValue) => (
|
||||
<input key={drinkValue} type="hidden" name="drink" value={drinkValue} />
|
||||
))}
|
||||
|
||||
</Form.Group>
|
||||
<Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'>
|
||||
<Form.Label className='fw-bold'>ABV</Form.Label>
|
||||
{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>
|
||||
@@ -129,38 +145,67 @@ const sliderIbu = () =>
|
||||
{ showBeerFilter ? (
|
||||
<>
|
||||
<Form.Group controlId="beer-style" className='bg-body-secondary rounded p-3 mt-3'>
|
||||
<Form.Label className='fw-bold mt-3'>Beer style</Form.Label>
|
||||
{ arg.beerStyles.map(style => (
|
||||
<Form.Check
|
||||
id={"beerstyle-" + style.name}
|
||||
type="checkbox"
|
||||
label={style.name}
|
||||
name="beerstyle"
|
||||
value={style.id}
|
||||
defaultChecked={arg.searchParams.has("beerstyle", style.id.toString()) ? true : false}
|
||||
/>
|
||||
))}
|
||||
</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()}
|
||||
<div className='d-flex justify-content-between align-items-center'>
|
||||
<Form.Label className='fw-bold mb-0'>Beer style {selectedBeerStyleCount > 0 ? ( <span className="badge text-bg-dark">{selectedBeerStyleCount} selected</span>) : ''}</Form.Label>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline-secondary'
|
||||
size='sm'
|
||||
onClick={() => setBeerStyleExpanded((current) => !current)}
|
||||
>
|
||||
{beerStyleExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
</div>
|
||||
{beerStyleExpanded ? (
|
||||
<div className='mt-3'>
|
||||
{ arg.beerStyles.map(style => (
|
||||
<Form.Check
|
||||
key={style.id}
|
||||
id={"beerstyle-" + style.name}
|
||||
type="checkbox"
|
||||
label={style.name}
|
||||
name="beerstyle"
|
||||
value={style.id}
|
||||
defaultChecked={arg.searchParams.has("beerstyle", style.id.toString()) ? true : false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted mt-3'>Tap expand to show beer styles</div>
|
||||
)}
|
||||
</Form.Group>
|
||||
</>
|
||||
) : ""}
|
||||
{ showWineFilter ? (
|
||||
<Form.Group controlId="wine-style" className='bg-body-secondary rounded p-3 mt-3'>
|
||||
<Form.Label className='fw-bold'>Wine style</Form.Label>
|
||||
{ arg.wineStyles.map(style => (
|
||||
<Form.Check
|
||||
id={"winestyle-" + style.name}
|
||||
type="checkbox"
|
||||
label={style.name}
|
||||
name="winestyle"
|
||||
value={style.id}
|
||||
defaultChecked={arg.searchParams.has("winestyle", style.id.toString()) ? true : false}
|
||||
/>
|
||||
))}
|
||||
</Form.Group>
|
||||
<div className='d-flex justify-content-between align-items-center'>
|
||||
<Form.Label className='fw-bold mb-0'>Wine style {selectedWineStyleCount > 0 ? ( <span className="badge text-bg-dark">{selectedWineStyleCount} selected</span>) : ''}</Form.Label>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline-secondary'
|
||||
size='sm'
|
||||
onClick={() => setWineStyleExpanded((current) => !current)}
|
||||
>
|
||||
{wineStyleExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
</div>
|
||||
{wineStyleExpanded ? (
|
||||
<div className='mt-3'>
|
||||
{ arg.wineStyles.map(style => (
|
||||
<Form.Check
|
||||
id={"winestyle-" + style.name}
|
||||
type="checkbox"
|
||||
label={style.name}
|
||||
name="winestyle"
|
||||
value={style.id}
|
||||
defaultChecked={arg.searchParams.has("winestyle", style.id.toString()) ? true : false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted mt-3'>Tap expand to show wine styles</div>
|
||||
)}
|
||||
</Form.Group>
|
||||
) : ""}
|
||||
{ showSodaFilter ? (
|
||||
<Form.Group controlId="soda-carbonated" className='bg-body-secondary rounded p-3 mt-3'>
|
||||
@@ -168,6 +213,7 @@ const sliderIbu = () =>
|
||||
<Form.Check
|
||||
type="radio"
|
||||
label="Both"
|
||||
id="carbonated-both"
|
||||
name="carbonated"
|
||||
value={0}
|
||||
defaultChecked={(arg.searchParams.get("carbonated") == "0" || !arg.searchParams.has("carbonated")) ? true : false}
|
||||
@@ -175,6 +221,7 @@ const sliderIbu = () =>
|
||||
<Form.Check
|
||||
type="radio"
|
||||
label="Sparkling"
|
||||
id="carbonated-sparkling"
|
||||
name="carbonated"
|
||||
value={1}
|
||||
defaultChecked={arg.searchParams.get("carbonated") == "1" ? true : false}
|
||||
@@ -182,6 +229,7 @@ const sliderIbu = () =>
|
||||
<Form.Check
|
||||
type="radio"
|
||||
label="Non-sparkling"
|
||||
id="carbonated-flat"
|
||||
name="carbonated"
|
||||
value={2}
|
||||
defaultChecked={arg.searchParams.get("carbonated") == "2" ? true : false}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,10 +76,32 @@ 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[] {
|
||||
|
||||
var callback = sortName;
|
||||
var callback = sortNewest;
|
||||
|
||||
if(searchParams.has("sort", "new")){
|
||||
callback = sortNewest;
|
||||
}
|
||||
|
||||
if(searchParams.has("sort", "name")){
|
||||
callback = sortName;
|
||||
|
||||
+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();
|
||||
}, 15 * 60 * 1000); // Every 15 minutes
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("online", handleOnline);
|
||||
window.removeEventListener("offline", handleOffline);
|
||||
window.clearInterval(hourly);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
return (
|
||||
<Document
|
||||
@@ -64,6 +103,25 @@ export function ErrorBoundary() {
|
||||
<div className="h-100 p-5 text-bg-dark rounded-3">
|
||||
<h1>{error.status}</h1>
|
||||
{error.statusText}
|
||||
<div className="mt-3">
|
||||
<button
|
||||
className="btn btn-primary me-2"
|
||||
onClick={() => {
|
||||
if (typeof window !== "undefined") window.location.reload();
|
||||
}}
|
||||
>
|
||||
Reload page
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => {
|
||||
tryRefresh();
|
||||
}}
|
||||
>
|
||||
Try refresh now
|
||||
</button>
|
||||
<div className="mt-2">Status: {isOnline ? "Online" : "Offline"}. Will retry automatically every 15 minutes.</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -84,6 +142,25 @@ export function ErrorBoundary() {
|
||||
<div className="h-100 p-5 text-bg-dark rounded-3">
|
||||
<h1>App Error</h1>
|
||||
{errorMessage}
|
||||
<div className="mt-3">
|
||||
<button
|
||||
className="btn btn-primary me-2"
|
||||
onClick={() => {
|
||||
if (typeof window !== "undefined") window.location.reload();
|
||||
}}
|
||||
>
|
||||
Reload page
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => {
|
||||
tryRefresh();
|
||||
}}
|
||||
>
|
||||
Try refresh now
|
||||
</button>
|
||||
<div className="mt-2">Status: {isOnline ? "Online" : "Offline"}. Will retry automatically every 15 minutes.</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -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}}},
|
||||
|
||||
@@ -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}});
|
||||
|
||||
|
||||
@@ -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}});
|
||||
|
||||
|
||||
@@ -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}});
|
||||
|
||||
|
||||
@@ -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}});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}}}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
||||
import { Outlet, json, useFetcher, useLoaderData, useRevalidator } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getSession } from "~/auth/session";
|
||||
import { Outlet, json, useLoaderData, useRevalidator } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Header, { HeaderData } from "~/components/header";
|
||||
import { enhance } from "~/utils/db.server";
|
||||
|
||||
@@ -32,18 +31,16 @@ export default function InventoryLayout() {
|
||||
let revalidator = useRevalidator();
|
||||
|
||||
const [checkouts, setCheckouts] = useState(data.checkouts);
|
||||
let fetcher = useFetcher();
|
||||
|
||||
let shouldFetch = true;
|
||||
const shouldFetchRef = useRef(true);
|
||||
|
||||
// User has switched back to the tab
|
||||
const onFocus = () => {
|
||||
shouldFetch = true;
|
||||
shouldFetchRef.current = true;
|
||||
};
|
||||
|
||||
// User has switched away from the tab (AKA tab is hidden)
|
||||
const onBlur = () => {
|
||||
shouldFetch = false;
|
||||
shouldFetchRef.current = false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,30 +56,44 @@ export default function InventoryLayout() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const pollCheckouts = async () => {
|
||||
if (!shouldFetchRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(shouldFetch){
|
||||
fetcher.load("/resource/checkouts");
|
||||
try {
|
||||
const response = await fetch("/resource/checkouts", { credentials: "same-origin" });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(fetcher.state === "idle"){
|
||||
if(!fetcher.data) return;
|
||||
const payload = await response.json() as { checkouts?: number | string | null };
|
||||
const newCheckouts = Number(payload.checkouts);
|
||||
|
||||
let newCheckouts = Number(fetcher.data.checkouts);
|
||||
if(newCheckouts != checkouts){
|
||||
if (!Number.isFinite(newCheckouts)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckouts(newCheckouts);
|
||||
if (newCheckouts !== checkouts) {
|
||||
setCheckouts(newCheckouts);
|
||||
|
||||
if (revalidator.state === "idle") {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
if (revalidator.state === "idle") {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore transient network errors while the connection is down.
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void pollCheckouts();
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
void pollCheckouts();
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [checkouts, revalidator]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,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,59 @@ 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 === "new") {
|
||||
return;
|
||||
}
|
||||
|
||||
activeFilterKeys.add(key);
|
||||
});
|
||||
|
||||
const activeFilterCount = activeFilterKeys.size;
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
const numResults = loadData.drinkResults.length;
|
||||
|
||||
let randomDrink = function(){
|
||||
@@ -52,12 +99,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}>
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function InventoryLayout() {
|
||||
setCheckouts(newCheckouts);
|
||||
}
|
||||
|
||||
}, 5000);
|
||||
}, 30000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
+159
-48
@@ -1,7 +1,8 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { useActionData, useLoaderData } from "@remix-run/react";
|
||||
import { Col, Container, Form, Row, Table } from "react-bootstrap";
|
||||
import { Form as RemixForm, useActionData, useLoaderData } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Col, Container, Form as BootstrapForm, Row, Table } from "react-bootstrap";
|
||||
import timeAgo from "~/utils/datetime";
|
||||
|
||||
import { enhance } from "~/utils/db.server";
|
||||
@@ -15,7 +16,7 @@ export async function loader({
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
const history = await dbe.history.findMany({select: {id: true, checkoutAt: true, inventoryAfter: true, container: {select: {drink: true}}}, take: 10, orderBy: {checkoutAt: "desc"}});
|
||||
const history = await dbe.history.findMany({select: {id: true, checkoutAt: true, inventoryAfter: true, container: {select: {drink: true}}}, take: 5, orderBy: {checkoutAt: "desc"}});
|
||||
return json({history});
|
||||
}
|
||||
|
||||
@@ -27,9 +28,13 @@ export async function action({
|
||||
const form = await request.formData();
|
||||
const barcode = form.get("barcode")?.toString() || "";
|
||||
|
||||
const container = await dbe.container.findUnique({
|
||||
select: {id: true, inventory: true, drink_id: true},
|
||||
where: {barcode: barcode}
|
||||
const container = await dbe.container.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
inventory: true,
|
||||
drink: { select: { name: true, image: true } },
|
||||
},
|
||||
where: { barcode: barcode },
|
||||
});
|
||||
|
||||
// No drink found for barcode, do nothing
|
||||
@@ -48,58 +53,164 @@ export async function action({
|
||||
// Log entry
|
||||
await dbe.history.create({data: {container: {connect: {id: container.id}}, inventoryAfter: newInventory}});
|
||||
|
||||
return json({error: ""});
|
||||
return json({
|
||||
error: "",
|
||||
lastScanned: {
|
||||
name: container.drink.name,
|
||||
image: container.drink.image ?? null,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default function ScanRoute() {
|
||||
const aData = useActionData<typeof action>();
|
||||
const lData = useLoaderData<typeof loader>();
|
||||
const fullscreenBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const [lastScanned, setLastScanned] = useState<{name:string;image:string|null} | null>(null);
|
||||
const [showLastScanned, setShowLastScanned] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasError = aData?.error ? aData.error.length > 0 : false;
|
||||
|
||||
const focusInput = () => {
|
||||
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
if (typeof document === "undefined") return;
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
} else {
|
||||
await document.documentElement.requestFullscreen();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore errors (user gesture required in some contexts)
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
focusInput();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
focusInput();
|
||||
}, [aData]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBlur = () => {
|
||||
focusInput();
|
||||
};
|
||||
|
||||
const input = inputRef.current;
|
||||
input?.addEventListener("blur", handleBlur);
|
||||
|
||||
return () => {
|
||||
input?.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aData?.lastScanned) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
|
||||
setLastScanned(aData.lastScanned);
|
||||
setShowLastScanned(true);
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setShowLastScanned(false);
|
||||
}, 5000);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [aData?.lastScanned]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Row className="mt-3">
|
||||
<Col xs={4}>
|
||||
<Form method="post" action="/scan?index" noValidate>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
Scan barcode:
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
name="barcode"
|
||||
isInvalid={hasError}
|
||||
autoFocus
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{aData?.error}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
<Container fluid className="mt-3">
|
||||
<Row>
|
||||
<Col xs={12} md={6} className="mb-3">
|
||||
<h3 onClick={() => fullscreenBtnRef.current?.click()} style={{cursor: 'pointer'}}>History</h3>
|
||||
<div>
|
||||
<Table striped size="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Drink</th>
|
||||
<th>When</th>
|
||||
<th># Left</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lData.history.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>{entry.container.drink.name}</td>
|
||||
<td>{timeAgo(new Date(entry.checkoutAt))}</td>
|
||||
<td>{entry.inventoryAfter}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="mt-3">
|
||||
<Col xs={12}>
|
||||
<h3>History</h3>
|
||||
<Table striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Drink</th>
|
||||
<th>When</th>
|
||||
<th>Amount left</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lData.history.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>{entry.container.drink.name}</td>
|
||||
<td>{timeAgo(new Date(entry.checkoutAt))}</td>
|
||||
<td>{entry.inventoryAfter}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
<Col xs={12} md={6}>
|
||||
<h3 onClick={() => fullscreenBtnRef.current?.click()} style={{cursor: 'pointer'}}>Scan Barcode</h3>
|
||||
<Row className="mb-3">
|
||||
<Col>
|
||||
{/* Hidden fullscreen toggle button - triggered by clicking the h3 headers */}
|
||||
<button
|
||||
type="button"
|
||||
ref={fullscreenBtnRef}
|
||||
onClick={toggleFullscreen}
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<RemixForm method="post" action="/scan?index" noValidate>
|
||||
<BootstrapForm.Group>
|
||||
<BootstrapForm.Control
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
name="barcode"
|
||||
isInvalid={hasError}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
inputMode="text"
|
||||
style={{ height: '3rem', fontSize: '1.8rem', textAlign: 'center' }}
|
||||
/>
|
||||
<BootstrapForm.Control.Feedback type="invalid">
|
||||
{aData?.error}
|
||||
</BootstrapForm.Control.Feedback>
|
||||
</BootstrapForm.Group>
|
||||
</RemixForm>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<div className="p-4 border rounded" style={{ minHeight: '50vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', textAlign: 'center' }}>
|
||||
{showLastScanned && lastScanned ? (
|
||||
<>
|
||||
<h1 style={{ fontSize: '2rem', marginBottom: '1rem' }}>{lastScanned.name}</h1>
|
||||
{lastScanned.image ? (
|
||||
<img
|
||||
src={lastScanned.image}
|
||||
alt={lastScanned.name}
|
||||
style={{ width: '100%', maxHeight: '40vh', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted" style={{ fontSize: '1.5rem' }}>No image available</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-muted" style={{ fontSize: '2rem', fontWeight: 600 }}>
|
||||
<h1>Please scan your drink</h1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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
|
||||
-13921
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,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 +117,7 @@ model Container {
|
||||
portions Int?
|
||||
price Float @default(0.0)
|
||||
inventory Int @default(0)
|
||||
lastAdded DateTime?
|
||||
checkouts History[]
|
||||
}
|
||||
|
||||
@@ -179,5 +181,5 @@ model Report {
|
||||
name String @unique()
|
||||
dateStart DateTime
|
||||
dateEnd DateTime
|
||||
file String
|
||||
file String?
|
||||
}
|
||||
|
||||
+7
-2
@@ -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
|
||||
|
||||
+4
-2
@@ -52,6 +52,7 @@ model Drink {
|
||||
lactose Boolean
|
||||
organic Boolean
|
||||
sugar Boolean @default(true)
|
||||
addedAt DateTime @default(now())
|
||||
|
||||
containers Container[]
|
||||
|
||||
@@ -141,6 +142,7 @@ model Container {
|
||||
price Float @default(0.0)
|
||||
|
||||
inventory Int @default(0)
|
||||
lastAdded DateTime?
|
||||
|
||||
checkouts History[]
|
||||
|
||||
@@ -203,7 +205,7 @@ model History {
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String @password @omit
|
||||
password String
|
||||
type UserType
|
||||
|
||||
sessions Session[]
|
||||
@@ -241,7 +243,7 @@ model Report {
|
||||
dateStart DateTime
|
||||
dateEnd DateTime
|
||||
|
||||
file String
|
||||
file String?
|
||||
|
||||
@@allow('all', auth().type == Admin)
|
||||
}
|
||||
+2
-1
@@ -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");
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user