946e96c3ea
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>
115 lines
3.6 KiB
TypeScript
115 lines
3.6 KiB
TypeScript
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>
|
|
);
|
|
}
|