From 06bafc288c1c514da1f860913fcee2557cb88f98 Mon Sep 17 00:00:00 2001 From: Mohammed-Fardeen-02 Date: Thu, 27 Aug 2026 16:44:20 +0530 Subject: [PATCH] implemented product and variant creation --- src/components/layouts/Headers.tsx | 2 +- .../asset-types/pages/NewAssetType.tsx | 160 ++++++- .../asset-types/types/asset-types.types.ts | 2 + src/features/assets/api/assets.api.ts | 5 + .../assets/services/assets.service.ts | 1 + .../attributes/pages/AttributeList.tsx | 2 +- src/features/brands/pages/BrandList.tsx | 4 +- .../categories/pages/CategoryList.tsx | 4 +- .../channels/components/ChannelMappingTab.tsx | 2 +- .../platform/pages/PlatformOverview.tsx | 2 +- .../platform/pages/PlatformTenantsPage.tsx | 2 +- .../components/DynamicAttributesSection.tsx | 9 + .../product/components/ProductAssetsTab.tsx | 418 +++++++++++++--- .../components/ProductAttributeGroup.tsx | 100 +++- .../variants/VariantAxesSelector.tsx | 17 +- .../variants/VariantBulkActions.tsx | 31 +- .../variants/VariantDetailModal.tsx | 373 +++++++++++++++ .../components/variants/VariantEditorRow.tsx | 89 ++-- .../components/variants/VariantListView.tsx | 123 +++-- .../components/variants/VariantMatrixView.tsx | 450 ++++++++---------- .../components/variants/VariantsTab.tsx | 333 +++++++++++-- src/features/product/pages/NewProduct.tsx | 171 +++++-- src/features/product/types/variant.types.ts | 5 +- .../settings/services/settings.service.ts | 26 +- src/routes/sidebar.config.ts | 1 - 25 files changed, 1786 insertions(+), 546 deletions(-) create mode 100644 src/features/product/components/variants/VariantDetailModal.tsx diff --git a/src/components/layouts/Headers.tsx b/src/components/layouts/Headers.tsx index 744b143..e3cc6f5 100644 --- a/src/components/layouts/Headers.tsx +++ b/src/components/layouts/Headers.tsx @@ -1,5 +1,5 @@ import { useNavigate } from "react-router-dom"; -import { Bell, ChevronDown, Building2, Globe, LogOut, Shield, User, Settings, CheckCircle2, ChevronRight, Menu, ShieldAlert, ArrowRight } from "lucide-react"; +import { Bell, ChevronDown, Building2, Globe, LogOut, Shield, User, Settings, CheckCircle2, ChevronRight, Menu, ShieldAlert } from "lucide-react"; import { useLanguage, type Language } from "../../contexts/LanguageContext"; import { useHeader } from "../../contexts/HeaderContext"; import { useSidebar } from "../../contexts/SidebarContext"; diff --git a/src/features/asset-types/pages/NewAssetType.tsx b/src/features/asset-types/pages/NewAssetType.tsx index 138ab33..b20948b 100644 --- a/src/features/asset-types/pages/NewAssetType.tsx +++ b/src/features/asset-types/pages/NewAssetType.tsx @@ -10,6 +10,7 @@ import { TextArea } from "../../../components/customs/TextArea"; import { Select } from "../../../components/customs/Select"; import { useAssetType } from "../hook/useAssetType"; import { assetTypeSchema } from "../validation/asset-types.schema"; +import { notify } from "../../../services/toast"; import type { AssetTypeCreateRequest } from "../types/asset-types.types"; const CATEGORIES = [ @@ -99,6 +100,35 @@ const CATEGORIES = [ }, ] as const; +const POPULAR_EXTENSIONS = [ + // Images + { ext: 'jpg', category: 'image', label: 'JPG' }, + { ext: 'jpeg', category: 'image', label: 'JPEG' }, + { ext: 'png', category: 'image', label: 'PNG' }, + { ext: 'webp', category: 'image', label: 'WEBP' }, + { ext: 'gif', category: 'image', label: 'GIF' }, + { ext: 'svg', category: 'image', label: 'SVG' }, + // Videos + { ext: 'mp4', category: 'video', label: 'MP4' }, + { ext: 'mov', category: 'video', label: 'MOV' }, + { ext: 'avi', category: 'video', label: 'AVI' }, + { ext: 'webm', category: 'video', label: 'WEBM' }, + // Documents + { ext: 'pdf', category: 'document', label: 'PDF' }, + { ext: 'doc', category: 'document', label: 'DOC' }, + { ext: 'docx', category: 'document', label: 'DOCX' }, + { ext: 'xls', category: 'document', label: 'XLS' }, + { ext: 'xlsx', category: 'document', label: 'XLSX' }, + { ext: 'ppt', category: 'document', label: 'PPT' }, + { ext: 'pptx', category: 'document', label: 'PPTX' }, + { ext: 'txt', category: 'document', label: 'TXT' }, + // Other + { ext: 'zip', category: 'other', label: 'ZIP' }, + { ext: 'rar', category: 'other', label: 'RAR' }, + { ext: 'csv', category: 'other', label: 'CSV' }, + { ext: 'json', category: 'other', label: 'JSON' }, +]; + const STEPS = [ { id: 'basic', label: 'Basic Information', step: 1 }, { id: 'category', label: 'Asset Category', step: 2 }, @@ -190,6 +220,36 @@ export default function NewAssetType() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isEdit, id, items]); + // Sync validation errors and switch steps if submit is attempted with errors + useEffect(() => { + if (formik.submitCount > 0 && !formik.isSubmitting) { + const errors = formik.errors; + const errorKeys = Object.keys(errors); + + if (errorKeys.length > 0) { + const messages: string[] = []; + + if (errors.name) messages.push(errors.name); + if (errors.code) messages.push(errors.code); + if (errors.category) { + messages.push(errors.category); + setActiveStep('category'); + } else if (errors.name || errors.code) { + setActiveStep('basic'); + } else if (errors.validation) { + setActiveStep('validation'); + const valErrors = errors.validation as any; + if (valErrors.maxFileSize) messages.push(valErrors.maxFileSize); + if (valErrors.allowedFileTypes) messages.push(valErrors.allowedFileTypes); + } + + notify.error(`Please resolve validation errors: ${messages.join('; ')}`); + formik.setSubmitting(false); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formik.submitCount, formik.isSubmitting]); + const handleNameChange = (e: React.ChangeEvent) => { formik.handleChange(e); if (!isEdit && !formik.touched.code) { @@ -199,8 +259,9 @@ export default function NewAssetType() { }; const handleAddFileType = () => { - if (newFileType.trim() && !formik.values.validation.allowedFileTypes.includes(newFileType.trim().toLowerCase())) { - formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, newFileType.trim().toLowerCase()]); + const trimmed = newFileType.trim().toLowerCase().replace(/^\./, ''); + if (trimmed && !formik.values.validation.allowedFileTypes.includes(trimmed)) { + formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, trimmed]); setNewFileType(''); } }; @@ -404,29 +465,92 @@ export default function NewAssetType() {
+ + {/* Selected formats badges list */}
- -
+ +
{formik.values.validation.allowedFileTypes.length === 0 ? ( - No file types added yet + No file types selected yet. Check the boxes below to allow extensions. ) : ( formik.values.validation.allowedFileTypes.map(type => ( - + .{type} - + )) )}
-
+
+ + {/* Multiselect checkboxes for popular formats */} +
+ +
+ {['image', 'video', 'document', 'other'].map(group => { + const exts = POPULAR_EXTENSIONS.filter(e => e.category === group); + const groupLabel = group === 'image' ? 'Image Formats' : group === 'video' ? 'Video Formats' : group === 'document' ? 'Document Formats' : 'Data & Archive Formats'; + + return ( +
+
{groupLabel}
+
+ {exts.map(item => { + const isChecked = formik.values.validation.allowedFileTypes.includes(item.ext); + return ( + + ); + })} +
+
+ ); + })} +
+
+ + {/* Custom Extension Input */} +
+ +
setNewFileType(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddFileType(); } }} - placeholder="Type extension and press Enter (e.g. jpg)" + placeholder="e.g. psd" + className="text-xs" /> -
@@ -528,12 +652,22 @@ export default function NewAssetType() {
{/* Bottom navigation */} -
+
{activeIndex > 0 && ( )} - {activeIndex < STEPS.length - 1 && ( + {activeIndex < STEPS.length - 1 ? ( + ) : ( + )}
diff --git a/src/features/asset-types/types/asset-types.types.ts b/src/features/asset-types/types/asset-types.types.ts index 5431725..53d3ec1 100644 --- a/src/features/asset-types/types/asset-types.types.ts +++ b/src/features/asset-types/types/asset-types.types.ts @@ -14,6 +14,8 @@ export interface AssetType { description?: string; status: 'active' | 'inactive'; isRequired: boolean; + isVariantEligible?: boolean; + is_variant_eligible?: boolean; category: 'image' | 'video' | 'document' | 'certificate' | 'marketing' | 'other' | ''; validation: AssetTypeValidation; createdAt: string; diff --git a/src/features/assets/api/assets.api.ts b/src/features/assets/api/assets.api.ts index 87ee5b4..88bddcc 100644 --- a/src/features/assets/api/assets.api.ts +++ b/src/features/assets/api/assets.api.ts @@ -104,6 +104,11 @@ export const assetsApi = { return res.success; }, + bulkAssignVariantAsset: async (productId: string, body: { asset_id: string; role: string; variant_ids: string[]; is_primary?: boolean }): Promise => { + const res = await apiClient.post>(`/api/v1/products/${productId}/assets/bulk-assign`, body); + return res.data || []; + }, + // Variant Assets Assignment getVariantAssets: async (variantId: string): Promise => { const res = await apiClient.get>(`/api/v1/variants/${variantId}/assets`); diff --git a/src/features/assets/services/assets.service.ts b/src/features/assets/services/assets.service.ts index 914dd77..39ddece 100644 --- a/src/features/assets/services/assets.service.ts +++ b/src/features/assets/services/assets.service.ts @@ -52,6 +52,7 @@ export const assetsService = { assignProductAsset: (productId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }) => assetsApi.assignProductAsset(productId, body), updateProductAsset: (productId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }) => assetsApi.updateProductAsset(productId, assetId, body), unassignProductAsset: (productId: string, assetId: string) => assetsApi.unassignProductAsset(productId, assetId), + bulkAssignVariantAsset: (productId: string, body: { asset_id: string; role: string; variant_ids: string[]; is_primary?: boolean }) => assetsApi.bulkAssignVariantAsset(productId, body), getVariantAssets: (variantId: string) => assetsApi.getVariantAssets(variantId), assignVariantAsset: (variantId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }) => assetsApi.assignVariantAsset(variantId, body), updateVariantAsset: (variantId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }) => assetsApi.updateVariantAsset(variantId, assetId, body), diff --git a/src/features/attributes/pages/AttributeList.tsx b/src/features/attributes/pages/AttributeList.tsx index 1ae536d..1e791a4 100644 --- a/src/features/attributes/pages/AttributeList.tsx +++ b/src/features/attributes/pages/AttributeList.tsx @@ -17,7 +17,7 @@ import { usePermissions } from "../../../hooks/usePermission"; import { Can } from "../../../components/customs/Can"; export default function AttributeList() { - const { canCreate, canEdit, canDelete } = usePermissions("products.attributes"); + const { canEdit, canDelete } = usePermissions("products.attributes"); const navigate = useNavigate(); const { attributes, fetchAttributes, deleteAttribute } = useAttribute(); const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" }); diff --git a/src/features/brands/pages/BrandList.tsx b/src/features/brands/pages/BrandList.tsx index 3e5da2b..c4663b4 100644 --- a/src/features/brands/pages/BrandList.tsx +++ b/src/features/brands/pages/BrandList.tsx @@ -55,8 +55,8 @@ export default function BrandList() { brands={brands} onRowClick={(row) => navigate(`/brands/${row.id}/edit`)} onView={(row) => navigate(`/brands/${row.id}/view`)} - onEdit={canEdit ? ((row) => navigate(`/brands/${row.id}/edit`)) : undefined} - onDelete={canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined} + onEdit={canEdit ? ((row) => navigate(`/brands/${row.id}/edit`)) : () => {}} + onDelete={canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : () => {}} />
diff --git a/src/features/categories/pages/CategoryList.tsx b/src/features/categories/pages/CategoryList.tsx index abcdcba..818c31b 100644 --- a/src/features/categories/pages/CategoryList.tsx +++ b/src/features/categories/pages/CategoryList.tsx @@ -35,8 +35,8 @@ export default function CategoryList() { const stats = { total: categories.length || 0, active: categories.filter((c) => c.status === "active").length, - products: categories.reduce((sum, c) => sum + (Number(c.productCount) || 0), 0), - families: categories.reduce((sum, c) => sum + (Number(c.familyCount) || 0), 0), + products: categories.reduce((sum, c: any) => sum + (Number(c.productCount) || 0), 0), + families: categories.reduce((sum, c: any) => sum + (Number(c.familyCount) || 0), 0), }; const handleDeleteConfirm = async () => { diff --git a/src/features/channels/components/ChannelMappingTab.tsx b/src/features/channels/components/ChannelMappingTab.tsx index 095ad26..2a932e3 100644 --- a/src/features/channels/components/ChannelMappingTab.tsx +++ b/src/features/channels/components/ChannelMappingTab.tsx @@ -13,7 +13,7 @@ const COMMON_PIM_ATTRIBUTES = [ { code: "created_at", label: "Creation Timestamp (created_at)" }, ]; -const COMMON_CHANNEL_FIELDS = [ +export const COMMON_CHANNEL_FIELDS = [ { code: "title", label: "Storefront Title (title)" }, { code: "body_html", label: "HTML Body Description (body_html)" }, { code: "variant_sku", label: "Variant SKU (variant_sku)" }, diff --git a/src/features/platform/pages/PlatformOverview.tsx b/src/features/platform/pages/PlatformOverview.tsx index 048085e..64028e3 100644 --- a/src/features/platform/pages/PlatformOverview.tsx +++ b/src/features/platform/pages/PlatformOverview.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Building2, Users, Package, Image, ShieldCheck, Activity, UserCheck, Play, StopCircle } from "lucide-react"; +import { Building2, Users, Package, Image, ShieldCheck, Activity, Play, StopCircle } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { PageWrapper } from "../../../components/layouts/PageWrapper"; import { Breadcrumb } from "../../../components/layouts/Breadcrumb"; diff --git a/src/features/platform/pages/PlatformTenantsPage.tsx b/src/features/platform/pages/PlatformTenantsPage.tsx index 54efad5..da0c665 100644 --- a/src/features/platform/pages/PlatformTenantsPage.tsx +++ b/src/features/platform/pages/PlatformTenantsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Plus, Building2, Search, Play, StopCircle, CheckCircle, XCircle, Copy, Check } from "lucide-react"; +import { Plus, Building2, Search, CheckCircle, XCircle, Copy, Check } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { PageWrapper } from "../../../components/layouts/PageWrapper"; import { Breadcrumb } from "../../../components/layouts/Breadcrumb"; diff --git a/src/features/product/components/DynamicAttributesSection.tsx b/src/features/product/components/DynamicAttributesSection.tsx index 8fcc96e..f2ee755 100644 --- a/src/features/product/components/DynamicAttributesSection.tsx +++ b/src/features/product/components/DynamicAttributesSection.tsx @@ -34,6 +34,9 @@ interface DynamicAttributesSectionProps { onAttributeChange: (code: string, value: any) => void; onAttributeBlur?: (code: string) => void; onAddAttributeClick?: (group: any) => void; + onRemoveAttribute?: (id: string) => void; + onRemoveGroup?: (id: string) => void; + customAttributeIds?: Set; readOnly?: boolean; } @@ -46,6 +49,9 @@ export const DynamicAttributesSection: React.FC = onAttributeChange, onAttributeBlur, onAddAttributeClick, + onRemoveAttribute, + onRemoveGroup, + customAttributeIds, readOnly, }) => { if (!hasAttributeSet) { @@ -95,6 +101,9 @@ export const DynamicAttributesSection: React.FC = onAttributeChange={onAttributeChange} onAttributeBlur={onAttributeBlur} onAddAttributeClick={onAddAttributeClick} + onRemoveAttribute={onRemoveAttribute} + onRemoveGroup={onRemoveGroup} + customAttributeIds={customAttributeIds} readOnly={readOnly} /> ))} diff --git a/src/features/product/components/ProductAssetsTab.tsx b/src/features/product/components/ProductAssetsTab.tsx index e04cc3a..398e5be 100644 --- a/src/features/product/components/ProductAssetsTab.tsx +++ b/src/features/product/components/ProductAssetsTab.tsx @@ -26,6 +26,35 @@ export const ProductAssetsTab: React.FC = ({ refreshProductData }) => { const [assignedAssets, setAssignedAssets] = useState([]); + const [assignedVariantAssets, setAssignedVariantAssets] = useState([]); + const [variants, setVariants] = useState([]); + const [selectedVariantId, setSelectedVariantId] = useState('global'); + const [isBulkMode, setIsBulkMode] = useState(false); + const [selectedVariantIds, setSelectedVariantIds] = useState([]); + + const variantFilterOptions = useMemo(() => { + const map = new Map }>(); + variants.forEach(v => { + (v.values || []).forEach((val: any) => { + if (val.axis) { + if (!map.has(val.axis.code)) { + map.set(val.axis.code, { name: val.axis.name || val.axis.code, values: new Set() }); + } + map.get(val.axis.code)?.values.add(val.value_text); + } + }); + }); + const result: { code: string; name: string; values: string[] }[] = []; + map.forEach((data, code) => { + result.push({ + code, + name: data.name, + values: Array.from(data.values) + }); + }); + return result; + }, [variants]); + const [loading, setLoading] = useState(false); // Library picker states @@ -93,6 +122,13 @@ export const ProductAssetsTab: React.FC = ({ return allAssetTypes.find(at => at.id === selectedAssetTypeId); }, [allAssetTypes, selectedAssetTypeId]); + // Reset selected variant target if the asset type does not support variants + useEffect(() => { + if (selectedAssetType && !(selectedAssetType.is_variant_eligible || selectedAssetType.isVariantEligible)) { + setSelectedVariantId('global'); + } + }, [selectedAssetType]); + // Resolve required asset type ids from family requirements const requiredAssetTypeIds = useMemo(() => { const ids: string[] = []; @@ -112,6 +148,26 @@ export const ProductAssetsTab: React.FC = ({ return [...new Set(ids)]; }, [family, allAssetFamilies]); + const combinedAssets = useMemo(() => { + const list: any[] = []; + assignedAssets.forEach(a => { + list.push({ + ...a, + isVariant: false, + variantId: undefined, + scopeLabel: 'Global' + }); + }); + assignedVariantAssets.forEach(va => { + list.push({ + ...va, + isVariant: true, + scopeLabel: `Variant: ${va.variantName.split(' - ')[1] || va.variantName} (${va.variantSku || 'No SKU'})` + }); + }); + return list; + }, [assignedAssets, assignedVariantAssets]); + const isAssetTypeRequiredByFamily = (code: string) => { const matchingType = allAssetTypes.find(at => at.code === code); if (!matchingType) return false; @@ -124,11 +180,34 @@ export const ProductAssetsTab: React.FC = ({ setLoading(true); try { const data = await assetsService.getProductAssets(productId); - // Sort by display order const sorted = [...data].sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); setAssignedAssets(sorted); + + const productVariants = await assetsService.getProductVariants(productId); + setVariants(productVariants || []); + + if (productVariants && productVariants.length > 0) { + const variantAssetsPromises = productVariants.map(async (v: any) => { + try { + const vAssets = await assetsService.getVariantAssets(v.id); + return vAssets.map((va: any) => ({ + ...va, + variantId: v.id, + variantName: v.name, + variantSku: v.sku + })); + } catch (err) { + console.error(`Failed to load assets for variant ${v.id}`, err); + return []; + } + }); + const allVariantAssets = (await Promise.all(variantAssetsPromises)).flat(); + setAssignedVariantAssets(allVariantAssets); + } else { + setAssignedVariantAssets([]); + } } catch (err: any) { - toast.error(err?.message || 'Failed to load product assets'); + toast.error(err?.message || 'Failed to load assets'); } finally { setLoading(false); } @@ -234,13 +313,29 @@ export const ProductAssetsTab: React.FC = ({ } } - // Map this asset to current product - await assetsService.assignProductAsset(productId, { - asset_id: newAsset.id, - role, - is_primary: assignedAssets.length === 0 && successCount === 0, - display_order: assignedAssets.length + successCount - }); + // Map this asset to current product or variant(s) + if (isBulkMode && selectedVariantIds.length > 0) { + await assetsService.bulkAssignVariantAsset(productId, { + asset_id: newAsset.id, + role, + variant_ids: selectedVariantIds, + is_primary: false + }); + } else if (selectedVariantId !== 'global') { + await assetsService.assignVariantAsset(selectedVariantId, { + asset_id: newAsset.id, + role, + is_primary: !assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.is_primary) && successCount === 0, + display_order: assignedVariantAssets.filter(m => m.variantId === selectedVariantId).length + successCount + }); + } else { + await assetsService.assignProductAsset(productId, { + asset_id: newAsset.id, + role, + is_primary: assignedAssets.length === 0 && successCount === 0, + display_order: assignedAssets.length + successCount + }); + } successCount++; } catch (err: any) { @@ -280,6 +375,10 @@ export const ProductAssetsTab: React.FC = ({ toast.error('Please select an Asset Type first.'); return; } + if (isBulkMode && selectedVariantIds.length === 0) { + toast.error('Please select at least one variant target for bulk assignment.'); + return; + } if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { handleMultipleFilesUpload(e.dataTransfer.files); } @@ -287,11 +386,20 @@ export const ProductAssetsTab: React.FC = ({ // Assign asset from the modal picker const handleAssignFromLibrary = async (asset: Asset) => { - // Check if already assigned - const alreadyAssigned = assignedAssets.some(m => m.asset_id === asset.id); - if (alreadyAssigned) { - toast.warn('Asset is already assigned to this product'); - return; + if (!isBulkMode) { + if (selectedVariantId !== 'global') { + const alreadyAssignedVariant = assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.asset_id === asset.id); + if (alreadyAssignedVariant) { + toast.warn('Asset is already assigned to this variant'); + return; + } + } else { + const alreadyAssigned = assignedAssets.some(m => m.asset_id === asset.id); + if (alreadyAssigned) { + toast.warn('Asset is already assigned to this product'); + return; + } + } } try { @@ -313,12 +421,28 @@ export const ProductAssetsTab: React.FC = ({ } } - await assetsService.assignProductAsset(productId, { - asset_id: asset.id, - role, - is_primary: assignedAssets.length === 0, - display_order: assignedAssets.length - }); + if (isBulkMode && selectedVariantIds.length > 0) { + await assetsService.bulkAssignVariantAsset(productId, { + asset_id: asset.id, + role, + variant_ids: selectedVariantIds, + is_primary: false + }); + } else if (selectedVariantId !== 'global') { + await assetsService.assignVariantAsset(selectedVariantId, { + asset_id: asset.id, + role, + is_primary: !assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.is_primary), + display_order: assignedVariantAssets.filter(m => m.variantId === selectedVariantId).length + }); + } else { + await assetsService.assignProductAsset(productId, { + asset_id: asset.id, + role, + is_primary: assignedAssets.length === 0, + display_order: assignedAssets.length + }); + } toast.success('Asset assigned from library'); loadProductAssets(); @@ -329,10 +453,14 @@ export const ProductAssetsTab: React.FC = ({ }; // Unassign asset mapping - const handleUnassign = async (assetId: string) => { - if (!window.confirm('Are you sure you want to unassign this asset from the product?')) return; + const handleUnassign = async (assetId: string, variantId?: string) => { + if (!window.confirm('Are you sure you want to unassign this asset?')) return; try { - await assetsService.unassignProductAsset(productId, assetId); + if (variantId) { + await assetsService.unassignVariantAsset(variantId, assetId); + } else { + await assetsService.unassignProductAsset(productId, assetId); + } toast.success('Asset unassigned'); loadProductAssets(); refreshProductData?.(); @@ -344,9 +472,13 @@ export const ProductAssetsTab: React.FC = ({ // Set selected asset as the primary display image - const handleSetPrimary = async (assetId: string) => { + const handleSetPrimary = async (assetId: string, variantId?: string) => { try { - await assetsService.updateProductAsset(productId, assetId, { is_primary: true }); + if (variantId) { + await assetsService.updateVariantAsset(variantId, assetId, { is_primary: true }); + } else { + await assetsService.updateProductAsset(productId, assetId, { is_primary: true }); + } toast.success('Primary image updated'); loadProductAssets(); refreshProductData?.(); @@ -461,8 +593,144 @@ export const ProductAssetsTab: React.FC = ({
+ + {/* Variant Target Selector */} + {selectedAssetType && (selectedAssetType.is_variant_eligible || selectedAssetType.isVariantEligible) && variants.length > 0 && ( +
+ +
+ {isBulkMode ? ( +
+ Bulk Mode Active ({selectedVariantIds.length} selected) +
+ ) : ( + + )} +
+
+ )} + {/* Bulk Selection and Attribute Filters Panel */} + {isBulkMode && selectedAssetType && (selectedAssetType.is_variant_eligible || selectedAssetType.isVariantEligible) && ( +
+
+ Bulk Variant Target Selection +
+ + +
+
+ + {/* Dynamic Attribute Filter Pills */} + {variantFilterOptions.length > 0 && ( +
+ Auto-Select by Specification: +
+ {variantFilterOptions.map(option => ( +
+ {option.name}: +
+ {option.values.map(val => { + const matchingIds = variants + .filter(v => (v.values || []).some((av: any) => av.axis?.code === option.code && av.value_text === val)) + .map(v => v.id); + + const isAllSelected = matchingIds.every(id => selectedVariantIds.includes(id)); + + return ( + + ); + })} +
+
+ ))} +
+
+ )} + + {/* Variants Checkbox Grid */} +
+ {variants.map(v => { + const isChecked = selectedVariantIds.includes(v.id); + return ( + + ); + })} +
+
+ )} + {selectedAssetType && (() => { const allowedFileTypes = selectedAssetType.validation?.allowedFileTypes ?? @@ -509,10 +777,15 @@ export const ProductAssetsTab: React.FC = ({ toast.error('Please select an Asset Type first.'); return; } + if (isBulkMode && selectedVariantIds.length === 0) { + toast.error('Please select at least one variant target for bulk assignment.'); + return; + } 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' - }`} + 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' + } ${isBulkMode && selectedVariantIds.length === 0 ? 'opacity-50 cursor-not-allowed' : ''}`} > = ({

Classification is required before mapping files to product registry

+ ) : isBulkMode && selectedVariantIds.length === 0 ? ( +
+ +
+

Select Variants Below

+

Check at least one variant before uploading files

+
+
) : (
@@ -569,18 +850,29 @@ export const ProductAssetsTab: React.FC = ({ toast.error('Please select an Asset Type first.'); return; } + if (isBulkMode && selectedVariantIds.length === 0) { + toast.error('Please select at least one variant target for bulk assignment.'); + return; + } fileInputRef.current?.click(); }} className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shadow-2xs cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" - disabled={!selectedAssetType} + disabled={!selectedAssetType || (isBulkMode && selectedVariantIds.length === 0)} > + Add Asset
)} - {/* Grid of assigned assets */} - {loading && assignedAssets.length === 0 ? ( + {loading && combinedAssets.length === 0 ? (
- ) : assignedAssets.length > 0 ? ( + ) : combinedAssets.length > 0 ? (
@@ -608,7 +899,7 @@ export const ProductAssetsTab: React.FC = ({ - {assignedAssets.map((mapping, idx) => { + {combinedAssets.map((mapping, idx) => { const asset = mapping.asset; if (!asset) return null; @@ -618,28 +909,32 @@ export const ProductAssetsTab: React.FC = ({ const is3DModel = asset.mime_type?.startsWith('model/') || ['glb', 'gltf', 'usdz'].includes(asset.extension || ''); return ( - + {/* Display Order sorting */} {!readOnly && ( )} @@ -662,11 +957,18 @@ export const ProductAssetsTab: React.FC = ({ {/* Meta info */} - - {/* Primary Badge toggle button */} + + - - + {/* Image Thumbnail */} + + + {/* SKU Input */} + + {/* Variant Specification */} - {/* SKU Input */} - - {/* Price Input */} - {/* Inventory Stock Input */} - - {/* Status dropdown */} + {variants.map(variant => ( + onSelectChange(variant.id, checked)} + onUpdate={onUpdate} + onDelete={onDelete} + onArchive={onArchive} + onViewDetail={() => setModalVariant(variant)} + readOnly={readOnly} + /> + ))} + {variants.length === 0 && ( + + + + )} + +
-
- - -
+ {!mapping.isVariant ? ( +
+ + +
+ ) : ( + + )}
-
- {asset.name} +
+ {asset.name} {mapping.role ? mapping.role.replace('_', ' ') : 'HERO IMAGE'} + + {mapping.scopeLabel} +
Size: {asset.file_size ? `${(asset.file_size / 1024).toFixed(1)} KB` : '—'} @@ -678,8 +980,6 @@ export const ProductAssetsTab: React.FC = ({
{mapping.is_primary ? ( @@ -692,7 +992,7 @@ export const ProductAssetsTab: React.FC = ({ ) : ( - )} +
+ {!readOnly && onRemoveGroup && ( + + )} + {!readOnly && onAddAttributeClick && ( + + )} +
No Attributes available.
@@ -83,6 +104,19 @@ export const ProductAttributeGroup: React.FC = ({ >

{group.name}

+ {!readOnly && onRemoveGroup && ( + + )} {!readOnly && onAddAttributeClick && ( + )} + onAttributeChange(attr.code, val)} + onBlur={() => onAttributeBlur?.(attr.code)} + error={errors[attr.code]} + touched={touched[attr.code]} + readOnly={readOnly} + /> +
+ ); + })} )} diff --git a/src/features/product/components/variants/VariantAxesSelector.tsx b/src/features/product/components/variants/VariantAxesSelector.tsx index c68c27e..4ca5c33 100644 --- a/src/features/product/components/variants/VariantAxesSelector.tsx +++ b/src/features/product/components/variants/VariantAxesSelector.tsx @@ -7,18 +7,29 @@ interface VariantAxesSelectorProps { onGenerate: (selected: Record, skuTemplate: string) => void; generating: boolean; parentSku: string; + initialSelectedValues?: Record; } export const VariantAxesSelector: React.FC = ({ axes, onGenerate, generating, - parentSku + parentSku, + initialSelectedValues }) => { - const [selectedValues, setSelectedValues] = useState>({}); + const [selectedValues, setSelectedValues] = useState>(initialSelectedValues || {}); const [skuTemplate, setSkuTemplate] = useState('{PARENT_SKU}-{COMBO}'); const [customInputs, setCustomInputs] = useState>({}); + React.useEffect(() => { + if (initialSelectedValues) { + setSelectedValues(prev => ({ + ...prev, + ...initialSelectedValues + })); + } + }, [initialSelectedValues]); + // Calculate combinations preview const activeAxes = axes.filter(axis => (selectedValues[axis.code] || []).length > 0); const totalCombinations = activeAxes.length > 0 @@ -120,7 +131,7 @@ export const VariantAxesSelector: React.FC = ({ // Pre-defined options list checkbox layout
{options.map(opt => { - const isChecked = selected.includes(opt.code); + const isChecked = selected.some(sel => sel.toLowerCase().trim() === opt.code.toLowerCase().trim()); return (
)} - {actionType === 'stock' && ( -
- - setBulkStock(e.target.value)} - className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface" - /> -
- )} - {actionType === 'status' && (
diff --git a/src/features/product/components/variants/VariantDetailModal.tsx b/src/features/product/components/variants/VariantDetailModal.tsx new file mode 100644 index 0000000..f7a79ed --- /dev/null +++ b/src/features/product/components/variants/VariantDetailModal.tsx @@ -0,0 +1,373 @@ +import React, { useState, useEffect } from 'react'; +import type { Variant, VariantStatus } from '../../types/variant.types'; +import { + X, Save, Archive, Trash2, Image as ImageIcon, + Package, Tag, DollarSign, CheckCircle, AlertCircle, Loader2, + ShoppingBag, Hash +} from 'lucide-react'; + +interface VariantDetailModalProps { + variant: Variant | null; + onClose: () => void; + onUpdate: (id: string, updates: Partial) => Promise; + onDelete?: (id: string) => void; + onArchive?: (id: string) => void; + readOnly?: boolean; +} + +export const VariantDetailModal: React.FC = ({ + variant, + onClose, + onUpdate, + onDelete, + onArchive, + readOnly = false +}) => { + const [sku, setSku] = useState(''); + const [price, setPrice] = useState(''); + const [costPrice, setCostPrice] = useState(''); + const [stock, setStock] = useState(''); + const [status, setStatus] = useState('draft'); + const [activeImageIdx, setActiveImageIdx] = useState(0); + const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); + + useEffect(() => { + if (variant) { + setSku(variant.sku || ''); + setPrice(String(variant.price ?? '')); + setCostPrice(String(variant.costPrice ?? '')); + setStock(String(variant.stock ?? '')); + setStatus(variant.status || 'draft'); + setActiveImageIdx(0); + setSaveState('idle'); + } + }, [variant]); + + if (!variant) return null; + + const images = variant.images || []; + const primaryImage = images.find(i => i.isPrimary) || images[0]; + const activeImage = images[activeImageIdx] || primaryImage; + + const axisEntries = Object.entries(variant.attributes || {}); + + const handleSave = async () => { + const pNum = parseFloat(price); + const cpNum = parseFloat(costPrice); + const sNum = parseInt(stock, 10); + + const hasChanges = + sku !== variant.sku || + pNum !== variant.price || + cpNum !== variant.costPrice || + sNum !== variant.stock || + status !== variant.status; + + if (!hasChanges) return; + + setSaveState('saving'); + try { + await onUpdate(variant.id, { + sku, + price: isNaN(pNum) ? 0 : pNum, + costPrice: isNaN(cpNum) ? 0 : cpNum, + stock: isNaN(sNum) ? 0 : sNum, + status + }); + setSaveState('saved'); + setTimeout(() => setSaveState('idle'), 2000); + } catch { + setSaveState('error'); + setTimeout(() => setSaveState('idle'), 3000); + } + }; + + const statusColor: Record = { + active: 'bg-emerald-100 text-emerald-700 border-emerald-200', + draft: 'bg-amber-100 text-amber-700 border-amber-200', + inactive: 'bg-slate-100 text-slate-600 border-slate-200', + archived: 'bg-red-50 text-red-600 border-red-200' + }; + + return ( +
+
+ + {/* ── Header ── */} +
+
+
+ +
+
+

+ {variant.name || 'Variant Details'} +

+

{variant.sku}

+
+
+
+ + {variant.status} + + +
+
+ + {/* ── Body ── */} +
+
+ + {/* Left: Image Gallery */} +
+ {/* Main image */} +
+ {activeImage?.url || activeImage?.thumbnailUrl ? ( + {activeImage.name + ) : ( +
+ + No image +
+ )} +
+ + {/* Thumbnail strip */} + {images.length > 1 && ( +
+ {images.map((img, idx) => ( + + ))} +
+ )} + + {/* Axis pills */} + {axisEntries.length > 0 && ( +
+

Variant Axes

+
+ {axisEntries.map(([key, val]) => ( + + + {key}: + {val} + + ))} +
+
+ )} + + {images.length > 0 && ( +

+ {images.length} asset{images.length !== 1 ? 's' : ''} uploaded +

+ )} +
+ + {/* Right: Edit Fields */} +
+ + {/* SKU */} +
+ + {readOnly ? ( +

{sku || '—'}

+ ) : ( + setSku(e.target.value)} + placeholder="e.g. PROD-RED-M" + className="w-full border border-border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground" + /> + )} +
+ + {/* Price & Cost */} +
+
+ + {readOnly ? ( +

${price}

+ ) : ( +
+ $ + setPrice(e.target.value)} + className="w-full border border-border rounded-lg pl-7 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground" + /> +
+ )} +
+
+ + {readOnly ? ( +

${costPrice}

+ ) : ( +
+ $ + setCostPrice(e.target.value)} + className="w-full border border-border rounded-lg pl-7 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground" + /> +
+ )} +
+
+ + {/* Stock & Status */} +
+
+ + {readOnly ? ( +

{stock}

+ ) : ( + setStock(e.target.value)} + className="w-full border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground" + /> + )} +
+
+ + {readOnly ? ( +

{status}

+ ) : ( + + )} +
+
+ + {/* Stats row */} +
+ {[ + { label: 'Available Stock', value: variant.availableStock ?? 0 }, + { label: 'Reserved', value: variant.reservedStock ?? 0 }, + { label: 'Safety Stock', value: variant.safetyStock ?? 0 }, + ].map(({ label, value }) => ( +
+
{value}
+
{label}
+
+ ))} +
+ + {/* Last updated */} + {variant.lastUpdated && ( +

+ Last updated: {new Date(variant.lastUpdated).toLocaleString()} +

+ )} +
+
+
+ + {/* ── Footer ── */} +
+ {/* Danger actions */} +
+ {!readOnly && onArchive && ( + + )} + {!readOnly && onDelete && ( + + )} +
+ + {/* Primary actions */} +
+ + {!readOnly && ( + + )} +
+
+
+
+ ); +}; diff --git a/src/features/product/components/variants/VariantEditorRow.tsx b/src/features/product/components/variants/VariantEditorRow.tsx index 08d535b..fe7e275 100644 --- a/src/features/product/components/variants/VariantEditorRow.tsx +++ b/src/features/product/components/variants/VariantEditorRow.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import type { Variant, VariantStatus } from '../../types/variant.types'; -import { Trash2, Archive, Loader, Check, CircleAlert } from 'lucide-react'; +import { Trash2, Archive, Loader, Check, CircleAlert, Image as ImageIcon, Eye } from 'lucide-react'; interface VariantEditorRowProps { variant: Variant; @@ -10,6 +10,7 @@ interface VariantEditorRowProps { onUpdate: (id: string, updates: Partial) => Promise; onDelete: (id: string) => void; onArchive: (id: string) => void; + onViewDetail?: () => void; readOnly?: boolean; } @@ -21,6 +22,7 @@ export const VariantEditorRow: React.FC = ({ onUpdate, onDelete, onArchive, + onViewDetail, readOnly }) => { const [sku, setSku] = useState(variant.sku); @@ -77,10 +79,24 @@ export const VariantEditorRow: React.FC = ({ } }; + const images = variant.images || []; + const primaryImg = images.find(i => i.isPrimary) || images[0]; + const thumbUrl = primaryImg?.thumbnailUrl || primaryImg?.url; + // ── Read-only row ────────────────────────────────────────────────────────── if (readOnly) { return (
+ {thumbUrl ? ( + {variant.name} + ) : ( +
+ +
+ )} +
{sku}
@@ -99,10 +115,8 @@ export const VariantEditorRow: React.FC = ({
{sku} ${price} ${costPrice}{stock} = ({ /> + {onViewDetail ? ( + + ) : ( + thumbUrl ? ( + {variant.name} + ) : ( +
+ +
+ ) + )} +
+ setSku(e.target.value)} + onBlur={handleFieldSave} + onKeyDown={handleKeyDown} + className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary font-mono bg-surface" + /> +
@@ -148,18 +197,6 @@ export const VariantEditorRow: React.FC = ({
- setSku(e.target.value)} - onBlur={handleFieldSave} - onKeyDown={handleKeyDown} - className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary font-mono bg-surface" - /> -
@@ -192,18 +229,6 @@ export const VariantEditorRow: React.FC = ({
- setStock(e.target.value)} - onBlur={handleFieldSave} - onKeyDown={handleKeyDown} - className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-center" - /> -
+ {onViewDetail && ( + + )}
+ No variants found matching criteria. +
+
+ + {modalVariant && ( + setModalVariant(null)} + onUpdate={async (id, updates) => { + const updated = await onUpdate(id, updates); + if (updated) setModalVariant(prev => prev ? { ...prev, ...updates } : null); + return updated; + }} + onDelete={id => { onDelete(id); setModalVariant(null); }} + onArchive={id => { onArchive(id); setModalVariant(null); }} + readOnly={readOnly} + /> + )} + ); }; diff --git a/src/features/product/components/variants/VariantMatrixView.tsx b/src/features/product/components/variants/VariantMatrixView.tsx index 4a42244..9a174a0 100644 --- a/src/features/product/components/variants/VariantMatrixView.tsx +++ b/src/features/product/components/variants/VariantMatrixView.tsx @@ -1,5 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import type { Variant } from '../../types/variant.types'; +import { VariantDetailModal } from './VariantDetailModal'; +import { Image as ImageIcon, Tag, Edit2, Trash2, Archive, CheckSquare, Square } from 'lucide-react'; interface VariantMatrixViewProps { variants: Variant[]; @@ -26,274 +28,218 @@ export const VariantMatrixView: React.FC = ({ onArchive, readOnly }) => { + const [modalVariant, setModalVariant] = useState(null); + 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; - return ( -
- - - - {!readOnly && ( - - )} - - {/* Dynamic columns for each variant axis */} - {axesKeys.map(key => ( - - ))} - - - - - - - {!readOnly && } - {!readOnly && } - - - - {variants.map(variant => ( - - {/* Checkbox */} - {!readOnly && ( - - )} - - {/* Dynamic cells for each variant axis */} - {axesKeys.map(key => { - const val = variant.attributes[key]; - return ( - - ); - })} - - {/* Delegate fields to VariantEditorRow columns via inline styles or matching markup */} - {/* Note: Instead of nesting a complete table inside a tr, we just render the editor cells directly in the matrix tr. */} - {/* To make it extremely clean and reuse the state, we can let VariantEditorRow handle the cells but structure it to match. */} - {/* But since VariantEditorRow expects specific column layouts, we can render the matching tds right here in VariantMatrixView or adapt it. */} - {/* Adapting: Since a tr cannot easily contain another tr, let's render the editor cells inline here for the matrix view. */} - - - ))} - {variants.length === 0 && ( - - - - )} - -
- { - 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" - /> - - {axesNames[key] || key} - SKU CodeSale PriceCost PriceStockStatusSaveActions
- onSelectChange(variant.id, e.target.checked)} - className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer" - /> - - - {val || '—'} - -
- No variants found matching criteria. -
-
- ); -}; - -// ── Inline Editor Cells Helper ──────────────────────────────────────────────── -interface InlineCellsProps { - variant: Variant; - onUpdate: (id: string, updates: Partial) => Promise; - onDelete: (id: string) => void; - onArchive: (id: string) => void; - readOnly?: boolean; -} - -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)); - const [stock, setStock] = useState(String(variant.stock)); - const [status, setStatus] = useState(variant.status); - - const [savingStatus, setSavingStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); - - useEffect(() => { - setSku(variant.sku); - setPrice(String(variant.price)); - setCostPrice(String(variant.costPrice)); - setStock(String(variant.stock)); - setStatus(variant.status); - }, [variant]); - - const handleSave = async () => { - const pNum = parseFloat(price); - const cpNum = parseFloat(costPrice); - const sNum = parseInt(stock, 10); - - const hasChanges = - sku !== variant.sku || - pNum !== variant.price || - cpNum !== variant.costPrice || - sNum !== variant.stock || - status !== variant.status; - - if (!hasChanges) return; - - setSavingStatus('saving'); - try { - await onUpdate(variant.id, { - sku, - price: isNaN(pNum) ? 0 : pNum, - costPrice: isNaN(cpNum) ? 0 : cpNum, - stock: isNaN(sNum) ? 0 : sNum, - status - }); - setSavingStatus('saved'); - setTimeout(() => setSavingStatus('idle'), 1500); - } catch (err) { - setSavingStatus('error'); - setTimeout(() => setSavingStatus('idle'), 3000); + const statusStyle = (s: string) => { + switch (s) { + case 'active': return 'bg-emerald-100 text-emerald-700 border-emerald-200'; + case 'draft': return 'bg-amber-100 text-amberald-700 border-amber-200'; + case 'inactive': return 'bg-slate-100 text-slate-500 border-slate-200'; + case 'archived': return 'bg-red-50 text-red-500 border-red-200'; + default: return 'bg-surface-muted text-muted-foreground border-border'; } }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLElement).blur(); - } - }; - - if (readOnly) { + if (variants.length === 0) { return ( - <> - {sku} - ${price} - ${costPrice} - {stock} - - - {status} - - - +
+ +

No variants found

+
); } return ( <> - - setSku(e.target.value)} - onBlur={handleSave} - onKeyDown={handleKeyDown} - className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary font-mono bg-surface" - /> - - -
- $ - setPrice(e.target.value)} - onBlur={handleSave} - onKeyDown={handleKeyDown} - className="w-full text-xs border border-border rounded pl-4 pr-1 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-right" - /> -
- - -
- $ - setCostPrice(e.target.value)} - onBlur={handleSave} - onKeyDown={handleKeyDown} - className="w-full text-xs border border-border rounded pl-4 pr-1 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-right" - /> -
- - - setStock(e.target.value)} - onBlur={handleSave} - onKeyDown={handleKeyDown} - className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-center" - /> - - - - - - {savingStatus === 'saving' && } - {savingStatus === 'saved' && } - {savingStatus === 'error' && } - - -
+ {/* Select-all toolbar */} + {!readOnly && ( +
- + {selectedIds.size > 0 && ( + + {selectedIds.size} selected + + )}
- + )} + + {/* Card grid */} +
+ {variants.map(variant => { + const images = variant.images || []; + const primaryImg = images.find(i => i.isPrimary) || images[0]; + const thumbUrl = primaryImg?.thumbnailUrl || primaryImg?.url; + const isSelected = selectedIds.has(variant.id); + const axisEntries = Object.entries(variant.attributes || {}); + + return ( +
+ {/* Selection checkbox overlay */} + {!readOnly && ( +
+ +
+ )} + + {/* Status badge */} +
+ + {variant.status} + +
+ + {/* Image area */} + + + {/* Card body */} +
+ {/* Axis pills */} +
+ {axisEntries.map(([key, val]) => ( + + + {val} + + ))} + {axisEntries.length === 0 && ( + No axes + )} +
+ + {/* SKU */} +
+ {variant.sku || '—'} +
+ + {/* Price row */} +
+ + {variant.price > 0 ? `$${variant.price.toFixed(2)}` : No price} + +
+ {!readOnly && ( + <> + + + + + )} +
+
+
+
+ ); + })} +
+ + {/* Variant Detail Modal */} + {modalVariant && ( + setModalVariant(null)} + onUpdate={async (id, updates) => { + const updated = await onUpdate(id, updates); + // Reflect updated data in the modal + if (updated) setModalVariant(prev => prev ? { ...prev, ...updates } : null); + return updated; + }} + onDelete={onDelete ? id => { onDelete(id); setModalVariant(null); } : undefined} + onArchive={onArchive ? id => { onArchive(id); setModalVariant(null); } : undefined} + readOnly={readOnly} + /> + )} ); }; diff --git a/src/features/product/components/variants/VariantsTab.tsx b/src/features/product/components/variants/VariantsTab.tsx index 6150867..15f2229 100644 --- a/src/features/product/components/variants/VariantsTab.tsx +++ b/src/features/product/components/variants/VariantsTab.tsx @@ -5,8 +5,9 @@ import { VariantListView } from './VariantListView'; import { VariantMatrixView } from './VariantMatrixView'; import { VariantBulkActions } from './VariantBulkActions'; import type { VariantAxis, VariantStatus, Variant } from '../../types/variant.types'; -import { Info, LayoutGrid, List, Plus, RefreshCw, Layers } from 'lucide-react'; +import { Info, LayoutGrid, List, Plus, RefreshCw, Layers, X } from 'lucide-react'; import { Loader } from '../../../../components/customs/Loader'; +import { notify } from '../../../../services/toast'; interface VariantsTabProps { productId?: string; @@ -14,6 +15,8 @@ interface VariantsTabProps { parentSku: string; family: any; // Product Family details readOnly?: boolean; + productAttributes?: Record; + availableAttributes?: any[]; } export const VariantsTab: React.FC = ({ @@ -21,7 +24,9 @@ export const VariantsTab: React.FC = ({ productType, parentSku, family, - readOnly + readOnly, + productAttributes = {}, + availableAttributes = [] }) => { const { variants, @@ -39,6 +44,24 @@ export const VariantsTab: React.FC = ({ const [showGenerator, setShowGenerator] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); + // Local state for dynamically configured variant axes (flows from family, reconstructed from variants, or custom selected) + const [localAxes, setLocalAxes] = useState([]); + + // Filter attributes that are marked as variant eligible OR are of eligible types (including type 'color') + const selectableAttributes = useMemo(() => { + return availableAttributes.filter(attr => + attr.is_variant_eligible === true || + attr.isVariantEligible === true || + ['select', 'enumeration', 'swatch', 'multiselect', 'color'].includes(attr.type || '') + ); + }, [availableAttributes]); + + // Form states for axis addition + const [selectedAttrId, setSelectedAttrId] = useState(''); + const [customAxisName, setCustomAxisName] = useState(''); + const [customAxisCode, setCustomAxisCode] = useState(''); + const [isAxesConfigOpen, setIsAxesConfigOpen] = useState(false); + // Load existing variants if product is created useEffect(() => { if (productId) { @@ -46,34 +69,146 @@ export const VariantsTab: React.FC = ({ } }, [productId, fetchByProduct]); - // Extract configured axes from the family - const variantAxes: VariantAxis[] = useMemo(() => { - if (!family || !Array.isArray(family.variantAxes)) return []; - return family.variantAxes; - }, [family]); + // Resolve and sync axes definitions (from family first, then reconstructed from variants, then auto-detected from productAttributes) + useEffect(() => { + if (family && Array.isArray(family.variantAxes) && family.variantAxes.length > 0) { + setLocalAxes(family.variantAxes); + } else if (variants.length > 0) { + const first = variants[0]; + if (first && first.attributes) { + const reconstructed = Object.keys(first.attributes).map(key => { + const exists = localAxes.find(la => la.code === key); + if (exists) return exists; + const foundAttr = selectableAttributes.find(a => a.code === key); + return { + id: foundAttr?.id || key, + code: key, + name: foundAttr?.name || key.toUpperCase().replace(/_VARIANT/g, '').replace(/_/g, ' '), + type: foundAttr?.type || 'select', + optionsList: foundAttr?.optionsList || [] + }; + }); + const currentKeys = localAxes.map(la => la.code).sort().join(','); + const newKeys = reconstructed.map(r => r.code).sort().join(','); + if (currentKeys !== newKeys) { + setLocalAxes(reconstructed as VariantAxis[]); + } + } + } else if (selectableAttributes.length > 0 && productAttributes) { + // Auto-detect variant axes from available attributes that have values selected + const detectedAxes: VariantAxis[] = []; + selectableAttributes.forEach(attr => { + const val = productAttributes[attr.code]; + if (val !== undefined && val !== null && val !== '' && !(Array.isArray(val) && val.length === 0)) { + detectedAxes.push({ + ...attr, + id: attr.id, + code: attr.code, + name: attr.name, + type: attr.type || 'select', + optionsList: attr.optionsList || [] + }); + } + }); - const axesKeys = useMemo(() => variantAxes.map(a => a.code), [variantAxes]); + if (detectedAxes.length > 0) { + const currentKeys = localAxes.map(la => la.code).sort().join(','); + const newKeys = detectedAxes.map(d => d.code).sort().join(','); + if (currentKeys !== newKeys) { + setLocalAxes(detectedAxes); + } + } + } + }, [family, variants, selectableAttributes, productAttributes]); + + const handleAddAttributeAxis = () => { + if (!selectedAttrId) return; + const attr = selectableAttributes.find(a => a.id === selectedAttrId); + if (!attr) return; + + if (localAxes.some(la => la.code === attr.code)) { + notify.error(`Axis with code "${attr.code}" is already added.`); + return; + } + + const newAxis: VariantAxis = { + ...attr, + id: attr.id, + code: attr.code, + name: attr.name, + type: attr.type || 'select', + optionsList: attr.optionsList || [] + }; + + setLocalAxes(prev => [...prev, newAxis]); + setSelectedAttrId(''); + notify.success(`Added axis: ${attr.name}`); + }; + + const handleAddCustomAxis = () => { + const name = customAxisName.trim(); + let code = customAxisCode.trim().toLowerCase().replace(/[^a-z0-9]/g, '_'); + + if (!name) { + notify.error('Please enter a name for the custom axis.'); + return; + } + if (!code) { + code = name.toLowerCase().replace(/[^a-z0-9]/g, '_'); + } + + if (localAxes.some(la => la.code === code)) { + notify.error(`Axis with code "${code}" is already added.`); + return; + } + + const newAxis: VariantAxis = { + id: `custom-${Date.now()}`, + code, + name, + type: 'select', + optionsList: [] + }; + + setLocalAxes(prev => [...prev, newAxis]); + setCustomAxisName(''); + setCustomAxisCode(''); + notify.success(`Added custom axis: ${name}`); + }; + + const handleRemoveAxis = (code: string) => { + setLocalAxes(prev => prev.filter(la => la.code !== code)); + notify.info(`Removed axis: ${code}`); + }; + + const initialSelectedValues = useMemo(() => { + const map: Record = {}; + if (!productAttributes) return map; + + localAxes.forEach(axis => { + const val = productAttributes[axis.code]; + if (val !== undefined && val !== null && val !== '') { + if (Array.isArray(val)) { + map[axis.code] = val.map(String); + } else if (typeof val === 'string' && val.includes(',')) { + map[axis.code] = val.split(',').map(s => s.trim()); + } else { + map[axis.code] = [String(val)]; + } + } + }); + return map; + }, [localAxes, productAttributes]); + + const axesKeys = useMemo(() => localAxes.map(a => a.code), [localAxes]); const axesNames = useMemo(() => { const map: Record = {}; - variantAxes.forEach(a => { + localAxes.forEach(a => { map[a.code] = a.name; }); return map; - }, [variantAxes]); - - // If the family does not support variants - if (variantAxes.length === 0) { - return ( -
- -

This Product Family does not support variants.

-

- The assigned Product Family ({family?.name || 'Selected Family'}) has no variant axes configured. -

-
- ); - } + }, [localAxes]); // If the product is not Configurable (type !== 'variant') if (productType !== 'variant') { @@ -103,7 +238,7 @@ export const VariantsTab: React.FC = ({ const handleGenerate = async (selected: Record, skuTemplate: string) => { const formattedAxes = Object.entries(selected).map(([code, values]) => { - const axisInfo = variantAxes.find(a => a.code === code); + const axisInfo = localAxes.find(a => a.code === code); return { code, name: axisInfo?.name || code, @@ -119,7 +254,7 @@ export const VariantsTab: React.FC = ({ }); setShowGenerator(false); } catch (err) { - // toast notification is done inside the hook + // handled in hook } }; @@ -143,14 +278,13 @@ export const VariantsTab: React.FC = ({ } }; - const handleBulkUpdates = async (updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus }) => { + const handleBulkUpdates = async (updates: { price?: number; costPrice?: number; status?: VariantStatus }) => { try { const ids = Array.from(selectedIds); await bulkUpdate({ ids, updates }); - // Refresh items fetchByProduct(productId); setSelectedIds(new Set()); } catch (err) {} @@ -176,6 +310,101 @@ export const VariantsTab: React.FC = ({ return updateVariant(id, updates); }; + const renderAxisCreatorControls = () => { + return ( +
+
+ {/* Option A: Choose from Attribute Set */} + {selectableAttributes.length > 0 && ( +
+
+

A. Choose from Attribute Set

+

Designate a dropdown/select attribute from your assigned Attribute Set.

+
+
+ + +
+
+ )} + + {/* Option B: Create Custom Axis */} +
+
+

B. Create Custom Axis

+

Create a custom variant axis not present in the attribute set (e.g. Size, Color).

+
+
+ setCustomAxisName(e.target.value)} + className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-foreground" + /> + setCustomAxisCode(e.target.value)} + className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-foreground" + /> +
+
+ +
+
+
+ + {/* List of currently added local axes */} + {localAxes.length > 0 && ( +
+

Designated Variant Axes ({localAxes.length})

+
+ {localAxes.map(axis => ( + + {axis.name} ({axis.code}) + + + ))} +
+
+ )} +
+ ); + }; + // Loading spinner for variant fetching if (loading && variants.length === 0) { return ( @@ -185,26 +414,64 @@ export const VariantsTab: React.FC = ({ ); } + // If local variant axes list is empty, they must add at least one axis + if (localAxes.length === 0) { + return ( +
+
+
+ +

Configure Variant Axes

+
+

+ This product has no variant axes defined. Designate attributes from your Attribute Set or add custom ones to enable variant generation. +

+
+ + {renderAxisCreatorControls()} +
+ ); + } + // Show generator UI if variants list is empty or generator toggled on if (variants.length === 0 || showGenerator) { return (
- {variants.length > 0 && ( -
+
+
+ {variants.length > 0 && ( + + )}
+
+ + {isAxesConfigOpen && ( +
+

Configure Variant Axes

+ {renderAxisCreatorControls()} +
)} +
); diff --git a/src/features/product/pages/NewProduct.tsx b/src/features/product/pages/NewProduct.tsx index 0c21939..005b355 100644 --- a/src/features/product/pages/NewProduct.tsx +++ b/src/features/product/pages/NewProduct.tsx @@ -68,6 +68,33 @@ export default function NewProduct() { const attributeSearchDropdownRef = useRef(null); const [customAddedAttributes, setCustomAddedAttributes] = useState([]); + const handleRemoveCustomAttribute = (attrId: string) => { + const attr = customAddedAttributes.find(a => a.id === attrId); + setCustomAddedAttributes(prev => prev.filter(a => a.id !== attrId)); + if (attr && attr.code) { + formik.setFieldValue(`attributes.${attr.code}`, undefined); + } + }; + + const [excludedGroupIds, setExcludedGroupIds] = useState>(new Set()); + const [productType, setProductType] = useState('simple'); + + const handleRemoveGroup = (groupId: string) => { + setExcludedGroupIds(prev => { + const next = new Set(prev); + next.add(String(groupId)); + return next; + }); + const group = filteredAttributeGroups.find((g: any) => String(g.id || g._id) === String(groupId)); + if (group && Array.isArray(group.attributes)) { + group.attributes.forEach((attr: any) => { + if (attr && attr.code) { + formik.setFieldValue(`attributes.${attr.code}`, undefined); + } + }); + } + }; + const [newlyCreatedBrandIds, setNewlyCreatedBrandIds] = useState([]); const [newlyCreatedUnitIds, setNewlyCreatedUnitIds] = useState([]); @@ -128,9 +155,9 @@ export default function NewProduct() { const displayChannelsList = useMemo(() => { const defaults = [ - { id: 'ch-shopify', name: 'Shopify Storefront', code: 'shopify', description: 'Direct Shopify e-commerce catalog sync', status: 'active' }, - { id: 'ch-amazon', name: 'Amazon Marketplace', code: 'amazon', description: 'Amazon seller central product listings', status: 'active' }, - { id: 'ch-custom-csv', name: 'Custom CSV Feed', code: 'custom_csv', description: 'Exportable CSV/XML syndication pipeline feed', status: 'active' } + { id: 'ch-shopify', name: 'Shopify Storefront', code: 'shopify', description: 'Direct Shopify e-commerce catalog sync', status: 'active' as const, createdAt: '' }, + { id: 'ch-amazon', name: 'Amazon Marketplace', code: 'amazon', description: 'Amazon seller central product listings', status: 'active' as const, createdAt: '' }, + { id: 'ch-custom-csv', name: 'Custom CSV Feed', code: 'custom_csv', description: 'Exportable CSV/XML syndication pipeline feed', status: 'active' as const, createdAt: '' } ]; if (!allChannels || allChannels.length === 0) return defaults; const merged = [...allChannels]; @@ -219,6 +246,7 @@ export default function NewProduct() { const handleAttributeSetChange = async (setId: string) => { setSelectedAttributeSetId(setId || null); + setExcludedGroupIds(new Set()); if (!setId) { setSelectedAttributeSetObj(null); setCustomAddedAttributes([]); @@ -242,12 +270,17 @@ export default function NewProduct() { }; - // Resolve list of brands based on allowed list const activeAllowedBrandsList = useMemo(() => { if (familyBrands && familyBrands.length > 0) { return brands.filter(b => newlyCreatedBrandIds.includes(b.id) || - familyBrands.some((fb: any) => (typeof fb === 'string' ? fb === b.id : fb?.id === b.id)) + familyBrands.some((fb: any) => { + if (typeof fb === 'string') { + const val = fb.toLowerCase().trim(); + return val === b.id || val === (b.code || '').toLowerCase().trim() || val === (b.name || '').toLowerCase().trim(); + } + return fb?.id === b.id || (fb?.code || '').toLowerCase().trim() === (b.code || '').toLowerCase().trim(); + }) ); } return brands; @@ -345,19 +378,36 @@ export default function NewProduct() { return groupsCopy; }, [activeAttributeGroups, customAddedAttributes]); + const variantAxesCodes = useMemo(() => { + if (!family || !Array.isArray(family.variantAxes)) return []; + return family.variantAxes.map((a: any) => (a.code || '').toLowerCase().trim()); + }, [family]); + const filteredAttributeGroups = useMemo(() => { if (!unifiedAttributeGroups) return []; + const isVariableProduct = productType === 'variant'; return unifiedAttributeGroups.map((group: any) => ({ ...group, id: group.id || group._id, - attributes: (group.attributes || []).filter((attr: any) => !EXCLUDED_ATTRIBUTE_CODES.includes((attr.code || '').toLowerCase())) - })).filter((group: any) => (group.attributes || []).length > 0); - }, [unifiedAttributeGroups]); + attributes: (group.attributes || []).filter((attr: any) => { + const codeLower = (attr.code || '').toLowerCase().trim(); + if (EXCLUDED_ATTRIBUTE_CODES.includes(codeLower)) return false; + if (isVariableProduct && variantAxesCodes.includes(codeLower)) return false; + return true; + }) + })).filter((group: any) => (group.attributes || []).length > 0 && !excludedGroupIds.has(String(group.id || group._id))); + }, [unifiedAttributeGroups, excludedGroupIds, variantAxesCodes, productType]); const filteredAttributesList = useMemo(() => { if (!activeAttributesList) return []; - return activeAttributesList.filter((attr: any) => !EXCLUDED_ATTRIBUTE_CODES.includes((attr.code || '').toLowerCase())); - }, [activeAttributesList]); + const isVariableProduct = productType === 'variant'; + return activeAttributesList.filter((attr: any) => { + const codeLower = (attr.code || '').toLowerCase().trim(); + if (EXCLUDED_ATTRIBUTE_CODES.includes(codeLower)) return false; + if (isVariableProduct && variantAxesCodes.includes(codeLower)) return false; + return true; + }); + }, [activeAttributesList, variantAxesCodes, productType]); const filteredRegistryAttributes = useMemo(() => { if (!allRegistryAttributes) return []; @@ -614,6 +664,12 @@ export default function NewProduct() { } }); + useEffect(() => { + if (formik.values.type !== productType) { + setProductType(formik.values.type); + } + }, [formik.values.type, productType]); + const areRequiredAttributesComplete = useMemo(() => { if (!Array.isArray(filteredAttributesList)) return true; return !filteredAttributesList.some((attr: any) => { @@ -628,7 +684,11 @@ export default function NewProduct() { const currentTabs = useMemo(() => { const isVariant = formik.values.type === 'variant'; - const assetsCount = product?.productAssets?.length ?? 0; + const productAssetsCount = product?.productAssets?.length ?? 0; + const variantAssetsCount = (product?.variants || []).reduce( + (sum: number, v: any) => sum + (v.images?.length ?? 0), 0 + ); + const assetsCount = productAssetsCount + variantAssetsCount; const tabsList = [ { id: 'general', label: 'General', icon: Box }, { id: 'attributes', label: 'Attributes', icon: LayoutGrid }, @@ -638,7 +698,7 @@ export default function NewProduct() { { id: 'review', label: 'Review', icon: Eye }, ]; return tabsList.map((t, idx) => ({ ...t, step: idx + 1 })); - }, [formik.values.type, product?.productAssets]); + }, [formik.values.type, product?.productAssets, product?.variants]); // Redirect if current activeTab is not in available tabs list (e.g. Variants removed) useEffect(() => { @@ -891,7 +951,9 @@ export default function NewProduct() { score += 20; } - if (product?.productAssets && product.productAssets.length > 0) { + const hasProductAssets = product?.productAssets && product.productAssets.length > 0; + const hasVariantAssets = (product?.variants || []).some((v: any) => (v.images && v.images.length > 0) || (v.variantAssets && v.variantAssets.length > 0)); + if (hasProductAssets || hasVariantAssets) { score += 15; } @@ -1302,13 +1364,13 @@ export default function NewProduct() { stroke="var(--color-primary)" strokeWidth="10" strokeDasharray="283" - strokeDashoffset={283 - (283 * (product?.completeness !== undefined && product?.completeness !== null && product.completeness > 0 ? product.completeness : productCompleteness)) / 100} + strokeDashoffset={283 - (283 * (product?.completeness !== undefined && product?.completeness !== null ? product.completeness : productCompleteness)) / 100} strokeLinecap="round" />
- {product?.completeness !== undefined && product?.completeness !== null && product.completeness > 0 ? product.completeness : productCompleteness}% + {product?.completeness !== undefined && product?.completeness !== null ? product.completeness : productCompleteness}%
@@ -1316,9 +1378,12 @@ export default function NewProduct() {
{[ { label: 'Variants', value: String(product?.variants?.length || 0) }, - { label: 'Assets', value: String(product?.productAssets?.length || 0) }, + { label: 'Assets', value: String( + (product?.productAssets?.length || 0) + + (product?.variants || []).reduce((s: number, v: any) => s + (v.images?.length ?? 0), 0) + )}, { label: 'Category', value: formik.values.category ? '1' : '0' }, - { label: 'Channels', value: String(product?.metadata?.channels?.length || 0) }, + { label: 'Channels', value: String((formik.values.metadata?.channels?.length || 0) + (family?.channels?.length || 0)) }, ].map(({ label, value }) => (
{label} @@ -1548,10 +1613,7 @@ export default function NewProduct() {
-
- - -
+
@@ -1747,7 +1809,18 @@ export default function NewProduct() { {/* Attribute Set dropdown */}
- +
+ + {selectedAttributeSetId && !isReadOnlyView && !(family && family.attributeSet) && ( + + )} +
{/* Searchable Attribute Set Selector */}
@@ -1928,6 +2001,9 @@ export default function NewProduct() { }} onAttributeBlur={(code) => formik.setFieldTouched(`attributes.${code}`, true)} onAddAttributeClick={(group) => handleOpenCreateAttributeModal(group.id || group._id)} + onRemoveAttribute={handleRemoveCustomAttribute} + onRemoveGroup={handleRemoveGroup} + customAttributeIds={new Set(customAddedAttributes.map(a => a.id))} readOnly={isReadOnlyView} /> )} @@ -1947,6 +2023,8 @@ export default function NewProduct() { parentSku={formik.values.sku} family={family} readOnly={isReadOnlyView} + productAttributes={formik.values.attributes} + availableAttributes={activeAttributesList} /> ) )} @@ -2074,12 +2152,7 @@ export default function NewProduct() { {formik.values.price !== '' ? `$${formik.values.price}` : (product?.price ? `$${product.price}` : '—')}
-
-
Initial Stock
-
- {formik.values.stock !== undefined ? formik.values.stock : (product?.stock ?? 0)} pcs -
-
+
Unit of Measure
@@ -2246,8 +2319,21 @@ export default function NewProduct() { {/* ── Assets ── */} {(() => { - const assets = product?.productAssets || []; - const ASSET_PREVIEW = 6; + const globalAssets = (product?.productAssets || []).map((pa: any) => ({ + ...pa, + scope: 'Global' + })); + const variantAssetsList = (product?.variants || []).flatMap((v: any) => + (v.images || []).map((img: any) => ({ + id: img.assetId || img.url, + asset: img, + role: img.role, + is_primary: img.isPrimary, + scope: `Variant: ${v.name?.split(' - ')[1] || v.name || v.sku}` + })) + ); + const allReviewAssets = [...globalAssets, ...variantAssetsList]; + const ASSET_PREVIEW = 8; return (
@@ -2255,7 +2341,7 @@ export default function NewProduct() {

Assets

- {assets.length > 0 ? `${assets.length} uploaded` : 'None uploaded'} + {allReviewAssets.length > 0 ? `${allReviewAssets.length} uploaded` : 'None uploaded'}
- {assets.length > 0 ? ( -
- {assets.slice(0, ASSET_PREVIEW).map((pa: any, idx: number) => { + {allReviewAssets.length > 0 ? ( +
+ {allReviewAssets.slice(0, ASSET_PREVIEW).map((pa: any, idx: number) => { const asset = pa.asset || pa; - const thumb = asset.thumbnail_url || asset.url || null; + const thumb = asset.thumbnail_url || asset.thumbnailUrl || asset.file_url || asset.url || null; const name = asset.name || asset.original_name || `Asset ${idx + 1}`; - const typeName = asset.assetType?.name || pa.asset_type || null; const role = pa.role || null; - const isPrimary = pa.is_primary; + const isPrimary = pa.is_primary || pa.isPrimary; + const scope = pa.scope || 'Global'; return (
{thumb ? ( @@ -2292,16 +2378,19 @@ export default function NewProduct() { )}
- - {role ? role.replace('_', ' ') : 'HERO IMAGE'} + + {role ? String(role).replace('_', ' ') : 'MEDIA'} + + + {scope}
); })} - {assets.length > ASSET_PREVIEW && ( -
+{assets.length - ASSET_PREVIEW} more assets
+ {allReviewAssets.length > ASSET_PREVIEW && ( +
+{allReviewAssets.length - ASSET_PREVIEW} more assets
)}
) : ( diff --git a/src/features/product/types/variant.types.ts b/src/features/product/types/variant.types.ts index 23b936b..bc2dc3a 100644 --- a/src/features/product/types/variant.types.ts +++ b/src/features/product/types/variant.types.ts @@ -47,16 +47,19 @@ export interface Variant { barcode?: string; weight?: number; dimensions?: { length?: number; width?: number; height?: number }; - images?: VariantImageSlot[]; + images: VariantImageSlot[]; } // ─── Image slot (prepared for Phase 3 DAM integration) ──────────────────── export interface VariantImageSlot { assetId?: string; url?: string; + thumbnailUrl?: string; + name?: string; role: 'primary' | 'gallery' | 'swatch'; isPrimary: boolean; displayOrder: number; + assetType?: { id: string; code: string; name: string } | null; } // ─── Axis configuration for batch generation ────────────────────────────── diff --git a/src/features/settings/services/settings.service.ts b/src/features/settings/services/settings.service.ts index 09c0d76..25ae2c2 100644 --- a/src/features/settings/services/settings.service.ts +++ b/src/features/settings/services/settings.service.ts @@ -2,11 +2,31 @@ import axiosInstance from '../../../api/axiosInstance'; export const settingsService = { getCategorySettings: async (category: string) => { - const response = await axiosInstance.get(`/settings/by-category/${category}`); - return response.data.data; + const response: any = await axiosInstance.get(`/settings/by-category/${category}`); + return response.data?.data; }, updateCategorySettings: async (category: string, data: Record) => { - const response = await axiosInstance.put(`/settings/by-category/${category}`, data); + const response: any = await axiosInstance.put(`/settings/by-category/${category}`, data); + return response.data; + }, + getAll: async (): Promise => { + const response: any = await axiosInstance.get('/settings'); + return response.data?.data || response.data || []; + }, + getById: async (id: string): Promise => { + const response: any = await axiosInstance.get(`/settings/${id}`); + return response.data?.data || response.data; + }, + create: async (data: any): Promise => { + const response: any = await axiosInstance.post('/settings', data); + return response.data?.data || response.data; + }, + update: async (id: string, data: any): Promise => { + const response: any = await axiosInstance.put(`/settings/${id}`, data); + return response.data?.data || response.data; + }, + delete: async (id: string): Promise => { + const response: any = await axiosInstance.delete(`/settings/${id}`); return response.data; } }; diff --git a/src/routes/sidebar.config.ts b/src/routes/sidebar.config.ts index a5bd364..e5a7afd 100644 --- a/src/routes/sidebar.config.ts +++ b/src/routes/sidebar.config.ts @@ -5,7 +5,6 @@ import { Grid3x3, Tag, Layers, - Database, Ruler, Award, Image,