Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,187 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { Bootstrap, Product, Strain } from "../../types.js";
|
|
||||||
import { TYPES } from "../../types.js";
|
|
||||||
import { TYPE_GLYPHS } from "../../format.js";
|
|
||||||
import { api } from "../../api.js";
|
|
||||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
|
||||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
|
||||||
|
|
||||||
const NEW_BRAND = "__new_brand__";
|
|
||||||
|
|
||||||
// Catalog-level edit. SKU is immutable (it's a barcode). Type can change but
|
|
||||||
// rarely should. Name is the strain name — typing it either matches an
|
|
||||||
// existing strain or creates a new one. Brand can be reassigned at any time.
|
|
||||||
export function EditProductFlow({
|
|
||||||
data,
|
|
||||||
product,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
data: Bootstrap;
|
|
||||||
product: Product;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
const initialStrain = data.strains.find((s) => s.id === product.strainId);
|
|
||||||
|
|
||||||
const [name, setName] = useState(initialStrain?.name ?? "");
|
|
||||||
const [type, setType] = useState(product.type);
|
|
||||||
const [brandId, setBrandId] = useState<string>(product.brandId ?? NEW_BRAND);
|
|
||||||
const [newBrandName, setNewBrandName] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const cfg = TYPES.find((t) => t.id === type);
|
|
||||||
|
|
||||||
const matchedStrain: Strain | null = useMemo(() => {
|
|
||||||
const q = name.trim().toLowerCase();
|
|
||||||
if (!q) return null;
|
|
||||||
return data.strains.find((s) => s.name.trim().toLowerCase() === q) ?? null;
|
|
||||||
}, [name, data.strains]);
|
|
||||||
|
|
||||||
const save = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (!name.trim()) throw new Error("Name (strain) required");
|
|
||||||
let nextBrandId: string | null = null;
|
|
||||||
if (brandId === NEW_BRAND) {
|
|
||||||
if (newBrandName.trim()) {
|
|
||||||
const b = await api.createBrand(newBrandName.trim());
|
|
||||||
nextBrandId = b.id;
|
|
||||||
} else {
|
|
||||||
nextBrandId = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
nextBrandId = brandId || null;
|
|
||||||
}
|
|
||||||
return api.updateProduct(product.id, {
|
|
||||||
type,
|
|
||||||
kind: cfg?.kind ?? product.kind,
|
|
||||||
// Server resolves matched-by-name to existing strain, else creates one
|
|
||||||
strainId: matchedStrain?.id,
|
|
||||||
strainName: matchedStrain ? undefined : name.trim(),
|
|
||||||
brandId: nextBrandId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
onError: (e: Error) => setError(e.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const isNewBrand = brandId === NEW_BRAND;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalBackdrop onClose={onClose}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "min(640px, 96vw)",
|
|
||||||
margin: "40px 20px",
|
|
||||||
background: "var(--bg)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
boxShadow: "var(--shadow-lg)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ModalHeader
|
|
||||||
title={`Edit product`}
|
|
||||||
eyebrow={`Catalog · ${product.sku}`}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ padding: 32 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: 20,
|
|
||||||
padding: "10px 14px",
|
|
||||||
background: "var(--bg-2)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
borderRadius: "var(--r-sm)",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--ink-3)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontFamily: "var(--serif)", fontSize: 16 }}>
|
|
||||||
{TYPE_GLYPHS[product.type]}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
SKU <span className="mono" style={{ color: "var(--ink-2)" }}>{product.sku}</span>{" "}
|
|
||||||
is locked. Edit individual purchases (price, batch THC) from the
|
|
||||||
inventory drawer.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16 }}>
|
|
||||||
<Field
|
|
||||||
label="Name (strain)"
|
|
||||||
span={2}
|
|
||||||
hint={
|
|
||||||
matchedStrain
|
|
||||||
? `Will link to existing strain "${matchedStrain.name}".`
|
|
||||||
: "Will create a new strain entry from this name."
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="e.g. Garden Ghost"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Type">
|
|
||||||
<Select value={type} onChange={(e) => setType(e.target.value)}>
|
|
||||||
{TYPES.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>
|
|
||||||
{t.id} ({t.kind})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field label="Brand">
|
|
||||||
<Select value={brandId} onChange={(e) => setBrandId(e.target.value)}>
|
|
||||||
{data.brands.map((b) => (
|
|
||||||
<option key={b.id} value={b.id}>
|
|
||||||
{b.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
<option value={NEW_BRAND}>+ Add new brand…</option>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
{isNewBrand && (
|
|
||||||
<Field label="New brand name (or leave blank for unbranded)" span={2}>
|
|
||||||
<Input
|
|
||||||
value={newBrandName}
|
|
||||||
onChange={(e) => setNewBrandName(e.target.value)}
|
|
||||||
placeholder="e.g. Foxglove Farms"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{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() || save.isPending}
|
|
||||||
onClick={() => save.mutate()}
|
|
||||||
>
|
|
||||||
{save.isPending ? "Saving…" : "Save"}
|
|
||||||
</Btn>
|
|
||||||
</div>
|
|
||||||
</ModalFooter>
|
|
||||||
</div>
|
|
||||||
</ModalBackdrop>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user