Add product editor
This commit is contained in:
@@ -1 +1,2 @@
|
||||
* Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead.
|
||||
* 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
|
||||
@@ -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
|
||||
|
||||
198
inventory/package-lock.json
generated
198
inventory/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
{/* Product editor */}
|
||||
<Route path="/product-editor" element={
|
||||
<Protected page="product_editor">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<ProductEditor />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
{/* Product import - separate chunk */}
|
||||
<Route path="/import" element={
|
||||
<Protected page="import">
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
85
inventory/src/components/product-editor/ComboboxField.tsx
Normal file
85
inventory/src/components/product-editor/ComboboxField.tsx
Normal file
@@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">{selectedLabel ?? placeholder}</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>
|
||||
<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",
|
||||
value === opt.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{opt.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
403
inventory/src/components/product-editor/ImageManager.tsx
Normal file
403
inventory/src/components/product-editor/ImageManager.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
"relative group rounded-lg border bg-white shrink-0 cursor-grab active:cursor-grabbing",
|
||||
"w-[140px] h-[140px]",
|
||||
image.hidden && "opacity-50 ring-2 ring-yellow-400"
|
||||
)}
|
||||
>
|
||||
{/* Action buttons */}
|
||||
<div className="absolute top-1 right-1 z-10 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onZoom(image);
|
||||
}}
|
||||
className="p-1 rounded bg-black/40 text-white hover:bg-black/60"
|
||||
title="View full size"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleHidden(image.iid);
|
||||
}}
|
||||
className="p-1 rounded bg-black/40 text-white hover:bg-black/60"
|
||||
title={image.hidden ? "Show image" : "Hide image"}
|
||||
>
|
||||
{image.hidden ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(image.iid);
|
||||
}}
|
||||
className="p-1 rounded bg-red-500/80 text-white hover:bg-red-600"
|
||||
title="Delete image"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hidden badge */}
|
||||
{image.hidden && (
|
||||
<div className="absolute bottom-1 left-1 z-10">
|
||||
<Badge variant="outline" className="text-[10px] bg-yellow-100 border-yellow-400 px-1 py-0">
|
||||
Hidden
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={src}
|
||||
alt={`Image ${image.iid}`}
|
||||
className="w-full h-full object-contain rounded-lg pointer-events-none select-none"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Image Manager Section ──────────────────────────────────────────────
|
||||
|
||||
let newImageCounter = 0;
|
||||
|
||||
export function ImageManager({
|
||||
images,
|
||||
setImages,
|
||||
isLoading,
|
||||
}: {
|
||||
images: ProductImage[];
|
||||
setImages: React.Dispatch<React.SetStateAction<ProductImage[]>>;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const [activeId, setActiveId] = useState<number | string | null>(null);
|
||||
const [zoomImage, setZoomImage] = useState<ProductImage | null>(null);
|
||||
const [urlInput, setUrlInput] = useState("");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const imageIds = images.map((img) => img.iid);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
|
||||
Images ({images.length})
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Drag to reorder. First visible image is the main image.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Image grid with drag-and-drop */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={imageIds} strategy={horizontalListSortingStrategy}>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{images.map((img) => (
|
||||
<SortableImageCard
|
||||
key={img.iid}
|
||||
image={img}
|
||||
onToggleHidden={toggleHidden}
|
||||
onDelete={deleteImage}
|
||||
onZoom={setZoomImage}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add image button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="w-[140px] h-[140px] rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 flex flex-col items-center justify-center gap-2 text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ImagePlus className="h-6 w-6" />
|
||||
<span className="text-xs">Add Image</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeImage ? (
|
||||
<div className="w-[140px] h-[140px] rounded-lg border bg-white shadow-lg">
|
||||
<img
|
||||
src={getImageSrc(activeImage) ?? ""}
|
||||
alt="Dragging"
|
||||
className="w-full h-full object-contain rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</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 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
/>
|
||||
|
||||
{/* Full-size image overlay */}
|
||||
<Dialog open={!!zoomImage} onOpenChange={(open) => !open && setZoomImage(null)}>
|
||||
<DialogContent className="max-w-3xl p-2">
|
||||
<DialogTitle className="sr-only">Product image preview</DialogTitle>
|
||||
{zoomImage && (
|
||||
<img
|
||||
src={
|
||||
zoomImage.imageUrl ??
|
||||
zoomImage.sizes["o"]?.url ??
|
||||
zoomImage.sizes["600x600"]?.url ??
|
||||
getImageSrc(zoomImage) ??
|
||||
""
|
||||
}
|
||||
alt={`Image ${zoomImage.iid}`}
|
||||
className="w-full h-auto object-contain max-h-[80vh] rounded"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
528
inventory/src/components/product-editor/ProductEditForm.tsx
Normal file
528
inventory/src/components/product-editor/ProductEditForm.tsx
Normal file
@@ -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<LineOption[]>([]);
|
||||
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
|
||||
const [productImages, setProductImages] = useState<ProductImage[]>([]);
|
||||
const [isLoadingImages, setIsLoadingImages] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const originalValuesRef = useRef<ProductFormValues | null>(null);
|
||||
const originalImagesRef = useRef<ProductImage[]>([]);
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { dirtyFields },
|
||||
} = useForm<ProductFormValues>();
|
||||
|
||||
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<string, string> = {};
|
||||
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<string, unknown> = {};
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
Editing: {product.title}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
PID: {product.pid} | SKU: {product.sku}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(changedCount > 0 || hasImageChanges) && (
|
||||
<Badge variant="secondary">
|
||||
{changedCount > 0 ? `${changedCount} field${changedCount !== 1 ? "s" : ""}` : ""}
|
||||
{changedCount > 0 && hasImageChanges ? " + " : ""}
|
||||
{hasImageChanges ? "images" : ""}
|
||||
{" changed"}
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Product Images */}
|
||||
<div className="mb-6">
|
||||
<ImageManager
|
||||
images={productImages}
|
||||
setImages={setProductImages}
|
||||
isLoading={isLoadingImages}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
|
||||
Basic Info
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Input {...register("name")} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>UPC</Label>
|
||||
<Input {...register("upc")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Item Number</Label>
|
||||
<Input {...register("item_number")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Supplier #</Label>
|
||||
<Input {...register("supplier_no")} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Notions #</Label>
|
||||
<Input
|
||||
{...register("notions_no")}
|
||||
className="max-w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Taxonomy */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
|
||||
Taxonomy
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Supplier</Label>
|
||||
<Controller
|
||||
name="supplier"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ComboboxField
|
||||
options={fieldOptions.suppliers ?? []}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Select supplier"
|
||||
searchPlaceholder="Search suppliers..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</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 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
|
||||
Pricing & Quantities
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label>MSRP</Label>
|
||||
<Input {...register("msrp")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Cost Each</Label>
|
||||
<Input {...register("cost_each")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Min Qty</Label>
|
||||
<Input {...register("qty_per_unit")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Case Pack</Label>
|
||||
<Input {...register("case_qty")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dimensions & Shipping */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
|
||||
Dimensions & Shipping
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label>Weight (lbs)</Label>
|
||||
<Input {...register("weight")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Length (in)</Label>
|
||||
<Input {...register("length")} />
|
||||
</div>
|
||||
<div>
|
||||
<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 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
|
||||
Description
|
||||
</h3>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea {...register("description")} rows={4} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Private Notes</Label>
|
||||
<Textarea {...register("priv_notes")} rows={2} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (originalValuesRef.current) {
|
||||
reset(originalValuesRef.current);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Reset Changes
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || (changedCount === 0 && !hasImageChanges)}
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Submit{" "}
|
||||
{changedCount > 0 || hasImageChanges
|
||||
? `(${changedCount} field${changedCount !== 1 ? "s" : ""}${hasImageChanges ? " + images" : ""})`
|
||||
: ""}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
114
inventory/src/components/product-editor/ProductSearch.tsx
Normal file
114
inventory/src/components/product-editor/ProductSearch.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import type { SearchProduct } from "./types";
|
||||
|
||||
export function ProductSearch({
|
||||
onSelect,
|
||||
}: {
|
||||
onSelect: (product: SearchProduct) => void;
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SearchProduct[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!searchTerm.trim()) return;
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const res = await axios.get("/api/import/search-products", {
|
||||
params: { q: searchTerm },
|
||||
});
|
||||
setSearchResults(res.data);
|
||||
} catch {
|
||||
toast.error("Search failed");
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [searchTerm]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(product: SearchProduct) => {
|
||||
onSelect(product);
|
||||
setSearchResults([]);
|
||||
},
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Search Products</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Search by name, SKU, UPC, brand..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={isSearching}>
|
||||
{isSearching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-4 border rounded-md">
|
||||
<ScrollArea className="max-h-80">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>SKU</TableHead>
|
||||
<TableHead>Brand</TableHead>
|
||||
<TableHead>Line</TableHead>
|
||||
<TableHead className="text-right">Price</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{searchResults.map((product) => (
|
||||
<TableRow
|
||||
key={product.pid}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSelect(product)}
|
||||
>
|
||||
<TableCell className="max-w-[300px] truncate">
|
||||
{product.title}
|
||||
</TableCell>
|
||||
<TableCell>{product.sku}</TableCell>
|
||||
<TableCell>{product.brand}</TableCell>
|
||||
<TableCell>{product.line}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
$
|
||||
{Number(product.regular_price)?.toFixed(2) ??
|
||||
product.regular_price}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
95
inventory/src/components/product-editor/types.ts
Normal file
95
inventory/src/components/product-editor/types.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export interface SearchProduct {
|
||||
pid: number;
|
||||
title: string;
|
||||
description: string;
|
||||
sku: string;
|
||||
barcode: string;
|
||||
harmonized_tariff_code: string;
|
||||
price: number;
|
||||
regular_price: number;
|
||||
cost_price: number;
|
||||
vendor: string;
|
||||
vendor_reference: string;
|
||||
notions_reference: string;
|
||||
brand: string;
|
||||
brand_id: string;
|
||||
line: string;
|
||||
line_id: string;
|
||||
subline: string;
|
||||
subline_id: string;
|
||||
artist: string;
|
||||
artist_id: string;
|
||||
moq: number;
|
||||
weight: number;
|
||||
length: number;
|
||||
width: number;
|
||||
height: number;
|
||||
country_of_origin: string;
|
||||
total_sold: number;
|
||||
first_received: string | null;
|
||||
date_last_sold: string | null;
|
||||
supplier?: string;
|
||||
case_qty?: number;
|
||||
tax_code?: string;
|
||||
size_cat?: string;
|
||||
shipping_restrictions?: string;
|
||||
}
|
||||
|
||||
export interface FieldOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface FieldOptions {
|
||||
companies: FieldOption[];
|
||||
artists: FieldOption[];
|
||||
sizes: FieldOption[];
|
||||
themes: FieldOption[];
|
||||
categories: FieldOption[];
|
||||
colors: FieldOption[];
|
||||
suppliers: FieldOption[];
|
||||
taxCategories: FieldOption[];
|
||||
shippingRestrictions: FieldOption[];
|
||||
}
|
||||
|
||||
export interface LineOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProductImage {
|
||||
iid: number | string; // number for existing, string like "new-0" for added
|
||||
order: number;
|
||||
hidden: boolean;
|
||||
sizes: Record<string, { width: number; height: number; url: string }>;
|
||||
imageUrl?: string; // for newly added images (the uploaded URL)
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductFormValues {
|
||||
name: string;
|
||||
company: string;
|
||||
line: string;
|
||||
subline: string;
|
||||
supplier: string;
|
||||
upc: string;
|
||||
item_number: string;
|
||||
supplier_no: string;
|
||||
notions_no: string;
|
||||
msrp: string;
|
||||
cost_each: string;
|
||||
qty_per_unit: string;
|
||||
case_qty: string;
|
||||
tax_cat: string;
|
||||
artist: string;
|
||||
weight: string;
|
||||
length: string;
|
||||
width: string;
|
||||
height: string;
|
||||
ship_restrictions: string;
|
||||
coo: string;
|
||||
hts_code: string;
|
||||
size_cat: string;
|
||||
description: string;
|
||||
priv_notes: string;
|
||||
}
|
||||
260
inventory/src/components/ui/carousel.tsx
Normal file
260
inventory/src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return
|
||||
}
|
||||
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
Carousel.displayName = "Carousel"
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CarouselContent.displayName = "CarouselContent"
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CarouselItem.displayName = "CarouselItem"
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselPrevious.displayName = "CarouselPrevious"
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselNext.displayName = "CarouselNext"
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
55
inventory/src/pages/ProductEditor.tsx
Normal file
55
inventory/src/pages/ProductEditor.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ProductSearch } from "@/components/product-editor/ProductSearch";
|
||||
import { ProductEditForm } from "@/components/product-editor/ProductEditForm";
|
||||
import type { SearchProduct, FieldOptions } from "@/components/product-editor/types";
|
||||
|
||||
export default function ProductEditor() {
|
||||
const [selectedProduct, setSelectedProduct] = useState<SearchProduct | null>(null);
|
||||
const [fieldOptions, setFieldOptions] = useState<FieldOptions | null>(null);
|
||||
const [isLoadingOptions, setIsLoadingOptions] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
axios
|
||||
.get("/api/import/field-options")
|
||||
.then((res) => setFieldOptions(res.data))
|
||||
.catch((err) => {
|
||||
console.error("Failed to load field options:", err);
|
||||
toast.error("Failed to load field options");
|
||||
})
|
||||
.finally(() => setIsLoadingOptions(false));
|
||||
}, []);
|
||||
|
||||
if (isLoadingOptions) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Product Editor</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Search for a product and edit its fields. Only changed fields will be
|
||||
submitted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProductSearch onSelect={setSelectedProduct} />
|
||||
|
||||
{selectedProduct && fieldOptions && (
|
||||
<ProductEditForm
|
||||
key={selectedProduct.pid}
|
||||
product={selectedProduct}
|
||||
fieldOptions={fieldOptions}
|
||||
onClose={() => setSelectedProduct(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
inventory/src/services/productEditor.ts
Normal file
98
inventory/src/services/productEditor.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export interface ImageChanges {
|
||||
order: (number | string)[];
|
||||
hidden: number[];
|
||||
deleted: number[];
|
||||
added: Record<string, string>; // e.g. { "new-0": "https://..." }
|
||||
}
|
||||
|
||||
export interface SubmitProductEditArgs {
|
||||
pid: number;
|
||||
changes: Record<string, unknown>;
|
||||
environment: "dev" | "prod";
|
||||
imageChanges?: ImageChanges;
|
||||
}
|
||||
|
||||
export interface SubmitProductEditResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
const DEV_ENDPOINT = "/apiv2-test/product/edit";
|
||||
const PROD_ENDPOINT = "/apiv2/product/edit";
|
||||
|
||||
const isHtmlResponse = (payload: string) => {
|
||||
const trimmed = payload.trim();
|
||||
return trimmed.startsWith("<!DOCTYPE html") || trimmed.startsWith("<html");
|
||||
};
|
||||
|
||||
export async function submitProductEdit({
|
||||
pid,
|
||||
changes,
|
||||
environment,
|
||||
imageChanges,
|
||||
}: SubmitProductEditArgs): Promise<SubmitProductEditResponse> {
|
||||
const targetUrl = environment === "dev" ? DEV_ENDPOINT : PROD_ENDPOINT;
|
||||
|
||||
const product: Record<string, unknown> = { pid, ...changes };
|
||||
if (imageChanges) {
|
||||
product.image_changes = imageChanges;
|
||||
}
|
||||
const payload = new URLSearchParams();
|
||||
payload.append("products", JSON.stringify([product]));
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||
},
|
||||
body: payload,
|
||||
};
|
||||
|
||||
if (environment === "dev") {
|
||||
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
|
||||
if (authToken) {
|
||||
payload.append("auth", authToken);
|
||||
fetchOptions.body = payload;
|
||||
}
|
||||
} else {
|
||||
fetchOptions.credentials = "include";
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(targetUrl, fetchOptions);
|
||||
} catch (networkError) {
|
||||
throw new Error(
|
||||
networkError instanceof Error ? networkError.message : "Network request failed"
|
||||
);
|
||||
}
|
||||
|
||||
const rawBody = await response.text();
|
||||
|
||||
if (isHtmlResponse(rawBody)) {
|
||||
throw new Error(
|
||||
"Backend authentication required. Please ensure you are logged into the backend system."
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawBody);
|
||||
} catch {
|
||||
throw new Error(`Unexpected response from backend (${response.status}).`);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Empty response from backend");
|
||||
}
|
||||
|
||||
const parsedResponse = parsed as Record<string, unknown>;
|
||||
return {
|
||||
success: Boolean(parsedResponse.success),
|
||||
message: typeof parsedResponse.message === "string" ? parsedResponse.message : undefined,
|
||||
data: parsedResponse.data,
|
||||
error: parsedResponse.error ?? parsedResponse.errors ?? parsedResponse.error_msg,
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user