AI Changes: updated look, recently added, search bar, easy admin checkout
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
Beer-inventory project for home use.
|
||||||
|
|
||||||
|
Used technology:
|
||||||
|
- remix.run (now known as react router)
|
||||||
|
- prisma
|
||||||
|
- zenstack
|
||||||
|
|
||||||
|
Limitations:
|
||||||
|
- Dependencies cannot be updated
|
||||||
|
- Project needs to work locally without online dependencies
|
||||||
|
- Project needs to work with existing database
|
||||||
|
- The project is turned off when not in use, so needs to be started quickly when needed
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- server-client together in the same file, as per remix-run
|
||||||
|
- react for frontend
|
||||||
|
- three levels
|
||||||
|
- user (can view the website on their phone)
|
||||||
|
- scanner (the physical raspberry pi with barcode scanner at the fridge)
|
||||||
|
- admin (me, the developer that can access and update everything)
|
||||||
@@ -7,6 +7,15 @@ interface Arguments {
|
|||||||
drink: DrinkComposite
|
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) {
|
function DrinkCard(arg:Arguments) {
|
||||||
var trimmedDescription = arg.drink.description.length > 120 ? arg.drink.description.substring(0, 120) + "..." : arg.drink.description;
|
var trimmedDescription = arg.drink.description.length > 120 ? arg.drink.description.substring(0, 120) + "..." : arg.drink.description;
|
||||||
var link = "/inventory/";
|
var link = "/inventory/";
|
||||||
@@ -39,35 +48,58 @@ function DrinkCard(arg:Arguments) {
|
|||||||
ibu = (<Badge bg={arg.drink.ibu > 45 ? "warning" : "secondary"}>IBU: {arg.drink.ibu}</Badge>);
|
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 = "";
|
|
||||||
if(isDrinkWithContainers(arg.drink)){
|
|
||||||
let inventoryNum = 0;
|
let inventoryNum = 0;
|
||||||
arg.drink.containers.map((container) => {
|
let latestActivity: Date | null = null;
|
||||||
|
if(isDrinkWithContainers(arg.drink)){
|
||||||
|
arg.drink.containers.forEach((container: { inventory: number; lastAdded?: Date | string | null }) => {
|
||||||
inventoryNum += container.inventory;
|
inventoryNum += container.inventory;
|
||||||
})
|
const candidate = toDate(container.lastAdded);
|
||||||
|
if (candidate && (!latestActivity || candidate.getTime() > latestActivity.getTime())) {
|
||||||
inventory = "Inventory: " + inventoryNum;
|
latestActivity = candidate;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const drinkAddedAt = toDate((arg.drink as { addedAt?: Date | string | null }).addedAt ?? null);
|
||||||
|
const latestTimestamp = drinkAddedAt && (!latestActivity || drinkAddedAt.getTime() > latestActivity.getTime())
|
||||||
|
? drinkAddedAt
|
||||||
|
: latestActivity;
|
||||||
|
const isNewlyAdded = latestTimestamp ? (Date.now() - latestTimestamp.getTime()) <= 30 * 24 * 60 * 60 * 1000 : false;
|
||||||
|
const isRecentlyRestocked = latestActivity ? (Date.now() - latestActivity.getTime()) <= 14 * 24 * 60 * 60 * 1000 : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={arg.drink.slug} style={{ borderColor: `${borderColor}`, borderWidth: '2px' }} className='mb-2'>
|
<Card key={arg.drink.slug} style={{ borderColor: `${borderColor}`, borderWidth: '2px' }} className='mb-2 h-100'>
|
||||||
<Card.Header className="text-muted">{manufacturer}</Card.Header>
|
<div style={{ position: 'relative', width: '100%', aspectRatio: '1 / 1', overflow: 'hidden' }}>
|
||||||
<Card.Img variant="top" className='image-fluid' src={arg.drink.image ? arg.drink.image : ""} />
|
{ (isNewlyAdded || isRecentlyRestocked) ? (
|
||||||
<Card.Body>
|
<Badge
|
||||||
<Card.Title><Link to={link + arg.drink.slug.toString()} className='stretched-link text-reset text-decoration-none'>{arg.drink.name}</Link></Card.Title>
|
bg={isRecentlyRestocked ? "success" : "primary"}
|
||||||
<Card.Text>
|
className='position-absolute top-0 start-0 m-2 text-wrap'
|
||||||
{trimmedDescription}
|
style={{ zIndex: 2 }}
|
||||||
</Card.Text>
|
>
|
||||||
|
{isNewlyAdded && isRecentlyRestocked ? "New · Restocked" : isNewlyAdded ? "New" : "Recently added"}
|
||||||
|
</Badge>
|
||||||
|
) : null }
|
||||||
|
<Card.Img
|
||||||
|
variant="top"
|
||||||
|
className='image-fluid'
|
||||||
|
src={arg.drink.image ? arg.drink.image : ""}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Card.Body className='d-flex flex-column justify-content-between py-3'>
|
||||||
|
<Card.Title className='h6 mb-2'><Link to={link + arg.drink.slug.toString()} className='stretched-link text-reset text-decoration-none'>{arg.drink.name}</Link></Card.Title>
|
||||||
|
<div className='d-flex flex-wrap gap-1 align-items-center'>
|
||||||
|
<Badge bg={arg.drink.abv > 0 ? "warning" : "secondary"} className='py-1'>ABV: {arg.drink.abv}%</Badge>
|
||||||
|
{style}
|
||||||
|
{ibu}
|
||||||
|
{arg.drink.sugar ? "" : <Badge bg="success" className='py-1'>Sugar-free</Badge>}
|
||||||
|
{arg.drink.gluten ? "" : <Badge bg="success" className='py-1'>Gluten-free</Badge>}
|
||||||
|
{arg.drink.organic ? <Badge bg="success" className='py-1'>Organic</Badge> : ""}
|
||||||
|
</div>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
<ListGroup variant="flush">
|
<Card.Footer className="text-center py-2">
|
||||||
<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>
|
<Badge bg="dark" className='px-3 py-2 fs-6'>Inventory: {inventoryNum}</Badge>
|
||||||
</ListGroup>
|
</Card.Footer>
|
||||||
<Card.Footer className="text-muted">{inventory}</Card.Footer>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ function DrinkPage(arg:Arguments) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var link = (<></>);
|
var link = (<></>);
|
||||||
if(arg.drink.link){
|
if(arg.drink.link){
|
||||||
var href = arg.drink.link;
|
var href = arg.drink.link;
|
||||||
@@ -184,7 +183,10 @@ function DrinkPage(arg:Arguments) {
|
|||||||
{arg.isAdmin ? (
|
{arg.isAdmin ? (
|
||||||
<>
|
<>
|
||||||
<td>€{container.price}</td>
|
<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>
|
</tr>
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
import { useSubmit } from '@remix-run/react';
|
import { useSubmit } from '@remix-run/react';
|
||||||
import { BeerStyle, Manufacturer, WineStyle } from '@zenstackhq/runtime/models';
|
import { BeerStyle, Manufacturer, WineStyle } from '@zenstackhq/runtime/models';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Form, Col, Row, Button } from 'react-bootstrap';
|
import { Form, Col, Row } from 'react-bootstrap';
|
||||||
import ReactSlider from 'react-slider';
|
|
||||||
import { ManufacturerWithDrinks } from '~/models/types';
|
|
||||||
|
|
||||||
interface Arguments {
|
interface Arguments {
|
||||||
beerStyles: BeerStyle[];
|
beerStyles: BeerStyle[];
|
||||||
@@ -16,8 +14,6 @@ interface Arguments {
|
|||||||
function DrinkFilter(arg : Arguments) {
|
function DrinkFilter(arg : Arguments) {
|
||||||
|
|
||||||
const submit = useSubmit();
|
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 showBeerFilter = arg.searchParams.has("drink", "Beer");
|
||||||
const showWineFilter = arg.searchParams.has("drink", "Wine");
|
const showWineFilter = arg.searchParams.has("drink", "Wine");
|
||||||
@@ -30,54 +26,48 @@ function DrinkFilter(arg : Arguments) {
|
|||||||
setTimeout(() => submit(form.current), 10);
|
setTimeout(() => submit(form.current), 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sliderAbv = () =>
|
const handleFormChange = (event: React.ChangeEvent<HTMLFormElement>) => {
|
||||||
(<div>
|
const formData = new FormData(event.currentTarget);
|
||||||
<ReactSlider
|
|
||||||
className="form-range"
|
if (formData.get("abv") === "all") {
|
||||||
thumbClassName="bg-primary text-light rounded dummy"
|
formData.delete("abv");
|
||||||
trackClassName="bg-secondary h-100 rounded dummy"
|
}
|
||||||
min={0}
|
|
||||||
max={50}
|
submit(formData, { method: "get", replace: true });
|
||||||
defaultValue={[abv.min, abv.max]}
|
}
|
||||||
renderThumb={(props, state) => <div {...props}>{state.valueNow}%</div>}
|
|
||||||
pearling
|
const abvSelection = arg.searchParams.get("abv") || "all";
|
||||||
minDistance={2}
|
const abvOptions = [
|
||||||
onAfterChange={(number, index) => {setAbv({min: number[0], max: number[1]}); submitAfterRender()}}
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "alcohol-free", label: "Alcohol free" },
|
||||||
|
{ value: "1-5", label: "1-5%" },
|
||||||
|
{ value: "5-10", label: "5-10%" },
|
||||||
|
{ value: "10-plus", label: "10% +" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const abvOptionsMarkup = abvOptions.map((option) => (
|
||||||
|
<Form.Check
|
||||||
|
key={option.value}
|
||||||
|
id={"abv-" + option.value}
|
||||||
|
type="radio"
|
||||||
|
label={option.label}
|
||||||
|
name="abv"
|
||||||
|
value={option.value}
|
||||||
|
defaultChecked={abvSelection === option.value}
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="min-abv" value={abv.min} />
|
));
|
||||||
<input type="hidden" name="max-abv" value={abv.max} />
|
|
||||||
|
|
||||||
</div>);
|
|
||||||
|
|
||||||
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 sortKey = arg.searchParams.get("sort") || "name";
|
const sortKey = arg.searchParams.get("sort") || "name";
|
||||||
|
|
||||||
return (
|
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>
|
<Row>
|
||||||
<Col>
|
<Col>
|
||||||
<Form.Group controlId="sort" className='bg-body-secondary rounded p-3'>
|
<Form.Group controlId="sort" className='bg-body-secondary rounded p-3'>
|
||||||
<Form.Label className='fw-bold'>Sort</Form.Label>
|
<Form.Label className='fw-bold'>Sort</Form.Label>
|
||||||
{ [
|
{ [
|
||||||
["name", "Name"],
|
["name", "Name"],
|
||||||
|
["new", "New"],
|
||||||
["popular", "Popularity"],
|
["popular", "Popularity"],
|
||||||
["abv-asc", "ABV Low -> High"],
|
["abv-asc", "ABV Low -> High"],
|
||||||
["abv-desc", "ABV High -> Low"],
|
["abv-desc", "ABV High -> Low"],
|
||||||
@@ -111,7 +101,7 @@ const sliderIbu = () =>
|
|||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'>
|
<Form.Group controlId="drink-abv" className='bg-body-secondary rounded p-3 mt-3'>
|
||||||
<Form.Label className='fw-bold'>ABV</Form.Label>
|
<Form.Label className='fw-bold'>ABV</Form.Label>
|
||||||
{sliderAbv()}
|
<div className='d-grid gap-2'>{abvOptionsMarkup}</div>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
{/* <Form.Group controlId="manufacturer" className='bg-body-secondary rounded p-3 mt-3'>
|
{/* <Form.Group controlId="manufacturer" className='bg-body-secondary rounded p-3 mt-3'>
|
||||||
<Form.Label className='fw-bold'>Manufacturer</Form.Label>
|
<Form.Label className='fw-bold'>Manufacturer</Form.Label>
|
||||||
@@ -141,10 +131,6 @@ const sliderIbu = () =>
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Group controlId="beer-ibu" className='bg-body-secondary rounded p-3 mt-3'>
|
|
||||||
<Form.Label className='fw-bold'>IBU</Form.Label>
|
|
||||||
{sliderIbu()}
|
|
||||||
</Form.Group>
|
|
||||||
</>
|
</>
|
||||||
) : ""}
|
) : ""}
|
||||||
{ showWineFilter ? (
|
{ showWineFilter ? (
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ export async function findDrinksFromSearch(searchParams: URLSearchParams) : Prom
|
|||||||
|
|
||||||
var result : DrinkComposite[] = [];
|
var result : DrinkComposite[] = [];
|
||||||
var noDrinkSelected = searchParams.getAll("drink").length == 0;
|
var noDrinkSelected = searchParams.getAll("drink").length == 0;
|
||||||
|
const searchQuery = searchParams.get("q")?.trim();
|
||||||
let whereBase = {delegate_aux_drink: {AND: <any>[]}};
|
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")){
|
if(searchParams.has("sugar-free")){
|
||||||
whereBase.delegate_aux_drink.AND.push({NOT: {sugar: true}});
|
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});
|
whereBase.delegate_aux_drink.AND.push({organic: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
if(searchParams.has("min-abv")){
|
const abvFilter = searchParams.get("abv");
|
||||||
let minabv = Number(searchParams.get("min-abv"));
|
if(abvFilter === "alcohol-free"){
|
||||||
whereBase.delegate_aux_drink.AND.push({abv: {gte: minabv}});
|
whereBase.delegate_aux_drink.AND.push({abv: {lt: 1}});
|
||||||
}
|
}
|
||||||
if(searchParams.has("max-abv")){
|
if(abvFilter === "1-5"){
|
||||||
let maxabv = Number(searchParams.get("max-abv"));
|
whereBase.delegate_aux_drink.AND.push({abv: {gte: 1, lt: 5}});
|
||||||
whereBase.delegate_aux_drink.AND.push({abv: {lte: maxabv}});
|
}
|
||||||
|
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")){
|
if(searchParams.has("manufacturer")){
|
||||||
@@ -49,6 +59,14 @@ export async function findDrinksFromSearch(searchParams: URLSearchParams) : Prom
|
|||||||
|
|
||||||
let where = Object.assign({}, whereBase, {style: {}, OR: <any>[]});
|
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
|
// Beer styles
|
||||||
if(searchParams.has("beerstyle")){
|
if(searchParams.has("beerstyle")){
|
||||||
where.style = {id: {in: searchParams.getAll("beerstyle").map(Number)}};
|
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: {}});
|
let where = Object.assign({}, whereBase, {style: {}});
|
||||||
|
|
||||||
|
if(searchQuery){
|
||||||
|
where.AND = [{ OR: searchClauses }];
|
||||||
|
}
|
||||||
|
|
||||||
// Wine styles
|
// Wine styles
|
||||||
if(searchParams.has("winestyle")){
|
if(searchParams.has("winestyle")){
|
||||||
where.style = {id: {in: searchParams.getAll("winestyle").map(Number)}};
|
where.style = {id: {in: searchParams.getAll("winestyle").map(Number)}};
|
||||||
@@ -96,6 +118,10 @@ export async function findDrinksFromSearch(searchParams: URLSearchParams) : Prom
|
|||||||
|
|
||||||
let where = whereBase;
|
let where = whereBase;
|
||||||
|
|
||||||
|
if(searchQuery){
|
||||||
|
where.AND = [{ OR: searchClauses }];
|
||||||
|
}
|
||||||
|
|
||||||
// Carbonated
|
// Carbonated
|
||||||
if(searchParams.get("carbonated") == "1"){
|
if(searchParams.get("carbonated") == "1"){
|
||||||
where = Object.assign({}, whereBase, {carbonated: true});
|
where = Object.assign({}, whereBase, {carbonated: true});
|
||||||
@@ -113,6 +139,10 @@ export async function findDrinksFromSearch(searchParams: URLSearchParams) : Prom
|
|||||||
if(searchParams.has("drink", "Cocktail") || noDrinkSelected){
|
if(searchParams.has("drink", "Cocktail") || noDrinkSelected){
|
||||||
let where = whereBase;
|
let where = whereBase;
|
||||||
|
|
||||||
|
if(searchQuery){
|
||||||
|
where.AND = [{ OR: searchClauses }];
|
||||||
|
}
|
||||||
|
|
||||||
// Mix
|
// Mix
|
||||||
if(searchParams.has("mix")){
|
if(searchParams.has("mix")){
|
||||||
where = Object.assign({}, whereBase, {mix: true});
|
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";
|
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{
|
function sortName(n1:DrinkComposite, n2:DrinkComposite) : number{
|
||||||
if (n1.name > n2.name) {
|
if (n1.name > n2.name) {
|
||||||
return 1;
|
return 1;
|
||||||
@@ -56,6 +76,24 @@ function sortPopularity(n1:DrinkComposite, n2:DrinkComposite) : number{
|
|||||||
return totalPopularity(n2) - totalPopularity(n1);
|
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[] {
|
export function sortDrinksFromSearch(searchParams: URLSearchParams, searchResult: DrinkComposite[]) : DrinkComposite[] {
|
||||||
|
|
||||||
@@ -85,6 +123,10 @@ export function sortDrinksFromSearch(searchParams: URLSearchParams, searchResult
|
|||||||
callback = sortPopularity;
|
callback = sortPopularity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(searchParams.has("sort", "new")){
|
||||||
|
callback = sortNewest;
|
||||||
|
}
|
||||||
|
|
||||||
var sortedArray: DrinkComposite[] = searchResult.sort(callback);
|
var sortedArray: DrinkComposite[] = searchResult.sort(callback);
|
||||||
|
|
||||||
return sortedArray;
|
return sortedArray;
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export const action = async ({
|
|||||||
const inventory = Number(form.get("inventory"));
|
const inventory = Number(form.get("inventory"));
|
||||||
|
|
||||||
try{
|
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: {
|
const updatedContainer = await dbe.container.update({ data: {
|
||||||
barcode: barcode,
|
barcode: barcode,
|
||||||
type: type,
|
type: type,
|
||||||
@@ -36,6 +39,7 @@ export const action = async ({
|
|||||||
portions: portions,
|
portions: portions,
|
||||||
price: price,
|
price: price,
|
||||||
inventory: inventory,
|
inventory: inventory,
|
||||||
|
lastAdded: shouldSetLastAdded ? new Date() : currentContainer?.lastAdded ?? null,
|
||||||
//drink: {connect: {id: drink}},
|
//drink: {connect: {id: drink}},
|
||||||
section: {connect: {id: section}}
|
section: {connect: {id: section}}
|
||||||
}, select: {drink: {select: {id: true, type: true}}},
|
}, select: {drink: {select: {id: true, type: true}}},
|
||||||
|
|||||||
@@ -28,7 +28,16 @@ export async function action({
|
|||||||
|
|
||||||
if(inventory <= 0) inventory = 0;
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ export const action = async ({
|
|||||||
portions: portions,
|
portions: portions,
|
||||||
price: price,
|
price: price,
|
||||||
inventory: inventory,
|
inventory: inventory,
|
||||||
|
lastAdded: inventory > 0 ? new Date() : null,
|
||||||
drink: {connect: {id: drink}},
|
drink: {connect: {id: drink}},
|
||||||
section: {connect: {id: section}}
|
section: {connect: {id: section}}
|
||||||
}, select: {drink: {select: {slug: true, type: true}}}
|
}, select: {drink: {select: {slug: true, type: true}}}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function AdminRoute() {
|
|||||||
<ListGroup.Item><Link to="/admin/new">Add new items</Link></ListGroup.Item>
|
<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/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/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>
|
</ListGroup>
|
||||||
|
|
||||||
<h1>Reports</h1>
|
<h1>Reports</h1>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { LoaderFunctionArgs, json, redirect } from "@remix-run/node";
|
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 { enhance } from "@zenstackhq/runtime";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Accordion, Button, Col, Container, Offcanvas, Row, useAccordionButton } from "react-bootstrap";
|
import { Button, Col, Container, Form, Offcanvas, Row } from "react-bootstrap";
|
||||||
import DrinkCard from "~/components/cards/drink.card";
|
import DrinkCard from "~/components/cards/drink.card";
|
||||||
import DrinkFilter from "~/components/filters/drink.filter";
|
import DrinkFilter from "~/components/filters/drink.filter";
|
||||||
import { findDrinksFromSearch } from "~/models/drinks.filter.server";
|
import { findDrinksFromSearch } from "~/models/drinks.filter.server";
|
||||||
@@ -34,12 +34,63 @@ export default function BeersRoute() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [searchQuery, setSearchQuery] = useState(searchParams.get("q") || "");
|
||||||
|
|
||||||
const [show, setShow] = useState(false);
|
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 handleClose = () => setShow(false);
|
||||||
const handleShow = () => setShow(true);
|
const handleShow = () => setShow(true);
|
||||||
|
|
||||||
|
const activeFilterKeys = new Set<string>();
|
||||||
|
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
activeFilterKeys.add("q");
|
||||||
|
}
|
||||||
|
|
||||||
|
searchParams.forEach((value, key) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "sort" && value === "name") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((key === "min-abv" && value === "0") || (key === "max-abv" && value === "50") || (key === "min-ibu" && value === "0") || (key === "max-ibu" && value === "150")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeFilterKeys.add(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeFilterCount = activeFilterKeys.size;
|
||||||
|
const hasActiveFilters = activeFilterCount > 0;
|
||||||
const numResults = loadData.drinkResults.length;
|
const numResults = loadData.drinkResults.length;
|
||||||
|
|
||||||
let randomDrink = function(){
|
let randomDrink = function(){
|
||||||
@@ -52,12 +103,26 @@ export default function BeersRoute() {
|
|||||||
<Container>
|
<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)"}}>
|
<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">
|
<Col key="filtering" className="mb-3">
|
||||||
<div>
|
<div className="d-flex flex-nowrap align-items-center gap-2" style={{ overflowX: 'auto' }}>
|
||||||
<Button variant="primary" onClick={handleShow}>Filters</Button>
|
<Button
|
||||||
<LinkContainer to="/inventory">
|
variant={hasActiveFilters ? "warning" : "primary"}
|
||||||
<Button variant="warning" className="mx-3">Clear</Button>
|
onClick={handleShow}
|
||||||
</LinkContainer>
|
className="d-flex align-items-center gap-2 flex-shrink-0"
|
||||||
<span className="float-end">{numResults} Results</span>
|
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>
|
</div>
|
||||||
|
|
||||||
<Offcanvas show={show} onHide={handleClose}>
|
<Offcanvas show={show} onHide={handleClose}>
|
||||||
|
|||||||
-13921
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,7 @@ model Drink {
|
|||||||
lactose Boolean
|
lactose Boolean
|
||||||
organic Boolean
|
organic Boolean
|
||||||
sugar Boolean @default(true)
|
sugar Boolean @default(true)
|
||||||
|
addedAt DateTime @default(now())
|
||||||
containers Container[]
|
containers Container[]
|
||||||
delegate_aux_beer Beer?
|
delegate_aux_beer Beer?
|
||||||
delegate_aux_wine Wine?
|
delegate_aux_wine Wine?
|
||||||
@@ -116,6 +117,7 @@ model Container {
|
|||||||
portions Int?
|
portions Int?
|
||||||
price Float @default(0.0)
|
price Float @default(0.0)
|
||||||
inventory Int @default(0)
|
inventory Int @default(0)
|
||||||
|
lastAdded DateTime?
|
||||||
checkouts History[]
|
checkouts History[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ model Drink {
|
|||||||
lactose Boolean
|
lactose Boolean
|
||||||
organic Boolean
|
organic Boolean
|
||||||
sugar Boolean @default(true)
|
sugar Boolean @default(true)
|
||||||
|
addedAt DateTime @default(now())
|
||||||
|
|
||||||
containers Container[]
|
containers Container[]
|
||||||
|
|
||||||
@@ -141,6 +142,7 @@ model Container {
|
|||||||
price Float @default(0.0)
|
price Float @default(0.0)
|
||||||
|
|
||||||
inventory Int @default(0)
|
inventory Int @default(0)
|
||||||
|
lastAdded DateTime?
|
||||||
|
|
||||||
checkouts History[]
|
checkouts History[]
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -120,7 +120,8 @@ export default defineConfig({
|
|||||||
|
|
||||||
|
|
||||||
route("manage/inventory", "routes/admin/manage/inventory.tsx");
|
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