@apptimate/ui 3.4.0 → 3.6.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.
Files changed (25) hide show
  1. package/package.json +3 -2
  2. package/src/base-components/Input.tsx +6 -3
  3. package/src/base-components/TagsInput.tsx +106 -0
  4. package/src/base-components/WizardModal.tsx +162 -0
  5. package/src/common-components/item-wizard/ItemFormWizard.tsx +705 -0
  6. package/src/common-components/item-wizard/SkuConfigModal.tsx +314 -0
  7. package/src/common-components/pickers/BrandPicker.tsx +194 -0
  8. package/src/common-components/pickers/CategoryPicker.tsx +342 -0
  9. package/src/common-components/pickers/EntityPickerModal.tsx +221 -0
  10. package/src/common-components/pickers/PartyPicker.tsx +231 -0
  11. package/src/common-components/pickers/TransactionItemPicker.tsx +166 -0
  12. package/src/common-components/pickers/UomGroupPicker.tsx +196 -0
  13. package/src/common-components/pickers/UomPicker.tsx +306 -0
  14. package/src/common-components/pickers/WarehousePicker.tsx +129 -0
  15. package/src/common-components/pickers/index.ts +11 -0
  16. package/src/common-components/pickers/modals/BatchSelectionModal.tsx +359 -0
  17. package/src/common-components/pickers/modals/SerialSelectionModal.tsx +247 -0
  18. package/src/common-components/pickers/modals/types.ts +30 -0
  19. package/src/common-components/transaction/MetadataPanel.tsx +417 -0
  20. package/src/common-components/transaction/ProductSelectionPanel.tsx +587 -0
  21. package/src/common-components/transaction/ProductTransactionScreen.tsx +250 -0
  22. package/src/common-components/transaction/index.ts +17 -0
  23. package/src/common-components/transaction/types.ts +300 -0
  24. package/src/components/shared/ImageUploadComponent.tsx +1 -2
  25. package/src/index.tsx +20 -0
