From d04610f1ac1cbe6a85f94462afa75527e321060f Mon Sep 17 00:00:00 2001 From: Mahir-Mohamed Date: Mon, 10 Aug 2026 12:03:00 +0530 Subject: [PATCH] Implement product attribute enhancements --- package-lock.json | 100 +- src/components/customs/CategoryTreeSelect.tsx | 12 +- .../components/DynamicAttributeRenderer.tsx | 11 +- .../components/DynamicAttributesSection.tsx | 3 + .../product/components/ProductAssetsTab.tsx | 202 ++- .../components/ProductAttributeGroup.tsx | 3 + .../components/variants/VariantEditorRow.tsx | 54 +- .../components/variants/VariantListView.tsx | 35 +- .../components/variants/VariantMatrixView.tsx | 75 +- .../components/variants/VariantsTab.tsx | 24 +- src/features/product/pages/NewProduct.tsx | 1612 +++++++++++++++-- 11 files changed, 1738 insertions(+), 393 deletions(-) diff --git a/package-lock.json b/package-lock.json index 577c153..8fe9a0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,6 +86,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -301,31 +302,10 @@ "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", "license": "MIT" }, - "node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "license": "MIT", "optional": true, "dependencies": { @@ -645,7 +625,6 @@ "integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/Boshen" } @@ -1382,7 +1361,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1400,7 +1378,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1418,7 +1395,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1436,7 +1412,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1454,7 +1429,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1472,7 +1446,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1490,7 +1463,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1508,7 +1480,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1526,7 +1497,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1544,7 +1514,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1562,7 +1531,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1580,7 +1548,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1595,7 +1562,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", @@ -1605,6 +1571,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.0.tgz", @@ -1618,7 +1618,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1636,7 +1635,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1647,6 +1645,7 @@ "integrity": "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "picomatch": "^4.0.4" }, @@ -2122,6 +2121,7 @@ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2131,6 +2131,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2141,6 +2142,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2196,6 +2198,7 @@ "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", @@ -2426,6 +2429,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2508,6 +2512,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -2568,6 +2573,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3019,6 +3025,7 @@ "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -4282,6 +4289,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4317,6 +4325,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4335,6 +4344,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz", "integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -4350,13 +4360,15 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -4545,7 +4557,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -4809,6 +4822,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4968,6 +4982,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -5448,6 +5463,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/components/customs/CategoryTreeSelect.tsx b/src/components/customs/CategoryTreeSelect.tsx index b317273..8fe1016 100644 --- a/src/components/customs/CategoryTreeSelect.tsx +++ b/src/components/customs/CategoryTreeSelect.tsx @@ -1,6 +1,6 @@ import React, { useState, useMemo, useEffect, useRef } from 'react'; import { - ChevronDown, ChevronRight, Folder, FolderOpen, Search, Plus, X, + ChevronDown, ChevronRight, Folder, FolderOpen, Search, X, Check, Loader2, FolderPlus } from 'lucide-react'; import { categoryService } from '../../features/categories/services/category.service'; @@ -254,7 +254,7 @@ export const CategoryTreeSelect: React.FC = ({ {isOpen && (
{/* Search Header */} -
+
= ({ className="w-full pl-8 pr-3 py-1.5 text-xs border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary bg-white" />
-
{/* Category Tree Body */} diff --git a/src/features/product/components/DynamicAttributeRenderer.tsx b/src/features/product/components/DynamicAttributeRenderer.tsx index c4e490f..7557beb 100644 --- a/src/features/product/components/DynamicAttributeRenderer.tsx +++ b/src/features/product/components/DynamicAttributeRenderer.tsx @@ -25,6 +25,7 @@ interface DynamicAttributeRendererProps { onChange: (value: any) => void; error?: string; touched?: boolean; + readOnly?: boolean; } export const DynamicAttributeRenderer: React.FC = ({ @@ -33,6 +34,7 @@ export const DynamicAttributeRenderer: React.FC = onChange, error, touched, + readOnly, }) => { const isRequired = attribute.is_required || attribute.isRequired; const inputClass = "w-full border border-border focus:ring-primary rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-surface text-foreground"; @@ -48,6 +50,7 @@ export const DynamicAttributeRenderer: React.FC = placeholder={`Enter ${attribute.name}`} rows={3} className={`${inputClass} resize-none`} + disabled={readOnly} /> ); case 'number': @@ -63,6 +66,7 @@ export const DynamicAttributeRenderer: React.FC = }} placeholder={`Enter ${attribute.name}`} className={inputClass} + disabled={readOnly} /> ); case 'date': @@ -72,6 +76,7 @@ export const DynamicAttributeRenderer: React.FC = value={value || ''} onChange={(e) => onChange(e.target.value)} className={inputClass} + disabled={readOnly} /> ); case 'boolean': @@ -82,6 +87,7 @@ export const DynamicAttributeRenderer: React.FC = const val = e.target.value; onChange(val === 'true' ? true : val === 'false' ? false : undefined); }} + disabled={readOnly} > @@ -95,6 +101,7 @@ export const DynamicAttributeRenderer: React.FC = handleCheckboxChange(opt.code, e.target.checked)} className="w-4 h-4 text-primary rounded border-border focus:ring-primary" + disabled={readOnly} /> {opt.label} @@ -143,6 +151,7 @@ export const DynamicAttributeRenderer: React.FC = onChange={(e) => onChange(e.target.value)} placeholder={`Enter ${attribute.name}`} className={inputClass} + disabled={readOnly} /> ); } diff --git a/src/features/product/components/DynamicAttributesSection.tsx b/src/features/product/components/DynamicAttributesSection.tsx index 6e5e47c..5ffd50c 100644 --- a/src/features/product/components/DynamicAttributesSection.tsx +++ b/src/features/product/components/DynamicAttributesSection.tsx @@ -32,6 +32,7 @@ interface DynamicAttributesSectionProps { errors?: Record; touched?: Record; onAttributeChange: (code: string, value: any) => void; + readOnly?: boolean; } export const DynamicAttributesSection: React.FC = ({ @@ -41,6 +42,7 @@ export const DynamicAttributesSection: React.FC = errors = {}, touched = {}, onAttributeChange, + readOnly, }) => { if (!hasAttributeSet) { return ( @@ -87,6 +89,7 @@ export const DynamicAttributesSection: React.FC = errors={errors} touched={touched} onAttributeChange={onAttributeChange} + readOnly={readOnly} /> ))}
diff --git a/src/features/product/components/ProductAssetsTab.tsx b/src/features/product/components/ProductAssetsTab.tsx index f18cd2b..207b55f 100644 --- a/src/features/product/components/ProductAssetsTab.tsx +++ b/src/features/product/components/ProductAssetsTab.tsx @@ -12,11 +12,13 @@ import { getAssetUrl, isImageFile } from '../../../lib/utils'; interface ProductAssetsTabProps { productId?: string; family: any; + readOnly?: boolean; } export const ProductAssetsTab: React.FC = ({ productId, - family + family, + readOnly }) => { const [assignedAssets, setAssignedAssets] = useState([]); const [loading, setLoading] = useState(false); @@ -271,55 +273,57 @@ export const ProductAssetsTab: React.FC = ({ )} {/* Upload Zone & Picker Trigger */} -
-
fileInputRef.current?.click()} - className={`md:col-span-2 border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/20 hover:bg-background/50 bg-surface' - }`} - > - - {uploading ? ( -
- -

Uploading new media file...

-
- ) : ( -
- -
-

Drag & Drop Files Here

-

or click to browse media files

-
-
- )} -
- -
-
-

