Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a5aa9599a | ||
|
|
452896be0c | ||
|
|
3da12a9e49 | ||
|
|
1d9f7840cd | ||
|
|
65d2f48dce | ||
|
|
9980a67261 | ||
|
|
9e05f162a9 | ||
|
|
064b1726da | ||
|
|
2bb10e43dc | ||
|
|
2f1524d0fa | ||
|
|
4b70e8dbf8 | ||
|
|
eb3a613cf2 | ||
|
|
ed816920e2 | ||
|
|
3c15c4313f | ||
|
|
06bafc288c |
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Search, X, Check, ChevronDown } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface Option {
|
||||
@@ -18,6 +19,7 @@ interface SelectProps {
|
||||
placeholder?: string;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
@@ -31,11 +33,14 @@ export function Select({
|
||||
placeholder = "Select...",
|
||||
children,
|
||||
disabled,
|
||||
searchable,
|
||||
}: SelectProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [rect, setRect] = React.useState<DOMRect | null>(null);
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const options = React.useMemo(() => {
|
||||
const opts: Option[] = [];
|
||||
@@ -53,6 +58,19 @@ export function Select({
|
||||
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
|
||||
// By default, enable search if there are more than 5 options or if explicitly requested
|
||||
const isSearchEnabled = searchable !== undefined ? searchable : options.length > 5;
|
||||
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
if (!isSearchEnabled || !searchQuery.trim()) {
|
||||
return options;
|
||||
}
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
return options.filter((opt) =>
|
||||
opt.label.toLowerCase().includes(q) || opt.value.toLowerCase().includes(q)
|
||||
);
|
||||
}, [options, isSearchEnabled, searchQuery]);
|
||||
|
||||
// Recalculate position on scroll/resize while open
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -68,6 +86,17 @@ export function Select({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-focus search input when opened
|
||||
React.useEffect(() => {
|
||||
if (isOpen && isSearchEnabled) {
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 50);
|
||||
} else if (!isOpen) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [isOpen, isSearchEnabled]);
|
||||
|
||||
// Close on outside click
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -77,6 +106,7 @@ export function Select({
|
||||
dropdownRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
onBlur?.({ target: { name } } as any);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
@@ -94,10 +124,11 @@ export function Select({
|
||||
const handleSelect = (val: string) => {
|
||||
onChange?.({ target: { name, value: val } });
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="relative w-full font-sans">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
id={id}
|
||||
@@ -115,18 +146,12 @@ export function Select({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cn(!selectedOption && "text-muted-foreground")}>
|
||||
<span className={cn(!selectedOption && "text-muted-foreground", "truncate pr-2")}>
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</span>
|
||||
<svg
|
||||
className={cn("h-4 w-4 text-muted-foreground transition-transform", isOpen && "rotate-180")}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m19 9-7 7-7-7" />
|
||||
</svg>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 text-muted-foreground transition-transform shrink-0", isOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && rect && createPortal(
|
||||
@@ -136,30 +161,60 @@ export function Select({
|
||||
position: "fixed",
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
width: Math.max(rect.width, 220),
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className="bg-surface text-foreground border border-border shadow-lg rounded-xl p-1 max-h-60 overflow-y-auto"
|
||||
className="bg-surface text-foreground border border-border shadow-xl rounded-xl overflow-hidden flex flex-col max-h-64 font-sans animate-in fade-in zoom-in-95 duration-100"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSelect(opt.value); }}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg px-3 py-2 text-sm select-none transition-colors flex items-center justify-between",
|
||||
opt.value === value
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "hover:bg-primary/5 hover:text-primary text-foreground"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.value === value && (
|
||||
<svg className="h-4 w-4 fill-current text-primary shrink-0" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{isSearchEnabled && (
|
||||
<div className="p-2 border-b border-border bg-surface-muted flex items-center gap-2 shrink-0">
|
||||
<Search className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
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 font-sans"
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground shrink-0 cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
<div className="p-1 overflow-y-auto flex-1 space-y-0.5">
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSelect(opt.value); }}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg px-3 py-2 text-xs select-none transition-colors flex items-center justify-between",
|
||||
opt.value === value
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "hover:bg-background text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate pr-2">{opt.label}</span>
|
||||
{opt.value === value && (
|
||||
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-xs text-muted-foreground">
|
||||
No options found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useFormik } from "formik";
|
||||
import {
|
||||
X, Image as ImageIcon, Video, FileText, Award, Megaphone,
|
||||
HelpCircle, Plus, AlertCircle, Check, Loader2, Save
|
||||
} from "lucide-react";
|
||||
import { Input } from "../../../components/customs/Input";
|
||||
import { TextArea } from "../../../components/customs/TextArea";
|
||||
import { Select } from "../../../components/customs/Select";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useAssetType } from "../hook/useAssetType";
|
||||
import { assetTypeSchema } from "../validation/asset-types.schema";
|
||||
import { notify } from "../../../services/toast";
|
||||
import type { AssetType, AssetTypeCreateRequest } from "../types/asset-types.types";
|
||||
|
||||
const CATEGORIES = [
|
||||
{
|
||||
id: 'image',
|
||||
label: 'Image',
|
||||
icon: ImageIcon,
|
||||
desc: 'jpg, jpeg, png...',
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-50',
|
||||
bgSelected: 'bg-blue-600',
|
||||
borderSelected: 'border-blue-500',
|
||||
ringSelected: 'ring-blue-500',
|
||||
bgSelectedCard: 'bg-blue-50/70',
|
||||
hoverBorder: 'hover:border-blue-300',
|
||||
hoverBg: 'hover:bg-blue-50/50'
|
||||
},
|
||||
{
|
||||
id: 'video',
|
||||
label: 'Video',
|
||||
icon: Video,
|
||||
desc: 'mp4, mov, avi...',
|
||||
color: 'text-orange-600',
|
||||
bg: 'bg-orange-50',
|
||||
bgSelected: 'bg-orange-600',
|
||||
borderSelected: 'border-orange-500',
|
||||
ringSelected: 'ring-orange-500',
|
||||
bgSelectedCard: 'bg-orange-50/70',
|
||||
hoverBorder: 'hover:border-orange-300',
|
||||
hoverBg: 'hover:bg-orange-50/50'
|
||||
},
|
||||
{
|
||||
id: 'document',
|
||||
label: 'Document',
|
||||
icon: FileText,
|
||||
desc: 'pdf, docx, doc...',
|
||||
color: 'text-red-600',
|
||||
bg: 'bg-red-50',
|
||||
bgSelected: 'bg-red-600',
|
||||
borderSelected: 'border-red-500',
|
||||
ringSelected: 'ring-red-500',
|
||||
bgSelectedCard: 'bg-red-50/70',
|
||||
hoverBorder: 'hover:border-red-300',
|
||||
hoverBg: 'hover:bg-red-50/50'
|
||||
},
|
||||
{
|
||||
id: 'certificate',
|
||||
label: 'Certificate',
|
||||
icon: Award,
|
||||
desc: 'pdf, jpg, png',
|
||||
color: 'text-emerald-600',
|
||||
bg: 'bg-emerald-50',
|
||||
bgSelected: 'bg-emerald-600',
|
||||
borderSelected: 'border-emerald-500',
|
||||
ringSelected: 'ring-emerald-500',
|
||||
bgSelectedCard: 'bg-emerald-50/70',
|
||||
hoverBorder: 'hover:border-emerald-300',
|
||||
hoverBg: 'hover:bg-emerald-50/50'
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
label: 'Marketing',
|
||||
icon: Megaphone,
|
||||
desc: 'jpg, png, svg...',
|
||||
color: 'text-purple-600',
|
||||
bg: 'bg-purple-50',
|
||||
bgSelected: 'bg-purple-600',
|
||||
borderSelected: 'border-purple-500',
|
||||
ringSelected: 'ring-purple-500',
|
||||
bgSelectedCard: 'bg-purple-50/70',
|
||||
hoverBorder: 'hover:border-purple-300',
|
||||
hoverBg: 'hover:bg-purple-50/50'
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
label: 'Other',
|
||||
icon: HelpCircle,
|
||||
desc: 'pdf, zip, csv...',
|
||||
color: 'text-muted-foreground',
|
||||
bg: 'bg-background',
|
||||
bgSelected: 'bg-surface-active',
|
||||
borderSelected: 'border-border',
|
||||
ringSelected: 'ring-ring',
|
||||
bgSelectedCard: 'bg-background',
|
||||
hoverBorder: 'hover:border-border',
|
||||
hoverBg: 'hover:bg-background/50'
|
||||
},
|
||||
] 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 Info', step: 1 },
|
||||
{ id: 'category', label: 'Asset Category', step: 2 },
|
||||
{ id: 'validation', label: 'Validation Rules', step: 3 },
|
||||
{ id: 'preview', label: 'Preview', step: 4 },
|
||||
];
|
||||
|
||||
const labelClass = 'block text-xs font-semibold text-foreground mb-1.5';
|
||||
const errorClass = 'text-xs text-red-500 mt-1';
|
||||
|
||||
interface CreateAssetTypeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (created: AssetType) => void;
|
||||
}
|
||||
|
||||
export function CreateAssetTypeModal({ isOpen, onClose, onSuccess }: CreateAssetTypeModalProps) {
|
||||
const { createItem, loading } = useAssetType();
|
||||
const [newFileType, setNewFileType] = useState('');
|
||||
const [activeStep, setActiveStep] = useState('basic');
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: "",
|
||||
code: "",
|
||||
description: "",
|
||||
status: "active" as "active" | "inactive",
|
||||
isRequired: false,
|
||||
category: "" as typeof CATEGORIES[number]['id'] | "",
|
||||
validation: {
|
||||
allowedFileTypes: [] as string[],
|
||||
maxFileSize: 10,
|
||||
minUploadCount: 0,
|
||||
maxUploadCount: 1,
|
||||
}
|
||||
},
|
||||
validationSchema: assetTypeSchema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
const created = await createItem(values as AssetTypeCreateRequest);
|
||||
if (created) {
|
||||
onSuccess(created);
|
||||
onClose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Handled in hook notify
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}, [formik.submitCount, formik.isSubmitting, formik.errors]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
formik.handleChange(e);
|
||||
if (!formik.touched.code) {
|
||||
const generatedCode = e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||
formik.setFieldValue('code', generatedCode);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFileType = () => {
|
||||
const trimmed = newFileType.trim().toLowerCase().replace(/^\./, '');
|
||||
if (trimmed && !formik.values.validation.allowedFileTypes.includes(trimmed)) {
|
||||
formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, trimmed]);
|
||||
setNewFileType('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeFileType = (type: string) => {
|
||||
formik.setFieldValue('validation.allowedFileTypes', formik.values.validation.allowedFileTypes.filter(t => t !== type));
|
||||
};
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-xs animate-in fade-in duration-150 p-4 font-sans">
|
||||
<div className="bg-surface rounded-2xl border border-border shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between bg-surface-muted/30">
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground text-base">Create New Asset Type</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Configure media classifications and validation rules</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-background rounded-lg text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator Bar */}
|
||||
<div className="px-6 py-3 border-b border-border bg-surface flex items-center justify-between gap-2 overflow-x-auto">
|
||||
{STEPS.map((s, idx) => {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all shrink-0 cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary font-bold border border-primary/20'
|
||||
: isDone
|
||||
? 'text-foreground hover:bg-surface-muted'
|
||||
: 'text-muted-foreground hover:bg-surface-muted/50'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold ${
|
||||
isActive
|
||||
? 'bg-primary text-white'
|
||||
: isDone
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-surface-muted text-muted-foreground border border-border'
|
||||
}`}>
|
||||
{isDone ? <Check className="w-3 h-3" /> : s.step}
|
||||
</span>
|
||||
<span>{s.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<form id="inline-asset-type-form" onSubmit={formik.handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
|
||||
{/* Step 1 — Basic Information */}
|
||||
{activeStep === 'basic' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Asset Type Name <span className="text-red-500">*</span></label>
|
||||
<Input
|
||||
name="name"
|
||||
value={formik.values.name}
|
||||
onChange={handleNameChange}
|
||||
onBlur={formik.handleBlur}
|
||||
placeholder="e.g. Primary Image"
|
||||
aria-invalid={formik.touched.name && Boolean(formik.errors.name)}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Asset Code <span className="text-red-500">*</span></label>
|
||||
<Input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
placeholder="e.g. primary_image"
|
||||
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">Unique identifier (slug)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Description</label>
|
||||
<TextArea
|
||||
name="description"
|
||||
value={formik.values.description}
|
||||
onChange={formik.handleChange}
|
||||
rows={2}
|
||||
placeholder="Describe the purpose and usage guidelines for this asset type..."
|
||||
className="resize-none text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<Select
|
||||
name="status"
|
||||
value={formik.values.status}
|
||||
onChange={formik.handleChange}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Required Asset</label>
|
||||
<label className="flex items-center gap-3 p-2 border border-border rounded-lg cursor-pointer hover:bg-background transition-colors h-9">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isRequired"
|
||||
checked={formik.values.isRequired}
|
||||
onChange={formik.handleChange}
|
||||
className="w-4 h-4 text-primary rounded border-border focus:ring-primary"
|
||||
/>
|
||||
<div className="text-xs font-medium text-foreground">Mark as required by default</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2 — Asset Category */}
|
||||
{activeStep === 'category' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">Select the media category. This determines default validation rules and file type presets.</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{CATEGORIES.map(cat => (
|
||||
<div
|
||||
key={cat.id}
|
||||
onClick={() => formik.setFieldValue('category', cat.id)}
|
||||
className={`
|
||||
cursor-pointer p-3.5 rounded-xl border transition-all flex flex-col items-center justify-center gap-1.5 text-center
|
||||
${formik.values.category === cat.id
|
||||
? `${cat.bgSelectedCard} ${cat.borderSelected} shadow-xs ring-1 ${cat.ringSelected}`
|
||||
: `bg-surface border-border ${cat.hoverBorder} ${cat.hoverBg}`
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all ${formik.values.category === cat.id ? `${cat.bgSelected} text-white` : `${cat.bg} ${cat.color}`}`}>
|
||||
<cat.icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`font-semibold text-xs ${formik.values.category === cat.id ? 'text-foreground font-bold' : 'text-foreground'}`}>{cat.label}</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">{cat.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{formik.touched.category && formik.errors.category && (
|
||||
<p className={errorClass}>{formik.errors.category}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3 — Validation Rules */}
|
||||
{activeStep === 'validation' && (
|
||||
<div className="space-y-4">
|
||||
{/* Selected formats badges list */}
|
||||
<div>
|
||||
<label className={labelClass}>Allowed File Types</label>
|
||||
<div className="min-h-[38px] p-2.5 border border-border rounded-lg flex flex-wrap gap-1.5 bg-background">
|
||||
{formik.values.validation.allowedFileTypes.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground py-0.5 px-1">No file types selected yet. Check boxes below or add custom formats.</span>
|
||||
) : (
|
||||
formik.values.validation.allowedFileTypes.map(type => (
|
||||
<span key={type} className="inline-flex items-center gap-1 px-2 py-0.5 bg-surface border border-border rounded-md text-xs font-semibold text-foreground shadow-2xs">
|
||||
.{type}
|
||||
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500 ml-1 transition-colors cursor-pointer"><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format selection */}
|
||||
<div>
|
||||
<label className={labelClass}>Select Formats</label>
|
||||
<div className="bg-background border border-border rounded-xl p-3 space-y-3 max-h-48 overflow-y-auto">
|
||||
{['image', 'video', 'document', 'other'].map(group => {
|
||||
const exts = POPULAR_EXTENSIONS.filter(e => e.category === group);
|
||||
const groupLabel = group === 'image' ? 'Image' : group === 'video' ? 'Video' : group === 'document' ? 'Document' : 'Data & Other';
|
||||
|
||||
return (
|
||||
<div key={group} className="space-y-1.5">
|
||||
<div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">{groupLabel}</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
|
||||
{exts.map(item => {
|
||||
const isChecked = formik.values.validation.allowedFileTypes.includes(item.ext);
|
||||
return (
|
||||
<label
|
||||
key={item.ext}
|
||||
className={`
|
||||
flex items-center gap-1.5 px-2 py-1 border rounded-md cursor-pointer transition-all select-none text-xs
|
||||
${isChecked
|
||||
? 'bg-primary/10 border-primary text-primary font-bold shadow-2xs'
|
||||
: 'bg-surface border-border text-foreground hover:border-primary/20'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
const current = formik.values.validation.allowedFileTypes || [];
|
||||
if (e.target.checked) {
|
||||
formik.setFieldValue('validation.allowedFileTypes', [...current, item.ext]);
|
||||
} else {
|
||||
formik.setFieldValue('validation.allowedFileTypes', current.filter(t => t !== item.ext));
|
||||
}
|
||||
}}
|
||||
className="w-3 h-3 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px]">.{item.ext}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Extension Input */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground mb-1">Add Custom Extension</label>
|
||||
<div className="flex gap-2 max-w-xs">
|
||||
<Input
|
||||
value={newFileType}
|
||||
onChange={(e) => setNewFileType(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddFileType(); } }}
|
||||
placeholder="e.g. psd"
|
||||
className="text-xs h-8"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddFileType}
|
||||
className="px-3 py-1 bg-surface hover:bg-background border border-border rounded-md text-xs font-semibold text-foreground flex items-center justify-center transition-colors cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Constraints */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Max Size (MB)</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="validation.maxFileSize"
|
||||
value={formik.values.validation.maxFileSize}
|
||||
onChange={formik.handleChange}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Min Uploads</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="validation.minUploadCount"
|
||||
value={formik.values.validation.minUploadCount}
|
||||
onChange={formik.handleChange}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Max Uploads</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="validation.maxUploadCount"
|
||||
value={formik.values.validation.maxUploadCount}
|
||||
onChange={formik.handleChange}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4 — Preview */}
|
||||
{activeStep === 'preview' && (
|
||||
<div className="space-y-4">
|
||||
<div className="border border-border rounded-xl overflow-hidden shadow-xs">
|
||||
<div className="bg-primary p-3.5 flex items-center justify-between text-white">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 bg-surface/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-sm">{formik.values.name || 'Asset Type Name'}</div>
|
||||
<div className="text-[11px] text-primary-light font-mono">{formik.values.code || 'asset_code'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 py-0.5 bg-surface/20 rounded-full text-xs font-medium flex items-center gap-1.5 backdrop-blur-sm">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${formik.values.status === 'active' ? 'bg-green-400' : 'bg-muted-foreground'}`} />
|
||||
{formik.values.status === 'active' ? 'Active' : 'Inactive'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background p-4 grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground uppercase mb-1.5 text-[10px]">Category & Formats</div>
|
||||
<div className="mb-2 font-medium capitalize text-foreground">{formik.values.category || 'Not specified'}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{formik.values.validation.allowedFileTypes.length === 0 ? (
|
||||
<span className="text-muted-foreground text-[11px]">All file types permitted</span>
|
||||
) : (
|
||||
formik.values.validation.allowedFileTypes.map(type => (
|
||||
<span key={type} className="px-1.5 py-0.5 bg-surface-muted text-foreground rounded text-[10px] font-mono">.{type}</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground uppercase mb-1.5 text-[10px]">Constraints</div>
|
||||
<div className="space-y-1 text-[11px] text-muted-foreground">
|
||||
<div className="flex justify-between"><span>Max Size:</span> <span className="font-medium text-foreground">{formik.values.validation.maxFileSize} MB</span></div>
|
||||
<div className="flex justify-between"><span>Uploads:</span> <span className="font-medium text-foreground">Min {formik.values.validation.minUploadCount}, Max {formik.values.validation.maxUploadCount}</span></div>
|
||||
<div className="flex justify-between"><span>Required:</span> <span className="font-medium text-foreground">{formik.values.isRequired ? 'Yes' : 'No (Optional)'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-3.5 border-t border-border bg-surface-muted/30 flex items-center justify-between">
|
||||
<Button variant="outline" size="sm" type="button" onClick={onClose} disabled={formik.isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{activeIndex > 0 && (
|
||||
<Button variant="outline" size="sm" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 ? (
|
||||
<Button variant="primary" size="sm" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
type="submit"
|
||||
form="inline-asset-type-form"
|
||||
icon={<Save className="w-3.5 h-3.5" />}
|
||||
loading={formik.isSubmitting || loading}
|
||||
>
|
||||
Create Asset Type
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,5 @@ export * from './types/asset-types.types';
|
||||
export * from './services/asset-types.service';
|
||||
export * from './hook/useAssetType';
|
||||
export * from './routes/asset-types.routes';
|
||||
export * from './components/CreateAssetTypeModal';
|
||||
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
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() {
|
||||
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden">
|
||||
<CardHeader title="Validation Rules" subtitle="Enforced when assets are uploaded" />
|
||||
<div className="p-6 space-y-6">
|
||||
|
||||
{/* Selected formats badges list */}
|
||||
<div>
|
||||
<label className={labelClass}>Allowed File Types <span className="text-red-500">*</span></label>
|
||||
<div className="min-h-[42px] p-2 border border-primary/10 rounded-lg mb-2 flex flex-wrap gap-2 bg-background">
|
||||
<label className={labelClass}>Allowed File Types Summary <span className="text-red-500">*</span></label>
|
||||
<div className="min-h-[42px] p-3 border border-primary/10 rounded-lg mb-4 flex flex-wrap gap-2 bg-background">
|
||||
{formik.values.validation.allowedFileTypes.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground py-1 px-2">No file types added yet</span>
|
||||
<span className="text-xs text-muted-foreground py-1 px-1">No file types selected yet. Check the boxes below to allow extensions.</span>
|
||||
) : (
|
||||
formik.values.validation.allowedFileTypes.map(type => (
|
||||
<span key={type} className="inline-flex items-center gap-1 px-2 py-1 bg-surface border border-border rounded text-xs font-medium text-foreground">
|
||||
<span key={type} className="inline-flex items-center gap-1 px-2.5 py-1 bg-surface border border-border rounded-lg text-xs font-semibold text-foreground animate-fade-in shadow-2xs">
|
||||
.{type}
|
||||
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500"><X className="w-3 h-3" /></button>
|
||||
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500 ml-1 transition-colors"><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
</div>
|
||||
|
||||
{/* Multiselect checkboxes for popular formats */}
|
||||
<div>
|
||||
<label className={labelClass}>Select Allowed Formats</label>
|
||||
<div className="bg-background border border-primary/10 rounded-xl p-5 space-y-5">
|
||||
{['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 (
|
||||
<div key={group} className="space-y-2">
|
||||
<div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">{groupLabel}</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-3">
|
||||
{exts.map(item => {
|
||||
const isChecked = formik.values.validation.allowedFileTypes.includes(item.ext);
|
||||
return (
|
||||
<label
|
||||
key={item.ext}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 border rounded-lg cursor-pointer transition-all select-none
|
||||
${isChecked
|
||||
? 'bg-primary/5 border-primary text-primary font-bold shadow-2xs'
|
||||
: 'bg-surface border-border text-foreground hover:border-primary/20 hover:bg-background/20'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
const current = formik.values.validation.allowedFileTypes || [];
|
||||
if (e.target.checked) {
|
||||
formik.setFieldValue('validation.allowedFileTypes', [...current, item.ext]);
|
||||
} else {
|
||||
formik.setFieldValue('validation.allowedFileTypes', current.filter(t => t !== item.ext));
|
||||
}
|
||||
}}
|
||||
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs">.{item.ext}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Extension Input */}
|
||||
<div className="pt-2">
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground mb-1">Add Custom Extension (Optional)</label>
|
||||
<div className="flex gap-2 max-w-sm">
|
||||
<Input
|
||||
value={newFileType}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button type="button" onClick={handleAddFileType} className="px-3 py-2 border border-primary/10 rounded-lg hover:bg-background">
|
||||
<Plus className="w-4 h-4 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddFileType}
|
||||
className="px-4 py-2 bg-surface hover:bg-background border border-border rounded-lg text-xs font-semibold text-foreground flex items-center justify-center transition-colors"
|
||||
title="Add custom format"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-muted-foreground mr-1" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -528,12 +652,22 @@ export default function NewAssetType() {
|
||||
</div>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<div className="shrink-0 pt-2 flex justify-end gap-2">
|
||||
<div className="shrink-0 pt-4 flex justify-end gap-2">
|
||||
{activeIndex > 0 && (
|
||||
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>Back</Button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
{activeIndex < STEPS.length - 1 ? (
|
||||
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
form="asset-type-form"
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
loading={formik.isSubmitting}
|
||||
>
|
||||
{isEdit ? 'Save Changes' : 'Create Asset Type'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -86,49 +86,68 @@ export const assetsApi = {
|
||||
// Product Assets Assignment
|
||||
getProductAssets: async (productId: string): Promise<AssetMapping[]> => {
|
||||
const res = await apiClient.get<ApiResponse<AssetMapping[]>>(`/api/v1/products/${productId}/assets`);
|
||||
return res.data || [];
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data) || [];
|
||||
},
|
||||
|
||||
getAllVariantAssets: async (productId: string): Promise<any[]> => {
|
||||
const res = await apiClient.get<ApiResponse<any[]>>(`/api/v1/products/${productId}/all-variant-assets`);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data) || [];
|
||||
},
|
||||
|
||||
assignProductAsset: async (productId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
|
||||
const res = await apiClient.post<ApiResponse<AssetMapping>>(`/api/v1/products/${productId}/assets`, body);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return raw?.data ? raw.data : raw;
|
||||
},
|
||||
|
||||
updateProductAsset: async (productId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
|
||||
const res = await apiClient.put<ApiResponse<AssetMapping>>(`/api/v1/products/${productId}/assets/${assetId}`, body);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return raw?.data ? raw.data : raw;
|
||||
},
|
||||
|
||||
unassignProductAsset: async (productId: string, assetId: string): Promise<boolean> => {
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/products/${productId}/assets/${assetId}`);
|
||||
return res.success;
|
||||
return res.data?.success ?? true;
|
||||
},
|
||||
|
||||
bulkAssignVariantAsset: async (productId: string, body: { asset_id: string; role: string; variant_ids: string[]; is_primary?: boolean }): Promise<any[]> => {
|
||||
const res = await apiClient.post<ApiResponse<any[]>>(`/api/v1/products/${productId}/assets/bulk-assign`, body);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data) || [];
|
||||
},
|
||||
|
||||
// Variant Assets Assignment
|
||||
getVariantAssets: async (variantId: string): Promise<AssetMapping[]> => {
|
||||
const res = await apiClient.get<ApiResponse<AssetMapping[]>>(`/api/v1/variants/${variantId}/assets`);
|
||||
return res.data || [];
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data) || [];
|
||||
},
|
||||
|
||||
assignVariantAsset: async (variantId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
|
||||
const res = await apiClient.post<ApiResponse<AssetMapping>>(`/api/v1/variants/${variantId}/assets`, body);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return raw?.data ? raw.data : raw;
|
||||
},
|
||||
|
||||
updateVariantAsset: async (variantId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
|
||||
const res = await apiClient.put<ApiResponse<AssetMapping>>(`/api/v1/variants/${variantId}/assets/${assetId}`, body);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return raw?.data ? raw.data : raw;
|
||||
},
|
||||
|
||||
unassignVariantAsset: async (variantId: string, assetId: string): Promise<boolean> => {
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/variants/${variantId}/assets/${assetId}`);
|
||||
return res.success;
|
||||
return res.data?.success ?? true;
|
||||
},
|
||||
|
||||
// Product variants list for the variant select dropdown
|
||||
getProductVariants: async (productId: string): Promise<any[]> => {
|
||||
const res = await apiClient.get<ApiResponse<any[]>>('/api/v1/variants', { params: { parentProductId: productId } });
|
||||
return res.data || [];
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data) || [];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -49,9 +49,11 @@ export const assetsService = {
|
||||
getFolders: () => assetsApi.getFolders(),
|
||||
getTags: () => assetsApi.getTags(),
|
||||
getProductAssets: (productId: string) => assetsApi.getProductAssets(productId),
|
||||
getAllVariantAssets: (productId: string) => assetsApi.getAllVariantAssets(productId),
|
||||
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),
|
||||
|
||||
@@ -197,20 +197,6 @@ export default function NewAttributeGroup() {
|
||||
<CardHeader title="Basic Information" subtitle="Define the group's identity and metadata" />
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className={labelClass}>Group Code <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g., general_info"
|
||||
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Group Name <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
@@ -230,6 +216,20 @@ export default function NewAttributeGroup() {
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Group Code <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g., general_info"
|
||||
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -139,6 +139,38 @@ export default function NewAttributeSet() {
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isBasicValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && !formik.errors.name && !formik.errors.code);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isBasicValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'basic') {
|
||||
if (!isBasicValid) {
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('code', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isBasicValid, activeIndex, formik]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="products.attributes">
|
||||
<PageWrapper>
|
||||
@@ -181,17 +213,23 @@ export default function NewAttributeSet() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
|
||||
isActive ? "bg-primary ring-2 ring-primary/20" :
|
||||
isDone ? "bg-emerald-500" :
|
||||
"bg-surface border-2 border-border hover:border-primary/30"
|
||||
}`}
|
||||
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{isDone
|
||||
? <Check className="w-3 h-3 text-white" />
|
||||
@@ -204,8 +242,13 @@ export default function NewAttributeSet() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${
|
||||
isActive ? "text-primary-dark" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
|
||||
@@ -228,20 +271,6 @@ export default function NewAttributeSet() {
|
||||
<CardHeader title="Basic Information" subtitle="Define the set's identity and metadata" />
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className={labelClass}>Set Code <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g., electronic_accessories"
|
||||
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case/kebab-case)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Set Name <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
@@ -259,6 +288,20 @@ export default function NewAttributeSet() {
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Set Code <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g., electronic_accessories"
|
||||
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case/kebab-case)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -452,7 +495,7 @@ export default function NewAttributeSet() {
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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: "" });
|
||||
|
||||
@@ -176,6 +176,14 @@ export default function NewAttribute() {
|
||||
apiVisible: (match as any).apiVisible ?? true,
|
||||
isRequiredForCompleteness: (match as any).isRequiredForCompleteness ?? false,
|
||||
});
|
||||
|
||||
if ((match as any).optionsList && Array.isArray((match as any).optionsList)) {
|
||||
setOptionsList((match as any).optionsList.map((o: any) => ({ code: o.code, label: o.label })));
|
||||
} else if ((match as any).options && Array.isArray((match as any).options)) {
|
||||
setOptionsList((match as any).options.map((o: any) => typeof o === 'string' ? { code: o.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, ''), label: o } : o));
|
||||
} else {
|
||||
setOptionsList([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -185,6 +193,39 @@ export default function NewAttribute() {
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isGeneralValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && formik.values.type && !formik.errors.name && !formik.errors.code && !formik.errors.type);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isGeneralValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isGeneralValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'general') {
|
||||
if (!isGeneralValid) {
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('code', true);
|
||||
formik.setFieldTouched('type', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isGeneralValid, activeIndex, formik]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="products.attributes">
|
||||
<div className="h-screen flex flex-col overflow-hidden bg-background/50">
|
||||
@@ -239,16 +280,22 @@ export default function NewAttribute() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = s.step < activeIndex + 1;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? "bg-primary ring-2 ring-primary/20" :
|
||||
isDone ? "bg-emerald-500" :
|
||||
"bg-surface border-2 border-border hover:border-primary/30"
|
||||
}`}
|
||||
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{isDone
|
||||
? <Check className="w-3 h-3 text-white" />
|
||||
@@ -261,8 +308,13 @@ export default function NewAttribute() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${isActive ? "text-primary-dark" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
|
||||
}`}>{s.label}</span>
|
||||
@@ -283,19 +335,6 @@ export default function NewAttribute() {
|
||||
<CardHeader title="General Information" subtitle="Basic details about the attribute" />
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className={labelClass}>Attribute Code <span className="text-red-400">*</span></label>
|
||||
<Input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit || isView}
|
||||
placeholder="e.g., product_weight"
|
||||
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
|
||||
/>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Attribute Name <span className="text-red-400">*</span></label>
|
||||
<Input
|
||||
@@ -309,6 +348,19 @@ export default function NewAttribute() {
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Attribute Code <span className="text-red-400">*</span></label>
|
||||
<Input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit || isView}
|
||||
placeholder="e.g., product_weight"
|
||||
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
|
||||
/>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -570,7 +622,7 @@ export default function NewAttribute() {
|
||||
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} icon={<ChevronLeft className="w-4 h-4" />}>Back</Button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next <ChevronRight className="w-4 h-4" /></Button>
|
||||
<Button variant="primary" type="button" onClick={handleNextStep}>Next <ChevronRight className="w-4 h-4" /></Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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 })) : () => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)" },
|
||||
|
||||
@@ -125,6 +125,39 @@ export default function NewChannel() {
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isBasicValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && formik.values.channelType && !formik.errors.name && !formik.errors.code && !formik.errors.channelType);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isBasicValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'basic') {
|
||||
if (!isBasicValid) {
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('code', true);
|
||||
formik.setFieldTouched('channelType', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isBasicValid, activeIndex, formik]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="settings.integrations">
|
||||
<PageWrapper>
|
||||
@@ -169,17 +202,23 @@ export default function NewChannel() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
|
||||
isActive ? "bg-primary ring-2 ring-primary/20" :
|
||||
isDone ? "bg-success" :
|
||||
"bg-surface border-2 border-border hover:border-primary/30"
|
||||
}`}
|
||||
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{isDone
|
||||
? <Check className="w-3 h-3 text-white" />
|
||||
@@ -192,8 +231,13 @@ export default function NewChannel() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${
|
||||
isActive ? "text-primary" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
|
||||
@@ -426,7 +470,7 @@ export default function NewChannel() {
|
||||
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>Back</Button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next</Button>
|
||||
<Button variant="primary" type="button" onClick={handleNextStep}>Next</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2549
-744
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ export interface Family {
|
||||
description?: string;
|
||||
category?: string;
|
||||
categoryId?: string | null;
|
||||
productType?: 'simple' | 'variant' | string;
|
||||
attributes: string[]; // List of attribute codes
|
||||
attributeGroups?: number;
|
||||
variantAxes: string[]; // List of attribute codes used as variant axes
|
||||
@@ -17,6 +18,7 @@ export interface Family {
|
||||
createdBy: string;
|
||||
channels?: string[];
|
||||
assetRequirements?: string[];
|
||||
directAssetTypes?: string[];
|
||||
completenessRules?: Record<string, number>;
|
||||
workflowCode?: string;
|
||||
attributeSetId?: string;
|
||||
|
||||
@@ -20,6 +20,8 @@ export const familySchema = Yup.object().shape({
|
||||
})
|
||||
),
|
||||
status: Yup.string().oneOf(['active', 'inactive', 'draft']),
|
||||
productType: Yup.string().oneOf(['simple', 'variant']).optional(),
|
||||
allowedBrands: Yup.array().of(Yup.string()).optional(),
|
||||
allowedUnits: Yup.array().of(Yup.string()).optional(),
|
||||
directAssetTypes: Yup.array().of(Yup.string()).optional(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Layers, RefreshCw, Save, Check, Plus, ArrowRight } from 'lucide-react';
|
||||
import { notify } from '../../../services/toast/index';
|
||||
|
||||
interface MappingRow {
|
||||
id: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
transformationType: string;
|
||||
defaultValue: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SHOPIFY_MAPPINGS: MappingRow[] = [
|
||||
{ id: 'm1', sourcePath: 'content.name', targetPath: 'title', transformationType: 'string', defaultValue: '', required: true },
|
||||
{ id: 'm2', sourcePath: 'content.description', targetPath: 'bodyHtml', transformationType: 'string', defaultValue: '', required: false },
|
||||
{ id: 'm3', sourcePath: 'content.status', targetPath: 'status', transformationType: 'uppercase', defaultValue: 'DRAFT', required: true },
|
||||
{ id: 'm4', sourcePath: 'taxonomy.brand.name', targetPath: 'vendor', transformationType: 'string', defaultValue: 'Generic', required: false },
|
||||
{ id: 'm5', sourcePath: 'taxonomy.category.name', targetPath: 'productType', transformationType: 'string', defaultValue: 'General', required: false },
|
||||
{ id: 'm6', sourcePath: 'variants.sku', targetPath: 'variants.sku', transformationType: 'string', defaultValue: '', required: true },
|
||||
{ id: 'm7', sourcePath: 'variants.price', targetPath: 'variants.price', transformationType: 'currency_format', defaultValue: '0.00', required: true }
|
||||
];
|
||||
|
||||
export default function FieldMappingsTab() {
|
||||
const [mappings, setMappings] = useState<MappingRow[]>(DEFAULT_SHOPIFY_MAPPINGS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
setSaving(true);
|
||||
setTimeout(() => {
|
||||
setSaving(false);
|
||||
notify.success('Field mappings updated successfully!');
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const handleAddMapping = () => {
|
||||
const newId = `m_${Date.now()}`;
|
||||
setMappings([
|
||||
...mappings,
|
||||
{ id: newId, sourcePath: 'attributes.', targetPath: 'metafields.', transformationType: 'string', defaultValue: '', required: false }
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between bg-background p-4 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground text-sm">Canonical PIM Attribute Mapping Schema</h3>
|
||||
<p className="text-xs text-muted-foreground">Map canonical product attributes to target channel GraphQL/REST properties</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddMapping}
|
||||
className="px-3 py-1.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add Mapping
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shadow-2xs flex items-center gap-1 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{saving ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />} Save Schema
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-xl overflow-hidden bg-surface shadow-2xs">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-background border-b border-border text-muted-foreground font-semibold">
|
||||
<th className="py-3 px-4">Canonical Source Path (PIM)</th>
|
||||
<th className="py-3 px-2 text-center">Transform</th>
|
||||
<th className="py-3 px-4">Channel Target Path (Shopify)</th>
|
||||
<th className="py-3 px-4">Transformation Type</th>
|
||||
<th className="py-3 px-4">Default Value</th>
|
||||
<th className="py-3 px-4 text-center">Required</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border font-medium">
|
||||
{mappings.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-background/50 transition-colors">
|
||||
<td className="py-2.5 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={m.sourcePath}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, sourcePath: val } : p));
|
||||
}}
|
||||
className="w-full font-mono text-[11px] bg-background border border-border rounded px-2.5 py-1 text-foreground focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2.5 px-2 text-center text-muted-foreground">
|
||||
<ArrowRight className="w-4 h-4 mx-auto text-primary" />
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={m.targetPath}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, targetPath: val } : p));
|
||||
}}
|
||||
className="w-full font-mono text-[11px] bg-background border border-border rounded px-2.5 py-1 text-foreground focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<select
|
||||
value={m.transformationType}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, transformationType: val } : p));
|
||||
}}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1 text-xs text-foreground focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="string">string (direct)</option>
|
||||
<option value="uppercase">uppercase</option>
|
||||
<option value="lowercase">lowercase</option>
|
||||
<option value="currency_format">currency_format</option>
|
||||
<option value="json_stringify">json_stringify</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={m.defaultValue}
|
||||
placeholder="—"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, defaultValue: val } : p));
|
||||
}}
|
||||
className="w-full font-mono text-[11px] bg-background border border-border rounded px-2 py-1 text-foreground focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={m.required}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, required: checked } : p));
|
||||
}}
|
||||
className="rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle2, AlertTriangle, XCircle, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface IntegrationHealthBadgeProps {
|
||||
status?: string;
|
||||
healthStatus?: string;
|
||||
}
|
||||
|
||||
export const IntegrationHealthBadge: React.FC<IntegrationHealthBadgeProps> = ({ status, healthStatus }) => {
|
||||
const normalizedStatus = (status || healthStatus || 'active').toLowerCase();
|
||||
|
||||
if (normalizedStatus === 'healthy' || normalizedStatus === 'active' || normalizedStatus === 'connected') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" />
|
||||
Healthy
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'syncing' || normalizedStatus === 'processing') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-50 text-blue-700 border border-blue-200">
|
||||
<RefreshCw className="w-3.5 h-3.5 text-blue-600 animate-spin" />
|
||||
Syncing
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'degraded' || normalizedStatus === 'rate_limited' || normalizedStatus === 'pending') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-50 text-amber-700 border border-amber-200">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-600" />
|
||||
{normalizedStatus === 'rate_limited' ? 'Rate Limited' : 'Pending'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-red-50 text-red-700 border border-red-200">
|
||||
<XCircle className="w-3.5 h-3.5 text-red-600" />
|
||||
Error
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { ShoppingCart, ShoppingBag, Globe, Code2, Plus, CheckCircle2, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface IntegrationTemplateGalleryProps {
|
||||
onSelectShopify: () => void;
|
||||
onSelectCustomApi: () => void;
|
||||
}
|
||||
|
||||
export const IntegrationTemplateGallery: React.FC<IntegrationTemplateGalleryProps> = ({
|
||||
onSelectShopify,
|
||||
onSelectCustomApi
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-primary/5 via-surface to-emerald-500/5 border border-border rounded-xl p-6 mb-8 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
|
||||
Pre-Built Channel Templates
|
||||
<span className="text-[10px] font-extrabold uppercase bg-primary text-white px-2 py-0.5 rounded-full">
|
||||
Zero Config
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">Select a channel template to connect in 1 click using native GraphQL/REST capability adapters</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Shopify Template Card */}
|
||||
<div className="bg-surface border-2 border-emerald-500/30 hover:border-emerald-500 rounded-xl p-4 transition-all shadow-2xs group relative flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-50 border border-emerald-200 flex items-center justify-center text-emerald-600 font-bold">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-emerald-700 bg-emerald-100 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> Ready
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-foreground text-sm group-hover:text-emerald-700 transition-colors">Shopify GraphQL</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
Sync products, variants, assets & inventory via Shopify Admin API v2025-01 with cost bucket management.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectShopify}
|
||||
className="mt-4 w-full py-2 px-3 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-semibold shadow-2xs flex items-center justify-center gap-1.5 cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Setup Shopify
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Amazon Template Card */}
|
||||
<div className="bg-surface/60 border border-border rounded-xl p-4 transition-all shadow-2xs opacity-80 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 font-bold">
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-muted-foreground bg-surface border border-border px-2 py-0.5 rounded-full">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-foreground text-sm">Amazon SP-API</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
Syndicate ASIN listings, FBA inventory and pricing updates via Amazon Selling Partner API.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="mt-4 w-full py-2 px-3 bg-surface border border-border text-muted-foreground rounded-lg text-xs font-semibold cursor-not-allowed opacity-60 flex items-center justify-center gap-1"
|
||||
>
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* WooCommerce Template Card */}
|
||||
<div className="bg-surface/60 border border-border rounded-xl p-4 transition-all shadow-2xs opacity-80 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-50 border border-purple-200 flex items-center justify-center text-purple-600 font-bold">
|
||||
<Globe className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-muted-foreground bg-surface border border-border px-2 py-0.5 rounded-full">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-foreground text-sm">WooCommerce REST</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
Push PIM canonical catalog to WordPress WooCommerce stores via REST API v3.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="mt-4 w-full py-2 px-3 bg-surface border border-border text-muted-foreground rounded-lg text-xs font-semibold cursor-not-allowed opacity-60 flex items-center justify-center gap-1"
|
||||
>
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom API Card */}
|
||||
<div className="bg-surface border border-border hover:border-primary/50 rounded-xl p-4 transition-all shadow-2xs group flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-bold">
|
||||
<Code2 className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded-full">
|
||||
Custom Wizard
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-foreground text-sm group-hover:text-primary transition-colors">Custom API / Webhook</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
Configure multi-step generic REST/GraphQL endpoints with custom headers and transformations.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectCustomApi}
|
||||
className="mt-4 w-full py-2 px-3 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs flex items-center justify-center gap-1.5 cursor-pointer transition-colors"
|
||||
>
|
||||
Custom Wizard <ArrowRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Eye, EyeOff, Key, Globe, Zap, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { useIntegration } from '../hook/useIntegration';
|
||||
import { integrationsService } from '../services/integrations.service';
|
||||
|
||||
interface ShopifyCredentialCardProps {
|
||||
integrationId: string;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export const ShopifyCredentialCard: React.FC<ShopifyCredentialCardProps> = ({ integrationId, onSaved }) => {
|
||||
const [authMode, setAuthMode] = useState<'private_app' | 'custom_app'>('private_app');
|
||||
const [shopDomain, setShopDomain] = useState('');
|
||||
|
||||
// Private app fields
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [apiSecret, setApiSecret] = useState('');
|
||||
const [storefrontToken, setStorefrontToken] = useState('');
|
||||
|
||||
// Custom app token
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [testResult, setTestResult] = useState<any>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [fetchingCreds, setFetchingCreds] = useState(false);
|
||||
|
||||
const { setCredentials, testConnection, loading, testingConnection } = useIntegration();
|
||||
|
||||
// Pre-fill existing credentials for this specific integration
|
||||
useEffect(() => {
|
||||
if (!integrationId) return;
|
||||
setFetchingCreds(true);
|
||||
integrationsService.getCredentials(integrationId)
|
||||
.then(creds => {
|
||||
if (creds.shop_domain) setShopDomain(creds.shop_domain);
|
||||
if (creds.api_key) setApiKey(creds.api_key);
|
||||
if (creds.api_secret_key) setApiSecret(creds.api_secret_key);
|
||||
if (creds.access_token) {
|
||||
setAccessToken(creds.access_token);
|
||||
if (creds.access_token.startsWith('shpat_')) {
|
||||
setAuthMode('custom_app');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setFetchingCreds(false));
|
||||
}, [integrationId]);
|
||||
|
||||
const handleSaveCredentials = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!shopDomain) return;
|
||||
|
||||
try {
|
||||
await setCredentials(integrationId, 'shop_domain', shopDomain.trim());
|
||||
|
||||
if (authMode === 'private_app') {
|
||||
if (apiKey) await setCredentials(integrationId, 'api_key', apiKey.trim());
|
||||
if (apiSecret) await setCredentials(integrationId, 'api_secret_key', apiSecret.trim());
|
||||
if (storefrontToken) await setCredentials(integrationId, 'access_token', storefrontToken.trim());
|
||||
} else {
|
||||
if (accessToken) await setCredentials(integrationId, 'access_token', accessToken.trim());
|
||||
}
|
||||
|
||||
if (onSaved) onSaved();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTestResult(null);
|
||||
setTestError(null);
|
||||
try {
|
||||
const res = await testConnection(integrationId);
|
||||
setTestResult(res);
|
||||
} catch (err: any) {
|
||||
setTestError(err?.response?.data?.message || err?.message || 'Connection failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-border rounded-xl p-6 shadow-sm space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-emerald-50 rounded-lg text-emerald-600">
|
||||
<Globe className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground text-sm flex items-center gap-2">
|
||||
Shopify Admin API Credentials
|
||||
{fetchingCreds && <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">Configure credentials for GraphQL product syndication</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 text-[10px] font-bold bg-primary/10 text-primary rounded">GraphQL Admin 2025-01</span>
|
||||
</div>
|
||||
|
||||
{/* Auth Mode Toggle */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-2">Authentication Mode</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className={`p-2.5 border rounded-lg cursor-pointer text-xs transition-all ${authMode === 'private_app' ? 'border-primary bg-primary/5 text-primary font-bold' : 'border-border bg-background text-muted-foreground'}`}>
|
||||
<input type="radio" name="credAuthMode" className="sr-only" checked={authMode === 'private_app'} onChange={() => setAuthMode('private_app')} />
|
||||
Private App (API Key + Secret)
|
||||
</label>
|
||||
<label className={`p-2.5 border rounded-lg cursor-pointer text-xs transition-all ${authMode === 'custom_app' ? 'border-primary bg-primary/5 text-primary font-bold' : 'border-border bg-background text-muted-foreground'}`}>
|
||||
<input type="radio" name="credAuthMode" className="sr-only" checked={authMode === 'custom_app'} onChange={() => setAuthMode('custom_app')} />
|
||||
Custom App (shpat_ Token)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-4">
|
||||
{/* Shop Domain */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
Store Domain <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="9xarg3-gj.myshopify.com"
|
||||
value={shopDomain}
|
||||
onChange={(e) => setShopDomain(e.target.value.replace(/^https?:\/\//, '').replace(/\/$/, ''))}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pl-8 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
<Globe className="w-4 h-4 text-muted-foreground absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Private App Fields */}
|
||||
{authMode === 'private_app' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="6ed762d39bbeb6eef41669057976b331"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
API Secret Key (Password) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
placeholder="API Secret / shpss_ token as password"
|
||||
value={apiSecret}
|
||||
onChange={(e) => setApiSecret(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pl-8 pr-10 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<Key className="w-4 h-4 text-muted-foreground absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<button type="button" onClick={() => setShowSecret(v => !v)} className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
For private apps: use the Shopify <strong>API secret key</strong> or <strong>shpss_ storefront token</strong> as password for Basic Auth
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
Storefront Token (Optional, shpss_...)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="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"
|
||||
value={storefrontToken}
|
||||
onChange={(e) => setStorefrontToken(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Custom App Token */}
|
||||
{authMode === 'custom_app' && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
Admin API Access Token <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
placeholder="shpat_xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
value={accessToken}
|
||||
onChange={(e) => setAccessToken(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pl-8 pr-10 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<Key className="w-4 h-4 text-muted-foreground absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<button type="button" onClick={() => setShowSecret(v => !v)} className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">Stored using AES-256-GCM encryption</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult?.connected && (
|
||||
<div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg flex items-start gap-2 text-xs text-emerald-800 font-medium">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span>Connected to <strong>{testResult.shopName}</strong></span>
|
||||
{testResult.plan && <span className="ml-1 text-emerald-700">· {testResult.plan}</span>}
|
||||
{testResult.email && <div className="text-[11px] mt-0.5">{testResult.email}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{testError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2 text-xs text-red-800">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<span>{testError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shadow-sm flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
Save Credentials
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testingConnection}
|
||||
className="px-4 py-2 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-sm flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{testingConnection ? <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" /> : <Zap className="w-3.5 h-3.5 text-amber-500" />}
|
||||
Test Connection
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,370 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, ShoppingCart, Zap, Key, Globe, Loader2, CheckCircle2, AlertCircle, Eye, EyeOff, ExternalLink, ArrowLeft } from 'lucide-react';
|
||||
import { useIntegration } from '../hook/useIntegration';
|
||||
import { integrationsService } from '../services/integrations.service';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
interface ShopifyTemplateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const ShopifyTemplateModal: React.FC<ShopifyTemplateModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess
|
||||
}) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [name, setName] = useState('Shopify Main Store');
|
||||
const [shopDomain, setShopDomain] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [apiSecret, setApiSecret] = useState('');
|
||||
const [syncMode, setSyncMode] = useState<'auto' | 'manual'>('manual');
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
const [savedIntegrationId, setSavedIntegrationId] = useState<string | null>(null);
|
||||
const [credentialsSaved, setCredentialsSaved] = useState(false);
|
||||
const [oauthConnecting, setOauthConnecting] = useState(false);
|
||||
const [oauthSuccess, setOauthSuccess] = useState(false);
|
||||
const [testResult, setTestResult] = useState<any>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [savingCreds, setSavingCreds] = useState(false);
|
||||
|
||||
const { createItem, setCredentials, testConnection, testingConnection } = useIntegration();
|
||||
|
||||
// Handle OAuth callback redirect back from Shopify
|
||||
useEffect(() => {
|
||||
const oauthStatus = searchParams.get('oauth');
|
||||
const intId = searchParams.get('integrationId');
|
||||
const shop = searchParams.get('shop');
|
||||
if (oauthStatus === 'success' && intId) {
|
||||
setCredentialsSaved(true);
|
||||
setOauthSuccess(true);
|
||||
setSavedIntegrationId(intId);
|
||||
if (shop) setShopDomain(shop);
|
||||
onSuccess?.();
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSaveCredentials = async () => {
|
||||
if (!name || !shopDomain || !apiKey || !apiSecret) return;
|
||||
setSavingCreds(true);
|
||||
try {
|
||||
let cleanDomain = shopDomain.trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
let cleanKey = apiKey.trim();
|
||||
let cleanSecret = apiSecret.trim();
|
||||
|
||||
// Auto-correct if user accidentally swapped shop domain and API key
|
||||
if (cleanKey.includes('.myshopify.com') && !cleanDomain.includes('.myshopify.com')) {
|
||||
const temp = cleanDomain;
|
||||
cleanDomain = cleanKey;
|
||||
cleanKey = temp;
|
||||
setShopDomain(cleanDomain);
|
||||
setApiKey(cleanKey);
|
||||
}
|
||||
|
||||
if (cleanDomain && !cleanDomain.includes('.')) {
|
||||
cleanDomain = `${cleanDomain}.myshopify.com`;
|
||||
setShopDomain(cleanDomain);
|
||||
}
|
||||
|
||||
// Step 1: Create or reuse integration record
|
||||
let integrationId = savedIntegrationId;
|
||||
if (!integrationId) {
|
||||
const created = await createItem({
|
||||
name,
|
||||
channel: 'shopify',
|
||||
integration_type: 'ecommerce',
|
||||
sync_mode: syncMode,
|
||||
sync_frequency: syncMode === 'auto' ? 'realtime' : 'manual',
|
||||
status: 'pending'
|
||||
});
|
||||
integrationId = created.id;
|
||||
setSavedIntegrationId(integrationId);
|
||||
}
|
||||
|
||||
// Step 2: Store credentials encrypted
|
||||
await setCredentials(integrationId!, 'shop_domain', cleanDomain);
|
||||
await setCredentials(integrationId!, 'api_key', cleanKey);
|
||||
await setCredentials(integrationId!, 'api_secret_key', cleanSecret);
|
||||
|
||||
setCredentialsSaved(true);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSavingCreds(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartOAuth = async () => {
|
||||
if (!savedIntegrationId) return;
|
||||
setOauthConnecting(true);
|
||||
try {
|
||||
const result = await integrationsService.startShopifyOAuth(savedIntegrationId);
|
||||
// Open Shopify auth page in new tab
|
||||
window.open(result.authorizationUrl, '_blank', 'width=1000,height=700,scrollbars=yes');
|
||||
} catch (err: any) {
|
||||
setTestError(err?.response?.data?.message || err?.message || 'Failed to start OAuth');
|
||||
} finally {
|
||||
setOauthConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!savedIntegrationId) return;
|
||||
setTestResult(null);
|
||||
setTestError(null);
|
||||
try {
|
||||
const res = await testConnection(savedIntegrationId);
|
||||
setTestResult(res);
|
||||
} catch (err: any) {
|
||||
setTestError(err?.response?.data?.message || err?.message || 'Connection test failed');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setName('Shopify Main Store');
|
||||
setShopDomain('');
|
||||
setApiKey('');
|
||||
setApiSecret('');
|
||||
setSavedIntegrationId(null);
|
||||
setCredentialsSaved(false);
|
||||
setOauthSuccess(false);
|
||||
setTestResult(null);
|
||||
setTestError(null);
|
||||
};
|
||||
|
||||
const step = !credentialsSaved ? 1 : !oauthSuccess ? 2 : 3;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-emerald-600 to-teal-700 p-5 text-white relative">
|
||||
<button type="button" onClick={() => { onClose(); resetForm(); }} className="absolute top-4 right-4 text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/10 cursor-pointer">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-xl bg-white/10 border border-white/20 flex items-center justify-center">
|
||||
<ShoppingCart className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold">Shopify Integration Setup</h2>
|
||||
<p className="text-xs text-white/70">Partners Dashboard OAuth 2.0 · Admin API 2025-01</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="flex items-center border-b border-border px-6 pt-4 pb-3 gap-0">
|
||||
{[
|
||||
{ n: 1, label: 'Store Details & Keys' },
|
||||
{ n: 2, label: 'Authorize via OAuth' },
|
||||
{ n: 3, label: 'Test & Activate' }
|
||||
].map((s, i) => (
|
||||
<React.Fragment key={s.n}>
|
||||
<div className={`flex items-center gap-1.5 ${step >= s.n ? 'text-primary' : 'text-muted-foreground'}`}>
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold border-2 ${step > s.n ? 'bg-primary border-primary text-white' : step === s.n ? 'border-primary text-primary' : 'border-border text-muted-foreground'}`}>
|
||||
{step > s.n ? <CheckCircle2 className="w-3.5 h-3.5" /> : s.n}
|
||||
</div>
|
||||
<span className="text-xs font-medium hidden sm:block">{s.label}</span>
|
||||
</div>
|
||||
{i < 2 && <div className={`flex-1 h-px mx-3 ${step > s.n ? 'bg-primary' : 'bg-border'}`} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4 overflow-y-auto max-h-[65vh]">
|
||||
{/* Step 1: Store Details */}
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-xs text-blue-900">
|
||||
<p className="font-semibold mb-1">📍 Finding your Client ID & Client Secret:</p>
|
||||
<p>Go to <a href="https://partners.shopify.com" target="_blank" rel="noreferrer" className="underline font-bold">partners.shopify.com</a> → <strong>Apps</strong> → Select your app (<strong>PIM Integration</strong>) → <strong>App setup</strong> → Copy the <strong>Client ID</strong> and <strong>Client secret</strong> under <i>API credentials</i>.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">Integration Name <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} className="w-full text-sm bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary" placeholder="Shopify Main Store" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">Shop Domain <span className="text-red-500">*</span></label>
|
||||
<div className="relative">
|
||||
<Globe className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="maskcomerce.myshopify.com"
|
||||
value={shopDomain}
|
||||
onChange={e => setShopDomain(e.target.value)}
|
||||
className="w-full text-sm font-mono bg-background border border-border rounded-lg px-3 py-2 pl-9 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">API Key (Client ID) <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="6ed762d39bbeb6eef416..."
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">API Secret (Client Secret) <span className="text-red-500">*</span></label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
placeholder="shpss_9dc647b3cd13de8..."
|
||||
value={apiSecret}
|
||||
onChange={e => setApiSecret(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pr-9 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
<button type="button" onClick={() => setShowSecret(v => !v)} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer">
|
||||
{showSecret ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-2">Sync Mode</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(['manual', 'auto'] as const).map(mode => (
|
||||
<label key={mode} className={`p-3 border rounded-lg cursor-pointer text-xs transition-all ${syncMode === mode ? 'border-primary bg-primary/5 text-primary font-bold' : 'border-border bg-background text-muted-foreground'}`}>
|
||||
<input type="radio" name="syncMode" className="sr-only" checked={syncMode === mode} onChange={() => setSyncMode(mode)} />
|
||||
{mode === 'manual' ? 'Manual Trigger' : 'Automatic (Outbox)'}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveCredentials}
|
||||
disabled={savingCreds || !shopDomain || !apiKey || !apiSecret || !name}
|
||||
className="w-full py-2.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold shadow-sm flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{savingCreds && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Save & Continue to Authorize
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 2: OAuth Authorization */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm text-amber-900">
|
||||
<p className="font-bold mb-2 flex items-center gap-2">
|
||||
<ExternalLink className="w-4 h-4" /> Allowed Redirection URLs in Partners Dashboard:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-1.5 text-xs">
|
||||
<li>Go to your <strong>Shopify Partners Dashboard</strong> → Apps → <strong>PIM Integration</strong> → App setup</li>
|
||||
<li>Under <strong>"Allowed redirection URL(s)"</strong>, add this URL:
|
||||
<code className="bg-amber-100 rounded px-1 py-0.5 text-[11px] font-mono block mt-1 font-bold">http://localhost:5002/api/v1/integrations/shopify/oauth/callback</code>
|
||||
<span className="text-[11px] text-amber-800 block mt-0.5">(If using port 5000, add <code>http://localhost:5000/api/v1/integrations/shopify/oauth/callback</code> as well)</span>
|
||||
</li>
|
||||
<li>Click <strong>Save</strong> in Partners Dashboard, then click Authorize below.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="bg-background border border-border rounded-xl p-4 space-y-2 text-xs">
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Shop:</span><span className="font-mono font-semibold">{shopDomain}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">API Key:</span><span className="font-mono">{apiKey.slice(0, 12)}...</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Scopes:</span><span className="text-emerald-700">read/write_product_feeds, read/write_product_listings, read/write_products</span></div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCredentialsSaved(false)}
|
||||
className="px-4 py-3 bg-surface border border-border hover:bg-background text-foreground rounded-xl text-xs font-semibold flex items-center justify-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Edit Details & Keys
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartOAuth}
|
||||
disabled={oauthConnecting}
|
||||
className="flex-1 py-3 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-sm font-bold shadow-sm flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{oauthConnecting ? <Loader2 className="w-4 h-4 animate-spin" /> : <ExternalLink className="w-4 h-4" />}
|
||||
Authorize Shopify Access
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
After authorizing in the new tab, this modal will automatically update.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Connected — Test + Done */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-emerald-50 border border-emerald-200 rounded-xl flex items-center gap-3 text-emerald-800">
|
||||
<CheckCircle2 className="w-7 h-7 text-emerald-600 shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-sm">Shopify Access Authorized!</p>
|
||||
<p className="text-xs mt-0.5">Access token saved securely. Your store is ready for product syndication.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(testResult || testError) && (
|
||||
<div className={`p-3 rounded-lg flex items-start gap-2 text-xs font-medium border ${testResult?.connected ? 'bg-emerald-50 border-emerald-200 text-emerald-800' : 'bg-red-50 border-red-200 text-red-800'}`}>
|
||||
{testResult?.connected
|
||||
? <CheckCircle2 className="w-4 h-4 text-emerald-600 shrink-0 mt-0.5" />
|
||||
: <AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
}
|
||||
<div>
|
||||
{testResult?.connected
|
||||
? <><strong>{testResult.shopName}</strong>{testResult.plan && ` · ${testResult.plan}`}{testResult.email && <div className="text-[11px] mt-0.5">{testResult.email}</div>}</>
|
||||
: testError
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOauthSuccess(false); setCredentialsSaved(false); }}
|
||||
className="px-3 py-2.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" /> Re-configure
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testingConnection}
|
||||
className="flex-1 py-2.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{testingConnection ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Zap className="w-3.5 h-3.5 text-amber-500" />}
|
||||
Test Connection
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onSuccess?.(); onClose(); resetForm(); }}
|
||||
className="flex-1 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold flex items-center justify-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Done — View Integrations
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X, CheckCircle2, AlertTriangle, Clock, RefreshCw, Layers } from 'lucide-react';
|
||||
import { useIntegration } from '../hook/useIntegration';
|
||||
import type { SyncItem } from '../types/integrations.types';
|
||||
|
||||
interface SyncJobStatusModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
jobId: string;
|
||||
integrationName?: string;
|
||||
}
|
||||
|
||||
export const SyncJobStatusModal: React.FC<SyncJobStatusModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
jobId,
|
||||
integrationName
|
||||
}) => {
|
||||
const [items, setItems] = useState<SyncItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { getSyncItems } = useIntegration();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && jobId) {
|
||||
setLoading(true);
|
||||
getSyncItems(jobId)
|
||||
.then(res => setItems(res))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [isOpen, jobId, getSyncItems]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const total = items.length;
|
||||
const successCount = items.filter(i => i.status === 'success').length;
|
||||
const failedCount = items.filter(i => i.status === 'failed').length;
|
||||
const pendingCount = items.filter(i => i.status === 'pending' || i.status === 'processing').length;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-xs flex items-center justify-center p-4">
|
||||
<div className="bg-surface border border-border rounded-xl shadow-xl w-full max-w-3xl overflow-hidden flex flex-col max-h-[85vh] animate-scale-in">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between bg-background/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground text-sm">Sync Execution Telemetry</h3>
|
||||
<p className="text-xs text-muted-foreground">{integrationName || 'Integration Sync Run'} • Job #{jobId.slice(0, 8)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-background rounded-lg text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats summary */}
|
||||
<div className="grid grid-cols-4 gap-3 p-4 bg-background border-b border-border text-center text-xs">
|
||||
<div className="p-2.5 bg-surface border border-border rounded-lg">
|
||||
<span className="text-muted-foreground font-semibold block">Total Scope</span>
|
||||
<span className="text-sm font-bold text-foreground">{total}</span>
|
||||
</div>
|
||||
<div className="p-2.5 bg-emerald-50 border border-emerald-200 rounded-lg">
|
||||
<span className="text-emerald-700 font-semibold block">Successful</span>
|
||||
<span className="text-sm font-bold text-emerald-800">{successCount}</span>
|
||||
</div>
|
||||
<div className="p-2.5 bg-red-50 border border-red-200 rounded-lg">
|
||||
<span className="text-red-700 font-semibold block">Failed</span>
|
||||
<span className="text-sm font-bold text-red-800">{failedCount}</span>
|
||||
</div>
|
||||
<div className="p-2.5 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<span className="text-blue-700 font-semibold block">In Progress</span>
|
||||
<span className="text-sm font-bold text-blue-800">{pendingCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item table */}
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-xs text-muted-foreground gap-2">
|
||||
<RefreshCw className="w-4 h-4 animate-spin text-primary" />
|
||||
<span>Fetching telemetry items...</span>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-10 text-xs text-muted-foreground">
|
||||
No sync items logged for this job run yet.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground font-semibold">
|
||||
<th className="py-2 px-3">Product Name & SKU</th>
|
||||
<th className="py-2 px-3">Operation</th>
|
||||
<th className="py-2 px-3">Status</th>
|
||||
<th className="py-2 px-3 text-center">Attempts</th>
|
||||
<th className="py-2 px-3 text-right">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border font-medium">
|
||||
{items.map((item: any) => (
|
||||
<tr key={item.id} className="hover:bg-background/50 transition-colors">
|
||||
<td className="py-2.5 px-3">
|
||||
<div className="font-bold text-foreground">{item.product?.name || `Product #${item.product_id.slice(0, 8)}`}</div>
|
||||
<div className="text-[11px] font-mono text-muted-foreground">{item.sku || item.product?.sku || item.product_id}</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-bold bg-surface border border-border text-foreground">
|
||||
{item.operation}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{item.status === 'success' ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600 font-bold text-[11px]">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Success
|
||||
</span>
|
||||
) : item.status === 'failed' ? (
|
||||
<span className="inline-flex items-center gap-1 text-red-600 font-bold text-[11px]">
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> Failed
|
||||
</span>
|
||||
) : item.status === 'skipped' ? (
|
||||
<span className="inline-flex items-center gap-1 text-amber-600 font-bold text-[11px]">
|
||||
<Clock className="w-3.5 h-3.5" /> Skipped
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-blue-600 font-bold text-[11px]">
|
||||
<Clock className="w-3.5 h-3.5 animate-spin" /> {item.status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 font-mono text-center">{item.attempt_count}</td>
|
||||
<td className="py-2.5 px-3 text-right text-muted-foreground truncate max-w-[220px]" title={item.error_message || 'Synced'}>
|
||||
{item.error_message ? <span className="text-red-500 font-semibold">{item.error_message}</span> : <span className="text-emerald-600 font-semibold">Synced to Store</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-border bg-background/50 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,159 +1,126 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { RefreshCw, Download } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw, Download, Layers } from "lucide-react";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { StatusBadge, type BadgeVariant } from "../../../components/customs/StatusBadge";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useIntegration } from "../hook/useIntegration";
|
||||
import { SyncJobStatusModal } from "./SyncJobStatusModal";
|
||||
import type { SyncJob } from "../types/integrations.types";
|
||||
|
||||
const MOCK_JOBS = [
|
||||
{
|
||||
id: "JOB-2891",
|
||||
integration: "Amazon India",
|
||||
type: "Full Sync",
|
||||
records: "4,240",
|
||||
success: "4,237",
|
||||
failed: "3",
|
||||
status: "Completed",
|
||||
started: "2025-06-09 14:00",
|
||||
completed: "2025-06-09 14:32",
|
||||
duration: "32m 14s",
|
||||
triggered: "Scheduled"
|
||||
},
|
||||
{
|
||||
id: "JOB-2892",
|
||||
integration: "Shopify Main Store",
|
||||
type: "Delta Sync",
|
||||
records: "128",
|
||||
success: "128",
|
||||
failed: "0",
|
||||
status: "Running",
|
||||
started: "2025-06-09 14:30",
|
||||
completed: "",
|
||||
duration: "In progress",
|
||||
triggered: "Realtime trigger"
|
||||
},
|
||||
{
|
||||
id: "JOB-2890",
|
||||
integration: "Amazon UAE",
|
||||
type: "Full Sync",
|
||||
records: "1,840",
|
||||
success: "1,840",
|
||||
failed: "0",
|
||||
status: "Completed",
|
||||
started: "2025-06-09 13:00",
|
||||
completed: "2025-06-09 13:15",
|
||||
duration: "15m 02s",
|
||||
triggered: "Scheduled"
|
||||
},
|
||||
{
|
||||
id: "JOB-2889",
|
||||
integration: "Warehouse WMS",
|
||||
type: "Inventory Pull",
|
||||
records: "284",
|
||||
success: "0",
|
||||
failed: "284",
|
||||
status: "Failed",
|
||||
started: "2025-06-08 08:00",
|
||||
completed: "2025-06-08 08:03",
|
||||
duration: "3m 12s",
|
||||
triggered: "Scheduled"
|
||||
},
|
||||
{
|
||||
id: "JOB-2888",
|
||||
integration: "Retail POS Network",
|
||||
type: "Catalogue Sync",
|
||||
records: "3,240",
|
||||
success: "3,240",
|
||||
failed: "0",
|
||||
status: "Completed",
|
||||
started: "2025-06-09 12:00",
|
||||
completed: "2025-06-09 12:18",
|
||||
duration: "18m 40s",
|
||||
triggered: "Scheduled"
|
||||
},
|
||||
{
|
||||
id: "JOB-2887",
|
||||
integration: "Amazon India",
|
||||
type: "Price Update",
|
||||
records: "521",
|
||||
success: "521",
|
||||
failed: "0",
|
||||
status: "Cancelled",
|
||||
started: "2025-06-08 18:00",
|
||||
completed: "2025-06-08 18:01",
|
||||
duration: "1m 04s",
|
||||
triggered: "Manual"
|
||||
},
|
||||
{
|
||||
id: "JOB-2892",
|
||||
integration: "Shopify Main Store",
|
||||
type: "Realtime Sync",
|
||||
records: "128",
|
||||
success: "128",
|
||||
failed: "0",
|
||||
status: "Completed",
|
||||
started: "2026-08-30 04:30",
|
||||
completed: "2026-08-30 04:31",
|
||||
duration: "1m 02s",
|
||||
triggered: "Outbox Trigger"
|
||||
}
|
||||
];
|
||||
|
||||
export default function SyncJobsList() {
|
||||
const navigate = useNavigate();
|
||||
const [jobs, setJobs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
||||
|
||||
const columns = [
|
||||
{ key: "id", label: "JOB ID", render: (val: string) => <span className="font-mono text-primary font-medium">{val}</span> },
|
||||
{ key: "integration", label: "INTEGRATION" },
|
||||
{ key: "type", label: "JOB TYPE" },
|
||||
{
|
||||
key: "records",
|
||||
label: "RECORDS",
|
||||
render: (_: any, row: any) => (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold text-foreground">{row.records}</span>
|
||||
{row.failed && parseInt(row.failed) > 0 && (
|
||||
<span className="text-red-600 text-xs ml-1">({row.failed} failed)</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "STATUS",
|
||||
render: (val: string) => {
|
||||
let variant: BadgeVariant = "neutral";
|
||||
if (val === "Completed") variant = "success";
|
||||
if (val === "Running") variant = "warning";
|
||||
if (val === "Failed") variant = "error";
|
||||
if (val === "Cancelled") variant = "neutral";
|
||||
const { getAllSyncJobs } = useIntegration();
|
||||
|
||||
return <StatusBadge status={variant} label={val} />;
|
||||
},
|
||||
},
|
||||
{ key: "started", label: "STARTED AT" },
|
||||
{ key: "completed", label: "COMPLETED AT" },
|
||||
{ key: "duration", label: "DURATION" },
|
||||
{ key: "triggered", label: "TRIGGERED BY" },
|
||||
];
|
||||
const fetchJobs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await getAllSyncJobs();
|
||||
const mapped = list.map((j: any) => ({
|
||||
id: j.id,
|
||||
integration: j.integration?.name || 'Shopify Store',
|
||||
type: j.trigger_source === 'outbox' ? 'Realtime Outbox' : 'Manual Trigger',
|
||||
records: String(j.total_items || 0),
|
||||
success: String(j.success_items || 0),
|
||||
failed: String(j.failed_items || 0),
|
||||
status: j.status === 'completed' ? 'Completed' : j.status === 'failed' ? 'Failed' : 'Running',
|
||||
started: j.started_at ? new Date(j.started_at).toLocaleString() : '—',
|
||||
completed: j.completed_at ? new Date(j.completed_at).toLocaleString() : '—',
|
||||
duration: j.completed_at && j.started_at
|
||||
? `${Math.round((new Date(j.completed_at).getTime() - new Date(j.started_at).getTime()) / 1000)}s`
|
||||
: 'In progress',
|
||||
triggered: j.trigger_source || 'manual'
|
||||
}));
|
||||
setJobs(mapped);
|
||||
} catch (err) {
|
||||
console.error('Failed to load sync jobs:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
}, []);
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={MOCK_JOBS}
|
||||
actionConfig={{
|
||||
onView: (row) => navigate(`${row.id}/view`),
|
||||
}}
|
||||
searchPlaceholder="Search jobs..."
|
||||
toolbarLeft={
|
||||
<div className="flex gap-2">
|
||||
<select className="h-9 px-3 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-surface">
|
||||
<option>All Statuses</option>
|
||||
<option>Completed</option>
|
||||
<option>Running</option>
|
||||
<option>Failed</option>
|
||||
</select>
|
||||
<Button variant="outline" className="flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh Jobs
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
toolbarRight={
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export Log
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
const columns = [
|
||||
{ key: "id", label: "JOB ID", render: (val: string) => <span className="font-mono text-primary font-medium">{val.slice(0, 8)}</span> },
|
||||
{ key: "integration", label: "INTEGRATION" },
|
||||
{ key: "type", label: "JOB TYPE" },
|
||||
{
|
||||
key: "records",
|
||||
label: "RECORDS",
|
||||
render: (_: any, row: any) => (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold text-foreground">{row.records}</span>
|
||||
{row.failed && parseInt(row.failed) > 0 && (
|
||||
<span className="text-red-600 text-xs ml-1">({row.failed} failed)</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "STATUS",
|
||||
render: (val: string) => {
|
||||
let variant: BadgeVariant = "neutral";
|
||||
if (val === "Completed" || val === "completed") variant = "success";
|
||||
if (val === "Running" || val === "pending" || val === "processing") variant = "warning";
|
||||
if (val === "Failed" || val === "failed") variant = "error";
|
||||
|
||||
return <StatusBadge status={variant} label={val} />;
|
||||
},
|
||||
},
|
||||
{ key: "started", label: "STARTED AT" },
|
||||
{ key: "completed", label: "COMPLETED AT" },
|
||||
{ key: "duration", label: "DURATION" },
|
||||
{ key: "triggered", label: "TRIGGERED BY" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={jobs}
|
||||
actionConfig={{
|
||||
onView: (row) => setSelectedJobId(row.id),
|
||||
}}
|
||||
searchPlaceholder="Search jobs..."
|
||||
toolbarLeft={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={fetchJobs} className="flex items-center gap-2">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh Jobs
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{selectedJobId && (
|
||||
<SyncJobStatusModal
|
||||
isOpen={!!selectedJobId}
|
||||
onClose={() => setSelectedJobId(null)}
|
||||
jobId={selectedJobId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { integrationsService } from '../services/integrations.service';
|
||||
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest } from '../types/integrations.types';
|
||||
import type {
|
||||
Integration,
|
||||
IntegrationCreateRequest,
|
||||
IntegrationUpdateRequest,
|
||||
TestConnectionResult,
|
||||
SyncJob,
|
||||
SyncItem
|
||||
} from '../types/integrations.types';
|
||||
import { notify } from '../../../services/toast';
|
||||
|
||||
export const useIntegration = () => {
|
||||
const [items, setItems] = useState<Integration[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
const [triggeringSync, setTriggeringSync] = useState(false);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -63,5 +72,91 @@ export const useIntegration = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
|
||||
const testConnection = useCallback(async (id: string): Promise<TestConnectionResult> => {
|
||||
setTestingConnection(true);
|
||||
try {
|
||||
const res = await integrationsService.testConnection(id);
|
||||
if (res.connected) {
|
||||
notify.success(`Connected to Shopify store: ${res.shopName || res.shopDomain}`);
|
||||
}
|
||||
return res;
|
||||
} catch (err: any) {
|
||||
notify.error(err?.message || 'Failed to connect to Shopify store');
|
||||
throw err;
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setCredentials = useCallback(async (id: string, type: string, value: string, expiresAt?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await integrationsService.setCredentials(id, type, value, expiresAt);
|
||||
notify.success('Credentials configured securely!');
|
||||
return res;
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const triggerSync = useCallback(async (id: string, options?: { productId?: string; productIds?: string[] }) => {
|
||||
setTriggeringSync(true);
|
||||
try {
|
||||
const res = await integrationsService.triggerSync(id, options);
|
||||
notify.success(`Sync initialized for ${res?.totalItems || 1} product(s)!`);
|
||||
return res;
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
throw err;
|
||||
} finally {
|
||||
setTriggeringSync(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getSyncJobs = useCallback(async (id: string): Promise<SyncJob[]> => {
|
||||
try {
|
||||
return await integrationsService.getSyncJobs(id);
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getAllSyncJobs = useCallback(async (): Promise<SyncJob[]> => {
|
||||
try {
|
||||
return await integrationsService.getAllSyncJobs();
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getSyncItems = useCallback(async (jobId: string): Promise<SyncItem[]> => {
|
||||
try {
|
||||
return await integrationsService.getSyncItems(jobId);
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
testingConnection,
|
||||
triggeringSync,
|
||||
fetchItems,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
testConnection,
|
||||
setCredentials,
|
||||
triggerSync,
|
||||
getSyncJobs,
|
||||
getAllSyncJobs,
|
||||
getSyncItems
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,14 +9,18 @@ import {
|
||||
Code2,
|
||||
Monitor,
|
||||
Warehouse,
|
||||
Globe
|
||||
Globe,
|
||||
Zap,
|
||||
Play,
|
||||
Key,
|
||||
X,
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { useIntegration } from "../hook/useIntegration";
|
||||
import { useChannel } from "../../channels/hook/useChannel";
|
||||
@@ -24,15 +28,17 @@ import type { Integration } from "../types/integrations.types";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
import { StatsCard } from "../../../components/customs/StatsCard";
|
||||
|
||||
// Import Tab Components
|
||||
// Import Custom Feature Components & Pre-Built Templates
|
||||
import { IntegrationHealthBadge } from "../components/IntegrationHealthBadge";
|
||||
import { ShopifyCredentialCard } from "../components/ShopifyCredentialCard";
|
||||
import { IntegrationTemplateGallery } from "../components/IntegrationTemplateGallery";
|
||||
import { ShopifyTemplateModal } from "../components/ShopifyTemplateModal";
|
||||
import FieldMappingsList from "../components/FieldMappingsTab";
|
||||
import PublishingRulesList from "../components/PublishingRulesTab";
|
||||
import SyncJobsList from "../components/SyncJobsTab";
|
||||
import ErrorCenterList from "../components/ErrorCenterTab";
|
||||
import AuditLogsList from "../components/AuditLogsTab";
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
|
||||
const INTEGRATION_META: Record<string, { label: string; icon: any; color: string; bg: string }> = {
|
||||
ecommerce: { label: "E-Commerce", icon: ShoppingCart, color: "text-blue-600", bg: "bg-blue-50" },
|
||||
marketplace: { label: "Marketplace", icon: ShoppingBag, color: "text-orange-600", bg: "bg-orange-50" },
|
||||
@@ -47,14 +53,23 @@ const INTEGRATION_META: Record<string, { label: string; icon: any; color: string
|
||||
|
||||
export default function IntegrationList() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<"Connections" | "Publishing Rules" | "Sync Jobs" | "Error Center" | "Audit & Logs">("Connections");
|
||||
const [searchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState<"Connections" | "Field Mappings" | "Publishing Rules" | "Sync Jobs" | "Error Center" | "Audit & Logs">("Connections");
|
||||
const [statusFilter, setStatusFilter] = useState("All Status");
|
||||
const [typeFilter, setTypeFilter] = useState("All Types");
|
||||
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
|
||||
const [shopifyModalOpen, setShopifyModalOpen] = useState(false);
|
||||
const [credentialModal, setCredentialModal] = useState<{ isOpen: boolean; integrationId: string; name: string }>({
|
||||
isOpen: false,
|
||||
integrationId: "",
|
||||
name: ""
|
||||
});
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [syncingId, setSyncingId] = useState<string | null>(null);
|
||||
|
||||
const { items, fetchItems, loading, deleteItem } = useIntegration();
|
||||
const { items, fetchItems, loading, deleteItem, testConnection, triggerSync } = useIntegration();
|
||||
const { items: channels, fetchItems: fetchChannels } = useChannel();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,6 +77,13 @@ export default function IntegrationList() {
|
||||
fetchChannels();
|
||||
}, [fetchItems, fetchChannels]);
|
||||
|
||||
// Automatically open Shopify modal into Step 3 when returning from OAuth redirect
|
||||
useEffect(() => {
|
||||
if (searchParams.get('oauth') === 'success') {
|
||||
setShopifyModalOpen(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteModal.id) return;
|
||||
setIsDeleting(true);
|
||||
@@ -75,13 +97,37 @@ export default function IntegrationList() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnectionClick = async (id: string) => {
|
||||
setTestingId(id);
|
||||
try {
|
||||
await testConnection(id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerSyncClick = async (id: string) => {
|
||||
setSyncingId(id);
|
||||
try {
|
||||
await triggerSync(id);
|
||||
setActiveTab("Sync Jobs");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSyncingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "name",
|
||||
label: "Integration Name",
|
||||
sortable: true,
|
||||
render: (_: any, row: Integration) => {
|
||||
const meta = INTEGRATION_META[row.integrationType] || INTEGRATION_META.custom_api;
|
||||
const metaType = row.integrationType || 'ecommerce';
|
||||
const meta = INTEGRATION_META[metaType] || INTEGRATION_META.ecommerce;
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -90,7 +136,7 @@ export default function IntegrationList() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{row.name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{row.description || "No description provided"}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{row.channel ? `Channel: ${row.channel.toUpperCase()}` : "No description provided"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -101,61 +147,77 @@ export default function IntegrationList() {
|
||||
label: "Channel",
|
||||
render: (val: string) => {
|
||||
const chan = channels.find(c => c.code === val || c.id === val);
|
||||
return <span className="font-medium">{chan ? chan.name : val}</span>;
|
||||
return <span className="font-semibold text-xs uppercase px-2 py-0.5 rounded bg-surface border border-border">{chan ? chan.name : val || 'Shopify'}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "integrationType",
|
||||
label: "Type",
|
||||
render: (val: string) => {
|
||||
const meta = INTEGRATION_META[val] || INTEGRATION_META.custom_api;
|
||||
return <span className="text-xs font-medium text-muted-foreground capitalize">{meta.label}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "environment",
|
||||
label: "Environment",
|
||||
render: (val: string) => <span className="text-blue-600 text-sm font-medium capitalize">{val}</span>,
|
||||
key: "sync_mode",
|
||||
label: "Sync Mode",
|
||||
render: (_: any, row: Integration) => (
|
||||
<span className="text-xs font-mono capitalize text-muted-foreground">{row.sync_mode || row.syncMode || 'auto'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Connection Status",
|
||||
render: (val: string) => {
|
||||
const isSuccess = val === "Connected" || val === "active";
|
||||
const isWarning = val === "Pending" || val === "pending";
|
||||
label: "Health Status",
|
||||
render: (_: any, row: Integration) => (
|
||||
<IntegrationHealthBadge status={row.status} healthStatus={row.health_status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "last_synced_at",
|
||||
label: "Last Synced",
|
||||
render: (_: any, row: Integration) => {
|
||||
const ts = row.last_synced_at || row.lastSync;
|
||||
return (
|
||||
<StatusBadge
|
||||
status={isSuccess ? "success" : isWarning ? "warning" : "neutral"}
|
||||
label={val === "active" ? "Connected" : val === "pending" ? "Pending" : val}
|
||||
/>
|
||||
<div className="text-xs text-foreground font-medium">
|
||||
{ts ? new Date(ts).toLocaleString() : 'Not synced yet'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "lastSync",
|
||||
label: "Last Sync",
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
render: (_: any, row: Integration) => (
|
||||
<div>
|
||||
<div className="text-foreground font-medium text-sm">{row.lastSync || "—"}</div>
|
||||
{row.syncErrors && row.syncErrors > 0 && (
|
||||
<div className="text-red-600 text-xs mt-0.5">{row.syncErrors} failed</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTestConnectionClick(row.id)}
|
||||
disabled={testingId === row.id}
|
||||
title="Test Connection"
|
||||
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-background text-amber-600 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Zap className={`w-3.5 h-3.5 ${testingId === row.id ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTriggerSyncClick(row.id)}
|
||||
disabled={syncingId === row.id}
|
||||
title="Trigger Manual Sync"
|
||||
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-background text-primary cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Play className={`w-3.5 h-3.5 ${syncingId === row.id ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCredentialModal({ isOpen: true, integrationId: row.id, name: row.name })}
|
||||
title="Configure Credentials"
|
||||
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-background text-foreground cursor-pointer"
|
||||
>
|
||||
<Key className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteModal({ isOpen: true, id: row.id, name: row.name })}
|
||||
title="Delete Integration"
|
||||
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-red-50 hover:border-red-200 text-red-600 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "published", label: "Published", render: (val: any) => val || 0 },
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "Created By",
|
||||
render: (_: any, row: Integration) => (
|
||||
<div>
|
||||
<div className="text-foreground text-sm font-medium">{row.author || "Admin"}</div>
|
||||
<div className="text-muted-foreground text-xs mt-0.5">
|
||||
{row.createdAt ? new Date(row.createdAt).toLocaleDateString() : "—"}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const filteredItems = items.filter(item => {
|
||||
@@ -165,7 +227,7 @@ export default function IntegrationList() {
|
||||
(statusFilter === "Disconnected" && (item.status === "Disconnected" || item.status === "inactive"));
|
||||
|
||||
const matchesType = typeFilter === "All Types" ||
|
||||
typeFilter.toLowerCase() === item.integrationType.toLowerCase();
|
||||
(item.integrationType && typeFilter.toLowerCase() === item.integrationType.toLowerCase());
|
||||
|
||||
return matchesStatus && matchesType;
|
||||
});
|
||||
@@ -173,142 +235,165 @@ export default function IntegrationList() {
|
||||
return (
|
||||
<ProtectedRoute node="settings.integrations">
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Integration Hub" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="bg-surface border-border text-foreground hover:bg-background" onClick={fetchItems}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />New Integration
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Integration Hub" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="bg-surface border-border text-foreground hover:bg-background" onClick={fetchItems}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={() => setShopifyModalOpen(true)} className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />Setup Shopify
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatsCard
|
||||
title="Total Integrations"
|
||||
value={items.length}
|
||||
subtitle="All integrations"
|
||||
icon={<Plug className="w-5 h-5" />}
|
||||
color="purple"
|
||||
{/* Quick Pre-Built Template Gallery */}
|
||||
<IntegrationTemplateGallery
|
||||
onSelectShopify={() => setShopifyModalOpen(true)}
|
||||
onSelectCustomApi={() => navigate("new")}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Connected Systems"
|
||||
value={items.filter(i => i.status === "Connected" || i.status === "active").length}
|
||||
subtitle="Healthy"
|
||||
icon={<CheckCircle className="w-5 h-5" />}
|
||||
color="green"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Failed Sync Jobs"
|
||||
value={items.filter(i => i.syncErrors && i.syncErrors > 0).length}
|
||||
subtitle="Need attention"
|
||||
icon={<AlertCircle className="w-5 h-5" />}
|
||||
color="red"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Last Synchronised"
|
||||
value="14:32"
|
||||
subtitle="Today"
|
||||
icon={<Clock className="w-5 h-5" />}
|
||||
color="slate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl shadow-sm border border-border mt-6">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border px-6 pt-2">
|
||||
{[
|
||||
{ id: "Connections", count: items.length },
|
||||
{ id: "Publishing Rules", count: 5 },
|
||||
{ id: "Sync Jobs", count: 6 },
|
||||
{ id: "Error Center", count: items.filter(i => i.syncErrors && i.syncErrors > 0).length },
|
||||
{ id: "Audit & Logs", count: null }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{tab.id}
|
||||
{tab.count !== null && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
activeTab === tab.id ? 'bg-primary-light text-primary-dark' : 'bg-surface-muted text-muted-foreground'
|
||||
}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatsCard
|
||||
title="Total Integrations"
|
||||
value={items.length}
|
||||
subtitle="All active channels"
|
||||
icon={<Plug className="w-5 h-5" />}
|
||||
color="purple"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Healthy Systems"
|
||||
value={items.filter(i => i.status === "Connected" || i.status === "active" || i.health_status === 'healthy').length}
|
||||
subtitle="Operational"
|
||||
icon={<CheckCircle className="w-5 h-5" />}
|
||||
color="green"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Outbox Events"
|
||||
value="Active"
|
||||
subtitle="Realtime Outbox Queue"
|
||||
icon={<AlertCircle className="w-5 h-5" />}
|
||||
color="blue"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Engine Health"
|
||||
value="BullMQ"
|
||||
subtitle="Redis Workers Running"
|
||||
icon={<Clock className="w-5 h-5" />}
|
||||
color="slate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{activeTab === "Connections" && (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredItems}
|
||||
rowIdKey="id"
|
||||
resultLabel="integrations"
|
||||
statusKey="status"
|
||||
actionConfig={{
|
||||
onView: (row) => navigate(`${row.id}/view`),
|
||||
onEdit: (row) => navigate(`${row.id}/edit`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
}}
|
||||
searchPlaceholder="Search integrations..."
|
||||
toolbarLeft={
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="h-9 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground bg-surface"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option>All Status</option>
|
||||
<option>Connected</option>
|
||||
<option>Pending</option>
|
||||
<option>Disconnected</option>
|
||||
</select>
|
||||
<select
|
||||
className="h-9 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground bg-surface"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
>
|
||||
<option>All Types</option>
|
||||
<option>Marketplace</option>
|
||||
<option>E-Commerce</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="bg-surface rounded-xl shadow-sm border border-border mt-6">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border px-6 pt-2 overflow-x-auto">
|
||||
{[
|
||||
{ id: "Connections", count: items.length },
|
||||
{ id: "Field Mappings", count: 7 },
|
||||
{ id: "Publishing Rules", count: 1 },
|
||||
{ id: "Sync Jobs", count: null },
|
||||
{ id: "Error Center", count: 0 },
|
||||
{ id: "Audit & Logs", count: null }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors cursor-pointer shrink-0 ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{tab.id}
|
||||
{tab.count !== null && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
activeTab === tab.id ? 'bg-primary-light text-primary-dark' : 'bg-surface-muted text-muted-foreground'
|
||||
}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "Publishing Rules" && <PublishingRulesList />}
|
||||
{activeTab === "Sync Jobs" && <SyncJobsList />}
|
||||
{activeTab === "Error Center" && <ErrorCenterList />}
|
||||
{activeTab === "Audit & Logs" && <AuditLogsList />}
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{activeTab === "Connections" && (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredItems}
|
||||
rowIdKey="id"
|
||||
resultLabel="integrations"
|
||||
actionConfig={{
|
||||
onEdit: (row) => navigate(`${row.id}/edit`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
}}
|
||||
searchPlaceholder="Search integrations..."
|
||||
toolbarLeft={
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="h-9 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground bg-surface"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option>All Status</option>
|
||||
<option>Connected</option>
|
||||
<option>Pending</option>
|
||||
<option>Disconnected</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "Field Mappings" && <FieldMappingsList />}
|
||||
{activeTab === "Publishing Rules" && <PublishingRulesList />}
|
||||
{activeTab === "Sync Jobs" && <SyncJobsList />}
|
||||
{activeTab === "Error Center" && <ErrorCenterList />}
|
||||
{activeTab === "Audit & Logs" && <AuditLogsList />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
title="Delete Integration"
|
||||
description="Are you sure you want to delete this integration? This action cannot be undone."
|
||||
itemName={deleteModal.name}
|
||||
loading={isDeleting}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
/>
|
||||
</PageWrapper>
|
||||
{/* Pre-Built Shopify Template Modal */}
|
||||
<ShopifyTemplateModal
|
||||
isOpen={shopifyModalOpen}
|
||||
onClose={() => setShopifyModalOpen(false)}
|
||||
onSuccess={fetchItems}
|
||||
/>
|
||||
|
||||
{/* Credentials Configuration Modal */}
|
||||
{credentialModal.isOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-xs flex items-center justify-center p-4">
|
||||
<div className="relative w-full max-w-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCredentialModal({ isOpen: false, integrationId: "", name: "" })}
|
||||
className="absolute top-3 right-3 p-1 text-muted-foreground hover:text-foreground cursor-pointer z-10"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<ShopifyCredentialCard
|
||||
integrationId={credentialModal.integrationId}
|
||||
onSaved={() => setCredentialModal({ isOpen: false, integrationId: "", name: "" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
title="Delete Integration"
|
||||
description="Are you sure you want to delete this integration? This action cannot be undone."
|
||||
itemName={deleteModal.name}
|
||||
loading={isDeleting}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
/>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -258,6 +258,39 @@ export default function NewIntegration() {
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isGeneralValid = Boolean(formik.values.name?.trim() && formik.values.channel && formik.values.integrationType && !formik.errors.name && !formik.errors.channel && !formik.errors.integrationType);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isGeneralValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isGeneralValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'general') {
|
||||
if (!isGeneralValid) {
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('channel', true);
|
||||
formik.setFieldTouched('integrationType', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isGeneralValid, activeIndex, formik]);
|
||||
|
||||
const selectedType = INTEGRATION_TYPES.find((t) => t.id === formik.values.integrationType);
|
||||
const selectedChanName = channels.find(c => c.code === formik.values.channel || c.id === formik.values.channel)?.name ?? '';
|
||||
|
||||
@@ -318,15 +351,34 @@ export default function NewIntegration() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button type="button" onClick={() => setActiveStep(s.id)} className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? 'bg-primary ring-2 ring-primary/20' : isDone ? 'bg-success' : 'bg-surface border-2 border-border hover:border-primary/30'}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? 'bg-primary ring-2 ring-primary/20' : isDone ? 'bg-success' : 'bg-surface border-2 border-border hover:border-primary/30'} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
{isDone ? <Check className="w-3 h-3 text-white" /> : <span className={`text-[9px] font-bold ${isActive ? 'text-white' : 'text-muted-foreground'}`}>{s.step}</span>}
|
||||
</button>
|
||||
{!isLast && <div className={`w-px flex-1 my-0.5 ${isDone ? 'bg-success/50' : 'bg-surface-muted'}`} style={{ minHeight: 14 }} />}
|
||||
</div>
|
||||
<button type="button" onClick={() => setActiveStep(s.id)} className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${isActive ? 'text-primary' : isDone ? 'text-muted-foreground' : 'text-muted-foreground hover:text-muted-foreground'}`}>{s.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -985,7 +1037,7 @@ export default function NewIntegration() {
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest } from '../types/integrations.types';
|
||||
import type {
|
||||
Integration,
|
||||
IntegrationCreateRequest,
|
||||
IntegrationUpdateRequest,
|
||||
TestConnectionResult,
|
||||
SyncJob,
|
||||
SyncItem
|
||||
} from '../types/integrations.types';
|
||||
import apiClient from '../../../api/axiosInstance';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
@@ -10,22 +17,82 @@ interface ApiResponse<T> {
|
||||
export const integrationsService = {
|
||||
getAll: async (): Promise<Integration[]> => {
|
||||
const res = await apiClient.get<ApiResponse<Integration[]>>('/api/v1/integrations');
|
||||
return res.data || [];
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as Integration[];
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Integration | undefined> => {
|
||||
const res = await apiClient.get<ApiResponse<Integration>>(`/api/v1/integrations/${id}`);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
create: async (req: IntegrationCreateRequest): Promise<Integration> => {
|
||||
const res = await apiClient.post<ApiResponse<Integration>>('/api/v1/integrations', req);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
update: async (id: string, req: IntegrationUpdateRequest): Promise<Integration> => {
|
||||
const res = await apiClient.put<ApiResponse<Integration>>(`/api/v1/integrations/${id}`, req);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/integrations/${id}`);
|
||||
return res.success;
|
||||
return res.success || true;
|
||||
},
|
||||
|
||||
getCredentials: async (id: string): Promise<Record<string, string>> => {
|
||||
const res = await apiClient.get<ApiResponse<Record<string, string>>>(`/api/v1/integrations/${id}/credentials`);
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Record<string, string>;
|
||||
},
|
||||
|
||||
setCredentials: async (id: string, credentialType: string, secretValue: string, expiresAt?: string): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/credentials`, {
|
||||
credential_type: credentialType,
|
||||
secret_value: secretValue,
|
||||
expires_at: expiresAt
|
||||
});
|
||||
const raw: any = res.data;
|
||||
return raw?.data || raw;
|
||||
},
|
||||
|
||||
testConnection: async (id: string): Promise<TestConnectionResult> => {
|
||||
const res = await apiClient.post<ApiResponse<TestConnectionResult>>(`/api/v1/integrations/${id}/test-connection`);
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as TestConnectionResult;
|
||||
},
|
||||
|
||||
triggerSync: async (id: string, options?: { productId?: string; productIds?: string[] }): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/sync`, options || {});
|
||||
const raw: any = res.data;
|
||||
return raw?.data || raw;
|
||||
},
|
||||
|
||||
getSyncJobs: async (id: string): Promise<SyncJob[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncJob[]>>(`/api/v1/integrations/${id}/jobs`);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncJob[];
|
||||
},
|
||||
|
||||
getAllSyncJobs: async (): Promise<SyncJob[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncJob[]>>(`/api/v1/integrations/jobs/all`);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncJob[];
|
||||
},
|
||||
|
||||
getSyncItems: async (jobId: string): Promise<SyncItem[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncItem[]>>(`/api/v1/integrations/jobs/${jobId}/items`);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncItem[];
|
||||
},
|
||||
|
||||
startShopifyOAuth: async (id: string): Promise<{ authorizationUrl: string; state: string }> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/shopify/oauth/start`);
|
||||
const raw: any = res.data;
|
||||
return raw?.data || raw;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,62 +2,97 @@ export interface Integration {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
channel: string; // channel code or ID
|
||||
integrationType: string; // e.g. ecommerce, marketplace, erp, wms, pos, b2b_portal, mobile_app, website, custom_api
|
||||
environment: string; // e.g. production, staging, development
|
||||
status: string; // Connected, Pending, Disconnected, active, inactive, pending
|
||||
channel: string; // e.g. shopify, amazon, custom_api
|
||||
integrationType?: string; // e.g. ecommerce, marketplace, erp
|
||||
environment?: string;
|
||||
status: string; // active, inactive, pending, error
|
||||
health_status?: string; // healthy, degraded, error
|
||||
healthStatus?: string;
|
||||
sync_mode?: string; // auto, manual, scheduled
|
||||
syncMode?: string;
|
||||
sync_frequency?: string; // realtime, hourly, daily
|
||||
syncFrequency?: string;
|
||||
last_synced_at?: string;
|
||||
lastSync?: string;
|
||||
|
||||
// E-commerce/Website connection config
|
||||
// E-commerce connection credentials & details
|
||||
storeUrl?: string;
|
||||
accessToken?: string;
|
||||
apiVersion?: string;
|
||||
webhookSecret?: string;
|
||||
shopIdentifier?: string;
|
||||
shopDomain?: string;
|
||||
|
||||
// Marketplace specific fields
|
||||
sellerId?: string;
|
||||
marketplaceId?: string;
|
||||
awsAccessKeyId?: string;
|
||||
awsSecretKey?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
|
||||
// ERP/WMS specific fields
|
||||
authMethod?: string;
|
||||
authToken?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
|
||||
// POS specific fields
|
||||
posTerminalId?: string;
|
||||
posStoreCode?: string;
|
||||
posApiKey?: string;
|
||||
posApiSecret?: string;
|
||||
|
||||
// Mobile App specific fields
|
||||
appId?: string;
|
||||
bundleIdentifier?: string;
|
||||
gatewayUrl?: string;
|
||||
|
||||
// Custom API specific fields
|
||||
customApiUrl?: string;
|
||||
customApiHeaderKey?: string;
|
||||
customApiHeaderValue?: string;
|
||||
|
||||
// Sync settings
|
||||
syncDirection: string; // pim_to_channel, channel_to_pim, bidirectional
|
||||
syncFrequency: string; // manual, hourly, daily, realtime
|
||||
autoRetry: boolean;
|
||||
retryAttempts: number;
|
||||
|
||||
// Listing page read-only / metadata fields
|
||||
lastSync?: string;
|
||||
syncErrors?: number;
|
||||
published?: string | number;
|
||||
author?: string;
|
||||
createdAt: string;
|
||||
createdAt?: string;
|
||||
created_at?: string;
|
||||
updatedAt?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export type IntegrationCreateRequest = Omit<Integration, 'id' | 'createdAt'>;
|
||||
export type IntegrationCreateRequest = Omit<Integration, 'id' | 'createdAt' | 'created_at'>;
|
||||
export type IntegrationUpdateRequest = Partial<IntegrationCreateRequest>;
|
||||
|
||||
export interface TestConnectionResult {
|
||||
connected: boolean;
|
||||
shopName?: string;
|
||||
shopDomain?: string;
|
||||
email?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SyncJob {
|
||||
id: string;
|
||||
tenant_id: number;
|
||||
integration_id: string;
|
||||
trigger_source: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
total_items: number;
|
||||
success_items: number;
|
||||
failed_items: number;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SyncAttempt {
|
||||
id: string;
|
||||
sync_item_id: string;
|
||||
attempt_number: number;
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
status: string;
|
||||
request_method: string;
|
||||
request_url: string;
|
||||
response_status?: number;
|
||||
error_code?: string;
|
||||
error_message?: string;
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
export interface SyncErrorItem {
|
||||
id: string;
|
||||
error_code: string;
|
||||
error_type: string;
|
||||
message: string;
|
||||
provider_message?: string;
|
||||
http_status?: number;
|
||||
retryable: boolean;
|
||||
attempt_number: number;
|
||||
}
|
||||
|
||||
export interface SyncItem {
|
||||
id: string;
|
||||
sync_job_id: string;
|
||||
integration_id: string;
|
||||
product_id: string;
|
||||
variant_id?: string;
|
||||
sku?: string;
|
||||
operation: string;
|
||||
status: 'pending' | 'processing' | 'success' | 'failed' | 'skipped';
|
||||
source_version: number;
|
||||
idempotency_key: string;
|
||||
attempt_count: number;
|
||||
error_code?: string;
|
||||
error_message?: string;
|
||||
attempts?: SyncAttempt[];
|
||||
errors?: SyncErrorItem[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -46,6 +49,9 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
|
||||
onAttributeChange,
|
||||
onAttributeBlur,
|
||||
onAddAttributeClick,
|
||||
onRemoveAttribute,
|
||||
onRemoveGroup,
|
||||
customAttributeIds,
|
||||
readOnly,
|
||||
}) => {
|
||||
if (!hasAttributeSet) {
|
||||
@@ -95,6 +101,9 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
|
||||
onAttributeChange={onAttributeChange}
|
||||
onAttributeBlur={onAttributeBlur}
|
||||
onAddAttributeClick={onAddAttributeClick}
|
||||
onRemoveAttribute={onRemoveAttribute}
|
||||
onRemoveGroup={onRemoveGroup}
|
||||
customAttributeIds={customAttributeIds}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, Plus } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Plus, X } from 'lucide-react';
|
||||
import { DynamicAttributeRenderer } from './DynamicAttributeRenderer';
|
||||
|
||||
interface AttributeOption {
|
||||
@@ -35,6 +35,9 @@ interface ProductAttributeGroupProps {
|
||||
onAttributeChange: (code: string, value: any) => void;
|
||||
onAttributeBlur?: (code: string) => void;
|
||||
onAddAttributeClick?: (group: AttributeGroup) => void;
|
||||
onRemoveAttribute?: (id: string) => void;
|
||||
onRemoveGroup?: (id: string) => void;
|
||||
customAttributeIds?: Set<string>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -46,6 +49,9 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
|
||||
onAttributeChange,
|
||||
onAttributeBlur,
|
||||
onAddAttributeClick,
|
||||
onRemoveAttribute,
|
||||
onRemoveGroup,
|
||||
customAttributeIds,
|
||||
readOnly,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
@@ -56,19 +62,34 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
|
||||
<div className="bg-surface rounded-xl border border-border shadow-xs p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-foreground text-xs uppercase tracking-wider">{group.name}</h3>
|
||||
{!readOnly && onAddAttributeClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddAttributeClick(group);
|
||||
}}
|
||||
className="px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors flex items-center justify-center shrink-0 h-[42px] w-[42px]"
|
||||
title={`Add attribute to ${group.name}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{!readOnly && onRemoveGroup && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveGroup(group.id);
|
||||
}}
|
||||
className="text-xs text-red-500 hover:text-red-650 font-semibold px-2 py-1 rounded hover:bg-red-50 transition-colors"
|
||||
title={`Remove ${group.name} container`}
|
||||
>
|
||||
Remove Group
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && onAddAttributeClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddAttributeClick(group);
|
||||
}}
|
||||
className="px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors flex items-center justify-center shrink-0 h-[42px] w-[42px]"
|
||||
title={`Add attribute to ${group.name}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">No Attributes available.</div>
|
||||
</div>
|
||||
@@ -83,6 +104,19 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
|
||||
>
|
||||
<h3 className="font-semibold text-foreground text-xs uppercase tracking-wider">{group.name}</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
{!readOnly && onRemoveGroup && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveGroup(group.id);
|
||||
}}
|
||||
className="text-xs text-red-500 hover:text-red-650 font-semibold px-2.5 py-1 rounded hover:bg-red-50/70 transition-colors"
|
||||
title={`Remove ${group.name} container`}
|
||||
>
|
||||
Remove Group
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && onAddAttributeClick && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -102,18 +136,32 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-6 grid grid-cols-2 gap-6">
|
||||
{attributes.map((attr) => (
|
||||
<DynamicAttributeRenderer
|
||||
key={attr.id}
|
||||
attribute={attr}
|
||||
value={values[attr.code]}
|
||||
onChange={(val) => onAttributeChange(attr.code, val)}
|
||||
onBlur={() => onAttributeBlur?.(attr.code)}
|
||||
error={errors[attr.code]}
|
||||
touched={touched[attr.code]}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
{attributes.map((attr) => {
|
||||
const isCustom = customAttributeIds?.has(attr.id);
|
||||
return (
|
||||
<div key={attr.id} className="relative border border-border/40 rounded-xl p-5 bg-background/15 group hover:border-border/80 transition-all">
|
||||
{isCustom && !readOnly && onRemoveAttribute && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveAttribute(attr.id)}
|
||||
className="absolute top-2 right-2 p-1.5 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
|
||||
title="Remove custom attribute"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<DynamicAttributeRenderer
|
||||
attribute={attr}
|
||||
value={values[attr.code]}
|
||||
onChange={(val) => onAttributeChange(attr.code, val)}
|
||||
onBlur={() => onAttributeBlur?.(attr.code)}
|
||||
error={errors[attr.code]}
|
||||
touched={touched[attr.code]}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,18 +7,29 @@ interface VariantAxesSelectorProps {
|
||||
onGenerate: (selected: Record<string, string[]>, skuTemplate: string) => void;
|
||||
generating: boolean;
|
||||
parentSku: string;
|
||||
initialSelectedValues?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export const VariantAxesSelector: React.FC<VariantAxesSelectorProps> = ({
|
||||
axes,
|
||||
onGenerate,
|
||||
generating,
|
||||
parentSku
|
||||
parentSku,
|
||||
initialSelectedValues
|
||||
}) => {
|
||||
const [selectedValues, setSelectedValues] = useState<Record<string, string[]>>({});
|
||||
const [selectedValues, setSelectedValues] = useState<Record<string, string[]>>(initialSelectedValues || {});
|
||||
const [skuTemplate, setSkuTemplate] = useState('{PARENT_SKU}-{COMBO}');
|
||||
const [customInputs, setCustomInputs] = useState<Record<string, string>>({});
|
||||
|
||||
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
|
||||
@@ -112,15 +123,41 @@ export const VariantAxesSelector: React.FC<VariantAxesSelectorProps> = ({
|
||||
|
||||
return (
|
||||
<div key={axis.id} className="space-y-2">
|
||||
<label className="block text-xs font-bold text-foreground uppercase tracking-wider">
|
||||
{axis.name} <span className="text-muted-foreground">({axis.code})</span>
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-bold text-foreground uppercase tracking-wider">
|
||||
{axis.name} <span className="text-muted-foreground font-mono text-[10px]">({axis.code})</span>
|
||||
{selected.length > 0 && (
|
||||
<span className="ml-2 text-[10px] text-primary font-semibold bg-primary/10 px-2 py-0.5 rounded-full border border-primary/20">
|
||||
{selected.length} selected
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{options.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedValues(prev => ({ ...prev, [axis.code]: options.map(o => o.code) }))}
|
||||
className="text-primary font-medium hover:underline"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<span className="text-muted-foreground/40">•</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedValues(prev => ({ ...prev, [axis.code]: [] }))}
|
||||
className="text-muted-foreground hover:text-foreground font-medium transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{options.length > 0 ? (
|
||||
// Pre-defined options list checkbox layout
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{options.map(opt => {
|
||||
const isChecked = selected.includes(opt.code);
|
||||
const isChecked = selected.some(sel => sel.toLowerCase().trim() === opt.code.toLowerCase().trim());
|
||||
return (
|
||||
<label
|
||||
key={opt.id}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { VariantStatus } from '../../types/variant.types';
|
||||
import { Settings, Check, Trash2, Archive, DollarSign, Package } from 'lucide-react';
|
||||
import { Settings, Check, Trash2, Archive, DollarSign } from 'lucide-react';
|
||||
|
||||
interface VariantBulkActionsProps {
|
||||
selectedCount: number;
|
||||
onApplyUpdates: (updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus }) => void;
|
||||
onApplyUpdates: (updates: { price?: number; costPrice?: number; status?: VariantStatus }) => void;
|
||||
onDeleteSelected: () => void;
|
||||
onArchiveSelected: () => void;
|
||||
}
|
||||
@@ -16,25 +16,22 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
|
||||
onArchiveSelected
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [actionType, setActionType] = useState<'price' | 'stock' | 'status' | null>(null);
|
||||
const [actionType, setActionType] = useState<'price' | 'status' | null>(null);
|
||||
|
||||
// States for bulk inputs
|
||||
const [bulkPrice, setBulkPrice] = useState('');
|
||||
const [bulkCostPrice, setBulkCostPrice] = useState('');
|
||||
const [bulkStock, setBulkStock] = useState('');
|
||||
const [bulkStatus, setBulkStatus] = useState<VariantStatus>('draft');
|
||||
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
const handleApply = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus } = {};
|
||||
const updates: { price?: number; costPrice?: number; status?: VariantStatus } = {};
|
||||
|
||||
if (actionType === 'price') {
|
||||
if (bulkPrice !== '') updates.price = parseFloat(bulkPrice);
|
||||
if (bulkCostPrice !== '') updates.costPrice = parseFloat(bulkCostPrice);
|
||||
} else if (actionType === 'stock') {
|
||||
if (bulkStock !== '') updates.stock = parseInt(bulkStock, 10);
|
||||
} else if (actionType === 'status') {
|
||||
updates.status = bulkStatus;
|
||||
}
|
||||
@@ -80,13 +77,6 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
|
||||
>
|
||||
<DollarSign className="w-4 h-4 text-muted-foreground" /> Update Price & Cost
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActionType('stock')}
|
||||
className="flex items-center gap-2 w-full text-left px-3 py-2 hover:bg-background rounded-lg text-xs text-foreground font-medium transition-colors"
|
||||
>
|
||||
<Package className="w-4 h-4 text-muted-foreground" /> Update Inventory Stock
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActionType('status')}
|
||||
@@ -124,19 +114,6 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionType === 'stock' && (
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-muted-foreground uppercase">Stock Level</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Enter inventory quantity"
|
||||
value={bulkStock}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionType === 'status' && (
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-muted-foreground uppercase">Lifecycle Status</label>
|
||||
|
||||
@@ -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<Variant>) => Promise<any>;
|
||||
onDelete?: (id: string) => void;
|
||||
onArchive?: (id: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
|
||||
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<VariantStatus>('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<VariantStatus, string> = {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-150">
|
||||
<div className="bg-surface border border-border rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Package className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-bold text-foreground text-sm leading-tight">
|
||||
{variant.name || 'Variant Details'}
|
||||
</h2>
|
||||
<p className="text-[11px] text-muted-foreground font-mono mt-0.5">{variant.sku}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-bold uppercase border ${statusColor[variant.status] || statusColor.draft}`}>
|
||||
{variant.status}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-background rounded-lg text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-5 gap-0 h-full">
|
||||
|
||||
{/* Left: Image Gallery */}
|
||||
<div className="col-span-2 border-r border-border p-5 flex flex-col gap-4 bg-background/50">
|
||||
{/* Main image */}
|
||||
<div className="aspect-square rounded-xl border border-border overflow-hidden bg-surface flex items-center justify-center">
|
||||
{activeImage?.url || activeImage?.thumbnailUrl ? (
|
||||
<img
|
||||
src={activeImage.thumbnailUrl || activeImage.url!}
|
||||
alt={activeImage.name || variant.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ImageIcon className="w-10 h-10 opacity-30" />
|
||||
<span className="text-[11px] font-medium">No image</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{images.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{images.map((img, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setActiveImageIdx(idx)}
|
||||
className={`flex-shrink-0 w-12 h-12 rounded-lg border-2 overflow-hidden transition-all ${idx === activeImageIdx ? 'border-primary' : 'border-border hover:border-primary/40'}`}
|
||||
>
|
||||
{img.url || img.thumbnailUrl ? (
|
||||
<img src={img.thumbnailUrl || img.url!} alt={img.name || `Image ${idx + 1}`} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-surface-muted flex items-center justify-center">
|
||||
<ImageIcon className="w-3 h-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Axis pills */}
|
||||
{axisEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Variant Axes</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{axisEntries.map(([key, val]) => (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/8 border border-primary/20 rounded-full text-[11px] font-semibold text-primary"
|
||||
>
|
||||
<Tag className="w-2.5 h-2.5" />
|
||||
<span className="text-muted-foreground capitalize">{key}:</span>
|
||||
<span>{val}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length > 0 && (
|
||||
<p className="text-[10px] text-muted-foreground text-center">
|
||||
{images.length} asset{images.length !== 1 ? 's' : ''} uploaded
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Edit Fields */}
|
||||
<div className="col-span-3 p-6 space-y-5">
|
||||
|
||||
{/* SKU */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<Hash className="inline w-3 h-3 mr-1" />SKU Code
|
||||
</label>
|
||||
{readOnly ? (
|
||||
<p className="font-mono text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{sku || '—'}</p>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={sku}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price & Cost */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<DollarSign className="inline w-3 h-3 mr-1" />Sale Price
|
||||
</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">${price}</p>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm font-semibold">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={price}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<DollarSign className="inline w-3 h-3 mr-1" />Cost Price
|
||||
</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">${costPrice}</p>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm font-semibold">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={costPrice}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stock & Status */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<ShoppingBag className="inline w-3 h-3 mr-1" />Stock
|
||||
</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{stock}</p>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={stock}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">Status</label>
|
||||
{readOnly ? (
|
||||
<p className={`inline-flex items-center px-2.5 py-1.5 rounded-lg text-xs font-bold uppercase border ${statusColor[status]}`}>{status}</p>
|
||||
) : (
|
||||
<select
|
||||
value={status}
|
||||
onChange={e => setStatus(e.target.value as VariantStatus)}
|
||||
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 font-medium"
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Available Stock', value: variant.availableStock ?? 0 },
|
||||
{ label: 'Reserved', value: variant.reservedStock ?? 0 },
|
||||
{ label: 'Safety Stock', value: variant.safetyStock ?? 0 },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="bg-surface-muted border border-border rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-foreground">{value}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-medium mt-0.5">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Last updated */}
|
||||
{variant.lastUpdated && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Last updated: {new Date(variant.lastUpdated).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0 bg-background/50">
|
||||
{/* Danger actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!readOnly && onArchive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onArchive(variant.id); onClose(); }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 border border-amber-200 text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg text-xs font-semibold transition-colors"
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5" />
|
||||
Archive
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm('Delete this variant?')) { onDelete(variant.id); onClose(); } }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 border border-red-200 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg text-xs font-semibold transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Primary actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-border text-muted-foreground hover:bg-background rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{readOnly ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saveState === 'saving'}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors disabled:opacity-60"
|
||||
>
|
||||
{saveState === 'saving' && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{saveState === 'saved' && <CheckCircle className="w-3.5 h-3.5 text-white" />}
|
||||
{saveState === 'error' && <AlertCircle className="w-3.5 h-3.5 text-white" />}
|
||||
{saveState === 'idle' && <Save className="w-3.5 h-3.5" />}
|
||||
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved!' : saveState === 'error' ? 'Error' : 'Save Changes'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<Variant>) => Promise<any>;
|
||||
onDelete: (id: string) => void;
|
||||
onArchive: (id: string) => void;
|
||||
onViewDetail?: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -21,6 +22,7 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onViewDetail,
|
||||
readOnly
|
||||
}) => {
|
||||
const [sku, setSku] = useState(variant.sku);
|
||||
@@ -77,14 +79,28 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<tr className="hover:bg-background/50 transition-colors border-b border-border">
|
||||
<td className="px-3 py-2 text-center">
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt={variant.name} className="w-8 h-8 object-cover rounded border border-border mx-auto" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded border border-border bg-surface-muted flex items-center justify-center mx-auto text-muted-foreground">
|
||||
<ImageIcon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-foreground">{sku}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name}>
|
||||
{variant.name.split(' - ')[1] || variant.name}
|
||||
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name || ''}>
|
||||
{(variant.name || '').split(' - ')[1] || variant.name || variant.sku}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{axesKeys.map(key => {
|
||||
@@ -99,10 +115,8 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-foreground">{sku}</td>
|
||||
<td className="px-4 py-3 text-right text-xs text-foreground">${price}</td>
|
||||
<td className="px-4 py-3 text-right text-xs text-foreground">${costPrice}</td>
|
||||
<td className="px-4 py-3 text-center text-xs text-foreground">{stock}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${
|
||||
status === 'active' ? 'bg-success/10 text-success' :
|
||||
@@ -128,11 +142,46 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Image Thumbnail */}
|
||||
<td className="px-3 py-2 text-center">
|
||||
{onViewDetail ? (
|
||||
<button type="button" onClick={onViewDetail} className="group relative block mx-auto focus:outline-none">
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt={variant.name} className="w-9 h-9 object-cover rounded-lg border border-border transition-all group-hover:border-primary" />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-lg border border-border bg-surface-muted flex items-center justify-center text-muted-foreground transition-all group-hover:border-primary">
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
thumbUrl ? (
|
||||
<img src={thumbUrl} alt={variant.name} className="w-9 h-9 object-cover rounded-lg border border-border mx-auto" />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-lg border border-border bg-surface-muted flex items-center justify-center mx-auto text-muted-foreground">
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* SKU Input */}
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={sku}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Variant Specification */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name}>
|
||||
{variant.name.split(' - ')[1] || variant.name}
|
||||
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name || ''}>
|
||||
{(variant.name || '').split(' - ')[1] || variant.name || variant.sku}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{axesKeys.map(key => {
|
||||
@@ -148,18 +197,6 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* SKU Input */}
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={sku}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Price Input */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="relative">
|
||||
@@ -192,18 +229,6 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Inventory Stock Input */}
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
value={stock}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Status dropdown */}
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
@@ -229,6 +254,16 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
{/* Row Actions */}
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
{onViewDetail && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewDetail}
|
||||
title="View & Edit Details"
|
||||
className="p-1 hover:bg-primary/10 text-muted-foreground hover:text-primary rounded transition-colors"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArchive(variant.id)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { Variant } from '../../types/variant.types';
|
||||
import { VariantEditorRow } from './VariantEditorRow';
|
||||
import { VariantDetailModal } from './VariantDetailModal';
|
||||
|
||||
interface VariantListViewProps {
|
||||
variants: Variant[];
|
||||
@@ -25,60 +26,80 @@ export const VariantListView: React.FC<VariantListViewProps> = ({
|
||||
onArchive,
|
||||
readOnly
|
||||
}) => {
|
||||
const [modalVariant, setModalVariant] = useState<Variant | null>(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 (
|
||||
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
|
||||
<table className="w-full border-collapse text-left min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="bg-background/70 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||
{!readOnly && (
|
||||
<th className="px-4 py-3 text-center w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
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"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className="px-4 py-3">Variant Specification</th>
|
||||
<th className="px-4 py-3 w-48">SKU Code</th>
|
||||
<th className="px-4 py-3 w-28 text-right">Sale Price</th>
|
||||
<th className="px-4 py-3 w-28 text-right">Cost Price</th>
|
||||
<th className="px-4 py-3 w-24 text-center">Stock</th>
|
||||
<th className="px-4 py-3 w-32">Status</th>
|
||||
{!readOnly && <th className="px-4 py-3 w-16 text-center">Save</th>}
|
||||
{!readOnly && <th className="px-4 py-3 w-24 text-right">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{variants.map(variant => (
|
||||
<VariantEditorRow
|
||||
key={variant.id}
|
||||
variant={variant}
|
||||
axesKeys={axesKeys}
|
||||
isSelected={selectedIds.has(variant.id)}
|
||||
onSelect={(checked) => onSelectChange(variant.id, checked)}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
{variants.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={readOnly ? 6 : 9} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
|
||||
No variants found matching criteria.
|
||||
</td>
|
||||
<>
|
||||
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
|
||||
<table className="w-full border-collapse text-left min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="bg-background/70 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||
{!readOnly && (
|
||||
<th className="px-4 py-3 text-center w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
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"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className="px-4 py-3 w-14">Image</th>
|
||||
<th className="px-4 py-3 w-44">SKU Code</th>
|
||||
<th className="px-4 py-3">Variant Specification</th>
|
||||
<th className="px-4 py-3 w-28 text-right">Sale Price</th>
|
||||
<th className="px-4 py-3 w-28 text-right">Cost Price</th>
|
||||
<th className="px-4 py-3 w-28">Status</th>
|
||||
{!readOnly && <th className="px-4 py-3 w-14 text-center">Save</th>}
|
||||
{!readOnly && <th className="px-4 py-3 w-28 text-right">Actions</th>}
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{variants.map(variant => (
|
||||
<VariantEditorRow
|
||||
key={variant.id}
|
||||
variant={variant}
|
||||
axesKeys={axesKeys}
|
||||
isSelected={selectedIds.has(variant.id)}
|
||||
onSelect={(checked) => onSelectChange(variant.id, checked)}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onViewDetail={() => setModalVariant(variant)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
{variants.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={readOnly ? 6 : 9} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
|
||||
No variants found matching criteria.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{modalVariant && (
|
||||
<VariantDetailModal
|
||||
variant={modalVariant}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
@@ -16,8 +18,8 @@ interface VariantMatrixViewProps {
|
||||
|
||||
export const VariantMatrixView: React.FC<VariantMatrixViewProps> = ({
|
||||
variants,
|
||||
axesKeys,
|
||||
axesNames,
|
||||
axesKeys: _axesKeys,
|
||||
axesNames: _axesNames,
|
||||
selectedIds,
|
||||
onSelectChange,
|
||||
onSelectAllChange,
|
||||
@@ -26,274 +28,218 @@ export const VariantMatrixView: React.FC<VariantMatrixViewProps> = ({
|
||||
onArchive,
|
||||
readOnly
|
||||
}) => {
|
||||
const [modalVariant, setModalVariant] = useState<Variant | null>(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 (
|
||||
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
|
||||
<table className="w-full border-collapse text-left min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="bg-primary/5/30 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||
{!readOnly && (
|
||||
<th className="px-4 py-3.5 text-center w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
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"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
|
||||
{/* Dynamic columns for each variant axis */}
|
||||
{axesKeys.map(key => (
|
||||
<th key={key} className="px-4 py-3.5 font-bold">
|
||||
{axesNames[key] || key}
|
||||
</th>
|
||||
))}
|
||||
|
||||
<th className="px-4 py-3.5 w-48">SKU Code</th>
|
||||
<th className="px-4 py-3.5 w-28 text-right">Sale Price</th>
|
||||
<th className="px-4 py-3.5 w-28 text-right">Cost Price</th>
|
||||
<th className="px-4 py-3.5 w-24 text-center">Stock</th>
|
||||
<th className="px-4 py-3.5 w-32">Status</th>
|
||||
{!readOnly && <th className="px-4 py-3.5 w-16 text-center">Save</th>}
|
||||
{!readOnly && <th className="px-4 py-3.5 w-24 text-right">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{variants.map(variant => (
|
||||
<tr
|
||||
key={variant.id}
|
||||
className={`hover:bg-background/50 transition-colors border-b border-border ${
|
||||
selectedIds.has(variant.id) ? 'bg-primary/5/10' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
{!readOnly && (
|
||||
<td className="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(variant.id)}
|
||||
onChange={(e) => onSelectChange(variant.id, e.target.checked)}
|
||||
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* Dynamic cells for each variant axis */}
|
||||
{axesKeys.map(key => {
|
||||
const val = variant.attributes[key];
|
||||
return (
|
||||
<td key={key} className="px-4 py-3">
|
||||
<span className="inline-block bg-primary-light text-primary-dark font-semibold text-xs px-2 py-0.5 rounded-full font-mono">
|
||||
{val || '—'}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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. */}
|
||||
<InlineEditorCells
|
||||
variant={variant}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
{variants.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={axesKeys.length + (readOnly ? 5 : 8)} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
|
||||
No variants found matching criteria.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Inline Editor Cells Helper ────────────────────────────────────────────────
|
||||
interface InlineCellsProps {
|
||||
variant: Variant;
|
||||
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
|
||||
onDelete: (id: string) => void;
|
||||
onArchive: (id: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const InlineEditorCells: React.FC<InlineCellsProps> = ({ 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 (
|
||||
<>
|
||||
<td className="px-4 py-3 font-mono text-xs text-foreground">{sku}</td>
|
||||
<td className="px-4 py-3 text-right text-xs text-foreground">${price}</td>
|
||||
<td className="px-4 py-3 text-right text-xs text-foreground">${costPrice}</td>
|
||||
<td className="px-4 py-3 text-center text-xs text-foreground">{stock}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${
|
||||
status === 'active' ? 'bg-success/10 text-success' :
|
||||
status === 'draft' ? 'bg-warning/10 text-warning' : 'bg-surface-muted text-muted-foreground'
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
</td>
|
||||
</>
|
||||
<div className="flex flex-col items-center justify-center py-20 bg-surface border border-dashed border-border rounded-xl text-muted-foreground gap-3">
|
||||
<ImageIcon className="w-10 h-10 opacity-20" />
|
||||
<p className="text-sm font-medium">No variants found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={sku}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="relative">
|
||||
<span className="absolute left-1.5 top-1/2 -translate-y-1/2 text-muted-foreground text-[10px] font-semibold">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={price}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="relative">
|
||||
<span className="absolute left-1.5 top-1/2 -translate-y-1/2 text-muted-foreground text-[10px] font-semibold">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={costPrice}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
value={stock}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as any)}
|
||||
onBlur={handleSave}
|
||||
className="text-xs border border-border rounded px-1.5 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface font-medium text-foreground"
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{savingStatus === 'saving' && <span className="inline-block w-3.5 h-3.5 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto" />}
|
||||
{savingStatus === 'saved' && <span className="text-emerald-500 font-bold text-xs">✓</span>}
|
||||
{savingStatus === 'error' && <span className="text-red-500 font-bold text-xs">⚠</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
{/* Select-all toolbar */}
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-3 mb-3 px-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArchive(variant.id)}
|
||||
title="Archive variant"
|
||||
className="p-1 hover:bg-amber-50 text-muted-foreground hover:text-amber-600 rounded transition-colors"
|
||||
onClick={() => onSelectAllChange(!allSelected)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground font-medium transition-colors"
|
||||
>
|
||||
<span className="text-[11px]">Archive</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(variant.id)}
|
||||
title="Delete variant"
|
||||
className="p-1 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
|
||||
>
|
||||
<span className="text-[11px]">Delete</span>
|
||||
{allSelected ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 text-primary" />
|
||||
) : someSelected ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{allSelected ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-xs font-semibold text-primary bg-primary/10 border border-primary/20 px-2 py-0.5 rounded-full">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* Card grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{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 (
|
||||
<div
|
||||
key={variant.id}
|
||||
className={`group relative flex flex-col bg-surface border rounded-2xl overflow-hidden shadow-xs transition-all duration-200 hover:shadow-md hover:-translate-y-0.5 ${
|
||||
isSelected
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: 'border-border hover:border-primary/30'
|
||||
}`}
|
||||
>
|
||||
{/* Selection checkbox overlay */}
|
||||
{!readOnly && (
|
||||
<div className="absolute top-2.5 left-2.5 z-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onSelectChange(variant.id, !isSelected); }}
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? 'bg-primary border-primary'
|
||||
: 'bg-white/80 border-border opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 12 12">
|
||||
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="absolute top-2.5 right-2.5 z-10">
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold uppercase border ${statusStyle(variant.status)}`}>
|
||||
{variant.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Image area */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalVariant(variant)}
|
||||
className="relative w-full aspect-square bg-background overflow-hidden focus:outline-none"
|
||||
>
|
||||
{thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={variant.name}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-1.5 text-muted-foreground/40">
|
||||
<ImageIcon className="w-8 h-8" />
|
||||
<span className="text-[10px] font-medium">No image</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image count badge */}
|
||||
{images.length > 1 && (
|
||||
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-[10px] font-semibold px-1.5 py-0.5 rounded-md backdrop-blur-sm">
|
||||
+{images.length - 1}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200 flex items-center justify-center">
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-white/90 backdrop-blur-sm rounded-full p-2 shadow-lg">
|
||||
<Edit2 className="w-4 h-4 text-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Card body */}
|
||||
<div className="p-3 flex flex-col gap-2 flex-1">
|
||||
{/* Axis pills */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{axisEntries.map(([key, val]) => (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-flex items-center gap-0.5 px-2 py-0.5 bg-primary/8 border border-primary/15 rounded-full text-[10px] font-semibold text-primary"
|
||||
>
|
||||
<Tag className="w-2.5 h-2.5 opacity-60" />
|
||||
{val}
|
||||
</span>
|
||||
))}
|
||||
{axisEntries.length === 0 && (
|
||||
<span className="text-[10px] text-muted-foreground">No axes</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SKU */}
|
||||
<div className="font-mono text-[10px] text-muted-foreground truncate" title={variant.sku}>
|
||||
{variant.sku || '—'}
|
||||
</div>
|
||||
|
||||
{/* Price row */}
|
||||
<div className="flex items-center justify-between mt-auto pt-1 border-t border-border">
|
||||
<span className="text-sm font-bold text-foreground">
|
||||
{variant.price > 0 ? `$${variant.price.toFixed(2)}` : <span className="text-muted-foreground text-xs">No price</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!readOnly && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); setModalVariant(variant); }}
|
||||
title="Edit"
|
||||
className="p-1 hover:bg-primary/10 text-muted-foreground hover:text-primary rounded transition-colors"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onArchive(variant.id); }}
|
||||
title="Archive"
|
||||
className="p-1 hover:bg-amber-50 text-muted-foreground hover:text-amber-600 rounded transition-colors"
|
||||
>
|
||||
<Archive className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); if (window.confirm('Delete variant?')) onDelete(variant.id); }}
|
||||
title="Delete"
|
||||
className="p-1 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Variant Detail Modal */}
|
||||
{modalVariant && (
|
||||
<VariantDetailModal
|
||||
variant={modalVariant}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,10 @@ interface VariantsTabProps {
|
||||
parentSku: string;
|
||||
family: any; // Product Family details
|
||||
readOnly?: boolean;
|
||||
productAttributes?: Record<string, any>;
|
||||
availableAttributes?: any[];
|
||||
onVariantsChange?: (variants: Variant[]) => void;
|
||||
initialVariants?: Variant[];
|
||||
}
|
||||
|
||||
export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
@@ -21,10 +26,15 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
productType,
|
||||
parentSku,
|
||||
family,
|
||||
readOnly
|
||||
readOnly,
|
||||
productAttributes = {},
|
||||
availableAttributes = [],
|
||||
onVariantsChange,
|
||||
initialVariants = []
|
||||
}) => {
|
||||
const {
|
||||
variants,
|
||||
setVariants,
|
||||
loading,
|
||||
generating,
|
||||
fetchByProduct,
|
||||
@@ -33,12 +43,37 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
archiveVariant,
|
||||
generateBatch,
|
||||
bulkUpdate
|
||||
} = useVariant();
|
||||
} = useVariant(initialVariants);
|
||||
|
||||
// Sync to parent when variants change
|
||||
useEffect(() => {
|
||||
if (onVariantsChange) {
|
||||
onVariantsChange(variants);
|
||||
}
|
||||
}, [variants, onVariantsChange]);
|
||||
|
||||
const [viewLayout, setViewLayout] = useState<'list' | 'matrix'>('matrix');
|
||||
const [showGenerator, setShowGenerator] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Local state for dynamically configured variant axes (flows from family, reconstructed from variants, or custom selected)
|
||||
const [localAxes, setLocalAxes] = useState<VariantAxis[]>([]);
|
||||
|
||||
// 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 +81,202 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
}
|
||||
}, [productId, fetchByProduct]);
|
||||
|
||||
// Extract configured axes from the family
|
||||
const variantAxes: VariantAxis[] = useMemo(() => {
|
||||
if (!family || !Array.isArray(family.variantAxes)) return [];
|
||||
return family.variantAxes;
|
||||
}, [family]);
|
||||
// Resolve relevant variant axes for this product (prioritized hierarchy: family axes -> existing variants -> product attributes with values)
|
||||
useEffect(() => {
|
||||
const axesMap = new Map<string, VariantAxis>();
|
||||
|
||||
// Priority 1: Family blueprint variant axes (if explicitly configured)
|
||||
if (family && Array.isArray(family.variantAxes) && family.variantAxes.length > 0) {
|
||||
family.variantAxes.forEach((fa: any) => axesMap.set(fa.code, fa));
|
||||
}
|
||||
|
||||
// Priority 2: Existing variants' actual attribute keys
|
||||
if (variants.length > 0) {
|
||||
variants.forEach(v => {
|
||||
if (v.attributes) {
|
||||
Object.keys(v.attributes).forEach(key => {
|
||||
if (!axesMap.has(key)) {
|
||||
const foundAttr = selectableAttributes.find(a => a.code === key);
|
||||
axesMap.set(key, {
|
||||
id: foundAttr?.id || key,
|
||||
code: key,
|
||||
name: foundAttr?.name || key.toUpperCase().replace(/_VARIANT/g, '').replace(/_/g, ' '),
|
||||
type: foundAttr?.type || 'select',
|
||||
optionsList: foundAttr?.optionsList || []
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 3: Only if no axes found yet, look at product attributes that have values set
|
||||
if (axesMap.size === 0 && productAttributes && selectableAttributes.length > 0) {
|
||||
selectableAttributes.forEach(attr => {
|
||||
const val = productAttributes[attr.code];
|
||||
if (val !== undefined && val !== null && val !== '' && !(Array.isArray(val) && val.length === 0)) {
|
||||
if (!axesMap.has(attr.code)) {
|
||||
axesMap.set(attr.code, {
|
||||
...attr,
|
||||
id: attr.id,
|
||||
code: attr.code,
|
||||
name: attr.name,
|
||||
type: attr.type || 'select',
|
||||
optionsList: attr.optionsList || []
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Preserve manually added local axes
|
||||
localAxes.forEach(la => {
|
||||
if (!axesMap.has(la.code)) {
|
||||
axesMap.set(la.code, la);
|
||||
}
|
||||
});
|
||||
|
||||
const resolved = Array.from(axesMap.values());
|
||||
const currentKeys = localAxes.map(la => la.code).sort().join(',');
|
||||
const newKeys = resolved.map(r => r.code).sort().join(',');
|
||||
if (currentKeys !== newKeys && resolved.length > 0) {
|
||||
setLocalAxes(resolved);
|
||||
}
|
||||
}, [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<string, string[]> = {};
|
||||
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]);
|
||||
|
||||
// Helper: cartesian product of axes
|
||||
const cartesian = (axes: { code: string; name: string; values: string[] }[]): Array<Array<{ code: string; value: string }>> => {
|
||||
if (!axes || axes.length === 0) return [];
|
||||
return axes.reduce<Array<Array<{ code: string; value: string }>>>((acc, axis) => {
|
||||
if (!axis.values || axis.values.length === 0) return acc;
|
||||
if (acc.length === 0) return axis.values.map(v => [{ code: axis.code, value: v }]);
|
||||
return acc.flatMap(combo => axis.values.map(v => [...combo, { code: axis.code, value: v }]));
|
||||
}, []);
|
||||
};
|
||||
|
||||
// Helper: build SKU from template
|
||||
const buildSku = (template: string | undefined, pSku: string, combo: Array<{ code: string; value: string }>): string => {
|
||||
let sku = template || '{PARENT_SKU}-{COMBO}';
|
||||
sku = sku.replace('{PARENT_SKU}', pSku || 'SKU');
|
||||
const comboStr = combo.map(c => c.value.replace(/\s+/g, '')).join('-');
|
||||
sku = sku.replace('{COMBO}', comboStr);
|
||||
for (const { code, value } of combo) {
|
||||
sku = sku.replace(new RegExp(`\\{${code}\\}`, 'gi'), value.replace(/\s+/g, ''));
|
||||
}
|
||||
return sku.toUpperCase();
|
||||
};
|
||||
|
||||
// Filter out unconfigured simple master variants (0 attributes) and ensure strict parent productId matching when productId is present
|
||||
const configuredVariants = useMemo(() => {
|
||||
if (!Array.isArray(variants)) return [];
|
||||
return variants.filter(v => {
|
||||
if (!v) return false;
|
||||
const vParentId = v.parentProductId || (v as any).product_id || (v as any).productId;
|
||||
if (productId && vParentId && vParentId !== productId) return false;
|
||||
return v.attributes && typeof v.attributes === 'object' && Object.keys(v.attributes).length > 0;
|
||||
});
|
||||
}, [variants, productId]);
|
||||
|
||||
// Derive axesKeys dynamically from actual configured variants if present, or fallback to localAxes
|
||||
const axesKeys = useMemo(() => {
|
||||
const keysSet = new Set<string>();
|
||||
configuredVariants.forEach(v => {
|
||||
if (v.attributes) {
|
||||
Object.keys(v.attributes).forEach(k => keysSet.add(k));
|
||||
}
|
||||
});
|
||||
if (keysSet.size > 0) {
|
||||
return Array.from(keysSet);
|
||||
}
|
||||
return (localAxes || []).map(a => a.code);
|
||||
}, [configuredVariants, localAxes]);
|
||||
|
||||
const axesKeys = useMemo(() => variantAxes.map(a => a.code), [variantAxes]);
|
||||
|
||||
const axesNames = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
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 (
|
||||
<div className="p-8 text-center bg-surface rounded-xl border border-border shadow-sm">
|
||||
<Layers className="w-10 h-10 text-muted-foreground mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-foreground mb-1">This Product Family does not support variants.</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
The assigned Product Family ({family?.name || 'Selected Family'}) has no variant axes configured.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [localAxes]);
|
||||
|
||||
// If the product is not Configurable (type !== 'variant')
|
||||
if (productType !== 'variant') {
|
||||
@@ -88,22 +291,9 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// If parent product has not been created/saved yet
|
||||
if (!productId) {
|
||||
return (
|
||||
<div className="p-8 text-center bg-surface rounded-xl border border-border shadow-sm">
|
||||
<Info className="w-10 h-10 text-primary mx-auto mb-3 animate-bounce" />
|
||||
<h3 className="font-semibold text-foreground mb-1">Save Product to Configure Variants</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
You must create and save the basic product information first before you can configure and generate variants. Please fill in the required fields in the General step and click **Create Draft** on the header bar.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleGenerate = async (selected: Record<string, string[]>, 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,
|
||||
@@ -111,15 +301,82 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await generateBatch({
|
||||
productId,
|
||||
axes: formattedAxes,
|
||||
skuTemplate
|
||||
});
|
||||
setShowGenerator(false);
|
||||
} catch (err) {
|
||||
// toast notification is done inside the hook
|
||||
if (productId) {
|
||||
try {
|
||||
await generateBatch({
|
||||
productId,
|
||||
axes: formattedAxes,
|
||||
skuTemplate
|
||||
});
|
||||
setShowGenerator(false);
|
||||
} catch (err) {
|
||||
// handled in hook
|
||||
}
|
||||
} else {
|
||||
// Local client-side generation before product is persisted
|
||||
const combinations = cartesian(formattedAxes);
|
||||
if (combinations.length === 0) {
|
||||
notify.error('No combinations could be generated from the selected values');
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect duplicates
|
||||
const existingSignatures = new Set(
|
||||
(variants || []).map(v => {
|
||||
return JSON.stringify(Object.fromEntries(Object.entries(v.attributes || {}).sort()));
|
||||
})
|
||||
);
|
||||
|
||||
const parentSkuVal = parentSku || 'SKU';
|
||||
const newVariants: Variant[] = [];
|
||||
let createdCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const combo of combinations) {
|
||||
const attrMap: Record<string, string> = {};
|
||||
for (const { code, value } of combo) {
|
||||
attrMap[code] = value;
|
||||
}
|
||||
const sig = JSON.stringify(Object.fromEntries(Object.entries(attrMap).sort()));
|
||||
|
||||
if (existingSignatures.has(sig)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const generatedSku = buildSku(skuTemplate, parentSkuVal, combo);
|
||||
const variantName = combo.map(c => c.value).join(' / ');
|
||||
|
||||
const newVariant: Variant = {
|
||||
id: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
sku: generatedSku,
|
||||
name: variantName,
|
||||
parentProductId: '',
|
||||
attributes: attrMap,
|
||||
status: 'draft',
|
||||
price: 0,
|
||||
costPrice: 0,
|
||||
currency: 'USD',
|
||||
stock: 0,
|
||||
availableStock: 0,
|
||||
reservedStock: 0,
|
||||
safetyStock: 0,
|
||||
images: []
|
||||
};
|
||||
|
||||
existingSignatures.add(sig);
|
||||
newVariants.push(newVariant);
|
||||
createdCount++;
|
||||
}
|
||||
|
||||
if (createdCount > 0) {
|
||||
setVariants(prev => [...prev, ...newVariants]);
|
||||
notify.success(`Generated ${createdCount} variant(s)${skippedCount > 0 ? `, skipped ${skippedCount} duplicates` : ''}`);
|
||||
setShowGenerator(false);
|
||||
} else {
|
||||
notify.info('All combinations already exist — no new variants created');
|
||||
setShowGenerator(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,17 +400,16 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
if (productId) fetchByProduct(productId);
|
||||
setSelectedIds(new Set());
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
@@ -161,7 +417,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
try {
|
||||
await Promise.all(Array.from(selectedIds).map(id => deleteVariant(id)));
|
||||
setSelectedIds(new Set());
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
};
|
||||
|
||||
const handleBulkArchive = async () => {
|
||||
@@ -169,13 +425,108 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
try {
|
||||
await Promise.all(Array.from(selectedIds).map(id => archiveVariant(id)));
|
||||
setSelectedIds(new Set());
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
};
|
||||
|
||||
const handleSingleUpdate = async (id: string, updates: Partial<Variant>) => {
|
||||
return updateVariant(id, updates);
|
||||
};
|
||||
|
||||
const renderAxisCreatorControls = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2">
|
||||
{/* Option A: Choose from Attribute Set */}
|
||||
{selectableAttributes.length > 0 && (
|
||||
<div className="border border-border/80 rounded-xl p-5 bg-background/25 space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">A. Choose from Attribute Set</h4>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">Designate a dropdown/select attribute from your assigned Attribute Set.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedAttrId}
|
||||
onChange={(e) => setSelectedAttrId(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 font-medium text-foreground"
|
||||
>
|
||||
<option value="">Select Attribute...</option>
|
||||
{selectableAttributes.map(attr => (
|
||||
<option key={attr.id} value={attr.id}>
|
||||
{attr.name} ({attr.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddAttributeAxis}
|
||||
disabled={!selectedAttrId}
|
||||
className="px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Add Axis
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Option B: Create Custom Axis */}
|
||||
<div className="border border-border/80 rounded-xl p-5 bg-background/25 space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">B. Create Custom Axis</h4>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">Create a custom variant axis not present in the attribute set (e.g. Size, Color).</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Axis Name (e.g. Size)"
|
||||
value={customAxisName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Axis Code (e.g. size)"
|
||||
value={customAxisCode}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCustomAxis}
|
||||
className="px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold"
|
||||
>
|
||||
Add Custom Axis
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List of currently added local axes */}
|
||||
{localAxes.length > 0 && (
|
||||
<div className="border border-border rounded-xl p-5 bg-background/10 space-y-3">
|
||||
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">Designated Variant Axes ({localAxes.length})</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{localAxes.map(axis => (
|
||||
<span key={axis.code} className="inline-flex items-center gap-1.5 px-3 py-1 bg-surface border border-border rounded-lg text-xs font-semibold text-foreground">
|
||||
{axis.name} <span className="text-muted-foreground font-mono">({axis.code})</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveAxis(axis.code)}
|
||||
className="p-0.5 hover:bg-red-50 hover:text-red-500 rounded transition-colors text-muted-foreground"
|
||||
title="Remove axis"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Loading spinner for variant fetching
|
||||
if (loading && variants.length === 0) {
|
||||
return (
|
||||
@@ -185,26 +536,64 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Show generator UI if variants list is empty or generator toggled on
|
||||
if (variants.length === 0 || showGenerator) {
|
||||
// If local variant axes list is empty, they must add at least one axis
|
||||
if (localAxes.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4 bg-surface rounded-xl border border-border p-6 shadow-sm">
|
||||
<div className="border-b border-border pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
<h3 className="font-semibold text-foreground text-sm">Configure Variant Axes</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
This product has no variant axes defined. Designate attributes from your Attribute Set or add custom ones to enable variant generation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{renderAxisCreatorControls()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show generator UI if configured variants list is empty or generator toggled on
|
||||
if (configuredVariants.length === 0 || showGenerator) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{variants.length > 0 && (
|
||||
<div className="flex justify-start">
|
||||
<div className="flex justify-between items-center gap-4 flex-wrap">
|
||||
<div className="flex gap-2">
|
||||
{configuredVariants.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGenerator(false)}
|
||||
className="px-3 py-1.5 border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold"
|
||||
>
|
||||
Cancel and view variants
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGenerator(false)}
|
||||
className="px-3 py-1.5 border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold"
|
||||
onClick={() => setIsAxesConfigOpen(!isAxesConfigOpen)}
|
||||
className="px-3 py-1.5 bg-surface hover:bg-background border border-border text-foreground rounded-lg text-xs font-semibold flex items-center gap-1.5"
|
||||
>
|
||||
Cancel and view variants
|
||||
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{isAxesConfigOpen ? 'Hide Axes Config' : 'Configure Variant Axes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAxesConfigOpen && (
|
||||
<div className="bg-surface rounded-xl border border-border p-5 shadow-xs space-y-4">
|
||||
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">Configure Variant Axes</h4>
|
||||
{renderAxisCreatorControls()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VariantAxesSelector
|
||||
axes={variantAxes}
|
||||
axes={localAxes}
|
||||
onGenerate={handleGenerate}
|
||||
generating={generating}
|
||||
parentSku={parentSku}
|
||||
initialSelectedValues={initialSelectedValues}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -227,7 +616,9 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchByProduct(productId)}
|
||||
onClick={() => {
|
||||
if (productId) fetchByProduct(productId);
|
||||
}}
|
||||
className="p-1.5 border border-border hover:bg-background text-muted-foreground rounded-lg"
|
||||
title="Refresh variants list"
|
||||
>
|
||||
@@ -240,11 +631,10 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewLayout('matrix')}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${
|
||||
viewLayout === 'matrix'
|
||||
? 'bg-surface text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${viewLayout === 'matrix'
|
||||
? 'bg-surface text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
Matrix Grid
|
||||
@@ -252,11 +642,10 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewLayout('list')}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${
|
||||
viewLayout === 'list'
|
||||
? 'bg-surface text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${viewLayout === 'list'
|
||||
? 'bg-surface text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
List Table
|
||||
@@ -275,7 +664,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
{/* Primary variants view switcher */}
|
||||
{viewLayout === 'matrix' ? (
|
||||
<VariantMatrixView
|
||||
variants={variants}
|
||||
variants={configuredVariants}
|
||||
axesKeys={axesKeys}
|
||||
axesNames={axesNames}
|
||||
selectedIds={selectedIds}
|
||||
@@ -288,7 +677,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
/>
|
||||
) : (
|
||||
<VariantListView
|
||||
variants={variants}
|
||||
variants={configuredVariants}
|
||||
axesKeys={axesKeys}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={handleSelectChange}
|
||||
|
||||
@@ -34,13 +34,15 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
|
||||
let setObj = blueprint.attributeSet || blueprint.attribute_set;
|
||||
const setId = blueprint.attribute_set_id || blueprint.attributeSetId || (setObj ? setObj.id : null);
|
||||
if ((!setObj || !setObj.groups) && setId) {
|
||||
if ((!setObj || !setObj.groups || setObj.groups.length === 0) && setId) {
|
||||
const fetchedSet = await attributeSetsService.getById(setId).catch(() => null);
|
||||
if (fetchedSet) setObj = fetchedSet;
|
||||
}
|
||||
setAttributeSet(setObj || null);
|
||||
|
||||
const groupsData = blueprint.groups || blueprint.attributeGroups || [];
|
||||
const groupsData = (setObj && Array.isArray(setObj.groups) && setObj.groups.length > 0)
|
||||
? setObj.groups
|
||||
: (blueprint.groups || blueprint.attributeGroups || []);
|
||||
let flatAttrs: any[] = [];
|
||||
if (Array.isArray(groupsData) && groupsData.length > 0) {
|
||||
setGroups(groupsData);
|
||||
@@ -72,8 +74,20 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
? blueprint.variantAxes.map((va: any) => typeof va === 'string' ? (allAttrsMap.get(va) || { id: va, code: va, name: va }) : va)
|
||||
: [];
|
||||
|
||||
const resolvedProductType =
|
||||
blueprint.productType ||
|
||||
blueprint.product_type ||
|
||||
(blueprint.completenessRules ? blueprint.completenessRules.productType : null) ||
|
||||
null;
|
||||
|
||||
const normalizedBlueprint = {
|
||||
...blueprint,
|
||||
attributeSet: setObj || null,
|
||||
attributeSetId: setId || null,
|
||||
attribute_set_id: setId || null,
|
||||
groups: groupsData,
|
||||
attributes: flatAttrs,
|
||||
productType: resolvedProductType,
|
||||
variantAxes: resolvedVariantAxes,
|
||||
variantEnabled: resolvedVariantAxes.length > 0 || Boolean(blueprint.variantEnabled)
|
||||
};
|
||||
@@ -81,7 +95,7 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
setFamily(normalizedBlueprint);
|
||||
setAllowedBrands(blueprint.allowedBrands || []);
|
||||
setCategory(blueprint.category || null);
|
||||
setAttributeSet(blueprint.attributeSet || null);
|
||||
setAttributeSet(setObj || null);
|
||||
setWorkflow(blueprint.workflow || (blueprint.workflowCode ? { code: blueprint.workflowCode } : null));
|
||||
setAssetFamily(blueprint.assetRequirements || blueprint.assetFamily || null);
|
||||
|
||||
|
||||
@@ -8,12 +8,15 @@ import type {
|
||||
} from '../types/variant.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useVariant = () => {
|
||||
const [variants, setVariants] = useState<Variant[]>([]);
|
||||
export const useVariant = (initialVariants: Variant[] = []) => {
|
||||
const [variants, setVariants] = useState<Variant[]>(initialVariants);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const fetchByProduct = useCallback(async (productId: string) => {
|
||||
if (!productId || productId === 'new' || productId === 'null' || productId === 'undefined') {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await variantService.getByProduct(productId);
|
||||
@@ -26,6 +29,10 @@ export const useVariant = () => {
|
||||
}, []);
|
||||
|
||||
const updateVariant = useCallback(async (id: string, updates: VariantUpdateRequest) => {
|
||||
if (id.startsWith('temp-')) {
|
||||
setVariants(prev => prev.map(v => v.id === id ? { ...v, ...updates } : v));
|
||||
return { id, ...updates } as Variant;
|
||||
}
|
||||
try {
|
||||
const updated = await variantService.update(id, updates);
|
||||
setVariants(prev => prev.map(v => v.id === id ? { ...v, ...updated } : v));
|
||||
@@ -37,6 +44,11 @@ export const useVariant = () => {
|
||||
}, []);
|
||||
|
||||
const deleteVariant = useCallback(async (id: string) => {
|
||||
if (id.startsWith('temp-')) {
|
||||
setVariants(prev => prev.filter(v => v.id !== id));
|
||||
toast.success('Variant deleted');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await variantService.delete(id);
|
||||
setVariants(prev => prev.filter(v => v.id !== id));
|
||||
@@ -48,6 +60,11 @@ export const useVariant = () => {
|
||||
}, []);
|
||||
|
||||
const archiveVariant = useCallback(async (id: string) => {
|
||||
if (id.startsWith('temp-')) {
|
||||
setVariants(prev => prev.map(v => v.id === id ? { ...v, status: 'archived' } : v));
|
||||
toast.success('Variant archived');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await variantService.archive(id);
|
||||
setVariants(prev => prev.map(v => v.id === id ? { ...v, status: 'archived' } : v));
|
||||
@@ -78,18 +95,29 @@ export const useVariant = () => {
|
||||
}, []);
|
||||
|
||||
const bulkUpdate = useCallback(async (req: BulkUpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await variantService.bulkUpdate(req);
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
// Refresh variants after bulk
|
||||
toast.success(`Updated ${successCount}/${req.ids.length} variants`);
|
||||
return results;
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Bulk update failed');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const tempIds = req.ids.filter(id => id.startsWith('temp-'));
|
||||
const realIds = req.ids.filter(id => !id.startsWith('temp-'));
|
||||
|
||||
if (tempIds.length > 0) {
|
||||
setVariants(prev => prev.map(v => tempIds.includes(v.id) ? { ...v, ...req.updates } : v));
|
||||
}
|
||||
|
||||
if (realIds.length > 0) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await variantService.bulkUpdate({ ...req, ids: realIds });
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
toast.success(`Updated ${successCount + tempIds.length}/${req.ids.length} variants`);
|
||||
return results;
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Bulk update failed');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
toast.success(`Updated ${tempIds.length}/${req.ids.length} variants`);
|
||||
return tempIds.map(id => ({ id, success: true }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 { channelsApi } from '../../channels/api/channels.api';
|
||||
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';
|
||||
@@ -68,6 +69,33 @@ export default function NewProduct() {
|
||||
const attributeSearchDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [customAddedAttributes, setCustomAddedAttributes] = useState<any[]>([]);
|
||||
|
||||
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<Set<string>>(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<string[]>([]);
|
||||
const [newlyCreatedUnitIds, setNewlyCreatedUnitIds] = useState<string[]>([]);
|
||||
|
||||
@@ -93,7 +121,12 @@ export default function NewProduct() {
|
||||
channels: f.channelCount ?? (Array.isArray(f.channels) ? f.channels.length : 0),
|
||||
workflow: f.workflowCode || 'Standard Approval',
|
||||
allowedBrands: f.allowedBrands || [],
|
||||
allowedUnits: f.allowedUnits || []
|
||||
allowedUnits: f.allowedUnits || [],
|
||||
category: f.category,
|
||||
categoryId: f.category_id || f.categoryId || (typeof f.category === 'object' ? f.category?.id : f.category) || null,
|
||||
productType: f.productType || f.product_type || (f.completenessRules as any)?.productType || null,
|
||||
attributeSetId: f.attributeSetId || f.attribute_set_id || (f.attributeSet ? f.attributeSet.id : null) || null,
|
||||
attributeSet: f.attributeSet || null
|
||||
}));
|
||||
}, [families]);
|
||||
|
||||
@@ -128,9 +161,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];
|
||||
@@ -211,14 +244,20 @@ export default function NewProduct() {
|
||||
|
||||
// Sync Attribute Set ID from Product Family configuration
|
||||
useEffect(() => {
|
||||
if (attributeSet) {
|
||||
setSelectedAttributeSetId(attributeSet.id);
|
||||
setSelectedAttributeSetObj(attributeSet);
|
||||
if (!isEdit && selectedFamily && creationMethod !== 'clone') {
|
||||
if (attributeSet) {
|
||||
setSelectedAttributeSetId(attributeSet.id);
|
||||
setSelectedAttributeSetObj(attributeSet);
|
||||
} else if (family && !family.attributeSet && !family.attributeSetId && !family.attribute_set_id) {
|
||||
setSelectedAttributeSetId(null);
|
||||
setSelectedAttributeSetObj(null);
|
||||
}
|
||||
}
|
||||
}, [attributeSet]);
|
||||
}, [attributeSet, family, selectedFamily, isEdit, creationMethod]);
|
||||
|
||||
const handleAttributeSetChange = async (setId: string) => {
|
||||
setSelectedAttributeSetId(setId || null);
|
||||
setExcludedGroupIds(new Set());
|
||||
if (!setId) {
|
||||
setSelectedAttributeSetObj(null);
|
||||
setCustomAddedAttributes([]);
|
||||
@@ -242,12 +281,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 +389,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 [];
|
||||
@@ -587,7 +648,7 @@ export default function NewProduct() {
|
||||
await productService.update(targetId, submissionValues as any);
|
||||
await hydrateProductEditor(targetId);
|
||||
if (submissionValues.status === 'active') {
|
||||
notify.success("Product published successfully with Active status!");
|
||||
notify.success("Product updated successfully with Active status!");
|
||||
} else {
|
||||
notify.success("Changes saved successfully!");
|
||||
}
|
||||
@@ -595,7 +656,7 @@ export default function NewProduct() {
|
||||
const createdRaw: any = await productService.create({ ...submissionValues, family_id: selectedFamily } as any);
|
||||
const created = createdRaw?.data?.id ? createdRaw.data : (createdRaw?.id ? createdRaw : createdRaw?.data || createdRaw);
|
||||
if (submissionValues.status === 'active') {
|
||||
notify.success("Product published successfully with Active status!");
|
||||
notify.success("Product created successfully with Active status!");
|
||||
} else {
|
||||
notify.success("Product draft created successfully!");
|
||||
}
|
||||
@@ -603,7 +664,9 @@ export default function NewProduct() {
|
||||
setIsEditMode(true);
|
||||
navigate(`/products/${created.id}/edit`, { replace: true });
|
||||
await hydrateProductEditor(created.id);
|
||||
setActiveTab('attributes');
|
||||
if (activeTab === 'general') {
|
||||
setActiveTab('attributes');
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || err?.message || 'Unable to create product.';
|
||||
@@ -614,6 +677,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 +697,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 +711,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(() => {
|
||||
@@ -648,6 +721,20 @@ export default function NewProduct() {
|
||||
}
|
||||
}, [activeTab, currentTabs]);
|
||||
|
||||
const [maxUnlockedStep, setMaxUnlockedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isReadOnlyView) {
|
||||
setMaxUnlockedStep(currentTabs.length);
|
||||
}
|
||||
}, [isEdit, isReadOnlyView, currentTabs.length]);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isReadOnlyView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
return stepNum <= maxUnlockedStep;
|
||||
}, [isEdit, isReadOnlyView, maxUnlockedStep]);
|
||||
|
||||
const isNextDisabled = useMemo(() => {
|
||||
// 1. Core General fields check (always required to proceed)
|
||||
const isGeneralInvalid = !formik.values.name || !formik.values.brand || !formik.values.unit || !formik.values.category;
|
||||
@@ -732,6 +819,47 @@ export default function NewProduct() {
|
||||
formik.handleSubmit();
|
||||
};
|
||||
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
|
||||
const handlePublishToChannels = async () => {
|
||||
const isCurrentActive = product?.status === 'active' || formik.values.status === 'active';
|
||||
if (!isCurrentActive) {
|
||||
notify.error('A product must be created as ACTIVE before it can be published.');
|
||||
return;
|
||||
}
|
||||
|
||||
const inheritedCodes = (family?.channels || []).map((fc: any) => fc.channel_code);
|
||||
const optionalCodes = formik.values.metadata?.channels || [];
|
||||
const allSelectedCodes = Array.from(new Set([...inheritedCodes, ...optionalCodes]));
|
||||
|
||||
if (allSelectedCodes.length === 0) {
|
||||
notify.error('Please select at least one channel in the Channels step before publishing.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const targetChannels = (allChannels || []).filter((ch: any) => allSelectedCodes.includes(ch.code));
|
||||
|
||||
for (const ch of targetChannels) {
|
||||
if (ch.id) {
|
||||
try {
|
||||
await channelsApi.triggerSyndication(ch.id);
|
||||
} catch (syndErr) {
|
||||
console.warn(`Syndication trigger warning for channel ${ch.name || ch.code}:`, syndErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notify.success(`Product successfully published to ${allSelectedCodes.length} selected channel(s)!`);
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || err?.message || 'Failed to publish product to channels.';
|
||||
notify.error(msg);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setValuesRef = useRef(formik.setValues);
|
||||
useEffect(() => {
|
||||
setValuesRef.current = formik.setValues;
|
||||
@@ -891,7 +1019,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;
|
||||
}
|
||||
|
||||
@@ -938,12 +1068,49 @@ export default function NewProduct() {
|
||||
}
|
||||
}, [id, productId, hydrateProductEditor]);
|
||||
|
||||
// Set default category when product family loads (Category Inheritance)
|
||||
// Set category, product type, and attribute set when product family is selected/changed (Inheritance in Create mode)
|
||||
const prevSelectedFamilyRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (familyCategory && !formik.values.category) {
|
||||
formik.setFieldValue('category', familyCategory.id);
|
||||
if (!isEdit && selectedFamily) {
|
||||
if (prevSelectedFamilyRef.current !== selectedFamily) {
|
||||
prevSelectedFamilyRef.current = selectedFamily;
|
||||
const resolvedCategoryId =
|
||||
(familyCategory && typeof familyCategory === 'object' ? familyCategory.id : familyCategory) ||
|
||||
(family?.category && typeof family.category === 'object' ? family.category.id : family?.category) ||
|
||||
family?.category_id ||
|
||||
family?.categoryId ||
|
||||
'';
|
||||
formik.setFieldValue('category', resolvedCategoryId || '', true);
|
||||
|
||||
const resolvedProductType =
|
||||
family?.productType ||
|
||||
selectedFamilyObj?.productType ||
|
||||
null;
|
||||
if (resolvedProductType) {
|
||||
formik.setFieldValue('type', resolvedProductType, true);
|
||||
}
|
||||
|
||||
const resolvedSet = attributeSet || family?.attributeSet || selectedFamilyObj?.attributeSet || null;
|
||||
const resolvedSetId = resolvedSet?.id || family?.attributeSetId || family?.attribute_set_id || selectedFamilyObj?.attributeSetId || null;
|
||||
if (resolvedSet) {
|
||||
setSelectedAttributeSetId(resolvedSet.id);
|
||||
setSelectedAttributeSetObj(resolvedSet);
|
||||
} else if (resolvedSetId) {
|
||||
setSelectedAttributeSetId(resolvedSetId);
|
||||
attributeSetsService.getById(resolvedSetId).then(res => {
|
||||
if (res) setSelectedAttributeSetObj(res);
|
||||
}).catch(() => { });
|
||||
} else if (family && !family.attributeSet && !family.attributeSetId && !family.attribute_set_id) {
|
||||
setSelectedAttributeSetId(null);
|
||||
setSelectedAttributeSetObj(null);
|
||||
}
|
||||
}
|
||||
} else if (!selectedFamily && !isEdit) {
|
||||
prevSelectedFamilyRef.current = null;
|
||||
setSelectedAttributeSetId(null);
|
||||
setSelectedAttributeSetObj(null);
|
||||
}
|
||||
}, [familyCategory]);
|
||||
}, [selectedFamily, familyCategory, family, attributeSet, selectedFamilyObj, isEdit]);
|
||||
|
||||
// Generate dynamic product code if empty and name is provided
|
||||
useEffect(() => {
|
||||
@@ -1027,14 +1194,47 @@ export default function NewProduct() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset previous custom attributes and excluded groups
|
||||
setExcludedGroupIds(new Set());
|
||||
setCustomAddedAttributes([]);
|
||||
|
||||
// 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);
|
||||
let blueprint: any = null;
|
||||
if (familyId) {
|
||||
setSelectedFamily(familyId);
|
||||
await loadConfiguration(familyId);
|
||||
blueprint = await loadConfiguration(familyId);
|
||||
} else {
|
||||
setSelectedFamily(null);
|
||||
}
|
||||
|
||||
// Extract saved attributes
|
||||
// Resolve Attribute Set from blueprint, productData, family, or metadata
|
||||
let resolvedSet: any = blueprint?.attributeSet || productData.family?.attributeSet || productData.attributeSet || null;
|
||||
let resolvedSetId: string | null = blueprint?.attributeSetId || blueprint?.attribute_set_id || productData.family?.attributeSetId || productData.family?.attribute_set_id || productData.metadata?.attributeSetId || productData.metadata?.attribute_set_id || productData.attributeSetId || productData.attribute_set_id || (resolvedSet ? resolvedSet.id : null);
|
||||
|
||||
if ((!resolvedSet || !resolvedSet.groups || resolvedSet.groups.length === 0) && resolvedSetId) {
|
||||
try {
|
||||
const setDetails = await attributeSetsService.getById(resolvedSetId);
|
||||
if (setDetails) {
|
||||
resolvedSet = setDetails;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load attribute set details during clone:", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedSet && resolvedSet.id) {
|
||||
setSelectedAttributeSetId(resolvedSet.id);
|
||||
setSelectedAttributeSetObj(resolvedSet);
|
||||
} else if (resolvedSetId) {
|
||||
setSelectedAttributeSetId(resolvedSetId);
|
||||
setSelectedAttributeSetObj(resolvedSet || null);
|
||||
} else {
|
||||
setSelectedAttributeSetId(null);
|
||||
setSelectedAttributeSetObj(null);
|
||||
}
|
||||
|
||||
// Extract saved attributes from productData
|
||||
const savedAttrs: Record<string, any> = {
|
||||
...(productData.metadata?.attributes || {}),
|
||||
...(productData.attributes || {})
|
||||
@@ -1048,6 +1248,73 @@ export default function NewProduct() {
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize blueprint attributes for all defined fields in the attribute set
|
||||
const blueprintAttrs: Record<string, any> = {};
|
||||
if (resolvedSet?.groups) {
|
||||
resolvedSet.groups.forEach((g: any) => {
|
||||
if (Array.isArray(g.attributes)) {
|
||||
g.attributes.forEach((attr: any) => {
|
||||
if (attr && attr.code) {
|
||||
blueprintAttrs[attr.code] = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (blueprint && Array.isArray(blueprint.attributes)) {
|
||||
blueprint.attributes.forEach((attr: any) => {
|
||||
if (attr && attr.code) {
|
||||
blueprintAttrs[attr.code] = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const mergedAttributes = {
|
||||
...blueprintAttrs,
|
||||
...savedAttrs
|
||||
};
|
||||
|
||||
// Collect codes in the resolved Attribute Set
|
||||
const setAttrCodes = new Set<string>();
|
||||
if (resolvedSet?.groups) {
|
||||
resolvedSet.groups.forEach((g: any) => {
|
||||
if (Array.isArray(g.attributes)) {
|
||||
g.attributes.forEach((a: any) => {
|
||||
if (a.code) setAttrCodes.add(a.code.toLowerCase());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Collect any custom attributes not in the resolved attribute set
|
||||
const customAdded: any[] = [];
|
||||
if (Array.isArray(productData.attributeValues)) {
|
||||
productData.attributeValues.forEach((av: any) => {
|
||||
const code = (av.attribute?.code || av.attribute_code || '').toLowerCase();
|
||||
if (code && !setAttrCodes.has(code) && !EXCLUDED_ATTRIBUTE_CODES.includes(code)) {
|
||||
const attrObj = av.attribute || allRegistryAttributes.find((a: any) => (a.code || '').toLowerCase() === code);
|
||||
if (attrObj && !customAdded.some((x: any) => x.id === attrObj.id || (x.code || '').toLowerCase() === code)) {
|
||||
customAdded.push(attrObj);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Object.keys(savedAttrs).forEach((code) => {
|
||||
const codeLower = code.toLowerCase();
|
||||
if (!setAttrCodes.has(codeLower) && !EXCLUDED_ATTRIBUTE_CODES.includes(codeLower)) {
|
||||
let attrObj = allRegistryAttributes.find((a: any) => (a.code || '').toLowerCase() === codeLower);
|
||||
if (!attrObj && Array.isArray(productData.attributeValues)) {
|
||||
const matchAv = productData.attributeValues.find((av: any) => (av.attribute?.code || av.attribute_code || '').toLowerCase() === codeLower);
|
||||
if (matchAv?.attribute) attrObj = matchAv.attribute;
|
||||
}
|
||||
if (attrObj && !customAdded.some((x: any) => x.id === attrObj.id || (x.code || '').toLowerCase() === codeLower)) {
|
||||
customAdded.push(attrObj);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setCustomAddedAttributes(customAdded);
|
||||
|
||||
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) || '';
|
||||
@@ -1077,7 +1344,7 @@ export default function NewProduct() {
|
||||
...(productData.metadata || {}),
|
||||
currentStage: 'draft',
|
||||
},
|
||||
attributes: savedAttrs
|
||||
attributes: mergedAttributes
|
||||
});
|
||||
notify.success(`Template preloaded from product: ${productData.name}`);
|
||||
} catch (err) {
|
||||
@@ -1112,6 +1379,70 @@ export default function NewProduct() {
|
||||
>
|
||||
<Pencil className="w-4 h-4" /> Edit Product
|
||||
</button>
|
||||
) : activeTab === 'review' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={formik.isSubmitting || isPublishing}
|
||||
onClick={() => handleSubmitWithValidation('draft')}
|
||||
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors cursor-pointer disabled:opacity-50 flex items-center gap-2 shadow-xs"
|
||||
>
|
||||
{formik.isSubmitting && formik.values.status === 'draft' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Saving Draft...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
Create Draft
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={formik.isSubmitting || isPublishing}
|
||||
onClick={() => handleSubmitWithValidation('active')}
|
||||
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors cursor-pointer disabled:opacity-50 flex items-center gap-2 shadow-xs"
|
||||
>
|
||||
{formik.isSubmitting && formik.values.status === 'active' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Creating Product...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Create Product
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
formik.isSubmitting ||
|
||||
isPublishing ||
|
||||
(product?.status !== 'active' && formik.values.status !== 'active')
|
||||
}
|
||||
onClick={handlePublishToChannels}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 shadow-xs ${(product?.status === 'active' || formik.values.status === 'active')
|
||||
? 'bg-primary hover:bg-primary-hover text-white cursor-pointer'
|
||||
: 'bg-surface-muted text-muted-foreground cursor-not-allowed border border-border opacity-60'
|
||||
}`}
|
||||
>
|
||||
{isPublishing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Publishing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
Publish
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : !isEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1244,21 +1575,22 @@ export default function NewProduct() {
|
||||
const isActive = activeTab === tab.id;
|
||||
const isDone = tab.step < activeIndex + 1;
|
||||
const isLast = idx === currentTabs.length - 1;
|
||||
const accessible = isStepAccessible(tab.step);
|
||||
return (
|
||||
<div key={tab.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isEdit || tab.id === 'general') {
|
||||
if (accessible) {
|
||||
setActiveTab(tab.id);
|
||||
}
|
||||
}}
|
||||
disabled={!isEdit && tab.id !== 'general'}
|
||||
disabled={!accessible}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? 'bg-primary ring-2 ring-primary/20' :
|
||||
isDone ? 'bg-success' :
|
||||
'bg-surface border-2 border-border hover:border-primary/30'
|
||||
} ${(!isEdit && tab.id !== 'general') ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
{isDone
|
||||
? <Check className="w-3 h-3 text-white" />
|
||||
@@ -1272,12 +1604,12 @@ export default function NewProduct() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isEdit || tab.id === 'general') {
|
||||
if (accessible) {
|
||||
setActiveTab(tab.id);
|
||||
}
|
||||
}}
|
||||
disabled={!isEdit && tab.id !== 'general'}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${(!isEdit && tab.id !== 'general') ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
disabled={!accessible}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${isActive ? 'text-primary-dark' : isDone ? 'text-muted-foreground' : 'text-muted-foreground hover:text-muted-foreground'
|
||||
}`}>{tab.label}</span>
|
||||
@@ -1302,13 +1634,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"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-sm font-bold text-foreground">
|
||||
{product?.completeness !== undefined && product?.completeness !== null && product.completeness > 0 ? product.completeness : productCompleteness}%
|
||||
{product?.completeness !== undefined && product?.completeness !== null ? product.completeness : productCompleteness}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1316,9 +1648,14 @@ export default function NewProduct() {
|
||||
<div className="space-y-1.5 text-[11px]">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={label} className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
@@ -1353,6 +1690,10 @@ export default function NewProduct() {
|
||||
setCreationMethod(val as any);
|
||||
setSelectedFamily(null);
|
||||
setClonedProductId(null);
|
||||
setSelectedAttributeSetId(null);
|
||||
setSelectedAttributeSetObj(null);
|
||||
setCustomAddedAttributes([]);
|
||||
setExcludedGroupIds(new Set());
|
||||
formik.resetForm();
|
||||
}}
|
||||
>
|
||||
@@ -1393,7 +1734,47 @@ export default function NewProduct() {
|
||||
key={fam.id}
|
||||
onClick={async () => {
|
||||
setSelectedFamily(fam.id);
|
||||
await loadConfiguration(fam.id);
|
||||
const cfg = await loadConfiguration(fam.id);
|
||||
const resolvedCatId =
|
||||
(cfg?.category && typeof cfg.category === 'object' ? cfg.category.id : cfg?.category) ||
|
||||
cfg?.category_id ||
|
||||
cfg?.categoryId ||
|
||||
(fam.category && typeof fam.category === 'object' ? fam.category.id : fam.category) ||
|
||||
fam.categoryId ||
|
||||
fam.category_id ||
|
||||
'';
|
||||
formik.setFieldValue('category', resolvedCatId || '', true);
|
||||
formik.setFieldTouched('category', true, false);
|
||||
|
||||
const resolvedProdType =
|
||||
cfg?.productType ||
|
||||
fam.productType ||
|
||||
(fam.completenessRules as any)?.productType ||
|
||||
null;
|
||||
if (resolvedProdType) {
|
||||
formik.setFieldValue('type', resolvedProdType, true);
|
||||
formik.setFieldTouched('type', true, false);
|
||||
}
|
||||
|
||||
const resolvedSet = cfg?.attributeSet || fam.attributeSet || null;
|
||||
const resolvedSetId = resolvedSet?.id || cfg?.attributeSetId || cfg?.attribute_set_id || fam.attributeSetId || fam.attribute_set_id || null;
|
||||
if (resolvedSet) {
|
||||
setSelectedAttributeSetId(resolvedSet.id);
|
||||
setSelectedAttributeSetObj(resolvedSet);
|
||||
} else if (resolvedSetId) {
|
||||
setSelectedAttributeSetId(resolvedSetId);
|
||||
const fetched = await attributeSetsService.getById(resolvedSetId).catch(() => null);
|
||||
if (fetched) {
|
||||
setSelectedAttributeSetObj(fetched);
|
||||
} else {
|
||||
setSelectedAttributeSetId(null);
|
||||
setSelectedAttributeSetObj(null);
|
||||
}
|
||||
} else {
|
||||
setSelectedAttributeSetId(null);
|
||||
setSelectedAttributeSetObj(null);
|
||||
}
|
||||
|
||||
setIsFamilyDropdownOpen(false);
|
||||
setFamilySearchQuery('');
|
||||
}}
|
||||
@@ -1548,10 +1929,7 @@ export default function NewProduct() {
|
||||
<label className={labelClass}>Price ($)</label>
|
||||
<input name="price" type="number" step="0.01" value={formik.values.price} onChange={formik.handleChange} onBlur={formik.handleBlur} placeholder="0.00" className={inputClass} disabled={isReadOnlyView} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Initial Stock</label>
|
||||
<input name="stock" type="number" value={formik.values.stock} onChange={formik.handleChange} onBlur={formik.handleBlur} placeholder="0" className={inputClass} disabled={isReadOnlyView} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>SKU</label>
|
||||
<input name="sku" value={formik.values.sku} onChange={formik.handleChange} placeholder="Stock Keeping Unit" className={inputClass} disabled={isReadOnlyView} />
|
||||
@@ -1747,7 +2125,18 @@ export default function NewProduct() {
|
||||
|
||||
{/* Attribute Set dropdown */}
|
||||
<div className="flex-1 min-w-[280px] max-w-md">
|
||||
<label className="block text-xs font-semibold text-muted-foreground mb-1.5">Attribute Set</label>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="block text-xs font-semibold text-muted-foreground">Attribute Set</label>
|
||||
{selectedAttributeSetId && !isReadOnlyView && !(family && family.attributeSet) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAttributeSetChange('')}
|
||||
className="text-[11px] text-red-500 hover:text-red-600 font-semibold transition-colors cursor-pointer"
|
||||
>
|
||||
Remove Set
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Searchable Attribute Set Selector */}
|
||||
<div ref={attributeSetDropdownRef} className="relative">
|
||||
@@ -1928,44 +2317,47 @@ 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'variants' && (
|
||||
!isEdit ? (
|
||||
<div className="bg-surface border border-border rounded-xl p-8 text-center flex flex-col items-center justify-center min-h-[300px]">
|
||||
<Tags className="w-10 h-10 text-muted-foreground mb-3 animate-pulse" />
|
||||
<h3 className="font-semibold text-foreground mb-1">Please save the product before managing variants.</h3>
|
||||
<p className="text-xs text-muted-foreground">Variants matrix generation and overrides require a persistent product entity.</p>
|
||||
</div>
|
||||
) : (
|
||||
<VariantsTab
|
||||
productId={id || productId}
|
||||
productType={formik.values.type}
|
||||
parentSku={formik.values.sku}
|
||||
family={family}
|
||||
readOnly={isReadOnlyView}
|
||||
/>
|
||||
)
|
||||
<VariantsTab
|
||||
productId={id || productId}
|
||||
productType={formik.values.type}
|
||||
parentSku={formik.values.sku}
|
||||
family={family}
|
||||
readOnly={isReadOnlyView}
|
||||
productAttributes={formik.values.attributes}
|
||||
availableAttributes={activeAttributesList}
|
||||
initialVariants={product?.variants || []}
|
||||
onVariantsChange={(vars) => {
|
||||
setProduct((prev: any) => ({ ...(prev || {}), variants: vars }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'assets' && (
|
||||
!isEdit ? (
|
||||
<div className="bg-surface border border-border rounded-xl p-8 text-center flex flex-col items-center justify-center min-h-[300px]">
|
||||
<ImageIcon className="w-10 h-10 text-muted-foreground mb-3 animate-pulse" />
|
||||
<h3 className="font-semibold text-foreground mb-1">Save product before uploading assets.</h3>
|
||||
<p className="text-xs text-muted-foreground">Digital Asset Management maps files directly to database product IDs.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ProductAssetsTab
|
||||
productId={id || productId}
|
||||
family={family}
|
||||
readOnly={isReadOnlyView}
|
||||
refreshProductData={refreshProductData}
|
||||
/>
|
||||
)
|
||||
<ProductAssetsTab
|
||||
productId={id || productId}
|
||||
family={family}
|
||||
readOnly={isReadOnlyView}
|
||||
refreshProductData={refreshProductData}
|
||||
variants={product?.variants || []}
|
||||
initialAssets={product?.productAssets || []}
|
||||
initialVariantAssets={product?.variantAssets || []}
|
||||
onAssetsChange={(assets, variantAssets) => {
|
||||
setProduct((prev: any) => ({
|
||||
...(prev || {}),
|
||||
productAssets: assets,
|
||||
variantAssets: variantAssets
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'channels' && (
|
||||
@@ -2074,12 +2466,7 @@ export default function NewProduct() {
|
||||
{formik.values.price !== '' ? `$${formik.values.price}` : (product?.price ? `$${product.price}` : '—')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Initial Stock</div>
|
||||
<div className="font-semibold text-sm text-foreground">
|
||||
{formik.values.stock !== undefined ? formik.values.stock : (product?.stock ?? 0)} pcs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Unit of Measure</div>
|
||||
<div className="font-medium text-sm text-foreground">
|
||||
@@ -2246,8 +2633,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 (
|
||||
<div className="bg-surface border border-border rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -2255,7 +2655,7 @@ export default function NewProduct() {
|
||||
<ImageIcon className="w-4 h-4 text-primary" />
|
||||
<h3 className="font-semibold text-sm text-foreground">Assets</h3>
|
||||
<span className="text-xs text-muted-foreground font-medium">
|
||||
{assets.length > 0 ? `${assets.length} uploaded` : 'None uploaded'}
|
||||
{allReviewAssets.length > 0 ? `${allReviewAssets.length} uploaded` : 'None uploaded'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -2266,15 +2666,15 @@ export default function NewProduct() {
|
||||
<Pencil className="w-3 h-3" /> Edit
|
||||
</button>
|
||||
</div>
|
||||
{assets.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{assets.slice(0, ASSET_PREVIEW).map((pa: any, idx: number) => {
|
||||
{allReviewAssets.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{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 (
|
||||
<div key={pa.id || idx} className="flex items-center gap-3 p-2.5 border border-border rounded-lg bg-background">
|
||||
{thumb ? (
|
||||
@@ -2292,16 +2692,19 @@ export default function NewProduct() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[10px] font-bold text-primary bg-primary/10 border border-primary/20 px-1.5 py-0.5 rounded uppercase">
|
||||
{role ? role.replace('_', ' ') : 'HERO IMAGE'}
|
||||
<span className="text-[9px] font-bold text-primary bg-primary/10 border border-primary/20 px-1.5 py-0.5 rounded uppercase">
|
||||
{role ? String(role).replace('_', ' ') : 'MEDIA'}
|
||||
</span>
|
||||
<span className="text-[9px] font-medium text-muted-foreground bg-surface-muted border border-border px-1.5 py-0.5 rounded truncate">
|
||||
{scope}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{assets.length > ASSET_PREVIEW && (
|
||||
<div className="text-xs text-muted-foreground text-center pt-1">+{assets.length - ASSET_PREVIEW} more assets</div>
|
||||
{allReviewAssets.length > ASSET_PREVIEW && (
|
||||
<div className="col-span-2 text-xs text-muted-foreground text-center pt-1">+{allReviewAssets.length - ASSET_PREVIEW} more assets</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -2445,12 +2848,21 @@ export default function NewProduct() {
|
||||
disabled={isNextDisabled}
|
||||
onClick={async () => {
|
||||
const errors: any = await formik.validateForm();
|
||||
const currentIdx = currentTabs.findIndex(t => t.id === activeTab);
|
||||
const nextTabObj = currentTabs[currentIdx + 1];
|
||||
|
||||
const advanceToNext = () => {
|
||||
if (nextTabObj) {
|
||||
setMaxUnlockedStep(prev => Math.max(prev, nextTabObj.step));
|
||||
setActiveTab(nextTabObj.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (activeTab === 'general') {
|
||||
const requiredFields = ['name', 'brand', 'unit', 'category'];
|
||||
const hasRequiredErrors = Object.keys(errors).some(k => requiredFields.includes(k));
|
||||
if (!hasRequiredErrors) {
|
||||
setActiveTab(currentTabs[currentTabs.findIndex(t => t.id === activeTab) + 1].id);
|
||||
advanceToNext();
|
||||
} else {
|
||||
const touchedObj: any = { ...formik.touched };
|
||||
requiredFields.forEach(k => {
|
||||
@@ -2476,7 +2888,7 @@ export default function NewProduct() {
|
||||
}
|
||||
|
||||
if (!hasRequiredErrors && !hasAttributeErrors && isAttributesComplete) {
|
||||
setActiveTab(currentTabs[currentTabs.findIndex(t => t.id === activeTab) + 1].id);
|
||||
advanceToNext();
|
||||
} else {
|
||||
const touchedObj: any = {
|
||||
...formik.touched,
|
||||
@@ -2510,7 +2922,7 @@ export default function NewProduct() {
|
||||
formik.setTouched(touchedObj);
|
||||
}
|
||||
} else {
|
||||
setActiveTab(currentTabs[currentTabs.findIndex(t => t.id === activeTab) + 1].id);
|
||||
advanceToNext();
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
@@ -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 ──────────────────────────────
|
||||
|
||||
@@ -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<string, any>) => {
|
||||
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<any[]> => {
|
||||
const response: any = await axiosInstance.get('/settings');
|
||||
return response.data?.data || response.data || [];
|
||||
},
|
||||
getById: async (id: string): Promise<any> => {
|
||||
const response: any = await axiosInstance.get(`/settings/${id}`);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
create: async (data: any): Promise<any> => {
|
||||
const response: any = await axiosInstance.post('/settings', data);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
update: async (id: string, data: any): Promise<any> => {
|
||||
const response: any = await axiosInstance.put(`/settings/${id}`, data);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
delete: async (id: string): Promise<any> => {
|
||||
const response: any = await axiosInstance.delete(`/settings/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,6 +181,39 @@ export default function NewVariant() {
|
||||
const activeProducts = products.filter(p => p.status === 'active');
|
||||
const isLoading = productsLoading || variantLoading || parentLoading;
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isBasicValid = Boolean(formik.values.sku?.trim() && formik.values.name?.trim() && formik.values.productId && !formik.errors.sku && !formik.errors.name && !formik.errors.productId);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isBasicValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'basic') {
|
||||
if (!isBasicValid) {
|
||||
formik.setFieldTouched('sku', true);
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('productId', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isBasicValid, activeIndex, formik]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="products.variants">
|
||||
<PageWrapper>
|
||||
@@ -235,17 +268,23 @@ export default function NewVariant() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
|
||||
isActive ? 'bg-primary ring-2 ring-primary/20' :
|
||||
isDone ? 'bg-success' :
|
||||
'bg-surface border-2 border-border hover:border-primary/30'
|
||||
}`}
|
||||
} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
{isDone
|
||||
? <Check className="w-3 h-3 text-white" />
|
||||
@@ -258,8 +297,13 @@ export default function NewVariant() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''}`}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${
|
||||
isActive ? 'text-primary' : isDone ? 'text-muted-foreground' : 'text-muted-foreground hover:text-muted-foreground'
|
||||
@@ -586,7 +630,7 @@ export default function NewVariant() {
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Grid3x3,
|
||||
Tag,
|
||||
Layers,
|
||||
Database,
|
||||
Ruler,
|
||||
Award,
|
||||
Image,
|
||||
|
||||
Reference in New Issue
Block a user