Add bulk editing to inventory tab with atomic batch API
Build and push image / build (push) Successful in 1m3s
Build and push image / build (push) Successful in 1m3s
Multi-select inventory items via checkboxes (select-all, shift-click range, group header select) and apply bulk actions through a floating toolbar: edit fields (shop, bin, price, THC/CBD), consume, checkout, check in, and mark gone. Backend processes all operations in a single SQLite transaction for atomicity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Item } from "../../types.js";
|
||||
import { TODAY_STR } 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 BulkCheckinModal({
|
||||
data,
|
||||
items,
|
||||
onClose,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
items: Item[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const eligible = items.filter((i) => i.status === "checked-out");
|
||||
const excluded = items.length - eligible.length;
|
||||
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
const [binId, setBinId] = useState(data.bins[0]?.id ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const checkin = useMutation({
|
||||
mutationFn: () => {
|
||||
const ops: BatchOp[] = eligible.map((i) => ({
|
||||
action: "checkin" as const,
|
||||
id: i.id,
|
||||
date,
|
||||
binId,
|
||||
}));
|
||||
return api.batchInventory(ops);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
const binName = data.bins.find((b) => b.id === binId)?.name ?? "bin";
|
||||
toast(`Checked ${eligible.length} item${eligible.length === 1 ? "" : "s"} into ${binName}`);
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: "min(640px, 96vw)",
|
||||
margin: "40px 20px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader title="Bulk check in" eyebrow={`${eligible.length} eligible item${eligible.length === 1 ? "" : "s"}`} onClose={onClose} />
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
{excluded > 0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--ink-2)",
|
||||
marginBottom: 20,
|
||||
padding: 14,
|
||||
background: "var(--amber-soft)",
|
||||
borderRadius: "var(--r-md)",
|
||||
}}
|
||||
>
|
||||
{excluded} item{excluded === 1 ? " is" : "s are"} not checked out and will be skipped.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eligible.length === 0 ? (
|
||||
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
|
||||
No checked-out items to return.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||
<Field label="Return to bin">
|
||||
<Select value={binId} onChange={(e) => setBinId(e.target.value)}>
|
||||
{data.bins.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Date">
|
||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}>
|
||||
Items: {eligible.map((i) => i.name).join(", ")}
|
||||
</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={checkin.isPending || eligible.length === 0 || !binId}
|
||||
onClick={() => checkin.mutate()}
|
||||
>
|
||||
{checkin.isPending ? "Saving…" : `Check in ${eligible.length}`}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Item } from "../../types.js";
|
||||
import { TODAY_STR } from "../../types.js";
|
||||
import { api } from "../../api.js";
|
||||
import type { BatchOp } from "../../api.js";
|
||||
import { Btn, Field, Input } from "../primitives/index.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||
import { useToast } from "../Toast.js";
|
||||
|
||||
export function BulkCheckoutModal({
|
||||
data,
|
||||
items,
|
||||
onClose,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
items: Item[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const eligible = items.filter((i) => i.status === "active");
|
||||
const excluded = items.length - eligible.length;
|
||||
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const checkout = useMutation({
|
||||
mutationFn: () => {
|
||||
const ops: BatchOp[] = eligible.map((i) => ({
|
||||
action: "checkout" as const,
|
||||
id: i.id,
|
||||
date,
|
||||
}));
|
||||
return api.batchInventory(ops);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
toast(`Checked out ${eligible.length} item${eligible.length === 1 ? "" : "s"}`);
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: "min(640px, 96vw)",
|
||||
margin: "40px 20px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader title="Bulk checkout" eyebrow={`${eligible.length} eligible item${eligible.length === 1 ? "" : "s"}`} onClose={onClose} />
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
{excluded > 0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--ink-2)",
|
||||
marginBottom: 20,
|
||||
padding: 14,
|
||||
background: "var(--amber-soft)",
|
||||
borderRadius: "var(--r-md)",
|
||||
}}
|
||||
>
|
||||
{excluded} item{excluded === 1 ? " is" : "s are"} not active and will be skipped.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eligible.length === 0 ? (
|
||||
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
|
||||
No active items to check out.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ maxWidth: 240 }}>
|
||||
<Field label="Date">
|
||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}>
|
||||
Items: {eligible.map((i) => i.name).join(", ")}
|
||||
</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="pocket"
|
||||
disabled={checkout.isPending || eligible.length === 0}
|
||||
onClick={() => checkout.mutate()}
|
||||
>
|
||||
{checkout.isPending ? "Saving…" : `Check out ${eligible.length}`}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Item } from "../../types.js";
|
||||
import { TODAY_STR } from "../../types.js";
|
||||
import { api } from "../../api.js";
|
||||
import type { BatchOp } from "../../api.js";
|
||||
import { Btn, Field, Icon, Input, Textarea } from "../primitives/index.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||
import { useToast } from "../Toast.js";
|
||||
|
||||
export function BulkConsumeModal({
|
||||
data,
|
||||
items,
|
||||
onClose,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
items: Item[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const eligible = items.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||
const excluded = items.length - eligible.length;
|
||||
|
||||
const [rating, setRating] = useState(4);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const finish = useMutation({
|
||||
mutationFn: () => {
|
||||
const ops: BatchOp[] = eligible.map((i) => ({
|
||||
action: "finish" as const,
|
||||
id: i.id,
|
||||
date,
|
||||
rating,
|
||||
notes: notes || undefined,
|
||||
}));
|
||||
return api.batchInventory(ops);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
toast(`Marked ${eligible.length} item${eligible.length === 1 ? "" : "s"} as consumed`);
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
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="Bulk consume" eyebrow={`${eligible.length} eligible item${eligible.length === 1 ? "" : "s"}`} onClose={onClose} />
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
{excluded > 0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--ink-2)",
|
||||
marginBottom: 20,
|
||||
padding: 14,
|
||||
background: "var(--amber-soft)",
|
||||
borderRadius: "var(--r-md)",
|
||||
}}
|
||||
>
|
||||
{excluded} item{excluded === 1 ? "" : "s"} already consumed or gone — {excluded === 1 ? "it" : "they"} will be skipped.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eligible.length === 0 ? (
|
||||
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
|
||||
No eligible items to consume.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||
<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="Notes (optional)">
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Shared notes for all items"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}>
|
||||
Items: {eligible.map((i) => i.name).join(", ")}
|
||||
</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={finish.isPending || eligible.length === 0}
|
||||
onClick={() => finish.mutate()}
|
||||
>
|
||||
{finish.isPending ? "Saving…" : `Mark ${eligible.length} consumed`}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Item } 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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
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 (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(4, 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="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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Item } from "../../types.js";
|
||||
import { TODAY_STR } from "../../types.js";
|
||||
import { api } from "../../api.js";
|
||||
import type { BatchOp } from "../../api.js";
|
||||
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||
import { useToast } from "../Toast.js";
|
||||
|
||||
const REASONS: [string, string][] = [
|
||||
["lost", "Lost / misplaced"],
|
||||
["damaged", "Damaged"],
|
||||
["expired", "Expired"],
|
||||
["gifted", "Gifted away"],
|
||||
["other", "Other"],
|
||||
];
|
||||
|
||||
export function BulkGoneModal({
|
||||
data,
|
||||
items,
|
||||
onClose,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
items: Item[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const eligible = items.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||
const excluded = items.length - eligible.length;
|
||||
|
||||
const [reason, setReason] = useState("lost");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mark = useMutation({
|
||||
mutationFn: () => {
|
||||
const ops: BatchOp[] = eligible.map((i) => ({
|
||||
action: "gone" as const,
|
||||
id: i.id,
|
||||
date,
|
||||
reason,
|
||||
notes: notes || undefined,
|
||||
}));
|
||||
return api.batchInventory(ops);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
toast(`Marked ${eligible.length} item${eligible.length === 1 ? "" : "s"} as gone`);
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: "min(640px, 96vw)",
|
||||
margin: "40px 20px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader
|
||||
title="Bulk mark as gone"
|
||||
eyebrow="Archive · not consumed"
|
||||
eyebrowColor="var(--terracotta)"
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--ink-2)",
|
||||
marginBottom: 20,
|
||||
padding: 14,
|
||||
background: "var(--amber-soft)",
|
||||
borderRadius: "var(--r-md)",
|
||||
}}
|
||||
>
|
||||
This marks items as lost, damaged, expired, or gifted. Counts as{" "}
|
||||
<strong>spend</strong> but not <strong>consumption</strong>.
|
||||
</div>
|
||||
|
||||
{excluded > 0 && (
|
||||
<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)",
|
||||
}}
|
||||
>
|
||||
{excluded} item{excluded === 1 ? " is" : "s are"} already consumed or gone and will be skipped.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eligible.length === 0 ? (
|
||||
<div style={{ textAlign: "center", color: "var(--ink-3)", padding: "24px 0", fontStyle: "italic" }}>
|
||||
No eligible items to mark gone.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||
<Field label="Reason">
|
||||
<Select value={reason} onChange={(e) => setReason(e.target.value)}>
|
||||
{REASONS.map(([k, l]) => (
|
||||
<option key={k} value={k}>{l}</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Date">
|
||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Field label="Notes (optional)">
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="What happened"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}>
|
||||
Items: {eligible.map((i) => i.name).join(", ")}
|
||||
</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="danger"
|
||||
icon="bin"
|
||||
disabled={mark.isPending || eligible.length === 0}
|
||||
onClick={() => mark.mutate()}
|
||||
>
|
||||
{mark.isPending ? "Saving…" : `Mark ${eligible.length} gone`}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user