Pantry
index.jsx 622 lines
Files · 8
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622import "./style.css";
import { localDate } from "../../shared/data.js";
import React, { useState, useEffect } from "react";
import { FormActions, ConfirmDelete } from "../../shared/forms.jsx";
import { useOperations } from "../../shared/data.js";
import { createRoot } from "react-dom/client";
import {
Page,
Header,
Button,
Modal,
Field,
EmptyState,
Stats,
Search,
ErrorNotice,
} from "../../shared/ui.jsx";
import { addDays, nullable } from "../../shared/format.js";
import { createPantry } from "./model.js";
const L = window.lattice,
store = createPantry(L);
const qty = (n) => n.toLocaleString(undefined, { maximumSignificantDigits: 8 });
function Editor({
kind,
value,
products,
units,
onSave,
onClose,
busy,
error,
}) {
const [f, set] = useState(value || {});
const input = (key, label, type = "text", hint, min) => (
<Field key={key} label={label} hint={hint}>
<input
type={type}
min={min}
step={type === "number" ? "any" : undefined}
value={f[key] ?? ""}
onChange={(e) => set({ ...f, [key]: e.target.value })}
/>
</Field>
);
const selectUnit = (key, label) => (
<Field label={label}>
<select
value={f[key] ?? ""}
onChange={(e) => set({ ...f, [key]: e.target.value })}
>
<option value="">Unspecified</option>
{units.map((u) => (
<option value={u.id} key={u.id}>
{u.name}
</option>
))}
</select>
</Field>
);
const submit = (e) => {
e.preventDefault();
const fields =
kind === "product"
? [
"name",
"description",
"location_id",
"qu_id_purchase",
"qu_id_stock",
"min_stock_amount",
"default_best_before_days",
"default_best_before_days_after_open",
"product_group_id",
]
: kind === "lot"
? [
"product_id",
"location_id",
"amount",
"best_before_date",
"purchased_date",
"stock_id",
"price",
"open",
"opened_date",
"note",
]
: ["name", "name_plural", "description"];
const numeric = [
"location_id",
"qu_id_purchase",
"qu_id_stock",
"min_stock_amount",
"default_best_before_days",
"default_best_before_days_after_open",
"product_group_id",
"product_id",
"amount",
"price",
"open",
];
const data = {};
for (const key of fields) {
const v = f[key];
if ((v === undefined || v === "") && !value?.id) {
if (["name", "amount"].includes(key)) data[key] = "";
continue;
}
data[key] = numeric.includes(key)
? v === "" || v == null
? [
"min_stock_amount",
"default_best_before_days",
"default_best_before_days_after_open",
"open",
].includes(key)
? 0
: null
: Number(v)
: nullable(v);
}
if (data.name) data.name = data.name.trim();
onSave(data);
};
return (
<Modal
title={`${value?.id ? "Edit" : "New"} ${kind === "lot" ? "stock lot" : kind === "product" ? "product" : "quantity unit"}`}
onClose={onClose}
>
<ErrorNotice error={error} />
<form onSubmit={submit}>
<div className="ui-form-grid">
{kind === "product" ? (
<>
<Field label="Product name" wide>
<input
autoFocus
required
value={f.name || ""}
onChange={(e) => set({ ...f, name: e.target.value })}
/>
</Field>
{input("description", "Description")}
{input(
"min_stock_amount",
"Minimum stock",
"number",
"In the stock unit",
0,
)}
{selectUnit("qu_id_purchase", "Purchase unit")}
{selectUnit("qu_id_stock", "Stock unit")}
{input(
"default_best_before_days",
"Default shelf life (days)",
"number",
"0 means no default",
0,
)}
{input(
"default_best_before_days_after_open",
"Shelf life after opening (days)",
"number",
"0 means no default",
0,
)}
</>
) : kind === "lot" ? (
<>
<Field label="Product" wide>
<select
autoFocus
required
disabled={!!value?.id}
value={f.product_id ?? ""}
onChange={(e) => set({ ...f, product_id: e.target.value })}
>
<option value="">Choose a product</option>
{products.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</Field>
{input(
"amount",
"Amount",
"number",
`In ${units.find((u) => u.id === products.find((p) => p.id === Number(f.product_id))?.qu_id_stock)?.name || "the product’s stock unit"}`,
0,
)}
{input(
"best_before_date",
"Best before",
"date",
"Blank uses product default for a new lot",
)}
{input("purchased_date", "Purchased", "date")}
{input(
"price",
"Purchase price",
"number",
"Total paid for this lot",
0,
)}
<Field label="Opened">
<select
value={f.open ?? 0}
onChange={(e) => set({ ...f, open: Number(e.target.value) })}
>
<option value="0">Unopened</option>
<option value="1">Opened</option>
</select>
</Field>
{input("opened_date", "Opened on", "date")}
<Field label="Note" wide>
<textarea
value={f.note || ""}
onChange={(e) => set({ ...f, note: e.target.value })}
/>
</Field>
</>
) : (
<>
{input("name", "Unit name")}
{input("name_plural", "Plural")}
{input("description", "Description")}
</>
)}
</div>
<FormActions busy={busy} onCancel={onClose} />
</form>
</Modal>
);
}
function App() {
const [data, setData] = useState({ products: [], lots: [], units: [] }),
[loading, setLoading] = useState(true),
[search, setSearch] = useState(""),
[filter, setFilter] = useState("All products"),
[editing, setEditing] = useState(null),
[deleting, setDeleting] = useState(null),
[consuming, setConsuming] = useState(null),
[amount, setAmount] = useState(""),
[note, setNote] = useState(""),
[unitPanel, setUnitPanel] = useState(false);
const load = async () => {
setData(await store.list());
setLoading(false);
};
const { busy, error, setError, act } = useOperations(load);
useEffect(() => {
load().catch((e) => {
setError(e.message);
setLoading(false);
});
for (const [name, handler] of [
["add-product", store.addProduct],
["add-stock", store.addStock],
])
L.events.listen(name, async (d) => {
try {
await handler(d);
await load();
} catch (e) {
setError(e.message);
throw e;
}
});
}, []);
const { products, lots, units } = data,
total = (p) =>
lots
.filter((l) => l.product_id === p.id)
.reduce((s, l) => s + l.amount, 0),
unit = (p) => units.find((u) => u.id === p.qu_id_stock),
unitName = (p, n) => {
const u = unit(p);
return u ? (n === 1 ? u.name : u.name_plural || u.name) : "stock units";
},
due = (l) =>
l.best_before_date && l.best_before_date <= addDays(localDate(), 7),
low = products.filter((p) => total(p) < p.min_stock_amount);
const filtered = products.filter(
(p) =>
`${p.name} ${p.description || ""}`
.toLowerCase()
.includes(search.toLowerCase()) &&
(filter === "All products" ||
(filter === "Low stock" && total(p) < p.min_stock_amount) ||
(filter === "Use soon" &&
lots.some((l) => l.product_id === p.id && due(l)))),
);
const save = (d) =>
act(async () => {
const { kind, value } = editing;
if (kind === "product")
await (value?.id
? store.editProduct(value.id, d)
: store.addProduct(d));
else if (kind === "lot")
await (value?.id ? store.editStock(value.id, d) : store.addStock(d));
else await store.unit(d, value?.id);
setEditing(null);
});
return (
<Page className="record-page">
<Header
title="Pantry"
subtitle="Stock, quantities, and best-before dates."
actions={
<>
{products.length === 0 && (
<Button disabled={busy} onClick={() => act(() => store.sample())}>
Add sample data
</Button>
)}
<Button onClick={() => setUnitPanel(true)}>Units</Button>
<Button
variant="primary"
onClick={() => setEditing({ kind: "product" })}
>
Add product
</Button>
</>
}
/>
<ErrorNotice error={error} clear={() => setError("")} />
<Stats
items={[
[products.length, "Products"],
[lots.filter(due).length, "Lots to use within 7 days"],
[low.length, "Products below minimum"],
]}
/>
<div className="ui-toolbar">
<Search
value={search}
onChange={setSearch}
placeholder="Search pantry products"
/>
<div className="ui-tabs">
{["All products", "Use soon", "Low stock"].map((f) => (
<Button
key={f}
aria-pressed={filter === f}
onClick={() => setFilter(f)}
>
{f}
</Button>
))}
</div>
</div>
{loading ? (
<p className="ui-loading">Loading pantry…</p>
) : filtered.length ? (
<div className="ui-records">
{filtered.map((p) => (
<section className="ui-panel" key={p.id}>
<div className="product-head">
<div>
<h3>
{p.name}{" "}
{total(p) < p.min_stock_amount && (
<span className="ui-badge ui-badge-warning">
Low stock
</span>
)}
</h3>
<small>
{p.description || "Pantry product"} · Minimum{" "}
{qty(p.min_stock_amount)} {unitName(p, p.min_stock_amount)}
</small>
</div>
<div className="ui-actions">
<div>
<span className="amount">{qty(total(p))}</span>{" "}
<small>{unitName(p, total(p))}</small>
</div>
<Button
aria-label={`Add stock for ${p.name}`}
onClick={() =>
setEditing({ kind: "lot", value: { product_id: p.id } })
}
>
+ Stock
</Button>
<Button
aria-label={`Edit ${p.name}`}
onClick={() => setEditing({ kind: "product", value: p })}
>
Edit
</Button>
<Button
aria-label={`Delete ${p.name}`}
onClick={() => setDeleting({ kind: "product", value: p })}
>
Delete
</Button>
</div>
</div>
{lots
.filter((l) => l.product_id === p.id)
.map((l) => (
<div className="lot" key={l.id}>
<div>
<strong>
{qty(l.amount)} {unitName(p, l.amount)}
</strong>
{l.note && <small>{l.note}</small>}
</div>
<div>
<span
className={`ui-badge ${due(l) ? (l.best_before_date < localDate() ? "ui-badge-danger" : "ui-badge-warning") : ""}`}
>
{l.best_before_date
? `Best before ${l.best_before_date}`
: "No expiry date"}
</span>
<small>
{l.purchased_date
? `Purchased ${l.purchased_date}`
: ""}
</small>
</div>
<div>
<span
className={`ui-badge ${l.open ? "ui-badge-success" : ""}`}
>
{l.open ? "Opened" : "Unopened"}
</span>
{l.opened_date && <small>On {l.opened_date}</small>}
</div>
<div className="ui-actions">
<Button
disabled={busy}
onClick={() => {
setConsuming(l);
setAmount(String(l.amount));
setNote("");
}}
>
Consume
</Button>
{!l.open && (
<Button
disabled={busy}
onClick={() => act(() => store.open(l.id))}
>
Open
</Button>
)}
<Button
aria-label={`Edit ${p.name} lot`}
onClick={() => setEditing({ kind: "lot", value: l })}
>
Edit
</Button>
<Button
aria-label={`Delete ${p.name} lot`}
onClick={() => setDeleting({ kind: "lot", value: l })}
>
Delete
</Button>
</div>
</div>
))}
{!lots.some((l) => l.product_id === p.id) && (
<p className="ui-panel-pad ui-muted">
No stock lots. Add stock to record a purchase.
</p>
)}
</section>
))}
</div>
) : (
<EmptyState
title={products.length ? "No products found" : "Your pantry is empty"}
>
{products.length
? "Try a different search or filter."
: "Add a product, then record its stock."}
</EmptyState>
)}
{unitPanel && !editing && !deleting && (
<Modal title="Quantity units" onClose={() => setUnitPanel(false)}>
<p className="ui-muted">
Purchase and stock units are selected separately on each product.
Stock amounts always use the stock unit; no conversion is assumed.
</p>
<div className="ui-records" style={{ marginTop: 20 }}>
{units.map((u) => (
<div className="ui-panel-pad ui-panel ui-list-header" key={u.id}>
<div>
<h3>{u.name}</h3>
<small>{u.name_plural || u.description}</small>
</div>
<div className="ui-actions">
<Button
onClick={() => setEditing({ kind: "unit", value: u })}
>
Edit
</Button>
<Button
onClick={() => setDeleting({ kind: "unit", value: u })}
>
Delete
</Button>
</div>
</div>
))}
</div>
<div className="ui-form-actions">
<Button
variant="primary"
onClick={() => setEditing({ kind: "unit" })}
>
+ New unit
</Button>
</div>
</Modal>
)}
{editing && (
<Editor
key={`${editing.kind}-${editing.value?.id || "new"}`}
{...editing}
products={products}
units={units}
onSave={save}
onClose={() => setEditing(null)}
busy={busy}
error={error}
/>
)}
{consuming && (
<Modal title="Consume from lot" onClose={() => setConsuming(null)}>
<ErrorNotice error={error} clear={() => setError("")} />
<p className="ui-muted">
Available: {qty(consuming.amount)}{" "}
{unitName(
products.find((p) => p.id === consuming.product_id),
consuming.amount,
)}
. Consuming the full amount removes this lot.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
act(async () => {
await store.consume(
consuming.id,
Number(amount),
note || undefined,
);
setConsuming(null);
});
}}
>
<div className="ui-form-grid" style={{ marginTop: 20 }}>
<Field label="Amount to consume">
<input
autoFocus
required
type="number"
min="0"
max={consuming.amount}
step="any"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</Field>
<Field label="Note">
<input value={note} onChange={(e) => setNote(e.target.value)} />
</Field>
</div>
<FormActions
busy={busy}
onCancel={() => setConsuming(null)}
label="Consume"
/>
</form>
</Modal>
)}
{deleting && (
<Modal
title={`Delete ${deleting.kind === "lot" ? "stock lot" : deleting.kind}?`}
onClose={() => setDeleting(null)}
>
<ErrorNotice error={error} clear={() => setError("")} />
<p>
{deleting.kind === "lot"
? "Remove this lot without recording consumption? Use Consume to record food used."
: `Delete “${deleting.value.name}”? Products must have no stock lots; units must be unused.`}
</p>
<div className="ui-form-actions">
<Button onClick={() => setDeleting(null)}>Cancel</Button>
<Button
variant="danger"
disabled={busy}
onClick={() =>
act(async () => {
const handler =
deleting.kind === "product"
? store.removeProduct
: deleting.kind === "lot"
? store.removeStock
: store.removeUnit;
await handler(deleting.value.id);
setDeleting(null);
})
}
>
Delete
</Button>
</div>
</Modal>
)}
</Page>
);
}
createRoot(document.getElementById("root")).render(<App />);