Asset Library

-

- Select existing images, catalogs or spec documents from the central Asset library. -

-
- + + {uploading ? ( +
+ +

Uploading new media file...

+
+ ) : ( +
+ +
+

Drag & Drop Files Here

+

or click to browse media files

+
+
+ )} +
+ +
+
+

Asset Library

+

+ Select existing images, catalogs or spec documents from the central Asset library. +

+
+ +
-
+ )} {/* Grid of assigned assets */} {loading && assignedAssets.length === 0 ? ( @@ -331,12 +335,12 @@ export const ProductAssetsTab: React.FC = ({ - + {!readOnly && } - + {!readOnly && } @@ -351,26 +355,28 @@ export const ProductAssetsTab: React.FC = ({ return ( {/* Display Order sorting */} - + {!readOnly && ( + + )} {/* Preview Thumbnail */} - {/* Role dropdown selection */} + {/* Role dropdown selection or read-only label */} {/* Primary Badge toggle button */} @@ -417,6 +433,8 @@ export const ProductAssetsTab: React.FC = ({ Primary + ) : readOnly ? ( + ) : ( + {!readOnly && ( + + )} ); })} diff --git a/src/features/product/components/ProductAttributeGroup.tsx b/src/features/product/components/ProductAttributeGroup.tsx index e506dc9..9441f21 100644 --- a/src/features/product/components/ProductAttributeGroup.tsx +++ b/src/features/product/components/ProductAttributeGroup.tsx @@ -33,6 +33,7 @@ interface ProductAttributeGroupProps { errors?: Record; touched?: Record; onAttributeChange: (code: string, value: any) => void; + readOnly?: boolean; } export const ProductAttributeGroup: React.FC = ({ @@ -41,6 +42,7 @@ export const ProductAttributeGroup: React.FC = ({ errors = {}, touched = {}, onAttributeChange, + readOnly, }) => { const [isExpanded, setIsExpanded] = useState(true); const attributes = group.attributes || []; @@ -75,6 +77,7 @@ export const ProductAttributeGroup: React.FC = ({ onChange={(val) => onAttributeChange(attr.code, val)} error={errors[attr.code]} touched={touched[attr.code]} + readOnly={readOnly} /> ))} diff --git a/src/features/product/components/variants/VariantEditorRow.tsx b/src/features/product/components/variants/VariantEditorRow.tsx index 908ab5b..08d535b 100644 --- a/src/features/product/components/variants/VariantEditorRow.tsx +++ b/src/features/product/components/variants/VariantEditorRow.tsx @@ -10,6 +10,7 @@ interface VariantEditorRowProps { onUpdate: (id: string, updates: Partial) => Promise; onDelete: (id: string) => void; onArchive: (id: string) => void; + readOnly?: boolean; } export const VariantEditorRow: React.FC = ({ @@ -19,7 +20,8 @@ export const VariantEditorRow: React.FC = ({ onSelect, onUpdate, onDelete, - onArchive + onArchive, + readOnly }) => { const [sku, setSku] = useState(variant.sku); const [price, setPrice] = useState(String(variant.price)); @@ -29,7 +31,6 @@ export const VariantEditorRow: React.FC = ({ const [savingStatus, setSavingStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); - // Synchronize state if variant updates externally useEffect(() => { setSku(variant.sku); setPrice(String(variant.price)); @@ -39,7 +40,6 @@ export const VariantEditorRow: React.FC = ({ }, [variant]); const handleFieldSave = async () => { - // Basic validation const pNum = parseFloat(price); const cpNum = parseFloat(costPrice); const sNum = parseInt(stock, 10); @@ -77,6 +77,45 @@ export const VariantEditorRow: React.FC = ({ } }; + // ── Read-only row ────────────────────────────────────────────────────────── + if (readOnly) { + return ( + + + + + + + + + ); + } + + // ── Editable row ─────────────────────────────────────────────────────────── return ( {/* Checkbox */} @@ -89,7 +128,7 @@ export const VariantEditorRow: React.FC = ({ /> - {/* Cartesian Combination specifications */} + {/* Variant Specification */} - {/* Save Status Spinner/Tick */} + {/* Save Status */}
OrderOrderPreview Asset Details Role Classification PrimaryActionsActions
-
- - -
-
+
+ + +
+
@@ -395,19 +401,29 @@ export const ProductAssetsTab: React.FC = ({ - + {readOnly ? ( + + {mapping.role === 'primary_image' ? 'Primary Image' : + mapping.role === 'gallery_image' ? 'Gallery Image' : + mapping.role === 'thumbnail' ? 'Thumbnail' : + mapping.role === 'video' ? 'Video' : + mapping.role === 'document' ? 'Documentation PDF' : mapping.role} + + ) : ( + + )} - - + +
+
+ + {variant.name.split(' - ')[1] || variant.name} + +
+ {axesKeys.map(key => { + const val = variant.attributes[key]; + if (!val) return null; + return ( + + {key}: {val} + + ); + })} +
+
+
{sku}${price}${costPrice}{stock} + + {status} + +
@@ -100,10 +139,7 @@ export const VariantEditorRow: React.FC = ({ const val = variant.attributes[key]; if (!val) return null; return ( - + {key}: {val} ); @@ -183,7 +219,7 @@ export const VariantEditorRow: React.FC = ({
{savingStatus === 'saving' && } {savingStatus === 'saved' && } diff --git a/src/features/product/components/variants/VariantListView.tsx b/src/features/product/components/variants/VariantListView.tsx index b4e14a1..4db1174 100644 --- a/src/features/product/components/variants/VariantListView.tsx +++ b/src/features/product/components/variants/VariantListView.tsx @@ -11,6 +11,7 @@ interface VariantListViewProps { onUpdate: (id: string, updates: Partial) => Promise; onDelete: (id: string) => void; onArchive: (id: string) => void; + readOnly?: boolean; } export const VariantListView: React.FC = ({ @@ -21,7 +22,8 @@ export const VariantListView: React.FC = ({ onSelectAllChange, onUpdate, onDelete, - onArchive + onArchive, + readOnly }) => { const allSelected = variants.length > 0 && variants.every(v => selectedIds.has(v.id)); const someSelected = variants.length > 0 && variants.some(v => selectedIds.has(v.id)) && !allSelected; @@ -31,25 +33,27 @@ export const VariantListView: React.FC = ({ - + {!readOnly && ( + + )} - - + {!readOnly && } + {!readOnly && } @@ -63,11 +67,12 @@ export const VariantListView: React.FC = ({ onUpdate={onUpdate} onDelete={onDelete} onArchive={onArchive} + readOnly={readOnly} /> ))} {variants.length === 0 && ( - diff --git a/src/features/product/components/variants/VariantMatrixView.tsx b/src/features/product/components/variants/VariantMatrixView.tsx index 67d9562..4a42244 100644 --- a/src/features/product/components/variants/VariantMatrixView.tsx +++ b/src/features/product/components/variants/VariantMatrixView.tsx @@ -11,6 +11,7 @@ interface VariantMatrixViewProps { onUpdate: (id: string, updates: Partial) => Promise; onDelete: (id: string) => void; onArchive: (id: string) => void; + readOnly?: boolean; } export const VariantMatrixView: React.FC = ({ @@ -22,7 +23,8 @@ export const VariantMatrixView: React.FC = ({ onSelectAllChange, onUpdate, onDelete, - onArchive + onArchive, + readOnly }) => { const allSelected = variants.length > 0 && variants.every(v => selectedIds.has(v.id)); const someSelected = variants.length > 0 && variants.some(v => selectedIds.has(v.id)) && !allSelected; @@ -32,17 +34,19 @@ export const VariantMatrixView: React.FC = ({
- { - if (el) el.indeterminate = someSelected; - }} - onChange={(e) => onSelectAllChange(e.target.checked)} - className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer" - /> - + { + if (el) el.indeterminate = someSelected; + }} + onChange={(e) => onSelectAllChange(e.target.checked)} + className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer" + /> + Variant Specification SKU Code Sale Price Cost Price Stock StatusSaveActionsSaveActions
+ No variants found matching criteria.
- + {!readOnly && ( + + )} {/* Dynamic columns for each variant axis */} {axesKeys.map(key => ( @@ -56,8 +60,8 @@ export const VariantMatrixView: React.FC = ({ - - + {!readOnly && } + {!readOnly && } @@ -69,14 +73,16 @@ export const VariantMatrixView: React.FC = ({ }`} > {/* Checkbox */} - + {!readOnly && ( + + )} {/* Dynamic cells for each variant axis */} {axesKeys.map(key => { @@ -100,12 +106,13 @@ export const VariantMatrixView: React.FC = ({ onUpdate={onUpdate} onDelete={onDelete} onArchive={onArchive} + readOnly={readOnly} /> ))} {variants.length === 0 && ( - @@ -122,9 +129,10 @@ interface InlineCellsProps { onUpdate: (id: string, updates: Partial) => Promise; onDelete: (id: string) => void; onArchive: (id: string) => void; + readOnly?: boolean; } -const InlineEditorCells: React.FC = ({ variant, onUpdate, onDelete, onArchive }) => { +const InlineEditorCells: React.FC = ({ variant, onUpdate, onDelete, onArchive, readOnly }) => { const [sku, setSku] = useState(variant.sku); const [price, setPrice] = useState(String(variant.price)); const [costPrice, setCostPrice] = useState(String(variant.costPrice)); @@ -179,6 +187,25 @@ const InlineEditorCells: React.FC = ({ variant, onUpdate, onDe } }; + if (readOnly) { + return ( + <> + + + + + + + ); + } + return ( <>
- { - if (el) el.indeterminate = someSelected; - }} - onChange={(e) => onSelectAllChange(e.target.checked)} - className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer" - /> - + { + if (el) el.indeterminate = someSelected; + }} + onChange={(e) => onSelectAllChange(e.target.checked)} + className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer" + /> + Cost Price Stock StatusSaveActionsSaveActions
- onSelectChange(variant.id, e.target.checked)} - className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer" - /> - + onSelectChange(variant.id, e.target.checked)} + className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer" + /> +
+ No variants found matching criteria.
{sku}${price}${costPrice}{stock} + + {status} + + diff --git a/src/features/product/components/variants/VariantsTab.tsx b/src/features/product/components/variants/VariantsTab.tsx index 95fef4f..6150867 100644 --- a/src/features/product/components/variants/VariantsTab.tsx +++ b/src/features/product/components/variants/VariantsTab.tsx @@ -13,13 +13,15 @@ interface VariantsTabProps { productType: string; parentSku: string; family: any; // Product Family details + readOnly?: boolean; } export const VariantsTab: React.FC = ({ productId, productType, parentSku, - family + family, + readOnly }) => { const { variants, @@ -213,14 +215,16 @@ export const VariantsTab: React.FC = ({ {/* View settings panel */}
- + {!readOnly && ( + + )}
diff --git a/src/features/product/pages/NewProduct.tsx b/src/features/product/pages/NewProduct.tsx index a91ce9a..9e1007c 100644 --- a/src/features/product/pages/NewProduct.tsx +++ b/src/features/product/pages/NewProduct.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import { productService } from '../services/product.service'; @@ -13,11 +13,16 @@ import { DynamicAttributesSection } from '../components/DynamicAttributesSection import { ProductAssetsTab } from '../components/ProductAssetsTab'; import { StageTimeline } from '../../workflow/components/StageTimeline'; import { notify } from '../../../services/toast'; -import { Box, LayoutGrid, Tags, Globe, Eye, Save, Send, Copy, Image as ImageIcon, FolderTree, Check, Plus, CheckCircle2, Loader2, AlertCircle } from 'lucide-react'; +import { useAttributeSet } from '../../attribute-sets/hook/useAttributeSet'; +import { useAttribute } from '../../attributes/hook/useAttribute'; +import { useAttributeGroup } from '../../attribute-groups/hook/useAttributeGroup'; +import { attributeSetsService } from '../../attribute-sets/services/attribute-sets.service'; +import { attributeGroupsService } from '../../attribute-groups/services/attribute-groups.service'; +import { Box, LayoutGrid, Tags, Globe, Eye, Save, Send, Image as ImageIcon, FolderTree, Check, Plus, CheckCircle2, Loader2, AlertCircle, Search, Pencil, X } from 'lucide-react'; import { PageWrapper } from '../../../components/layouts/PageWrapper'; import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute'; import { Breadcrumb } from '../../../components/layouts/Breadcrumb'; -import { FamilyCard } from '../../../components/customs/FamilyCard'; + import { Radio, RadioGroup } from '../../../components/customs/Radio'; import { Select } from '../../../components/customs/Select'; import { Loader } from '../../../components/customs/Loader'; @@ -35,22 +40,47 @@ const TABS = [ export default function NewProduct() { const { id } = useParams<{ id?: string }>(); const navigate = useNavigate(); + const location = useLocation(); + + const isReadOnlyView = !location.pathname.endsWith('/edit') && Boolean(id); const [productId, setProductId] = useState(id); const [isEditMode, setIsEditMode] = useState(Boolean(id)); const isEdit = Boolean(id || productId || isEditMode); - const { families, fetchFamilies, loading: familiesLoading } = useFamily(); - const { categories, fetchCategories } = useCategory(); - const { brands, fetchBrands } = useBrand(); - const { units, fetchUnits } = useUnit(); + const { families, fetchFamilies } = useFamily(); + const { categories, fetchCategories, createCategory } = useCategory(); + const { brands, fetchBrands, createBrand } = useBrand(); + const { units, fetchUnits, createUnit } = useUnit(); + + // Manual Attribute Set states + const [selectedAttributeSetId, setSelectedAttributeSetId] = useState(null); + const [selectedAttributeSetObj, setSelectedAttributeSetObj] = useState(null); + const { items: attributeSetsList, fetchItems: fetchAttributeSets } = useAttributeSet(); + const { createAttribute, attributes: allRegistryAttributes, fetchAttributes: fetchRegistryAttributes } = useAttribute(); + const { items: allAttributeGroups, fetchItems: fetchAttributeGroups } = useAttributeGroup(); + + const [isAttributeSetDropdownOpen, setIsAttributeSetDropdownOpen] = useState(false); + const [attributeSetSearchQuery, setAttributeSetSearchQuery] = useState(''); + const attributeSetDropdownRef = useRef(null); + + const [attributeSearchQuery, setAttributeSearchQuery] = useState(''); + const [isAttributeSearchDropdownOpen, setIsAttributeSearchDropdownOpen] = useState(false); + const attributeSearchDropdownRef = useRef(null); + const [customAddedAttributes, setCustomAddedAttributes] = useState([]); + + const [newlyCreatedBrandIds, setNewlyCreatedBrandIds] = useState([]); + const [newlyCreatedUnitIds, setNewlyCreatedUnitIds] = useState([]); useEffect(() => { fetchFamilies(); fetchCategories(); fetchBrands(); fetchUnits(); - }, [fetchFamilies, fetchCategories, fetchBrands, fetchUnits]); + fetchAttributeSets(); + fetchAttributeGroups(); + fetchRegistryAttributes(); + }, [fetchFamilies, fetchCategories, fetchBrands, fetchUnits, fetchAttributeSets, fetchAttributeGroups, fetchRegistryAttributes]); const realFamiliesList = useMemo(() => { return families.map((f: any) => ({ @@ -67,6 +97,37 @@ export default function NewProduct() { })); }, [families]); + const [familySearchQuery, setFamilySearchQuery] = useState(''); + const [productSearchQueryInput, setProductSearchQueryInput] = useState(''); + const [productsList, setProductsList] = useState([]); + const [loadingProducts, setLoadingProducts] = useState(false); + const [clonedProductId, setClonedProductId] = useState(null); + + const [isFamilyDropdownOpen, setIsFamilyDropdownOpen] = useState(false); + const [isProductDropdownOpen, setIsProductDropdownOpen] = useState(false); + const familyDropdownRef = useRef(null); + const productDropdownRef = useRef(null); + + const filteredFamilies = useMemo(() => { + if (!familySearchQuery) return realFamiliesList; + const query = familySearchQuery.toLowerCase(); + return realFamiliesList.filter((f: any) => + (f.name || '').toLowerCase().includes(query) || + (f.code || '').toLowerCase().includes(query) + ); + }, [realFamiliesList, familySearchQuery]); + + const filteredAttributeSets = useMemo(() => { + if (!attributeSetsList) return []; + const query = attributeSetSearchQuery.toLowerCase().trim(); + return attributeSetsList.filter((s: any) => + (s.name || '').toLowerCase().includes(query) || + (s.code || '').toLowerCase().includes(query) + ); + }, [attributeSetsList, attributeSetSearchQuery]); + + + const [step, setStep] = useState(() => (id ? 2 : 1)); const [creationMethod, setCreationMethod] = useState<'family' | 'clone'>('family'); const [selectedFamily, setSelectedFamily] = useState(null); @@ -75,6 +136,39 @@ export default function NewProduct() { const [loadingProduct, setLoadingProduct] = useState(false); const [loadError, setLoadError] = useState(null); + // Inline creation modal states + const [showBrandModal, setShowBrandModal] = useState(false); + const [inlineBrandName, setInlineBrandName] = useState(''); + const [inlineBrandDescription, setInlineBrandDescription] = useState(''); + const [inlineBrandStatus, setInlineBrandStatus] = useState<'active' | 'inactive'>('active'); + const [inlineBrandSubmitting, setInlineBrandSubmitting] = useState(false); + + const [showUnitModal, setShowUnitModal] = useState(false); + const [inlineUnitName, setInlineUnitName] = useState(''); + const [inlineUnitSymbol, setInlineUnitSymbol] = useState(''); + const [inlineUnitType, setInlineUnitType] = useState('Weight'); + const [inlineUnitStatus, setInlineUnitStatus] = useState<'active' | 'inactive'>('active'); + const [inlineUnitDescription, setInlineUnitDescription] = useState(''); + const [inlineUnitSubmitting, setInlineUnitSubmitting] = useState(false); + + const [showCategoryModal, setShowCategoryModal] = useState(false); + const [inlineCategoryName, setInlineCategoryName] = useState(''); + const [inlineCategoryParentId, setInlineCategoryParentId] = useState(''); + const [inlineCategoryStatus, setInlineCategoryStatus] = useState<'active' | 'inactive'>('active'); + const [inlineCategoryImageUrl, setInlineCategoryImageUrl] = useState(''); + const [inlineCategoryDescription, setInlineCategoryDescription] = useState(''); + const [inlineCategorySubmitting, setInlineCategorySubmitting] = useState(false); + + + + const [showAttributeModal, setShowAttributeModal] = useState(false); + const [inlineAttributeName, setInlineAttributeName] = useState(''); + const [inlineAttributeType, setInlineAttributeType] = useState('text'); + const [inlineAttributeGroupId, setInlineAttributeGroupId] = useState(''); + const [inlineAttributeRequired, setInlineAttributeRequired] = useState(false); + const [inlineAttributeOptions, setInlineAttributeOptions] = useState(''); + const [inlineAttributeSubmitting, setInlineAttributeSubmitting] = useState(false); + // 1. Hook configuration for loading all PIM metadata dynamically const { family, @@ -91,6 +185,35 @@ export default function NewProduct() { return realFamiliesList.find(f => f.id === selectedFamily); }, [realFamiliesList, selectedFamily]); + // Sync Attribute Set ID from Product Family configuration + useEffect(() => { + if (attributeSet) { + setSelectedAttributeSetId(attributeSet.id); + setSelectedAttributeSetObj(attributeSet); + } + }, [attributeSet]); + + const handleAttributeSetChange = async (setId: string) => { + setSelectedAttributeSetId(setId || null); + if (!setId) { + setSelectedAttributeSetObj(null); + setCustomAddedAttributes([]); + formik.setFieldValue('attributes', {}); + return; + } + setLoadingProduct(true); + try { + const setDetails = await attributeSetsService.getById(setId); + setSelectedAttributeSetObj(setDetails); + setCustomAddedAttributes([]); + formik.setFieldValue('attributes', {}); + } catch (err) { + notify.error('Failed to load attribute set details'); + } finally { + setLoadingProduct(false); + } + }; + const workflowStages = useMemo(() => { return inheritedWorkflow?.stages || []; }, [inheritedWorkflow]); @@ -98,45 +221,176 @@ export default function NewProduct() { // Resolve list of brands based on allowed list const activeAllowedBrandsList = useMemo(() => { if (familyBrands && familyBrands.length > 0) { - return brands.filter(b => + return brands.filter(b => + newlyCreatedBrandIds.includes(b.id) || familyBrands.some((fb: any) => (typeof fb === 'string' ? fb === b.id : fb?.id === b.id)) ); } return brands; - }, [familyBrands, brands]); + }, [familyBrands, brands, newlyCreatedBrandIds]); // Resolve list of units based on allowed list const activeAllowedUnitsList = useMemo(() => { const familyUnits = family?.allowedUnits || (family as any)?.completenessRules?.allowedUnits; if (familyUnits && familyUnits.length > 0) { - return units.filter(u => + return units.filter(u => + newlyCreatedUnitIds.includes(u.id) || familyUnits.some((fu: any) => (typeof fu === 'string' ? fu === u.id : fu?.id === u.id)) ); } return units; - }, [family, units]); + }, [family, units, newlyCreatedUnitIds]); // 2. Exclude redundant dynamic attributes that are already core/general classification fields const EXCLUDED_ATTRIBUTE_CODES = ['brand', 'brand_id', 'product_name', 'name', 'short_description', 'description', 'long_description', 'category', 'category_id', 'unit', 'unit_id', 'code', 'sku']; - const filteredAttributesList = useMemo(() => { - if (!attributesList) return []; - return attributesList.filter((attr: any) => !EXCLUDED_ATTRIBUTE_CODES.includes((attr.code || '').toLowerCase())); - }, [attributesList]); + const activeAttributeGroups = useMemo(() => { + if (selectedFamily) { + return attributeGroups || []; + } + return selectedAttributeSetObj?.groups || []; + }, [selectedFamily, attributeGroups, selectedAttributeSetObj]); + + const activeAttributesList = useMemo(() => { + const list: any[] = []; + if (selectedFamily) { + list.push(...(attributesList || [])); + } else if (selectedAttributeSetObj?.groups) { + selectedAttributeSetObj.groups.forEach((g: any) => { + if (g.attributes) { + list.push(...g.attributes); + } + }); + } + + // Append custom added attributes to activeAttributesList + customAddedAttributes.forEach((attr) => { + if (!list.some((a) => a.id === attr.id || (a.code || '').toLowerCase() === (attr.code || '').toLowerCase())) { + list.push(attr); + } + }); + + return list; + }, [selectedFamily, attributesList, selectedAttributeSetObj, customAddedAttributes]); + + const unifiedAttributeGroups = useMemo(() => { + const groupsCopy = activeAttributeGroups.map((g: any) => ({ + ...g, + attributes: [...(g.attributes || [])] + })); + + customAddedAttributes.forEach((attr: any) => { + const targetGroupInfo = attr.groups?.[0] || { + id: 'general-group', + name: 'General Attributes', + code: 'general_attributes' + }; + + let groupObj = groupsCopy.find((g: any) => g.id === targetGroupInfo.id || g.code === targetGroupInfo.code); + if (!groupObj) { + groupObj = { + id: targetGroupInfo.id, + name: targetGroupInfo.name, + code: targetGroupInfo.code, + attributes: [] + }; + groupsCopy.push(groupObj); + } + + const isAlreadyInGroup = groupObj.attributes.some((a: any) => a.id === attr.id || (a.code || '').toLowerCase() === (attr.code || '').toLowerCase()); + if (!isAlreadyInGroup) { + groupObj.attributes.push(attr); + } + }); + + return groupsCopy; + }, [activeAttributeGroups, customAddedAttributes]); const filteredAttributeGroups = useMemo(() => { - if (!attributeGroups) return []; - return attributeGroups.map((group: any) => ({ + if (!unifiedAttributeGroups) return []; + return unifiedAttributeGroups.map((group: any) => ({ ...group, attributes: (group.attributes || []).filter((attr: any) => !EXCLUDED_ATTRIBUTE_CODES.includes((attr.code || '').toLowerCase())) })).filter((group: any) => (group.attributes || []).length > 0); - }, [attributeGroups]); + }, [unifiedAttributeGroups]); + + const filteredAttributesList = useMemo(() => { + if (!activeAttributesList) return []; + return activeAttributesList.filter((attr: any) => !EXCLUDED_ATTRIBUTE_CODES.includes((attr.code || '').toLowerCase())); + }, [activeAttributesList]); + + const filteredRegistryAttributes = useMemo(() => { + if (!allRegistryAttributes) return []; + const query = attributeSearchQuery.toLowerCase().trim(); + if (!query) return []; + return allRegistryAttributes.filter((attr: any) => + (attr.name || '').toLowerCase().includes(query) || + (attr.code || '').toLowerCase().includes(query) + ); + }, [allRegistryAttributes, attributeSearchQuery]); + + const isAttributeAlreadyAdded = useCallback((attr: any) => { + const codeLower = (attr.code || '').toLowerCase(); + const id = attr.id; + return ( + activeAttributesList.some((a: any) => a.id === id || (a.code || '').toLowerCase() === codeLower) || + customAddedAttributes.some((a: any) => a.id === id || (a.code || '').toLowerCase() === codeLower) + ); + }, [activeAttributesList, customAddedAttributes]); + + // Hydrate custom added attributes from product details once metadata is loaded + useEffect(() => { + if (product && allRegistryAttributes.length > 0) { + const savedAttrs: Record = { + ...(product.metadata?.attributes || {}), + ...(product.attributes || {}) + }; + if (Array.isArray(product.attributeValues)) { + product.attributeValues.forEach((av: any) => { + const code = av.attribute?.code || av.attribute_code; + if (code) { + savedAttrs[code] = av.value; + } + }); + } + + const inheritedCodes = new Set( + activeAttributesList.map((a: any) => (a.code || '').toLowerCase()) + ); + + const customAdded: any[] = []; + Object.keys(savedAttrs).forEach((code) => { + if (!inheritedCodes.has(code.toLowerCase())) { + const registryAttr = allRegistryAttributes.find( + (a: any) => (a.code || '').toLowerCase() === code.toLowerCase() + ); + if (registryAttr && !customAdded.some(x => x.id === registryAttr.id)) { + customAdded.push(registryAttr); + } + } + }); + + if (customAdded.length > 0) { + setCustomAddedAttributes(prev => { + const merged = [...prev]; + customAdded.forEach(attr => { + if (!merged.some(x => x.id === attr.id)) { + merged.push(attr); + } + }); + return merged; + }); + } + } + }, [product, allRegistryAttributes, activeAttributesList]); // 3. Generate Validation Schema dynamically const dynamicValidationSchema = useMemo(() => { return Yup.object().shape({ name: Yup.string().required('Product name is required'), - category: Yup.string(), + category: Yup.string().required('Category is required'), + brand: Yup.string().required('Brand is required'), + unit: Yup.string().required('Unit is required'), status: Yup.string().oneOf(['active', 'pending', 'draft', 'disabled']), price: Yup.string(), stock: Yup.number().integer().min(0, 'Stock cannot be negative'), @@ -163,7 +417,6 @@ export default function NewProduct() { country: '', hsn: '', type: 'simple', - shortDesc: '', description: '', categories: [] as string[], metadata: { @@ -174,20 +427,26 @@ export default function NewProduct() { attributes: {} as Record }, validationSchema: dynamicValidationSchema, + validateOnMount: true, onSubmit: async (values, { setSubmitting }) => { try { const finalAttributes = { ...(values.attributes || {}) }; if (values.brand) finalAttributes['brand'] = values.brand; if (values.name) finalAttributes['product_name'] = values.name; - if (values.shortDesc) finalAttributes['short_description'] = values.shortDesc; if (values.description) finalAttributes['long_description'] = values.description; + const updatedMetadata = { + ...(values.metadata || {}), + attributeSetId: selectedAttributeSetId + }; + const submissionValues = { ...values, category_id: values.category || null, brand_id: values.brand || null, unit_id: values.unit || null, categories: values.category ? [values.category] : [], + metadata: updatedMetadata, attributes: finalAttributes }; @@ -215,6 +474,8 @@ export default function NewProduct() { } }); + const isNextDisabled = !formik.values.name || !formik.values.brand || !formik.values.unit || !formik.values.category || !formik.isValid; + const handleSubmitWithValidation = async (targetStatus?: string) => { const statusToUse = targetStatus || formik.values.status || 'draft'; await formik.setFieldValue('status', statusToUse); @@ -274,7 +535,7 @@ export default function NewProduct() { const hydrateProductEditor = useCallback(async (targetId: string) => { if (!targetId || targetId === 'new') return; - + // Prevent duplicate hydration calls loadedIdRef.current = targetId; setLoadingProduct(true); @@ -294,11 +555,16 @@ export default function NewProduct() { // 2. Extract & Force Blueprint Hydration const familyId = productData.family_id || productData.familyId || (productData.family ? (typeof productData.family === 'object' ? productData.family.id : productData.family) : null); - + let blueprint: any = null; if (familyId) { setSelectedFamily(familyId); blueprint = await loadConfiguration(familyId); + } else if (productData.metadata?.attributeSetId) { + const setId = productData.metadata.attributeSetId; + setSelectedAttributeSetId(setId); + const setDetails = await attributeSetsService.getById(setId); + setSelectedAttributeSetObj(setDetails); } // 3. Extract Saved Attribute Values @@ -354,7 +620,6 @@ export default function NewProduct() { country: productData.country || productData.metadata?.country || '', hsn: productData.hsn || productData.metadata?.hsn || '', type: productData.type || productData.metadata?.type || 'simple', - shortDesc: productData.shortDesc || productData.metadata?.shortDesc || '', description: productData.description || productData.metadata?.description || '', categories: Array.isArray(productData.categories) ? productData.categories : (categoryId ? [categoryId] : []), metadata: productData.metadata || { @@ -404,6 +669,19 @@ export default function NewProduct() { return score; }, [formik.values, product, family]); + const modalAvailableGroups = useMemo(() => { + if (selectedAttributeSetId && activeAttributeGroups && activeAttributeGroups.length > 0) { + return activeAttributeGroups; + } + return allAttributeGroups || []; + }, [selectedAttributeSetId, activeAttributeGroups, allAttributeGroups]); + + useEffect(() => { + if (showAttributeModal && modalAvailableGroups && modalAvailableGroups.length > 0) { + setInlineAttributeGroupId(modalAvailableGroups[0].id); + } + }, [showAttributeModal, modalAvailableGroups]); + // Unsaved changes protection useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { @@ -464,6 +742,114 @@ export default function NewProduct() { } }, [selectedFamily, familyBrands, family, isEdit]); + // Dropdown outside click handler + useEffect(() => { + const handleOutsideClick = (e: MouseEvent) => { + if (familyDropdownRef.current && !familyDropdownRef.current.contains(e.target as Node)) { + setIsFamilyDropdownOpen(false); + } + if (productDropdownRef.current && !productDropdownRef.current.contains(e.target as Node)) { + setIsProductDropdownOpen(false); + } + if (attributeSetDropdownRef.current && !attributeSetDropdownRef.current.contains(e.target as Node)) { + setIsAttributeSetDropdownOpen(false); + } + if (attributeSearchDropdownRef.current && !attributeSearchDropdownRef.current.contains(e.target as Node)) { + setIsAttributeSearchDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleOutsideClick); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, []); + + // Load products list for cloning + useEffect(() => { + if (creationMethod === 'clone' && !isEdit) { + setLoadingProducts(true); + productService.getAll() + .then((res: any) => { + const prods = res?.data || res || []; + setProductsList(prods); + }) + .catch(() => { + notify.error('Failed to load products for cloning'); + }) + .finally(() => { + setLoadingProducts(false); + }); + } + }, [creationMethod, isEdit]); + + const handleCloneProduct = async (prodId: string) => { + setLoadingProduct(true); + try { + const rawData: any = await productService.getById(prodId); + const productData = rawData?.data?.id ? rawData.data : (rawData?.id ? rawData : rawData?.data || rawData); + + if (!productData) { + notify.error('Failed to retrieve product details to clone'); + return; + } + + // Load configuration of the product family + const familyId = productData.family_id || productData.familyId || (productData.family ? (typeof productData.family === 'object' ? productData.family.id : productData.family) : null); + if (familyId) { + setSelectedFamily(familyId); + await loadConfiguration(familyId); + } + + // Extract saved attributes + const savedAttrs: Record = { + ...(productData.metadata?.attributes || {}), + ...(productData.attributes || {}) + }; + if (Array.isArray(productData.attributeValues)) { + productData.attributeValues.forEach((av: any) => { + const code = av.attribute?.code || av.attribute_code; + if (code) { + savedAttrs[code] = av.value; + } + }); + } + + const brandId = productData.brand_id || productData.brandId || (productData.brand && typeof productData.brand === 'object' ? (productData.brand as any).id : productData.brand) || ''; + const categoryId = productData.category_id || productData.categoryId || (productData.category && typeof productData.category === 'object' ? (productData.category as any).id : productData.category) || ''; + const unitId = productData.unit_id || productData.unitId || (productData.unit && typeof productData.unit === 'object' ? (productData.unit as any).id : productData.unit) || ''; + + formik.setValues({ + name: `${productData.name || ''} - Copy`, + sku: '', + productId: '', + category: categoryId, + subcategory: productData.subcategory || productData.metadata?.subcategory || '', + status: 'draft', + price: productData.price !== undefined && productData.price !== null ? productData.price : (productData.metadata?.price || ''), + stock: productData.stock !== undefined && productData.stock !== null ? productData.stock : (productData.metadata?.stock || 0), + code: '', + barcode: productData.barcode || productData.metadata?.barcode || '', + gtin: productData.gtin || productData.metadata?.gtin || '', + upc: productData.upc || productData.metadata?.upc || '', + ean: productData.ean || productData.metadata?.ean || '', + brand: brandId, + unit: unitId, + country: productData.country || productData.metadata?.country || '', + hsn: productData.hsn || productData.metadata?.hsn || '', + type: productData.type || productData.metadata?.type || 'simple', + description: productData.description || productData.metadata?.description || '', + categories: Array.isArray(productData.categories) ? productData.categories : (categoryId ? [categoryId] : []), + metadata: { + ...(productData.metadata || {}), + currentStage: 'draft', + }, + attributes: savedAttrs + }); + notify.success(`Template preloaded from product: ${productData.name}`); + } catch (err) { + notify.error('Failed to clone product template'); + } finally { + setLoadingProduct(false); + } + }; const inputClass = "w-full border border-border focus:ring-primary rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-surface"; const labelClass = "block text-sm font-medium text-foreground mb-1.5"; @@ -472,15 +858,25 @@ export default function NewProduct() { - - {selectedFamilyObj?.name || family?.name || 'Selected Family'} - - {!isEdit ? ( + {(selectedFamilyObj?.name || family?.name) && ( + + {selectedFamilyObj?.name || family?.name} + + )} + {isReadOnlyView ? ( + + ) : !isEdit ? ( - - -
- - {creationMethod === 'family' && ( - <> -

Product Family

- {familiesLoading && realFamiliesList.length === 0 ? ( -
- Loading Product Families... -
- ) : realFamiliesList.length > 0 ? ( -
- {realFamiliesList.map(family => ( - { setSelectedFamily(id); setStep(2); }} - /> - ))} -
- ) : ( -
- -

No Product Families Found

-

Please create a Product Family first to generate products from a family blueprint.

- -
- )} - - )} - - {creationMethod === 'clone' && ( -
- -

Search for a product to clone

-

You can copy all attributes and settings from an existing product.

- -
- )} - - )} - - {step === 2 && ( + {(step === 1 || step === 2) && ( loadingProduct ? (
@@ -776,21 +1091,7 @@ export default function NewProduct() { {/* Main Content Area */}
- {Object.keys(formik.errors).length > 0 && ( -
- -
-

Please resolve the following errors:

-
    - {Object.entries(formik.errors).map(([key, err]) => ( -
  • - {key}: {typeof err === 'string' ? err : JSON.stringify(err)} -
  • - ))} -
-
-
- )} + {isEdit && inheritedWorkflow && (
@@ -857,10 +1158,153 @@ export default function NewProduct() {
)} - +
{activeTab === 'general' && (
+ {!isEdit && ( +
+
+ +

Creation Template Selection

+
+
+
+ + +
+ + {creationMethod === 'family' && ( +
+ +
setIsFamilyDropdownOpen(!isFamilyDropdownOpen)} + className="w-full border border-border rounded-lg px-3 py-2.5 text-sm flex items-center justify-between bg-surface cursor-pointer hover:border-primary/30" + > + + {selectedFamilyObj?.name || family?.name || 'Select Product Family...'} + + +
+ + {isFamilyDropdownOpen && ( +
+
+ + setFamilySearchQuery(e.target.value)} + placeholder="Search by name or code..." + className="w-full px-2 py-1 text-xs border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary bg-background text-foreground" + /> +
+
+ {filteredFamilies.length > 0 ? ( + filteredFamilies.map((fam: any) => ( +
{ + setSelectedFamily(fam.id); + await loadConfiguration(fam.id); + setIsFamilyDropdownOpen(false); + setFamilySearchQuery(''); + }} + className={`px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors ${ + selectedFamily === fam.id ? 'bg-primary/10 text-primary font-bold' : 'text-foreground hover:bg-background/50' + }`} + > +
{fam.name}
+
Code: {fam.code}
+
+ )) + ) : ( +
No families found.
+ )} +
+
+ )} +
+ )} + + {creationMethod === 'clone' && ( +
+ +
!loadingProducts && setIsProductDropdownOpen(!isProductDropdownOpen)} + className={`w-full border border-border rounded-lg px-3 py-2.5 text-sm flex items-center justify-between bg-surface cursor-pointer hover:border-primary/30 ${loadingProducts ? 'opacity-65 cursor-not-allowed' : ''}`} + > + + {loadingProducts ? 'Loading catalog products...' : productsList.find(p => p.id === clonedProductId)?.name || 'Select Product to Clone...'} + + {loadingProducts ? : } +
+ + {isProductDropdownOpen && ( +
+
+ + setProductSearchQueryInput(e.target.value)} + placeholder="Search by name, SKU, or code..." + className="w-full px-2 py-1 text-xs border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary bg-background text-foreground" + /> +
+
+ {(() => { + const query = productSearchQueryInput.toLowerCase(); + const filtered = productsList.filter(p => + (p.name || '').toLowerCase().includes(query) || + (p.sku || '').toLowerCase().includes(query) || + (p.code || '').toLowerCase().includes(query) + ); + return filtered.length > 0 ? ( + filtered.map((p: any) => ( +
{ + setClonedProductId(p.id); + setIsProductDropdownOpen(false); + setProductSearchQueryInput(''); + await handleCloneProduct(p.id); + }} + className={`px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors ${ + clonedProductId === p.id ? 'bg-primary/10 text-primary font-bold' : 'text-foreground hover:bg-background/50' + }`} + > +
{p.name}
+
+ SKU: {p.sku || '—'} | Code: {p.code} +
+
+ )) + ) : ( +
No products found.
+ ); + })()} +
+
+ )} +
+ )} +
+
+ )} + {/* Basic Information */}
@@ -874,14 +1318,16 @@ export default function NewProduct() { name="name" value={formik.values.name} onChange={formik.handleChange} + onBlur={formik.handleBlur} placeholder="Enter product name" className={inputClass} + disabled={isReadOnlyView} /> + {formik.touched.name && formik.errors.name && ( +

{formik.errors.name}

+ )}
-
- - -
+
@@ -891,6 +1337,7 @@ export default function NewProduct() { checked={formik.values.status === 'draft'} onChange={formik.handleChange} label="Draft" + disabled={isReadOnlyView} />
-
- -