218 lines
7.2 KiB
TypeScript
218 lines
7.2 KiB
TypeScript
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
|
import { json, redirect } from "@remix-run/node";
|
|
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";
|
|
|
|
export async function loader({
|
|
request,
|
|
}: LoaderFunctionArgs) {
|
|
const { dbe, session } = await enhance(request);
|
|
|
|
if(!session.has("user_id")){
|
|
return redirect("/");
|
|
}
|
|
|
|
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});
|
|
}
|
|
|
|
export async function action({
|
|
request,
|
|
}: ActionFunctionArgs){
|
|
const { dbe } = await enhance(request);
|
|
|
|
const form = await request.formData();
|
|
const barcode = form.get("barcode")?.toString() || "";
|
|
|
|
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
|
|
if(!container){
|
|
return redirect("/scan/link/" + barcode);
|
|
}
|
|
// No inventory, do nothing
|
|
if(container.inventory <= 0){
|
|
return json({error: "No inventory!"});
|
|
}
|
|
|
|
// Update inventory
|
|
const newInventory = Math.max(container.inventory - 1, 0);
|
|
await dbe.container.update({data: {inventory: newInventory}, where: {id: container.id}});
|
|
|
|
// Log entry
|
|
await dbe.history.create({data: {container: {connect: {id: container.id}}, inventoryAfter: newInventory}});
|
|
|
|
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 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>
|
|
<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>
|
|
);
|
|
} |