@apptimate/ui 6.2.0 → 6.4.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.
@@ -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,208 @@ 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
- <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..."
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
- {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>
382
+ {isSearching && <Loader2 size={16} className="animate-spin text-primary shrink-0" />}
423
383
 
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
- onClick={() => {
431
- if (item.has_variants) {
432
- setExpandedItemIds(prev => prev.includes(item.id) ? prev.filter(id => id !== item.id) : [...prev, item.id]);
433
- } else {
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
- </div>
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
- {/* Product info */}
459
- <div className="flex-1 min-w-0">
460
- <div className="text-[13px] font-medium text-foreground-1 truncate">{item.name}</div>
461
- <div className="text-[11px] text-foreground-subtle flex items-center gap-2 mt-0.5">
462
- <span className="font-mono">{item.sku}</span>
463
- {item.has_variants && (
464
- <Badge variant="flat" color="default" size="small">
465
- {item.variants?.length || 0} variants
466
- </Badge>
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
+ onClick={() => {
431
+ if (item.has_variants) {
432
+ setExpandedItemIds(prev => prev.includes(item.id) ? prev.filter(id => id !== item.id) : [...prev, item.id]);
433
+ } else {
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" />
467
455
  )}
468
456
  </div>
469
- </div>
470
-
471
- {/* Price + stock + Chevron */}
472
- <div className="text-right shrink-0 flex items-center gap-3">
473
- <div className="flex flex-col items-end">
474
- {config.showPrices && (
475
- <div className="text-[13px] font-semibold text-foreground-1">
476
- {formatCurrency(item.variants?.[0]?.[config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price'] || 0)}
477
- </div>
478
- )}
479
- {config.showStock && item.stock && (
480
- <div className={cn(
481
- "text-[11px] font-medium",
482
- item.stock.qty_available > 0 ? "text-green-600" : "text-danger-alt"
483
- )}>
484
- {item.stock.qty_available} {item.uom?.abbreviation || 'pcs'}
485
- </div>
457
+
458
+ {/* Product info */}
459
+ <div className="flex-1 min-w-0">
460
+ <div className="text-[13px] font-medium text-foreground-1 truncate">{item.name}</div>
461
+ <div className="text-[11px] text-foreground-subtle flex items-center gap-2 mt-0.5">
462
+ <span className="font-mono">{item.sku}</span>
463
+ {item.has_variants && (
464
+ <Badge variant="flat" color="default" size="small">
465
+ {item.variants?.length || 0} variants
466
+ </Badge>
467
+ )}
468
+ </div>
469
+ </div>
470
+
471
+ {/* Price + stock + Chevron */}
472
+ <div className="text-right shrink-0 flex items-center gap-3">
473
+ <div className="flex flex-col items-end">
474
+ {config.showPrices && (
475
+ <div className="text-[13px] font-semibold text-foreground-1">
476
+ {formatCurrency(item.variants?.[0]?.[config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price'] || 0)}
477
+ </div>
478
+ )}
479
+ {config.showStock && item.stock && (
480
+ <div className={cn(
481
+ "text-[11px] font-medium",
482
+ item.stock.qty_available > 0 ? "text-green-600" : "text-danger-alt"
483
+ )}>
484
+ {item.stock.qty_available} {item.uom?.abbreviation || 'pcs'}
485
+ </div>
486
+ )}
487
+ </div>
488
+ {item.has_variants && (
489
+ <ChevronRight
490
+ size={16}
491
+ className={cn("text-foreground-disabled transition-transform", expandedItemIds.includes(item.id) && "rotate-90")}
492
+ />
486
493
  )}
487
494
  </div>
488
- {item.has_variants && (
489
- <ChevronRight
490
- size={16}
491
- className={cn("text-foreground-disabled transition-transform", expandedItemIds.includes(item.id) && "rotate-90")}
492
- />
493
- )}
494
- </div>
495
- </button>
496
-
497
- {/* Variants Tree */}
498
- {item.has_variants && expandedItemIds.includes(item.id) && (
499
- <div className="relative flex flex-col pb-2 bg-surface-0">
500
- {/* Vertical connecting line for the tree (aligned with thumbnail center at 34px) */}
501
- <div className="absolute left-[34px] top-0 bottom-[24px] w-px bg-border-subtle" />
502
-
503
- <div className="pl-[52px] pr-4 flex flex-col gap-0.5 mt-1">
504
- {item.variants?.map((variant: any) => (
505
- <button
506
- key={variant.id}
507
- id={`dropdown-item-variant-${variant.id}`}
508
- onClick={() => handleSelectItem(item, variant)}
509
- className={cn(
510
- "relative flex items-center justify-between py-2 px-3 rounded-[6px] hover:bg-surface-hover transition-colors text-left group cursor-pointer",
511
- focusedKey === `variant-${variant.id}` && "bg-primary/5"
512
- )}
513
- >
514
- {/* Horizontal connecting line to variant */}
515
- <div className="absolute left-[-18px] top-1/2 w-[18px] h-px bg-border-subtle" />
516
- <div className="flex flex-col">
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)}
495
+ </button>
496
+
497
+ {/* Variants Tree */}
498
+ {item.has_variants && expandedItemIds.includes(item.id) && (
499
+ <div className="relative flex flex-col pb-2 bg-surface-0">
500
+ {/* Vertical connecting line for the tree (aligned with thumbnail center at 34px) */}
501
+ <div className="absolute left-[34px] top-0 bottom-[24px] w-px bg-border-subtle" />
502
+
503
+ <div className="pl-[52px] pr-4 flex flex-col gap-0.5 mt-1">
504
+ {item.variants?.map((variant: any) => (
505
+ <button
506
+ key={variant.id}
507
+ id={`dropdown-item-variant-${variant.id}`}
508
+ onClick={() => handleSelectItem(item, variant)}
509
+ className={cn(
510
+ "relative flex items-center justify-between py-2 px-3 rounded-[6px] hover:bg-surface-hover transition-colors text-left group cursor-pointer",
511
+ focusedKey === `variant-${variant.id}` && "bg-primary/5"
512
+ )}
513
+ >
514
+ {/* Horizontal connecting line to variant */}
515
+ <div className="absolute left-[-18px] top-1/2 w-[18px] h-px bg-border-subtle" />
516
+ <div className="flex flex-col">
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>
528
523
  </div>
529
- )}
530
- {config.showStock && variant.stock && (
531
- <div className={cn(
532
- "text-[11.5px] font-medium min-w-[50px] text-right",
533
- variant.stock.qty_available > 0 ? "text-green-600" : "text-danger-alt"
534
- )}>
535
- {variant.stock.qty_available} {item.uom?.abbreviation || 'pcs'}
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)}
528
+ </div>
529
+ )}
530
+ {config.showStock && variant.stock && (
531
+ <div className={cn(
532
+ "text-[11.5px] font-medium min-w-[50px] text-right",
533
+ variant.stock.qty_available > 0 ? "text-green-600" : "text-danger-alt"
534
+ )}>
535
+ {variant.stock.qty_available} {item.uom?.abbreviation || 'pcs'}
536
+ </div>
537
+ )}
538
+ <span className="text-[11px] font-medium text-primary opacity-0 group-hover:opacity-100 transition-opacity w-[40px] text-right">
539
+ Select
540
+ </span>
536
541
  </div>
537
- )}
538
- <span className="text-[11px] font-medium text-primary opacity-0 group-hover:opacity-100 transition-opacity w-[40px] text-right">
539
- Select
540
- </span>
541
- </div>
542
- </button>
543
- ))}
544
- {(!item.variants || item.variants.length === 0) && (
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.
542
+ </button>
543
+ ))}
544
+ {(!item.variants || item.variants.length === 0) && (
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.
548
+ </div>
549
+ )}
548
550
  </div>
549
- )}
550
551
  </div>
551
- </div>
552
- )}
553
- </div>
554
- ))}
555
- </div>
556
- )}
552
+ )}
553
+ </div>
554
+ ))}
555
+ </div>
556
+ )}
557
557
 
