@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.
- 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,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 "{searchValue}"</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
|
+
}
|