Edit and delete brands and shops
Build and push image / build (push) Successful in 49s

Adds PATCH and DELETE endpoints for brands and shops that mirror the
existing bins pattern: deleting a brand or shop nullifies referencing
products (and strains, for brands) inside a transaction so nothing is
lost. Brand renames return 409 when the new name collides with the
UNIQUE constraint, surfaced inline in the edit modal.

The Brands and Shops views now show inline edit/trash icons on each
card; the trash button confirms with a preview of how many products
will be unparented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 21:33:42 -04:00
parent d00eb4c12b
commit 8ef8859c7d
6 changed files with 365 additions and 9 deletions
+76
View File
@@ -15,6 +15,44 @@ catalogRouter.post("/brands", (req, res) => {
res.json({ id, name: name.trim() }); res.json({ id, name: name.trim() });
}); });
catalogRouter.patch("/brands/:id", (req, res) => {
const { id } = req.params;
const { name } = req.body as { name?: string };
if (!name?.trim()) return res.status(400).json({ error: "name required" });
const existing = db
.prepare<[string], { id: string }>("SELECT id FROM brands WHERE id = ?")
.get(id);
if (!existing) return res.status(404).json({ error: "brand not found" });
try {
db.prepare("UPDATE brands SET name = ? WHERE id = ?").run(name.trim(), id);
res.json({ id, name: name.trim() });
} catch (err) {
const msg = err instanceof Error ? err.message : "";
if (msg.includes("UNIQUE")) {
return res.status(409).json({ error: "another brand already uses that name" });
}
throw err;
}
});
// Deleting a brand unparents any products and strains that reference it
// (brand_id → NULL), so users never lose products when reorganizing.
catalogRouter.delete("/brands/:id", (req, res) => {
const { id } = req.params;
const tx = db.transaction(() => {
db.prepare("UPDATE products SET brand_id = NULL WHERE brand_id = ?").run(id);
db.prepare("UPDATE strains SET brand_id = NULL WHERE brand_id = ?").run(id);
const result = db.prepare("DELETE FROM brands WHERE id = ?").run(id);
if (result.changes === 0) throw new Error("not found");
});
try {
tx();
res.json({ ok: true });
} catch {
res.status(404).json({ error: "brand not found" });
}
});
catalogRouter.post("/shops", (req, res) => { catalogRouter.post("/shops", (req, res) => {
const { name, location } = req.body as { name: string; location?: string }; const { name, location } = req.body as { name: string; location?: string };
if (!name?.trim()) return res.status(400).json({ error: "name required" }); if (!name?.trim()) return res.status(400).json({ error: "name required" });
@@ -27,6 +65,44 @@ catalogRouter.post("/shops", (req, res) => {
res.json({ id, name: name.trim(), location: location?.trim() ?? null }); res.json({ id, name: name.trim(), location: location?.trim() ?? null });
}); });
catalogRouter.patch("/shops/:id", (req, res) => {
const { id } = req.params;
const { name, location } = req.body as { name?: string; location?: string | null };
const existing = db
.prepare<[string], { id: string; name: string; location: string | null }>(
"SELECT id, name, location FROM shops WHERE id = ?",
)
.get(id);
if (!existing) return res.status(404).json({ error: "shop not found" });
const nextName = name?.trim() ? name.trim() : existing.name;
const nextLocation =
location === undefined ? existing.location : location?.toString().trim() || null;
db.prepare("UPDATE shops SET name = ?, location = ? WHERE id = ?").run(
nextName,
nextLocation,
id,
);
res.json({ id, name: nextName, location: nextLocation });
});
// Deleting a shop unparents any products that reference it (shop_id → NULL).
catalogRouter.delete("/shops/:id", (req, res) => {
const { id } = req.params;
const tx = db.transaction(() => {
db.prepare("UPDATE products SET shop_id = NULL WHERE shop_id = ?").run(id);
const result = db.prepare("DELETE FROM shops WHERE id = ?").run(id);
if (result.changes === 0) throw new Error("not found");
});
try {
tx();
res.json({ ok: true });
} catch {
res.status(404).json({ error: "shop not found" });
}
});
catalogRouter.post("/bins", (req, res) => { catalogRouter.post("/bins", (req, res) => {
const { name, location, capacity } = req.body as { const { name, location, capacity } = req.body as {
name: string; name: string;
+29 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "./api.js"; import { api } from "./api.js";
import type { Bin, Bootstrap, Product } from "./types.js"; import type { Bin, Bootstrap, Brand, Product, Shop } from "./types.js";
import { computeStats } from "./stats.js"; import { computeStats } from "./stats.js";
import { Sidebar } from "./components/Sidebar.js"; import { Sidebar } from "./components/Sidebar.js";
import type { ViewKey } from "./components/Sidebar.js"; import type { ViewKey } from "./components/Sidebar.js";
@@ -23,6 +23,8 @@ import {
AddBrandModal, AddBrandModal,
AddShopModal, AddShopModal,
EditBinModal, EditBinModal,
EditBrandModal,
EditShopModal,
} from "./components/modals/CatalogModals.js"; } from "./components/modals/CatalogModals.js";
type ModalKey = type ModalKey =
@@ -34,6 +36,8 @@ type ModalKey =
| "addShop" | "addShop"
| "addBin" | "addBin"
| "editBin" | "editBin"
| "editBrand"
| "editShop"
| null; | null;
export function App() { export function App() {
@@ -42,6 +46,8 @@ export function App() {
const [modal, setModal] = useState<ModalKey>(null); const [modal, setModal] = useState<ModalKey>(null);
const [modalProduct, setModalProduct] = useState<Product | null>(null); const [modalProduct, setModalProduct] = useState<Product | null>(null);
const [modalBin, setModalBin] = useState<Bin | null>(null); const [modalBin, setModalBin] = useState<Bin | null>(null);
const [modalBrand, setModalBrand] = useState<Brand | null>(null);
const [modalShop, setModalShop] = useState<Shop | null>(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",
@@ -142,10 +148,24 @@ export function App() {
/> />
)} )}
{view === "shops" && ( {view === "shops" && (
<ShopsView data={data} onAddShop={() => setModal("addShop")} /> <ShopsView
data={data}
onAddShop={() => setModal("addShop")}
onEditShop={(shop) => {
setModalShop(shop);
setModal("editShop");
}}
/>
)} )}
{view === "brands" && ( {view === "brands" && (
<BrandsView data={data} onAddBrand={() => setModal("addBrand")} /> <BrandsView
data={data}
onAddBrand={() => setModal("addBrand")}
onEditBrand={(brand) => {
setModalBrand(brand);
setModal("editBrand");
}}
/>
)} )}
{view === "charts" && <ChartsView data={data} stats={stats} />} {view === "charts" && <ChartsView data={data} stats={stats} />}
{view === "settings" && ( {view === "settings" && (
@@ -180,6 +200,12 @@ export function App() {
{modal === "editBin" && modalBin && ( {modal === "editBin" && modalBin && (
<EditBinModal bin={modalBin} onClose={() => setModal(null)} /> <EditBinModal bin={modalBin} onClose={() => setModal(null)} />
)} )}
{modal === "editBrand" && modalBrand && (
<EditBrandModal brand={modalBrand} onClose={() => setModal(null)} />
)}
{modal === "editShop" && modalShop && (
<EditShopModal shop={modalShop} onClose={() => setModal(null)} />
)}
</div> </div>
); );
} }
+18
View File
@@ -61,12 +61,30 @@ export const api = {
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}), }),
updateBrand: (id: string, body: { name: string }) =>
request<{ id: string; name: string }>(`/brands/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
deleteBrand: (id: string) =>
request<{ ok: true }>(`/brands/${id}`, { method: "DELETE" }),
createShop: (body: { name: string; location?: string }) => createShop: (body: { name: string; location?: string }) =>
request<{ id: string; name: string; location: string | null }>("/shops", { request<{ id: string; name: string; location: string | null }>("/shops", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
updateShop: (id: string, body: { name?: string; location?: string | null }) =>
request<{ id: string; name: string; location: string | null }>(`/shops/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
deleteShop: (id: string) =>
request<{ ok: true }>(`/shops/${id}`, { method: "DELETE" }),
createBin: (body: { name: string; location?: string; capacity?: number }) => createBin: (body: { name: string; location?: string; capacity?: number }) =>
request<{ id: string; name: string; location: string | null; capacity: number }>("/bins", { request<{ id: string; name: string; location: string | null; capacity: number }>("/bins", {
method: "POST", method: "POST",
+132
View File
@@ -57,6 +57,70 @@ export function AddBrandModal({ onClose }: { onClose: () => void }) {
); );
} }
export function EditBrandModal({
brand,
onClose,
}: {
brand: { id: string; name: string };
onClose: () => void;
}) {
const qc = useQueryClient();
const [name, setName] = useState(brand.name);
const update = useMutation({
mutationFn: () => api.updateBrand(brand.id, { name: name.trim() }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose();
},
});
return (
<ModalBackdrop onClose={onClose}>
<div
style={{
width: "min(480px, 96vw)",
margin: "40px 20px",
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-lg)",
}}
>
<ModalHeader title="Edit brand" eyebrow="Catalog" onClose={onClose} />
<div style={{ padding: 32 }}>
<Field label="Brand name">
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Foxglove Farms"
/>
</Field>
{update.isError && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--terracotta)" }}>
{String(update.error instanceof Error ? update.error.message : update.error)}
</div>
)}
</div>
<ModalFooter>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn
variant="primary"
icon="check"
disabled={!name.trim() || update.isPending}
onClick={() => update.mutate()}
>
{update.isPending ? "Saving…" : "Save changes"}
</Btn>
</div>
</ModalFooter>
</div>
</ModalBackdrop>
);
}
export function EditBinModal({ export function EditBinModal({
bin, bin,
onClose, onClose,
@@ -211,6 +275,74 @@ export function AddBinModal({ onClose }: { onClose: () => void }) {
); );
} }
export function EditShopModal({
shop,
onClose,
}: {
shop: { id: string; name: string; location: string | null };
onClose: () => void;
}) {
const qc = useQueryClient();
const [name, setName] = useState(shop.name);
const [location, setLocation] = useState(shop.location ?? "");
const update = useMutation({
mutationFn: () =>
api.updateShop(shop.id, { name: name.trim(), location: location.trim() }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose();
},
});
return (
<ModalBackdrop onClose={onClose}>
<div
style={{
width: "min(560px, 96vw)",
margin: "40px 20px",
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-lg)",
}}
>
<ModalHeader title="Edit shop" eyebrow="Catalog" onClose={onClose} />
<div style={{ padding: 32, display: "grid", gap: 16 }}>
<Field label="Shop name">
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Greenleaf Co-op"
/>
</Field>
<Field label="Location (optional)">
<Input
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g. Capitol Hill"
/>
</Field>
</div>
<ModalFooter>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn
variant="primary"
icon="check"
disabled={!name.trim() || update.isPending}
onClick={() => update.mutate()}
>
{update.isPending ? "Saving…" : "Save changes"}
</Btn>
</div>
</ModalFooter>
</div>
</ModalBackdrop>
);
}
export function AddShopModal({ onClose }: { onClose: () => void }) { export function AddShopModal({ onClose }: { onClose: () => void }) {
const qc = useQueryClient(); const qc = useQueryClient();
const [name, setName] = useState(""); const [name, setName] = useState("");
+58 -4
View File
@@ -1,13 +1,33 @@
import type { Bootstrap } from "../types.js"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Btn, Card, Pill } from "../components/primitives/index.js"; import type { Bootstrap, Brand } from "../types.js";
import { api } from "../api.js";
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
export function BrandsView({ export function BrandsView({
data, data,
onAddBrand, onAddBrand,
onEditBrand,
}: { }: {
data: Bootstrap; data: Bootstrap;
onAddBrand: () => void; onAddBrand: () => void;
onEditBrand: (brand: Brand) => void;
}) { }) {
const qc = useQueryClient();
const remove = useMutation({
mutationFn: (id: string) => api.deleteBrand(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["bootstrap"] }),
});
const handleDelete = (brandId: string, brandName: string, productCount: number, strainCount: number) => {
const parts: string[] = [];
if (productCount > 0) parts.push(`${productCount} product${productCount === 1 ? "" : "s"}`);
if (strainCount > 0) parts.push(`${strainCount} strain${strainCount === 1 ? "" : "s"}`);
const tail = parts.length > 0
? ` ${parts.join(" and ")} will be unbranded.`
: "";
if (window.confirm(`Delete "${brandName}"?${tail}`)) remove.mutate(brandId);
};
return ( return (
<div <div
style={{ style={{
@@ -51,7 +71,8 @@ export function BrandsView({
}} }}
> >
{data.brands.map((b) => { {data.brands.map((b) => {
const count = data.products.filter((p) => p.brandId === b.id).length; const productCount = data.products.filter((p) => p.brandId === b.id).length;
const strainCount = data.strains.filter((s) => s.brandId === b.id).length;
return ( return (
<Card key={b.id} style={{ display: "flex", alignItems: "center", gap: 12 }}> <Card key={b.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@@ -60,8 +81,41 @@ export function BrandsView({
</div> </div>
</div> </div>
<Pill tone="outline"> <Pill tone="outline">
{count} purchase{count === 1 ? "" : "s"} {productCount} purchase{productCount === 1 ? "" : "s"}
</Pill> </Pill>
<button
onClick={() => onEditBrand(b)}
title="Edit brand"
aria-label={`Edit brand ${b.name}`}
style={{
background: "transparent",
border: "none",
padding: 4,
borderRadius: "var(--r-sm)",
cursor: "pointer",
color: "var(--ink-3)",
display: "inline-flex",
}}
>
<Icon name="edit" size={14} />
</button>
<button
onClick={() => handleDelete(b.id, b.name, productCount, strainCount)}
title="Remove brand"
aria-label={`Remove brand ${b.name}`}
disabled={remove.isPending}
style={{
background: "transparent",
border: "none",
padding: 4,
borderRadius: "var(--r-sm)",
cursor: remove.isPending ? "wait" : "pointer",
color: "var(--ink-3)",
display: "inline-flex",
}}
>
<Icon name="bin" size={14} />
</button>
</Card> </Card>
); );
})} })}
+52 -2
View File
@@ -1,13 +1,30 @@
import type { Bootstrap } from "../types.js"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Btn, Card, Pill } from "../components/primitives/index.js"; import type { Bootstrap, Shop } from "../types.js";
import { api } from "../api.js";
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
export function ShopsView({ export function ShopsView({
data, data,
onAddShop, onAddShop,
onEditShop,
}: { }: {
data: Bootstrap; data: Bootstrap;
onAddShop: () => void; onAddShop: () => void;
onEditShop: (shop: Shop) => void;
}) { }) {
const qc = useQueryClient();
const remove = useMutation({
mutationFn: (id: string) => api.deleteShop(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["bootstrap"] }),
});
const handleDelete = (shopId: string, shopName: string, productCount: number) => {
const tail = productCount > 0
? ` ${productCount} product${productCount === 1 ? "" : "s"} will lose this shop.`
: "";
if (window.confirm(`Delete "${shopName}"?${tail}`)) remove.mutate(shopId);
};
return ( return (
<div <div
style={{ style={{
@@ -67,6 +84,39 @@ export function ShopsView({
<Pill tone="outline"> <Pill tone="outline">
{count} purchase{count === 1 ? "" : "s"} {count} purchase{count === 1 ? "" : "s"}
</Pill> </Pill>
<button
onClick={() => onEditShop(s)}
title="Edit shop"
aria-label={`Edit shop ${s.name}`}
style={{
background: "transparent",
border: "none",
padding: 4,
borderRadius: "var(--r-sm)",
cursor: "pointer",
color: "var(--ink-3)",
display: "inline-flex",
}}
>
<Icon name="edit" size={14} />
</button>
<button
onClick={() => handleDelete(s.id, s.name, count)}
title="Remove shop"
aria-label={`Remove shop ${s.name}`}
disabled={remove.isPending}
style={{
background: "transparent",
border: "none",
padding: 4,
borderRadius: "var(--r-sm)",
cursor: remove.isPending ? "wait" : "pointer",
color: "var(--ink-3)",
display: "inline-flex",
}}
>
<Icon name="bin" size={14} />
</button>
</Card> </Card>
); );
})} })}