Product editor tweaks

This commit is contained in:
2026-01-30 13:56:28 -05:00
parent 2dc8152b53
commit 003e1ddd61
7 changed files with 739 additions and 286 deletions

View File

@@ -24,6 +24,9 @@ export function ComboboxField({
placeholder, placeholder,
searchPlaceholder, searchPlaceholder,
disabled, disabled,
defaultOpen,
onOpenChange: onOpenChangeProp,
triggerClassName,
}: { }: {
options: FieldOption[]; options: FieldOption[];
value: string; value: string;
@@ -31,19 +34,26 @@ export function ComboboxField({
placeholder: string; placeholder: string;
searchPlaceholder?: string; searchPlaceholder?: string;
disabled?: boolean; disabled?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
triggerClassName?: string;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(defaultOpen ?? false);
const selectedLabel = options.find((o) => o.value === value)?.label; const handleOpenChange = (next: boolean) => {
setOpen(next);
onOpenChangeProp?.(next);
};
const selectedLabel = options.find((o) => String(o.value) === String(value))?.label;
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
disabled={disabled} disabled={disabled}
className="w-full justify-between font-normal" className={cn("w-full justify-between font-normal", triggerClassName)}
> >
<span className="truncate">{selectedLabel ?? placeholder}</span> <span className="truncate">{selectedLabel ?? placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
@@ -70,7 +80,7 @@ export function ComboboxField({
<Check <Check
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 h-4 w-4",
value === opt.value ? "opacity-100" : "opacity-0" String(value) === String(opt.value) ? "opacity-100" : "opacity-0"
)} )}
/> />
{opt.label} {opt.label}

View File

@@ -0,0 +1,93 @@
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { FieldOption } from "./types";
export function EditableComboboxField({
options,
value,
onChange,
label,
placeholder,
searchPlaceholder,
disabled,
}: {
options: FieldOption[];
value: string;
onChange: (val: string) => void;
label?: string;
placeholder: string;
searchPlaceholder?: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const selectedLabel = options.find((o) => String(o.value) === String(value))?.label;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className={cn(
"flex h-auto w-full items-center gap-1.5 rounded-md border-b border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
"hover:border-input hover:bg-muted/50 transition-colors",
disabled && "opacity-50 cursor-not-allowed",
)}
>
{label && (
<span className="text-muted-foreground shrink-0">{label}</span>
)}
<span className={cn("truncate flex-1", !selectedLabel && "text-muted-foreground")}>
{selectedLabel ?? placeholder}
</span>
<ChevronsUpDown className="h-3.5 w-3.5 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder ?? "Search..."} />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup>
{options.map((opt) => (
<CommandItem
key={opt.value}
value={opt.label}
onSelect={() => {
onChange(opt.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
String(value) === String(opt.value) ? "opacity-100" : "opacity-0"
)}
/>
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,86 @@
import { useState, useRef, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
export function EditableInput({
value,
onChange,
onBlur,
name,
label,
placeholder,
className,
inputClassName,
maxLength,
}: {
value: string;
onChange: (val: string) => void;
onBlur?: () => void;
name?: string;
label?: string;
placeholder?: string;
className?: string;
inputClassName?: string;
maxLength?: number;
}) {
const [editing, setEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
}
}, [editing]);
if (editing) {
return (
<div
className={cn(
"flex h-auto w-full items-center gap-1.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm",
className,
)}
>
{label && (
<span className="text-muted-foreground shrink-0">{label}</span>
)}
<Input
ref={inputRef}
name={name}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={() => {
setEditing(false);
onBlur?.();
}}
onKeyDown={(e) => {
if (e.key === "Escape" || e.key === "Enter") {
setEditing(false);
}
}}
placeholder={placeholder}
maxLength={maxLength}
className={cn("border-0 p-0 h-auto shadow-none focus-visible:ring-0", inputClassName)}
/>
</div>
);
}
return (
<button
type="button"
onClick={() => setEditing(true)}
className={cn(
"flex h-auto w-full items-center gap-1.5 rounded-md border-b border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
"hover:border-input hover:bg-muted/50 transition-colors",
className,
)}
>
{label && (
<span className="text-muted-foreground shrink-0">{label}</span>
)}
<span className={cn("truncate", !value && "text-muted-foreground")}>
{value || placeholder}
</span>
</button>
);
}

View File

@@ -0,0 +1,246 @@
import { useState, useMemo, useCallback } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { FieldOption } from "./types";
interface ColorOption extends FieldOption {
hex?: string;
hexColor?: string;
hex_color?: string;
}
function getHex(opt: ColorOption): string | undefined {
const raw = opt.hex ?? opt.hexColor ?? opt.hex_color;
if (!raw) return undefined;
return raw.startsWith("#") ? raw : `#${raw}`;
}
function isWhite(hex: string) {
return /^#?f{3,6}$/i.test(hex);
}
export function EditableMultiSelect({
options,
value,
onChange,
label,
placeholder,
searchPlaceholder,
showColors,
}: {
options: FieldOption[];
value: string[];
onChange: (val: string[]) => void;
label?: string;
placeholder?: string;
searchPlaceholder?: string;
showColors?: boolean;
}) {
const [open, setOpen] = useState(false);
const selectedLabels = useMemo(() => {
return value.map((v) => {
const opt = options.find((o) => String(o.value) === String(v));
const hex = showColors && opt ? getHex(opt as ColorOption) : undefined;
return { value: v, label: opt?.label ?? v, hex };
});
}, [value, options, showColors]);
const handleSelect = useCallback(
(optValue: string) => {
if (value.includes(optValue)) {
onChange(value.filter((v) => v !== optValue));
} else {
onChange([...value, optValue]);
}
},
[value, onChange]
);
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
e.currentTarget.scrollTop += e.deltaY;
}, []);
// Read-only display when closed
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className={cn(
"flex h-auto w-full items-center gap-1.5 rounded-md border-b border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
"hover:border-input hover:bg-muted/50 transition-colors",
)}
>
{label && (
<span className="text-muted-foreground shrink-0">{label}</span>
)}
{selectedLabels.length === 0 ? (
<span className="text-muted-foreground truncate">
{placeholder ?? "—"}
</span>
) : showColors ? (
<span className="flex items-center gap-1 truncate">
{selectedLabels.slice(0, 8).map((s) =>
s.hex ? (
<span
key={s.value}
className={cn(
"inline-block h-3 w-3 rounded-full shrink-0",
isWhite(s.hex) && "border border-black"
)}
style={{ backgroundColor: s.hex }}
title={s.label}
/>
) : (
<Badge key={s.value} variant="secondary" className="text-xs py-0">
{s.label}
</Badge>
)
)}
{selectedLabels.length > 8 && (
<span className="text-muted-foreground text-xs">
+{selectedLabels.length - 8}
</span>
)}
</span>
) : (
<span className="flex items-center gap-1 truncate">
<Badge variant="secondary" className="text-xs py-0 shrink-0">
{selectedLabels.length}
</Badge>
<span className="truncate">
{selectedLabels.map((s) => s.label).join(", ")}
</span>
</span>
)}
</button>
);
}
return (
<div>
{label && (
<span className="text-xs text-muted-foreground mb-1 block">{label}</span>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"h-8 w-full justify-between text-sm font-normal",
selectedLabels.length === 0 && "text-muted-foreground"
)}
>
<span className="truncate">
{selectedLabels.length === 0
? placeholder ?? "Select..."
: `${selectedLabels.length} selected`}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder ?? "Search..."} />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<div
className="max-h-[250px] overflow-y-auto overscroll-contain"
onWheel={handleWheel}
>
{/* Selected items pinned to top */}
{value.length > 0 && (
<CommandGroup>
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50/80 dark:bg-green-950/40 border-b border-green-100 dark:border-green-900">
<Check className="h-3 w-3" />
<span>Selected ({value.length})</span>
</div>
{value.map((selectedVal) => {
const opt = options.find(
(o) => String(o.value) === String(selectedVal)
) as ColorOption | undefined;
const hex = showColors && opt ? getHex(opt) : undefined;
return (
<CommandItem
key={`sel-${selectedVal}`}
value={`sel-${opt?.label ?? selectedVal}`}
onSelect={() => handleSelect(selectedVal)}
className="bg-green-50/50 dark:bg-green-950/30"
>
<Check className="mr-2 h-4 w-4 opacity-100 text-green-600" />
{hex && (
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full mr-2 shrink-0",
isWhite(hex) && "border border-black"
)}
style={{ backgroundColor: hex }}
/>
)}
{opt?.label ?? selectedVal}
</CommandItem>
);
})}
</CommandGroup>
)}
{/* All options (excluding already-selected) */}
<CommandGroup
heading={value.length > 0 ? "All Options" : undefined}
>
{options
.filter((o) => !value.includes(String(o.value)))
.map((opt) => {
const hex = showColors
? getHex(opt as ColorOption)
: undefined;
return (
<CommandItem
key={opt.value}
value={opt.label}
onSelect={() => handleSelect(String(opt.value))}
>
<Check className="mr-2 h-4 w-4 opacity-0" />
{hex && (
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full mr-2 shrink-0",
isWhite(hex) && "border border-black"
)}
style={{ backgroundColor: hex }}
/>
)}
{opt.label}
</CommandItem>
);
})}
</CommandGroup>
</div>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -292,9 +292,28 @@ export function ImageManager({
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide"> <h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Images ({images.length}) Images ({images.length})
</h3> </h3>
<p className="text-xs text-muted-foreground">
Drag to reorder. First visible image is the main image. {/* Add by URL */}
</p> <div className="flex gap-2 items-center">
<Link className="h-4 w-4 text-muted-foreground shrink-0" />
<Input
placeholder="Add image by URL..."
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleUrlAdd())}
className="text-sm h-8"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUrlAdd}
disabled={!urlInput.trim()}
className="h-8 shrink-0"
>
Add
</Button>
</div>
</div> </div>
{/* Image grid with drag-and-drop */} {/* Image grid with drag-and-drop */}
@@ -328,7 +347,7 @@ export function ImageManager({
) : ( ) : (
<> <>
<ImagePlus className="h-6 w-6" /> <ImagePlus className="h-6 w-6" />
<span className="text-xs">Add Image</span> <span className="text-xs">Upload Image</span>
</> </>
)} )}
</button> </button>
@@ -347,28 +366,6 @@ export function ImageManager({
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
{/* Add by URL */}
<div className="flex gap-2 items-center">
<Link className="h-4 w-4 text-muted-foreground shrink-0" />
<Input
placeholder="Add image by URL..."
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleUrlAdd())}
className="text-sm h-8"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUrlAdd}
disabled={!urlInput.trim()}
className="h-8 shrink-0"
>
Add
</Button>
</div>
{/* Hidden file input */} {/* Hidden file input */}
<input <input
ref={fileInputRef} ref={fileInputRef}

