diff --git a/CLAUDE.md b/CLAUDE.md index a963e90..8fe1618 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,2 @@ -* Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead. \ No newline at end of file +* Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead. +* If you use the task tool to have an agent investigate something, make sure to let it know to avoid using glob \ No newline at end of file diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index 6e354cd..6cc8ed4 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -1246,6 +1246,48 @@ router.get('/search-products', async (req, res) => { } }); +// Get product images for a given PID from production DB +router.get('/product-images/:pid', async (req, res) => { + const pid = parseInt(req.params.pid, 10); + if (!pid || pid <= 0) { + return res.status(400).json({ error: 'Valid PID is required' }); + } + + try { + const { connection } = await getDbConnection(); + + const [rows] = await connection.query( + 'SELECT iid, type, width, height, `order`, hidden FROM product_images WHERE pid = ? ORDER BY `order` DESC, type', + [pid] + ); + + // Group by iid and build image URLs using the same logic as the PHP codebase + const typeMap = { 1: 'o', 2: 'l', 3: 't', 4: '100x100', 5: '175x175', 6: '300x300', 7: '600x600', 8: '500x500', 9: '150x150' }; + const padded = String(pid).padStart(10, '0'); + const pathPrefix = `${padded.substring(0, 4)}/${padded.substring(4, 7)}/`; + + const imagesByIid = {}; + for (const row of rows) { + const typeName = typeMap[row.type]; + if (!typeName) continue; + if (!imagesByIid[row.iid]) { + imagesByIid[row.iid] = { iid: row.iid, order: row.order, hidden: !!row.hidden, sizes: {} }; + } + imagesByIid[row.iid].sizes[typeName] = { + width: row.width, + height: row.height, + url: `https://sbing.com/i/products/${pathPrefix}${pid}-${typeName}-${row.iid}.jpg`, + }; + } + + const images = Object.values(imagesByIid).sort((a, b) => b.order - a.order); + res.json(images); + } catch (error) { + console.error('Error fetching product images:', error); + res.status(500).json({ error: 'Failed to fetch product images' }); + } +}); + const UPC_SUPPLIER_PREFIX_LEADING_DIGIT = '4'; const UPC_MAX_SEQUENCE = 99999; const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes diff --git a/inventory/package-lock.json b/inventory/package-lock.json index 0672665..4a802a1 100644 --- a/inventory/package-lock.json +++ b/inventory/package-lock.json @@ -28,7 +28,7 @@ "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-separator": "^1.1.1", - "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.6", @@ -51,6 +51,7 @@ "cmdk": "^1.0.0", "date-fns": "^3.6.0", "diff": "^7.0.0", + "embla-carousel-react": "^8.6.0", "framer-motion": "^12.4.4", "immer": "^11.1.3", "input-otp": "^1.4.1", @@ -1498,6 +1499,24 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", @@ -1633,6 +1652,24 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", @@ -1699,6 +1736,24 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -1891,6 +1946,24 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", @@ -1928,6 +2001,24 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", @@ -2031,6 +2122,24 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-progress": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz", @@ -2192,6 +2301,24 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", @@ -2216,12 +2343,12 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", - "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1" + "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2233,6 +2360,21 @@ } } }, + "node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-switch": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz", @@ -2414,6 +2556,24 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -4794,6 +4954,34 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", diff --git a/inventory/package.json b/inventory/package.json index 8a3ee83..ce947a4 100644 --- a/inventory/package.json +++ b/inventory/package.json @@ -32,7 +32,7 @@ "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-separator": "^1.1.1", - "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.6", @@ -55,6 +55,7 @@ "cmdk": "^1.0.0", "date-fns": "^3.6.0", "diff": "^7.0.0", + "embla-carousel-react": "^8.6.0", "framer-motion": "^12.4.4", "immer": "^11.1.3", "input-otp": "^1.4.1", diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index c67fd01..d385b5f 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -33,9 +33,12 @@ const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard')); const Dashboard = lazy(() => import('./pages/Dashboard')); const SmallDashboard = lazy(() => import('./pages/SmallDashboard')); -// 3. Product import - separate chunk +// 3. Product import - separate chunk const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import }))); +// Product editor +const ProductEditor = lazy(() => import('./pages/ProductEditor')); + // 4. Chat archive - separate chunk const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat }))); @@ -185,6 +188,15 @@ function App() { } /> + {/* Product editor */} + + }> + + + + } /> + {/* Product import - separate chunk */} diff --git a/inventory/src/components/layout/AppSidebar.tsx b/inventory/src/components/layout/AppSidebar.tsx index 6b55a9d..56e336a 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -13,6 +13,7 @@ import { Percent, FileSearch, ShoppingCart, + FilePenLine, } from "lucide-react"; import { IconCrystalBall } from "@tabler/icons-react"; import { @@ -113,6 +114,12 @@ const toolsItems = [ icon: IconCrystalBall, url: "/forecasting", permission: "access:forecasting" + }, + { + title: "Product Editor", + icon: FilePenLine, + url: "/product-editor", + permission: "access:product_editor" } ]; diff --git a/inventory/src/components/product-editor/ComboboxField.tsx b/inventory/src/components/product-editor/ComboboxField.tsx new file mode 100644 index 0000000..030573f --- /dev/null +++ b/inventory/src/components/product-editor/ComboboxField.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ChevronsUpDown, Check } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { FieldOption } from "./types"; + +export function ComboboxField({ + options, + value, + onChange, + placeholder, + searchPlaceholder, + disabled, +}: { + options: FieldOption[]; + value: string; + onChange: (val: string) => void; + placeholder: string; + searchPlaceholder?: string; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + const selectedLabel = options.find((o) => o.value === value)?.label; + + return ( + + + + + + + + + No results. + + {options.map((opt) => ( + { + onChange(opt.value); + setOpen(false); + }} + > + + {opt.label} + + ))} + + + + + + ); +} diff --git a/inventory/src/components/product-editor/ImageManager.tsx b/inventory/src/components/product-editor/ImageManager.tsx new file mode 100644 index 0000000..f8ec3f8 --- /dev/null +++ b/inventory/src/components/product-editor/ImageManager.tsx @@ -0,0 +1,403 @@ +import { useState, useCallback, useRef } from "react"; +import axios from "axios"; +import { toast } from "sonner"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Loader2, + Eye, + EyeOff, + Trash2, + ZoomIn, + ImagePlus, + Link, +} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragOverlay, + type DragStartEvent, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + horizontalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { ProductImage } from "./types"; + +// ── Helper: get best image URL ───────────────────────────────────────── + +export function getImageSrc(img: ProductImage): string | null { + if (img.imageUrl) return img.imageUrl; + return ( + img.sizes["600x600"]?.url ?? + img.sizes["500x500"]?.url ?? + img.sizes["300x300"]?.url ?? + img.sizes["l"]?.url ?? + img.sizes["o"]?.url ?? + null + ); +} + +// ── Sortable Image Card ──────────────────────────────────────────────── + +function SortableImageCard({ + image, + onToggleHidden, + onDelete, + onZoom, +}: { + image: ProductImage; + onToggleHidden: (iid: number | string) => void; + onDelete: (iid: number | string) => void; + onZoom: (image: ProductImage) => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: image.iid }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + touchAction: "none" as const, + }; + + const src = getImageSrc(image); + if (!src) return null; + + return ( +
+ {/* Action buttons */} +
+ + + +
+ + {/* Hidden badge */} + {image.hidden && ( +
+ + Hidden + +
+ )} + + {`Image +
+ ); +} + +// ── Image Manager Section ────────────────────────────────────────────── + +let newImageCounter = 0; + +export function ImageManager({ + images, + setImages, + isLoading, +}: { + images: ProductImage[]; + setImages: React.Dispatch>; + isLoading: boolean; +}) { + const [activeId, setActiveId] = useState(null); + const [zoomImage, setZoomImage] = useState(null); + const [urlInput, setUrlInput] = useState(""); + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveId(event.active.id as number | string); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + setActiveId(null); + const { active, over } = event; + if (!over || active.id === over.id) return; + + setImages((prev) => { + const oldIndex = prev.findIndex((img) => img.iid === active.id); + const newIndex = prev.findIndex((img) => img.iid === over.id); + return arrayMove(prev, oldIndex, newIndex); + }); + }, + [setImages] + ); + + const toggleHidden = useCallback( + (iid: number | string) => { + setImages((prev) => + prev.map((img) => + img.iid === iid ? { ...img, hidden: !img.hidden } : img + ) + ); + }, + [setImages] + ); + + const deleteImage = useCallback( + (iid: number | string) => { + setImages((prev) => prev.filter((img) => img.iid !== iid)); + }, + [setImages] + ); + + const addNewImage = useCallback( + (imageUrl: string) => { + const id = `new-${newImageCounter++}`; + const newImg: ProductImage = { + iid: id, + order: 0, + hidden: false, + sizes: {}, + imageUrl, + isNew: true, + }; + setImages((prev) => [...prev, newImg]); + }, + [setImages] + ); + + const handleFileUpload = useCallback( + async (files: FileList | null) => { + if (!files || files.length === 0) return; + setIsUploading(true); + try { + for (const file of Array.from(files)) { + const formData = new FormData(); + formData.append("image", file); + const res = await axios.post("/api/import/upload-image", formData); + if (res.data?.imageUrl) { + addNewImage(res.data.imageUrl); + } + } + } catch { + toast.error("Failed to upload image"); + } finally { + setIsUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }, + [addNewImage] + ); + + const handleUrlAdd = useCallback(() => { + const url = urlInput.trim(); + if (!url) return; + addNewImage(url); + setUrlInput(""); + }, [urlInput, addNewImage]); + + const activeImage = activeId + ? images.find((img) => img.iid === activeId) + : null; + + if (isLoading) { + return ( +
+ +
+ ); + } + + const imageIds = images.map((img) => img.iid); + + return ( +
+
+

+ Images ({images.length}) +

+

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

+
+ + {/* Image grid with drag-and-drop */} + + +
+ {images.map((img) => ( + + ))} + + {/* Add image button */} + +
+
+ + {activeImage ? ( +
+ Dragging +
+ ) : null} +
+
+ + {/* Add by URL */} +
+ + setUrlInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleUrlAdd())} + className="text-sm h-8" + /> + +
+ + {/* Hidden file input */} + handleFileUpload(e.target.files)} + /> + + {/* Full-size image overlay */} + !open && setZoomImage(null)}> + + Product image preview + {zoomImage && ( + {`Image + )} + + +
+ ); +} diff --git a/inventory/src/components/product-editor/ProductEditForm.tsx b/inventory/src/components/product-editor/ProductEditForm.tsx new file mode 100644 index 0000000..2da0feb --- /dev/null +++ b/inventory/src/components/product-editor/ProductEditForm.tsx @@ -0,0 +1,528 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import axios from "axios"; +import { useForm, Controller } from "react-hook-form"; +import { toast } from "sonner"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, X } from "lucide-react"; +import { submitProductEdit, type ImageChanges } from "@/services/productEditor"; +import { ComboboxField } from "./ComboboxField"; +import { ImageManager } from "./ImageManager"; +import type { + SearchProduct, + FieldOptions, + LineOption, + ProductImage, + ProductFormValues, +} from "./types"; + +export function ProductEditForm({ + product, + fieldOptions, + onClose, +}: { + product: SearchProduct; + fieldOptions: FieldOptions; + onClose: () => void; +}) { + const [lineOptions, setLineOptions] = useState([]); + const [sublineOptions, setSublineOptions] = useState([]); + const [productImages, setProductImages] = useState([]); + const [isLoadingImages, setIsLoadingImages] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const originalValuesRef = useRef(null); + const originalImagesRef = useRef([]); + + const { + register, + control, + handleSubmit, + reset, + watch, + formState: { dirtyFields }, + } = useForm(); + + const watchCompany = watch("company"); + const watchLine = watch("line"); + + // Populate form on mount + useEffect(() => { + const formValues: ProductFormValues = { + name: product.title ?? "", + company: String(product.brand_id ?? ""), + line: String(product.line_id ?? ""), + subline: String(product.subline_id ?? ""), + supplier: String(product.supplier ?? ""), + upc: product.barcode ?? "", + item_number: product.sku ?? "", + supplier_no: product.vendor_reference ?? "", + notions_no: product.notions_reference ?? "", + msrp: String(product.regular_price ?? ""), + cost_each: String(product.cost_price ?? ""), + qty_per_unit: String(product.moq ?? ""), + case_qty: String(product.case_qty ?? ""), + tax_cat: String(product.tax_code ?? ""), + artist: String(product.artist_id ?? ""), + weight: product.weight?.toString() ?? "", + length: product.length?.toString() ?? "", + width: product.width?.toString() ?? "", + height: product.height?.toString() ?? "", + ship_restrictions: product.shipping_restrictions ?? "", + coo: product.country_of_origin ?? "", + hts_code: product.harmonized_tariff_code ?? "", + size_cat: product.size_cat ?? "", + description: product.description ?? "", + priv_notes: "", + }; + + originalValuesRef.current = { ...formValues }; + reset(formValues); + + // Fetch images + setIsLoadingImages(true); + axios + .get(`/api/import/product-images/${product.pid}`) + .then((res) => { + setProductImages(res.data); + originalImagesRef.current = res.data; + }) + .catch(() => toast.error("Failed to load product images")) + .finally(() => setIsLoadingImages(false)); + }, [product, reset]); + + // Load lines when company changes + useEffect(() => { + if (!watchCompany) { + setLineOptions([]); + return; + } + axios + .get(`/api/import/product-lines/${watchCompany}`) + .then((res) => setLineOptions(res.data)) + .catch(() => setLineOptions([])); + }, [watchCompany]); + + // Load sublines when line changes + useEffect(() => { + if (!watchLine) { + setSublineOptions([]); + return; + } + axios + .get(`/api/import/sublines/${watchLine}`) + .then((res) => setSublineOptions(res.data)) + .catch(() => setSublineOptions([])); + }, [watchLine]); + + const computeImageChanges = useCallback((): ImageChanges | null => { + const original = originalImagesRef.current; + const current = productImages; + + const originalIds = original.map((img) => img.iid); + const currentIds = current.map((img) => img.iid); + + const deleted = originalIds.filter((id) => !currentIds.includes(id)) as number[]; + const hidden = current.filter((img) => img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number"); + const added: Record = {}; + for (const img of current) { + if (img.isNew && img.imageUrl) { + added[String(img.iid)] = img.imageUrl; + } + } + + const order = current.map((img) => img.iid); + + const originalHidden = original.filter((img) => img.hidden).map((img) => img.iid); + const orderChanged = JSON.stringify(order.filter((id) => typeof id === "number")) !== JSON.stringify(originalIds); + const hiddenChanged = JSON.stringify([...hidden].sort()) !== JSON.stringify([...(originalHidden as number[])].sort()); + const hasDeleted = deleted.length > 0; + const hasAdded = Object.keys(added).length > 0; + + if (!orderChanged && !hiddenChanged && !hasDeleted && !hasAdded) { + return null; + } + + return { order, hidden, deleted, added }; + }, [productImages]); + + const onSubmit = useCallback( + async (data: ProductFormValues) => { + if (!originalValuesRef.current) return; + + const original = originalValuesRef.current; + const changes: Record = {}; + + for (const key of Object.keys(data) as (keyof ProductFormValues)[]) { + if (data[key] !== original[key]) { + changes[key] = data[key]; + } + } + + const imageChanges = computeImageChanges(); + + if (Object.keys(changes).length === 0 && !imageChanges) { + toast.info("No changes to submit"); + return; + } + + setIsSubmitting(true); + try { + const result = await submitProductEdit({ + pid: product.pid, + changes, + environment: "prod", + imageChanges: imageChanges ?? undefined, + }); + + if (result.success) { + toast.success("Product updated successfully"); + originalValuesRef.current = { ...data }; + originalImagesRef.current = [...productImages]; + reset(data); + } else { + toast.error(result.message ?? "Failed to update product"); + if (result.error) { + console.error("Edit error details:", result.error); + } + } + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to update product" + ); + } finally { + setIsSubmitting(false); + } + }, + [product.pid, reset, computeImageChanges, productImages] + ); + + const hasImageChanges = computeImageChanges() !== null; + const changedCount = Object.keys(dirtyFields).length; + + return ( + + +
+
+ + Editing: {product.title} + +

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

+
+
+ {(changedCount > 0 || hasImageChanges) && ( + + {changedCount > 0 ? `${changedCount} field${changedCount !== 1 ? "s" : ""}` : ""} + {changedCount > 0 && hasImageChanges ? " + " : ""} + {hasImageChanges ? "images" : ""} + {" changed"} + + )} + +
+
+
+ + {/* Product Images */} +
+ +
+ +
+ {/* Basic Info */} +
+

+ Basic Info +

+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + {/* Taxonomy */} +
+

+ Taxonomy +

+
+
+ + ( + + )} + /> +
+
+ + ( + + )} + /> +
+
+
+
+ + ( + + )} + /> +
+
+ + ( + + )} + /> +
+
+ + ( + + )} + /> +
+
+
+ + {/* Pricing */} +
+

+ Pricing & Quantities +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Dimensions & Shipping */} +
+

+ Dimensions & Shipping +

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

+ Description +

+
+ +