diff --git a/inventory/src/components/product-editor/ComboboxField.tsx b/inventory/src/components/product-editor/ComboboxField.tsx index 030573f..ad3329c 100644 --- a/inventory/src/components/product-editor/ComboboxField.tsx +++ b/inventory/src/components/product-editor/ComboboxField.tsx @@ -24,6 +24,9 @@ export function ComboboxField({ placeholder, searchPlaceholder, disabled, + defaultOpen, + onOpenChange: onOpenChangeProp, + triggerClassName, }: { options: FieldOption[]; value: string; @@ -31,19 +34,26 @@ export function ComboboxField({ placeholder: string; searchPlaceholder?: string; disabled?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + triggerClassName?: string; }) { - const [open, setOpen] = useState(false); - const selectedLabel = options.find((o) => o.value === value)?.label; + const [open, setOpen] = useState(defaultOpen ?? false); + const handleOpenChange = (next: boolean) => { + setOpen(next); + onOpenChangeProp?.(next); + }; + const selectedLabel = options.find((o) => String(o.value) === String(value))?.label; return ( - + + + + + + + No results. + + {options.map((opt) => ( + { + onChange(opt.value); + setOpen(false); + }} + > + + {opt.label} + + ))} + + + + + + ); +} diff --git a/inventory/src/components/product-editor/EditableInput.tsx b/inventory/src/components/product-editor/EditableInput.tsx new file mode 100644 index 0000000..29eb3ac --- /dev/null +++ b/inventory/src/components/product-editor/EditableInput.tsx @@ -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(null); + + useEffect(() => { + if (editing) { + inputRef.current?.focus(); + } + }, [editing]); + + if (editing) { + return ( +
+ {label && ( + {label} + )} + 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)} + /> +
+ ); + } + + return ( + + ); +} diff --git a/inventory/src/components/product-editor/EditableMultiSelect.tsx b/inventory/src/components/product-editor/EditableMultiSelect.tsx new file mode 100644 index 0000000..7a8c16a --- /dev/null +++ b/inventory/src/components/product-editor/EditableMultiSelect.tsx @@ -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) => { + e.stopPropagation(); + e.currentTarget.scrollTop += e.deltaY; + }, []); + + // Read-only display when closed + if (!open) { + return ( + + ); + } + + return ( +
+ {label && ( + {label} + )} + + + + + + + + + No results. +
+ {/* Selected items pinned to top */} + {value.length > 0 && ( + +
+ + Selected ({value.length}) +
+ {value.map((selectedVal) => { + const opt = options.find( + (o) => String(o.value) === String(selectedVal) + ) as ColorOption | undefined; + const hex = showColors && opt ? getHex(opt) : undefined; + return ( + handleSelect(selectedVal)} + className="bg-green-50/50 dark:bg-green-950/30" + > + + {hex && ( + + )} + {opt?.label ?? selectedVal} + + ); + })} +
+ )} + + {/* All options (excluding already-selected) */} + 0 ? "All Options" : undefined} + > + {options + .filter((o) => !value.includes(String(o.value))) + .map((opt) => { + const hex = showColors + ? getHex(opt as ColorOption) + : undefined; + return ( + handleSelect(String(opt.value))} + > + + {hex && ( + + )} + {opt.label} + + ); + })} + +
+
+
+
+
+
+ ); +} diff --git a/inventory/src/components/product-editor/ImageManager.tsx b/inventory/src/components/product-editor/ImageManager.tsx index f8ec3f8..01fd0bf 100644 --- a/inventory/src/components/product-editor/ImageManager.tsx +++ b/inventory/src/components/product-editor/ImageManager.tsx @@ -292,9 +292,28 @@ export function ImageManager({

Images ({images.length})

-

- Drag to reorder. First visible image is the main image. -

+ + {/* Add by URL */} +
+ + setUrlInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleUrlAdd())} + className="text-sm h-8" + /> + +
{/* Image grid with drag-and-drop */} @@ -328,7 +347,7 @@ export function ImageManager({ ) : ( <> - Add Image + Upload Image )} @@ -347,28 +366,6 @@ export function ImageManager({ - {/* Add by URL */} -
- - setUrlInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleUrlAdd())} - className="text-sm h-8" - /> - -
- {/* Hidden file input */} toast.error("Failed to load product images")) .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]); // Load lines when company changes @@ -158,8 +289,14 @@ export function ProductEditForm({ const changes: Record = {}; for (const key of Object.keys(data) as (keyof ProductFormValues)[]) { - if (data[key] !== original[key]) { - changes[key] = data[key]; + const cur = 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] ); + // 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 changedCount = Object.keys(dirtyFields).length; @@ -209,12 +357,20 @@ export function ProductEditForm({
- - Editing: {product.title} - -

- PID: {product.pid} | SKU: {product.sku} -

+ ( + + )} + />
{(changedCount > 0 || hasImageChanges) && ( @@ -241,259 +397,121 @@ export function ProductEditForm({ />
-
- {/* Basic Info */} -
-

- Basic Info -

-
-
- - -
+ + {/* Supplier + badges row */} +
+
+ ( + + )} />
-
-
- - -
-
- - -
-
- - -
-
-
- - +
+ {[ + { label: "PID", value: product.pid }, + { label: "Item #", value: product.sku }, + { label: "UPC", value: product.barcode }, + ...(product.vendor_reference ? [{ label: "Supplier #", value: product.vendor_reference }] : []), + ...(product.notions_reference ? [{ label: "Notions #", value: product.notions_reference }] : []), + ].map((item) => ( + { + navigator.clipboard.writeText(String(item.value)); + toast.success(`Copied ${item.label}`); + }} + > + {item.label}: {item.value} + + + ))}
- {/* Taxonomy */} -
-

- Taxonomy -

-
-
- - ( - ( +
+ {group.fields.map((fc) => { + if (fc.type === "input") { + return ( + ( + + )} /> - )} - /> -
-
- - ( - - )} - /> -
-
-
-
- - ( - - )} - /> -
-
- - ( - - )} - /> -
-
- - ( - - )} - /> -
-
-
+ ); + } - {/* Pricing */} -
-

- Pricing & Quantities -

-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
+ if (fc.type === "combobox") { + return ( + ( + + )} + /> + ); + } - {/* Dimensions & Shipping */} -
-

- Dimensions & Shipping -

-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - ( - ( + + )} /> - )} - /> -
-
- - ( - - )} - /> -
-
- - ( - - )} - /> -
-
-
-
- - -
-
- - -
-
-
+ ); + } - {/* Description */} -
-

- Description -

-
- -