@apptimate/ui 7.4.0 → 7.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -18,9 +18,11 @@ export interface ModalProps {
|
|
|
18
18
|
position?: 'center' | 'bottom';
|
|
19
19
|
/** Override z-index layer (default 50). Increase for nested modals. */
|
|
20
20
|
zIndex?: number;
|
|
21
|
+
/** Whether clicking the backdrop should close the modal (default true) */
|
|
22
|
+
closeOnOutsideClick?: boolean;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
export const Modal = ({ isOpen, onClose, title, children, className, footer, backdrop = 'opaque', size = 'sm', position = 'bottom', zIndex }: ModalProps) => {
|
|
25
|
+
export const Modal = ({ isOpen, onClose, title, children, className, footer, backdrop = 'opaque', size = 'sm', position = 'bottom', zIndex, closeOnOutsideClick = true }: ModalProps) => {
|
|
24
26
|
// SSR-safe portal mount guard — document is only available on the client
|
|
25
27
|
const [mounted, setMounted] = useState(false);
|
|
26
28
|
const [isDesktop, setIsDesktop] = useState(true);
|
|
@@ -73,7 +75,7 @@ export const Modal = ({ isOpen, onClose, title, children, className, footer, bac
|
|
|
73
75
|
initial={{ opacity: 0 }}
|
|
74
76
|
animate={{ opacity: 1 }}
|
|
75
77
|
exit={{ opacity: 0 }}
|
|
76
|
-
onClick={onClose}
|
|
78
|
+
onClick={closeOnOutsideClick ? onClose : undefined}
|
|
77
79
|
className={backdropClasses}
|
|
78
80
|
/>
|
|
79
81
|
<motion.div
|
|
@@ -14,6 +14,7 @@ export interface TagsInputProps {
|
|
|
14
14
|
placeholder?: string;
|
|
15
15
|
className?: string;
|
|
16
16
|
disabled?: boolean;
|
|
17
|
+
lockedValues?: string[];
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export const TagsInput: React.FC<TagsInputProps> = ({
|
|
@@ -25,6 +26,7 @@ export const TagsInput: React.FC<TagsInputProps> = ({
|
|
|
25
26
|
placeholder = "Type and press Enter...",
|
|
26
27
|
className,
|
|
27
28
|
disabled = false,
|
|
29
|
+
lockedValues = [],
|
|
28
30
|
}) => {
|
|
29
31
|
const [inputValue, setInputValue] = useState('');
|
|
30
32
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
@@ -40,12 +42,15 @@ export const TagsInput: React.FC<TagsInputProps> = ({
|
|
|
40
42
|
setInputValue('');
|
|
41
43
|
} else if (e.key === 'Backspace' && !inputValue && values.length > 0) {
|
|
42
44
|
e.preventDefault();
|
|
43
|
-
|
|
45
|
+
const lastTag = values[values.length - 1];
|
|
46
|
+
if (!lockedValues.includes(lastTag)) {
|
|
47
|
+
onChange(values.slice(0, -1));
|
|
48
|
+
}
|
|
44
49
|
}
|
|
45
50
|
};
|
|
46
51
|
|
|
47
52
|
const removeTag = (tagToRemove: string) => {
|
|
48
|
-
if (disabled) return;
|
|
53
|
+
if (disabled || lockedValues.includes(tagToRemove)) return;
|
|
49
54
|
onChange(values.filter(tag => tag !== tagToRemove));
|
|
50
55
|
};
|
|
51
56
|
|
|
@@ -68,10 +73,12 @@ export const TagsInput: React.FC<TagsInputProps> = ({
|
|
|
68
73
|
{values.map((tag) => (
|
|
69
74
|
<span
|
|
70
75
|
key={tag}
|
|
71
|
-
className="flex items-center gap-1.5 px-2.5 py-1 text-[12px] font-medium bg-[#F3F4F6] text-[#2D3142] rounded-md border border-[#E5E7EB]"
|
|
76
|
+
className={cn("flex items-center gap-1.5 px-2.5 py-1 text-[12px] font-medium bg-[#F3F4F6] text-[#2D3142] rounded-md border border-[#E5E7EB]",
|
|
77
|
+
lockedValues.includes(tag) && "opacity-80 pr-2.5"
|
|
78
|
+
)}
|
|
72
79
|
>
|
|
73
80
|
{tag}
|
|
74
|
-
{!disabled && (
|
|
81
|
+
{!disabled && !lockedValues.includes(tag) && (
|
|
75
82
|
<button
|
|
76
83
|
type="button"
|
|
77
84
|
onClick={(e) => { e.stopPropagation(); removeTag(tag); }}
|
|
@@ -149,6 +149,17 @@ export default function ItemFormWizard({
|
|
|
149
149
|
return true;
|
|
150
150
|
};
|
|
151
151
|
|
|
152
|
+
const getLockedValues = useCallback((attrName: string) => {
|
|
153
|
+
if (!isEditing || !attrName) return [];
|
|
154
|
+
const locked = new Set<string>();
|
|
155
|
+
formData.variants.forEach(v => {
|
|
156
|
+
if ((v as any).id && v.attribute_values && v.attribute_values[attrName]) {
|
|
157
|
+
locked.add(v.attribute_values[attrName]);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
return Array.from(locked);
|
|
161
|
+
}, [isEditing, formData.variants]);
|
|
162
|
+
|
|
152
163
|
// No need for renderStepper, it is handled by WizardModal
|
|
153
164
|
|
|
154
165
|
// ═══════════════════════════════════════
|
|
@@ -264,29 +275,32 @@ export default function ItemFormWizard({
|
|
|
264
275
|
<div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
|
|
265
276
|
<div className="flex items-center justify-between mb-3">
|
|
266
277
|
<p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">Variant Attributes</p>
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
</button>
|
|
278
|
+
{!isEditing && (
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
onClick={() => setFormData(p => ({ ...p, attributes: [...p.attributes, { name: "", values: [] }] }))}
|
|
282
|
+
className="flex items-center gap-1.5 text-[12px] font-semibold text-primary-600 hover:text-primary-700 transition-colors"
|
|
283
|
+
>
|
|
284
|
+
<Plus size={14} /> Add Attribute
|
|
285
|
+
</button>
|
|
286
|
+
)}
|
|
277
287
|
</div>
|
|
278
288
|
|
|
279
289
|
{formData.attributes.length === 0 ? (
|
|
280
290
|
<div className="text-center py-4 text-gray-400 text-[13px]">No attributes defined. Click "Add Attribute" to add variants like Color or Size.</div>
|
|
281
291
|
) : (
|
|
282
292
|
<div className="space-y-4">
|
|
283
|
-
{formData.attributes.map((attr, idx) =>
|
|
293
|
+
{formData.attributes.map((attr, idx) => {
|
|
294
|
+
const lockedValues = getLockedValues(attr.name);
|
|
295
|
+
const hasLockedValues = lockedValues.length > 0;
|
|
296
|
+
|
|
297
|
+
return (
|
|
284
298
|
<div key={idx} className="flex flex-col sm:flex-row items-start gap-4">
|
|
285
299
|
<div className="w-full sm:w-[250px]">
|
|
286
300
|
<Input
|
|
287
301
|
placeholder="e.g. Size, Color"
|
|
288
302
|
value={attr.name}
|
|
289
|
-
disabled={isEditing}
|
|
303
|
+
disabled={isEditing && hasLockedValues}
|
|
290
304
|
onChange={(e) => {
|
|
291
305
|
const newAttrs = [...formData.attributes];
|
|
292
306
|
newAttrs[idx] = { ...newAttrs[idx], name: e.target.value };
|
|
@@ -297,7 +311,7 @@ export default function ItemFormWizard({
|
|
|
297
311
|
<div className="flex-1 flex items-start gap-2 w-full">
|
|
298
312
|
<TagsInput
|
|
299
313
|
values={attr.values}
|
|
300
|
-
|
|
314
|
+
lockedValues={lockedValues}
|
|
301
315
|
onChange={(values) => {
|
|
302
316
|
const newAttrs = [...formData.attributes];
|
|
303
317
|
newAttrs[idx] = { ...newAttrs[idx], values };
|
|
@@ -305,24 +319,21 @@ export default function ItemFormWizard({
|
|
|
305
319
|
}}
|
|
306
320
|
placeholder="Type values (e.g. S, M, L) and press Enter"
|
|
307
321
|
/>
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
>
|
|
321
|
-
<Trash size={16} />
|
|
322
|
-
</button>
|
|
322
|
+
{!hasLockedValues && (
|
|
323
|
+
<button
|
|
324
|
+
type="button"
|
|
325
|
+
onClick={() => {
|
|
326
|
+
const newAttrs = formData.attributes.filter((_, i) => i !== idx);
|
|
327
|
+
setFormData(p => ({ ...p, attributes: newAttrs }));
|
|
328
|
+
}}
|
|
329
|
+
className="p-2.5 rounded-lg transition-colors mt-0.5 text-gray-400 hover:text-danger-alt hover:bg-danger-50"
|
|
330
|
+
>
|
|
331
|
+
<Trash size={16} />
|
|
332
|
+
</button>
|
|
333
|
+
)}
|
|
323
334
|
</div>
|
|
324
335
|
</div>
|
|
325
|
-
))}
|
|
336
|
+
)})}
|
|
326
337
|
</div>
|
|
327
338
|
)}
|
|
328
339
|
</div>
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React, { useState, useEffect } from "react";
|
|
3
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
4
4
|
import { Modal } from '../../base-components/Modal';
|
|
5
5
|
import { ModalFooter } from '../../base-components/Modal';
|
|
6
6
|
import { Button } from '../../base-components/Button';
|
|
7
7
|
import { Select } from '../../base-components/Select';
|
|
8
8
|
import { Input } from '../../base-components/Input';
|
|
9
9
|
|
|
10
|
-
import { saveInventorySetting, getInventorySetting,
|
|
11
|
-
import { Trash2, Plus } from "lucide-react";
|
|
10
|
+
import { saveInventorySetting, getInventorySetting, getItemCount, lookupSkuComponentFields, getSkuComponentTemplates, createSkuComponentField } from "@apptimate/core-lib";
|
|
11
|
+
import { Trash2, Plus, LayoutTemplate, ChevronDown } from "lucide-react";
|
|
12
12
|
import toast from "react-hot-toast";
|
|
13
13
|
|
|
14
14
|
interface Props {
|
|
@@ -25,28 +25,62 @@ interface Props {
|
|
|
25
25
|
isVariant?: boolean;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
interface CustomField {
|
|
29
|
+
id: number;
|
|
30
|
+
name: string;
|
|
31
|
+
code: string;
|
|
32
|
+
is_system?: boolean;
|
|
33
|
+
applicable_to?: string;
|
|
34
|
+
options: { id?: number; label: string; code: string }[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface SkuTemplate {
|
|
38
|
+
id: number;
|
|
39
|
+
name: string;
|
|
40
|
+
config: any;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const generateSkuPreview = (cfg: any, categoryName?: string, brandName?: string, itemName?: string, attributes?: Record<string, string>, customFields?: CustomField[]) => {
|
|
29
44
|
let result = "";
|
|
30
45
|
if (!cfg || !cfg.parts) return result;
|
|
31
46
|
|
|
32
47
|
cfg.parts.forEach((p: any, idx: number) => {
|
|
33
48
|
let val = "";
|
|
34
|
-
if (p.type === "category") val =
|
|
35
|
-
if (p.type === "brand") val =
|
|
36
|
-
if (p.type === "name") val =
|
|
49
|
+
if (p.type === "category") val = categoryName ? categoryName.substring(0, p.length || 3).toUpperCase() : "";
|
|
50
|
+
if (p.type === "brand") val = brandName ? brandName.substring(0, p.length || 3).toUpperCase() : "";
|
|
51
|
+
if (p.type === "name") val = itemName ? itemName.substring(0, p.length || 3).toUpperCase() : "";
|
|
37
52
|
if (p.type === "custom") val = p.custom_value || "";
|
|
38
|
-
if (p.type
|
|
39
|
-
if
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
.join("-");
|
|
53
|
+
if (p.type?.startsWith("custom_field:")) {
|
|
54
|
+
// Use selected option code if available, otherwise first option code
|
|
55
|
+
if (p.selected_option_code) {
|
|
56
|
+
val = p.selected_option_code;
|
|
43
57
|
} else {
|
|
44
|
-
|
|
58
|
+
const fieldId = parseInt(p.type.split(":")[1]);
|
|
59
|
+
const field = customFields?.find(f => f.id === fieldId);
|
|
60
|
+
if (field?.is_system) {
|
|
61
|
+
// System fields use dynamic data
|
|
62
|
+
const sysCode = field.code;
|
|
63
|
+
if (sysCode === "item_name") val = itemName ? itemName.substring(0, p.length || 3).toUpperCase() : "";
|
|
64
|
+
else if (sysCode === "category") val = categoryName ? categoryName.substring(0, p.length || 3).toUpperCase() : "";
|
|
65
|
+
else if (sysCode === "brand") val = brandName ? brandName.substring(0, p.length || 3).toUpperCase() : "";
|
|
66
|
+
else if (sysCode === "variant_attributes") {
|
|
67
|
+
if (attributes && Object.keys(attributes).length > 0) {
|
|
68
|
+
const attrVals = Object.values(attributes).filter(v => v);
|
|
69
|
+
if (attrVals.length > 0) {
|
|
70
|
+
val = attrVals.map((v: any) => String(v).substring(0, p.length || 3).toUpperCase()).join("-");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
val = field?.options?.[0]?.code || "";
|
|
76
|
+
}
|
|
45
77
|
}
|
|
46
78
|
}
|
|
47
79
|
|
|
48
|
-
|
|
49
|
-
|
|
80
|
+
if (val) {
|
|
81
|
+
const isLast = (idx === cfg.parts.length - 1) && (!cfg.sequence_length || cfg.sequence_length === 0);
|
|
82
|
+
result += val + (isLast ? "" : (p.separator || ""));
|
|
83
|
+
}
|
|
50
84
|
});
|
|
51
85
|
|
|
52
86
|
// Add sequence at the end
|
|
@@ -56,33 +90,48 @@ export const generateSkuPreview = (cfg: any, categoryName?: string, brandName?:
|
|
|
56
90
|
return result;
|
|
57
91
|
};
|
|
58
92
|
|
|
93
|
+
|
|
59
94
|
export default function SkuConfigModal({ isOpen, onClose, onGenerate, categoryName, categoryId, brandName, itemName, itemNames, itemAttributes, itemsAttributes, isVariant }: Props) {
|
|
60
95
|
const [isLoading, setIsLoading] = useState(false);
|
|
61
96
|
const [config, setConfig] = useState({
|
|
62
|
-
parts:
|
|
63
|
-
{ type: "category", length: 3, custom_value: "", separator: "-" },
|
|
64
|
-
{ type: "name", length: 3, custom_value: "", separator: "-" },
|
|
65
|
-
{ type: "attributes", length: 3, custom_value: "", separator: "" },
|
|
66
|
-
] : [
|
|
67
|
-
{ type: "category", length: 3, custom_value: "", separator: "-" },
|
|
68
|
-
{ type: "name", length: 3, custom_value: "", separator: "-" },
|
|
69
|
-
]) as any[],
|
|
97
|
+
parts: [] as any[],
|
|
70
98
|
sequence_length: 4,
|
|
71
99
|
current_sequence: 1,
|
|
72
100
|
});
|
|
73
101
|
|
|
102
|
+
// Custom fields & templates
|
|
103
|
+
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
|
104
|
+
const [templates, setTemplates] = useState<SkuTemplate[]>([]);
|
|
105
|
+
const [loadedTemplate, setLoadedTemplate] = useState<SkuTemplate | null>(null);
|
|
106
|
+
const [isTemplateDropdownOpen, setIsTemplateDropdownOpen] = useState(false);
|
|
107
|
+
|
|
108
|
+
const fetchCustomFields = useCallback(async () => {
|
|
109
|
+
try {
|
|
110
|
+
const res = await lookupSkuComponentFields({ applicable_to: isVariant ? 'variant' : 'item' });
|
|
111
|
+
if (res.is_success) setCustomFields(res.result || []);
|
|
112
|
+
} catch {}
|
|
113
|
+
}, [isVariant]);
|
|
114
|
+
|
|
115
|
+
const fetchTemplates = useCallback(async () => {
|
|
116
|
+
try {
|
|
117
|
+
const res = await getSkuComponentTemplates({
|
|
118
|
+
status: "active",
|
|
119
|
+
per_page: 50,
|
|
120
|
+
applicable_to: isVariant ? 'variant' : 'item'
|
|
121
|
+
});
|
|
122
|
+
const data = res.result?.data || [];
|
|
123
|
+
if (res.is_success) setTemplates(data);
|
|
124
|
+
return data;
|
|
125
|
+
} catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}, [isVariant]);
|
|
129
|
+
|
|
74
130
|
useEffect(() => {
|
|
75
131
|
if (isOpen) {
|
|
76
132
|
// Reset state to default immediately when opened
|
|
77
133
|
setConfig({
|
|
78
|
-
parts:
|
|
79
|
-
{ type: "category", length: 3, custom_value: "", separator: "-" },
|
|
80
|
-
{ type: "name", length: 3, custom_value: "", separator: "-" },
|
|
81
|
-
{ type: "attributes", length: 3, custom_value: "", separator: "" },
|
|
82
|
-
] : [
|
|
83
|
-
{ type: "category", length: 3, custom_value: "", separator: "-" },
|
|
84
|
-
{ type: "name", length: 3, custom_value: "", separator: "-" },
|
|
85
|
-
] as any[],
|
|
134
|
+
parts: [] as any[],
|
|
86
135
|
sequence_length: 4,
|
|
87
136
|
current_sequence: 1,
|
|
88
137
|
});
|
|
@@ -91,20 +140,24 @@ export default function SkuConfigModal({ isOpen, onClose, onGenerate, categoryNa
|
|
|
91
140
|
const settingKey = isVariant ? "sku_generation_rule_variant_v3" : "sku_generation_rule_v3";
|
|
92
141
|
|
|
93
142
|
Promise.all([
|
|
94
|
-
|
|
95
|
-
|
|
143
|
+
getItemCount(categoryId).catch(() => null),
|
|
144
|
+
fetchCustomFields(),
|
|
145
|
+
fetchTemplates(),
|
|
96
146
|
])
|
|
97
|
-
.then(([
|
|
147
|
+
.then(([countRes, , fetchedTemplates]) => {
|
|
98
148
|
let currentSequence = 1;
|
|
99
149
|
if (countRes && countRes.is_success) {
|
|
100
150
|
currentSequence = (countRes.result?.count || 0) + 1;
|
|
101
151
|
}
|
|
102
152
|
|
|
103
|
-
|
|
153
|
+
const defaultTemplate = (fetchedTemplates || []).find((t: any) => t.is_default);
|
|
154
|
+
|
|
155
|
+
if (defaultTemplate && defaultTemplate.config) {
|
|
104
156
|
setConfig({
|
|
105
|
-
...
|
|
157
|
+
...defaultTemplate.config,
|
|
106
158
|
current_sequence: currentSequence,
|
|
107
159
|
});
|
|
160
|
+
setLoadedTemplate(defaultTemplate);
|
|
108
161
|
} else {
|
|
109
162
|
setConfig((prev) => ({
|
|
110
163
|
...prev,
|
|
@@ -115,11 +168,11 @@ export default function SkuConfigModal({ isOpen, onClose, onGenerate, categoryNa
|
|
|
115
168
|
.catch(() => {})
|
|
116
169
|
.finally(() => setIsLoading(false));
|
|
117
170
|
}
|
|
118
|
-
}, [isOpen, categoryId, isVariant]);
|
|
171
|
+
}, [isOpen, categoryId, isVariant, fetchCustomFields, fetchTemplates]);
|
|
119
172
|
|
|
120
173
|
const generatePreview = (cfg = config) => {
|
|
121
174
|
const effectiveCfg = { ...cfg, sequence_length: isVariant ? 0 : cfg.sequence_length };
|
|
122
|
-
return generateSkuPreview(effectiveCfg, categoryName, brandName, itemName, itemAttributes || (itemsAttributes ? itemsAttributes[0] : undefined));
|
|
175
|
+
return generateSkuPreview(effectiveCfg, categoryName, brandName, itemName, itemAttributes || (itemsAttributes ? itemsAttributes[0] : undefined), customFields);
|
|
123
176
|
};
|
|
124
177
|
|
|
125
178
|
const handleApply = async () => {
|
|
@@ -134,17 +187,13 @@ export default function SkuConfigModal({ isOpen, onClose, onGenerate, categoryNa
|
|
|
134
187
|
|
|
135
188
|
const nextConfig = { ...config };
|
|
136
189
|
const effectiveSequenceLength = isVariant ? 0 : nextConfig.sequence_length;
|
|
137
|
-
const settingKey = isVariant ? "sku_generation_rule_variant_v3" : "sku_generation_rule_v3";
|
|
138
|
-
|
|
139
|
-
// Save config and increment sequence
|
|
140
|
-
await saveInventorySetting(settingKey, nextConfig);
|
|
141
190
|
|
|
142
191
|
if (itemNames && itemNames.length > 0) {
|
|
143
192
|
// Generate for multiple items
|
|
144
193
|
const generated = itemNames.map((name, idx) => {
|
|
145
194
|
return generateSkuPreview(
|
|
146
195
|
{ ...nextConfig, sequence_length: effectiveSequenceLength, current_sequence: nextConfig.current_sequence + idx },
|
|
147
|
-
categoryName, brandName, name, itemsAttributes ? itemsAttributes[idx] : undefined
|
|
196
|
+
categoryName, brandName, name, itemsAttributes ? itemsAttributes[idx] : undefined, customFields
|
|
148
197
|
);
|
|
149
198
|
});
|
|
150
199
|
onGenerate(generated, { ...nextConfig, current_sequence: nextConfig.current_sequence + count });
|
|
@@ -152,7 +201,7 @@ export default function SkuConfigModal({ isOpen, onClose, onGenerate, categoryNa
|
|
|
152
201
|
// Generate for single item
|
|
153
202
|
const sku = generateSkuPreview(
|
|
154
203
|
{ ...nextConfig, sequence_length: effectiveSequenceLength },
|
|
155
|
-
categoryName, brandName, itemName, itemAttributes
|
|
204
|
+
categoryName, brandName, itemName, itemAttributes, customFields
|
|
156
205
|
);
|
|
157
206
|
onGenerate(sku, { ...nextConfig, current_sequence: nextConfig.current_sequence + 1 });
|
|
158
207
|
}
|
|
@@ -169,13 +218,32 @@ export default function SkuConfigModal({ isOpen, onClose, onGenerate, categoryNa
|
|
|
169
218
|
const updatePart = (index: number, key: string, value: any) => {
|
|
170
219
|
const newParts = [...config.parts];
|
|
171
220
|
newParts[index] = { ...newParts[index], [key]: value };
|
|
221
|
+
|
|
222
|
+
// If selecting a custom_field, store field metadata
|
|
223
|
+
if (key === "type" && value.startsWith("custom_field:")) {
|
|
224
|
+
const fieldId = parseInt(value.split(":")[1]);
|
|
225
|
+
const field = customFields.find(f => f.id === fieldId);
|
|
226
|
+
newParts[index].field_id = fieldId;
|
|
227
|
+
newParts[index].field_code = field?.code || "";
|
|
228
|
+
newParts[index].field_name = field?.name || "";
|
|
229
|
+
// Default to first option code
|
|
230
|
+
newParts[index].selected_option_code = field?.options?.[0]?.code || "";
|
|
231
|
+
} else if (key === "type" && !value.startsWith("custom_field:")) {
|
|
232
|
+
delete newParts[index].field_id;
|
|
233
|
+
delete newParts[index].field_code;
|
|
234
|
+
delete newParts[index].field_name;
|
|
235
|
+
delete newParts[index].selected_option_code;
|
|
236
|
+
}
|
|
237
|
+
|
|
172
238
|
setConfig({ ...config, parts: newParts });
|
|
239
|
+
setLoadedTemplate(null);
|
|
173
240
|
};
|
|
174
241
|
|
|
175
242
|
const removePart = (index: number) => {
|
|
176
243
|
const newParts = [...config.parts];
|
|
177
244
|
newParts.splice(index, 1);
|
|
178
245
|
setConfig({ ...config, parts: newParts });
|
|
246
|
+
setLoadedTemplate(null);
|
|
179
247
|
};
|
|
180
248
|
|
|
181
249
|
const addPart = () => {
|
|
@@ -183,14 +251,36 @@ export default function SkuConfigModal({ isOpen, onClose, onGenerate, categoryNa
|
|
|
183
251
|
...config,
|
|
184
252
|
parts: [...config.parts, { type: "custom", length: 3, custom_value: "", separator: "-" }],
|
|
185
253
|
});
|
|
254
|
+
setLoadedTemplate(null);
|
|
186
255
|
};
|
|
187
256
|
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
257
|
+
const handleLoadTemplate = (template: SkuTemplate) => {
|
|
258
|
+
if (template.config) {
|
|
259
|
+
setConfig({
|
|
260
|
+
...config,
|
|
261
|
+
parts: template.config.parts || [],
|
|
262
|
+
sequence_length: template.config.sequence_length ?? config.sequence_length,
|
|
263
|
+
});
|
|
264
|
+
setLoadedTemplate(template);
|
|
265
|
+
}
|
|
266
|
+
setIsTemplateDropdownOpen(false);
|
|
267
|
+
toast.success(`Template "${template.name}" loaded`);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// Build unified field options from API data (system fields first, then custom)
|
|
271
|
+
const systemFields = customFields.filter(f => f.is_system);
|
|
272
|
+
const userFields = customFields.filter(f => !f.is_system);
|
|
273
|
+
|
|
274
|
+
const allFieldOptions = [
|
|
275
|
+
// System fields
|
|
276
|
+
...systemFields.map(f => ({ label: f.name, value: `custom_field:${f.id}` })),
|
|
277
|
+
// Custom text (always available)
|
|
193
278
|
{ label: "Custom Text/Code", value: "custom" },
|
|
279
|
+
// User-created custom fields
|
|
280
|
+
...(userFields.length > 0 ? [
|
|
281
|
+
{ label: "──── Custom Fields ────", value: "__divider__", disabled: true },
|
|
282
|
+
...userFields.map(f => ({ label: f.name, value: `custom_field:${f.id}` })),
|
|
283
|
+
] : []),
|
|
194
284
|
];
|
|
195
285
|
|
|
196
286
|
const separatorOptions = [
|
|
@@ -209,6 +299,42 @@ export default function SkuConfigModal({ isOpen, onClose, onGenerate, categoryNa
|
|
|
209
299
|
) : (
|
|
210
300
|
<div className="space-y-5 animate-fade-in py-2">
|
|
211
301
|
|
|
302
|
+
{/* Template Preset Loader */}
|
|
303
|
+
{templates.length > 0 && (
|
|
304
|
+
<div className="relative">
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
onClick={() => setIsTemplateDropdownOpen(!isTemplateDropdownOpen)}
|
|
308
|
+
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors border border-primary-100"
|
|
309
|
+
>
|
|
310
|
+
<LayoutTemplate size={15} />
|
|
311
|
+
{loadedTemplate ? `Template: ${loadedTemplate.name}` : "Load Template"}
|
|
312
|
+
<ChevronDown size={14} className={`transition-transform ${isTemplateDropdownOpen ? "rotate-180" : ""}`} />
|
|
313
|
+
</button>
|
|
314
|
+
|
|
315
|
+
{isTemplateDropdownOpen && (
|
|
316
|
+
<>
|
|
317
|
+
<div className="fixed inset-0 z-10" onClick={() => setIsTemplateDropdownOpen(false)} />
|
|
318
|
+
<div className="absolute top-full mt-1 left-0 z-20 bg-white border border-gray-200 rounded-xl shadow-lg py-1 min-w-[280px] max-h-[200px] overflow-y-auto">
|
|
319
|
+
{templates.map(t => (
|
|
320
|
+
<button
|
|
321
|
+
key={t.id}
|
|
322
|
+
type="button"
|
|
323
|
+
onClick={() => handleLoadTemplate(t)}
|
|
324
|
+
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 transition-colors flex items-center justify-between"
|
|
325
|
+
>
|
|
326
|
+
<span className="font-medium text-gray-700">{t.name}</span>
|
|
327
|
+
<span className="font-mono text-[11px] text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
|
328
|
+
{generatePreview({ ...t.config, current_sequence: 1 })}
|
|
329
|
+
</span>
|
|
330
|
+
</button>
|
|
331
|
+
))}
|
|
332
|
+
</div>
|
|
333
|
+
</>
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
|
|
212
338
|
<div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
|
|
213
339
|
<div className="flex items-center justify-between mb-3">
|
|
214
340
|
<p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">Formula Components</p>
|
|
@@ -229,47 +355,75 @@ export default function SkuConfigModal({ isOpen, onClose, onGenerate, categoryNa
|
|
|
229
355
|
|
|
230
356
|
{/* Table Rows */}
|
|
231
357
|
<div className="space-y-2">
|
|
232
|
-
{config.parts.map((part, index) =>
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
358
|
+
{config.parts.map((part: any, index: number) => {
|
|
359
|
+
const currentType = part.type?.startsWith("custom_field:") ? part.type : part.type;
|
|
360
|
+
const isCustomField = part.type?.startsWith("custom_field:");
|
|
361
|
+
const customField = isCustomField ? customFields.find(f => f.id === parseInt(part.type.split(":")[1])) : null;
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<div key={index} className="grid grid-cols-12 gap-3 items-center bg-white p-2 rounded-lg border border-gray-200">
|
|
365
|
+
<div className="col-span-5">
|
|
366
|
+
<select
|
|
367
|
+
className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white text-sm focus:ring-2 focus:ring-primary-200 focus:border-primary-400 outline-none transition-all"
|
|
368
|
+
value={currentType}
|
|
369
|
+
onChange={(e) => updatePart(index, "type", e.target.value)}
|
|
370
|
+
>
|
|
371
|
+
{allFieldOptions.map((opt: any) => (
|
|
372
|
+
<option key={opt.value} value={opt.value} disabled={opt.disabled || opt.value.startsWith("__divider")}>
|
|
373
|
+
{opt.label}
|
|
374
|
+
</option>
|
|
375
|
+
))}
|
|
376
|
+
</select>
|
|
377
|
+
</div>
|
|
378
|
+
<div className="col-span-3">
|
|
379
|
+
{part.type === "custom" ? (
|
|
380
|
+
<Input
|
|
381
|
+
placeholder="e.g. PRE"
|
|
382
|
+
value={part.custom_value}
|
|
383
|
+
onChange={(e) => updatePart(index, "custom_value", e.target.value.toUpperCase())}
|
|
384
|
+
/>
|
|
385
|
+
) : isCustomField && customField && !customField.is_system && customField.options?.length > 0 ? (
|
|
386
|
+
<select
|
|
387
|
+
className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white text-sm focus:ring-2 focus:ring-primary-200 focus:border-primary-400 outline-none transition-all"
|
|
388
|
+
value={part.selected_option_code || ""}
|
|
389
|
+
onChange={(e) => updatePart(index, "selected_option_code", e.target.value)}
|
|
390
|
+
>
|
|
391
|
+
{customField.options.map(opt => (
|
|
392
|
+
<option key={opt.code} value={opt.code}>
|
|
393
|
+
{opt.label} ({opt.code})
|
|
394
|
+
</option>
|
|
395
|
+
))}
|
|
396
|
+
</select>
|
|
397
|
+
) : (
|
|
398
|
+
<Input
|
|
399
|
+
type="number"
|
|
400
|
+
placeholder="3"
|
|
401
|
+
value={part.length === "" ? "" : String(part.length ?? 3)}
|
|
402
|
+
onChange={(e) => updatePart(index, "length", e.target.value === "" ? "" : parseInt(e.target.value))}
|
|
403
|
+
/>
|
|
404
|
+
)}
|
|
405
|
+
</div>
|
|
406
|
+
<div className="col-span-3">
|
|
407
|
+
{isVariant && index === config.parts.length - 1 ? (
|
|
408
|
+
<div className="w-full px-3 py-2 rounded-lg border border-transparent bg-gray-50/50 text-gray-400 text-sm italic flex items-center justify-center h-[38px]">
|
|
409
|
+
None
|
|
410
|
+
</div>
|
|
411
|
+
) : (
|
|
412
|
+
<Select
|
|
413
|
+
options={separatorOptions}
|
|
414
|
+
value={part.separator}
|
|
415
|
+
onChange={(e) => updatePart(index, "separator", e.target.value)}
|
|
416
|
+
/>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
<div className="col-span-1 flex justify-center">
|
|
420
|
+
<button type="button" onClick={() => removePart(index)} className="text-gray-400 hover:text-red-500 transition-colors p-1.5 rounded hover:bg-red-50">
|
|
421
|
+
<Trash2 size={16} />
|
|
422
|
+
</button>
|
|
423
|
+
</div>
|
|
270
424
|
</div>
|
|
271
|
-
|
|
272
|
-
)
|
|
425
|
+
);
|
|
426
|
+
})}
|
|
273
427
|
|
|
274
428
|
{config.parts.length === 0 && (
|
|
275
429
|
<div className="text-center py-6 text-sm text-gray-400 border-2 border-dashed border-gray-200 rounded-lg bg-white">
|