Initial commit: Apothecary v0.4.0
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Product } from "../../types.js";
|
||||
import { helpers, TODAY_STR } from "../../types.js";
|
||||
import { remainingShort } from "../../stats.js";
|
||||
import { fmt } from "../../format.js";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Icon, Input, Select, Textarea } from "../primitives/index.js";
|
||||
import { ScanField } from "../ScanField.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
|
||||
|
||||
export function ConsumeFlow({
|
||||
data,
|
||||
onClose,
|
||||
product: initialProduct,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onClose: () => void;
|
||||
product: Product | null;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const active = data.products.filter((p) => p.status === "active");
|
||||
const [productId, setProductId] = useState(initialProduct?.id ?? active[0]?.id ?? "");
|
||||
const [rating, setRating] = useState(4);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
|
||||
const product = data.products.find((p) => p.id === productId);
|
||||
|
||||
const finish = useMutation({
|
||||
mutationFn: () => api.finishProduct(productId, { date, rating, notes }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
if (!product) return null;
|
||||
const bin = data.bins.find((b) => b.id === product.binId);
|
||||
const lifespan = Math.round((+new Date(date) - +new Date(product.purchaseDate)) / 86_400_000);
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: "min(720px, 96vw)",
|
||||
margin: "40px 20px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader title="Mark as consumed" eyebrow="Archive · used up" onClose={onClose} />
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
<ScanField
|
||||
products={active}
|
||||
matchedProduct={product ?? null}
|
||||
onMatch={setProductId}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Field label="Or pick from list">
|
||||
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
|
||||
{active.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} — {helpers.brandName(data, p.brandId)} ({remainingShort(p)} left)
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
||||
{product.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
{helpers.brandName(data, product.brandId)} · {bin?.name} · purchased{" "}
|
||||
{fmt.dateShort(product.purchaseDate)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LASTED</div>
|
||||
<div className="serif" style={{ fontSize: 24 }}>{lifespan} days</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 24 }}>
|
||||
<Field label="Date finished">
|
||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Rating">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
alignItems: "center",
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setRating(n)}
|
||||
style={{ border: "none", background: "transparent", cursor: "pointer", padding: 2 }}
|
||||
>
|
||||
<Icon
|
||||
name="star"
|
||||
size={20}
|
||||
color={n <= rating ? "var(--amber)" : "var(--ink-4)"}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
fontSize: 12,
|
||||
color: "var(--ink-3)",
|
||||
fontFamily: "var(--mono)",
|
||||
}}
|
||||
>
|
||||
{rating}/5
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Field label="Final notes" hint="Flavor, effects, would you rebuy">
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="What stood out?"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<div />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="check"
|
||||
disabled={finish.isPending}
|
||||
onClick={() => finish.mutate()}
|
||||
>
|
||||
{finish.isPending ? "Saving…" : "Mark consumed"}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user