a1be29ab6e
Build and push image / build (push) Successful in 1m6s
Record the total weight of a jar (product + container) at acquisition so audits can be done by simply re-weighing the sealed jar. The tare is derived (containerWeight − productWeight), and the audit flow offers a "Weigh container" toggle that auto-calculates remaining product from the scale reading. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
211 lines
7.0 KiB
TypeScript
211 lines
7.0 KiB
TypeScript
import { useState } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import type { Bootstrap, Item } from "../../types.js";
|
|
import { TYPES } from "../../types.js";
|
|
import { api } from "../../api.js";
|
|
import type { BatchOp } from "../../api.js";
|
|
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
|
import { useToast } from "../Toast.js";
|
|
|
|
export function BulkEditModal({
|
|
data,
|
|
items,
|
|
onClose,
|
|
}: {
|
|
data: Bootstrap;
|
|
items: Item[];
|
|
onClose: () => void;
|
|
}) {
|
|
const qc = useQueryClient();
|
|
const { toast } = useToast();
|
|
|
|
const [shopId, setShopId] = useState("");
|
|
const [binId, setBinId] = useState("");
|
|
const [price, setPrice] = useState("");
|
|
const [thc, setThc] = useState("");
|
|
const [cbd, setCbd] = useState("");
|
|
const [totalCannabinoids, setTotalCannabinoids] = useState("");
|
|
const [purchaseDate, setPurchaseDate] = useState("");
|
|
const [containerWeight, setContainerWeight] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const hasBulkWeighable = items.some((i) => {
|
|
const cfg = TYPES.find((t) => t.id === i.type);
|
|
return i.kind === "bulk" && cfg?.weighable;
|
|
});
|
|
|
|
const save = useMutation({
|
|
mutationFn: () => {
|
|
const fields: Record<string, string | number | null> = {};
|
|
if (shopId) fields.shopId = shopId;
|
|
if (binId) fields.binId = binId;
|
|
if (price !== "") fields.price = parseFloat(price);
|
|
if (thc !== "") fields.thc = parseFloat(thc);
|
|
if (cbd !== "") fields.cbd = parseFloat(cbd);
|
|
if (totalCannabinoids !== "") fields.totalCannabinoids = parseFloat(totalCannabinoids);
|
|
if (purchaseDate) fields.purchaseDate = purchaseDate;
|
|
if (containerWeight !== "") fields.containerWeight = parseFloat(containerWeight);
|
|
|
|
if (Object.keys(fields).length === 0) {
|
|
return Promise.reject(new Error("No fields to update — fill in at least one field."));
|
|
}
|
|
|
|
const ops: BatchOp[] = items.map((i) => ({
|
|
action: "update" as const,
|
|
id: i.id,
|
|
fields,
|
|
}));
|
|
return api.batchInventory(ops);
|
|
},
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
|
toast(`Updated ${items.length} item${items.length === 1 ? "" : "s"}`);
|
|
onClose();
|
|
},
|
|
onError: (e: Error) => setError(e.message),
|
|
});
|
|
|
|
return (
|
|
<ModalBackdrop onClose={onClose}>
|
|
<div
|
|
style={{
|
|
width: "min(840px, 96vw)",
|
|
margin: "40px 20px",
|
|
background: "var(--bg)",
|
|
border: "1px solid var(--line)",
|
|
borderRadius: "var(--r-lg)",
|
|
boxShadow: "var(--shadow-lg)",
|
|
}}
|
|
>
|
|
<ModalHeader
|
|
title="Bulk edit"
|
|
eyebrow={`${items.length} item${items.length === 1 ? "" : "s"} selected`}
|
|
onClose={onClose}
|
|
/>
|
|
|
|
<div style={{ padding: 32 }}>
|
|
<div
|
|
style={{
|
|
fontSize: 13,
|
|
color: "var(--ink-2)",
|
|
marginBottom: 20,
|
|
padding: 14,
|
|
background: "var(--bg-2)",
|
|
borderRadius: "var(--r-md)",
|
|
border: "1px solid var(--line)",
|
|
}}
|
|
>
|
|
Only fields you fill in will be updated. Leave blank to keep current values.
|
|
</div>
|
|
|
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
|
|
Source
|
|
</div>
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16, marginBottom: 28 }}>
|
|
<Field label="Shop">
|
|
<Select value={shopId} onChange={(e) => setShopId(e.target.value)}>
|
|
<option value="">No change</option>
|
|
{data.shops.map((s) => (
|
|
<option key={s.id} value={s.id}>{s.name}</option>
|
|
))}
|
|
</Select>
|
|
</Field>
|
|
<Field label="Bin">
|
|
<Select value={binId} onChange={(e) => setBinId(e.target.value)}>
|
|
<option value="">No change</option>
|
|
{data.bins.map((b) => (
|
|
<option key={b.id} value={b.id}>{b.name}</option>
|
|
))}
|
|
</Select>
|
|
</Field>
|
|
</div>
|
|
|
|
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>
|
|
Values
|
|
</div>
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 16 }}>
|
|
<Field label="Price ($)">
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
placeholder="—"
|
|
value={price}
|
|
onChange={(e) => setPrice(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Purchase date">
|
|
<Input
|
|
type="date"
|
|
value={purchaseDate}
|
|
onChange={(e) => setPurchaseDate(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="THC %">
|
|
<Input
|
|
type="number"
|
|
step="0.1"
|
|
placeholder="—"
|
|
value={thc}
|
|
onChange={(e) => setThc(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="CBD %">
|
|
<Input
|
|
type="number"
|
|
step="0.1"
|
|
placeholder="—"
|
|
value={cbd}
|
|
onChange={(e) => setCbd(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Total cannabinoids %">
|
|
<Input
|
|
type="number"
|
|
step="0.1"
|
|
placeholder="—"
|
|
value={totalCannabinoids}
|
|
onChange={(e) => setTotalCannabinoids(e.target.value)}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
{hasBulkWeighable && (
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 16, marginTop: 16 }}>
|
|
<Field label="Container weight (g)">
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
placeholder="—"
|
|
value={containerWeight}
|
|
onChange={(e) => setContainerWeight(e.target.value)}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
|
)}
|
|
</div>
|
|
|
|
<ModalFooter>
|
|
<div />
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
|
<Btn
|
|
variant="primary"
|
|
icon="check"
|
|
disabled={save.isPending}
|
|
onClick={() => save.mutate()}
|
|
>
|
|
{save.isPending ? "Saving…" : `Update ${items.length} item${items.length === 1 ? "" : "s"}`}
|
|
</Btn>
|
|
</div>
|
|
</ModalFooter>
|
|
</div>
|
|
</ModalBackdrop>
|
|
);
|
|
}
|