@apptimate/ui 6.3.0 → 6.5.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 +1 -1
- package/src/base-components/Checkbox.tsx +10 -3
- package/src/base-components/SearchableSelect.tsx +2 -0
- package/src/base-components/Table.tsx +1 -1
- package/src/common-components/PaymentSection.tsx +6 -0
- package/src/common-components/item-wizard/ItemFormWizard.tsx +116 -107
- package/src/common-components/pickers/modals/SerialSelectionModal.tsx +20 -10
- package/src/common-components/transaction/MetadataPanel.tsx +2 -2
- package/src/common-components/transaction/ProductSelectionPanel.tsx +522 -515
- package/src/common-components/transaction/ProductTransactionScreen.tsx +50 -7
- package/src/common-components/transaction/types.ts +2 -0
|
@@ -20,7 +20,7 @@ interface ProductSelectionPanelProps {
|
|
|
20
20
|
warehouseId?: number;
|
|
21
21
|
partyId?: number | null;
|
|
22
22
|
lineItems: TransactionLineItem[];
|
|
23
|
-
onAddItem: (item: TransactionItem, variant?: any, extraData?: { quantity?: number; batches?: any[]; serial_numbers?: string[]; serial_number_objects?: {id: number, serial_number: string}[] }) => void;
|
|
23
|
+
onAddItem: (item: TransactionItem, variant?: any, extraData?: { quantity?: number; batches?: any[]; serial_numbers?: string[]; serial_number_objects?: { id: number, serial_number: string }[] }) => void;
|
|
24
24
|
onUpdateLine: (uid: string, updates: Partial<TransactionLineItem>) => void;
|
|
25
25
|
onRemoveLine: (uid: string) => void;
|
|
26
26
|
onToggleExpand: (uid: string) => void;
|
|
@@ -75,7 +75,7 @@ export function ProductSelectionPanel({
|
|
|
75
75
|
try {
|
|
76
76
|
const params: Record<string, string | number> = { search: value, limit: 20, type: config.type };
|
|
77
77
|
if (warehouseId) params.warehouse_id = warehouseId;
|
|
78
|
-
|
|
78
|
+
|
|
79
79
|
if (['sales', 'quotation', 'pos'].includes(config.type)) {
|
|
80
80
|
params.apply_sales_prices = 1;
|
|
81
81
|
params.transaction_type = config.type;
|
|
@@ -92,16 +92,16 @@ export function ProductSelectionPanel({
|
|
|
92
92
|
setSearchValue('');
|
|
93
93
|
setSearchResults([]);
|
|
94
94
|
setShowResults(false);
|
|
95
|
-
|
|
95
|
+
|
|
96
96
|
const { type: matchType, entity } = exactMatch;
|
|
97
97
|
if (matchType === 'serial') {
|
|
98
|
-
onAddItem(entity.item, entity.variant, { serial_numbers: [entity.serial_number], quantity: 1 });
|
|
98
|
+
onAddItem(entity.item, entity.variant, { serial_numbers: [entity.serial_number], serial_number_objects: [{ id: entity.id, serial_number: entity.serial_number }], quantity: 1 });
|
|
99
99
|
} else if (matchType === 'batch') {
|
|
100
100
|
const batchVariant = entity.variant || (entity.variant_id && entity.item?.variants ? entity.item.variants.find((v: any) => v.id === entity.variant_id) : null);
|
|
101
101
|
if (['sales', 'quotation', 'pos'].includes(config.type)) {
|
|
102
|
-
onAddItem(entity.item, batchVariant, {
|
|
103
|
-
batches: [{ batch_id: entity.id, batch_number: entity.batch_number, selling_price: entity.selling_price, quantity: 1 }],
|
|
104
|
-
quantity: 1
|
|
102
|
+
onAddItem(entity.item, batchVariant, {
|
|
103
|
+
batches: [{ batch_id: entity.id, batch_number: entity.batch_number, selling_price: entity.selling_price, quantity: 1 }],
|
|
104
|
+
quantity: 1
|
|
105
105
|
});
|
|
106
106
|
} else {
|
|
107
107
|
setActiveBatchItem({ item: entity.item, variant: batchVariant, preSelectedBatch: entity });
|
|
@@ -150,18 +150,18 @@ export function ProductSelectionPanel({
|
|
|
150
150
|
|
|
151
151
|
const handleBatchConfirm = (selections: { batch: any; quantity: number }[]) => {
|
|
152
152
|
const batchesData = selections.map(sel => ({
|
|
153
|
-
batch_id: sel.batch.id > 0 ? sel.batch.id : null,
|
|
153
|
+
batch_id: sel.batch.id > 0 ? sel.batch.id : null,
|
|
154
154
|
batch_number: sel.batch.batch_number,
|
|
155
155
|
manufacturing_date: sel.batch.manufacturing_date,
|
|
156
156
|
expiry_date: sel.batch.expiry_date,
|
|
157
157
|
supplier_reference: sel.batch.supplier_reference,
|
|
158
158
|
selling_price: sel.batch.selling_price,
|
|
159
|
-
quantity: sel.quantity
|
|
159
|
+
quantity: sel.quantity
|
|
160
160
|
}));
|
|
161
161
|
const totalQty = batchesData.reduce((sum, b) => sum + b.quantity, 0);
|
|
162
162
|
|
|
163
163
|
if (activeBatchItem) {
|
|
164
|
-
onAddItem(activeBatchItem.item, activeBatchItem.variant, {
|
|
164
|
+
onAddItem(activeBatchItem.item, activeBatchItem.variant, {
|
|
165
165
|
batches: batchesData,
|
|
166
166
|
quantity: totalQty > 0 ? totalQty : 1
|
|
167
167
|
});
|
|
@@ -169,7 +169,7 @@ export function ProductSelectionPanel({
|
|
|
169
169
|
}
|
|
170
170
|
};
|
|
171
171
|
|
|
172
|
-
const handleSerialConfirm = (serials: string[], serialObjects?: {id: number, serial_number: string}[]) => {
|
|
172
|
+
const handleSerialConfirm = (serials: string[], serialObjects?: { id: number, serial_number: string }[]) => {
|
|
173
173
|
if (!activeSerialItem) return;
|
|
174
174
|
|
|
175
175
|
onAddItem(activeSerialItem.item, activeSerialItem.variant, { serial_numbers: serials, serial_number_objects: serialObjects, quantity: serials.length });
|
|
@@ -234,9 +234,9 @@ export function ProductSelectionPanel({
|
|
|
234
234
|
const focused = flattenedItems[focusedIndex];
|
|
235
235
|
if (focused.type === 'item') {
|
|
236
236
|
if (focused.item.has_variants) {
|
|
237
|
-
setExpandedItemIds(prev =>
|
|
238
|
-
prev.includes(focused.item.id)
|
|
239
|
-
? prev.filter(id => id !== focused.item.id)
|
|
237
|
+
setExpandedItemIds(prev =>
|
|
238
|
+
prev.includes(focused.item.id)
|
|
239
|
+
? prev.filter(id => id !== focused.item.id)
|
|
240
240
|
: [...prev, focused.item.id]
|
|
241
241
|
);
|
|
242
242
|
} else {
|
|
@@ -255,12 +255,12 @@ export function ProductSelectionPanel({
|
|
|
255
255
|
const num = value === '' ? 0 : Number(value);
|
|
256
256
|
const line = lineItems.find(l => l.uid === uid);
|
|
257
257
|
if (!line) return;
|
|
258
|
-
|
|
258
|
+
|
|
259
259
|
let qty = line.quantity;
|
|
260
260
|
let price = line.unit_price;
|
|
261
261
|
let discAmt = line.discount_amount;
|
|
262
262
|
let discPercent = line.discount_percent;
|
|
263
|
-
|
|
263
|
+
|
|
264
264
|
if (field === 'quantity') qty = Math.max(0.001, num);
|
|
265
265
|
if (field === 'unit_price') price = Math.max(0, num);
|
|
266
266
|
if (field === 'discount_amount') {
|
|
@@ -274,7 +274,7 @@ export function ProductSelectionPanel({
|
|
|
274
274
|
} else {
|
|
275
275
|
// If price or qty changes, check which discount type is active
|
|
276
276
|
const activeDiscountType = discountTypes[uid] || 'amount';
|
|
277
|
-
|
|
277
|
+
|
|
278
278
|
if (activeDiscountType === 'percent') {
|
|
279
279
|
// Preserve percentage, recalculate amount
|
|
280
280
|
discAmt = (price * qty * discPercent) / 100;
|
|
@@ -283,28 +283,28 @@ export function ProductSelectionPanel({
|
|
|
283
283
|
discPercent = (price * qty) > 0 ? (discAmt * 100) / (price * qty) : 0;
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
|
-
|
|
286
|
+
|
|
287
287
|
if (field === 'uom_id') {
|
|
288
288
|
const newUomId = Number(value);
|
|
289
289
|
const uoms = line.item.uom?.uom_group?.uoms || [];
|
|
290
|
-
|
|
290
|
+
|
|
291
291
|
const oldUom = uoms.find((u: any) => u.id === (line.uom_id || line.item.uom_id));
|
|
292
292
|
const newUom = uoms.find((u: any) => u.id === newUomId);
|
|
293
|
-
|
|
293
|
+
|
|
294
294
|
let newPrice = line.unit_price;
|
|
295
|
-
|
|
295
|
+
|
|
296
296
|
if (oldUom && newUom) {
|
|
297
297
|
const oldFactor = Number(oldUom.conversion_factor) || 1;
|
|
298
298
|
const newFactor = Number(newUom.conversion_factor) || 1;
|
|
299
|
-
|
|
299
|
+
|
|
300
300
|
// Calculate the base price then multiply by the new factor
|
|
301
301
|
const basePrice = line.unit_price / oldFactor;
|
|
302
302
|
newPrice = basePrice * newFactor;
|
|
303
303
|
}
|
|
304
|
-
|
|
304
|
+
|
|
305
305
|
const newDiscAmt = (newPrice * line.quantity * line.discount_percent) / 100;
|
|
306
|
-
|
|
307
|
-
onUpdateLine(uid, {
|
|
306
|
+
|
|
307
|
+
onUpdateLine(uid, {
|
|
308
308
|
uom_id: newUomId,
|
|
309
309
|
unit_price: newPrice,
|
|
310
310
|
discount_amount: newDiscAmt,
|
|
@@ -325,7 +325,7 @@ export function ProductSelectionPanel({
|
|
|
325
325
|
const handleToggleFreeQty = (uid: string, isFree: boolean) => {
|
|
326
326
|
const line = lineItems.find(l => l.uid === uid);
|
|
327
327
|
if (!line) return;
|
|
328
|
-
|
|
328
|
+
|
|
329
329
|
let newPrice = 0;
|
|
330
330
|
if (!isFree) {
|
|
331
331
|
const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
|
|
@@ -359,208 +359,210 @@ export function ProductSelectionPanel({
|
|
|
359
359
|
<div ref={panelRef} className="relative mb-4">
|
|
360
360
|
<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">
|
|
361
361
|
<Search size={18} className="text-foreground-disabled shrink-0" />
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
value={searchValue}
|
|
371
|
-
onChange={(e) => handleSearch(e.target.value)}
|
|
372
|
-
onFocus={() => searchResults.length > 0 && setShowResults(true)}
|
|
373
|
-
onKeyDown={handleKeyDown}
|
|
374
|
-
disabled={config.requireWarehouseFirst && !warehouseId}
|
|
375
|
-
className={cn(
|
|
376
|
-
"flex-1 bg-transparent text-[13.5px] text-foreground-1 outline-none placeholder:text-foreground-disabled",
|
|
377
|
-
config.requireWarehouseFirst && !warehouseId && "cursor-not-allowed"
|
|
378
|
-
)}
|
|
379
|
-
autoComplete="off"
|
|
380
|
-
spellCheck={false}
|
|
381
|
-
/>
|
|
382
|
-
{isSearching && <Loader2 size={16} className="animate-spin text-primary shrink-0" />}
|
|
383
|
-
|
|
384
|
-
<div className="flex items-center gap-1.5 ml-1">
|
|
385
|
-
<HintIcon
|
|
386
|
-
text="Automatically adds an item to the list when a barcode is scanned or an exact match is found."
|
|
387
|
-
icon={
|
|
388
|
-
<button
|
|
389
|
-
type="button"
|
|
390
|
-
onClick={() => setAutoAddEnabled(!autoAddEnabled)}
|
|
391
|
-
className={cn(
|
|
392
|
-
"relative inline-flex h-5 w-8 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none",
|
|
393
|
-
autoAddEnabled ? "bg-primary" : "bg-gray-300"
|
|
394
|
-
)}
|
|
395
|
-
>
|
|
396
|
-
<span
|
|
397
|
-
className={cn(
|
|
398
|
-
"pointer-events-none inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out",
|
|
399
|
-
autoAddEnabled ? "translate-x-4" : "translate-x-1"
|
|
400
|
-
)}
|
|
401
|
-
/>
|
|
402
|
-
</button>
|
|
362
|
+
<input
|
|
363
|
+
ref={inputRef}
|
|
364
|
+
type="text"
|
|
365
|
+
placeholder={
|
|
366
|
+
config.requireWarehouseFirst && !warehouseId
|
|
367
|
+
? "Select a warehouse first to search items..."
|
|
368
|
+
: "Search by name, SKU, or scan barcode..."
|
|
403
369
|
}
|
|
370
|
+
value={searchValue}
|
|
371
|
+
onChange={(e) => handleSearch(e.target.value)}
|
|
372
|
+
onFocus={() => searchResults.length > 0 && setShowResults(true)}
|
|
373
|
+
onKeyDown={handleKeyDown}
|
|
374
|
+
disabled={config.requireWarehouseFirst && !warehouseId}
|
|
375
|
+
className={cn(
|
|
376
|
+
"flex-1 bg-transparent text-[13.5px] text-foreground-1 outline-none placeholder:text-foreground-disabled",
|
|
377
|
+
config.requireWarehouseFirst && !warehouseId && "cursor-not-allowed"
|
|
378
|
+
)}
|
|
379
|
+
autoComplete="off"
|
|
380
|
+
spellCheck={false}
|
|
404
381
|
/>
|
|
405
|
-
{
|
|
406
|
-
<>
|
|
407
|
-
<div className="w-[1px] h-5 bg-border-subtle mx-1" />
|
|
408
|
-
<button
|
|
409
|
-
type="button"
|
|
410
|
-
onClick={() => onAddNewItem({ setItemSearch: handleSearch })}
|
|
411
|
-
title="Create new item"
|
|
412
|
-
className="flex items-center justify-center outline-none mr-1"
|
|
413
|
-
>
|
|
414
|
-
<Plus
|
|
415
|
-
size={18}
|
|
416
|
-
className="text-foreground-disabled shrink-0 cursor-pointer hover:text-primary transition-colors"
|
|
417
|
-
/>
|
|
418
|
-
</button>
|
|
419
|
-
</>
|
|
420
|
-
)}
|
|
421
|
-
</div>
|
|
422
|
-
</div>
|
|
382
|
+
{isSearching && <Loader2 size={16} className="animate-spin text-primary shrink-0" />}
|
|
423
383
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
handleSelectItem(item);
|
|
435
|
-
}
|
|
436
|
-
}}
|
|
437
|
-
id={`dropdown-item-item-${item.id}`}
|
|
438
|
-
className={cn(
|
|
439
|
-
"w-full flex items-center gap-3 px-4 py-3 text-left transition-colors",
|
|
440
|
-
expandedItemIds.includes(item.id) && "bg-surface-hover",
|
|
441
|
-
item.has_variants ? "cursor-default" : "cursor-pointer hover:bg-surface-hover",
|
|
442
|
-
focusedKey === `item-${item.id}` && "bg-primary/5"
|
|
443
|
-
)}
|
|
444
|
-
>
|
|
445
|
-
{/* Product thumbnail */}
|
|
446
|
-
<div className="w-9 h-9 rounded-[8px] bg-surface-0 flex items-center justify-center shrink-0 overflow-hidden">
|
|
447
|
-
{item.primary_image?.url || item.image_url ? (
|
|
448
|
-
<img
|
|
449
|
-
src={item.primary_image?.url || item.image_url}
|
|
450
|
-
alt={item.name}
|
|
451
|
-
className="w-full h-full object-cover"
|
|
452
|
-
/>
|
|
453
|
-
) : (
|
|
454
|
-
<Package size={16} className="text-foreground-disabled" />
|
|
384
|
+
<div className="flex items-center gap-1.5 ml-1">
|
|
385
|
+
<HintIcon
|
|
386
|
+
text="Automatically adds an item to the list when a barcode is scanned or an exact match is found."
|
|
387
|
+
icon={
|
|
388
|
+
<button
|
|
389
|
+
type="button"
|
|
390
|
+
onClick={() => setAutoAddEnabled(!autoAddEnabled)}
|
|
391
|
+
className={cn(
|
|
392
|
+
"relative inline-flex h-5 w-8 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none",
|
|
393
|
+
autoAddEnabled ? "bg-primary" : "bg-gray-300"
|
|
455
394
|
)}
|
|
456
|
-
|
|
395
|
+
>
|
|
396
|
+
<span
|
|
397
|
+
className={cn(
|
|
398
|
+
"pointer-events-none inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out",
|
|
399
|
+
autoAddEnabled ? "translate-x-4" : "translate-x-1"
|
|
400
|
+
)}
|
|
401
|
+
/>
|
|
402
|
+
</button>
|
|
403
|
+
}
|
|
404
|
+
/>
|
|
405
|
+
{onAddNewItem && (
|
|
406
|
+
<>
|
|
407
|
+
<div className="w-[1px] h-5 bg-border-subtle mx-1" />
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
onClick={() => onAddNewItem({ setItemSearch: handleSearch })}
|
|
411
|
+
title="Create new item"
|
|
412
|
+
className="flex items-center justify-center outline-none mr-1"
|
|
413
|
+
>
|
|
414
|
+
<Plus
|
|
415
|
+
size={18}
|
|
416
|
+
className="text-foreground-disabled shrink-0 cursor-pointer hover:text-primary transition-colors"
|
|
417
|
+
/>
|
|
418
|
+
</button>
|
|
419
|
+
</>
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
457
423
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
424
|
+
{/* ── Search Results Dropdown ── */}
|
|
425
|
+
{showResults && searchResults.length > 0 && (
|
|
426
|
+
<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">
|
|
427
|
+
{searchResults.map((item) => (
|
|
428
|
+
<div key={item.id} className="flex flex-col border-b border-border-subtle last:border-0">
|
|
429
|
+
<button
|
|
430
|
+
type="button"
|
|
431
|
+
onClick={() => {
|
|
432
|
+
if (item.has_variants) {
|
|
433
|
+
setExpandedItemIds(prev => prev.includes(item.id) ? prev.filter(id => id !== item.id) : [...prev, item.id]);
|
|
434
|
+
} else {
|
|
435
|
+
handleSelectItem(item);
|
|
436
|
+
}
|
|
437
|
+
}}
|
|
438
|
+
id={`dropdown-item-item-${item.id}`}
|
|
439
|
+
className={cn(
|
|
440
|
+
"w-full flex items-center gap-3 px-4 py-3 text-left transition-colors",
|
|
441
|
+
expandedItemIds.includes(item.id) && "bg-surface-hover",
|
|
442
|
+
item.has_variants ? "cursor-default" : "cursor-pointer hover:bg-surface-hover",
|
|
443
|
+
focusedKey === `item-${item.id}` && "bg-primary/5"
|
|
444
|
+
)}
|
|
445
|
+
>
|
|
446
|
+
{/* Product thumbnail */}
|
|
447
|
+
<div className="w-9 h-9 rounded-[8px] bg-surface-0 flex items-center justify-center shrink-0 overflow-hidden">
|
|
448
|
+
{item.primary_image?.url || item.image_url ? (
|
|
449
|
+
<img
|
|
450
|
+
src={item.primary_image?.url || item.image_url}
|
|
451
|
+
alt={item.name}
|
|
452
|
+
className="w-full h-full object-cover"
|
|
453
|
+
/>
|
|
454
|
+
) : (
|
|
455
|
+
<Package size={16} className="text-foreground-disabled" />
|
|
467
456
|
)}
|
|
468
457
|
</div>
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
458
|
+
|
|
459
|
+
{/* Product info */}
|
|
460
|
+
<div className="flex-1 min-w-0">
|
|
461
|
+
<div className="text-[13px] font-medium text-foreground-1 truncate">{item.name}</div>
|
|
462
|
+
<div className="text-[11px] text-foreground-subtle flex items-center gap-2 mt-0.5">
|
|
463
|
+
<span className="font-mono">{item.sku}</span>
|
|
464
|
+
{item.has_variants && (
|
|
465
|
+
<Badge variant="flat" color="default" size="small">
|
|
466
|
+
{item.variants?.length || 0} variants
|
|
467
|
+
</Badge>
|
|
468
|
+
)}
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
{/* Price + stock + Chevron */}
|
|
473
|
+
<div className="text-right shrink-0 flex items-center gap-3">
|
|
474
|
+
<div className="flex flex-col items-end">
|
|
475
|
+
{config.showPrices && (
|
|
476
|
+
<div className="text-[13px] font-semibold text-foreground-1">
|
|
477
|
+
{formatCurrency(item.variants?.[0]?.[config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price'] || 0)}
|
|
478
|
+
</div>
|
|
479
|
+
)}
|
|
480
|
+
{config.showStock && item.has_stocks && item.stock && (
|
|
481
|
+
<div className={cn(
|
|
482
|
+
"text-[11px] font-medium",
|
|
483
|
+
item.stock.qty_available > 0 ? "text-green-600" : "text-danger-alt"
|
|
484
|
+
)}>
|
|
485
|
+
{item.stock.qty_available} {item.uom?.abbreviation || 'pcs'}
|
|
486
|
+
</div>
|
|
487
|
+
)}
|
|
488
|
+
</div>
|
|
489
|
+
{item.has_variants && (
|
|
490
|
+
<ChevronRight
|
|
491
|
+
size={16}
|
|
492
|
+
className={cn("text-foreground-disabled transition-transform", expandedItemIds.includes(item.id) && "rotate-90")}
|
|
493
|
+
/>
|
|
486
494
|
)}
|
|
487
495
|
</div>
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
"
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
<span className="text-[12.5px] font-medium text-foreground-1">
|
|
518
|
-
{variant.variant_name}
|
|
519
|
-
</span>
|
|
520
|
-
<span className="text-[11px] font-mono text-foreground-subtle mt-0.5">
|
|
521
|
-
{variant.sku}
|
|
522
|
-
</span>
|
|
523
|
-
</div>
|
|
524
|
-
<div className="text-right shrink-0 flex items-center gap-4">
|
|
525
|
-
{config.showPrices && (
|
|
526
|
-
<div className="text-[12.5px] font-semibold text-foreground-1 min-w-[70px] text-right">
|
|
527
|
-
{formatCurrency(variant[config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price'] || item.variants?.[0]?.[config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price'] || 0)}
|
|
496
|
+
</button>
|
|
497
|
+
|
|
498
|
+
{/* Variants Tree */}
|
|
499
|
+
{item.has_variants && expandedItemIds.includes(item.id) && (
|
|
500
|
+
<div className="relative flex flex-col pb-2 bg-surface-0">
|
|
501
|
+
{/* Vertical connecting line for the tree (aligned with thumbnail center at 34px) */}
|
|
502
|
+
<div className="absolute left-[34px] top-0 bottom-[24px] w-px bg-border-subtle" />
|
|
503
|
+
|
|
504
|
+
<div className="pl-[52px] pr-4 flex flex-col gap-0.5 mt-1">
|
|
505
|
+
{item.variants?.map((variant: any) => (
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
key={variant.id}
|
|
509
|
+
id={`dropdown-item-variant-${variant.id}`}
|
|
510
|
+
onClick={() => handleSelectItem(item, variant)}
|
|
511
|
+
className={cn(
|
|
512
|
+
"relative flex items-center justify-between py-2 px-3 rounded-[6px] hover:bg-surface-hover transition-colors text-left group cursor-pointer",
|
|
513
|
+
focusedKey === `variant-${variant.id}` && "bg-primary/5"
|
|
514
|
+
)}
|
|
515
|
+
>
|
|
516
|
+
{/* Horizontal connecting line to variant */}
|
|
517
|
+
<div className="absolute left-[-18px] top-1/2 w-[18px] h-px bg-border-subtle" />
|
|
518
|
+
<div className="flex flex-col">
|
|
519
|
+
<span className="text-[12.5px] font-medium text-foreground-1">
|
|
520
|
+
{variant.variant_name}
|
|
521
|
+
</span>
|
|
522
|
+
<span className="text-[11px] font-mono text-foreground-subtle mt-0.5">
|
|
523
|
+
{variant.sku}
|
|
524
|
+
</span>
|
|
528
525
|
</div>
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
{
|
|
526
|
+
<div className="text-right shrink-0 flex items-center gap-4">
|
|
527
|
+
{config.showPrices && (
|
|
528
|
+
<div className="text-[12.5px] font-semibold text-foreground-1 min-w-[70px] text-right">
|
|
529
|
+
{formatCurrency(variant[config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price'] || item.variants?.[0]?.[config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price'] || 0)}
|
|
530
|
+
</div>
|
|
531
|
+
)}
|
|
532
|
+
{config.showStock && item.has_stocks && variant.stock && (
|
|
533
|
+
<div className={cn(
|
|
534
|
+
"text-[11.5px] font-medium min-w-[50px] text-right",
|
|
535
|
+
variant.stock.qty_available > 0 ? "text-green-600" : "text-danger-alt"
|
|
536
|
+
)}>
|
|
537
|
+
{variant.stock.qty_available} {item.uom?.abbreviation || 'pcs'}
|
|
538
|
+
</div>
|
|
539
|
+
)}
|
|
540
|
+
<span className="text-[11px] font-medium text-primary opacity-0 group-hover:opacity-100 transition-opacity w-[40px] text-right">
|
|
541
|
+
Select
|
|
542
|
+
</span>
|
|
536
543
|
</div>
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
<div className="text-[12px] text-foreground-disabled italic py-2 px-3 relative">
|
|
546
|
-
<div className="absolute left-[-18px] top-1/2 w-[18px] h-px bg-border-subtle" />
|
|
547
|
-
No active variants found.
|
|
544
|
+
</button>
|
|
545
|
+
))}
|
|
546
|
+
{(!item.variants || item.variants.length === 0) && (
|
|
547
|
+
<div className="text-[12px] text-foreground-disabled italic py-2 px-3 relative">
|
|
548
|
+
<div className="absolute left-[-18px] top-1/2 w-[18px] h-px bg-border-subtle" />
|
|
549
|
+
No active variants found.
|
|
550
|
+
</div>
|
|
551
|
+
)}
|
|
548
552
|
</div>
|
|
549
|
-
)}
|
|
550
553
|
</div>
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
)}
|
|
554
|
+
)}
|
|
555
|
+
</div>
|
|
556
|
+
))}
|
|
557
|
+
</div>
|
|
558
|
+
)}
|
|
557
559
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
560
|
+
{showResults && searchResults.length === 0 && searchValue.length > 0 && !isSearching && (
|
|
561
|
+
<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">
|
|
562
|
+
<p className="text-[13px] text-foreground-disabled">No items found for "{searchValue}"</p>
|
|
563
|
+
</div>
|
|
564
|
+
)}
|
|
565
|
+
</div>
|
|
564
566
|
)}
|
|
565
567
|
|
|
566
568
|
{/* ── Selected Items Table ── */}
|
|
@@ -588,174 +590,179 @@ export function ProductSelectionPanel({
|
|
|
588
590
|
</THeader>
|
|
589
591
|
<TBody>
|
|
590
592
|
{lineItems.map((line) => {
|
|
591
|
-
const
|
|
593
|
+
const isSalesModule = ['sales', 'quotation', 'pos'].includes(config.type);
|
|
594
|
+
const isBatchPending = isSalesModule && line.item.tracking_type === 'batch' && (!line.batches || line.batches.length === 0);
|
|
595
|
+
const isDiscountDisabled = !config.allowEditPrice || line.is_free_qty || (isSalesModule && !line.item.is_discountable) || line.unit_price === 0 || isBatchPending;
|
|
592
596
|
return (
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
)}
|
|
602
|
-
</div>
|
|
603
|
-
<div className="flex flex-col flex-1 min-w-0">
|
|
604
|
-
<span className="text-[13px] font-semibold text-foreground-1 leading-tight break-words">
|
|
605
|
-
{line.item.name}
|
|
606
|
-
{line.is_free_qty && (
|
|
607
|
-
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-green-100 text-green-700 uppercase tracking-wider align-middle">
|
|
608
|
-
Free
|
|
609
|
-
</span>
|
|
597
|
+
<TRow key={line.uid}>
|
|
598
|
+
<TCell label="Item" className="py-2.5">
|
|
599
|
+
<div className="flex items-start gap-3">
|
|
600
|
+
<div className="w-8 h-8 rounded-[6px] bg-surface-hover flex items-center justify-center shrink-0 overflow-hidden mt-0.5">
|
|
601
|
+
{line.item.primary_image?.url || line.item.image_url ? (
|
|
602
|
+
<img src={line.item.primary_image?.url || line.item.image_url} alt={line.item.name} className="w-full h-full object-cover" />
|
|
603
|
+
) : (
|
|
604
|
+
<Package size={14} className="text-foreground-disabled" />
|
|
610
605
|
)}
|
|
611
|
-
</
|
|
612
|
-
<
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
606
|
+
</div>
|
|
607
|
+
<div className="flex flex-col flex-1 min-w-0">
|
|
608
|
+
<span className="text-[13px] font-semibold text-foreground-1 leading-tight break-words">
|
|
609
|
+
{line.item.name}
|
|
610
|
+
{line.is_free_qty && (
|
|
611
|
+
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-green-100 text-green-700 uppercase tracking-wider align-middle">
|
|
612
|
+
Free
|
|
613
|
+
</span>
|
|
614
|
+
)}
|
|
615
|
+
</span>
|
|
616
|
+
<span className="text-[11px] text-foreground-subtle font-mono mt-0.5 break-words">
|
|
617
|
+
{(() => {
|
|
618
|
+
if (line.item.has_variants && line.variant_id) {
|
|
619
|
+
const variant = line.variant || line.item.variants?.find(v => v.id === line.variant_id);
|
|
620
|
+
if (variant) {
|
|
621
|
+
return `${variant.variant_name} • ${variant.sku}`;
|
|
622
|
+
}
|
|
618
623
|
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
624
|
+
return line.item.sku || '';
|
|
625
|
+
})()}
|
|
626
|
+
</span>
|
|
627
|
+
{((!config.disableTrackingSelection && (line.item.tracking_type === 'batch' || line.item.tracking_type === 'serial')) || (line.item.tracking_type === 'batch' && ['sales', 'quotation', 'pos'].includes(config.type))) && (
|
|
628
|
+
<div className="flex flex-wrap gap-1 mt-1.5">
|
|
629
|
+
{line.item.tracking_type === 'batch' && (() => {
|
|
630
|
+
if (['sales', 'quotation', 'pos'].includes(config.type)) {
|
|
631
|
+
return (
|
|
632
|
+
<div className="mt-1.5 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
|
633
|
+
{(!line.batches || line.batches.length === 0) && (
|
|
634
|
+
<AlertTriangle size={16} className="text-danger shrink-0" />
|
|
635
|
+
)}
|
|
636
|
+
<div className="w-56">
|
|
637
|
+
<AsyncSearchableSelect
|
|
638
|
+
controlClassName="!min-h-8 h-8 !py-1 text-sm"
|
|
639
|
+
placeholder="Select Batch..."
|
|
640
|
+
loadOptions={async (search) => {
|
|
641
|
+
const params: any = {};
|
|
642
|
+
if (line.variant_id) params.variant_id = line.variant_id;
|
|
643
|
+
if (warehouseId) params.warehouse_id = warehouseId;
|
|
644
|
+
const res = await getItemBatches(line.item.id, params);
|
|
645
|
+
const batches = res?.result?.data || res?.result || [];
|
|
646
|
+
return batches.filter((b: any) => b.batch_number.toLowerCase().includes(search.toLowerCase()));
|
|
647
|
+
}}
|
|
648
|
+
option={{
|
|
649
|
+
label: 'batch_number',
|
|
650
|
+
value: 'id',
|
|
651
|
+
renderOption: (b: any) => (
|
|
652
|
+
<div className="flex items-center justify-between w-full pr-1">
|
|
653
|
+
<span>{b.batch_number}</span>
|
|
654
|
+
{warehouseId ? (
|
|
655
|
+
<span className="text-foreground-subtle text-[11px] font-mono" title="Available Stock">{b.qty_available ?? 0}</span>
|
|
656
|
+
) : null}
|
|
657
|
+
</div>
|
|
658
|
+
)
|
|
659
|
+
}}
|
|
660
|
+
defaultValue={line.batches?.[0] ? { id: line.batches[0].batch_id, batch_number: line.batches[0].batch_number, selling_price: line.batches[0].selling_price } : undefined}
|
|
661
|
+
onChange={(val, selected: any) => {
|
|
662
|
+
if (selected) {
|
|
663
|
+
onUpdateLine(line.uid, {
|
|
664
|
+
batches: [{ batch_id: selected.id, batch_number: selected.batch_number, selling_price: selected.selling_price, quantity: line.quantity }],
|
|
665
|
+
unit_price: selected.selling_price || 0
|
|
666
|
+
});
|
|
667
|
+
} else {
|
|
668
|
+
onUpdateLine(line.uid, { batches: [] });
|
|
669
|
+
}
|
|
670
|
+
}}
|
|
671
|
+
/>
|
|
672
|
+
</div>
|
|
668
673
|
</div>
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
}
|
|
674
|
+
);
|
|
675
|
+
}
|
|
672
676
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
677
|
+
const batchesCount = line.batches?.length || 0;
|
|
678
|
+
const selectedTotal = line.batches?.reduce((sum, b) => sum + b.quantity, 0) || 0;
|
|
679
|
+
const isMismatch = selectedTotal !== line.quantity;
|
|
680
|
+
return (
|
|
681
|
+
<button
|
|
682
|
+
type="button"
|
|
683
|
+
onClick={() => setActiveBatchLineUid(line.uid)}
|
|
684
|
+
className={cn(
|
|
685
|
+
"mt-1 flex items-center gap-1.5 text-[11.5px] font-medium transition-colors hover:underline text-left",
|
|
686
|
+
isMismatch
|
|
687
|
+
? "text-warning-600 hover:text-warning-700"
|
|
688
|
+
: "text-primary"
|
|
689
|
+
)}
|
|
690
|
+
>
|
|
691
|
+
{batchesCount > 0 ? (
|
|
692
|
+
isMismatch ? (
|
|
693
|
+
<><AlertTriangle size={12} className="mb-[1px]" /> <span>{selectedTotal} / {line.quantity} Selected</span></>
|
|
694
|
+
) : (
|
|
695
|
+
<>✓ {batchesCount} Batches Selected</>
|
|
696
|
+
)
|
|
689
697
|
) : (
|
|
690
|
-
|
|
691
|
-
)
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
698
|
+
<><AlertTriangle size={12} className="mb-[1px]" /> <span>Select Batches</span></>
|
|
699
|
+
)}
|
|
700
|
+
</button>
|
|
701
|
+
);
|
|
702
|
+
})()}
|
|
703
|
+
{line.item.tracking_type === 'serial' && (() => {
|
|
704
|
+
const selectedCount = line.serial_numbers?.length || 0;
|
|
705
|
+
const isMismatch = selectedCount !== line.quantity;
|
|
706
|
+
return (
|
|
707
|
+
<button
|
|
708
|
+
type="button"
|
|
709
|
+
onClick={() => setActiveSerialLineUid(line.uid)}
|
|
710
|
+
className={cn(
|
|
711
|
+
"mt-1 flex items-center gap-1.5 text-[11.5px] font-medium transition-colors hover:underline text-left",
|
|
712
|
+
isMismatch
|
|
713
|
+
? "text-warning-600 hover:text-warning-700"
|
|
714
|
+
: "text-primary"
|
|
715
|
+
)}
|
|
716
|
+
>
|
|
717
|
+
{selectedCount > 0 ? (
|
|
718
|
+
isMismatch ? (
|
|
719
|
+
<><AlertTriangle size={12} className="mb-[1px]" /> <span>{selectedCount} / {line.quantity} Serials Selected</span></>
|
|
720
|
+
) : (
|
|
721
|
+
<>✓ {selectedCount} Serials Selected</>
|
|
722
|
+
)
|
|
714
723
|
) : (
|
|
715
|
-
|
|
716
|
-
)
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
type="checkbox"
|
|
730
|
-
checked={line.is_free_qty || false}
|
|
731
|
-
onChange={(e) => handleToggleFreeQty(line.uid, e.target.checked)}
|
|
732
|
-
disabled={!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id)}
|
|
733
|
-
className={cn(
|
|
734
|
-
"rounded border-border-subtle text-primary h-3 w-3",
|
|
735
|
-
!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id) ? "opacity-50 cursor-not-allowed" : "focus:ring-primary"
|
|
736
|
-
)}
|
|
737
|
-
/>
|
|
738
|
-
<span className={cn(
|
|
739
|
-
"text-[11.5px] font-medium transition-colors",
|
|
740
|
-
!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id) ? "text-foreground-disabled" : "text-foreground-subtle hover:text-foreground-1"
|
|
741
|
-
)}>
|
|
742
|
-
Free Quantity
|
|
743
|
-
</span>
|
|
744
|
-
</label>
|
|
745
|
-
|
|
746
|
-
{line.is_free_qty && (
|
|
747
|
-
<div className="flex items-center">
|
|
748
|
-
<button
|
|
749
|
-
onClick={() => setActiveParentLineUidFor(line.uid)}
|
|
724
|
+
<><AlertTriangle size={12} className="mb-[1px]" /> <span>Select Serial Numbers</span></>
|
|
725
|
+
)}
|
|
726
|
+
</button>
|
|
727
|
+
);
|
|
728
|
+
})()}
|
|
729
|
+
</div>
|
|
730
|
+
)}
|
|
731
|
+
{Number(line.unit_price) === 0 && (
|
|
732
|
+
<div className="mt-2 flex items-center gap-2">
|
|
733
|
+
<label className={cn("flex items-center gap-1.5", !!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id) ? "cursor-not-allowed" : "cursor-pointer")}>
|
|
734
|
+
<input
|
|
735
|
+
type="checkbox"
|
|
736
|
+
checked={line.is_free_qty || false}
|
|
737
|
+
onChange={(e) => handleToggleFreeQty(line.uid, e.target.checked)}
|
|
750
738
|
disabled={!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id)}
|
|
751
739
|
className={cn(
|
|
752
|
-
"
|
|
753
|
-
!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id) ? "opacity-
|
|
740
|
+
"rounded border-border-subtle text-primary h-3 w-3",
|
|
741
|
+
!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id) ? "opacity-50 cursor-not-allowed" : "focus:ring-primary"
|
|
754
742
|
)}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
743
|
+
/>
|
|
744
|
+
<span className={cn(
|
|
745
|
+
"text-[11.5px] font-medium transition-colors",
|
|
746
|
+
!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id) ? "text-foreground-disabled" : "text-foreground-subtle hover:text-foreground-1"
|
|
747
|
+
)}>
|
|
748
|
+
Free Quantity
|
|
749
|
+
</span>
|
|
750
|
+
</label>
|
|
751
|
+
|
|
752
|
+
{line.is_free_qty && (
|
|
753
|
+
<div className="flex items-center">
|
|
754
|
+
<button
|
|
755
|
+
type="button"
|
|
756
|
+
onClick={() => setActiveParentLineUidFor(line.uid)}
|
|
757
|
+
disabled={!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id)}
|
|
758
|
+
className={cn(
|
|
759
|
+
"h-6 text-[11.5px] bg-surface-1 border border-border-subtle rounded-md px-2 ml-2 min-w-[120px] max-w-[180px] text-left truncate transition-colors flex justify-between items-center",
|
|
760
|
+
!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id) ? "opacity-60 cursor-not-allowed" : "hover:bg-surface-hover focus:border-primary/50 focus:outline-none"
|
|
761
|
+
)}
|
|
762
|
+
>
|
|
763
|
+
<span>
|
|
764
|
+
{line.parent_line_uids && line.parent_line_uids.length > 0
|
|
765
|
+
? (() => {
|
|
759
766
|
if (line.parent_line_uids.length === 1) {
|
|
760
767
|
const p = lineItems.find((l) => l.uid === line.parent_line_uids?.[0]);
|
|
761
768
|
if (p) {
|
|
@@ -772,152 +779,151 @@ export function ProductSelectionPanel({
|
|
|
772
779
|
return `${line.parent_line_uids.length} Parents Selected`;
|
|
773
780
|
}
|
|
774
781
|
})()
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
)}
|
|
783
|
-
</div>
|
|
784
|
-
</div>
|
|
785
|
-
</TCell>
|
|
786
|
-
<TCell label="Qty" className="py-2.5 text-center">
|
|
787
|
-
<input
|
|
788
|
-
type="number"
|
|
789
|
-
value={line.quantity}
|
|
790
|
-
onChange={(e) => updateLineInput(line.uid, 'quantity', e.target.value)}
|
|
791
|
-
className="w-20 h-10 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"
|
|
792
|
-
min={0.001}
|
|
793
|
-
step="any"
|
|
794
|
-
/>
|
|
795
|
-
</TCell>
|
|
796
|
-
|
|
797
|
-
{(config.enablePurchaseUnit || config.enableSalesUnit) && (
|
|
798
|
-
<TCell label="Unit" className="py-2.5">
|
|
799
|
-
<select
|
|
800
|
-
value={line.uom_id || line.item.uom_id || ''}
|
|
801
|
-
onChange={(e) => updateLineInput(line.uid, 'uom_id', e.target.value)}
|
|
802
|
-
disabled={line.item.tracking_type === 'serial' || isBatchPending}
|
|
803
|
-
className="h-10 px-2 text-[13px] font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors min-w-[80px] disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
|
|
804
|
-
>
|
|
805
|
-
{line.item.uom?.uom_group?.uoms?.map((uom: any) => (
|
|
806
|
-
<option key={uom.id} value={uom.id}>{uom.abbreviation}</option>
|
|
807
|
-
))}
|
|
808
|
-
</select>
|
|
809
|
-
</TCell>
|
|
810
|
-
)}
|
|
811
|
-
|
|
812
|
-
{config.showPrices && (
|
|
813
|
-
<TCell label="Rate" className="py-2.5 text-right relative">
|
|
814
|
-
<div className="flex items-center justify-end w-full gap-2">
|
|
815
|
-
{line.applied_price_rule?.price_list_name && (() => {
|
|
816
|
-
const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
|
|
817
|
-
const actualVariant = line.variant_id ? line.item.variants?.find((v: any) => v.id === line.variant_id) : (line.item.variants?.[0] || null);
|
|
818
|
-
const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
|
|
819
|
-
const batchPrice = ['sales', 'quotation', 'pos'].includes(config.type) && line.batches?.[0] ? (line.batches[0] as any)[batchPriceField] : undefined;
|
|
820
|
-
const basePrice = (batchPrice !== undefined && batchPrice !== null)
|
|
821
|
-
? Number(batchPrice)
|
|
822
|
-
: (actualVariant
|
|
823
|
-
? Number(actualVariant[variantPriceField] || line.item[config.priceField] || 0)
|
|
824
|
-
: Number(line.item[config.priceField] || 0));
|
|
825
|
-
const finalPrice = Number(line.applied_price_rule?.final_rate || 0);
|
|
826
|
-
if (basePrice === finalPrice) return null;
|
|
827
|
-
|
|
828
|
-
return (
|
|
829
|
-
<HintIcon
|
|
830
|
-
text={`Applied Rule: ${line.applied_price_rule?.price_list_name}\nBase Rate: ${basePrice.toFixed(2)}${
|
|
831
|
-
line.applied_price_rule?.rate ? `\nNew Rate: ${line.applied_price_rule?.rate}` :
|
|
832
|
-
line.applied_price_rule?.markdown_percentage ? `\nMarkdown: ${line.applied_price_rule?.markdown_percentage}%` : ''
|
|
833
|
-
}\nFinal Rate: ${Number(line.applied_price_rule?.final_rate || 0).toFixed(2)}`}
|
|
834
|
-
icon={<Tag size={15} className="text-primary-600 opacity-80 hover:opacity-100 transition-opacity" />}
|
|
835
|
-
/>
|
|
836
|
-
);
|
|
837
|
-
})()}
|
|
838
|
-
<input
|
|
839
|
-
type="text"
|
|
840
|
-
value={editingRates[line.uid] ?? Number(line.unit_price || 0).toFixed(2)}
|
|
841
|
-
onChange={(e) => {
|
|
842
|
-
const val = e.target.value;
|
|
843
|
-
if (/^\d*\.?\d{0,2}$/.test(val)) {
|
|
844
|
-
setEditingRates(prev => ({ ...prev, [line.uid]: val }));
|
|
845
|
-
updateLineInput(line.uid, 'unit_price', val);
|
|
846
|
-
}
|
|
847
|
-
}}
|
|
848
|
-
onBlur={() => {
|
|
849
|
-
setEditingRates(prev => {
|
|
850
|
-
const next = { ...prev };
|
|
851
|
-
delete next[line.uid];
|
|
852
|
-
return next;
|
|
853
|
-
});
|
|
854
|
-
// Clamp the discount amount if it exceeds the new rate
|
|
855
|
-
if (discountTypes[line.uid] !== 'percent') {
|
|
856
|
-
updateLineInput(line.uid, 'discount_amount', line.quantity > 0 ? line.discount_amount / line.quantity : 0);
|
|
857
|
-
}
|
|
858
|
-
}}
|
|
859
|
-
disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || isBatchPending}
|
|
860
|
-
className={cn(
|
|
861
|
-
"w-24 h-10 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",
|
|
862
|
-
(!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || isBatchPending) && "opacity-60 cursor-not-allowed bg-surface-hover"
|
|
782
|
+
: "Select Parent Line..."}
|
|
783
|
+
</span>
|
|
784
|
+
</button>
|
|
785
|
+
<HintIcon text="Select the original line item that this free quantity is associated with." />
|
|
786
|
+
</div>
|
|
787
|
+
)}
|
|
788
|
+
</div>
|
|
863
789
|
)}
|
|
864
|
-
|
|
790
|
+
</div>
|
|
865
791
|
</div>
|
|
866
792
|
</TCell>
|
|
867
|
-
|
|
793
|
+
<TCell label="Qty" className="py-2.5 text-center">
|
|
794
|
+
<input
|
|
795
|
+
type="number"
|
|
796
|
+
value={line.quantity}
|
|
797
|
+
onChange={(e) => updateLineInput(line.uid, 'quantity', e.target.value)}
|
|
798
|
+
className="w-20 h-10 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"
|
|
799
|
+
min={0.001}
|
|
800
|
+
step="any"
|
|
801
|
+
/>
|
|
802
|
+
</TCell>
|
|
868
803
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
className="h-full bg-surface-hover text-[11px] font-medium text-foreground-subtle border-r border-border-subtle pl-1.5 pr-0.5 focus:outline-none cursor-pointer disabled:cursor-not-allowed"
|
|
877
|
-
value={discountTypes[line.uid] || 'amount'}
|
|
878
|
-
onChange={(e) => {
|
|
879
|
-
setDiscountTypes(prev => ({ ...prev, [line.uid]: e.target.value as 'amount' | 'percent' }));
|
|
880
|
-
}}
|
|
881
|
-
disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || line.unit_price === 0 || isBatchPending}
|
|
804
|
+
{(config.enablePurchaseUnit || config.enableSalesUnit) && (
|
|
805
|
+
<TCell label="Unit" className="py-2.5">
|
|
806
|
+
<select
|
|
807
|
+
value={line.uom_id || line.item.uom_id || ''}
|
|
808
|
+
onChange={(e) => updateLineInput(line.uid, 'uom_id', e.target.value)}
|
|
809
|
+
disabled={line.item.tracking_type === 'serial' || isBatchPending}
|
|
810
|
+
className="h-10 px-2 text-[13px] font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors min-w-[80px] disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
|
|
882
811
|
>
|
|
883
|
-
|
|
884
|
-
|
|
812
|
+
{line.item.uom?.uom_group?.uoms?.map((uom: any) => (
|
|
813
|
+
<option key={uom.id} value={uom.id}>{uom.abbreviation}</option>
|
|
814
|
+
))}
|
|
885
815
|
</select>
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
value={(discountTypes[line.uid] === 'percent' ? line.discount_percent : (line.quantity > 0 ? Number((line.discount_amount / line.quantity).toFixed(2)) : 0)) || ''}
|
|
889
|
-
placeholder="0.00"
|
|
890
|
-
onChange={(e) => {
|
|
891
|
-
const val = e.target.value;
|
|
892
|
-
updateLineInput(line.uid, discountTypes[line.uid] === 'percent' ? 'discount_percent' : 'discount_amount', val ? Number(val) : 0);
|
|
893
|
-
}}
|
|
894
|
-
disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || line.unit_price === 0 || isBatchPending}
|
|
895
|
-
className="flex-1 w-full min-w-0 h-full px-2 text-[13px] text-right font-medium bg-transparent focus:outline-none disabled:cursor-not-allowed"
|
|
896
|
-
min={0}
|
|
897
|
-
step="any"
|
|
898
|
-
/>
|
|
899
|
-
</div>
|
|
900
|
-
</TCell>
|
|
901
|
-
)}
|
|
816
|
+
</TCell>
|
|
817
|
+
)}
|
|
902
818
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
819
|
+
{config.showPrices && (
|
|
820
|
+
<TCell label="Rate" className="py-2.5 text-right relative">
|
|
821
|
+
<div className="flex items-center justify-end w-full gap-2">
|
|
822
|
+
{line.applied_price_rule?.price_list_name && (() => {
|
|
823
|
+
const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
|
|
824
|
+
const actualVariant = line.variant_id ? line.item.variants?.find((v: any) => v.id === line.variant_id) : (line.item.variants?.[0] || null);
|
|
825
|
+
const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
|
|
826
|
+
const batchPrice = ['sales', 'quotation', 'pos'].includes(config.type) && line.batches?.[0] ? (line.batches[0] as any)[batchPriceField] : undefined;
|
|
827
|
+
const basePrice = (batchPrice !== undefined && batchPrice !== null)
|
|
828
|
+
? Number(batchPrice)
|
|
829
|
+
: (actualVariant
|
|
830
|
+
? Number(actualVariant[variantPriceField] || line.item[config.priceField] || 0)
|
|
831
|
+
: Number(line.item[config.priceField] || 0));
|
|
832
|
+
const finalPrice = Number(line.applied_price_rule?.final_rate || 0);
|
|
833
|
+
if (basePrice === finalPrice) return null;
|
|
908
834
|
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
835
|
+
return (
|
|
836
|
+
<HintIcon
|
|
837
|
+
text={`Applied Rule: ${line.applied_price_rule?.price_list_name}\nBase Rate: ${basePrice.toFixed(2)}${line.applied_price_rule?.rate ? `\nNew Rate: ${line.applied_price_rule?.rate}` :
|
|
838
|
+
line.applied_price_rule?.markdown_percentage ? `\nMarkdown: ${line.applied_price_rule?.markdown_percentage}%` : ''
|
|
839
|
+
}\nFinal Rate: ${Number(line.applied_price_rule?.final_rate || 0).toFixed(2)}`}
|
|
840
|
+
icon={<Tag size={15} className="text-primary-600 opacity-80 hover:opacity-100 transition-opacity" />}
|
|
841
|
+
/>
|
|
842
|
+
);
|
|
843
|
+
})()}
|
|
844
|
+
<input
|
|
845
|
+
type="text"
|
|
846
|
+
value={editingRates[line.uid] ?? Number(line.unit_price || 0).toFixed(2)}
|
|
847
|
+
onChange={(e) => {
|
|
848
|
+
const val = e.target.value;
|
|
849
|
+
if (/^\d*\.?\d{0,2}$/.test(val)) {
|
|
850
|
+
setEditingRates(prev => ({ ...prev, [line.uid]: val }));
|
|
851
|
+
updateLineInput(line.uid, 'unit_price', val);
|
|
852
|
+
}
|
|
853
|
+
}}
|
|
854
|
+
onBlur={() => {
|
|
855
|
+
setEditingRates(prev => {
|
|
856
|
+
const next = { ...prev };
|
|
857
|
+
delete next[line.uid];
|
|
858
|
+
return next;
|
|
859
|
+
});
|
|
860
|
+
// Clamp the discount amount if it exceeds the new rate
|
|
861
|
+
if (discountTypes[line.uid] !== 'percent') {
|
|
862
|
+
updateLineInput(line.uid, 'discount_amount', line.quantity > 0 ? line.discount_amount / line.quantity : 0);
|
|
863
|
+
}
|
|
864
|
+
}}
|
|
865
|
+
disabled={!config.allowEditPrice || line.is_free_qty || isBatchPending}
|
|
866
|
+
className={cn(
|
|
867
|
+
"w-24 h-10 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",
|
|
868
|
+
(!config.allowEditPrice || line.is_free_qty || isBatchPending) && "opacity-60 cursor-not-allowed bg-surface-hover"
|
|
869
|
+
)}
|
|
870
|
+
/>
|
|
871
|
+
</div>
|
|
872
|
+
</TCell>
|
|
873
|
+
)}
|
|
874
|
+
|
|
875
|
+
{showDiscountCol && (
|
|
876
|
+
<TCell label="Disc." className="py-2.5 text-right">
|
|
877
|
+
<div className={cn(
|
|
878
|
+
"flex items-center w-32 h-10 bg-surface-1 border border-border-subtle rounded-md overflow-hidden focus-within:border-primary/50 transition-colors ml-auto",
|
|
879
|
+
isDiscountDisabled && "opacity-60 bg-surface-hover"
|
|
880
|
+
)}>
|
|
881
|
+
<select
|
|
882
|
+
className="h-full bg-surface-hover text-[11px] font-medium text-foreground-subtle border-r border-border-subtle pl-1.5 pr-0.5 focus:outline-none cursor-pointer disabled:cursor-not-allowed"
|
|
883
|
+
value={discountTypes[line.uid] || 'amount'}
|
|
884
|
+
onChange={(e) => {
|
|
885
|
+
setDiscountTypes(prev => ({ ...prev, [line.uid]: e.target.value as 'amount' | 'percent' }));
|
|
886
|
+
}}
|
|
887
|
+
disabled={isDiscountDisabled}
|
|
888
|
+
>
|
|
889
|
+
<option value="amount">Amt</option>
|
|
890
|
+
<option value="percent">%</option>
|
|
891
|
+
</select>
|
|
892
|
+
<input
|
|
893
|
+
type="number"
|
|
894
|
+
value={(discountTypes[line.uid] === 'percent' ? line.discount_percent : (line.quantity > 0 ? Number((line.discount_amount / line.quantity).toFixed(2)) : 0)) || ''}
|
|
895
|
+
placeholder="0.00"
|
|
896
|
+
onChange={(e) => {
|
|
897
|
+
const val = e.target.value;
|
|
898
|
+
updateLineInput(line.uid, discountTypes[line.uid] === 'percent' ? 'discount_percent' : 'discount_amount', val ? Number(val) : 0);
|
|
899
|
+
}}
|
|
900
|
+
disabled={isDiscountDisabled}
|
|
901
|
+
className="flex-1 w-full min-w-0 h-full px-2 text-[13px] text-right font-medium bg-transparent focus:outline-none disabled:cursor-not-allowed"
|
|
902
|
+
min={0}
|
|
903
|
+
step="any"
|
|
904
|
+
/>
|
|
905
|
+
</div>
|
|
906
|
+
</TCell>
|
|
907
|
+
)}
|
|
908
|
+
|
|
909
|
+
{config.showPrices && (
|
|
910
|
+
<TCell label="Total" className="text-right py-2.5 font-semibold text-[13px] text-foreground-1">
|
|
911
|
+
{formatCurrency(line.line_total)}
|
|
912
|
+
</TCell>
|
|
913
|
+
)}
|
|
914
|
+
|
|
915
|
+
<TCell className="text-center py-2.5">
|
|
916
|
+
<button
|
|
917
|
+
onClick={() => onRemoveLine(line.uid)}
|
|
918
|
+
className="w-10 h-10 inline-flex items-center justify-center rounded-md text-foreground-disabled hover:bg-danger-alt/10 hover:text-danger-alt transition-colors"
|
|
919
|
+
title="Remove Item"
|
|
920
|
+
>
|
|
921
|
+
<Trash2 size={16} />
|
|
922
|
+
</button>
|
|
923
|
+
</TCell>
|
|
924
|
+
</TRow>
|
|
925
|
+
);
|
|
926
|
+
})}
|
|
921
927
|
</TBody>
|
|
922
928
|
</Table>
|
|
923
929
|
)}
|
|
@@ -937,13 +943,14 @@ export function ProductSelectionPanel({
|
|
|
937
943
|
warehouseId={warehouseId}
|
|
938
944
|
allowCreate={config.allowCreateBatchAndSerial}
|
|
939
945
|
initialSerials={lineItems.find(l => l.uid === activeSerialLineUid)?.serial_numbers || []}
|
|
946
|
+
initialSerialObjects={lineItems.find(l => l.uid === activeSerialLineUid)?.serial_number_objects || []}
|
|
940
947
|
onConfirm={(serials, serialObjects) => {
|
|
941
948
|
const line = lineItems.find(l => l.uid === activeSerialLineUid);
|
|
942
949
|
if (!line) return;
|
|
943
|
-
|
|
950
|
+
|
|
944
951
|
const newQty = serials.length;
|
|
945
952
|
const discAmt = (line.unit_price * newQty * line.discount_percent) / 100;
|
|
946
|
-
|
|
953
|
+
|
|
947
954
|
onUpdateLine(line.uid, {
|
|
948
955
|
serial_numbers: serials,
|
|
949
956
|
serial_number_objects: serialObjects,
|
|
@@ -1007,19 +1014,19 @@ export function ProductSelectionPanel({
|
|
|
1007
1014
|
handleBatchConfirm(selections);
|
|
1008
1015
|
} else if (activeBatchLineUid) {
|
|
1009
1016
|
const batchesData = selections.map(sel => ({
|
|
1010
|
-
batch_id: sel.batch.id > 0 ? sel.batch.id : null,
|
|
1017
|
+
batch_id: sel.batch.id > 0 ? sel.batch.id : null,
|
|
1011
1018
|
batch_number: sel.batch.batch_number,
|
|
1012
1019
|
manufacturing_date: sel.batch.manufacturing_date,
|
|
1013
1020
|
expiry_date: sel.batch.expiry_date,
|
|
1014
1021
|
supplier_reference: sel.batch.supplier_reference,
|
|
1015
1022
|
selling_price: sel.batch.selling_price,
|
|
1016
|
-
quantity: sel.quantity
|
|
1023
|
+
quantity: sel.quantity
|
|
1017
1024
|
}));
|
|
1018
1025
|
const line = lineItems.find(l => l.uid === activeBatchLineUid);
|
|
1019
1026
|
if (!line) return;
|
|
1020
1027
|
const newQty = batchesData.reduce((sum, b) => sum + b.quantity, 0);
|
|
1021
1028
|
const discAmt = (line.unit_price * newQty * line.discount_percent) / 100;
|
|
1022
|
-
|
|
1029
|
+
|
|
1023
1030
|
onUpdateLine(line.uid, {
|
|
1024
1031
|
batches: batchesData,
|
|
1025
1032
|
quantity: newQty > 0 ? newQty : 1,
|