From 8ef8859c7d0e13156d39789c6f89df2ba8b1f9eb Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 3 May 2026 21:33:42 -0400 Subject: [PATCH] Edit and delete brands and shops 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) --- server/src/routes/catalog.ts | 76 +++++++++++ web/src/App.tsx | 32 ++++- web/src/api.ts | 18 +++ web/src/components/modals/CatalogModals.tsx | 132 ++++++++++++++++++++ web/src/views/BrandsView.tsx | 62 ++++++++- web/src/views/ShopsView.tsx | 54 +++++++- 6 files changed, 365 insertions(+), 9 deletions(-) diff --git a/server/src/routes/catalog.ts b/server/src/routes/catalog.ts index 879df0e..72ea2ec 100644 --- a/server/src/routes/catalog.ts +++ b/server/src/routes/catalog.ts @@ -15,6 +15,44 @@ catalogRouter.post("/brands", (req, res) => { 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) => { const { name, location } = req.body as { name: string; location?: string }; 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 }); }); +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) => { const { name, location, capacity } = req.body as { name: string; diff --git a/web/src/App.tsx b/web/src/App.tsx index bd0e74c..e611506 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; 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 { Sidebar } from "./components/Sidebar.js"; import type { ViewKey } from "./components/Sidebar.js"; @@ -23,6 +23,8 @@ import { AddBrandModal, AddShopModal, EditBinModal, + EditBrandModal, + EditShopModal, } from "./components/modals/CatalogModals.js"; type ModalKey = @@ -34,6 +36,8 @@ type ModalKey = | "addShop" | "addBin" | "editBin" + | "editBrand" + | "editShop" | null; export function App() { @@ -42,6 +46,8 @@ export function App() { const [modal, setModal] = useState(null); const [modalProduct, setModalProduct] = useState(null); const [modalBin, setModalBin] = useState(null); + const [modalBrand, setModalBrand] = useState(null); + const [modalShop, setModalShop] = useState(null); const [theme, setTheme] = useState( () => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light", @@ -142,10 +148,24 @@ export function App() { /> )} {view === "shops" && ( - setModal("addShop")} /> + setModal("addShop")} + onEditShop={(shop) => { + setModalShop(shop); + setModal("editShop"); + }} + /> )} {view === "brands" && ( - setModal("addBrand")} /> + setModal("addBrand")} + onEditBrand={(brand) => { + setModalBrand(brand); + setModal("editBrand"); + }} + /> )} {view === "charts" && } {view === "settings" && ( @@ -180,6 +200,12 @@ export function App() { {modal === "editBin" && modalBin && ( setModal(null)} /> )} + {modal === "editBrand" && modalBrand && ( + setModal(null)} /> + )} + {modal === "editShop" && modalShop && ( + setModal(null)} /> + )} ); } diff --git a/web/src/api.ts b/web/src/api.ts index c9d6538..1a400a6 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -61,12 +61,30 @@ export const api = { 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 }) => request<{ id: string; name: string; location: string | null }>("/shops", { method: "POST", 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 }) => request<{ id: string; name: string; location: string | null; capacity: number }>("/bins", { method: "POST", diff --git a/web/src/components/modals/CatalogModals.tsx b/web/src/components/modals/CatalogModals.tsx index d231571..2f6d00e 100644 --- a/web/src/components/modals/CatalogModals.tsx +++ b/web/src/components/modals/CatalogModals.tsx @@ -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 ( + +
+ +
+ + setName(e.target.value)} + placeholder="e.g. Foxglove Farms" + /> + + {update.isError && ( +
+ {String(update.error instanceof Error ? update.error.message : update.error)} +
+ )} +
+ +
+
+ Cancel + update.mutate()} + > + {update.isPending ? "Saving…" : "Save changes"} + +
+ +
+ + ); +} + export function EditBinModal({ bin, 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 ( + +
+ +
+ + setName(e.target.value)} + placeholder="e.g. Greenleaf Co-op" + /> + + + setLocation(e.target.value)} + placeholder="e.g. Capitol Hill" + /> + +
+ +
+
+ Cancel + update.mutate()} + > + {update.isPending ? "Saving…" : "Save changes"} + +
+ +
+ + ); +} + export function AddShopModal({ onClose }: { onClose: () => void }) { const qc = useQueryClient(); const [name, setName] = useState(""); diff --git a/web/src/views/BrandsView.tsx b/web/src/views/BrandsView.tsx index 9d8a8df..d737e78 100644 --- a/web/src/views/BrandsView.tsx +++ b/web/src/views/BrandsView.tsx @@ -1,13 +1,33 @@ -import type { Bootstrap } from "../types.js"; -import { Btn, Card, Pill } from "../components/primitives/index.js"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +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({ data, onAddBrand, + onEditBrand, }: { data: Bootstrap; 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 (
{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 (
@@ -60,8 +81,41 @@ export function BrandsView({
- {count} purchase{count === 1 ? "" : "s"} + {productCount} purchase{productCount === 1 ? "" : "s"} + + ); })} diff --git a/web/src/views/ShopsView.tsx b/web/src/views/ShopsView.tsx index 4a1d36e..f524c41 100644 --- a/web/src/views/ShopsView.tsx +++ b/web/src/views/ShopsView.tsx @@ -1,13 +1,30 @@ -import type { Bootstrap } from "../types.js"; -import { Btn, Card, Pill } from "../components/primitives/index.js"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +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({ data, onAddShop, + onEditShop, }: { data: Bootstrap; 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 (
{count} purchase{count === 1 ? "" : "s"} + + ); })}