Product editor tweaks
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
inventory/src/components/product-editor/EditableInput.tsx
Normal file
86
inventory/src/components/product-editor/EditableInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
inventory/src/components/product-editor/EditableMultiSelect.tsx
Normal file
246
inventory/src/components/product-editor/EditableMultiSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user