@apptimate/ui 7.3.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apptimate/ui",
3
- "version": "7.3.0",
3
+ "version": "7.5.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "dependencies": {
@@ -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
- onChange(values.slice(0, -1));
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
- <button
268
- type="button"
269
- disabled={isEditing}
270
- title={isEditing ? "Cannot add attributes to an existing item" : ""}
271
- onClick={() => setFormData(p => ({ ...p, attributes: [...p.attributes, { name: "", values: [] }] }))}
272
- className={`flex items-center gap-1.5 text-[12px] font-semibold transition-colors ${isEditing ? "text-gray-400 cursor-not-allowed" : "text-primary-600 hover:text-primary-700"
273
- }`}
274
- >
275
- <Plus size={14} /> Add Attribute
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
- disabled={isEditing}
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
- <button
309
- type="button"
310
- disabled={isEditing}
311
- title={isEditing ? "Cannot remove attributes from an existing item" : ""}
312
- onClick={() => {
313
- const newAttrs = formData.attributes.filter((_, i) => i !== idx);
314
- setFormData(p => ({ ...p, attributes: newAttrs }));
315
- }}
316
- className={`p-2.5 rounded-lg transition-colors mt-0.5 ${isEditing
317
- ? "text-gray-300 cursor-not-allowed"
318
- : "text-gray-400 hover:text-danger-alt hover:bg-danger-50"
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, getItemCategoryCount } from "@apptimate/core-lib";
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
- export const generateSkuPreview = (cfg: any, categoryName?: string, brandName?: string, itemName?: string, attributes?: Record<string, string>) => {
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 = (categoryName || "CAT").substring(0, p.length || 3).toUpperCase();
35
- if (p.type === "brand") val = (brandName || "BRN").substring(0, p.length || 3).toUpperCase();
36
- if (p.type === "name") val = (itemName || "ITM").substring(0, p.length || 3).toUpperCase();
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 === "attributes") {
39
- if (attributes && Object.keys(attributes).length > 0) {
40
- val = Object.values(attributes)
41
- .map((v: any) => String(v).substring(0, p.length || 3).toUpperCase())
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
- val = "ATTR";
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
- const isLast = (idx === cfg.parts.length - 1) && (!cfg.sequence_length || cfg.sequence_length === 0);
49
- result += val + (isLast ? "" : (p.separator || ""));
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: (isVariant ? [
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: isVariant ? [
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
- getInventorySetting(settingKey).catch(() => null),
95
- categoryId ? getItemCategoryCount(categoryId).catch(() => null) : Promise.resolve(null)
143
+ getItemCount(categoryId).catch(() => null),
144
+ fetchCustomFields(),
145
+ fetchTemplates(),
96
146
  ])
97
- .then(([settingRes, countRes]) => {
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
- if (settingRes && settingRes.is_success && settingRes.result) {
153
+ const defaultTemplate = (fetchedTemplates || []).find((t: any) => t.is_default);
154
+
155
+ if (defaultTemplate && defaultTemplate.config) {
104
156
  setConfig({
105
- ...settingRes.result,
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 fieldOptions = [
189
- { label: "Category", value: "category" },
190
- { label: "Brand", value: "brand" },
191
- { label: "Item Name", value: "name" },
192
- ...(isVariant ? [{ label: "Variant Attributes", value: "attributes" }] : []),
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
- <div key={index} className="grid grid-cols-12 gap-3 items-center bg-white p-2 rounded-lg border border-gray-200">
234
- <div className="col-span-5">
235
- <Select
236
- options={fieldOptions}
237
- value={part.type}
238
- onChange={(e) => updatePart(index, "type", e.target.value)}
239
- />
240
- </div>
241
- <div className="col-span-3">
242
- {part.type === "custom" ? (
243
- <Input
244
- placeholder="e.g. PRE"
245
- value={part.custom_value}
246
- onChange={(e) => updatePart(index, "custom_value", e.target.value.toUpperCase())}
247
- />
248
- ) : (
249
- <Input
250
- type="number"
251
- placeholder="3"
252
- value={String(part.length || 3)}
253
- onChange={(e) => updatePart(index, "length", Math.max(1, parseInt(e.target.value) || 1))}
254
- />
255
- )}
256
- </div>
257
- <div className="col-span-3">
258
- {!(isVariant && index === config.parts.length - 1) && (
259
- <Select
260
- options={separatorOptions}
261
- value={part.separator}
262
- onChange={(e) => updatePart(index, "separator", e.target.value)}
263
- />
264
- )}
265
- </div>
266
- <div className="col-span-1 flex justify-center">
267
- <button type="button" onClick={() => removePart(index)} className="text-gray-400 hover:text-red-500 transition-colors p-1.5 rounded hover:bg-red-50">
268
- <Trash2 size={16} />
269
- </button>
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
- </div>
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">