Fix 18 UX issues: confirmations, undo, drawer nav, empty states, and polish
Build and push image / build (push) Successful in 54s
Build and push image / build (push) Successful in 54s
Comprehensive UX audit covering modals, drawers, dashboard, and inventory. Key changes: confirmation steps before destructive actions, undo via toast for consume/gone/checkout, back-navigation across entity drawers, optional ratings, discrete item count field, audit progress bar, and sortable column affordance. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,3 +18,4 @@ web/dist
|
|||||||
|
|
||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
.gstack/
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ archiveLegacyIfPresent();
|
|||||||
archiveV1IfPresent();
|
archiveV1IfPresent();
|
||||||
migrateAddCheckoutDate();
|
migrateAddCheckoutDate();
|
||||||
migrateAddContainerWeight();
|
migrateAddContainerWeight();
|
||||||
|
migrateAddPrevBinId();
|
||||||
|
|
||||||
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
|
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
|
||||||
db.exec(schema);
|
db.exec(schema);
|
||||||
@@ -35,6 +36,14 @@ function migrateAddContainerWeight(): void {
|
|||||||
db.exec(`ALTER TABLE inventory_items ADD COLUMN container_weight REAL`);
|
db.exec(`ALTER TABLE inventory_items ADD COLUMN container_weight REAL`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateAddPrevBinId(): void {
|
||||||
|
const cols = db
|
||||||
|
.prepare(`PRAGMA table_info(inventory_items)`)
|
||||||
|
.all() as { name: string }[];
|
||||||
|
if (cols.length === 0 || cols.some((c) => c.name === "prev_bin_id")) return;
|
||||||
|
db.exec(`ALTER TABLE inventory_items ADD COLUMN prev_bin_id TEXT REFERENCES bins(id)`);
|
||||||
|
}
|
||||||
|
|
||||||
// One-shot migration: the original schema put per-instance fields (weight,
|
// One-shot migration: the original schema put per-instance fields (weight,
|
||||||
// bin_id, etc.) directly on `products`. The split schema separates products
|
// bin_id, etc.) directly on `products`. The split schema separates products
|
||||||
// (catalog) from inventory_items (instance). When we detect the old shape,
|
// (catalog) from inventory_items (instance). When we detect the old shape,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type InventoryRow = {
|
|||||||
consumed_date: string | null;
|
consumed_date: string | null;
|
||||||
gone_date: string | null;
|
gone_date: string | null;
|
||||||
checkout_date: string | null;
|
checkout_date: string | null;
|
||||||
|
prev_bin_id: string | null;
|
||||||
rating: number | null;
|
rating: number | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
};
|
};
|
||||||
@@ -112,6 +113,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
|
|||||||
consumedDate: i.consumed_date,
|
consumedDate: i.consumed_date,
|
||||||
goneDate: i.gone_date,
|
goneDate: i.gone_date,
|
||||||
checkoutDate: i.checkout_date,
|
checkoutDate: i.checkout_date,
|
||||||
|
prevBinId: i.prev_bin_id,
|
||||||
rating: i.rating,
|
rating: i.rating,
|
||||||
notes: i.notes,
|
notes: i.notes,
|
||||||
audits: (auditsByInventory.get(i.id) ?? []).map((a) => ({
|
audits: (auditsByInventory.get(i.id) ?? []).map((a) => ({
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ function doCheckout(id: string, date: string): void {
|
|||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE inventory_items
|
`UPDATE inventory_items
|
||||||
SET status = 'checked-out', checkout_date = ?, bin_id = NULL
|
SET status = 'checked-out', checkout_date = ?, prev_bin_id = bin_id, bin_id = NULL
|
||||||
WHERE id = ? AND status = 'active'`,
|
WHERE id = ? AND status = 'active'`,
|
||||||
)
|
)
|
||||||
.run(date, id);
|
.run(date, id);
|
||||||
@@ -249,7 +249,7 @@ function doCheckin(id: string, date: string, binId: string, remainingWeight?: nu
|
|||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE inventory_items
|
`UPDATE inventory_items
|
||||||
SET status = 'active', bin_id = ?, checkout_date = NULL
|
SET status = 'active', bin_id = ?, checkout_date = NULL, prev_bin_id = NULL
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
).run(binId, id);
|
).run(binId, id);
|
||||||
|
|
||||||
@@ -355,6 +355,30 @@ inventoryRouter.post("/inventory/:id/gone", (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
inventoryRouter.post("/inventory/:id/reactivate", (req, res) => {
|
||||||
|
const { binId } = req.body as { binId: string };
|
||||||
|
try {
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE inventory_items
|
||||||
|
SET status = 'active',
|
||||||
|
bin_id = ?,
|
||||||
|
consumed_date = NULL,
|
||||||
|
gone_date = NULL,
|
||||||
|
checkout_date = NULL,
|
||||||
|
prev_bin_id = NULL
|
||||||
|
WHERE id = ? AND status IN ('consumed', 'gone', 'checked-out')`,
|
||||||
|
)
|
||||||
|
.run(binId, req.params.id);
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: "not found or already active" });
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
inventoryRouter.post("/inventory/:id/audit", (req, res) => {
|
inventoryRouter.post("/inventory/:id/audit", (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { date, mode, value, confirmedBy } = req.body as {
|
const { date, mode, value, confirmedBy } = req.body as {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS inventory_items (
|
|||||||
consumed_date TEXT,
|
consumed_date TEXT,
|
||||||
gone_date TEXT,
|
gone_date TEXT,
|
||||||
checkout_date TEXT,
|
checkout_date TEXT,
|
||||||
|
prev_bin_id TEXT REFERENCES bins(id),
|
||||||
rating INTEGER,
|
rating INTEGER,
|
||||||
notes TEXT
|
notes TEXT
|
||||||
);
|
);
|
||||||
|
|||||||
+40
-4
@@ -4,6 +4,12 @@ import { Routes, Route } from "react-router-dom";
|
|||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
import type { Bin, Bootstrap, Brand, Item, Product, Shop } from "./types.js";
|
import type { Bin, Bootstrap, Brand, Item, Product, Shop } from "./types.js";
|
||||||
import { enrichItems } from "./types.js";
|
import { enrichItems } from "./types.js";
|
||||||
|
|
||||||
|
type DrawerBack =
|
||||||
|
| { kind: "brand"; brand: Brand }
|
||||||
|
| { kind: "shop"; shop: Shop }
|
||||||
|
| { kind: "sku"; product: Product }
|
||||||
|
| null;
|
||||||
import { getStoredTimezone, TZ_STORAGE_KEY } from "./tz.js";
|
import { getStoredTimezone, TZ_STORAGE_KEY } from "./tz.js";
|
||||||
import { computeStats } from "./stats.js";
|
import { computeStats } from "./stats.js";
|
||||||
import { Sidebar } from "./components/Sidebar.js";
|
import { Sidebar } from "./components/Sidebar.js";
|
||||||
@@ -79,6 +85,7 @@ export function App() {
|
|||||||
const [selectedBrand, setSelectedBrand] = useState<Brand | null>(null);
|
const [selectedBrand, setSelectedBrand] = useState<Brand | null>(null);
|
||||||
const [selectedShop, setSelectedShop] = useState<Shop | null>(null);
|
const [selectedShop, setSelectedShop] = useState<Shop | null>(null);
|
||||||
const [modalProduct, setModalProduct] = useState<Product | null>(null);
|
const [modalProduct, setModalProduct] = useState<Product | null>(null);
|
||||||
|
const [drawerBack, setDrawerBack] = useState<DrawerBack>(null);
|
||||||
|
|
||||||
const [theme, setTheme] = useState<ThemeKey>(
|
const [theme, setTheme] = useState<ThemeKey>(
|
||||||
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
|
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
|
||||||
@@ -288,13 +295,21 @@ export function App() {
|
|||||||
<ProductDetail
|
<ProductDetail
|
||||||
item={selected}
|
item={selected}
|
||||||
data={data}
|
data={data}
|
||||||
onClose={() => setSelected(null)}
|
onClose={() => { setSelected(null); setDrawerBack(null); }}
|
||||||
onConsume={openConsume}
|
onConsume={openConsume}
|
||||||
onMarkGone={openMarkGone}
|
onMarkGone={openMarkGone}
|
||||||
onAudit={openAudit}
|
onAudit={openAudit}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onCheckout={openCheckout}
|
onCheckout={openCheckout}
|
||||||
onCheckin={openCheckin}
|
onCheckin={openCheckin}
|
||||||
|
backLabel={drawerBack?.kind === "brand" ? drawerBack.brand.name : drawerBack?.kind === "shop" ? drawerBack.shop.name : drawerBack?.kind === "sku" ? `SKU ${drawerBack.product.sku}` : undefined}
|
||||||
|
onBack={drawerBack ? () => {
|
||||||
|
setSelected(null);
|
||||||
|
if (drawerBack.kind === "brand") setSelectedBrand(drawerBack.brand);
|
||||||
|
else if (drawerBack.kind === "shop") setSelectedShop(drawerBack.shop);
|
||||||
|
else if (drawerBack.kind === "sku") setSelectedSku(drawerBack.product);
|
||||||
|
setDrawerBack(null);
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -302,7 +317,7 @@ export function App() {
|
|||||||
<SkuDetail
|
<SkuDetail
|
||||||
product={selectedSku}
|
product={selectedSku}
|
||||||
data={data}
|
data={data}
|
||||||
onClose={() => setSelectedSku(null)}
|
onClose={() => { setSelectedSku(null); setDrawerBack(null); }}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setModalProduct(selectedSku);
|
setModalProduct(selectedSku);
|
||||||
setModal("editSku");
|
setModal("editSku");
|
||||||
@@ -310,13 +325,22 @@ export function App() {
|
|||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
api.deleteProduct(selectedSku.id).then(() => {
|
api.deleteProduct(selectedSku.id).then(() => {
|
||||||
setSelectedSku(null);
|
setSelectedSku(null);
|
||||||
|
setDrawerBack(null);
|
||||||
queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
|
queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onSelectItem={(i) => {
|
onSelectItem={(i) => {
|
||||||
|
setDrawerBack({ kind: "sku", product: selectedSku });
|
||||||
setSelectedSku(null);
|
setSelectedSku(null);
|
||||||
setSelected(i);
|
setSelected(i);
|
||||||
}}
|
}}
|
||||||
|
backLabel={drawerBack?.kind === "brand" ? drawerBack.brand.name : drawerBack?.kind === "shop" ? drawerBack.shop.name : undefined}
|
||||||
|
onBack={drawerBack ? () => {
|
||||||
|
setSelectedSku(null);
|
||||||
|
if (drawerBack.kind === "brand") setSelectedBrand(drawerBack.brand);
|
||||||
|
else if (drawerBack.kind === "shop") setSelectedShop(drawerBack.shop);
|
||||||
|
setDrawerBack(null);
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -324,7 +348,7 @@ export function App() {
|
|||||||
<BrandDetail
|
<BrandDetail
|
||||||
brand={selectedBrand}
|
brand={selectedBrand}
|
||||||
data={data}
|
data={data}
|
||||||
onClose={() => setSelectedBrand(null)}
|
onClose={() => { setSelectedBrand(null); setDrawerBack(null); }}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setModalBrand(selectedBrand);
|
setModalBrand(selectedBrand);
|
||||||
setModal("editBrand");
|
setModal("editBrand");
|
||||||
@@ -332,17 +356,26 @@ export function App() {
|
|||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
api.deleteBrand(selectedBrand.id).then(() => {
|
api.deleteBrand(selectedBrand.id).then(() => {
|
||||||
setSelectedBrand(null);
|
setSelectedBrand(null);
|
||||||
|
setDrawerBack(null);
|
||||||
queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
|
queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onSelectSku={(p) => {
|
onSelectSku={(p) => {
|
||||||
|
setDrawerBack({ kind: "brand", brand: selectedBrand });
|
||||||
setSelectedBrand(null);
|
setSelectedBrand(null);
|
||||||
setSelectedSku(p);
|
setSelectedSku(p);
|
||||||
}}
|
}}
|
||||||
onSelectItem={(i) => {
|
onSelectItem={(i) => {
|
||||||
|
setDrawerBack({ kind: "brand", brand: selectedBrand });
|
||||||
setSelectedBrand(null);
|
setSelectedBrand(null);
|
||||||
setSelected(i);
|
setSelected(i);
|
||||||
}}
|
}}
|
||||||
|
backLabel={drawerBack?.kind === "shop" ? drawerBack.shop.name : undefined}
|
||||||
|
onBack={drawerBack ? () => {
|
||||||
|
setSelectedBrand(null);
|
||||||
|
if (drawerBack.kind === "shop") setSelectedShop(drawerBack.shop);
|
||||||
|
setDrawerBack(null);
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -350,7 +383,7 @@ export function App() {
|
|||||||
<ShopDetail
|
<ShopDetail
|
||||||
shop={selectedShop}
|
shop={selectedShop}
|
||||||
data={data}
|
data={data}
|
||||||
onClose={() => setSelectedShop(null)}
|
onClose={() => { setSelectedShop(null); setDrawerBack(null); }}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setModalShop(selectedShop);
|
setModalShop(selectedShop);
|
||||||
setModal("editShop");
|
setModal("editShop");
|
||||||
@@ -358,14 +391,17 @@ export function App() {
|
|||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
api.deleteShop(selectedShop.id).then(() => {
|
api.deleteShop(selectedShop.id).then(() => {
|
||||||
setSelectedShop(null);
|
setSelectedShop(null);
|
||||||
|
setDrawerBack(null);
|
||||||
queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
|
queryClient.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onSelectBrand={(b) => {
|
onSelectBrand={(b) => {
|
||||||
|
setDrawerBack({ kind: "shop", shop: selectedShop });
|
||||||
setSelectedShop(null);
|
setSelectedShop(null);
|
||||||
setSelectedBrand(b);
|
setSelectedBrand(b);
|
||||||
}}
|
}}
|
||||||
onSelectItem={(i) => {
|
onSelectItem={(i) => {
|
||||||
|
setDrawerBack({ kind: "shop", shop: selectedShop });
|
||||||
setSelectedShop(null);
|
setSelectedShop(null);
|
||||||
setSelected(i);
|
setSelected(i);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -147,6 +147,15 @@ export const api = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
reactivateInventoryItem: (
|
||||||
|
id: string,
|
||||||
|
body: { binId: string },
|
||||||
|
) =>
|
||||||
|
request<{ ok: true }>(`/inventory/${id}/reactivate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
auditInventoryItem: (
|
auditInventoryItem: (
|
||||||
id: string,
|
id: string,
|
||||||
body: { date: string; mode: AuditMode; value: number; confirmedBy?: string },
|
body: { date: string; mode: AuditMode; value: number; confirmedBy?: string },
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { getToday, getStoredTimezone } from "../tz.js";
|
|||||||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||||
import { Btn, Pill, Icon } from "./primitives/index.js";
|
import { Btn, Pill, Icon } from "./primitives/index.js";
|
||||||
import { remainingShort } from "../stats.js";
|
import { remainingShort } from "../stats.js";
|
||||||
|
import { useExitAnimation } from "../hooks/useExitAnimation.js";
|
||||||
|
import { useFocusTrap } from "../hooks/useFocusTrap.js";
|
||||||
|
|
||||||
export function BrandDetail({
|
export function BrandDetail({
|
||||||
brand,
|
brand,
|
||||||
@@ -14,6 +16,8 @@ export function BrandDetail({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onSelectSku,
|
onSelectSku,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
|
backLabel,
|
||||||
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
brand: Brand;
|
brand: Brand;
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
@@ -22,6 +26,8 @@ export function BrandDetail({
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onSelectSku: (p: Product) => void;
|
onSelectSku: (p: Product) => void;
|
||||||
onSelectItem: (i: Item) => void;
|
onSelectItem: (i: Item) => void;
|
||||||
|
backLabel?: string;
|
||||||
|
onBack?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const products = data.products.filter((p) => p.brandId === brand.id);
|
const products = data.products.filter((p) => p.brandId === brand.id);
|
||||||
const strainMap = new Map(data.strains.map((s) => [s.id, s]));
|
const strainMap = new Map(data.strains.map((s) => [s.id, s]));
|
||||||
@@ -47,13 +53,16 @@ export function BrandDetail({
|
|||||||
const todayStr = getToday(getStoredTimezone());
|
const todayStr = getToday(getStoredTimezone());
|
||||||
const tz = getStoredTimezone();
|
const tz = getStoredTimezone();
|
||||||
|
|
||||||
|
const { closing, triggerClose } = useExitAnimation(220, onClose);
|
||||||
|
const trapRef = useFocusTrap<HTMLDivElement>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") triggerClose();
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [onClose]);
|
}, [triggerClose]);
|
||||||
|
|
||||||
const statCards: [string, React.ReactNode][] = [
|
const statCards: [string, React.ReactNode][] = [
|
||||||
["SKUs", String(products.length)],
|
["SKUs", String(products.length)],
|
||||||
@@ -64,6 +73,9 @@ export function BrandDetail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={trapRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -71,16 +83,16 @@ export function BrandDetail({
|
|||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
animation: "backdrop-in 200ms ease-out",
|
animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
onClick={triggerClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
width: "min(720px, 100vw)",
|
width: "min(720px, 100vw)",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
animation: "drawer-in 250ms ease-out",
|
animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out",
|
||||||
background: "var(--bg)",
|
background: "var(--bg)",
|
||||||
borderLeft: "1px solid var(--line)",
|
borderLeft: "1px solid var(--line)",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
@@ -92,14 +104,34 @@ export function BrandDetail({
|
|||||||
padding: "20px 32px",
|
padding: "20px 32px",
|
||||||
borderBottom: "1px solid var(--line)",
|
borderBottom: "1px solid var(--line)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
flexDirection: "column",
|
||||||
justifyContent: "space-between",
|
gap: onBack ? 8 : 0,
|
||||||
position: "sticky",
|
position: "sticky",
|
||||||
top: 0,
|
top: 0,
|
||||||
background: "var(--bg)",
|
background: "var(--bg)",
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{onBack && backLabel && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--sage)",
|
||||||
|
cursor: "pointer",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ transform: "scaleX(-1)", display: "inline-flex" }}><Icon name="arrow" size={12} /></span> Back to {backLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
Brand
|
Brand
|
||||||
</div>
|
</div>
|
||||||
@@ -110,9 +142,11 @@ export function BrandDetail({
|
|||||||
icon="bin"
|
icon="bin"
|
||||||
disabled={hasItems}
|
disabled={hasItems}
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
|
title={hasItems ? "Cannot delete — has inventory items" : undefined}
|
||||||
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
|
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
|
||||||
/>
|
/>
|
||||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
<Btn variant="ghost" icon="close" onClick={triggerClose} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export function ProductDetail({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onCheckout,
|
onCheckout,
|
||||||
onCheckin,
|
onCheckin,
|
||||||
|
backLabel,
|
||||||
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
item: Item;
|
item: Item;
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
@@ -30,6 +32,8 @@ export function ProductDetail({
|
|||||||
onEdit: (i: Item) => void;
|
onEdit: (i: Item) => void;
|
||||||
onCheckout: (i: Item) => void;
|
onCheckout: (i: Item) => void;
|
||||||
onCheckin: (i: Item) => void;
|
onCheckin: (i: Item) => void;
|
||||||
|
backLabel?: string;
|
||||||
|
onBack?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const bin = data.bins.find((b) => b.id === item.binId);
|
const bin = data.bins.find((b) => b.id === item.binId);
|
||||||
const cfg = TYPES.find((t) => t.id === item.type);
|
const cfg = TYPES.find((t) => t.id === item.type);
|
||||||
@@ -136,25 +140,45 @@ export function ProductDetail({
|
|||||||
padding: "20px 32px",
|
padding: "20px 32px",
|
||||||
borderBottom: "1px solid var(--line)",
|
borderBottom: "1px solid var(--line)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
flexDirection: "column",
|
||||||
justifyContent: "space-between",
|
gap: onBack ? 8 : 0,
|
||||||
position: "sticky",
|
position: "sticky",
|
||||||
top: 0,
|
top: 0,
|
||||||
background: "var(--bg)",
|
background: "var(--bg)",
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{onBack && backLabel && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--sage)",
|
||||||
|
cursor: "pointer",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ transform: "scaleX(-1)", display: "inline-flex" }}><Icon name="arrow" size={12} /></span> Back to {backLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
Inventory · <span className="mono">{item.assetId}</span>
|
Inventory · <span className="mono">{item.assetId}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||||
{isActive && overdue && (
|
{isActive && (
|
||||||
<Btn variant="sage" icon="search" onClick={() => onAudit(item)}>
|
<Btn variant={overdue ? "sage" : "ghost"} icon="search" onClick={() => onAudit(item)}>
|
||||||
Audit
|
Audit
|
||||||
</Btn>
|
</Btn>
|
||||||
)}
|
)}
|
||||||
{isActive && !overdue && (
|
{isActive && (
|
||||||
<Btn variant="secondary" icon="pocket" onClick={() => onCheckout(item)}>
|
<Btn variant={overdue ? "ghost" : "secondary"} icon="pocket" onClick={() => onCheckout(item)}>
|
||||||
Check out
|
Check out
|
||||||
</Btn>
|
</Btn>
|
||||||
)}
|
)}
|
||||||
@@ -164,16 +188,6 @@ export function ProductDetail({
|
|||||||
</Btn>
|
</Btn>
|
||||||
)}
|
)}
|
||||||
<div style={{ width: 1, height: 20, background: "var(--line)", margin: "0 2px" }} />
|
<div style={{ width: 1, height: 20, background: "var(--line)", margin: "0 2px" }} />
|
||||||
{isActive && !overdue && (
|
|
||||||
<Btn variant="ghost" icon="search" onClick={() => onAudit(item)}>
|
|
||||||
Audit
|
|
||||||
</Btn>
|
|
||||||
)}
|
|
||||||
{isActive && overdue && (
|
|
||||||
<Btn variant="ghost" icon="pocket" onClick={() => onCheckout(item)}>
|
|
||||||
Check out
|
|
||||||
</Btn>
|
|
||||||
)}
|
|
||||||
{(isActive || isCheckedOut) && (
|
{(isActive || isCheckedOut) && (
|
||||||
<Btn variant="ghost" icon="leaf" onClick={() => onConsume(item)}>
|
<Btn variant="ghost" icon="leaf" onClick={() => onConsume(item)}>
|
||||||
Consume
|
Consume
|
||||||
@@ -190,6 +204,7 @@ export function ProductDetail({
|
|||||||
<Btn variant="ghost" icon="close" onClick={triggerClose} />
|
<Btn variant="ghost" icon="close" onClick={triggerClose} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: "32px 32px 60px" }}>
|
<div style={{ padding: "32px 32px 60px" }}>
|
||||||
<div style={{ display: "flex", alignItems: "baseline", gap: 16, marginBottom: 8 }}>
|
<div style={{ display: "flex", alignItems: "baseline", gap: 16, marginBottom: 8 }}>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { getToday, getStoredTimezone } from "../tz.js";
|
|||||||
import { fmt } from "../format.js";
|
import { fmt } from "../format.js";
|
||||||
import { Btn, Pill, Icon } from "./primitives/index.js";
|
import { Btn, Pill, Icon } from "./primitives/index.js";
|
||||||
import { remainingShort } from "../stats.js";
|
import { remainingShort } from "../stats.js";
|
||||||
|
import { useExitAnimation } from "../hooks/useExitAnimation.js";
|
||||||
|
import { useFocusTrap } from "../hooks/useFocusTrap.js";
|
||||||
|
|
||||||
export function ShopDetail({
|
export function ShopDetail({
|
||||||
shop,
|
shop,
|
||||||
@@ -50,13 +52,16 @@ export function ShopDetail({
|
|||||||
const todayStr = getToday(getStoredTimezone());
|
const todayStr = getToday(getStoredTimezone());
|
||||||
const tz = getStoredTimezone();
|
const tz = getStoredTimezone();
|
||||||
|
|
||||||
|
const { closing, triggerClose } = useExitAnimation(220, onClose);
|
||||||
|
const trapRef = useFocusTrap<HTMLDivElement>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") triggerClose();
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [onClose]);
|
}, [triggerClose]);
|
||||||
|
|
||||||
const statCards: [string, React.ReactNode][] = [
|
const statCards: [string, React.ReactNode][] = [
|
||||||
["Purchases", String(allItems.length)],
|
["Purchases", String(allItems.length)],
|
||||||
@@ -66,6 +71,9 @@ export function ShopDetail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={trapRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -73,16 +81,16 @@ export function ShopDetail({
|
|||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
animation: "backdrop-in 200ms ease-out",
|
animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
onClick={triggerClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
width: "min(720px, 100vw)",
|
width: "min(720px, 100vw)",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
animation: "drawer-in 250ms ease-out",
|
animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out",
|
||||||
background: "var(--bg)",
|
background: "var(--bg)",
|
||||||
borderLeft: "1px solid var(--line)",
|
borderLeft: "1px solid var(--line)",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
@@ -112,9 +120,10 @@ export function ShopDetail({
|
|||||||
icon="bin"
|
icon="bin"
|
||||||
disabled={hasItems}
|
disabled={hasItems}
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
|
title={hasItems ? "Cannot delete — has inventory items" : undefined}
|
||||||
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
|
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
|
||||||
/>
|
/>
|
||||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
<Btn variant="ghost" icon="close" onClick={triggerClose} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ export function Sidebar({
|
|||||||
))}
|
))}
|
||||||
<div className="nav-section">Quick</div>
|
<div className="nav-section">Quick</div>
|
||||||
<div className="nav-divider" />
|
<div className="nav-divider" />
|
||||||
<button className="nav-link nav-action" onClick={onAddProduct} title="Add product">
|
<button className="nav-link nav-action" onClick={onAddProduct} title="Add inventory">
|
||||||
<Icon name="plus" size={16} /> <span className="nav-label">Add product</span>
|
<Icon name="plus" size={16} /> <span className="nav-label">Add inventory</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="nav-link nav-action" onClick={onAudit} title="Audit">
|
<button className="nav-link nav-action" onClick={onAudit} title="Audit">
|
||||||
<Icon name="search" size={16} /> <span className="nav-label">Audit</span>
|
<Icon name="search" size={16} /> <span className="nav-label">Audit</span>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { getToday, getStoredTimezone } from "../tz.js";
|
|||||||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||||
import { Btn, Pill, Icon } from "./primitives/index.js";
|
import { Btn, Pill, Icon } from "./primitives/index.js";
|
||||||
import { remainingShort } from "../stats.js";
|
import { remainingShort } from "../stats.js";
|
||||||
|
import { useExitAnimation } from "../hooks/useExitAnimation.js";
|
||||||
|
import { useFocusTrap } from "../hooks/useFocusTrap.js";
|
||||||
|
|
||||||
export function SkuDetail({
|
export function SkuDetail({
|
||||||
product,
|
product,
|
||||||
@@ -13,6 +15,8 @@ export function SkuDetail({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
|
backLabel,
|
||||||
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
product: Product;
|
product: Product;
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
@@ -20,6 +24,8 @@ export function SkuDetail({
|
|||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onSelectItem: (i: Item) => void;
|
onSelectItem: (i: Item) => void;
|
||||||
|
backLabel?: string;
|
||||||
|
onBack?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const strain = data.strains.find((s) => s.id === product.strainId);
|
const strain = data.strains.find((s) => s.id === product.strainId);
|
||||||
const cfg = TYPES.find((t) => t.id === product.type);
|
const cfg = TYPES.find((t) => t.id === product.type);
|
||||||
@@ -65,13 +71,16 @@ export function SkuDetail({
|
|||||||
(a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate),
|
(a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { closing, triggerClose } = useExitAnimation(220, onClose);
|
||||||
|
const trapRef = useFocusTrap<HTMLDivElement>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") triggerClose();
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [onClose]);
|
}, [triggerClose]);
|
||||||
|
|
||||||
const statCards: [string, React.ReactNode][] = [
|
const statCards: [string, React.ReactNode][] = [
|
||||||
["Purchases", String(items.length)],
|
["Purchases", String(items.length)],
|
||||||
@@ -85,6 +94,9 @@ export function SkuDetail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={trapRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -92,16 +104,16 @@ export function SkuDetail({
|
|||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
animation: "backdrop-in 200ms ease-out",
|
animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
onClick={triggerClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
width: "min(720px, 100vw)",
|
width: "min(720px, 100vw)",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
animation: "drawer-in 250ms ease-out",
|
animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out",
|
||||||
background: "var(--bg)",
|
background: "var(--bg)",
|
||||||
borderLeft: "1px solid var(--line)",
|
borderLeft: "1px solid var(--line)",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
@@ -113,14 +125,34 @@ export function SkuDetail({
|
|||||||
padding: "20px 32px",
|
padding: "20px 32px",
|
||||||
borderBottom: "1px solid var(--line)",
|
borderBottom: "1px solid var(--line)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
flexDirection: "column",
|
||||||
justifyContent: "space-between",
|
gap: onBack ? 8 : 0,
|
||||||
position: "sticky",
|
position: "sticky",
|
||||||
top: 0,
|
top: 0,
|
||||||
background: "var(--bg)",
|
background: "var(--bg)",
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{onBack && backLabel && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--sage)",
|
||||||
|
cursor: "pointer",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ transform: "scaleX(-1)", display: "inline-flex" }}><Icon name="arrow" size={12} /></span> Back to {backLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||||||
SKU · <span className="mono">{product.sku}</span>
|
SKU · <span className="mono">{product.sku}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,9 +163,11 @@ export function SkuDetail({
|
|||||||
icon="bin"
|
icon="bin"
|
||||||
disabled={hasItems}
|
disabled={hasItems}
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
|
title={hasItems ? "Cannot delete — has inventory items" : undefined}
|
||||||
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
|
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
|
||||||
/>
|
/>
|
||||||
<Btn variant="ghost" icon="close" onClick={onClose} />
|
<Btn variant="ghost" icon="close" onClick={triggerClose} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,20 @@ import { Icon } from "./primitives/index.js";
|
|||||||
|
|
||||||
type ToastType = "success" | "error";
|
type ToastType = "success" | "error";
|
||||||
|
|
||||||
|
interface ToastAction {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface Toast {
|
interface Toast {
|
||||||
id: number;
|
id: number;
|
||||||
message: string;
|
message: string;
|
||||||
type: ToastType;
|
type: ToastType;
|
||||||
|
action?: ToastAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToastContext = createContext<{
|
const ToastContext = createContext<{
|
||||||
toast: (message: string, type?: ToastType) => void;
|
toast: (message: string, type?: ToastType, action?: ToastAction) => void;
|
||||||
}>({ toast: () => {} });
|
}>({ toast: () => {} });
|
||||||
|
|
||||||
export const useToast = () => useContext(ToastContext);
|
export const useToast = () => useContext(ToastContext);
|
||||||
@@ -44,9 +50,9 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toast = useCallback((message: string, type: ToastType = "success") => {
|
const toast = useCallback((message: string, type: ToastType = "success", action?: ToastAction) => {
|
||||||
const id = nextId++;
|
const id = nextId++;
|
||||||
setToasts((prev) => [...prev, { id, message, type }]);
|
setToasts((prev) => [...prev, { id, message, type, action }]);
|
||||||
startTimer(id);
|
startTimer(id);
|
||||||
}, [startTimer]);
|
}, [startTimer]);
|
||||||
|
|
||||||
@@ -94,6 +100,28 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|||||||
color={t.type === "error" ? "var(--terracotta)" : "var(--sage)"}
|
color={t.type === "error" ? "var(--terracotta)" : "var(--sage)"}
|
||||||
/>
|
/>
|
||||||
<span style={{ flex: 1 }}>{t.message}</span>
|
<span style={{ flex: 1 }}>{t.message}</span>
|
||||||
|
{t.action && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
t.action!.onClick();
|
||||||
|
dismiss(t.id);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "2px 6px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--sage)",
|
||||||
|
flexShrink: 0,
|
||||||
|
textDecoration: "underline",
|
||||||
|
textUnderlineOffset: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => dismiss(t.id)}
|
onClick={() => dismiss(t.id)}
|
||||||
aria-label="Dismiss notification"
|
aria-label="Dismiss notification"
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ function SelectProductStep({
|
|||||||
const [newBrandName, setNewBrandName] = useState("");
|
const [newBrandName, setNewBrandName] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { setError(null); }, [newSku, newName, newBrandId, newBrandName]);
|
||||||
|
|
||||||
const handleScan = (result: ScanResult) => {
|
const handleScan = (result: ScanResult) => {
|
||||||
if (result.kind === "product") {
|
if (result.kind === "product") {
|
||||||
onPickProduct(result.product.id);
|
onPickProduct(result.product.id);
|
||||||
@@ -383,10 +385,13 @@ function InstanceDetailsStep({
|
|||||||
const [newShopLocation, setNewShopLocation] = useState("");
|
const [newShopLocation, setNewShopLocation] = useState("");
|
||||||
const [newBinName, setNewBinName] = useState("");
|
const [newBinName, setNewBinName] = useState("");
|
||||||
const [newBinCapacity, setNewBinCapacity] = useState(10);
|
const [newBinCapacity, setNewBinCapacity] = useState(10);
|
||||||
|
const [countOriginal, setCountOriginal] = useState(last?.countOriginal ?? 1);
|
||||||
const [containerWeight, setContainerWeight] = useState("");
|
const [containerWeight, setContainerWeight] = useState("");
|
||||||
const [totalCannaManual, setTotalCannaManual] = useState(!!last);
|
const [totalCannaManual, setTotalCannaManual] = useState(!!last);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { setError(null); }, [assetId]);
|
||||||
|
|
||||||
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
||||||
setForm((f) => {
|
setForm((f) => {
|
||||||
const next = { ...f, [k]: v };
|
const next = { ...f, [k]: v };
|
||||||
@@ -429,9 +434,9 @@ function InstanceDetailsStep({
|
|||||||
binId,
|
binId,
|
||||||
weight: isDiscrete ? undefined : form.weight,
|
weight: isDiscrete ? undefined : form.weight,
|
||||||
containerWeight: !isDiscrete && containerWeight !== "" ? parseFloat(containerWeight) : undefined,
|
containerWeight: !isDiscrete && containerWeight !== "" ? parseFloat(containerWeight) : undefined,
|
||||||
countOriginal: isDiscrete ? 1 : undefined,
|
countOriginal: isDiscrete ? countOriginal : undefined,
|
||||||
unitWeight: isDiscrete ? form.unitWeight : undefined,
|
unitWeight: isDiscrete ? form.unitWeight : undefined,
|
||||||
price: form.price,
|
price: isDiscrete ? form.price * countOriginal : form.price,
|
||||||
thc: form.thc,
|
thc: form.thc,
|
||||||
cbd: form.cbd,
|
cbd: form.cbd,
|
||||||
totalCannabinoids: form.totalCannabinoids,
|
totalCannabinoids: form.totalCannabinoids,
|
||||||
@@ -590,7 +595,17 @@ function InstanceDetailsStep({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isDiscrete ? (
|
{isDiscrete ? (
|
||||||
<Field label={`Unit weight (${cfg?.weightUnit ?? "g"})`} span={2} hint="Weight of one unit — for grams stats">
|
<>
|
||||||
|
<Field label="Count" hint="How many units in this purchase">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="1"
|
||||||
|
value={countOriginal}
|
||||||
|
onChange={(e) => setCountOriginal(Math.max(1, Math.floor(+e.target.value || 1)))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={`Unit weight (${cfg?.weightUnit ?? "g"})`} hint="Weight of one unit — for grams stats">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
@@ -598,6 +613,7 @@ function InstanceDetailsStep({
|
|||||||
onChange={(e) => update("unitWeight", +e.target.value)}
|
onChange={(e) => update("unitWeight", +e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Field label={`Size (${cfg?.unit ?? "g"})`} span={2}>
|
<Field label={`Size (${cfg?.unit ?? "g"})`} span={2}>
|
||||||
<Input
|
<Input
|
||||||
@@ -610,7 +626,7 @@ function InstanceDetailsStep({
|
|||||||
)}
|
)}
|
||||||
<Field
|
<Field
|
||||||
label={isDiscrete ? "Price per unit ($)" : "Total price ($)"}
|
label={isDiscrete ? "Price per unit ($)" : "Total price ($)"}
|
||||||
hint={isDiscrete && form.price > 0 ? `1 × ${fmt.money(form.price)} = ${fmt.money(form.price)} total` : undefined}
|
hint={isDiscrete && form.price > 0 && countOriginal > 1 ? `${countOriginal} × ${fmt.money(form.price)} = ${fmt.money(form.price * countOriginal)} total` : undefined}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -148,6 +148,20 @@ export function AuditFlow({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{queue && queue.length > 1 && (
|
||||||
|
<div style={{ height: 3, background: "var(--bg-3)" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${((queueIdx + 1) / queue.length) * 100}%`,
|
||||||
|
background: "var(--sage)",
|
||||||
|
borderRadius: "0 2px 2px 0",
|
||||||
|
transition: "width 300ms ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ padding: 32 }}>
|
<div style={{ padding: 32 }}>
|
||||||
<ScanField
|
<ScanField
|
||||||
items={overdueFirst}
|
items={overdueFirst}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Bootstrap, Item } from "../../types.js";
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
import { TYPES, helpers, enrichItems } from "../../types.js";
|
import { TYPES, helpers, enrichItems } from "../../types.js";
|
||||||
@@ -24,12 +24,15 @@ export function CheckinFlow({
|
|||||||
const allItems = enrichItems(data);
|
const allItems = enrichItems(data);
|
||||||
const checkedOut = allItems.filter((i) => i.status === "checked-out");
|
const checkedOut = allItems.filter((i) => i.status === "checked-out");
|
||||||
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
||||||
const [binId, setBinId] = useState(data.bins[0]?.id ?? "");
|
const [binId, setBinId] = useState(initialItem?.prevBinId ?? data.bins[0]?.id ?? "");
|
||||||
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
||||||
const [remaining, setRemaining] = useState("");
|
const [remaining, setRemaining] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const item = allItems.find((i) => i.id === itemId);
|
const item = allItems.find((i) => i.id === itemId);
|
||||||
|
|
||||||
|
useEffect(() => { setError(null); }, [itemId]);
|
||||||
|
|
||||||
const isBulk = item?.kind === "bulk";
|
const isBulk = item?.kind === "bulk";
|
||||||
const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined;
|
const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined;
|
||||||
const est = item ? helpers.estimatedRemaining(item, getToday(getStoredTimezone())) : 0;
|
const est = item ? helpers.estimatedRemaining(item, getToday(getStoredTimezone())) : 0;
|
||||||
@@ -58,6 +61,7 @@ export function CheckinFlow({
|
|||||||
if (result.kind === "item") {
|
if (result.kind === "item") {
|
||||||
setItemId(result.item.id);
|
setItemId(result.item.id);
|
||||||
const scanned = allItems.find((i) => i.id === result.item.id);
|
const scanned = allItems.find((i) => i.id === result.item.id);
|
||||||
|
if (scanned?.prevBinId) setBinId(scanned.prevBinId);
|
||||||
if (scanned?.kind === "bulk") {
|
if (scanned?.kind === "bulk") {
|
||||||
setRemaining(helpers.estimatedRemaining(scanned, getToday(getStoredTimezone())).toFixed(2));
|
setRemaining(helpers.estimatedRemaining(scanned, getToday(getStoredTimezone())).toFixed(2));
|
||||||
}
|
}
|
||||||
@@ -129,7 +133,10 @@ export function CheckinFlow({
|
|||||||
marginTop: 24,
|
marginTop: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Field label="Return to bin">
|
<Field
|
||||||
|
label="Return to bin"
|
||||||
|
hint={item.prevBinId ? `Was in ${data.bins.find((b) => b.id === item.prevBinId)?.name ?? "unknown"} before checkout` : undefined}
|
||||||
|
>
|
||||||
<Select value={binId} onChange={(e) => setBinId(e.target.value)}>
|
<Select value={binId} onChange={(e) => setBinId(e.target.value)}>
|
||||||
{data.bins.map((b) => (
|
{data.bins.map((b) => (
|
||||||
<option key={b.id} value={b.id}>
|
<option key={b.id} value={b.id}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Bootstrap, Item } from "../../types.js";
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
import { helpers, enrichItems } from "../../types.js";
|
import { helpers, enrichItems } from "../../types.js";
|
||||||
@@ -29,11 +29,22 @@ export function CheckoutFlow({
|
|||||||
|
|
||||||
const item = allItems.find((i) => i.id === itemId);
|
const item = allItems.find((i) => i.id === itemId);
|
||||||
|
|
||||||
|
useEffect(() => { setError(null); }, [itemId]);
|
||||||
|
|
||||||
const checkout = useMutation({
|
const checkout = useMutation({
|
||||||
mutationFn: () => api.checkoutInventoryItem(itemId, { date }),
|
mutationFn: () => api.checkoutInventoryItem(itemId, { date }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
toast(`Checked out ${item?.name ?? "item"}`);
|
const undoItemId = itemId;
|
||||||
|
const undoBinId = item?.binId ?? data.bins[0]?.id ?? "";
|
||||||
|
toast(`Checked out ${item?.name ?? "item"}`, "success", {
|
||||||
|
label: "Undo",
|
||||||
|
onClick: () => {
|
||||||
|
api.reactivateInventoryItem(undoItemId, { binId: undoBinId }).then(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (e: Error) => setError(e.message),
|
onError: (e: Error) => setError(e.message),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Bootstrap, Item } from "../../types.js";
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
import { helpers, enrichItems } from "../../types.js";
|
import { helpers, enrichItems } from "../../types.js";
|
||||||
@@ -24,18 +24,31 @@ export function ConsumeFlow({
|
|||||||
const allItems = enrichItems(data);
|
const allItems = enrichItems(data);
|
||||||
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
|
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||||
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
||||||
const [rating, setRating] = useState(4);
|
const [rating, setRating] = useState<number | null>(null);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
const item = allItems.find((i) => i.id === itemId);
|
const item = allItems.find((i) => i.id === itemId);
|
||||||
|
|
||||||
|
useEffect(() => { setError(null); setConfirming(false); }, [itemId]);
|
||||||
|
|
||||||
const finish = useMutation({
|
const finish = useMutation({
|
||||||
mutationFn: () => api.finishInventoryItem(itemId, { date, rating, notes }),
|
mutationFn: () => api.finishInventoryItem(itemId, { date, rating: rating ?? undefined, notes }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
toast(`Marked ${item?.name ?? "item"} as consumed — ${rating}/5 stars`);
|
const ratingStr = rating != null ? ` — ${rating}/5 stars` : "";
|
||||||
|
const undoItemId = itemId;
|
||||||
|
const undoBinId = item?.binId ?? data.bins[0]?.id ?? "";
|
||||||
|
toast(`Marked ${item?.name ?? "item"} as consumed${ratingStr}`, "success", {
|
||||||
|
label: "Undo",
|
||||||
|
onClick: () => {
|
||||||
|
api.reactivateInventoryItem(undoItemId, { binId: undoBinId }).then(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (e: Error) => setError(e.message),
|
onError: (e: Error) => setError(e.message),
|
||||||
@@ -110,7 +123,7 @@ export function ConsumeFlow({
|
|||||||
<Field label="Date finished">
|
<Field label="Date finished">
|
||||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Rating">
|
<Field label="Rating" hint="Optional — click to rate, click again to clear">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -125,13 +138,13 @@ export function ConsumeFlow({
|
|||||||
{[1, 2, 3, 4, 5].map((n) => (
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
<button
|
<button
|
||||||
key={n}
|
key={n}
|
||||||
onClick={() => setRating(n)}
|
onClick={() => setRating(rating === n ? null : n)}
|
||||||
style={{ border: "none", background: "transparent", cursor: "pointer", padding: 2 }}
|
style={{ border: "none", background: "transparent", cursor: "pointer", padding: 2 }}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name="star"
|
name="star"
|
||||||
size={20}
|
size={20}
|
||||||
color={n <= rating ? "var(--amber)" : "var(--ink-4)"}
|
color={rating != null && n <= rating ? "var(--amber)" : "var(--ink-4)"}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -143,7 +156,7 @@ export function ConsumeFlow({
|
|||||||
fontFamily: "var(--mono)",
|
fontFamily: "var(--mono)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{rating}/5
|
{rating != null ? `${rating}/5` : "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -160,6 +173,21 @@ export function ConsumeFlow({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{confirming && item && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--amber-soft)",
|
||||||
|
border: "1px solid var(--amber)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mark <strong>{item.name}</strong> (<span className="mono">{item.assetId}</span>) as consumed? This cannot be undone.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
)}
|
)}
|
||||||
@@ -171,14 +199,28 @@ export function ConsumeFlow({
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||||
|
{confirming ? (
|
||||||
|
<>
|
||||||
|
<Btn variant="ghost" onClick={() => setConfirming(false)}>Back</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="danger"
|
||||||
|
icon="check"
|
||||||
|
disabled={finish.isPending}
|
||||||
|
onClick={() => finish.mutate()}
|
||||||
|
>
|
||||||
|
{finish.isPending ? "Saving…" : "Confirm"}
|
||||||
|
</Btn>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<Btn
|
<Btn
|
||||||
variant="primary"
|
variant="primary"
|
||||||
icon="check"
|
icon="check"
|
||||||
disabled={finish.isPending || !item}
|
disabled={!item}
|
||||||
onClick={() => finish.mutate()}
|
onClick={() => setConfirming(true)}
|
||||||
>
|
>
|
||||||
{finish.isPending ? "Saving…" : error ? "Try again" : "Mark consumed"}
|
{error ? "Try again" : "Mark consumed"}
|
||||||
</Btn>
|
</Btn>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Bootstrap, Item } from "../../types.js";
|
import type { Bootstrap, Item } from "../../types.js";
|
||||||
import { helpers, enrichItems } from "../../types.js";
|
import { helpers, enrichItems } from "../../types.js";
|
||||||
import { getToday, getStoredTimezone } from "../../tz.js";
|
import { getToday, getStoredTimezone } from "../../tz.js";
|
||||||
import { remainingShort } from "../../stats.js";
|
import { remainingShort } from "../../stats.js";
|
||||||
|
import { fmt } from "../../format.js";
|
||||||
import { api } from "../../api.js";
|
import { api } from "../../api.js";
|
||||||
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
|
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
|
||||||
|
import { ScanField, type ScanResult } from "../ScanField.js";
|
||||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||||
import { useToast } from "../Toast.js";
|
import { useToast } from "../Toast.js";
|
||||||
|
|
||||||
@@ -30,24 +32,42 @@ export function MarkGoneFlow({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const allItems = enrichItems(data);
|
const allItems = enrichItems(data);
|
||||||
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
|
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||||
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
|
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
||||||
const [reason, setReason] = useState("lost");
|
const [reason, setReason] = useState("lost");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
const [date, setDate] = useState(getToday(getStoredTimezone()));
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
const item = allItems.find((i) => i.id === itemId);
|
const item = allItems.find((i) => i.id === itemId);
|
||||||
|
|
||||||
|
useEffect(() => { setError(null); setConfirming(false); }, [itemId]);
|
||||||
|
|
||||||
const mark = useMutation({
|
const mark = useMutation({
|
||||||
mutationFn: () => api.markInventoryItemGone(itemId, { date, reason, notes }),
|
mutationFn: () => api.markInventoryItemGone(itemId, { date, reason, notes }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
toast(`Marked ${item?.name ?? "item"} as gone`);
|
const undoItemId = itemId;
|
||||||
|
const undoBinId = item?.binId ?? data.bins[0]?.id ?? "";
|
||||||
|
toast(`Marked ${item?.name ?? "item"} as gone`, "success", {
|
||||||
|
label: "Undo",
|
||||||
|
onClick: () => {
|
||||||
|
api.reactivateInventoryItem(undoItemId, { binId: undoBinId }).then(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (e: Error) => setError(e.message),
|
onError: (e: Error) => setError(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!item) return null;
|
const handleScan = (result: ScanResult) => {
|
||||||
|
if (result.kind === "item") {
|
||||||
|
setItemId(result.item.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bin = item ? data.bins.find((b) => b.id === item.binId) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalBackdrop onClose={onClose}>
|
<ModalBackdrop onClose={onClose}>
|
||||||
@@ -83,17 +103,53 @@ export function MarkGoneFlow({
|
|||||||
<strong>spend</strong> but not as <strong>consumption</strong>, so daily averages stay accurate.
|
<strong>spend</strong> but not as <strong>consumption</strong>, so daily averages stay accurate.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Inventory item">
|
<ScanField
|
||||||
<Select value={itemId} onChange={(e) => setItemId(e.target.value)}>
|
items={active}
|
||||||
{active.map((i) => (
|
products={[]}
|
||||||
<option key={i.id} value={i.id}>
|
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
|
||||||
{i.assetId} · {i.name} — {helpers.brandName(data, i.brandId)} ({remainingShort(i)} left)
|
onMatch={handleScan}
|
||||||
</option>
|
mode="assetId"
|
||||||
))}
|
/>
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 16 }}>
|
{active.length === 0 ? (
|
||||||
|
<div style={{ marginTop: 24, textAlign: "center", color: "var(--ink-3)", fontSize: 13, fontStyle: "italic", padding: "24px 0" }}>
|
||||||
|
No active items to mark as gone.
|
||||||
|
</div>
|
||||||
|
) : !item ? (
|
||||||
|
<div style={{ marginTop: 24, textAlign: "center", color: "var(--ink-3)", fontSize: 13, fontStyle: "italic", padding: "24px 0" }}>
|
||||||
|
Scan or type an asset ID to continue.
|
||||||
|
</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 }}>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||||
|
<span className="mono">{item.assetId}</span> · {helpers.brandName(data, item.brandId)} · {bin?.name ?? "no bin"} · {remainingShort(item)} left
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: "right" }}>
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>PURCHASED</div>
|
||||||
|
<div className="serif" style={{ fontSize: 18 }}>
|
||||||
|
{fmt.dateShort(item.purchaseDate, getStoredTimezone())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 24 }}>
|
||||||
<Field label="Reason">
|
<Field label="Reason">
|
||||||
<Select value={reason} onChange={(e) => setReason(e.target.value)}>
|
<Select value={reason} onChange={(e) => setReason(e.target.value)}>
|
||||||
{REASONS.map(([k, l]) => (
|
{REASONS.map(([k, l]) => (
|
||||||
@@ -115,6 +171,23 @@ export function MarkGoneFlow({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirming && item && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 14,
|
||||||
|
background: "var(--amber-soft)",
|
||||||
|
border: "1px solid var(--amber)",
|
||||||
|
borderRadius: "var(--r-md)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mark <strong>{item.name}</strong> (<span className="mono">{item.assetId}</span>) as gone? This cannot be undone.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
|
||||||
@@ -125,9 +198,18 @@ export function MarkGoneFlow({
|
|||||||
<div />
|
<div />
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||||
|
{confirming ? (
|
||||||
|
<>
|
||||||
|
<Btn variant="ghost" onClick={() => setConfirming(false)}>Back</Btn>
|
||||||
<Btn variant="danger" icon="bin" disabled={mark.isPending} onClick={() => mark.mutate()}>
|
<Btn variant="danger" icon="bin" disabled={mark.isPending} onClick={() => mark.mutate()}>
|
||||||
{mark.isPending ? "Saving…" : "Mark gone"}
|
{mark.isPending ? "Saving…" : "Confirm"}
|
||||||
</Btn>
|
</Btn>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Btn variant="danger" icon="bin" disabled={!item} onClick={() => setConfirming(true)}>
|
||||||
|
Mark gone
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ export function Btn({
|
|||||||
style,
|
style,
|
||||||
type,
|
type,
|
||||||
disabled,
|
disabled,
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
variant?: BtnVariant;
|
variant?: BtnVariant;
|
||||||
@@ -323,6 +324,7 @@ export function Btn({
|
|||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
type?: "button" | "submit" | "reset";
|
type?: "button" | "submit" | "reset";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
const variants: Record<BtnVariant, CSSProperties> = {
|
const variants: Record<BtnVariant, CSSProperties> = {
|
||||||
primary: disabled
|
primary: disabled
|
||||||
@@ -339,6 +341,7 @@ export function Btn({
|
|||||||
type={type}
|
type={type}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -85,6 +85,13 @@
|
|||||||
.inv-row:hover {
|
.inv-row:hover {
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
}
|
}
|
||||||
|
.sortable-col .sort-hint {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 100ms;
|
||||||
|
}
|
||||||
|
.sortable-col:hover .sort-hint {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
.inv-row .inv-row-chevron {
|
.inv-row .inv-row-chevron {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 100ms;
|
transition: opacity 100ms;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface InventoryItem {
|
|||||||
consumedDate: string | null;
|
consumedDate: string | null;
|
||||||
goneDate: string | null;
|
goneDate: string | null;
|
||||||
checkoutDate: string | null;
|
checkoutDate: string | null;
|
||||||
|
prevBinId: string | null;
|
||||||
rating: number | null;
|
rating: number | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
audits: Audit[];
|
audits: Audit[];
|
||||||
|
|||||||
@@ -87,6 +87,17 @@ export function Dashboard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{stats.purchaseCount === 0 ? (
|
||||||
|
<Card style={{ textAlign: "center", padding: "60px 32px" }}>
|
||||||
|
<div className="serif" style={{ fontSize: 28, fontWeight: 500, marginBottom: 8 }}>
|
||||||
|
No inventory yet
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, color: "var(--ink-3)", marginBottom: 24, maxWidth: 400, margin: "0 auto 24px" }}>
|
||||||
|
Add your first item to start tracking consumption, audits, and spending.
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -333,6 +344,7 @@ export function Dashboard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i.id}
|
key={i.id}
|
||||||
|
className="inv-row"
|
||||||
onClick={() => onSelectItem(i)}
|
onClick={() => onSelectItem(i)}
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 24px",
|
padding: "12px 24px",
|
||||||
@@ -388,6 +400,7 @@ export function Dashboard({
|
|||||||
{lowDiscrete.slice(0, 2).map((g) => (
|
{lowDiscrete.slice(0, 2).map((g) => (
|
||||||
<div
|
<div
|
||||||
key={g.key}
|
key={g.key}
|
||||||
|
className="inv-row"
|
||||||
onClick={() => onSelectItem(g.items[0]!)}
|
onClick={() => onSelectItem(g.items[0]!)}
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 24px",
|
padding: "12px 24px",
|
||||||
@@ -422,6 +435,8 @@ export function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,10 +129,9 @@ export function Inventory({
|
|||||||
const { selected, toggle, toggleAll, toggleGroup, clear, isAllSelected, isIndeterminate } =
|
const { selected, toggle, toggleAll, toggleGroup, clear, isAllSelected, isIndeterminate } =
|
||||||
useSelection(visibleIds);
|
useSelection(visibleIds);
|
||||||
|
|
||||||
// Clear selection when filters / search / view change
|
// Selection is automatically pruned by useSelection when visibleIds changes.
|
||||||
useEffect(() => {
|
// No need to force-clear — items that leave the visible set are removed,
|
||||||
clear();
|
// but items that remain stay selected.
|
||||||
}, [filter, typeFilter, search, view]);
|
|
||||||
|
|
||||||
// Escape to deselect
|
// Escape to deselect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -465,6 +464,7 @@ function HeaderRow({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
|
className="sortable-col"
|
||||||
onClick={() => onSort(sk)}
|
onClick={() => onSort(sk)}
|
||||||
style={{
|
style={{
|
||||||
background: "none",
|
background: "none",
|
||||||
@@ -482,7 +482,10 @@ function HeaderRow({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{active && <span style={{ fontSize: 9 }}>{sortAsc ? "▲" : "▼"}</span>}
|
{active
|
||||||
|
? <span style={{ fontSize: 9 }}>{sortAsc ? "▲" : "▼"}</span>
|
||||||
|
: <span className="sort-hint" style={{ fontSize: 9 }}>▲</span>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user