@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,587 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useCallback, useMemo } from 'react';
4
+ import { cn } from '@apptimate/core-lib';
5
+ import {
6
+ Search, ScanBarcode, X, ShoppingCart, Package, Loader2,
7
+ Trash2, ChevronDown, ChevronRight, AlertTriangle
8
+ } from 'lucide-react';
9
+ import { Badge } from '../../base-components/Badge';
10
+ import { Table, THeader, TBody, TRow, TCell } from '../../base-components/Table';
11
+ import { transactionLookup, getItemBatches } from '@apptimate/core-lib';
12
+ import type { TransactionItem, TransactionLineItem, TransactionScreenConfig } from './types';
13
+ import { BatchSelectionModal, SerialSelectionModal } from "../pickers";
14
+
15
+ interface ProductSelectionPanelProps {
16
+ config: TransactionScreenConfig;
17
+ warehouseId?: number;
18
+ lineItems: TransactionLineItem[];
19
+ onAddItem: (item: TransactionItem, variant?: any, extraData?: { quantity?: number; batches?: any[]; serial_numbers?: string[] }) => void;
20
+ onUpdateLine: (uid: string, updates: Partial<TransactionLineItem>) => void;
21
+ onRemoveLine: (uid: string) => void;
22
+ onToggleExpand: (uid: string) => void;
23
+ }
24
+
25
+ export function ProductSelectionPanel({
26
+ config,
27
+ warehouseId,
28
+ lineItems,
29
+ onAddItem,
30
+ onUpdateLine,
31
+ onRemoveLine,
32
+ onToggleExpand,
33
+ }: ProductSelectionPanelProps) {
34
+ const [searchValue, setSearchValue] = useState('');
35
+ const [searchResults, setSearchResults] = useState<TransactionItem[]>([]);
36
+ const [showResults, setShowResults] = useState(false);
37
+ const [isSearching, setIsSearching] = useState(false);
38
+ const [expandedItemIds, setExpandedItemIds] = useState<number[]>([]);
39
+ const [editingRates, setEditingRates] = useState<Record<string, string>>({});
40
+
41
+ const [activeBatchItem, setActiveBatchItem] = useState<{ item: TransactionItem; variant?: any } | null>(null);
42
+ const [activeBatchLineUid, setActiveBatchLineUid] = useState<string | null>(null);
43
+ const [activeSerialItem, setActiveSerialItem] = useState<{ item: TransactionItem; variant?: any } | null>(null);
44
+ const [activeSerialLineUid, setActiveSerialLineUid] = useState<string | null>(null);
45
+ const searchTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
46
+ const panelRef = React.useRef<HTMLDivElement>(null);
47
+
48
+
49
+ // ── Search handler ──
50
+ const handleSearch = useCallback(
51
+ (value: string) => {
52
+ setSearchValue(value);
53
+ if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
54
+
55
+ if (value.length < 1) {
56
+ setSearchResults([]);
57
+ setShowResults(false);
58
+ return;
59
+ }
60
+
61
+ searchTimeoutRef.current = setTimeout(async () => {
62
+ setIsSearching(true);
63
+ try {
64
+ const params: Record<string, string | number> = { search: value, limit: 20, type: config.type };
65
+ if (warehouseId) params.warehouse_id = warehouseId;
66
+ const res = await transactionLookup(params);
67
+ if (res.is_success) {
68
+ const results = res.result || [];
69
+ setSearchResults(results);
70
+ setExpandedItemIds(results.filter((i: any) => i.has_variants).map((i: any) => i.id));
71
+ setShowResults(true);
72
+ }
73
+ } finally {
74
+ setIsSearching(false);
75
+ }
76
+ }, 250);
77
+ },
78
+ [warehouseId]
79
+ );
80
+
81
+ // ── Select item → check tracking type or add to cart ──
82
+ const handleSelectItem = useCallback(
83
+ (item: TransactionItem, variant?: any) => {
84
+ setSearchValue('');
85
+ setSearchResults([]);
86
+ setShowResults(false);
87
+
88
+ if (item.tracking_type === 'batch') {
89
+ setActiveBatchItem({ item, variant });
90
+ } else if (item.tracking_type === 'serial') {
91
+ setActiveSerialItem({ item, variant });
92
+ } else {
93
+ onAddItem(item, variant);
94
+ }
95
+ },
96
+ [onAddItem]
97
+ );
98
+
99
+ const handleBatchConfirm = (selections: { batch: any; quantity: number }[]) => {
100
+ const batchesData = selections.map(sel => ({
101
+ batch_id: sel.batch.id > 0 ? sel.batch.id : null,
102
+ batch_number: sel.batch.batch_number,
103
+ manufacturing_date: sel.batch.manufacturing_date,
104
+ expiry_date: sel.batch.expiry_date,
105
+ supplier_reference: sel.batch.supplier_reference,
106
+ selling_price: sel.batch.selling_price,
107
+ quantity: sel.quantity
108
+ }));
109
+ const totalQty = batchesData.reduce((sum, b) => sum + b.quantity, 0);
110
+
111
+ if (activeBatchItem) {
112
+ onAddItem(activeBatchItem.item, activeBatchItem.variant, {
113
+ batches: batchesData,
114
+ quantity: totalQty > 0 ? totalQty : 1
115
+ });
116
+ setActiveBatchItem(null);
117
+ }
118
+ };
119
+
120
+ const handleSerialConfirm = (serials: string[]) => {
121
+ if (!activeSerialItem) return;
122
+ onAddItem(activeSerialItem.item, activeSerialItem.variant, { serial_numbers: serials, quantity: serials.length });
123
+ setActiveSerialItem(null);
124
+ };
125
+
126
+ // Close dropdown on click outside
127
+ useEffect(() => {
128
+ const handler = (e: MouseEvent) => {
129
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
130
+ setShowResults(false);
131
+ }
132
+ };
133
+ document.addEventListener('mousedown', handler);
134
+ return () => document.removeEventListener('mousedown', handler);
135
+ }, []);
136
+
137
+ const formatCurrency = (v: number | string) => Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
138
+
139
+ // ── Update inline input ──
140
+ const updateLineInput = (uid: string, field: 'quantity' | 'unit_price', value: string) => {
141
+ const num = value === '' ? 0 : Number(value);
142
+ const line = lineItems.find(l => l.uid === uid);
143
+ if (!line) return;
144
+
145
+ let qty = line.quantity;
146
+ let price = line.unit_price;
147
+
148
+ if (field === 'quantity') qty = Math.max(0.001, num);
149
+ if (field === 'unit_price') price = Math.max(0, num);
150
+
151
+ const discAmt = (price * qty * line.discount_percent) / 100;
152
+
153
+ onUpdateLine(uid, {
154
+ quantity: qty,
155
+ unit_price: price,
156
+ discount_amount: discAmt,
157
+ line_total: price * qty - discAmt,
158
+ });
159
+ };
160
+
161
+ return (
162
+ <div className="flex flex-col h-full">
163
+ {/* ── Search Bar ── */}
164
+ <div ref={panelRef} className="relative mb-4">
165
+ <div className="flex items-center gap-2 bg-surface-0 border-[1.5px] border-border-subtle rounded-[12px] px-4 py-2.5 focus-within:border-primary/50 transition-all">
166
+ <Search size={18} className="text-foreground-disabled shrink-0" />
167
+ <input
168
+ type="text"
169
+ placeholder={
170
+ config.requireWarehouseFirst && !warehouseId
171
+ ? "Select a warehouse first to search items..."
172
+ : "Search by name, SKU, or scan barcode..."
173
+ }
174
+ value={searchValue}
175
+ onChange={(e) => handleSearch(e.target.value)}
176
+ onFocus={() => searchResults.length > 0 && setShowResults(true)}
177
+ disabled={config.requireWarehouseFirst && !warehouseId}
178
+ className={cn(
179
+ "flex-1 bg-transparent text-[13.5px] text-foreground-1 outline-none placeholder:text-foreground-disabled",
180
+ config.requireWarehouseFirst && !warehouseId && "cursor-not-allowed"
181
+ )}
182
+ autoComplete="off"
183
+ spellCheck={false}
184
+ />
185
+ {isSearching && <Loader2 size={16} className="animate-spin text-primary shrink-0" />}
186
+ <ScanBarcode size={18} className="text-foreground-disabled shrink-0 cursor-pointer hover:text-primary transition-colors" />
187
+ </div>
188
+
189
+ {/* ── Search Results Dropdown ── */}
190
+ {showResults && searchResults.length > 0 && (
191
+ <div className="absolute z-50 top-full mt-1 left-0 right-0 bg-surface-1 border border-border-subtle rounded-[12px] shadow-xl max-h-[calc(100vh-220px)] overflow-y-auto custom-scrollbar">
192
+ {searchResults.map((item) => (
193
+ <div key={item.id} className="flex flex-col border-b border-border-subtle/50 last:border-0">
194
+ <button
195
+ onClick={() => {
196
+ if (item.has_variants) {
197
+ setExpandedItemIds(prev => prev.includes(item.id) ? prev.filter(id => id !== item.id) : [...prev, item.id]);
198
+ } else {
199
+ handleSelectItem(item);
200
+ }
201
+ }}
202
+ className={cn("w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-surface-hover transition-colors", expandedItemIds.includes(item.id) && "bg-surface-hover")}
203
+ >
204
+ {/* Product thumbnail */}
205
+ <div className="w-9 h-9 rounded-[8px] bg-surface-0 flex items-center justify-center shrink-0 overflow-hidden">
206
+ {item.primary_image?.url || item.image_url ? (
207
+ <img
208
+ src={item.primary_image?.url || item.image_url}
209
+ alt={item.name}
210
+ className="w-full h-full object-cover"
211
+ />
212
+ ) : (
213
+ <Package size={16} className="text-foreground-disabled" />
214
+ )}
215
+ </div>
216
+
217
+ {/* Product info */}
218
+ <div className="flex-1 min-w-0">
219
+ <div className="text-[13px] font-medium text-foreground-1 truncate">{item.name}</div>
220
+ <div className="text-[11px] text-foreground-subtle flex items-center gap-2 mt-0.5">
221
+ <span className="font-mono">{item.sku}</span>
222
+ {item.has_variants && (
223
+ <Badge variant="flat" color="default" size="small">
224
+ {item.variants?.length || 0} variants
225
+ </Badge>
226
+ )}
227
+ </div>
228
+ </div>
229
+
230
+ {/* Price + stock + Chevron */}
231
+ <div className="text-right shrink-0 flex items-center gap-3">
232
+ <div className="flex flex-col items-end">
233
+ {config.showPrices && (
234
+ <div className="text-[13px] font-semibold text-foreground-1">
235
+ {formatCurrency(item.variants?.[0]?.[config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price'] || 0)}
236
+ </div>
237
+ )}
238
+ {config.showStock && item.stock && (
239
+ <div className={cn(
240
+ "text-[11px] font-medium",
241
+ item.stock.qty_available > 0 ? "text-green-600" : "text-danger-alt"
242
+ )}>
243
+ {item.stock.qty_available} {item.uom?.abbreviation || 'pcs'}
244
+ </div>
245
+ )}
246
+ </div>
247
+ {item.has_variants && (
248
+ <ChevronRight
249
+ size={16}
250
+ className={cn("text-foreground-disabled transition-transform", expandedItemIds.includes(item.id) && "rotate-90")}
251
+ />
252
+ )}
253
+ </div>
254
+ </button>
255
+
256
+ {/* Variants Tree */}
257
+ {item.has_variants && expandedItemIds.includes(item.id) && (
258
+ <div className="relative flex flex-col pb-2 bg-surface-0">
259
+ {/* Vertical connecting line for the tree (aligned with thumbnail center at 34px) */}
260
+ <div className="absolute left-[34px] top-0 bottom-[24px] w-px bg-border-subtle" />
261
+
262
+ <div className="pl-[52px] pr-4 flex flex-col gap-0.5 mt-1">
263
+ {item.variants?.map((variant: any) => (
264
+ <button
265
+ key={variant.id}
266
+ onClick={() => handleSelectItem(item, variant)}
267
+ className="relative flex items-center justify-between py-2 px-3 rounded-[6px] hover:bg-surface-hover transition-colors text-left group"
268
+ >
269
+ {/* Horizontal connecting line to variant */}
270
+ <div className="absolute left-[-18px] top-1/2 w-[18px] h-px bg-border-subtle" />
271
+ <div className="flex flex-col">
272
+ <span className="text-[12.5px] font-medium text-foreground-1">
273
+ {variant.variant_name}
274
+ </span>
275
+ <span className="text-[11px] font-mono text-foreground-subtle mt-0.5">
276
+ {variant.sku}
277
+ </span>
278
+ </div>
279
+ <div className="text-right shrink-0 flex items-center gap-4">
280
+ {config.showPrices && (
281
+ <div className="text-[12.5px] font-semibold text-foreground-1 min-w-[70px] text-right">
282
+ {formatCurrency(variant[config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price'] || item.variants?.[0]?.[config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price'] || 0)}
283
+ </div>
284
+ )}
285
+ {config.showStock && variant.stock && (
286
+ <div className={cn(
287
+ "text-[11.5px] font-medium min-w-[50px] text-right",
288
+ variant.stock.qty_available > 0 ? "text-green-600" : "text-danger-alt"
289
+ )}>
290
+ {variant.stock.qty_available} {item.uom?.abbreviation || 'pcs'}
291
+ </div>
292
+ )}
293
+ <span className="text-[11px] font-medium text-primary opacity-0 group-hover:opacity-100 transition-opacity w-[40px] text-right">
294
+ Select
295
+ </span>
296
+ </div>
297
+ </button>
298
+ ))}
299
+ {(!item.variants || item.variants.length === 0) && (
300
+ <div className="text-[12px] text-foreground-disabled italic py-2 px-3 relative">
301
+ <div className="absolute left-[-18px] top-1/2 w-[18px] h-px bg-border-subtle" />
302
+ No active variants found.
303
+ </div>
304
+ )}
305
+ </div>
306
+ </div>
307
+ )}
308
+ </div>
309
+ ))}
310
+ </div>
311
+ )}
312
+
313
+ {showResults && searchResults.length === 0 && searchValue.length > 0 && !isSearching && (
314
+ <div className="absolute z-50 top-full mt-1 left-0 right-0 bg-surface-1 border border-border-subtle rounded-[12px] shadow-xl px-4 py-6 text-center">
315
+ <p className="text-[13px] text-foreground-disabled">No items found for &quot;{searchValue}&quot;</p>
316
+ </div>
317
+ )}
318
+ </div>
319
+
320
+ {/* ── Selected Items Table ── */}
321
+ <div className="flex-1 overflow-y-auto custom-scrollbar w-full">
322
+ {lineItems.length === 0 ? (
323
+ <div className="flex flex-col items-center justify-center py-16 text-center h-full">
324
+ <div className="w-16 h-16 rounded-full bg-surface-hover flex items-center justify-center mb-4">
325
+ <ShoppingCart size={24} className="text-foreground-disabled" />
326
+ </div>
327
+ <p className="text-[14px] font-medium text-foreground-subtle mb-1">No items added</p>
328
+ <p className="text-[12px] text-foreground-disabled">Search for products above to add them</p>
329
+ </div>
330
+ ) : (
331
+ <Table>
332
+ <THeader>
333
+ <TRow>
334
+ <TCell isHeader className="w-[45%]">Item</TCell>
335
+ <TCell isHeader>Qty</TCell>
336
+ {config.showPrices && <TCell isHeader>Rate</TCell>}
337
+ {config.showPrices && <TCell isHeader className="text-right">Total</TCell>}
338
+ <TCell isHeader className="w-10 text-center"></TCell>
339
+ </TRow>
340
+ </THeader>
341
+ <TBody>
342
+ {lineItems.map((line) => (
343
+ <TRow key={line.uid}>
344
+ <TCell label="Item" className="py-2.5">
345
+ <div className="flex items-start gap-3">
346
+ <div className="w-8 h-8 rounded-[6px] bg-surface-hover flex items-center justify-center shrink-0 overflow-hidden mt-0.5">
347
+ {line.item.primary_image?.url || line.item.image_url ? (
348
+ <img src={line.item.primary_image?.url || line.item.image_url} alt={line.item.name} className="w-full h-full object-cover" />
349
+ ) : (
350
+ <Package size={14} className="text-foreground-disabled" />
351
+ )}
352
+ </div>
353
+ <div className="flex flex-col flex-1 min-w-0">
354
+ <span className="text-[13px] font-semibold text-foreground-1 leading-tight break-words">{line.item.name}</span>
355
+ <span className="text-[11px] text-foreground-subtle font-mono mt-0.5 break-words">
356
+ {line.variant_id && line.item.variants?.find(v => v.id === line.variant_id)?.sku
357
+ ? line.item.variants.find(v => v.id === line.variant_id)?.sku
358
+ : line.item.sku}
359
+ </span>
360
+ {(line.variant_id || (line.batches && line.batches.length > 0) || (line.serial_numbers && line.serial_numbers.length > 0)) && (
361
+ <div className="flex flex-wrap gap-1 mt-1.5">
362
+ {line.variant_id && line.item.variants?.find(v => v.id === line.variant_id) && (
363
+ <Badge variant="flat" color="primary" className="text-[10px] px-1.5 py-0">
364
+ {line.item.variants.find(v => v.id === line.variant_id)?.variant_name}
365
+ </Badge>
366
+ )}
367
+ {line.item.tracking_type === 'batch' && (() => {
368
+ const batchesCount = line.batches?.length || 0;
369
+ const selectedTotal = line.batches?.reduce((sum, b) => sum + b.quantity, 0) || 0;
370
+ const isMismatch = selectedTotal !== line.quantity;
371
+ return (
372
+ <button
373
+ onClick={() => setActiveBatchLineUid(line.uid)}
374
+ className={cn(
375
+ "mt-1 flex items-center gap-1.5 text-[11.5px] font-medium transition-colors hover:underline text-left",
376
+ isMismatch
377
+ ? "text-warning-600 hover:text-warning-700"
378
+ : "text-primary"
379
+ )}
380
+ >
381
+ {batchesCount > 0 ? (
382
+ isMismatch ? (
383
+ <><AlertTriangle size={12} className="mb-[1px]" /> <span>{selectedTotal} / {line.quantity} Selected</span></>
384
+ ) : (
385
+ <>✓ {batchesCount} Batches Selected</>
386
+ )
387
+ ) : (
388
+ <><AlertTriangle size={12} className="mb-[1px]" /> <span>Select Batches</span></>
389
+ )}
390
+ </button>
391
+ );
392
+ })()}
393
+ {line.item.tracking_type === 'serial' && (() => {
394
+ const selectedCount = line.serial_numbers?.length || 0;
395
+ const isMismatch = selectedCount !== line.quantity;
396
+ return (
397
+ <button
398
+ onClick={() => setActiveSerialLineUid(line.uid)}
399
+ className={cn(
400
+ "mt-1 flex items-center gap-1.5 text-[11.5px] font-medium transition-colors hover:underline text-left",
401
+ isMismatch
402
+ ? "text-warning-600 hover:text-warning-700"
403
+ : "text-primary"
404
+ )}
405
+ >
406
+ {selectedCount > 0 ? (
407
+ isMismatch ? (
408
+ <><AlertTriangle size={12} className="mb-[1px]" /> <span>{selectedCount} / {line.quantity} Serials Selected</span></>
409
+ ) : (
410
+ <>✓ {selectedCount} Serials Selected</>
411
+ )
412
+ ) : (
413
+ <><AlertTriangle size={12} className="mb-[1px]" /> <span>Select Serial Numbers</span></>
414
+ )}
415
+ </button>
416
+ );
417
+ })()}
418
+ </div>
419
+ )}
420
+ </div>
421
+ </div>
422
+ </TCell>
423
+
424
+ <TCell label="Qty" className="py-2.5">
425
+ <input
426
+ type="number"
427
+ value={line.quantity}
428
+ onChange={(e) => updateLineInput(line.uid, 'quantity', e.target.value)}
429
+ className="w-20 h-8 px-2 text-[13px] text-center font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors"
430
+ min={0.001}
431
+ step="any"
432
+ />
433
+ </TCell>
434
+
435
+ {config.showPrices && (
436
+ <TCell label="Rate" className="py-2.5">
437
+ <input
438
+ type="text"
439
+ value={editingRates[line.uid] ?? line.unit_price.toFixed(2)}
440
+ onChange={(e) => {
441
+ const val = e.target.value;
442
+ if (/^\d*\.?\d{0,2}$/.test(val)) {
443
+ setEditingRates(prev => ({ ...prev, [line.uid]: val }));
444
+ updateLineInput(line.uid, 'unit_price', val);
445
+ }
446
+ }}
447
+ onBlur={() => {
448
+ setEditingRates(prev => {
449
+ const next = { ...prev };
450
+ delete next[line.uid];
451
+ return next;
452
+ });
453
+ }}
454
+ disabled={!config.allowEditPrice}
455
+ className={cn(
456
+ "w-24 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",
457
+ !config.allowEditPrice && "opacity-60 cursor-not-allowed bg-surface-hover border-transparent"
458
+ )}
459
+ />
460
+ </TCell>
461
+ )}
462
+
463
+ {config.showPrices && (
464
+ <TCell label="Total" className="text-right py-2.5 font-semibold text-[13px] text-foreground-1">
465
+ {formatCurrency(line.line_total)}
466
+ </TCell>
467
+ )}
468
+
469
+ <TCell className="text-center py-2.5">
470
+ <button
471
+ onClick={() => onRemoveLine(line.uid)}
472
+ className="w-7 h-7 inline-flex items-center justify-center rounded-md text-foreground-disabled hover:bg-danger-alt/10 hover:text-danger-alt transition-colors"
473
+ title="Remove Item"
474
+ >
475
+ <Trash2 size={16} />
476
+ </button>
477
+ </TCell>
478
+ </TRow>
479
+ ))}
480
+ </TBody>
481
+ </Table>
482
+ )}
483
+ </div>
484
+
485
+ {activeSerialLineUid && (
486
+ <SerialSelectionModal
487
+ isOpen={true}
488
+ onClose={() => setActiveSerialLineUid(null)}
489
+ item={lineItems.find(l => l.uid === activeSerialLineUid)?.item || null}
490
+ variant={
491
+ (() => {
492
+ const line = lineItems.find(l => l.uid === activeSerialLineUid);
493
+ return line?.variant_id ? line.item.variants?.find(v => v.id === line.variant_id) || null : null;
494
+ })()
495
+ }
496
+ warehouseId={warehouseId}
497
+ allowCreate={config.allowCreateBatchAndSerial}
498
+ initialSerials={lineItems.find(l => l.uid === activeSerialLineUid)?.serial_numbers || []}
499
+ onConfirm={(serials) => {
500
+ const line = lineItems.find(l => l.uid === activeSerialLineUid);
501
+ if (!line) return;
502
+
503
+ const newQty = serials.length;
504
+ const discAmt = (line.unit_price * newQty * line.discount_percent) / 100;
505
+
506
+ onUpdateLine(line.uid, {
507
+ serial_numbers: serials,
508
+ quantity: newQty,
509
+ discount_amount: discAmt,
510
+ line_total: line.unit_price * newQty - discAmt,
511
+ });
512
+ setActiveSerialLineUid(null);
513
+ }}
514
+ />
515
+ )}
516
+
517
+ {/* ── Totals Bar ── */}
518
+ {lineItems.length > 0 && (
519
+ <div className="mt-3 pt-3 border-t border-border-subtle">
520
+ <div className="flex items-center justify-between text-[12px] text-foreground-subtle">
521
+ <span>{lineItems.length} item{lineItems.length > 1 ? 's' : ''} · {lineItems.reduce((s, l) => s + l.quantity, 0)} units</span>
522
+ {config.showPrices && (
523
+ <span className="text-[15px] font-bold text-foreground-0">
524
+ {formatCurrency(lineItems.reduce((s, l) => s + l.line_total, 0))}
525
+ </span>
526
+ )}
527
+ </div>
528
+ </div>
529
+ )}
530
+
531
+ <BatchSelectionModal
532
+ isOpen={!!activeBatchItem || !!activeBatchLineUid}
533
+ onClose={() => { setActiveBatchItem(null); setActiveBatchLineUid(null); }}
534
+ item={activeBatchItem?.item || lineItems.find(l => l.uid === activeBatchLineUid)?.item || null}
535
+ variant={
536
+ activeBatchItem?.variant ||
537
+ (() => {
538
+ const line = lineItems.find(l => l.uid === activeBatchLineUid);
539
+ return line?.variant_id ? line.item.variants?.find(v => v.id === line.variant_id) || null : null;
540
+ })()
541
+ }
542
+ warehouseId={warehouseId}
543
+ allowMultiple={true}
544
+ allowCreate={config.allowCreateBatchAndSerial}
545
+ initialBatches={lineItems.find(l => l.uid === activeBatchLineUid)?.batches || []}
546
+ onConfirm={(selections) => {
547
+ if (activeBatchItem) {
548
+ handleBatchConfirm(selections);
549
+ } else if (activeBatchLineUid) {
550
+ const batchesData = selections.map(sel => ({
551
+ batch_id: sel.batch.id > 0 ? sel.batch.id : null,
552
+ batch_number: sel.batch.batch_number,
553
+ manufacturing_date: sel.batch.manufacturing_date,
554
+ expiry_date: sel.batch.expiry_date,
555
+ supplier_reference: sel.batch.supplier_reference,
556
+ selling_price: sel.batch.selling_price,
557
+ quantity: sel.quantity
558
+ }));
559
+ const line = lineItems.find(l => l.uid === activeBatchLineUid);
560
+ if (!line) return;
561
+ const newQty = batchesData.reduce((sum, b) => sum + b.quantity, 0);
562
+ const discAmt = (line.unit_price * newQty * line.discount_percent) / 100;
563
+
564
+ onUpdateLine(line.uid, {
565
+ batches: batchesData,
566
+ quantity: newQty > 0 ? newQty : 1,
567
+ discount_amount: discAmt,
568
+ line_total: line.unit_price * (newQty > 0 ? newQty : 1) - discAmt,
569
+ });
570
+ setActiveBatchLineUid(null);
571
+ }
572
+ }}
573
+ />
574
+
575
+ <SerialSelectionModal
576
+ isOpen={!!activeSerialItem}
577
+ onClose={() => setActiveSerialItem(null)}
578
+ item={activeSerialItem?.item || null}
579
+ variant={activeSerialItem?.variant || null}
580
+ warehouseId={warehouseId}
581
+ allowCreate={config.allowCreateBatchAndSerial}
582
+ initialSerials={[]}
583
+ onConfirm={handleSerialConfirm}
584
+ />
585
+ </div>
586
+ );
587
+ }