View File

@@ -2,24 +2,129 @@ import { useState, useEffect, useCallback, useRef } from "react";
import axios from "axios"; import axios from "axios";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Loader2, X } from "lucide-react"; import { Loader2, X, Copy } from "lucide-react";
import { submitProductEdit, type ImageChanges } from "@/services/productEditor"; import { submitProductEdit, type ImageChanges } from "@/services/productEditor";
import { ComboboxField } from "./ComboboxField"; import { EditableComboboxField } from "./EditableComboboxField";
import { EditableInput } from "./EditableInput";
import { EditableMultiSelect } from "./EditableMultiSelect";
import { ImageManager } from "./ImageManager"; import { ImageManager } from "./ImageManager";
import type { import type {
SearchProduct, SearchProduct,
FieldOptions, FieldOptions,
FieldOption,
LineOption, LineOption,
ProductImage, ProductImage,
ProductFormValues, ProductFormValues,
} from "./types"; } from "./types";
// --- Field configuration types ---
type FieldType = "input" | "combobox" | "multiselect" | "textarea";
interface FieldConfig {
key: keyof ProductFormValues;
label: string;
type: FieldType;
placeholder?: string;
searchPlaceholder?: string;
maxLength?: number;
rows?: number;
/** For combobox: key into fieldOptions, or "lines"/"sublines" for dynamic */
optionsKey?: keyof FieldOptions | "lines" | "sublines";
/** For combobox: disable when this other field is empty */
disabledUnless?: keyof ProductFormValues;
/** For multiselect colors field */
showColors?: boolean;
}
interface FieldGroup {
label?: string;
cols: number;
fields: FieldConfig[];
}
// --- Default field layout ---
const DEFAULT_LAYOUT: FieldGroup[] = [
{
label: "Taxonomy",
cols: 3,
fields: [
{ key: "company", label: "Company", type: "combobox", optionsKey: "companies", searchPlaceholder: "Search companies..." },
{ key: "line", label: "Line", type: "combobox", optionsKey: "lines", searchPlaceholder: "Search lines...", disabledUnless: "company" },
{ key: "subline", label: "Sub Line", type: "combobox", optionsKey: "sublines", searchPlaceholder: "Search sublines...", disabledUnless: "line" },
],
},
{
cols: 3,
fields: [
{ key: "artist", label: "Artist", type: "combobox", optionsKey: "artists", searchPlaceholder: "Search artists..." },
{ key: "tax_cat", label: "Tax Category", type: "combobox", optionsKey: "taxCategories" },
{ key: "ship_restrictions", label: "Shipping", type: "combobox", optionsKey: "shippingRestrictions" },
],
},
{
label: "Identifiers",
cols: 4,
fields: [
{ key: "upc", label: "UPC", type: "input" },
{ key: "item_number", label: "Item #", type: "input" },
{ key: "supplier_no", label: "Supplier #", type: "input" },
{ key: "notions_no", label: "Notions #", type: "input" },
],
},
{
label: "Pricing",
cols: 4,
fields: [
{ key: "msrp", label: "MSRP", type: "input" },
{ key: "cost_each", label: "Cost", type: "input" },
{ key: "qty_per_unit", label: "Min Qty", type: "input" },
{ key: "case_qty", label: "Case Pack", type: "input" },
],
},
{
label: "Dimensions",
cols: 4,
fields: [
{ key: "weight", label: "Weight (oz)", type: "input" },
{ key: "length", label: "Length (in)", type: "input" },
{ key: "width", label: "Width (in)", type: "input" },
{ key: "height", label: "Height (in)", type: "input" },
],
},
{
cols: 3,
fields: [
{ key: "size_cat", label: "Size Category", type: "combobox", optionsKey: "sizes" },
{ key: "coo", label: "Country of Origin", type: "input", placeholder: "2-letter code", maxLength: 2 },
{ key: "hts_code", label: "HTS Code", type: "input" },
],
},
{
label: "Classification",
cols: 3,
fields: [
{ key: "categories", label: "Categories", type: "multiselect", optionsKey: "categories", searchPlaceholder: "Search categories..." },
{ key: "themes", label: "Themes", type: "multiselect", optionsKey: "themes", searchPlaceholder: "Search themes..." },
{ key: "colors", label: "Colors", type: "multiselect", optionsKey: "colors", searchPlaceholder: "Search colors...", showColors: true },
],
},
{
label: "Description",
cols: 1,
fields: [
{ key: "description", label: "Description", type: "textarea", rows: 4 },
{ key: "priv_notes", label: "Private Notes", type: "textarea", rows: 2 },
],
},
];
export function ProductEditForm({ export function ProductEditForm({
product, product,
fieldOptions, fieldOptions,
@@ -78,6 +183,9 @@ export function ProductEditForm({
size_cat: product.size_cat ?? "", size_cat: product.size_cat ?? "",
description: product.description ?? "", description: product.description ?? "",
priv_notes: "", priv_notes: "",
categories: [],
themes: [],
colors: [],
}; };
originalValuesRef.current = { ...formValues }; originalValuesRef.current = { ...formValues };
@@ -93,6 +201,29 @@ export function ProductEditForm({
}) })
.catch(() => toast.error("Failed to load product images")) .catch(() => toast.error("Failed to load product images"))
.finally(() => setIsLoadingImages(false)); .finally(() => setIsLoadingImages(false));
// Fetch product categories (categories, themes, colors)
axios
.get(`/api/import/product-categories/${product.pid}`)
.then((res) => {
const cats: string[] = [];
const themes: string[] = [];
for (const item of res.data) {
const t = Number(item.type);
if (t >= 10 && t <= 13) cats.push(String(item.value));
else if (t >= 20 && t <= 21) themes.push(String(item.value));
}
const updatedValues = {
...formValues,
categories: cats,
themes,
};
originalValuesRef.current = { ...updatedValues };
reset(updatedValues);
})
.catch(() => {
// Non-critical — just leave arrays empty
});
}, [product, reset]); }, [product, reset]);
// Load lines when company changes // Load lines when company changes
@@ -158,8 +289,14 @@ export function ProductEditForm({
const changes: Record<string, unknown> = {}; const changes: Record<string, unknown> = {};
for (const key of Object.keys(data) as (keyof ProductFormValues)[]) { for (const key of Object.keys(data) as (keyof ProductFormValues)[]) {
if (data[key] !== original[key]) { const cur = data[key];
changes[key] = data[key]; const orig = original[key];
if (Array.isArray(cur) && Array.isArray(orig)) {
if (JSON.stringify([...cur].sort()) !== JSON.stringify([...orig].sort())) {
changes[key] = cur;
}
} else if (cur !== orig) {
changes[key] = cur;
} }
} }
@@ -201,6 +338,17 @@ export function ProductEditForm({
[product.pid, reset, computeImageChanges, productImages] [product.pid, reset, computeImageChanges, productImages]
); );
// Resolve options for a field config
const getOptions = useCallback(
(optionsKey?: string): FieldOption[] => {
if (!optionsKey) return [];
if (optionsKey === "lines") return lineOptions;
if (optionsKey === "sublines") return sublineOptions;
return (fieldOptions[optionsKey as keyof FieldOptions] as FieldOption[]) ?? [];
},
[fieldOptions, lineOptions, sublineOptions]
);
const hasImageChanges = computeImageChanges() !== null; const hasImageChanges = computeImageChanges() !== null;
const changedCount = Object.keys(dirtyFields).length; const changedCount = Object.keys(dirtyFields).length;
@@ -209,12 +357,20 @@ export function ProductEditForm({
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle className="text-lg"> <Controller
Editing: {product.title} name="name"
</CardTitle> control={control}
<p className="text-sm text-muted-foreground mt-1"> render={({ field }) => (
PID: {product.pid} | SKU: {product.sku} <EditableInput
</p> value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Product name"
className="text-lg font-semibold border-none"
inputClassName="text-lg font-semibold"
/>
)}
/>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(changedCount > 0 || hasImageChanges) && ( {(changedCount > 0 || hasImageChanges) && (
@@ -241,259 +397,121 @@ export function ProductEditForm({
/> />
</div> </div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-1">
{/* Basic Info */} {/* Supplier + badges row */}
<div className="space-y-4"> <div className="flex justify-between gap-1">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide"> <div className="p-1 rounded-md">
Basic Info <Controller name="supplier" control={control} render={({ field }) => (
</h3> <EditableComboboxField label="Supplier" options={fieldOptions.suppliers ?? []} value={field.value} onChange={field.onChange} placeholder="—" searchPlaceholder="Search suppliers..." />
<div className="grid grid-cols-1 gap-4"> )} />
<div>
<Label>Name</Label>
<Input {...register("name")} />
</div>
</div> </div>
<div className="grid grid-cols-3 gap-4"> <div className="flex gap-1">
<div> {[
<Label>UPC</Label> { label: "PID", value: product.pid },
<Input {...register("upc")} /> { label: "Item #", value: product.sku },
</div> { label: "UPC", value: product.barcode },
<div> ...(product.vendor_reference ? [{ label: "Supplier #", value: product.vendor_reference }] : []),
<Label>Item Number</Label> ...(product.notions_reference ? [{ label: "Notions #", value: product.notions_reference }] : []),
<Input {...register("item_number")} /> ].map((item) => (
</div> <Badge
<div> key={item.label}
<Label>Supplier #</Label> variant="outline"
<Input {...register("supplier_no")} /> className="gap-1 cursor-pointer hover:bg-muted border-none border-muted-foreground/50"
</div> onClick={() => {
</div> navigator.clipboard.writeText(String(item.value));
<div> toast.success(`Copied ${item.label}`);
<Label>Notions #</Label> }}
<Input >
{...register("notions_no")} {item.label}: {item.value}
className="max-w-[200px]" <Copy className="h-3 w-3 opacity-50" />
/> </Badge>
))}
</div> </div>
</div> </div>
{/* Taxonomy */} {/* Dynamic field groups */}
<div className="space-y-4"> {DEFAULT_LAYOUT.map((group, gi) => (
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide"> <div
Taxonomy key={gi}
</h3> className={`grid gap-x-4 gap-y-1 p-1 rounded-md`}
<div className="grid grid-cols-2 gap-4"> style={{ gridTemplateColumns: `repeat(${group.cols}, minmax(0, 1fr))` }}
<div> >
<Label>Supplier</Label> {group.fields.map((fc) => {
<Controller if (fc.type === "input") {
name="supplier" return (
control={control} <Controller
render={({ field }) => ( key={fc.key}
<ComboboxField name={fc.key}
options={fieldOptions.suppliers ?? []} control={control}
value={field.value} render={({ field }) => (
onChange={field.onChange} <EditableInput
placeholder="Select supplier" label={fc.label}
searchPlaceholder="Search suppliers..." value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder={fc.placeholder ?? "—"}
maxLength={fc.maxLength}
/>
)}
/> />
)} );
/> }
</div>
<div>
<Label>Company / Brand</Label>
<Controller
name="company"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.companies ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select company"
searchPlaceholder="Search companies..."
/>
)}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label>Line</Label>
<Controller
name="line"
control={control}
render={({ field }) => (
<ComboboxField
options={lineOptions}
value={field.value}
onChange={field.onChange}
placeholder="Select line"
searchPlaceholder="Search lines..."
disabled={!watchCompany}
/>
)}
/>
</div>
<div>
<Label>Sub Line</Label>
<Controller
name="subline"
control={control}
render={({ field }) => (
<ComboboxField
options={sublineOptions}
value={field.value}
onChange={field.onChange}
placeholder="Select subline"
searchPlaceholder="Search sublines..."
disabled={!watchLine}
/>
)}
/>
</div>
<div>
<Label>Artist</Label>
<Controller
name="artist"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.artists ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select artist"
searchPlaceholder="Search artists..."
/>
)}
/>
</div>
</div>
</div>
{/* Pricing */} if (fc.type === "combobox") {
<div className="space-y-4"> return (
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide"> <Controller
Pricing & Quantities key={fc.key}
</h3> name={fc.key}
<div className="grid grid-cols-4 gap-4"> control={control}
<div> render={({ field }) => (
<Label>MSRP</Label> <EditableComboboxField
<Input {...register("msrp")} /> label={fc.label}
</div> options={getOptions(fc.optionsKey)}
<div> value={field.value as string}
<Label>Cost Each</Label> onChange={field.onChange}
<Input {...register("cost_each")} /> placeholder="—"
</div> searchPlaceholder={fc.searchPlaceholder}
<div> disabled={fc.disabledUnless ? !watch(fc.disabledUnless) : false}
<Label>Min Qty</Label> />
<Input {...register("qty_per_unit")} /> )}
</div> />
<div> );
<Label>Case Pack</Label> }
<Input {...register("case_qty")} />
</div>
</div>
</div>
{/* Dimensions & Shipping */} if (fc.type === "multiselect") {
<div className="space-y-4"> return (
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide"> <Controller
Dimensions & Shipping key={fc.key}
</h3> name={fc.key}
<div className="grid grid-cols-4 gap-4"> control={control}
<div> render={({ field }) => (
<Label>Weight (lbs)</Label> <EditableMultiSelect
<Input {...register("weight")} /> label={fc.label}
</div> options={getOptions(fc.optionsKey)}
<div> value={(field.value as string[]) ?? []}
<Label>Length (in)</Label> onChange={field.onChange}
<Input {...register("length")} /> placeholder="—"
</div> searchPlaceholder={fc.searchPlaceholder}
<div> showColors={fc.showColors}
<Label>Width (in)</Label> />
<Input {...register("width")} /> )}
</div>
<div>
<Label>Height (in)</Label>
<Input {...register("height")} />
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label>Tax Category</Label>
<Controller
name="tax_cat"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.taxCategories ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select tax category"
/> />
)} );
/> }
</div>
<div>
<Label>Shipping Restrictions</Label>
<Controller
name="ship_restrictions"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.shippingRestrictions ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select restriction"
/>
)}
/>
</div>
<div>
<Label>Size Category</Label>
<Controller
name="size_cat"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.sizes ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select size"
/>
)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Country of Origin</Label>
<Input
{...register("coo")}
placeholder="2-letter code"
maxLength={2}
/>
</div>
<div>
<Label>HTS Code</Label>
<Input {...register("hts_code")} />
</div>
</div>
</div>
{/* Description */} if (fc.type === "textarea") {
<div className="space-y-4"> return (
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide"> <div key={fc.key} className="col-span-full">
Description <Label>{fc.label}</Label>
</h3> <Textarea {...register(fc.key)} rows={fc.rows ?? 3} />
<div> </div>
<Label>Description</Label> );
<Textarea {...register("description")} rows={4} /> }
return null;
})}
</div> </div>
<div> ))}
<Label>Private Notes</Label>
<Textarea {...register("priv_notes")} rows={2} />
</div>
</div>
{/* Submit */} {/* Submit */}
<div className="flex items-center justify-end gap-3 pt-4 border-t"> <div className="flex items-center justify-end gap-3 pt-4 border-t">

View File

@@ -92,4 +92,7 @@ export interface ProductFormValues {
size_cat: string; size_cat: string;
description: string; description: string;
priv_notes: string; priv_notes: string;
categories: string[];
themes: string[];
colors: string[];
} }