345 lines
18 KiB
TypeScript
345 lines
18 KiB
TypeScript
"use client";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { X, Upload, CheckCircle2, AlertTriangle, Eye } from "lucide-react";
|
|
import { api } from "@/lib/api";
|
|
|
|
type Mode = "add" | "edit" | "duplicate" | "view";
|
|
|
|
const CATEGORIES = ["Pain Relief", "Supplement", "Pharma", "Cosmetic", "Food", "Beverage", "Other"];
|
|
const UNIT_TYPES = ["Tablets", "Capsules", "ml", "g", "kg", "Strips", "Bottles"];
|
|
const FORMS = ["Tablet", "Capsule", "Syrup", "Powder", "Cream", "Gel", "Liquid"];
|
|
|
|
const empty = {
|
|
name: "", brand_id: "", category: "", sku: "", description: "", image_url: "",
|
|
manufacturer: { company: "", plant: "", country: "India", license_number: "", location: "" },
|
|
details: { net_weight: "", pack_size: "", unit_type: "", form_type: "", storage_condition: "", shelf_life: "", temperature_range: "", dosage: "" },
|
|
recycling_info: "", internal_notes: "", qr_type: "dynamic", display_brand_logo: true,
|
|
status: "active",website: "",
|
|
email: "",
|
|
phone: "",
|
|
};
|
|
|
|
export default function ProductDialog({
|
|
mode, productId, onClose, onSaved,
|
|
}: { mode: Mode; productId?: string; onClose: () => void; onSaved: () => void }) {
|
|
const [brands, setBrands] = useState<any[]>([]);
|
|
const [form, setForm] = useState<any>(empty);
|
|
const [logoUrl, setLogoUrl] = useState("");
|
|
const [err, setErr] = useState("");
|
|
const [fErr, setFErr] = useState<Record<string, string>>({});
|
|
const [busy, setBusy] = useState(false);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
const bodyRef = useRef<HTMLDivElement>(null);
|
|
const readOnly = mode === "view";
|
|
|
|
// required fields — key uses "group.field" for nested
|
|
const REQUIRED: Record<string, string> = {
|
|
name: "Product name is required",
|
|
brand_id: "Please select a brand",
|
|
category: "Category is required",
|
|
sku: "SKU is required",
|
|
"manufacturer.company": "Manufacturer name is required",
|
|
"manufacturer.country": "Country of origin is required",
|
|
};
|
|
|
|
function validate(): boolean {
|
|
const e: Record<string, string> = {};
|
|
for (const [key, msg] of Object.entries(REQUIRED)) {
|
|
const val = key.includes(".")
|
|
? form[key.split(".")[0]]?.[key.split(".")[1]]
|
|
: form[key];
|
|
if (!val || !String(val).trim()) e[key] = msg;
|
|
}
|
|
setFErr(e);
|
|
if (Object.keys(e).length) {
|
|
setErr("Please fill in all required fields highlighted below.");
|
|
bodyRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
|
return false;
|
|
}
|
|
setErr("");
|
|
return true;
|
|
}
|
|
|
|
function validateMinimal(): boolean {
|
|
const e: Record<string, string> = {};
|
|
if (!form.name.trim()) e.name = REQUIRED.name;
|
|
if (!form.brand_id) e.brand_id = REQUIRED.brand_id;
|
|
setFErr(e);
|
|
if (Object.keys(e).length) {
|
|
setErr("A draft still needs a product name and brand.");
|
|
bodyRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
|
return false;
|
|
}
|
|
setErr("");
|
|
return true;
|
|
}
|
|
|
|
useEffect(() => {
|
|
api("/brands").then(setBrands).catch(() => {});
|
|
if ((mode === "edit" || mode === "view" || mode === "duplicate") && productId) {
|
|
api(`/products/${productId}`).then((p) => {
|
|
setForm({
|
|
name: mode === "duplicate" ? `${p.name} (Copy)` : p.name,
|
|
brand_id: p.brand_id, category: p.category ?? "", sku: mode === "duplicate" ? "" : (p.sku ?? ""),
|
|
description: p.description ?? "", image_url: p.image_url ?? "",
|
|
manufacturer: { ...empty.manufacturer, ...(p.manufacturer ?? {}) },
|
|
details: { ...empty.details, ...(p.details ?? {}) },
|
|
recycling_info: p.recycling_info ?? "", internal_notes: p.internal_notes ?? "",
|
|
qr_type: p.qr_type ?? "dynamic", display_brand_logo: p.display_brand_logo ?? true,
|
|
status: p.status ?? "active",
|
|
});
|
|
if (p.image_url) setLogoUrl(p.image_url);
|
|
}).catch(() => {});
|
|
}
|
|
}, [mode, productId]);
|
|
|
|
const clearErr = (key: string) =>
|
|
setFErr((e) => { if (!e[key]) return e; const n = { ...e }; delete n[key]; return n; });
|
|
const set = (k: string, v: any) => { setForm((f: any) => ({ ...f, [k]: v })); clearErr(k); };
|
|
const setN = (group: string, k: string, v: any) => {
|
|
setForm((f: any) => ({ ...f, [group]: { ...f[group], [k]: v } }));
|
|
clearErr(`${group}.${k}`);
|
|
};
|
|
|
|
function onImage(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
if (file.size > 2 * 1024 * 1024) { setErr("Image must be under 2 MB"); return; }
|
|
const r = new FileReader();
|
|
r.onload = () => { set("image_url", r.result as string); setLogoUrl(r.result as string); };
|
|
r.readAsDataURL(file);
|
|
}
|
|
|
|
async function save(asDraft: boolean) {
|
|
// A draft still needs the identifying fields; publishing needs everything.
|
|
const ok = asDraft
|
|
? (form.name.trim() && form.brand_id ? true : validateMinimal())
|
|
: validate();
|
|
if (!ok) return;
|
|
setBusy(true);
|
|
const payload = { ...form, status: asDraft ? "draft" : form.status };
|
|
try {
|
|
if (mode === "edit" && productId) {
|
|
await api(`/products/${productId}`, { method: "PUT", body: JSON.stringify(payload) });
|
|
} else {
|
|
// add + duplicate both create a new product
|
|
await api("/products", { method: "POST", body: JSON.stringify(payload) });
|
|
}
|
|
onSaved();
|
|
onClose();
|
|
} catch (e: any) {
|
|
const d = e.detail;
|
|
setErr(typeof d === "object" ? d.message : d || "Failed to save product");
|
|
} finally { setBusy(false); }
|
|
}
|
|
|
|
const titles: Record<Mode, string> = { add: "Add Product", edit: "Edit Product", duplicate: "Duplicate Product", view: "Product Details" };
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/40 z-50 flex items-start justify-center overflow-y-auto p-4" onClick={onClose}>
|
|
<div className="bg-slate-50 rounded-2xl w-full max-w-7xl my-4" onClick={(e) => e.stopPropagation()}>
|
|
{/* header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 bg-white rounded-t-2xl sticky top-0 z-10">
|
|
<h2 className="text-lg font-bold">{titles[mode]}</h2>
|
|
<button onClick={onClose}><X className="h-5 w-5 text-slate-400" /></button>
|
|
</div>
|
|
|
|
<div ref={bodyRef} className="p-6 space-y-5 max-h-[calc(100vh-9rem)] overflow-y-auto">
|
|
{err && <div className="card p-3 bg-rose-50 border-rose-200 text-rose-700 text-sm flex items-center gap-2"><AlertTriangle className="h-4 w-4" /> {err}</div>}
|
|
|
|
<div className="grid lg:grid-cols-3 gap-5">
|
|
{/* 1. Basic */}
|
|
<Section n={1} title="Basic Information">
|
|
<Inp label="Product Name" req value={form.name} onChange={(v) => set("name", v)} ro={readOnly} placeholder="e.g. Pain Relief 500mg" error={fErr.name} />
|
|
<Sel label="Brand" req value={form.brand_id} onChange={(v) => set("brand_id", v)} ro={readOnly}
|
|
opts={brands.map((b) => ({ value: b.id, label: b.name }))} ph="Select brand" error={fErr.brand_id} />
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Sel label="Category" req value={form.category} onChange={(v) => set("category", v)} ro={readOnly}
|
|
opts={CATEGORIES.map((c) => ({ value: c, label: c }))} ph="Select" error={fErr.category} />
|
|
<Inp label="SKU" req value={form.sku} onChange={(v) => set("sku", v)} ro={readOnly} placeholder="e.g. PR500" error={fErr.sku} />
|
|
</div>
|
|
<Inp label="Description" value={form.description} onChange={(v) => set("description", v)} ro={readOnly} placeholder="Short description" />
|
|
<div>
|
|
<Label>Product Image <span className="text-rose-500">*</span></Label>
|
|
<div className="flex gap-3 mt-1">
|
|
<button type="button" disabled={readOnly} onClick={() => fileRef.current?.click()}
|
|
className="flex-1 border-2 border-dashed border-slate-200 rounded-lg p-3 text-center text-xs text-slate-400">
|
|
<Upload className="h-5 w-5 mx-auto mb-1" /> Click to upload<br />JPG, PNG (Max 2MB)
|
|
</button>
|
|
{logoUrl && <img src={logoUrl} alt="" className="h-16 w-16 object-contain rounded-lg border border-slate-200" />}
|
|
<input ref={fileRef} type="file" accept="image/png,image/jpeg" className="hidden" onChange={onImage} />
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* 2. Manufacturing */}
|
|
<Section n={2} title="Manufacturing Information">
|
|
<Inp label="Manufacturer Name" req value={form.manufacturer.company} onChange={(v) => setN("manufacturer", "company", v)} ro={readOnly} placeholder="e.g. ABC Pharma Pvt Ltd" error={fErr["manufacturer.company"]} />
|
|
<Inp label="Manufacturing Plant" value={form.manufacturer.plant} onChange={(v) => setN("manufacturer", "plant", v)} ro={readOnly} placeholder="Plant" />
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<Inp label="Country of Origin" req value={form.manufacturer.country} onChange={(v) => setN("manufacturer", "country", v)} ro={readOnly} error={fErr["manufacturer.country"]} />
|
|
|
|
<Inp label="License / Registration No." value={form.manufacturer.license_number} onChange={(v) => setN("manufacturer", "license_number", v)} ro={readOnly} placeholder="e.g. TN/25D/12345" />
|
|
</div>
|
|
<Inp label="Manufacturing Location" value={form.manufacturer.location} onChange={(v) => setN("manufacturer", "location", v)} ro={readOnly} placeholder="e.g. Bengaluru, Karnataka" />
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
|
|
|
|
<Inp
|
|
label="Support Email"
|
|
value={form.manufacturer.email}
|
|
onChange={(v) => setN("manufacturer", "email", v)}
|
|
ro={readOnly}
|
|
placeholder="support@example.com"
|
|
/>
|
|
|
|
<Inp
|
|
label="Phone Number"
|
|
value={form.manufacturer.phone}
|
|
onChange={(v) => setN("manufacturer", "phone", v)}
|
|
ro={readOnly}
|
|
placeholder="+91 9876543210"
|
|
/>
|
|
</div>
|
|
<Inp
|
|
label="Company Website"
|
|
value={form.manufacturer.website}
|
|
onChange={(v) => setN("manufacturer", "website", v)}
|
|
ro={readOnly}
|
|
placeholder="https://example.com"
|
|
/>
|
|
</Section>
|
|
|
|
{/* 3. Details */}
|
|
<Section n={3} title="Product Details">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Inp label="Net Weight / Volume" value={form.details.net_weight} onChange={(v) => setN("details", "net_weight", v)} ro={readOnly} placeholder="e.g. 10 Tablets" />
|
|
<Inp label="Pack Size" value={form.details.pack_size} onChange={(v) => setN("details", "pack_size", v)} ro={readOnly} placeholder="e.g. 10/Strip" />
|
|
<Sel label="Unit Type" value={form.details.unit_type} onChange={(v) => setN("details", "unit_type", v)} ro={readOnly} opts={UNIT_TYPES.map((u) => ({ value: u, label: u }))} ph="Select" />
|
|
<Sel label="Form / Type" value={form.details.form_type} onChange={(v) => setN("details", "form_type", v)} ro={readOnly} opts={FORMS.map((f) => ({ value: f, label: f }))} ph="Select" />
|
|
<Inp label="Storage Condition" value={form.details.storage_condition} onChange={(v) => setN("details", "storage_condition", v)} ro={readOnly} placeholder="Dry & Cool" />
|
|
<Inp label="Shelf Life" value={form.details.shelf_life} onChange={(v) => setN("details", "shelf_life", v)} ro={readOnly} placeholder="24 Months" />
|
|
<Inp label="Temperature Range" value={form.details.temperature_range} onChange={(v) => setN("details", "temperature_range", v)} ro={readOnly} placeholder="15-25°C" />
|
|
<Inp label="Dosage / Usage" value={form.details.dosage} onChange={(v) => setN("details", "dosage", v)} ro={readOnly} placeholder="As directed" />
|
|
</div>
|
|
</Section>
|
|
</div>
|
|
|
|
<div className="grid lg:grid-cols-3 gap-5">
|
|
{/* 4. QR config */}
|
|
<Section n={4} title="QR Configuration">
|
|
<Sel label="QR Type" value={form.qr_type} onChange={(v) => set("qr_type", v)} ro={readOnly}
|
|
opts={[{ value: "dynamic", label: "Dynamic (Recommended)" }, { value: "static", label: "Static" }]} />
|
|
<ToggleRow label="Status" on={form.status === "active"} ro={readOnly} onClick={() => set("status", form.status === "active" ? "inactive" : "active")} text={form.status === "active" ? "Active" : "Inactive"} />
|
|
<p className="text-xs text-slate-500 bg-blue-50 rounded-lg p-2">Dynamic QR lets you update info anytime without changing the printed code.</p>
|
|
</Section>
|
|
|
|
{/* 6. Compliance summary */}
|
|
<Section n={6} title="Compliance Summary">
|
|
{[
|
|
["FSSAI / License", !!form.manufacturer.license_number],
|
|
["Country / Origin", !!form.manufacturer.country],
|
|
["Product Category", !!form.category],
|
|
["Recycling / EPR", !!form.recycling_info],
|
|
].map(([label, ok]) => (
|
|
<div key={label as string} className="flex justify-between items-center text-sm py-1">
|
|
<span className="text-slate-600">{label}</span>
|
|
{ok ? <span className="text-emerald-600 flex items-center gap-1 text-xs font-semibold"><CheckCircle2 className="h-4 w-4" /> Complete</span>
|
|
: <span className="text-amber-600 text-xs font-semibold">Incomplete</span>}
|
|
</div>
|
|
))}
|
|
<Inp label="Recycling / EPR info" value={form.recycling_info} onChange={(v) => set("recycling_info", v)} ro={readOnly} placeholder="Disposal & recycling notes" />
|
|
</Section>
|
|
|
|
{/* 8. Internal notes + 7 branding */}
|
|
<Section n={7} title="Branding & Notes">
|
|
<ToggleRow label="Display Brand Logo on Verification Page" on={form.display_brand_logo} ro={readOnly} onClick={() => set("display_brand_logo", !form.display_brand_logo)} text={form.display_brand_logo ? "Yes" : "No"} />
|
|
<Inp label="Internal Notes (optional)" value={form.internal_notes} onChange={(v) => set("internal_notes", v)} ro={readOnly} placeholder="Internal notes about this product" />
|
|
</Section>
|
|
</div>
|
|
|
|
{/* 9. Preview */}
|
|
<Section n={9} title="Product Preview" full>
|
|
<div className="flex items-center gap-4">
|
|
{logoUrl ? <img src={logoUrl} className="h-16 w-16 rounded-lg object-contain border border-slate-200" /> : <div className="h-16 w-16 rounded-lg bg-slate-100" />}
|
|
<div className="flex-1">
|
|
<div className="font-bold">{form.name || "Product name"}</div>
|
|
<div className="text-sm text-slate-500">
|
|
Brand: {brands.find((b) => b.id === form.brand_id)?.name ?? "—"} · SKU: {form.sku || "—"} · {form.category || "—"}
|
|
</div>
|
|
</div>
|
|
<span className="text-xs font-semibold bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded-full capitalize">{form.status}</span>
|
|
</div>
|
|
</Section>
|
|
</div>
|
|
|
|
{/* footer */}
|
|
{!readOnly && (
|
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-200 bg-white rounded-b-2xl sticky bottom-0">
|
|
<button onClick={onClose} className="px-5 py-2 rounded-lg border border-slate-200 text-sm font-medium">Cancel</button>
|
|
<button onClick={() => save(true)} disabled={busy} className="px-5 py-2 rounded-lg bg-slate-100 text-sm font-medium">Save as Draft</button>
|
|
<button onClick={() => save(false)} disabled={busy} className="px-6 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold">
|
|
{busy ? "Saving…" : mode === "edit" ? "Update Product" : "Save Product"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({ n, title, children, full }: any) {
|
|
return (
|
|
<div className={`card p-5 ${full ? "lg:col-span-3" : ""}`}>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span className="h-6 w-6 rounded-full bg-brand-600 text-white text-xs grid place-items-center font-bold">{n}</span>
|
|
<h3 className="font-bold text-sm text-brand-700 uppercase tracking-wide">{title}</h3>
|
|
</div>
|
|
<div className="space-y-3">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
function Label({ children }: any) { return <span className="text-sm font-medium text-slate-700">{children}</span>; }
|
|
function Inp({ label, value, onChange, ro, req, placeholder, error }: {
|
|
label: string; value: any; onChange: (v: string) => void;
|
|
ro?: boolean; req?: boolean; placeholder?: string; error?: string;
|
|
}) {
|
|
return (
|
|
<label className="block">
|
|
<Label>{label}{req && <span className="text-rose-500"> *</span>}</Label>
|
|
<input value={value ?? ""} disabled={ro} placeholder={placeholder} onChange={(e) => onChange(e.target.value)}
|
|
className={`mt-1 w-full px-3 py-2 rounded-lg border text-sm disabled:bg-slate-50 ${error ? "border-rose-400 ring-1 ring-rose-200" : "border-slate-200"}`} />
|
|
{error && <span className="text-xs text-rose-600 mt-1 block">{error}</span>}
|
|
</label>
|
|
);
|
|
}
|
|
function Sel({ label, value, onChange, ro, req, opts, ph, error }: {
|
|
label: string; value: any; onChange: (v: string) => void;
|
|
ro?: boolean; req?: boolean; opts: { value: string; label: string }[]; ph?: string; error?: string;
|
|
}) {
|
|
return (
|
|
<label className="block">
|
|
<Label>{label}{req && <span className="text-rose-500"> *</span>}</Label>
|
|
<select value={value ?? ""} disabled={ro} onChange={(e) => onChange(e.target.value)}
|
|
className={`mt-1 w-full px-3 py-2 rounded-lg border text-sm disabled:bg-slate-50 ${error ? "border-rose-400 ring-1 ring-rose-200" : "border-slate-200"}`}>
|
|
<option value="">{ph ?? "Select"}</option>
|
|
{opts.map((o: any) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
</select>
|
|
{error && <span className="text-xs text-rose-600 mt-1 block">{error}</span>}
|
|
</label>
|
|
);
|
|
}
|
|
function ToggleRow({ label, on, onClick, ro, text }: any) {
|
|
return (
|
|
<div className="flex items-center justify-between">
|
|
<Label>{label}</Label>
|
|
<button type="button" disabled={ro} onClick={onClick} className="flex items-center gap-2">
|
|
<span className={`w-10 h-5 rounded-full relative transition ${on ? "bg-emerald-500" : "bg-slate-300"}`}>
|
|
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition ${on ? "left-5" : "left-0.5"}`} />
|
|
</span>
|
|
<span className="text-sm text-slate-600">{text}</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|