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:
+29
-3
@@ -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<ModalKey>(null);
|
||||
const [modalProduct, setModalProduct] = useState<Product | 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>(
|
||||
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
|
||||
@@ -142,10 +148,24 @@ export function App() {
|
||||
/>
|
||||
)}
|
||||
{view === "shops" && (
|
||||
<ShopsView data={data} onAddShop={() => setModal("addShop")} />
|
||||
<ShopsView
|
||||
data={data}
|
||||
onAddShop={() => setModal("addShop")}
|
||||
onEditShop={(shop) => {
|
||||
setModalShop(shop);
|
||||
setModal("editShop");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{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 === "settings" && (
|
||||
@@ -180,6 +200,12 @@ export function App() {
|
||||
{modal === "editBin" && modalBin && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
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 (
|
||||
<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 }) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState("");
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
@@ -51,7 +71,8 @@ export function BrandsView({
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<Card key={b.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -60,8 +81,41 @@ export function BrandsView({
|
||||
</div>
|
||||
</div>
|
||||
<Pill tone="outline">
|
||||
{count} purchase{count === 1 ? "" : "s"}
|
||||
{productCount} purchase{productCount === 1 ? "" : "s"}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
@@ -67,6 +84,39 @@ export function ShopsView({
|
||||
<Pill tone="outline">
|
||||
{count} purchase{count === 1 ? "" : "s"}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user