@apptimate/ui 3.5.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,314 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect } from "react";
4
+ import { Modal, ModalFooter, Button, Select, Input } from "@apptimate/ui";
5
+ import { saveInventorySetting, getInventorySetting, getItemCategoryCount } from "@apptimate/core-lib";
6
+ import { Trash2, Plus } from "lucide-react";
7
+ import toast from "react-hot-toast";
8
+
9
+ interface Props {
10
+ isOpen: boolean;
11
+ onClose: () => void;
12
+ onGenerate: (sku: any, newConfig?: any) => void;
13
+ categoryName?: string;
14
+ categoryId?: number;
15
+ brandName?: string;
16
+ itemName?: string;
17
+ itemNames?: string[];
18
+ itemAttributes?: Record<string, string>;
19
+ itemsAttributes?: Record<string, string>[];
20
+ isVariant?: boolean;
21
+ }
22
+
23
+ export const generateSkuPreview = (cfg: any, categoryName?: string, brandName?: string, itemName?: string, attributes?: Record<string, string>) => {
24
+ let result = "";
25
+ if (!cfg || !cfg.parts) return result;
26
+
27
+ cfg.parts.forEach((p: any, idx: number) => {
28
+ let val = "";
29
+ if (p.type === "category") val = (categoryName || "CAT").substring(0, p.length || 3).toUpperCase();
30
+ if (p.type === "brand") val = (brandName || "BRN").substring(0, p.length || 3).toUpperCase();
31
+ if (p.type === "name") val = (itemName || "ITM").substring(0, p.length || 3).toUpperCase();
32
+ if (p.type === "custom") val = p.custom_value || "";
33
+ if (p.type === "attributes") {
34
+ if (attributes && Object.keys(attributes).length > 0) {
35
+ val = Object.values(attributes)
36
+ .map((v: any) => String(v).substring(0, p.length || 3).toUpperCase())
37
+ .join("-");
38
+ } else {
39
+ val = "ATTR";
40
+ }
41
+ }
42
+
43
+ const isLast = (idx === cfg.parts.length - 1) && (!cfg.sequence_length || cfg.sequence_length === 0);
44
+ result += val + (isLast ? "" : (p.separator || ""));
45
+ });
46
+
47
+ // Add sequence at the end
48
+ if (cfg.sequence_length > 0) {
49
+ result += String(cfg.current_sequence).padStart(cfg.sequence_length, "0");
50
+ }
51
+ return result;
52
+ };
53
+
54
+ export default function SkuConfigModal({ isOpen, onClose, onGenerate, categoryName, categoryId, brandName, itemName, itemNames, itemAttributes, itemsAttributes, isVariant }: Props) {
55
+ const [isLoading, setIsLoading] = useState(false);
56
+ const [config, setConfig] = useState({
57
+ parts: (isVariant ? [
58
+ { type: "category", length: 3, custom_value: "", separator: "-" },
59
+ { type: "name", length: 3, custom_value: "", separator: "-" },
60
+ { type: "attributes", length: 3, custom_value: "", separator: "" },
61
+ ] : [
62
+ { type: "category", length: 3, custom_value: "", separator: "-" },
63
+ { type: "name", length: 3, custom_value: "", separator: "-" },
64
+ ]) as any[],
65
+ sequence_length: 4,
66
+ current_sequence: 1,
67
+ });
68
+
69
+ useEffect(() => {
70
+ if (isOpen) {
71
+ // Reset state to default immediately when opened
72
+ setConfig({
73
+ parts: isVariant ? [
74
+ { type: "category", length: 3, custom_value: "", separator: "-" },
75
+ { type: "name", length: 3, custom_value: "", separator: "-" },
76
+ { type: "attributes", length: 3, custom_value: "", separator: "" },
77
+ ] : [
78
+ { type: "category", length: 3, custom_value: "", separator: "-" },
79
+ { type: "name", length: 3, custom_value: "", separator: "-" },
80
+ ] as any[],
81
+ sequence_length: 4,
82
+ current_sequence: 1,
83
+ });
84
+
85
+ setIsLoading(true);
86
+ const settingKey = isVariant ? "sku_generation_rule_variant_v3" : "sku_generation_rule_v3";
87
+
88
+ Promise.all([
89
+ getInventorySetting(settingKey).catch(() => null),
90
+ categoryId ? getItemCategoryCount(categoryId).catch(() => null) : Promise.resolve(null)
91
+ ])
92
+ .then(([settingRes, countRes]) => {
93
+ let currentSequence = 1;
94
+ if (countRes && countRes.is_success) {
95
+ currentSequence = (countRes.result?.count || 0) + 1;
96
+ }
97
+
98
+ if (settingRes && settingRes.is_success && settingRes.result) {
99
+ setConfig({
100
+ ...settingRes.result,
101
+ current_sequence: currentSequence,
102
+ });
103
+ } else {
104
+ setConfig((prev) => ({
105
+ ...prev,
106
+ current_sequence: currentSequence,
107
+ }));
108
+ }
109
+ })
110
+ .catch(() => {})
111
+ .finally(() => setIsLoading(false));
112
+ }
113
+ }, [isOpen, categoryId, isVariant]);
114
+
115
+ const generatePreview = (cfg = config) => {
116
+ const effectiveCfg = { ...cfg, sequence_length: isVariant ? 0 : cfg.sequence_length };
117
+ return generateSkuPreview(effectiveCfg, categoryName, brandName, itemName, itemAttributes || (itemsAttributes ? itemsAttributes[0] : undefined));
118
+ };
119
+
120
+ const handleApply = async () => {
121
+ setIsLoading(true);
122
+ try {
123
+ const count = itemNames ? itemNames.length : 1;
124
+ if (count === 0) {
125
+ toast.error("No items to generate SKUs for.");
126
+ setIsLoading(false);
127
+ return;
128
+ }
129
+
130
+ const nextConfig = { ...config };
131
+ const effectiveSequenceLength = isVariant ? 0 : nextConfig.sequence_length;
132
+ const settingKey = isVariant ? "sku_generation_rule_variant_v3" : "sku_generation_rule_v3";
133
+
134
+ // Save config and increment sequence
135
+ await saveInventorySetting(settingKey, nextConfig);
136
+
137
+ if (itemNames && itemNames.length > 0) {
138
+ // Generate for multiple items
139
+ const generated = itemNames.map((name, idx) => {
140
+ return generateSkuPreview(
141
+ { ...nextConfig, sequence_length: effectiveSequenceLength, current_sequence: nextConfig.current_sequence + idx },
142
+ categoryName, brandName, name, itemsAttributes ? itemsAttributes[idx] : undefined
143
+ );
144
+ });
145
+ onGenerate(generated, { ...nextConfig, current_sequence: nextConfig.current_sequence + count });
146
+ } else {
147
+ // Generate for single item
148
+ const sku = generateSkuPreview(
149
+ { ...nextConfig, sequence_length: effectiveSequenceLength },
150
+ categoryName, brandName, itemName, itemAttributes
151
+ );
152
+ onGenerate(sku, { ...nextConfig, current_sequence: nextConfig.current_sequence + 1 });
153
+ }
154
+
155
+ toast.success(itemNames ? `${count} SKUs generated` : "SKU generated");
156
+ onClose();
157
+ } catch (e: any) {
158
+ toast.error(e.message || "Failed to save SKU settings");
159
+ } finally {
160
+ setIsLoading(false);
161
+ }
162
+ };
163
+
164
+ const updatePart = (index: number, key: string, value: any) => {
165
+ const newParts = [...config.parts];
166
+ newParts[index] = { ...newParts[index], [key]: value };
167
+ setConfig({ ...config, parts: newParts });
168
+ };
169
+
170
+ const removePart = (index: number) => {
171
+ const newParts = [...config.parts];
172
+ newParts.splice(index, 1);
173
+ setConfig({ ...config, parts: newParts });
174
+ };
175
+
176
+ const addPart = () => {
177
+ setConfig({
178
+ ...config,
179
+ parts: [...config.parts, { type: "custom", length: 3, custom_value: "", separator: "-" }],
180
+ });
181
+ };
182
+
183
+ const fieldOptions = [
184
+ { label: "Category", value: "category" },
185
+ { label: "Brand", value: "brand" },
186
+ { label: "Item Name", value: "name" },
187
+ ...(isVariant ? [{ label: "Variant Attributes", value: "attributes" }] : []),
188
+ { label: "Custom Text/Code", value: "custom" },
189
+ ];
190
+
191
+ const separatorOptions = [
192
+ { label: "Hyphen (-)", value: "-" },
193
+ { label: "Underscore (_)", value: "_" },
194
+ { label: "Slash (/)", value: "/" },
195
+ { label: "None", value: "" },
196
+ ];
197
+
198
+ if (!isOpen) return null;
199
+
200
+ return (
201
+ <Modal isOpen={isOpen} onClose={onClose} title="Configure SKU Generation" size="lg">
202
+ {isLoading ? (
203
+ <div className="py-20 text-center text-gray-500">Loading settings...</div>
204
+ ) : (
205
+ <div className="space-y-5 animate-fade-in py-2">
206
+
207
+ <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
208
+ <div className="flex items-center justify-between mb-3">
209
+ <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">Formula Components</p>
210
+ <Button type="button" variant="flat" color="primary" size="small" icon={<Plus size={14} />} onClick={addPart}>
211
+ Add Component
212
+ </Button>
213
+ </div>
214
+
215
+ {/* Table Header */}
216
+ {config.parts.length > 0 && (
217
+ <div className="grid grid-cols-12 gap-3 mb-2 px-2 text-[11px] font-bold text-gray-400 uppercase tracking-wider">
218
+ <div className="col-span-5">Component Field</div>
219
+ <div className="col-span-3">Letters / Text</div>
220
+ <div className="col-span-3">Separator</div>
221
+ <div className="col-span-1 text-center"></div>
222
+ </div>
223
+ )}
224
+
225
+ {/* Table Rows */}
226
+ <div className="space-y-2">
227
+ {config.parts.map((part, index) => (
228
+ <div key={index} className="grid grid-cols-12 gap-3 items-center bg-white p-2 rounded-lg border border-gray-200">
229
+ <div className="col-span-5">
230
+ <Select
231
+ options={fieldOptions}
232
+ value={part.type}
233
+ onChange={(e) => updatePart(index, "type", e.target.value)}
234
+ />
235
+ </div>
236
+ <div className="col-span-3">
237
+ {part.type === "custom" ? (
238
+ <Input
239
+ placeholder="e.g. PRE"
240
+ value={part.custom_value}
241
+ onChange={(e) => updatePart(index, "custom_value", e.target.value.toUpperCase())}
242
+ />
243
+ ) : (
244
+ <Input
245
+ type="number"
246
+ placeholder="3"
247
+ value={String(part.length || 3)}
248
+ onChange={(e) => updatePart(index, "length", Math.max(1, parseInt(e.target.value) || 1))}
249
+ />
250
+ )}
251
+ </div>
252
+ <div className="col-span-3">
253
+ {!(isVariant && index === config.parts.length - 1) && (
254
+ <Select
255
+ options={separatorOptions}
256
+ value={part.separator}
257
+ onChange={(e) => updatePart(index, "separator", e.target.value)}
258
+ />
259
+ )}
260
+ </div>
261
+ <div className="col-span-1 flex justify-center">
262
+ <button type="button" onClick={() => removePart(index)} className="text-gray-400 hover:text-red-500 transition-colors p-1.5 rounded hover:bg-red-50">
263
+ <Trash2 size={16} />
264
+ </button>
265
+ </div>
266
+ </div>
267
+ ))}
268
+
269
+ {config.parts.length === 0 && (
270
+ <div className="text-center py-6 text-sm text-gray-400 border-2 border-dashed border-gray-200 rounded-lg bg-white">
271
+ No components added. Only sequence will be generated.
272
+ </div>
273
+ )}
274
+ </div>
275
+ </div>
276
+
277
+ {!isVariant && (
278
+ <div className="p-4 bg-gray-50/50 rounded-xl border border-gray-100">
279
+ <p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider mb-3">Sequence Settings</p>
280
+ <div className="w-1/2">
281
+ <Select
282
+ label="Sequence Length"
283
+ options={[
284
+ { label: "None", value: "0" },
285
+ { label: "2 digits (01)", value: "2" },
286
+ { label: "3 digits (001)", value: "3" },
287
+ { label: "4 digits (0001)", value: "4" },
288
+ { label: "5 digits (00001)", value: "5" },
289
+ ]}
290
+ value={String(config.sequence_length)}
291
+ onChange={(e) => setConfig({ ...config, sequence_length: Number(e.target.value) })}
292
+ />
293
+ </div>
294
+ </div>
295
+ )}
296
+
297
+ <div className="p-4 bg-primary-50 rounded-xl border border-primary-100 text-center">
298
+ <p className="text-[11px] font-bold text-primary-400 uppercase tracking-wider mb-1">Preview</p>
299
+ <p className="text-lg font-mono font-bold text-primary-700">
300
+ {generatePreview(config)}
301
+ </p>
302
+ </div>
303
+
304
+ <ModalFooter>
305
+ <Button variant="flat" color="default" onClick={onClose}>Cancel</Button>
306
+ <Button color="primary" onClick={handleApply} isDisabled={isLoading || (config.parts.length === 0 && config.sequence_length === 0)}>
307
+ Apply & Generate
308
+ </Button>
309
+ </ModalFooter>
310
+ </div>
311
+ )}
312
+ </Modal>
313
+ );
314
+ }
@@ -0,0 +1,194 @@
1
+ "use client";
2
+
3
+ import React, { useState, useCallback } from "react";
4
+ import { Button, Input, Modal, ModalFooter } from "@apptimate/ui";
5
+ import { Plus, Package } from "lucide-react";
6
+ import {
7
+ EntityPickerModal,
8
+ PickerItem,
9
+ PickerTrigger,
10
+ } from "./EntityPickerModal";
11
+ import { lookupBrands, createBrand } from "@apptimate/core-lib";
12
+ import toast from "react-hot-toast";
13
+
14
+ /* ──────────────────────────────────────────────────────────────────────────
15
+ * BrandPicker
16
+ *
17
+ * Popup selector for brands with:
18
+ * - Flat list (search + select)
19
+ * - Quick-add new brand
20
+ * ────────────────────────────────────────────────────────────────────── */
21
+
22
+ interface BrandPickerProps {
23
+ /** Currently selected brand ID */
24
+ value?: number | string | null;
25
+ /** Display name of the selected brand */
26
+ displayValue?: string | null;
27
+ /** Called when a brand is selected – receives { id, name } or null */
28
+ onChange: (brand: { id: number; name: string } | null) => void;
29
+ /** Field label */
30
+ label?: string;
31
+ /** Placeholder text */
32
+ placeholder?: string;
33
+ /** Required field? */
34
+ isRequired?: boolean;
35
+ }
36
+
37
+ export function BrandPicker({
38
+ value,
39
+ displayValue,
40
+ onChange,
41
+ label = "Brand",
42
+ placeholder = "Select brand…",
43
+ isRequired = false,
44
+ }: BrandPickerProps) {
45
+ const [isOpen, setIsOpen] = useState(false);
46
+ const [search, setSearch] = useState("");
47
+ const [brands, setBrands] = useState<any[]>([]);
48
+ const [isLoading, setIsLoading] = useState(false);
49
+
50
+ // Quick add
51
+ const [isQuickAddOpen, setIsQuickAddOpen] = useState(false);
52
+ const [quickAddName, setQuickAddName] = useState("");
53
+ const [isCreating, setIsCreating] = useState(false);
54
+
55
+ const fetchData = useCallback(async () => {
56
+ setIsLoading(true);
57
+ try {
58
+ const res = await lookupBrands();
59
+ if (res.is_success) setBrands(res.result || []);
60
+ } catch {}
61
+ setIsLoading(false);
62
+ }, []);
63
+
64
+ const handleOpen = () => {
65
+ setSearch("");
66
+ setIsOpen(true);
67
+ fetchData();
68
+ };
69
+
70
+ const handleSelect = (brand: any) => {
71
+ onChange({ id: brand.id, name: brand.name });
72
+ setIsOpen(false);
73
+ };
74
+
75
+ const handleClear = () => {
76
+ onChange(null);
77
+ };
78
+
79
+ const handleQuickAdd = async () => {
80
+ if (!quickAddName.trim()) return;
81
+ setIsCreating(true);
82
+ try {
83
+ const res = await createBrand({ name: quickAddName.trim(), status: "active" });
84
+ if (res.is_success && res.result) {
85
+ toast.success("Brand created");
86
+ onChange({ id: res.result.id, name: res.result.name });
87
+ setIsQuickAddOpen(false);
88
+ setQuickAddName("");
89
+ setIsOpen(false);
90
+ } else {
91
+ toast.error(res.message || "Failed to create");
92
+ }
93
+ } catch (e: any) {
94
+ toast.error(e.message || "Error creating brand");
95
+ }
96
+ setIsCreating(false);
97
+ };
98
+
99
+ const searchLower = search.toLowerCase();
100
+ const filtered = brands.filter((b) =>
101
+ b.name.toLowerCase().includes(searchLower)
102
+ );
103
+
104
+ return (
105
+ <>
106
+ <PickerTrigger
107
+ label={label}
108
+ value={displayValue || null}
109
+ placeholder={placeholder}
110
+ isRequired={isRequired}
111
+ onClick={handleOpen}
112
+ onClear={value ? handleClear : undefined}
113
+ />
114
+
115
+ <EntityPickerModal
116
+ isOpen={isOpen}
117
+ onClose={() => setIsOpen(false)}
118
+ onSelect={handleSelect}
119
+ title="Select Brand"
120
+ searchPlaceholder="Search brands…"
121
+ selectedId={value}
122
+ search={search}
123
+ onSearchChange={setSearch}
124
+ size="sm"
125
+ headerActions={
126
+ <button
127
+ type="button"
128
+ onClick={() => { setIsQuickAddOpen(true); setQuickAddName(""); }}
129
+ className="p-1.5 rounded-lg bg-primary-50 text-primary-600 hover:bg-primary-100 transition-colors flex-shrink-0"
130
+ title="Quick Add Brand"
131
+ >
132
+ <Plus size={16} strokeWidth={2.5} />
133
+ </button>
134
+ }
135
+ >
136
+ {isLoading ? (
137
+ <div className="py-12 text-center">
138
+ <div className="inline-block h-6 w-6 rounded-full border-2 border-gray-200 border-t-primary-500 animate-spin" />
139
+ <p className="mt-3 text-sm text-gray-400">Loading brands…</p>
140
+ </div>
141
+ ) : filtered.length === 0 ? (
142
+ <div className="py-12 text-center">
143
+ <Package size={32} className="mx-auto text-gray-300 mb-2" />
144
+ <p className="text-sm text-gray-400">No brands found</p>
145
+ </div>
146
+ ) : (
147
+ <div className="space-y-0.5 py-1">
148
+ {filtered.map((brand) => (
149
+ <PickerItem
150
+ key={brand.id}
151
+ label={brand.name}
152
+ isSelected={String(value) === String(brand.id)}
153
+ onClick={() => handleSelect(brand)}
154
+ />
155
+ ))}
156
+ </div>
157
+ )}
158
+ </EntityPickerModal>
159
+
160
+ {/* Quick Add Modal */}
161
+ <Modal
162
+ isOpen={isQuickAddOpen}
163
+ onClose={() => setIsQuickAddOpen(false)}
164
+ title="Quick Add Brand"
165
+ size="sm"
166
+ zIndex={300}
167
+ backdrop="blur"
168
+ >
169
+ <div className="space-y-4">
170
+ <Input
171
+ label="Brand Name"
172
+ isRequired
173
+ placeholder="Enter brand name"
174
+ value={quickAddName}
175
+ onChange={(e) => setQuickAddName(e.target.value)}
176
+ onKeyDown={(e) => { if (e.key === "Enter") handleQuickAdd(); }}
177
+ />
178
+ <ModalFooter>
179
+ <Button
180
+ variant="flat"
181
+ color="default"
182
+ onClick={() => setIsQuickAddOpen(false)}
183
+ >
184
+ Cancel
185
+ </Button>
186
+ <Button onClick={handleQuickAdd} isDisabled={isCreating || !quickAddName.trim()}>
187
+ {isCreating ? "Creating…" : "Create & Select"}
188
+ </Button>
189
+ </ModalFooter>
190
+ </div>
191
+ </Modal>
192
+ </>
193
+ );
194
+ }