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"} + + ); })}