@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,359 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Search, Plus, X } from "lucide-react";
|
|
3
|
+
import { Modal, ModalFooter } from "../../../base-components/Modal";
|
|
4
|
+
import { Button } from "../../../base-components/Button";
|
|
5
|
+
import { Table, THeader, TBody, TRow, TCell } from "../../../base-components/Table";
|
|
6
|
+
import { TransactionItem, TransactionItemVariant, TransactionBatch } from "./types";
|
|
7
|
+
import { sendRequest } from "@apptimate/core-lib";
|
|
8
|
+
import { toast } from "react-hot-toast";
|
|
9
|
+
import { SearchableSelect } from "../../../base-components/SearchableSelect";
|
|
10
|
+
|
|
11
|
+
interface BatchSelectionModalProps {
|
|
12
|
+
isOpen: boolean;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
item: TransactionItem | null;
|
|
15
|
+
variant: TransactionItemVariant | null;
|
|
16
|
+
warehouseId?: string | number;
|
|
17
|
+
allowMultiple?: boolean;
|
|
18
|
+
allowCreate?: boolean;
|
|
19
|
+
initialBatches?: any[];
|
|
20
|
+
onConfirm: (selections: { batch: TransactionBatch; quantity: number }[]) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type BatchRow = {
|
|
24
|
+
uid: string;
|
|
25
|
+
isNew: boolean;
|
|
26
|
+
batch_id?: number;
|
|
27
|
+
batch_number: string;
|
|
28
|
+
manufacturing_date: string;
|
|
29
|
+
expiry_date: string;
|
|
30
|
+
supplier_reference: string;
|
|
31
|
+
selling_price: string;
|
|
32
|
+
quantity: string;
|
|
33
|
+
qty_available?: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const formatDateStr = (d?: string | null) => d ? d.split('T')[0] : "";
|
|
37
|
+
|
|
38
|
+
export function BatchSelectionModal({
|
|
39
|
+
isOpen,
|
|
40
|
+
onClose,
|
|
41
|
+
item,
|
|
42
|
+
variant,
|
|
43
|
+
warehouseId,
|
|
44
|
+
allowMultiple = false,
|
|
45
|
+
allowCreate = false,
|
|
46
|
+
initialBatches = [],
|
|
47
|
+
onConfirm
|
|
48
|
+
}: BatchSelectionModalProps) {
|
|
49
|
+
const [batches, setBatches] = useState<TransactionBatch[]>([]);
|
|
50
|
+
const [rows, setRows] = useState<BatchRow[]>([]);
|
|
51
|
+
const [selectKey, setSelectKey] = useState(0);
|
|
52
|
+
const [isLoadingBatches, setIsLoadingBatches] = useState(false);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!isOpen) {
|
|
56
|
+
setBatches([]);
|
|
57
|
+
setRows([]);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (isOpen && item) {
|
|
61
|
+
fetchBatches();
|
|
62
|
+
if (initialBatches && initialBatches.length > 0) {
|
|
63
|
+
setRows(initialBatches.map(b => ({
|
|
64
|
+
uid: Math.random().toString(36).substr(2, 9),
|
|
65
|
+
isNew: b.batch_id ? false : true,
|
|
66
|
+
batch_id: b.batch_id,
|
|
67
|
+
batch_number: b.batch_number || "",
|
|
68
|
+
manufacturing_date: formatDateStr(b.manufacturing_date),
|
|
69
|
+
expiry_date: formatDateStr(b.expiry_date),
|
|
70
|
+
supplier_reference: b.supplier_reference || "",
|
|
71
|
+
selling_price: b.selling_price?.toString() || "",
|
|
72
|
+
quantity: b.quantity?.toString() || "",
|
|
73
|
+
})));
|
|
74
|
+
} else {
|
|
75
|
+
setRows([]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}, [isOpen, item, variant, warehouseId]);
|
|
79
|
+
|
|
80
|
+
const fetchBatches = async () => {
|
|
81
|
+
if (!item) return;
|
|
82
|
+
setIsLoadingBatches(true);
|
|
83
|
+
try {
|
|
84
|
+
const params = new URLSearchParams({ item_id: String(item.id) });
|
|
85
|
+
if (variant) params.append("variant_id", String(variant.id));
|
|
86
|
+
if (warehouseId) params.append("warehouse_id", String(warehouseId));
|
|
87
|
+
|
|
88
|
+
const res = await sendRequest({ url: `${process.env.NEXT_PUBLIC_API_URL}/api/inventory/batches/lookup?${params.toString()}`, method: "GET" });
|
|
89
|
+
if (res.responseData?.result) {
|
|
90
|
+
setBatches(res.responseData.result);
|
|
91
|
+
} else {
|
|
92
|
+
setBatches([]);
|
|
93
|
+
}
|
|
94
|
+
} catch (e: any) {
|
|
95
|
+
toast.error("Failed to load available batches");
|
|
96
|
+
setBatches([]);
|
|
97
|
+
} finally {
|
|
98
|
+
setIsLoadingBatches(false);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleAddRow = () => {
|
|
103
|
+
if (!allowMultiple && rows.length > 0) {
|
|
104
|
+
toast.error("Only one batch can be selected.");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const rawPrice = variant?.sale_price || item?.variants?.[0]?.sale_price || item?.default_sale_price || 0;
|
|
108
|
+
const priceStr = rawPrice ? Number(rawPrice).toFixed(2) : "";
|
|
109
|
+
|
|
110
|
+
const newRow: BatchRow = {
|
|
111
|
+
uid: Math.random().toString(36).substr(2, 9),
|
|
112
|
+
isNew: true,
|
|
113
|
+
batch_number: "",
|
|
114
|
+
manufacturing_date: "",
|
|
115
|
+
expiry_date: "",
|
|
116
|
+
supplier_reference: "",
|
|
117
|
+
selling_price: priceStr,
|
|
118
|
+
quantity: "",
|
|
119
|
+
};
|
|
120
|
+
setRows([...rows, newRow]);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const handleSelectExisting = (batchId: number | string) => {
|
|
124
|
+
if (!allowMultiple && rows.length > 0) {
|
|
125
|
+
toast.error("Only one batch can be selected.");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const batch = batches.find(b => String(b.id) === String(batchId));
|
|
129
|
+
if (!batch) return;
|
|
130
|
+
|
|
131
|
+
// Check if already in rows
|
|
132
|
+
if (rows.some(r => String(r.batch_id) === String(batch.id))) {
|
|
133
|
+
toast.error("Batch already added to the list.");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const newRow: BatchRow = {
|
|
138
|
+
uid: Math.random().toString(36).substr(2, 9),
|
|
139
|
+
isNew: false,
|
|
140
|
+
batch_id: batch.id,
|
|
141
|
+
batch_number: batch.batch_number,
|
|
142
|
+
manufacturing_date: formatDateStr(batch.manufacturing_date),
|
|
143
|
+
expiry_date: formatDateStr(batch.expiry_date),
|
|
144
|
+
supplier_reference: batch.supplier_reference || "",
|
|
145
|
+
selling_price: batch.selling_price ? batch.selling_price.toString() : "",
|
|
146
|
+
quantity: "",
|
|
147
|
+
qty_available: batch.qty_available,
|
|
148
|
+
};
|
|
149
|
+
setRows([...rows, newRow]);
|
|
150
|
+
setSelectKey(prev => prev + 1);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const updateRow = (uid: string, field: keyof BatchRow, value: string) => {
|
|
154
|
+
setRows(rows.map(r => r.uid === uid ? { ...r, [field]: value } : r));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const removeRow = (uid: string) => {
|
|
158
|
+
setRows(rows.filter(r => r.uid !== uid));
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const handleConfirm = () => {
|
|
162
|
+
const selections: { batch: TransactionBatch; quantity: number }[] = [];
|
|
163
|
+
|
|
164
|
+
for (const row of rows) {
|
|
165
|
+
const qty = parseFloat(row.quantity);
|
|
166
|
+
if (isNaN(qty) || qty <= 0) {
|
|
167
|
+
toast.error("Please enter a valid quantity for all selected batches.");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (row.isNew) {
|
|
171
|
+
const price = parseFloat(row.selling_price);
|
|
172
|
+
if (isNaN(price) || price < 0) {
|
|
173
|
+
toast.error("Please enter a valid selling price for new batches.");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!row.isNew && !allowCreate && row.qty_available !== undefined && qty > row.qty_available) {
|
|
178
|
+
toast.error(`Quantity exceeds available stock for batch ${row.batch_number}.`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
selections.push({
|
|
183
|
+
batch: {
|
|
184
|
+
id: row.batch_id || 0,
|
|
185
|
+
item_id: item?.id || 0,
|
|
186
|
+
batch_number: row.batch_number,
|
|
187
|
+
manufacturing_date: row.manufacturing_date || undefined,
|
|
188
|
+
expiry_date: row.expiry_date || undefined,
|
|
189
|
+
supplier_reference: row.supplier_reference || undefined,
|
|
190
|
+
selling_price: row.selling_price ? parseFloat(row.selling_price) : undefined,
|
|
191
|
+
},
|
|
192
|
+
quantity: qty
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (selections.length === 0) {
|
|
197
|
+
toast.error("Please add at least one batch.");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
onConfirm(selections);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const totalQty = rows.reduce((sum, r) => sum + (parseFloat(r.quantity) || 0), 0);
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<Modal isOpen={isOpen} onClose={onClose} size="xl" title={`Batch Selection - ${item?.name} ${variant ? `(${variant.variant_name})` : ""}`}>
|
|
208
|
+
<div className="flex flex-col gap-4">
|
|
209
|
+
|
|
210
|
+
<div className="flex justify-between items-center">
|
|
211
|
+
<div className="flex gap-2 items-center">
|
|
212
|
+
{allowCreate && (
|
|
213
|
+
<Button size="small" onClick={handleAddRow} icon={<Plus size={16} />}>New Batch</Button>
|
|
214
|
+
)}
|
|
215
|
+
<div className="w-64">
|
|
216
|
+
<SearchableSelect
|
|
217
|
+
key={selectKey}
|
|
218
|
+
options={batches as any[]}
|
|
219
|
+
onChange={(val) => {
|
|
220
|
+
if(val) handleSelectExisting(val as string | number);
|
|
221
|
+
}}
|
|
222
|
+
placeholder="Select Existing Batch..."
|
|
223
|
+
option={{ label: 'batch_number', value: 'id', renderOption: (opt: any) => (
|
|
224
|
+
<div className="flex justify-between w-full">
|
|
225
|
+
<span>{opt.batch_number}</span>
|
|
226
|
+
</div>
|
|
227
|
+
) }}
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
{totalQty > 0 && (
|
|
232
|
+
<div className="text-sm font-medium text-primary-600 bg-primary-50 px-3 py-1 rounded-full">
|
|
233
|
+
Total Qty: {totalQty}
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<div className="max-h-[60vh] overflow-y-auto custom-scrollbar w-full">
|
|
239
|
+
<Table>
|
|
240
|
+
<THeader>
|
|
241
|
+
<TRow>
|
|
242
|
+
<TCell isHeader label="Batch Number" className="min-w-[150px]">Batch Number</TCell>
|
|
243
|
+
<TCell isHeader label="Mfg Batch#" className="min-w-[120px]">Mfg Batch#</TCell>
|
|
244
|
+
<TCell isHeader label="Mfg Date" className="min-w-[140px]">Mfg Date</TCell>
|
|
245
|
+
<TCell isHeader label="Expiry Date" className="min-w-[140px]">Expiry Date</TCell>
|
|
246
|
+
<TCell isHeader label="Selling Price" className="min-w-[120px]">Selling Price</TCell>
|
|
247
|
+
<TCell isHeader label="Quantity" className="min-w-[120px] text-right">Quantity</TCell>
|
|
248
|
+
<TCell isHeader label="" className="w-10"></TCell>
|
|
249
|
+
</TRow>
|
|
250
|
+
</THeader>
|
|
251
|
+
<TBody>
|
|
252
|
+
{rows.length > 0 ? (
|
|
253
|
+
rows.map(row => (
|
|
254
|
+
<TRow key={row.uid}>
|
|
255
|
+
<TCell label="Batch Number">
|
|
256
|
+
{row.isNew ? (
|
|
257
|
+
<input
|
|
258
|
+
value={row.batch_number}
|
|
259
|
+
onChange={(e) => updateRow(row.uid, 'batch_number', e.target.value)}
|
|
260
|
+
placeholder="Auto generate if empty"
|
|
261
|
+
className="w-full h-8 px-2 text-[13px] font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors"
|
|
262
|
+
/>
|
|
263
|
+
) : (
|
|
264
|
+
<div className="font-mono text-sm font-medium">{row.batch_number}</div>
|
|
265
|
+
)}
|
|
266
|
+
</TCell>
|
|
267
|
+
<TCell label="Mfg Batch#">
|
|
268
|
+
{row.isNew ? (
|
|
269
|
+
<input
|
|
270
|
+
value={row.supplier_reference}
|
|
271
|
+
onChange={(e) => updateRow(row.uid, 'supplier_reference', e.target.value)}
|
|
272
|
+
className="w-full h-8 px-2 text-[13px] font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors"
|
|
273
|
+
/>
|
|
274
|
+
) : (
|
|
275
|
+
<div className="text-sm text-gray-500">{row.supplier_reference || '—'}</div>
|
|
276
|
+
)}
|
|
277
|
+
</TCell>
|
|
278
|
+
<TCell label="Mfg Date">
|
|
279
|
+
{row.isNew ? (
|
|
280
|
+
<input
|
|
281
|
+
type="date"
|
|
282
|
+
value={row.manufacturing_date}
|
|
283
|
+
onChange={(e) => updateRow(row.uid, 'manufacturing_date', e.target.value)}
|
|
284
|
+
className="w-full h-8 px-2 text-[13px] font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors"
|
|
285
|
+
/>
|
|
286
|
+
) : (
|
|
287
|
+
<div className="text-sm text-gray-500">{row.manufacturing_date || '—'}</div>
|
|
288
|
+
)}
|
|
289
|
+
</TCell>
|
|
290
|
+
<TCell label="Expiry Date">
|
|
291
|
+
{row.isNew ? (
|
|
292
|
+
<input
|
|
293
|
+
type="date"
|
|
294
|
+
value={row.expiry_date}
|
|
295
|
+
onChange={(e) => updateRow(row.uid, 'expiry_date', e.target.value)}
|
|
296
|
+
className="w-full h-8 px-2 text-[13px] font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors"
|
|
297
|
+
/>
|
|
298
|
+
) : (
|
|
299
|
+
<div className="text-sm text-gray-500">{row.expiry_date ? new Date(row.expiry_date).toLocaleDateString() : '—'}</div>
|
|
300
|
+
)}
|
|
301
|
+
</TCell>
|
|
302
|
+
<TCell label="Selling Price">
|
|
303
|
+
{row.isNew ? (
|
|
304
|
+
<input
|
|
305
|
+
type="number"
|
|
306
|
+
value={row.selling_price}
|
|
307
|
+
onChange={(e) => updateRow(row.uid, 'selling_price', e.target.value)}
|
|
308
|
+
className="w-full h-8 px-2 text-[13px] font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors"
|
|
309
|
+
/>
|
|
310
|
+
) : (
|
|
311
|
+
<div className="text-sm text-gray-500">{row.selling_price || '—'}</div>
|
|
312
|
+
)}
|
|
313
|
+
</TCell>
|
|
314
|
+
<TCell label="Quantity">
|
|
315
|
+
<div className="flex flex-col items-end">
|
|
316
|
+
<input
|
|
317
|
+
type="number"
|
|
318
|
+
min="0"
|
|
319
|
+
max={!allowCreate ? row.qty_available : undefined}
|
|
320
|
+
value={row.quantity}
|
|
321
|
+
onChange={(e) => updateRow(row.uid, 'quantity', e.target.value)}
|
|
322
|
+
placeholder="0"
|
|
323
|
+
className="w-full h-8 px-2 text-[13px] text-right font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors"
|
|
324
|
+
/>
|
|
325
|
+
{!row.isNew && row.qty_available !== undefined && (
|
|
326
|
+
<span className="text-[10px] text-gray-400 mt-0.5">Avail: {row.qty_available}</span>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
</TCell>
|
|
330
|
+
<TCell label="">
|
|
331
|
+
<button
|
|
332
|
+
onClick={() => removeRow(row.uid)}
|
|
333
|
+
className="p-1 text-gray-400 hover:text-danger-500 rounded-md hover:bg-danger-50 transition-colors w-full flex justify-center"
|
|
334
|
+
>
|
|
335
|
+
<X size={16} />
|
|
336
|
+
</button>
|
|
337
|
+
</TCell>
|
|
338
|
+
</TRow>
|
|
339
|
+
))
|
|
340
|
+
) : (
|
|
341
|
+
<TRow>
|
|
342
|
+
<TCell colSpan={7} className="p-8 text-center text-gray-500">
|
|
343
|
+
Add a new batch or select an existing one to continue.
|
|
344
|
+
</TCell>
|
|
345
|
+
</TRow>
|
|
346
|
+
)}
|
|
347
|
+
</TBody>
|
|
348
|
+
</Table>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
<ModalFooter>
|
|
352
|
+
<Button variant="flat" color="secondary" onClick={onClose}>Cancel</Button>
|
|
353
|
+
<Button onClick={handleConfirm} isDisabled={rows.length === 0}>
|
|
354
|
+
Confirm Batches
|
|
355
|
+
</Button>
|
|
356
|
+
</ModalFooter>
|
|
357
|
+
</Modal>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo } from "react";
|
|
2
|
+
import { X, Search } from "lucide-react";
|
|
3
|
+
import { Modal, ModalFooter } from "../../../base-components/Modal";
|
|
4
|
+
import { Button } from "../../../base-components/Button";
|
|
5
|
+
import { TransactionItem, TransactionItemVariant } from "./types";
|
|
6
|
+
import { sendRequest } from "@apptimate/core-lib";
|
|
7
|
+
import { toast } from "react-hot-toast";
|
|
8
|
+
|
|
9
|
+
interface SerialSelectionModalProps {
|
|
10
|
+
isOpen: boolean;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
item: TransactionItem | null;
|
|
13
|
+
variant: TransactionItemVariant | null;
|
|
14
|
+
allowCreate?: boolean;
|
|
15
|
+
initialSerials?: string[];
|
|
16
|
+
warehouseId?: number;
|
|
17
|
+
onConfirm: (serials: string[]) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function SerialSelectionModal({
|
|
21
|
+
isOpen,
|
|
22
|
+
onClose,
|
|
23
|
+
item,
|
|
24
|
+
variant,
|
|
25
|
+
allowCreate = false,
|
|
26
|
+
initialSerials = [],
|
|
27
|
+
warehouseId,
|
|
28
|
+
onConfirm
|
|
29
|
+
}: SerialSelectionModalProps) {
|
|
30
|
+
const [serials, setSerials] = useState<string[]>([]);
|
|
31
|
+
const [inputValue, setInputValue] = useState("");
|
|
32
|
+
const [availableSerials, setAvailableSerials] = useState<any[]>([]);
|
|
33
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (isOpen) {
|
|
37
|
+
setSerials(initialSerials);
|
|
38
|
+
setInputValue("");
|
|
39
|
+
}
|
|
40
|
+
}, [isOpen, item, allowCreate]);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!isOpen || allowCreate) return;
|
|
44
|
+
const delay = setTimeout(() => {
|
|
45
|
+
fetchSerials(inputValue);
|
|
46
|
+
}, 300);
|
|
47
|
+
return () => clearTimeout(delay);
|
|
48
|
+
}, [inputValue, isOpen, allowCreate, item, variant, warehouseId]);
|
|
49
|
+
|
|
50
|
+
const fetchSerials = async (search: string = "") => {
|
|
51
|
+
if (!item) return;
|
|
52
|
+
setIsLoading(true);
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(`${process.env.NEXT_PUBLIC_API_URL}/api/inventory/serials/lookup`);
|
|
55
|
+
url.searchParams.append("item_id", item.id.toString());
|
|
56
|
+
url.searchParams.append("status", "available");
|
|
57
|
+
if (variant) {
|
|
58
|
+
url.searchParams.append("variant_id", variant.id.toString());
|
|
59
|
+
}
|
|
60
|
+
if (warehouseId) {
|
|
61
|
+
url.searchParams.append("warehouse_id", warehouseId.toString());
|
|
62
|
+
}
|
|
63
|
+
if (search) {
|
|
64
|
+
url.searchParams.append("search", search);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const res = await sendRequest({
|
|
68
|
+
url: url.toString(),
|
|
69
|
+
method: "GET"
|
|
70
|
+
});
|
|
71
|
+
if (res.responseData?.result) {
|
|
72
|
+
setAvailableSerials(res.responseData.result);
|
|
73
|
+
} else {
|
|
74
|
+
setAvailableSerials([]);
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {
|
|
77
|
+
toast.error("Failed to load serial numbers");
|
|
78
|
+
}
|
|
79
|
+
setIsLoading(false);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleAddSerial = (e?: React.FormEvent) => {
|
|
83
|
+
e?.preventDefault();
|
|
84
|
+
// Allow comma or space separated parsing
|
|
85
|
+
const rawTokens = inputValue.split(/[\s,]+/);
|
|
86
|
+
let addedCount = 0;
|
|
87
|
+
const newSerials = [...serials];
|
|
88
|
+
|
|
89
|
+
rawTokens.forEach(token => {
|
|
90
|
+
const val = token.trim();
|
|
91
|
+
if (val && !newSerials.includes(val)) {
|
|
92
|
+
newSerials.push(val);
|
|
93
|
+
addedCount++;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (addedCount > 0) {
|
|
98
|
+
setSerials(newSerials);
|
|
99
|
+
setInputValue("");
|
|
100
|
+
} else if (inputValue.trim()) {
|
|
101
|
+
toast.error("Serial numbers already added or invalid.");
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleRemoveSerial = (s: string) => {
|
|
106
|
+
setSerials(serials.filter(v => v !== s));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const toggleSelectSerial = (s: string) => {
|
|
110
|
+
if (serials.includes(s)) {
|
|
111
|
+
setSerials(serials.filter(v => v !== s));
|
|
112
|
+
} else {
|
|
113
|
+
setSerials([...serials, s]);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const handleConfirm = () => {
|
|
118
|
+
if (serials.length === 0) {
|
|
119
|
+
toast.error("Please add at least one serial number.");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
onConfirm(serials);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Display available serials from backend
|
|
126
|
+
const filteredAvailable = availableSerials;
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Modal
|
|
130
|
+
isOpen={isOpen}
|
|
131
|
+
onClose={onClose}
|
|
132
|
+
size="md"
|
|
133
|
+
title={`${allowCreate ? 'Add' : 'Select'} Serial Numbers`}
|
|
134
|
+
>
|
|
135
|
+
<div className="flex flex-col gap-4">
|
|
136
|
+
{/* Header Info */}
|
|
137
|
+
<div className="flex items-center justify-between py-2 border-b border-border-subtle">
|
|
138
|
+
<div className="flex flex-col">
|
|
139
|
+
<span className="text-[13px] font-semibold text-foreground-1">
|
|
140
|
+
{item?.name} {variant ? `(${variant.variant_name})` : ""}
|
|
141
|
+
</span>
|
|
142
|
+
<span className="text-[11px] text-foreground-subtle">{item?.sku}</span>
|
|
143
|
+
</div>
|
|
144
|
+
<div className="flex flex-col items-end">
|
|
145
|
+
<span className="text-sm font-bold text-foreground-1">{serials.length}</span>
|
|
146
|
+
<span className="text-[11px] text-foreground-subtle">Quantity</span>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{allowCreate ? (
|
|
151
|
+
<>
|
|
152
|
+
{/* Create Mode */}
|
|
153
|
+
<div className="flex items-center justify-between">
|
|
154
|
+
<span className="text-sm font-medium text-foreground-subtle">
|
|
155
|
+
Count: <span className="text-foreground-1 font-bold">{serials.length}</span>
|
|
156
|
+
</span>
|
|
157
|
+
<button
|
|
158
|
+
onClick={() => setSerials([])}
|
|
159
|
+
className="text-sm text-primary hover:underline font-medium"
|
|
160
|
+
>
|
|
161
|
+
Clear All
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div className="flex flex-col border border-border-subtle rounded-xl overflow-hidden bg-surface-0 min-h-[250px] shadow-sm">
|
|
166
|
+
<div className="p-3 border-b border-border-subtle bg-surface-hover">
|
|
167
|
+
<form onSubmit={handleAddSerial} className="flex gap-2">
|
|
168
|
+
<input
|
|
169
|
+
type="text"
|
|
170
|
+
autoFocus
|
|
171
|
+
placeholder="Type serials separated by space or comma, then press Enter..."
|
|
172
|
+
value={inputValue}
|
|
173
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
174
|
+
className="flex-1 px-3 py-2 bg-white border border-border-subtle rounded-md text-[13px] focus:outline-none focus:border-primary transition-colors"
|
|
175
|
+
/>
|
|
176
|
+
<Button type="submit" variant="flat" color="primary" size="small">Add</Button>
|
|
177
|
+
</form>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div className="flex-1 p-3 custom-scrollbar flex flex-wrap gap-2 content-start max-h-[300px] overflow-y-auto">
|
|
181
|
+
{serials.length === 0 ? (
|
|
182
|
+
<div className="w-full mt-10 flex flex-col items-center justify-center text-[13px] text-foreground-disabled">
|
|
183
|
+
<p>No serial numbers added yet.</p>
|
|
184
|
+
</div>
|
|
185
|
+
) : (
|
|
186
|
+
serials.map(s => (
|
|
187
|
+
<div key={s} className="flex items-center gap-1.5 bg-surface-1 border border-border-subtle px-2.5 py-1 rounded-md text-[13px] font-mono text-foreground-1">
|
|
188
|
+
{s}
|
|
189
|
+
<button onClick={() => handleRemoveSerial(s)} className="text-foreground-subtle hover:text-danger-500 transition-colors">
|
|
190
|
+
<X size={14} />
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
))
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</>
|
|
198
|
+
) : (
|
|
199
|
+
<>
|
|
200
|
+
{/* Select Mode */}
|
|
201
|
+
<div className="flex flex-col border border-border-subtle rounded-xl overflow-hidden bg-surface-0 h-[350px] shadow-sm">
|
|
202
|
+
<div className="p-3 border-b border-border-subtle bg-surface-hover relative">
|
|
203
|
+
<Search className="absolute left-5 top-1/2 -translate-y-1/2 text-foreground-subtle" size={16} />
|
|
204
|
+
<input
|
|
205
|
+
type="text"
|
|
206
|
+
autoFocus
|
|
207
|
+
placeholder="Search available serial numbers..."
|
|
208
|
+
value={inputValue}
|
|
209
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
210
|
+
className="w-full pl-9 pr-3 py-2 bg-white border border-border-subtle rounded-md text-[13px] focus:outline-none focus:border-primary transition-colors"
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div className="flex-1 overflow-y-auto p-2 custom-scrollbar flex flex-col gap-0.5">
|
|
215
|
+
{isLoading ? (
|
|
216
|
+
<div className="w-full h-full flex items-center justify-center text-[13px] text-foreground-subtle">Loading serials...</div>
|
|
217
|
+
) : availableSerials.length === 0 ? (
|
|
218
|
+
<div className="w-full h-full flex items-center justify-center text-[13px] text-foreground-subtle">No available serials found for this item.</div>
|
|
219
|
+
) : filteredAvailable.length === 0 ? (
|
|
220
|
+
<div className="w-full h-full flex items-center justify-center text-[13px] text-foreground-subtle">No matching serials found.</div>
|
|
221
|
+
) : (
|
|
222
|
+
filteredAvailable.map(s => (
|
|
223
|
+
<label key={s.serial_number} className="flex items-center gap-3 px-3 py-2 hover:bg-surface-hover rounded-md cursor-pointer transition-colors">
|
|
224
|
+
<input
|
|
225
|
+
type="checkbox"
|
|
226
|
+
className="w-4 h-4 rounded border-border-subtle text-primary focus:ring-primary"
|
|
227
|
+
checked={serials.includes(s.serial_number)}
|
|
228
|
+
onChange={() => toggleSelectSerial(s.serial_number)}
|
|
229
|
+
/>
|
|
230
|
+
<span className="font-mono text-[13px] text-foreground-1">{s.serial_number}</span>
|
|
231
|
+
</label>
|
|
232
|
+
))
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
<ModalFooter>
|
|
240
|
+
<Button variant="flat" color="secondary" onClick={onClose}>Cancel</Button>
|
|
241
|
+
<Button onClick={handleConfirm} isDisabled={serials.length === 0}>
|
|
242
|
+
Confirm {serials.length > 0 ? `(${serials.length})` : ""}
|
|
243
|
+
</Button>
|
|
244
|
+
</ModalFooter>
|
|
245
|
+
</Modal>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface TransactionItem {
|
|
2
|
+
id: number;
|
|
3
|
+
name: string;
|
|
4
|
+
sku: string;
|
|
5
|
+
barcode?: string;
|
|
6
|
+
image_url?: string;
|
|
7
|
+
has_variants: boolean;
|
|
8
|
+
tracking_type: 'none' | 'batch' | 'serial';
|
|
9
|
+
[key: string]: any; // Allow other properties
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TransactionItemVariant {
|
|
13
|
+
id: number;
|
|
14
|
+
item_id: number;
|
|
15
|
+
variant_name: string;
|
|
16
|
+
sku: string;
|
|
17
|
+
[key: string]: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TransactionBatch {
|
|
21
|
+
id: number;
|
|
22
|
+
item_id: number;
|
|
23
|
+
batch_number: string;
|
|
24
|
+
manufacturing_date?: string;
|
|
25
|
+
expiry_date?: string;
|
|
26
|
+
supplier_reference?: string;
|
|
27
|
+
selling_price?: number;
|
|
28
|
+
qty_available?: number;
|
|
29
|
+
qty_on_hand?: number;
|
|
30
|
+
}
|