558
- {showResults && searchResults.length === 0 && searchValue.length > 0 && !isSearching && (
559
- <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">
560
- <p className="text-[13px] text-foreground-disabled">No items found for &quot;{searchValue}&quot;</p>
561
- </div>
562
- )}
563
- </div>
558
+ {showResults && searchResults.length === 0 && searchValue.length > 0 && !isSearching && (
559
+ <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">
560
+ <p className="text-[13px] text-foreground-disabled">No items found for &quot;{searchValue}&quot;</p>
561
+ </div>
562
+ )}
563
+ </div>
564
564
  )}
565
565
 
566
566
  {/* ── Selected Items Table ── */}
@@ -588,174 +588,179 @@ export function ProductSelectionPanel({
588
588
  </THeader>
589
589
  <TBody>
590
590
  {lineItems.map((line) => {
591
- const isBatchPending = ['sales', 'quotation', 'pos'].includes(config.type) && line.item.tracking_type === 'batch' && (!line.batches || line.batches.length === 0);
591
+ const isSalesModule = ['sales', 'quotation', 'pos'].includes(config.type);
592
+ const isBatchPending = isSalesModule && line.item.tracking_type === 'batch' && (!line.batches || line.batches.length === 0);
593
+ const isDiscountDisabled = !config.allowEditPrice || line.is_free_qty || (isSalesModule && !line.item.is_discountable) || line.unit_price === 0 || isBatchPending;
592
594
  return (
593
- <TRow key={line.uid}>
594
- <TCell label="Item" className="py-2.5">
595
- <div className="flex items-start gap-3">
596
- <div className="w-8 h-8 rounded-[6px] bg-surface-hover flex items-center justify-center shrink-0 overflow-hidden mt-0.5">
597
- {line.item.primary_image?.url || line.item.image_url ? (
598
- <img src={line.item.primary_image?.url || line.item.image_url} alt={line.item.name} className="w-full h-full object-cover" />
599
- ) : (
600
- <Package size={14} className="text-foreground-disabled" />
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>
595
+ <TRow key={line.uid}>
596
+ <TCell label="Item" className="py-2.5">
597
+ <div className="flex items-start gap-3">
598
+ <div className="w-8 h-8 rounded-[6px] bg-surface-hover flex items-center justify-center shrink-0 overflow-hidden mt-0.5">
599
+ {line.item.primary_image?.url || line.item.image_url ? (
600
+ <img src={line.item.primary_image?.url || line.item.image_url} alt={line.item.name} className="w-full h-full object-cover" />
601
+ ) : (
602
+ <Package size={14} className="text-foreground-disabled" />
610
603
  )}
611
- </span>
612
- <span className="text-[11px] text-foreground-subtle font-mono mt-0.5 break-words">
613
- {(() => {
614
- if (line.item.has_variants && line.variant_id) {
615
- const variant = line.variant || line.item.variants?.find(v => v.id === line.variant_id);
616
- if (variant) {
617
- return `${variant.variant_name} • ${variant.sku}`;
604
+ </div>
605
+ <div className="flex flex-col flex-1 min-w-0">
606
+ <span className="text-[13px] font-semibold text-foreground-1 leading-tight break-words">
607
+ {line.item.name}
608
+ {line.is_free_qty && (
609
+ <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">
610
+ Free
611
+ </span>
612
+ )}
613
+ </span>
614
+ <span className="text-[11px] text-foreground-subtle font-mono mt-0.5 break-words">
615
+ {(() => {
616
+ if (line.item.has_variants && line.variant_id) {
617
+ const variant = line.variant || line.item.variants?.find(v => v.id === line.variant_id);
618
+ if (variant) {
619
+ return `${variant.variant_name} • ${variant.sku}`;
620
+ }
618
621
  }
619
- }
620
- return line.item.sku || '';
621
- })()}
622
- </span>
623
- {((!config.disableTrackingSelection && (line.item.tracking_type === 'batch' || line.item.tracking_type === 'serial')) || (line.item.tracking_type === 'batch' && ['sales', 'quotation', 'pos'].includes(config.type))) && (
624
- <div className="flex flex-wrap gap-1 mt-1.5">
625
- {line.item.tracking_type === 'batch' && (() => {
626
- if (['sales', 'quotation', 'pos'].includes(config.type)) {
627
- return (
628
- <div className="mt-1.5 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
629
- {(!line.batches || line.batches.length === 0) && (
630
- <AlertTriangle size={16} className="text-danger shrink-0" />
631
- )}
632
- <div className="w-56">
633
- <AsyncSearchableSelect
634
- controlClassName="!min-h-8 h-8 !py-1 text-sm"
635
- placeholder="Select Batch..."
636
- loadOptions={async (search) => {
637
- const params: any = {};
638
- if (line.variant_id) params.variant_id = line.variant_id;
639
- if (warehouseId) params.warehouse_id = warehouseId;
640
- const res = await getItemBatches(line.item.id, params);
641
- const batches = res?.result?.data || res?.result || [];
642
- return batches.filter((b: any) => b.batch_number.toLowerCase().includes(search.toLowerCase()));
643
- }}
644
- option={{
645
- label: 'batch_number',
646
- value: 'id',
647
- renderOption: (b: any) => (
648
- <div className="flex items-center justify-between w-full pr-1">
649
- <span>{b.batch_number}</span>
650
- {warehouseId ? (
651
- <span className="text-foreground-subtle text-[11px] font-mono" title="Available Stock">{b.qty_available ?? 0}</span>
652
- ) : null}
653
- </div>
654
- )
655
- }}
656
- 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}
657
- onChange={(val, selected: any) => {
658
- if (selected) {
659
- onUpdateLine(line.uid, {
660
- batches: [{ batch_id: selected.id, batch_number: selected.batch_number, selling_price: selected.selling_price, quantity: line.quantity }],
661
- unit_price: selected.selling_price || 0
662
- });
663
- } else {
664
- onUpdateLine(line.uid, { batches: [] });
665
- }
666
- }}
667
- />
622
+ return line.item.sku || '';
623
+ })()}
624
+ </span>
625
+ {((!config.disableTrackingSelection && (line.item.tracking_type === 'batch' || line.item.tracking_type === 'serial')) || (line.item.tracking_type === 'batch' && ['sales', 'quotation', 'pos'].includes(config.type))) && (
626
+ <div className="flex flex-wrap gap-1 mt-1.5">
627
+ {line.item.tracking_type === 'batch' && (() => {
628
+ if (['sales', 'quotation', 'pos'].includes(config.type)) {
629
+ return (
630
+ <div className="mt-1.5 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
631
+ {(!line.batches || line.batches.length === 0) && (
632
+ <AlertTriangle size={16} className="text-danger shrink-0" />
633
+ )}
634
+ <div className="w-56">
635
+ <AsyncSearchableSelect
636
+ controlClassName="!min-h-8 h-8 !py-1 text-sm"
637
+ placeholder="Select Batch..."
638
+ loadOptions={async (search) => {
639
+ const params: any = {};
640
+ if (line.variant_id) params.variant_id = line.variant_id;
641
+ if (warehouseId) params.warehouse_id = warehouseId;
642
+ const res = await getItemBatches(line.item.id, params);
643
+ const batches = res?.result?.data || res?.result || [];
644
+ return batches.filter((b: any) => b.batch_number.toLowerCase().includes(search.toLowerCase()));
645
+ }}
646
+ option={{
647
+ label: 'batch_number',
648
+ value: 'id',
649
+ renderOption: (b: any) => (
650
+ <div className="flex items-center justify-between w-full pr-1">
651
+ <span>{b.batch_number}</span>
652
+ {warehouseId ? (
653
+ <span className="text-foreground-subtle text-[11px] font-mono" title="Available Stock">{b.qty_available ?? 0}</span>
654
+ ) : null}
655
+ </div>
656
+ )
657
+ }}
658
+ 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}
659
+ onChange={(val, selected: any) => {
660
+ if (selected) {
661
+ onUpdateLine(line.uid, {
662
+ batches: [{ batch_id: selected.id, batch_number: selected.batch_number, selling_price: selected.selling_price, quantity: line.quantity }],
663
+ unit_price: selected.selling_price || 0
664
+ });
665
+ } else {
666
+ onUpdateLine(line.uid, { batches: [] });
667
+ }
668
+ }}
669
+ />
670
+ </div>
668
671
  </div>
669
- </div>
670
- );
671
- }
672
+ );
673
+ }
672
674
 
673
- const batchesCount = line.batches?.length || 0;
674
- const selectedTotal = line.batches?.reduce((sum, b) => sum + b.quantity, 0) || 0;
675
- const isMismatch = selectedTotal !== line.quantity;
676
- return (
677
- <button
678
- onClick={() => setActiveBatchLineUid(line.uid)}
679
- className={cn(
680
- "mt-1 flex items-center gap-1.5 text-[11.5px] font-medium transition-colors hover:underline text-left",
681
- isMismatch
682
- ? "text-warning-600 hover:text-warning-700"
683
- : "text-primary"
684
- )}
685
- >
686
- {batchesCount > 0 ? (
687
- isMismatch ? (
688
- <><AlertTriangle size={12} className="mb-[1px]" /> <span>{selectedTotal} / {line.quantity} Selected</span></>
675
+ const batchesCount = line.batches?.length || 0;
676
+ const selectedTotal = line.batches?.reduce((sum, b) => sum + b.quantity, 0) || 0;
677
+ const isMismatch = selectedTotal !== line.quantity;
678
+ return (
679
+ <button
680
+ type="button"
681
+ onClick={() => setActiveBatchLineUid(line.uid)}
682
+ className={cn(
683
+ "mt-1 flex items-center gap-1.5 text-[11.5px] font-medium transition-colors hover:underline text-left",
684
+ isMismatch
685
+ ? "text-warning-600 hover:text-warning-700"
686
+ : "text-primary"
687
+ )}
688
+ >
689
+ {batchesCount > 0 ? (
690
+ isMismatch ? (
691
+ <><AlertTriangle size={12} className="mb-[1px]" /> <span>{selectedTotal} / {line.quantity} Selected</span></>
692
+ ) : (
693
+ <>✓ {batchesCount} Batches Selected</>
694
+ )
689
695
  ) : (
690
- <>✓ {batchesCount} Batches Selected</>
691
- )
692
- ) : (
693
- <><AlertTriangle size={12} className="mb-[1px]" /> <span>Select Batches</span></>
694
- )}
695
- </button>
696
- );
697
- })()}
698
- {line.item.tracking_type === 'serial' && (() => {
699
- const selectedCount = line.serial_numbers?.length || 0;
700
- const isMismatch = selectedCount !== line.quantity;
701
- return (
702
- <button
703
- onClick={() => setActiveSerialLineUid(line.uid)}
704
- className={cn(
705
- "mt-1 flex items-center gap-1.5 text-[11.5px] font-medium transition-colors hover:underline text-left",
706
- isMismatch
707
- ? "text-warning-600 hover:text-warning-700"
708
- : "text-primary"
709
- )}
710
- >
711
- {selectedCount > 0 ? (
712
- isMismatch ? (
713
- <><AlertTriangle size={12} className="mb-[1px]" /> <span>{selectedCount} / {line.quantity} Serials Selected</span></>
696
+ <><AlertTriangle size={12} className="mb-[1px]" /> <span>Select Batches</span></>
697
+ )}
698
+ </button>
699
+ );
700
+ })()}
701
+ {line.item.tracking_type === 'serial' && (() => {
702
+ const selectedCount = line.serial_numbers?.length || 0;
703
+ const isMismatch = selectedCount !== line.quantity;
704
+ return (
705
+ <button
706
+ type="button"
707
+ onClick={() => setActiveSerialLineUid(line.uid)}
708
+ className={cn(
709
+ "mt-1 flex items-center gap-1.5 text-[11.5px] font-medium transition-colors hover:underline text-left",
710
+ isMismatch
711
+ ? "text-warning-600 hover:text-warning-700"
712
+ : "text-primary"
713
+ )}
714
+ >
715
+ {selectedCount > 0 ? (
716
+ isMismatch ? (
717
+ <><AlertTriangle size={12} className="mb-[1px]" /> <span>{selectedCount} / {line.quantity} Serials Selected</span></>
718
+ ) : (
719
+ <>✓ {selectedCount} Serials Selected</>
720
+ )
714
721
  ) : (
715
- <>✓ {selectedCount} Serials Selected</>
716
- )
717
- ) : (
718
- <><AlertTriangle size={12} className="mb-[1px]" /> <span>Select Serial Numbers</span></>
719
- )}
720
- </button>
721
- );
722
- })()}
723
- </div>
724
- )}
725
- {Number(line.unit_price) === 0 && (
726
- <div className="mt-2 flex items-center gap-2">
727
- <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")}>
728
- <input
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)}
722
+ <><AlertTriangle size={12} className="mb-[1px]" /> <span>Select Serial Numbers</span></>
723
+ )}
724
+ </button>
725
+ );
726
+ })()}
727
+ </div>
728
+ )}
729
+ {Number(line.unit_price) === 0 && (
730
+ <div className="mt-2 flex items-center gap-2">
731
+ <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")}>
732
+ <input
733
+ type="checkbox"
734
+ checked={line.is_free_qty || false}
735
+ onChange={(e) => handleToggleFreeQty(line.uid, e.target.checked)}
750
736
  disabled={!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id)}
751
737
  className={cn(
752
- "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",
753
- !!(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"
738
+ "rounded border-border-subtle text-primary h-3 w-3",
739
+ !!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id) ? "opacity-50 cursor-not-allowed" : "focus:ring-primary"
754
740
  )}
755
- >
756
- <span>
757
- {line.parent_line_uids && line.parent_line_uids.length > 0
758
- ? (() => {
741
+ />
742
+ <span className={cn(
743
+ "text-[11.5px] font-medium transition-colors",
744
+ !!(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"
745
+ )}>
746
+ Free Quantity
747
+ </span>
748
+ </label>
749
+
750
+ {line.is_free_qty && (
751
+ <div className="flex items-center">
752
+ <button
753
+ type="button"
754
+ onClick={() => setActiveParentLineUidFor(line.uid)}
755
+ disabled={!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id)}
756
+ className={cn(
757
+ "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",
758
+ !!(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"
759
+ )}
760
+ >
761
+ <span>
762
+ {line.parent_line_uids && line.parent_line_uids.length > 0
763
+ ? (() => {
759
764
  if (line.parent_line_uids.length === 1) {
760
765
  const p = lineItems.find((l) => l.uid === line.parent_line_uids?.[0]);
761
766
  if (p) {
@@ -772,152 +777,151 @@ export function ProductSelectionPanel({
772
777
  return `${line.parent_line_uids.length} Parents Selected`;
773
778
  }
774
779
  })()
775
- : "Select Parent Line..."}
776
- </span>
777
- </button>
778
- <HintIcon text="Select the original line item that this free quantity is associated with." />
779
- </div>
780
- )}
781
- </div>
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"
780
+ : "Select Parent Line..."}
781
+ </span>
782
+ </button>
783
+ <HintIcon text="Select the original line item that this free quantity is associated with." />
784
+ </div>
785
+ )}
786
+ </div>
863
787
  )}
864
- />
788
+ </div>
865
789
  </div>
866
790
  </TCell>
867
- )}
791
+ <TCell label="Qty" className="py-2.5 text-center">
792
+ <input
793
+ type="number"
794
+ value={line.quantity}
795
+ onChange={(e) => updateLineInput(line.uid, 'quantity', e.target.value)}
796
+ 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"
797
+ min={0.001}
798
+ step="any"
799
+ />
800
+ </TCell>
868
801
 
869
- {showDiscountCol && (
870
- <TCell label="Disc." className="py-2.5 text-right">
871
- <div className={cn(
872
- "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",
873
- (!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || line.unit_price === 0 || isBatchPending) && "opacity-60 bg-surface-hover"
874
- )}>
875
- <select
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}
802
+ {(config.enablePurchaseUnit || config.enableSalesUnit) && (
803
+ <TCell label="Unit" className="py-2.5">
804
+ <select
805
+ value={line.uom_id || line.item.uom_id || ''}
806
+ onChange={(e) => updateLineInput(line.uid, 'uom_id', e.target.value)}
807
+ disabled={line.item.tracking_type === 'serial' || isBatchPending}
808
+ 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
809
  >
883
- <option value="amount">Amt</option>
884
- <option value="percent">%</option>
810
+ {line.item.uom?.uom_group?.uoms?.map((uom: any) => (
811
+ <option key={uom.id} value={uom.id}>{uom.abbreviation}</option>
812
+ ))}
885
813
  </select>
886
- <input
887
- type="number"
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
- )}
814
+ </TCell>
815
+ )}
902
816
 
903
- {config.showPrices && (
904
- <TCell label="Total" className="text-right py-2.5 font-semibold text-[13px] text-foreground-1">
905
- {formatCurrency(line.line_total)}
906
- </TCell>
907
- )}
817
+ {config.showPrices && (
818
+ <TCell label="Rate" className="py-2.5 text-right relative">
819
+ <div className="flex items-center justify-end w-full gap-2">
820
+ {line.applied_price_rule?.price_list_name && (() => {
821
+ const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
822
+ const actualVariant = line.variant_id ? line.item.variants?.find((v: any) => v.id === line.variant_id) : (line.item.variants?.[0] || null);
823
+ const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
824
+ const batchPrice = ['sales', 'quotation', 'pos'].includes(config.type) && line.batches?.[0] ? (line.batches[0] as any)[batchPriceField] : undefined;
825
+ const basePrice = (batchPrice !== undefined && batchPrice !== null)
826
+ ? Number(batchPrice)
827
+ : (actualVariant
828
+ ? Number(actualVariant[variantPriceField] || line.item[config.priceField] || 0)
829
+ : Number(line.item[config.priceField] || 0));
830
+ const finalPrice = Number(line.applied_price_rule?.final_rate || 0);
831
+ if (basePrice === finalPrice) return null;
908
832
 
909
- <TCell className="text-center py-2.5">
910
- <button
911
- onClick={() => onRemoveLine(line.uid)}
912
- 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"
913
- title="Remove Item"
914
- >
915
- <Trash2 size={16} />
916
- </button>
917
- </TCell>
918
- </TRow>
919
- );
920
- })}
833
+ return (
834
+ <HintIcon
835
+ 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}` :
836
+ line.applied_price_rule?.markdown_percentage ? `\nMarkdown: ${line.applied_price_rule?.markdown_percentage}%` : ''
837
+ }\nFinal Rate: ${Number(line.applied_price_rule?.final_rate || 0).toFixed(2)}`}
838
+ icon={<Tag size={15} className="text-primary-600 opacity-80 hover:opacity-100 transition-opacity" />}
839
+ />
840
+ );
841
+ })()}
842
+ <input
843
+ type="text"
844
+ value={editingRates[line.uid] ?? Number(line.unit_price || 0).toFixed(2)}
845
+ onChange={(e) => {
846
+ const val = e.target.value;
847
+ if (/^\d*\.?\d{0,2}$/.test(val)) {
848
+ setEditingRates(prev => ({ ...prev, [line.uid]: val }));
849
+ updateLineInput(line.uid, 'unit_price', val);
850
+ }
851
+ }}
852
+ onBlur={() => {
853
+ setEditingRates(prev => {
854
+ const next = { ...prev };
855
+ delete next[line.uid];
856
+ return next;
857
+ });
858
+ // Clamp the discount amount if it exceeds the new rate
859
+ if (discountTypes[line.uid] !== 'percent') {
860
+ updateLineInput(line.uid, 'discount_amount', line.quantity > 0 ? line.discount_amount / line.quantity : 0);
861
+ }
862
+ }}
863
+ disabled={!config.allowEditPrice || line.is_free_qty || isBatchPending}
864
+ className={cn(
865
+ "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",
866
+ (!config.allowEditPrice || line.is_free_qty || isBatchPending) && "opacity-60 cursor-not-allowed bg-surface-hover"
867
+ )}
868
+ />
869
+ </div>
870
+ </TCell>
871
+ )}
872
+
873
+ {showDiscountCol && (
874
+ <TCell label="Disc." className="py-2.5 text-right">
875
+ <div className={cn(
876
+ "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",
877
+ isDiscountDisabled && "opacity-60 bg-surface-hover"
878
+ )}>
879
+ <select
880
+ 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"
881
+ value={discountTypes[line.uid] || 'amount'}
882
+ onChange={(e) => {
883
+ setDiscountTypes(prev => ({ ...prev, [line.uid]: e.target.value as 'amount' | 'percent' }));
884
+ }}
885
+ disabled={isDiscountDisabled}
886
+ >
887
+ <option value="amount">Amt</option>
888
+ <option value="percent">%</option>
889
+ </select>
890
+ <input
891
+ type="number"
892
+ value={(discountTypes[line.uid] === 'percent' ? line.discount_percent : (line.quantity > 0 ? Number((line.discount_amount / line.quantity).toFixed(2)) : 0)) || ''}
893
+ placeholder="0.00"
894
+ onChange={(e) => {
895
+ const val = e.target.value;
896
+ updateLineInput(line.uid, discountTypes[line.uid] === 'percent' ? 'discount_percent' : 'discount_amount', val ? Number(val) : 0);
897
+ }}
898
+ disabled={isDiscountDisabled}
899
+ 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"
900
+ min={0}
901
+ step="any"
902
+ />
903
+ </div>
904
+ </TCell>
905
+ )}
906
+
907
+ {config.showPrices && (
908
+ <TCell label="Total" className="text-right py-2.5 font-semibold text-[13px] text-foreground-1">
909
+ {formatCurrency(line.line_total)}
910
+ </TCell>
911
+ )}
912
+
913
+ <TCell className="text-center py-2.5">
914
+ <button
915
+ onClick={() => onRemoveLine(line.uid)}
916
+ 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"
917
+ title="Remove Item"
918
+ >
919
+ <Trash2 size={16} />
920
+ </button>
921
+ </TCell>
922
+ </TRow>
923
+ );
924
+ })}
921
925
  </TBody>