@@ -0,0 +1,705 @@
1
+ "use client";
2
+
3
+ import React, { useState, useCallback } from "react";
4
+ import { Input, Select, Button, WizardModal, Checkbox, HintIcon, TagsInput } from "@apptimate/ui";
5
+ import { CategoryPicker, BrandPicker } from "../pickers";
6
+ import { UomPicker } from "../pickers/UomPicker";
7
+ import { ChevronLeft, ChevronRight, Settings, Trash, Trash2, Plus } from "lucide-react";
8
+ import SkuConfigModal, { generateSkuPreview } from "./SkuConfigModal";
9
+ import { getInventorySetting, cn } from "@apptimate/core-lib";
10
+
11
+ // ── Types ──
12
+ export interface ItemFormData {
13
+ // Page 1: Basic
14
+ name: string; sku: string; barcode: string;
15
+ category_id: string; category_name: string;
16
+ brand_id: string; brand_name: string;
17
+ description: string;
18
+ sale_price: string; cost_price: string;
19
+ has_variants: boolean; tracking_type: string; valuation_method: string;
20
+ // Page 2: Stock & Config
21
+ uom_id: string; uom_name: string;
22
+ purchase_uom_id: string; sales_uom_id: string;
23
+ purchase_uom_conversion: string; sales_uom_conversion: string;
24
+ min_stock_level: string; max_stock_level: string;
25
+ reorder_point: string; reorder_qty: string; lead_time_days: string;
26
+ is_available_purchase: boolean; is_available_sell: boolean; is_available_ecommerce: boolean;
27
+ has_stocks: boolean; is_taxable: boolean; is_returnable: boolean; is_discountable: boolean;
28
+ weight: string; weight_unit: string; dimensions: string;
29
+ attributes: { name: string; values: string[] }[];
30
+ // Page 3: Variants
31
+ variants: VariantRow[];
32
+ can_edit_variants?: boolean;
33
+ }
34
+
35
+ export interface VariantRow {
36
+ id?: number; sku: string; variant_name: string; barcode: string;
37
+ sale_price: string; cost_price: string; attribute_values: Record<string, string>;
38
+ }
39
+
40
+ export const emptyFormData: ItemFormData = {
41
+ name: "", sku: "", barcode: "",
42
+ category_id: "", category_name: "",
43
+ brand_id: "", brand_name: "",
44
+ description: "",
45
+ sale_price: "0", cost_price: "0",
46
+ has_variants: false, tracking_type: "none", valuation_method: "fifo",
47
+ uom_id: "", uom_name: "", purchase_uom_id: "", sales_uom_id: "",
48
+ purchase_uom_conversion: "1", sales_uom_conversion: "1",
49
+ min_stock_level: "", max_stock_level: "",
50
+ reorder_point: "", reorder_qty: "", lead_time_days: "",
51
+ is_available_purchase: true, is_available_sell: true, is_available_ecommerce: false,
52
+ has_stocks: true, is_taxable: true, is_returnable: true, is_discountable: true,
53
+ weight: "", weight_unit: "", dimensions: "",
54
+ attributes: [{ name: "", values: [] }],
55
+ variants: [],
56
+ can_edit_variants: true,
57
+ };
58
+
59
+ interface Props {
60
+ formData: ItemFormData;
61
+ setFormData: React.Dispatch<React.SetStateAction<ItemFormData>>;
62
+ uoms: any[];
63
+ onSubmit: () => void;
64
+ isOpen: boolean;
65
+ onClose: () => void;
66
+ isEditing: boolean;
67
+ }
68
+
69
+
70
+ export default function ItemFormWizard({
71
+ formData, setFormData, uoms, onSubmit, isOpen, onClose, isEditing,
72
+ }: Props) {
73
+ const [step, setStep] = useState(0);
74
+
75
+ React.useEffect(() => {
76
+ if (isOpen) {
77
+ setStep(0);
78
+ }
79
+ }, [isOpen]);
80
+
81
+ const showStep3 = formData.has_variants || formData.is_available_purchase || formData.is_available_sell;
82
+ React.useEffect(() => {
83
+ if (!showStep3 && step === 2) {
84
+ setStep(1);
85
+ }
86
+ }, [showStep3, step]);
87
+
88
+ const [skuModalOpen, setSkuModalOpen] = useState(false);
89
+ const [activeVariantSkuIndex, setActiveVariantSkuIndex] = useState<number | null>(null);
90
+ const [skuConfig, setSkuConfig] = useState<any>({
91
+ parts: [
92
+ { type: "category", length: 3, custom_value: "", separator: "-" },
93
+ { type: "name", length: 3, custom_value: "", separator: "-" },
94
+ ],
95
+ sequence_length: 4,
96
+ current_sequence: 1,
97
+ });
98
+
99
+ const fetchSkuConfig = useCallback(() => {
100
+ getInventorySetting("sku_generation_rule_v3").then((res) => {
101
+ if (res.is_success && res.result) {
102
+ setSkuConfig(res.result);
103
+ }
104
+ }).catch(() => {});
105
+ }, []);
106
+
107
+ // Fetch initial config
108
+ React.useEffect(() => {
109
+ fetchSkuConfig();
110
+ }, [fetchSkuConfig]);
111
+
112
+ // Refetch config when modal closes (in case they changed the formula or sequence)
113
+ React.useEffect(() => {
114
+ if (!skuModalOpen) fetchSkuConfig();
115
+ }, [skuModalOpen, fetchSkuConfig]);
116
+
117
+ const update = useCallback((key: string, value: any) => {
118
+ setFormData((prev) => ({ ...prev, [key]: value }));
119
+ }, [setFormData]);
120
+
121
+ React.useEffect(() => {
122
+ if (formData.tracking_type === 'serial' && formData.valuation_method !== 'specific') {
123
+ update('valuation_method', 'specific');
124
+ } else if (formData.tracking_type !== 'serial' && formData.valuation_method === 'specific') {
125
+ update('valuation_method', 'fifo');
126
+ }
127
+ }, [formData.tracking_type, formData.valuation_method, update]);
128
+
129
+ const canNext = (): boolean => {
130
+ if (step === 0) return Boolean(formData.name && formData.sku && formData.category_id && formData.uom_id);
131
+ return true;
132
+ };
133
+
134
+ // No need for renderStepper, it is handled by WizardModal
135
+
136
+ // ═══════════════════════════════════════
137
+ // PAGE 1: Basic Information
138
+ // ═══════════════════════════════════════
139
+ const renderPage1 = () => (
140
+ <div className="space-y-4 animate-fade-in">
141
+ <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
142
+ <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-3">Basic Info</p>
143
+ <div className="space-y-4">
144
+ {/* Row 1: Name & Category */}
145
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
146
+ <Input label="Item Name" isRequired placeholder="Enter item name" value={formData.name} onChange={(e) => update("name", e.target.value)} />
147
+ <CategoryPicker label="Category" isRequired value={formData.category_id || null} displayValue={formData.category_name || null}
148
+ onChange={(cat: any) => setFormData((p) => ({ ...p, category_id: cat ? String(cat.id) : "", category_name: cat?.name || "" }))} />
149
+ </div>
150
+ {/* Row 2: Brand & SKU */}
151
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
152
+ <BrandPicker label="Brand" value={formData.brand_id || null} displayValue={formData.brand_name || null}
153
+ onChange={(brand: any) => setFormData((p) => ({ ...p, brand_id: brand ? String(brand.id) : "", brand_name: brand?.name || "" }))} />
154
+ <div className="flex flex-col gap-1.5 w-full">
155
+ <div className="flex items-center justify-between">
156
+ <label className="text-[11px] font-normal text-gray-500 tracking-normal apptimate-input-label">
157
+ SKU <span className="text-danger-alt ml-0.5">*</span>
158
+ </label>
159
+ <button
160
+ type="button"
161
+ onClick={() => setSkuModalOpen(true)}
162
+ className="text-[11px] font-semibold text-gray-900 hover:text-gray-700 transition-colors"
163
+ >
164
+ Generate
165
+ </button>
166
+ </div>
167
+ <Input
168
+ placeholder="e.g. ITM-001"
169
+ value={formData.sku}
170
+ onChange={(e) => update("sku", e.target.value)}
171
+ />
172
+ </div>
173
+ </div>
174
+
175
+ {/* Row 3: Description */}
176
+ <div className="flex flex-col gap-1.5 w-full">
177
+ <label className="text-[11px] font-normal text-gray-500 tracking-normal apptimate-input-label">
178
+ DESCRIPTION
179
+ </label>
180
+ <textarea
181
+ className="w-full bg-surface-0 border-[1.5px] border-border-subtle rounded-[10px] px-3.5 py-2.5 text-[13.5px] text-foreground-1 outline-none transition-all hover:border-gray-300 focus:border-gray-300 focus:bg-surface-1 placeholder:text-foreground-disabled min-h-[100px] resize-y"
182
+ placeholder="Optional item description..."
183
+ value={formData.description}
184
+ onChange={(e) => update("description", e.target.value)}
185
+ />
186
+ </div>
187
+ </div>
188
+ </div>
189
+ {/* Units of Measure & Physical Attributes */}
190
+ <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
191
+ <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-3">Units of Measure & Physical Attributes</p>
192
+ <div className="space-y-3">
193
+ <UomPicker label="Stocking UOM (Base)" isRequired value={formData.uom_id || null} displayValue={formData.uom_name || null} baseUnitsOnly
194
+ onChange={(uom: any) => setFormData((p) => ({ ...p, uom_id: uom ? String(uom.id) : "", uom_name: uom?.name || "" }))} />
195
+
196
+ <div className="grid grid-cols-3 gap-3">
197
+ <Input label="Weight" type="number" placeholder="0" value={formData.weight} onChange={(e) => update("weight", e.target.value)} />
198
+ <Select label="Weight Unit" options={[{ label: "—", value: "" }, { label: "kg", value: "kg" }, { label: "g", value: "g" }, { label: "lb", value: "lb" }, { label: "oz", value: "oz" }]}
199
+ value={formData.weight_unit} onChange={(e) => update("weight_unit", e.target.value)} />
200
+ <Input label="Dimensions (LxWxH)" placeholder="e.g. 10x5x3" value={formData.dimensions} onChange={(e) => update("dimensions", e.target.value)} />
201
+ </div>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ );
206
+
207
+ // ═══════════════════════════════════════
208
+ // PAGE 2: Stock & Configuration
209
+ // ═══════════════════════════════════════
210
+ const renderPage2 = () => (
211
+ <div className="space-y-5 animate-fade-in">
212
+ {/* Tracking */}
213
+ <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
214
+ <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-3">Item Type</p>
215
+ <div className="flex flex-wrap gap-2">
216
+ <button
217
+ type="button"
218
+ disabled={isEditing && formData.can_edit_variants === false}
219
+ title={isEditing && formData.can_edit_variants === false ? "Cannot change item type while transactions exist" : ""}
220
+ onClick={() => setFormData((p) => ({ ...p, has_variants: false }))}
221
+ className={`px-4 py-2 rounded-lg text-sm font-semibold border-2 transition-all duration-150 ${
222
+ !formData.has_variants ? "border-primary-500 bg-primary-50 text-primary-700" : "border-gray-200 bg-white text-gray-500 hover:border-gray-300 hover:bg-gray-50"
223
+ } ${isEditing && formData.can_edit_variants === false ? "opacity-60 cursor-not-allowed" : ""}`}
224
+ >
225
+ Standard Item (No Variants)
226
+ </button>
227
+ <button
228
+ type="button"
229
+ disabled={isEditing && formData.can_edit_variants === false}
230
+ title={isEditing && formData.can_edit_variants === false ? "Cannot change item type while transactions exist" : ""}
231
+ onClick={() => setFormData((p) => ({
232
+ ...p,
233
+ has_variants: true,
234
+ attributes: p.attributes.length === 0 ? [{ name: "", values: [] }] : p.attributes
235
+ }))}
236
+ className={`px-4 py-2 rounded-lg text-sm font-semibold border-2 transition-all duration-150 flex items-center gap-1.5 ${
237
+ formData.has_variants ? "border-primary-500 bg-primary-50 text-primary-700" : "border-gray-200 bg-white text-gray-500 hover:border-gray-300 hover:bg-gray-50"
238
+ } ${isEditing && formData.can_edit_variants === false ? "opacity-60 cursor-not-allowed" : ""}`}
239
+ >
240
+ Item with Variants
241
+ <HintIcon text="Use this if the item comes in multiple options (e.g., sizes, colors)." />
242
+ </button>
243
+ </div>
244
+ </div>
245
+
246
+ {/* Variant Attributes (Only if has_variants is true) */}
247
+ {formData.has_variants && (
248
+ <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
249
+ <div className="flex items-center justify-between mb-3">
250
+ <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">Variant Attributes</p>
251
+ <button
252
+ type="button"
253
+ disabled={isEditing && formData.can_edit_variants === false}
254
+ title={isEditing && formData.can_edit_variants === false ? "Cannot add attributes while transactions exist" : ""}
255
+ onClick={() => setFormData(p => ({ ...p, attributes: [...p.attributes, { name: "", values: [] }] }))}
256
+ className={`flex items-center gap-1.5 text-[12px] font-semibold transition-colors ${
257
+ isEditing && formData.can_edit_variants === false ? "text-gray-400 cursor-not-allowed" : "text-primary-600 hover:text-primary-700"
258
+ }`}
259
+ >
260
+ <Plus size={14} /> Add Attribute
261
+ </button>
262
+ </div>
263
+
264
+ {formData.attributes.length === 0 ? (
265
+ <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>
266
+ ) : (
267
+ <div className="space-y-4">
268
+ {formData.attributes.map((attr, idx) => (
269
+ <div key={idx} className="flex flex-col sm:flex-row items-start gap-4">
270
+ <div className="w-full sm:w-[250px]">
271
+ <Input
272
+ placeholder="e.g. Size, Color"
273
+ value={attr.name}
274
+ disabled={isEditing && formData.can_edit_variants === false}
275
+ onChange={(e) => {
276
+ const newAttrs = [...formData.attributes];
277
+ newAttrs[idx] = { ...newAttrs[idx], name: e.target.value };
278
+ setFormData(p => ({ ...p, attributes: newAttrs }));
279
+ }}
280
+ />
281
+ </div>
282
+ <div className="flex-1 flex items-start gap-2 w-full">
283
+ <TagsInput
284
+ values={attr.values}
285
+ disabled={isEditing && formData.can_edit_variants === false}
286
+ onChange={(values) => {
287
+ const newAttrs = [...formData.attributes];
288
+ newAttrs[idx] = { ...newAttrs[idx], values };
289
+ setFormData(p => ({ ...p, attributes: newAttrs }));
290
+ }}
291
+ placeholder="Type values (e.g. S, M, L) and press Enter"
292
+ />
293
+ <button
294
+ type="button"
295
+ disabled={isEditing && formData.can_edit_variants === false}
296
+ title={isEditing && formData.can_edit_variants === false ? "Cannot remove attributes while transactions exist" : ""}
297
+ onClick={() => {
298
+ const newAttrs = formData.attributes.filter((_, i) => i !== idx);
299
+ setFormData(p => ({ ...p, attributes: newAttrs }));
300
+ }}
301
+ className={`p-2.5 rounded-lg transition-colors mt-0.5 ${
302
+ isEditing && formData.can_edit_variants === false
303
+ ? "text-gray-300 cursor-not-allowed"
304
+ : "text-gray-400 hover:text-danger-alt hover:bg-danger-50"
305
+ }`}
306
+ >
307
+ <Trash size={16} />
308
+ </button>
309
+ </div>
310
+ </div>
311
+ ))}
312
+ </div>
313
+ )}
314
+ </div>
315
+ )}
316
+
317
+ {/* Availability & Settings */}
318
+ <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
319
+ <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-3">Availability & Settings</p>
320
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-y-3 gap-x-4">
321
+ <Checkbox
322
+ label={<span className="flex items-center gap-1.5">Available for Purchase <HintIcon text="Can be purchased from suppliers" /></span>}
323
+ checked={formData.is_available_purchase}
324
+ onChange={(e: any) => setFormData((p) => ({ ...p, is_available_purchase: e.target.checked }))}
325
+ />
326
+ <Checkbox
327
+ label={<span className="flex items-center gap-1.5">Available for Sale <HintIcon text="Can be sold to customers" /></span>}
328
+ checked={formData.is_available_sell}
329
+ onChange={(e: any) => setFormData((p) => ({ ...p, is_available_sell: e.target.checked }))}
330
+ />
331
+ <Checkbox
332
+ label={<span className="flex items-center gap-1.5">Available on E-Commerce <HintIcon text="List on online store" /></span>}
333
+ checked={formData.is_available_ecommerce}
334
+ onChange={(e: any) => setFormData((p) => ({ ...p, is_available_ecommerce: e.target.checked }))}
335
+ />
336
+ <Checkbox
337
+ label={<span className="flex items-center gap-1.5">Track Stock <HintIcon text="Monitor inventory levels" /></span>}
338
+ checked={formData.has_stocks}
339
+ onChange={(e: any) => setFormData((p) => ({ ...p, has_stocks: e.target.checked }))}
340
+ disabled={isEditing && formData.can_edit_variants === false}
341
+ />
342
+ <Checkbox
343
+ label={<span className="flex items-center gap-1.5">Taxable <HintIcon text="Subject to taxes" /></span>}
344
+ checked={formData.is_taxable}
345
+ onChange={(e: any) => setFormData((p) => ({ ...p, is_taxable: e.target.checked }))}
346
+ />
347
+ <Checkbox
348
+ label={<span className="flex items-center gap-1.5">Returnable <HintIcon text="Eligible for returns" /></span>}
349
+ checked={formData.is_returnable}
350
+ onChange={(e: any) => setFormData((p) => ({ ...p, is_returnable: e.target.checked }))}
351
+ />
352
+ <Checkbox
353
+ label={<span className="flex items-center gap-1.5">Discountable <HintIcon text="Eligible for discounts" /></span>}
354
+ checked={formData.is_discountable}
355
+ onChange={(e: any) => setFormData((p) => ({ ...p, is_discountable: e.target.checked }))}
356
+ />
357
+ </div>
358
+ </div>
359
+
360
+ {formData.has_stocks && (
361
+ <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
362
+ <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-3">Tracking Options</p>
363
+ <div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-6">
364
+ <label className="flex items-center gap-2 text-sm text-[#2D3142] cursor-pointer">
365
+ <input
366
+ type="radio"
367
+ name="tracking_type"
368
+ className="w-4 h-4 text-[#7A52F4] border-gray-300 focus:ring-[#7A52F4] disabled:opacity-50"
369
+ checked={formData.tracking_type === 'none'}
370
+ onChange={() => setFormData(p => ({ ...p, tracking_type: 'none' }))}
371
+ disabled={isEditing && formData.can_edit_variants === false}
372
+ />
373
+ <span>None</span>
374
+ </label>
375
+ <label className="flex items-center gap-2 text-sm text-[#2D3142] cursor-pointer">
376
+ <input
377
+ type="radio"
378
+ name="tracking_type"
379
+ className="w-4 h-4 text-[#7A52F4] border-gray-300 focus:ring-[#7A52F4] disabled:opacity-50"
380
+ checked={formData.tracking_type === 'batch'}
381
+ onChange={() => setFormData(p => ({ ...p, tracking_type: 'batch' }))}
382
+ disabled={isEditing && formData.can_edit_variants === false}
383
+ />
384
+ <span className="flex items-center gap-1.5">Batches <HintIcon text="Track items by manufacturing batch or lot" /></span>
385
+ </label>
386
+ <label className="flex items-center gap-2 text-sm text-[#2D3142] cursor-pointer">
387
+ <input
388
+ type="radio"
389
+ name="tracking_type"
390
+ className="w-4 h-4 text-[#7A52F4] border-gray-300 focus:ring-[#7A52F4] disabled:opacity-50"
391
+ checked={formData.tracking_type === 'serial'}
392
+ onChange={() => setFormData(p => ({ ...p, tracking_type: 'serial' }))}
393
+ disabled={isEditing && formData.can_edit_variants === false}
394
+ />
395
+ <span className="flex items-center gap-1.5">Serial Numbers <HintIcon text="Track each individual unit with a unique serial number" /></span>
396
+ </label>
397
+ </div>
398
+
399
+ </div>
400
+ )}
401
+
402
+ {formData.has_stocks && formData.tracking_type !== 'serial' && (
403
+ <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
404
+ <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-3">Valuation Method (COGS)</p>
405
+ <div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-6">
406
+ <label className="flex items-center gap-2 text-sm text-[#2D3142] cursor-pointer">
407
+ <input
408
+ type="radio"
409
+ name="valuation_method"
410
+ className="w-4 h-4 text-[#7A52F4] border-gray-300 focus:ring-[#7A52F4] disabled:opacity-50"
411
+ checked={formData.valuation_method === 'fifo'}
412
+ onChange={() => setFormData(p => ({ ...p, valuation_method: 'fifo' }))}
413
+ disabled={isEditing && formData.can_edit_variants === false}
414
+ />
415
+ <span>First-In, First-Out (FIFO)</span>
416
+ </label>
417
+ <label className="flex items-center gap-2 text-sm text-[#2D3142] cursor-pointer">
418
+ <input
419
+ type="radio"
420
+ name="valuation_method"
421
+ className="w-4 h-4 text-[#7A52F4] border-gray-300 focus:ring-[#7A52F4] disabled:opacity-50"
422
+ checked={formData.valuation_method === 'lifo'}
423
+ onChange={() => setFormData(p => ({ ...p, valuation_method: 'lifo' }))}
424
+ disabled={isEditing && formData.can_edit_variants === false}
425
+ />
426
+ <span>Last-In, First-Out (LIFO)</span>
427
+ </label>
428
+ <label className="flex items-center gap-2 text-sm text-[#2D3142] cursor-pointer">
429
+ <input
430
+ type="radio"
431
+ name="valuation_method"
432
+ className="w-4 h-4 text-[#7A52F4] border-gray-300 focus:ring-[#7A52F4] disabled:opacity-50"
433
+ checked={formData.valuation_method === 'mac'}
434
+ onChange={() => setFormData(p => ({ ...p, valuation_method: 'mac' }))}
435
+ disabled={isEditing && formData.can_edit_variants === false}
436
+ />
437
+ <span className="flex items-center gap-1.5">Moving Average Cost (MAC)</span>
438
+ </label>
439
+ </div>
440
+ </div>
441
+ )}
442
+
443
+ {/* Stock Levels */}
444
+ {formData.has_stocks && (
445
+ <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
446
+ <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-3">Stock Levels</p>
447
+ <div className="grid grid-cols-2 gap-3">
448
+ <Input label="Min Qty" type="number" placeholder="0" value={formData.min_stock_level} onChange={(e) => update("min_stock_level", e.target.value)} />
449
+ <Input label="Max Qty" type="number" placeholder="0" value={formData.max_stock_level} onChange={(e) => update("max_stock_level", e.target.value)} />
450
+ </div>
451
+ <div className="grid grid-cols-3 gap-3 mt-3">
452
+ <Input label="Reorder Level" type="number" placeholder="0" value={formData.reorder_point} onChange={(e) => update("reorder_point", e.target.value)} />
453
+ <Input label="Reorder Qty" type="number" placeholder="0" value={formData.reorder_qty} onChange={(e) => update("reorder_qty", e.target.value)} />
454
+ <Input label="Lead Time (days)" type="number" placeholder="0" value={formData.lead_time_days} onChange={(e) => update("lead_time_days", e.target.value)} />
455
+ </div>
456
+ </div>
457
+ )}
458
+
459
+ </div>
460
+ );
461
+
462
+ // ═══════════════════════════════════════
463
+ // PAGE 3: Variants
464
+ // ═══════════════════════════════════════
465
+
466
+ const addVariantRow = () => {
467
+ setFormData((p) => ({
468
+ ...p,
469
+ variants: [...p.variants, { sku: "", variant_name: "", barcode: "", sale_price: "", cost_price: "", attribute_values: {} }],
470
+ }));
471
+ };
472
+
473
+ const updateVariant = (idx: number, key: string, value: any) => {
474
+ setFormData((p) => {
475
+ const variants = [...p.variants];
476
+ variants[idx] = { ...variants[idx], [key]: value };
477
+ return { ...p, variants };
478
+ });
479
+ };
480
+
481
+ const removeVariant = (idx: number) => {
482
+ setFormData((p) => ({ ...p, variants: p.variants.filter((_, i) => i !== idx) }));
483
+ };
484
+
485
+ const copyToAll = (key: keyof VariantRow) => {
486
+ if (formData.variants.length <= 1) return;
487
+ const val = formData.variants[0][key];
488
+ setFormData(p => {
489
+ const newVariants = p.variants.map((v, i) => i === 0 ? v : { ...v, [key]: val });
490
+ return { ...p, variants: newVariants };
491
+ });
492
+ };
493
+
494
+ const generateCombinations = useCallback(() => {
495
+ const validAttributes = formData.attributes.filter(a => a.name && a.values.length > 0);
496
+ if (validAttributes.length === 0) return;
497
+
498
+ let combinations: any[] = [{}];
499
+ validAttributes.forEach((attr) => {
500
+ const newCombinations: any[] = [];
501
+ combinations.forEach((combo) => {
502
+ attr.values.forEach((value) => {
503
+ newCombinations.push({
504
+ ...combo,
505
+ [attr.name]: value,
506
+ });
507
+ });
508
+ });
509
+ combinations = newCombinations;
510
+ });
511
+
512
+ const newVariants: VariantRow[] = combinations.map((combo) => {
513
+ const variantName = Object.values(combo).join(" - ");
514
+ return {
515
+ variant_name: variantName,
516
+ sku: "",
517
+ barcode: "",
518
+ sale_price: formData.sale_price || "0",
519
+ cost_price: formData.cost_price || "0",
520
+ attribute_values: combo,
521
+ };
522
+ });
523
+
524
+ setFormData(p => ({ ...p, variants: newVariants }));
525
+ }, [formData.attributes, formData.sale_price, formData.cost_price, setFormData]);
526
+
527
+ // Auto-generate variants on step 2 if empty
528
+ React.useEffect(() => {
529
+ if (step === 2 && formData.has_variants && formData.variants.length === 0) {
530
+ generateCombinations();
531
+ }
532
+ }, [step, formData.has_variants, formData.variants.length, generateCombinations]);
533
+
534
+ const renderPage3 = () => (
535
+ <div className="space-y-5 animate-fade-in">
536
+ {!formData.has_variants ? (
537
+ <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
538
+ <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-3">Item Pricing</p>
539
+ <div className="grid grid-cols-2 gap-4">
540
+ {formData.is_available_sell && (
541
+ <Input label="Sale Price" type="number" placeholder="0.00" value={formData.sale_price} onChange={(e) => update("sale_price", e.target.value)} />
542
+ )}
543
+ {formData.is_available_purchase && (
544
+ <Input label="Cost Price" type="number" placeholder="0.00" value={formData.cost_price} onChange={(e) => update("cost_price", e.target.value)} />
545
+ )}
546
+ </div>
547
+ </div>
548
+ ) : (
549
+ <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
550
+ <div className="flex items-center justify-between mb-3">
551
+ <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">Variants ({formData.variants.length})</p>
552
+ <div className="flex items-center gap-4">
553
+ </div>
554
+ </div>
555
+ {formData.variants.length === 0 ? (
556
+ <div className="text-center py-6 text-gray-400 text-[13px]">No variants added yet. Please go back to the previous step to add variant attributes.</div>
557
+ ) : (
558
+ <div className="overflow-x-auto rounded-lg border border-gray-200">
559
+ <table className="w-full text-left text-sm text-gray-600">
560
+ <thead className="bg-gray-50 text-[11px] font-bold text-gray-400 uppercase tracking-wider">
561
+ <tr>
562
+ <th className="px-4 py-3 font-medium">Variant Name *</th>
563
+ <th className="px-4 py-3 font-medium align-top">
564
+ <div className="flex flex-col items-start gap-1">
565
+ <span>SKU *</span>
566
+ {formData.variants.length > 0 && (
567
+ <button type="button" onClick={() => { setActiveVariantSkuIndex(-1); setSkuModalOpen(true); }} className="text-primary-600 hover:text-primary-700 font-semibold normal-case text-[11px]">Generate All</button>
568
+ )}
569
+ </div>
570
+ </th>
571
+ <th className="px-4 py-3 font-medium align-top">Barcode</th>
572
+ {formData.is_available_sell && (
573
+ <th className="px-4 py-3 font-medium w-[140px] align-top">
574
+ <div className="flex flex-col items-start gap-1">
575
+ <span>Sale Price</span>
576
+ {formData.variants.length > 1 && (
577
+ <button type="button" onClick={() => copyToAll("sale_price")} className="text-primary-600 hover:text-primary-700 font-semibold normal-case text-[11px]">Copy to All</button>
578
+ )}
579
+ </div>
580
+ </th>
581
+ )}
582
+ {formData.is_available_purchase && (
583
+ <th className="px-4 py-3 font-medium w-[140px] align-top">
584
+ <div className="flex flex-col items-start gap-1">
585
+ <span>Cost Price</span>
586
+ {formData.variants.length > 1 && (
587
+ <button type="button" onClick={() => copyToAll("cost_price")} className="text-primary-600 hover:text-primary-700 font-semibold normal-case text-[11px]">Copy to All</button>
588
+ )}
589
+ </div>
590
+ </th>
591
+ )}
592
+ <th className="px-4 py-3 text-center w-12 align-top"></th>
593
+ </tr>
594
+ </thead>
595
+ <tbody className="divide-y divide-gray-100 bg-white">
596
+ {formData.variants.map((v, idx) => (
597
+ <tr key={idx} className="hover:bg-gray-50/50 transition-colors">
598
+ <td className="p-2 min-w-[200px] align-top">
599
+ <Input placeholder="e.g. Gold 22K" value={v.variant_name} onChange={(e) => updateVariant(idx, "variant_name", e.target.value)} />
600
+ </td>
601
+ <td className="p-2 min-w-[200px] align-top">
602
+ <Input placeholder="e.g. ITM-001" value={v.sku} onChange={(e) => updateVariant(idx, "sku", e.target.value)} />
603
+ </td>
604
+ <td className="p-2 min-w-[200px] align-top">
605
+ <Input placeholder="Leave empty to auto-generate" value={v.barcode} onChange={(e) => updateVariant(idx, "barcode", e.target.value)} />
606
+ </td>
607
+ {formData.is_available_sell && (
608
+ <td className="p-2 min-w-[120px] align-top">
609
+ <Input type="number" placeholder="0.00" value={v.sale_price} onChange={(e) => updateVariant(idx, "sale_price", e.target.value)} />
610
+ </td>
611
+ )}
612
+ {formData.is_available_purchase && (
613
+ <td className="p-2 min-w-[120px] align-top">
614
+ <Input type="number" placeholder="0.00" value={v.cost_price} onChange={(e) => updateVariant(idx, "cost_price", e.target.value)} />
615
+ </td>
616
+ )}
617
+ <td className="p-2 text-center align-middle w-12">
618
+ <button
619
+ type="button"
620
+ disabled={isEditing && formData.can_edit_variants === false}
621
+ title={isEditing && formData.can_edit_variants === false ? "Cannot delete variants while transactions exist" : ""}
622
+ onClick={() => removeVariant(idx)}
623
+ className={cn(
624
+ "p-1.5 rounded-lg transition-colors mt-1",
625
+ isEditing && formData.can_edit_variants === false
626
+ ? "text-gray-300 cursor-not-allowed"
627
+ : "text-gray-400 hover:text-danger-500 hover:bg-danger-50"
628
+ )}
629
+ >
630
+ <Trash2 size={14} />
631
+ </button>
632
+ </td>
633
+ </tr>
634
+ ))}
635
+ </tbody>
636
+ </table>
637
+ </div>
638
+ )}
639
+ </div>
640
+ )}
641
+ </div>
642
+ );
643
+
644
+ // ── Render ──
645
+ const wizardSteps = [
646
+ { key: "basic", label: "Basic Info", num: 1 },
647
+ { key: "stock", label: "Stock & Config", num: 2 },
648
+ ...(showStep3 ? [{ key: "variants", label: formData.has_variants ? "Variants" : "Pricing", num: 3 }] : []),
649
+ ];
650
+
651
+ return (
652
+ <>
653
+ <WizardModal
654
+ isOpen={isOpen}
655
+ onClose={onClose}
656
+ title={isEditing ? "Edit Item" : "Add New Item"}
657
+ size="xl"
658
+ steps={wizardSteps}
659
+ currentStep={step}
660
+ onStepChange={setStep}
661
+ canNext={canNext}
662
+ onSubmit={onSubmit}
663
+ cancelLabel="Cancel"
664
+ submitLabel={isEditing ? "Update Item" : "Create Item"}
665
+ >
666
+ {step === 0 && renderPage1()}
667
+ {step === 1 && renderPage2()}
668
+ {step === 2 && renderPage3()}
669
+ </WizardModal>
670
+ <SkuConfigModal
671
+ isOpen={skuModalOpen}
672
+ onClose={() => {
673
+ setSkuModalOpen(false);
674
+ setActiveVariantSkuIndex(null);
675
+ }}
676
+ onGenerate={(sku, newConfig) => {
677
+ if (newConfig) setSkuConfig(newConfig);
678
+ if (activeVariantSkuIndex === -1 && Array.isArray(sku)) {
679
+ setFormData(p => {
680
+ const newVariants = [...p.variants];
681
+ sku.forEach((s, idx) => {
682
+ if (newVariants[idx]) newVariants[idx].sku = s;
683
+ });
684
+ return { ...p, variants: newVariants };
685
+ });
686
+ setActiveVariantSkuIndex(null);
687
+ } else if (activeVariantSkuIndex !== null && activeVariantSkuIndex >= 0) {
688
+ updateVariant(activeVariantSkuIndex, "sku", sku);
689
+ setActiveVariantSkuIndex(null);
690
+ } else {
691
+ update("sku", sku);
692
+ }
693
+ }}
694
+ categoryName={formData.category_name}
695
+ categoryId={formData.category_id ? Number(formData.category_id) : undefined}
696
+ brandName={formData.brand_name}
697
+ itemName={formData.name}
698
+ itemNames={activeVariantSkuIndex === -1 ? formData.variants.map(() => formData.name) : undefined}
699
+ itemAttributes={activeVariantSkuIndex !== null && activeVariantSkuIndex >= 0 ? formData.variants[activeVariantSkuIndex].attribute_values : undefined}
700
+ itemsAttributes={activeVariantSkuIndex === -1 ? formData.variants.map(v => v.attribute_values) : undefined}
701
+ isVariant={activeVariantSkuIndex !== null}
702
+ />
703
+ </>
704
+ );
705
+ }