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 />);