922
926
  </Table>
923
927
  )}
@@ -937,13 +941,14 @@ export function ProductSelectionPanel({
937
941
  warehouseId={warehouseId}
938
942
  allowCreate={config.allowCreateBatchAndSerial}
939
943
  initialSerials={lineItems.find(l => l.uid === activeSerialLineUid)?.serial_numbers || []}
944
+ initialSerialObjects={lineItems.find(l => l.uid === activeSerialLineUid)?.serial_number_objects || []}
940
945
  onConfirm={(serials, serialObjects) => {
941
946
  const line = lineItems.find(l => l.uid === activeSerialLineUid);
942
947
  if (!line) return;
943
-
948
+
944
949
  const newQty = serials.length;
945
950
  const discAmt = (line.unit_price * newQty * line.discount_percent) / 100;
946
-
951
+
947
952
  onUpdateLine(line.uid, {
948
953
  serial_numbers: serials,
949
954
  serial_number_objects: serialObjects,
@@ -1007,19 +1012,19 @@ export function ProductSelectionPanel({
1007
1012
  handleBatchConfirm(selections);
1008
1013
  } else if (activeBatchLineUid) {
1009
1014
  const batchesData = selections.map(sel => ({
1010
- batch_id: sel.batch.id > 0 ? sel.batch.id : null,
1015
+ batch_id: sel.batch.id > 0 ? sel.batch.id : null,
1011
1016
  batch_number: sel.batch.batch_number,
1012
1017
  manufacturing_date: sel.batch.manufacturing_date,
1013
1018
  expiry_date: sel.batch.expiry_date,
1014
1019
  supplier_reference: sel.batch.supplier_reference,
1015
1020
  selling_price: sel.batch.selling_price,
1016
- quantity: sel.quantity
1021
+ quantity: sel.quantity
1017
1022
  }));
1018
1023
  const line = lineItems.find(l => l.uid === activeBatchLineUid);
1019
1024
  if (!line) return;
1020
1025
  const newQty = batchesData.reduce((sum, b) => sum + b.quantity, 0);
1021
1026
  const discAmt = (line.unit_price * newQty * line.discount_percent) / 100;
1022
-
1027
+
1023
1028
  onUpdateLine(line.uid, {
1024
1029
  batches: batchesData,
1025
1030
  quantity: newQty > 0 ? newQty : 1,