@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.
- package/package.json +3 -2
- package/src/base-components/Input.tsx +6 -3
- package/src/base-components/TagsInput.tsx +106 -0
- package/src/base-components/WizardModal.tsx +162 -0
- package/src/common-components/item-wizard/ItemFormWizard.tsx +705 -0
- package/src/common-components/item-wizard/SkuConfigModal.tsx +314 -0
- package/src/common-components/pickers/BrandPicker.tsx +194 -0
- package/src/common-components/pickers/CategoryPicker.tsx +342 -0
- package/src/common-components/pickers/EntityPickerModal.tsx +221 -0
- package/src/common-components/pickers/PartyPicker.tsx +231 -0
- package/src/common-components/pickers/TransactionItemPicker.tsx +166 -0
- package/src/common-components/pickers/UomGroupPicker.tsx +196 -0
- package/src/common-components/pickers/UomPicker.tsx +306 -0
- package/src/common-components/pickers/WarehousePicker.tsx +129 -0
- package/src/common-components/pickers/index.ts +11 -0
- package/src/common-components/pickers/modals/BatchSelectionModal.tsx +359 -0
- package/src/common-components/pickers/modals/SerialSelectionModal.tsx +247 -0
- package/src/common-components/pickers/modals/types.ts +30 -0
- package/src/common-components/transaction/MetadataPanel.tsx +417 -0
- package/src/common-components/transaction/ProductSelectionPanel.tsx +587 -0
- package/src/common-components/transaction/ProductTransactionScreen.tsx +250 -0
- package/src/common-components/transaction/index.ts +17 -0
- package/src/common-components/transaction/types.ts +300 -0
- package/src/components/shared/ImageUploadComponent.tsx +1 -2
- 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
|
+
}
|