@apptimate/ui 4.5.0 → 4.7.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.
@@ -4,7 +4,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
4
4
  import { cn } from '@apptimate/core-lib';
5
5
  import {
6
6
  Search, ScanBarcode, X, ShoppingCart, Package, Loader2,
7
- Trash2, ChevronDown, ChevronRight, AlertTriangle
7
+ Trash2, ChevronDown, ChevronRight, AlertTriangle, Tag
8
8
  } from 'lucide-react';
9
9
  import { Badge } from '../../base-components/Badge';
10
10
  import { Table, THeader, TBody, TRow, TCell } from '../../base-components/Table';
@@ -13,10 +13,12 @@ import type { TransactionItem, TransactionLineItem, TransactionScreenConfig } fr
13
13
  import { BatchSelectionModal, SerialSelectionModal } from "../pickers";
14
14
  import { ParentLineSelectionModal } from "./ParentLineSelectionModal";
15
15
  import { HintIcon } from '../../base-components/HintIcon';
16
+ import { AsyncSearchableSelect } from '../../base-components/SearchableSelect';
16
17
 
17
18
  interface ProductSelectionPanelProps {
18
19
  config: TransactionScreenConfig;
19
20
  warehouseId?: number;
21
+ partyId?: number | null;
20
22
  lineItems: TransactionLineItem[];
21
23
  onAddItem: (item: TransactionItem, variant?: any, extraData?: { quantity?: number; batches?: any[]; serial_numbers?: string[] }) => void;
22
24
  onUpdateLine: (uid: string, updates: Partial<TransactionLineItem>) => void;
@@ -27,6 +29,7 @@ interface ProductSelectionPanelProps {
27
29
  export function ProductSelectionPanel({
28
30
  config,
29
31
  warehouseId,
32
+ partyId,
30
33
  lineItems,
31
34
  onAddItem,
32
35
  onUpdateLine,
@@ -40,7 +43,7 @@ export function ProductSelectionPanel({
40
43
  const [expandedItemIds, setExpandedItemIds] = useState<number[]>([]);
41
44
  const [editingRates, setEditingRates] = useState<Record<string, string>>({});
42
45
  const [focusedIndex, setFocusedIndex] = useState<number>(-1);
43
-
46
+ const [discountTypes, setDiscountTypes] = useState<Record<string, 'amount' | 'percent'>>({});
44
47
  const [activeBatchItem, setActiveBatchItem] = useState<{ item: TransactionItem; variant?: any; preSelectedBatch?: any } | null>(null);
45
48
  const [activeBatchLineUid, setActiveBatchLineUid] = useState<string | null>(null);
46
49
  const [activeSerialItem, setActiveSerialItem] = useState<{ item: TransactionItem; variant?: any } | null>(null);
@@ -51,6 +54,7 @@ export function ProductSelectionPanel({
51
54
  const panelRef = React.useRef<HTMLDivElement>(null);
52
55
  const inputRef = React.useRef<HTMLInputElement>(null);
53
56
 
57
+ const showDiscountCol = config.showPrices && ['sales', 'quotation', 'pos'].includes(config.type);
54
58
 
55
59
  // ── Search handler ──
56
60
  const handleSearch = useCallback(
@@ -69,6 +73,13 @@ export function ProductSelectionPanel({
69
73
  try {
70
74
  const params: Record<string, string | number> = { search: value, limit: 20, type: config.type };
71
75
  if (warehouseId) params.warehouse_id = warehouseId;
76
+
77
+ if (['sales', 'quotation', 'pos'].includes(config.type)) {
78
+ params.apply_sales_prices = 1;
79
+ params.transaction_type = config.type;
80
+ if (partyId) params.party_id = partyId;
81
+ }
82
+
72
83
  const res = await transactionLookup(params);
73
84
  if (res.is_success && res.result) {
74
85
  const payload = res.result as any;
@@ -84,8 +95,15 @@ export function ProductSelectionPanel({
84
95
  if (matchType === 'serial') {
85
96
  onAddItem(entity.item, entity.variant, { serial_numbers: [entity.serial_number], quantity: 1 });
86
97
  } else if (matchType === 'batch') {
87
- const batchVariant = entity.variant_id && entity.item?.variants ? entity.item.variants.find((v: any) => v.id === entity.variant_id) : null;
88
- setActiveBatchItem({ item: entity.item, variant: batchVariant, preSelectedBatch: entity });
98
+ const batchVariant = entity.variant || (entity.variant_id && entity.item?.variants ? entity.item.variants.find((v: any) => v.id === entity.variant_id) : null);
99
+ if (['sales', 'quotation'].includes(config.type)) {
100
+ onAddItem(entity.item, batchVariant, {
101
+ batches: [{ batch_id: entity.id, batch_number: entity.batch_number, selling_price: entity.selling_price, quantity: 1 }],
102
+ quantity: 1
103
+ });
104
+ } else {
105
+ setActiveBatchItem({ item: entity.item, variant: batchVariant, preSelectedBatch: entity });
106
+ }
89
107
  } else if (matchType === 'variant') {
90
108
  handleSelectItem(entity.item, entity);
91
109
  } else if (matchType === 'item') {
@@ -103,7 +121,7 @@ export function ProductSelectionPanel({
103
121
  }
104
122
  }, 250);
105
123
  },
106
- [warehouseId, autoAddEnabled, config.type]
124
+ [warehouseId, autoAddEnabled, config.type, partyId]
107
125
  );
108
126
 
109
127
  // ── Select item → check tracking type or add to cart ──
@@ -114,14 +132,18 @@ export function ProductSelectionPanel({
114
132
  setShowResults(false);
115
133
 
116
134
  if (!config.disableTrackingSelection && item.tracking_type === 'batch') {
117
- setActiveBatchItem({ item, variant });
135
+ if (['sales', 'quotation'].includes(config.type)) {
136
+ onAddItem(item, variant);
137
+ } else {
138
+ setActiveBatchItem({ item, variant });
139
+ }
118
140
  } else if (!config.disableTrackingSelection && item.tracking_type === 'serial') {
119
141
  setActiveSerialItem({ item, variant });
120
142
  } else {
121
143
  onAddItem(item, variant);
122
144
  }
123
145
  },
124
- [onAddItem]
146
+ [onAddItem, config.type, config.disableTrackingSelection]
125
147
  );
126
148
 
127
149
  const handleBatchConfirm = (selections: { batch: any; quantity: number }[]) => {
@@ -227,18 +249,38 @@ export function ProductSelectionPanel({
227
249
  }
228
250
  };
229
251
 
230
- const updateLineInput = (uid: string, field: 'quantity' | 'unit_price' | 'free_qty' | 'uom_id', value: string) => {
252
+ const updateLineInput = (uid: string, field: 'quantity' | 'unit_price' | 'free_qty' | 'uom_id' | 'discount_amount' | 'discount_percent', value: string | number) => {
231
253
  const num = value === '' ? 0 : Number(value);
232
254
  const line = lineItems.find(l => l.uid === uid);
233
255
  if (!line) return;
234
256
 
235
257
  let qty = line.quantity;
236
258
  let price = line.unit_price;
259
+ let discAmt = line.discount_amount;
260
+ let discPercent = line.discount_percent;
237
261
 
238
262
  if (field === 'quantity') qty = Math.max(0.001, num);
239
263
  if (field === 'unit_price') price = Math.max(0, num);
240
-
241
- const discAmt = (price * qty * line.discount_percent) / 100;
264
+ if (field === 'discount_amount') {
265
+ const maxDiscount = price;
266
+ const unitDiscount = Math.min(Math.max(0, num), maxDiscount);
267
+ discAmt = unitDiscount * qty;
268
+ discPercent = (price * qty) > 0 ? (discAmt * 100) / (price * qty) : 0;
269
+ } else if (field === 'discount_percent') {
270
+ discPercent = Math.min(Math.max(0, num), 100);
271
+ discAmt = (price * qty * discPercent) / 100;
272
+ } else {
273
+ // If price or qty changes, check which discount type is active
274
+ const activeDiscountType = discountTypes[uid] || 'amount';
275
+
276
+ if (activeDiscountType === 'percent') {
277
+ // Preserve percentage, recalculate amount
278
+ discAmt = (price * qty * discPercent) / 100;
279
+ } else {
280
+ // Preserve amount (we will clamp on blur to avoid ruining it while typing), recalculate percentage
281
+ discPercent = (price * qty) > 0 ? (discAmt * 100) / (price * qty) : 0;
282
+ }
283
+ }
242
284
 
243
285
  if (field === 'uom_id') {
244
286
  const newUomId = Number(value);
@@ -260,8 +302,6 @@ export function ProductSelectionPanel({
260
302
 
261
303
  const newDiscAmt = (newPrice * line.quantity * line.discount_percent) / 100;
262
304
 
263
- setEditingRates(prev => ({ ...prev, [uid]: newPrice.toFixed(2) }));
264
-
265
305
  onUpdateLine(uid, {
266
306
  uom_id: newUomId,
267
307
  unit_price: newPrice,
@@ -275,6 +315,7 @@ export function ProductSelectionPanel({
275
315
  quantity: qty,
276
316
  unit_price: price,
277
317
  discount_amount: discAmt,
318
+ discount_percent: discPercent,
278
319
  line_total: price * qty - discAmt,
279
320
  });
280
321
  };
@@ -531,15 +572,18 @@ export function ProductSelectionPanel({
531
572
  <THeader>
532
573
  <TRow>
533
574
  <TCell isHeader className="w-[45%]">Item</TCell>
534
- <TCell isHeader>Qty</TCell>
535
- {config.enablePurchaseUnit && <TCell isHeader>Unit</TCell>}
536
- {config.showPrices && <TCell isHeader>Rate</TCell>}
575
+ <TCell isHeader className="text-center">Qty</TCell>
576
+ {(config.enablePurchaseUnit || config.enableSalesUnit) && <TCell isHeader>Unit</TCell>}
577
+ {config.showPrices && <TCell isHeader className="text-right">Rate</TCell>}
578
+ {showDiscountCol && <TCell isHeader className="text-right">Disc.</TCell>}
537
579
  {config.showPrices && <TCell isHeader className="text-right">Total</TCell>}
538
580
  <TCell isHeader className="w-10 text-center"></TCell>
539
581
  </TRow>
540
582
  </THeader>
541
583
  <TBody>
542
- {lineItems.map((line) => (
584
+ {lineItems.map((line) => {
585
+ const isBatchPending = ['sales', 'quotation'].includes(config.type) && line.item.tracking_type === 'batch' && (!line.batches || line.batches.length === 0);
586
+ return (
543
587
  <TRow key={line.uid}>
544
588
  <TCell label="Item" className="py-2.5">
545
589
  <div className="flex items-start gap-3">
@@ -564,9 +608,56 @@ export function ProductSelectionPanel({
564
608
  ? `${line.item.variants.find(v => v.id === line.variant_id)?.variant_name} • ${line.item.variants.find(v => v.id === line.variant_id)?.sku}`
565
609
  : line.item.sku}
566
610
  </span>
567
- {!config.disableTrackingSelection && (line.item.tracking_type === 'batch' || line.item.tracking_type === 'serial') && (
611
+ {((!config.disableTrackingSelection && (line.item.tracking_type === 'batch' || line.item.tracking_type === 'serial')) || (line.item.tracking_type === 'batch' && ['sales', 'quotation'].includes(config.type))) && (
568
612
  <div className="flex flex-wrap gap-1 mt-1.5">
569
613
  {line.item.tracking_type === 'batch' && (() => {
614
+ if (['sales', 'quotation'].includes(config.type)) {
615
+ return (
616
+ <div className="mt-1.5 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
617
+ {(!line.batches || line.batches.length === 0) && (
618
+ <AlertTriangle size={16} className="text-danger shrink-0" />
619
+ )}
620
+ <div className="w-56">
621
+ <AsyncSearchableSelect
622
+ controlClassName="!min-h-8 h-8 !py-1 text-sm"
623
+ placeholder="Select Batch..."
624
+ loadOptions={async (search) => {
625
+ const params: any = {};
626
+ if (line.variant_id) params.variant_id = line.variant_id;
627
+ if (warehouseId) params.warehouse_id = warehouseId;
628
+ const res = await getItemBatches(line.item.id, params);
629
+ const batches = res?.result?.data || res?.result || [];
630
+ return batches.filter((b: any) => b.batch_number.toLowerCase().includes(search.toLowerCase()));
631
+ }}
632
+ option={{
633
+ label: 'batch_number',
634
+ value: 'id',
635
+ renderOption: (b: any) => (
636
+ <div className="flex items-center justify-between w-full pr-1">
637
+ <span>{b.batch_number}</span>
638
+ {warehouseId ? (
639
+ <span className="text-foreground-subtle text-[11px] font-mono" title="Available Stock">{b.qty_available ?? 0}</span>
640
+ ) : null}
641
+ </div>
642
+ )
643
+ }}
644
+ 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}
645
+ onChange={(val, selected: any) => {
646
+ if (selected) {
647
+ onUpdateLine(line.uid, {
648
+ batches: [{ batch_id: selected.id, batch_number: selected.batch_number, selling_price: selected.selling_price, quantity: line.quantity }],
649
+ unit_price: selected.selling_price || 0
650
+ });
651
+ } else {
652
+ onUpdateLine(line.uid, { batches: [] });
653
+ }
654
+ }}
655
+ />
656
+ </div>
657
+ </div>
658
+ );
659
+ }
660
+
570
661
  const batchesCount = line.batches?.length || 0;
571
662
  const selectedTotal = line.batches?.reduce((sum, b) => sum + b.quantity, 0) || 0;
572
663
  const isMismatch = selectedTotal !== line.quantity;
@@ -619,22 +710,22 @@ export function ProductSelectionPanel({
619
710
  })()}
620
711
  </div>
621
712
  )}
622
- {(config.type === 'purchase' || config.type === 'grn') && Number(line.unit_price) === 0 && (
713
+ {Number(line.unit_price) === 0 && (
623
714
  <div className="mt-2 flex items-center gap-2">
624
- <label className={cn("flex items-center gap-1.5", !!line.purchase_order_line_id ? "cursor-not-allowed" : "cursor-pointer")}>
715
+ <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")}>
625
716
  <input
626
717
  type="checkbox"
627
718
  checked={line.is_free_qty || false}
628
719
  onChange={(e) => handleToggleFreeQty(line.uid, e.target.checked)}
629
- disabled={!!line.purchase_order_line_id}
720
+ disabled={!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id)}
630
721
  className={cn(
631
722
  "rounded border-border-subtle text-primary h-3 w-3",
632
- !!line.purchase_order_line_id ? "opacity-50 cursor-not-allowed" : "focus:ring-primary"
723
+ !!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id) ? "opacity-50 cursor-not-allowed" : "focus:ring-primary"
633
724
  )}
634
725
  />
635
726
  <span className={cn(
636
727
  "text-[11.5px] font-medium transition-colors",
637
- !!line.purchase_order_line_id ? "text-foreground-disabled" : "text-foreground-subtle hover:text-foreground-1"
728
+ !!(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"
638
729
  )}>
639
730
  Free Quantity
640
731
  </span>
@@ -644,17 +735,17 @@ export function ProductSelectionPanel({
644
735
  <div className="flex items-center">
645
736
  <button
646
737
  onClick={() => setActiveParentLineUidFor(line.uid)}
647
- disabled={!!line.purchase_order_line_id}
738
+ disabled={!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id)}
648
739
  className={cn(
649
740
  "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",
650
- !!line.purchase_order_line_id ? "opacity-60 cursor-not-allowed" : "hover:bg-surface-hover focus:border-primary/50 focus:outline-none"
741
+ !!(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"
651
742
  )}
652
743
  >
653
744
  <span>
654
745
  {line.parent_line_uids && line.parent_line_uids.length > 0
655
746
  ? (() => {
656
747
  if (line.parent_line_uids.length === 1) {
657
- const p = lineItems.find((l) => l.uid === line.parent_line_uids![0]);
748
+ const p = lineItems.find((l) => l.uid === line.parent_line_uids?.[0]);
658
749
  return p
659
750
  ? p.item.has_variants && p.variant_id && p.item.variants?.find((v) => v.id === p.variant_id)
660
751
  ? `${p.item.name} (${p.item.variants.find((v) => v.id === p.variant_id)?.variant_name})`
@@ -675,25 +766,24 @@ export function ProductSelectionPanel({
675
766
  </div>
676
767
  </div>
677
768
  </TCell>
678
-
679
- <TCell label="Qty" className="py-2.5">
769
+ <TCell label="Qty" className="py-2.5 text-center">
680
770
  <input
681
771
  type="number"
682
772
  value={line.quantity}
683
773
  onChange={(e) => updateLineInput(line.uid, 'quantity', e.target.value)}
684
- className="w-20 h-8 px-2 text-[13px] text-center font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors"
774
+ 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"
685
775
  min={0.001}
686
776
  step="any"
687
777
  />
688
778
  </TCell>
689
779
 
690
- {config.enablePurchaseUnit && (
780
+ {(config.enablePurchaseUnit || config.enableSalesUnit) && (
691
781
  <TCell label="Unit" className="py-2.5">
692
782
  <select
693
783
  value={line.uom_id || line.item.uom_id || ''}
694
784
  onChange={(e) => updateLineInput(line.uid, 'uom_id', e.target.value)}
695
- disabled={line.item.tracking_type === 'serial'}
696
- className="h-8 px-2 text-[13px] font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors min-w-[80px] disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
785
+ disabled={line.item.tracking_type === 'serial' || isBatchPending}
786
+ 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"
697
787
  >
698
788
  {line.item.uom?.uom_group?.uoms?.map((uom: any) => (
699
789
  <option key={uom.id} value={uom.id}>{uom.abbreviation}</option>
@@ -703,30 +793,93 @@ export function ProductSelectionPanel({
703
793
  )}
704
794
 
705
795
  {config.showPrices && (
706
- <TCell label="Rate" className="py-2.5">
707
- <input
708
- type="text"
709
- value={editingRates[line.uid] ?? Number(line.unit_price || 0).toFixed(2)}
710
- onChange={(e) => {
711
- const val = e.target.value;
712
- if (/^\d*\.?\d{0,2}$/.test(val)) {
713
- setEditingRates(prev => ({ ...prev, [line.uid]: val }));
714
- updateLineInput(line.uid, 'unit_price', val);
715
- }
716
- }}
717
- onBlur={() => {
718
- setEditingRates(prev => {
719
- const next = { ...prev };
720
- delete next[line.uid];
721
- return next;
722
- });
723
- }}
724
- disabled={!config.allowEditPrice || line.is_free_qty}
725
- className={cn(
726
- "w-24 h-8 px-2 text-[13px] text-right font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors",
727
- (!config.allowEditPrice || line.is_free_qty) && "opacity-60 cursor-not-allowed bg-surface-hover border-transparent"
728
- )}
729
- />
796
+ <TCell label="Rate" className="py-2.5 text-right relative">
797
+ <div className="flex items-center justify-end w-full gap-2">
798
+ {line.applied_price_rule?.price_list_name && (() => {
799
+ const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
800
+ const actualVariant = line.variant_id ? line.item.variants?.find((v: any) => v.id === line.variant_id) : (line.item.variants?.[0] || null);
801
+ const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
802
+ const batchPrice = ['sales', 'quotation'].includes(config.type) && line.batches?.[0] ? (line.batches[0] as any)[batchPriceField] : undefined;
803
+ const basePrice = (batchPrice !== undefined && batchPrice !== null)
804
+ ? Number(batchPrice)
805
+ : (actualVariant
806
+ ? Number(actualVariant[variantPriceField] || line.item[config.priceField] || 0)
807
+ : Number(line.item[config.priceField] || 0));
808
+ const finalPrice = Number(line.applied_price_rule?.final_rate || 0);
809
+ if (basePrice === finalPrice) return null;
810
+
811
+ return (
812
+ <HintIcon
813
+ text={`Applied Rule: ${line.applied_price_rule?.price_list_name}\nBase Rate: ${basePrice.toFixed(2)}${
814
+ line.applied_price_rule?.rate ? `\nNew Rate: ${line.applied_price_rule?.rate}` :
815
+ line.applied_price_rule?.markdown_percentage ? `\nMarkdown: ${line.applied_price_rule?.markdown_percentage}%` : ''
816
+ }\nFinal Rate: ${Number(line.applied_price_rule?.final_rate || 0).toFixed(2)}`}
817
+ icon={<Tag size={15} className="text-primary-600 opacity-80 hover:opacity-100 transition-opacity" />}
818
+ />
819
+ );
820
+ })()}
821
+ <input
822
+ type="text"
823
+ value={editingRates[line.uid] ?? Number(line.unit_price || 0).toFixed(2)}
824
+ onChange={(e) => {
825
+ const val = e.target.value;
826
+ if (/^\d*\.?\d{0,2}$/.test(val)) {
827
+ setEditingRates(prev => ({ ...prev, [line.uid]: val }));
828
+ updateLineInput(line.uid, 'unit_price', val);
829
+ }
830
+ }}
831
+ onBlur={() => {
832
+ setEditingRates(prev => {
833
+ const next = { ...prev };
834
+ delete next[line.uid];
835
+ return next;
836
+ });
837
+ // Clamp the discount amount if it exceeds the new rate
838
+ if (discountTypes[line.uid] !== 'percent') {
839
+ updateLineInput(line.uid, 'discount_amount', line.quantity > 0 ? line.discount_amount / line.quantity : 0);
840
+ }
841
+ }}
842
+ disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || isBatchPending}
843
+ className={cn(
844
+ "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",
845
+ (!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || isBatchPending) && "opacity-60 cursor-not-allowed bg-surface-hover"
846
+ )}
847
+ />
848
+ </div>
849
+ </TCell>
850
+ )}
851
+
852
+ {showDiscountCol && (
853
+ <TCell label="Disc." className="py-2.5 text-right">
854
+ <div className={cn(
855
+ "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",
856
+ (!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || line.unit_price === 0 || isBatchPending) && "opacity-60 bg-surface-hover"
857
+ )}>
858
+ <select
859
+ 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"
860
+ value={discountTypes[line.uid] || 'amount'}
861
+ onChange={(e) => {
862
+ setDiscountTypes(prev => ({ ...prev, [line.uid]: e.target.value as 'amount' | 'percent' }));
863
+ }}
864
+ disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || line.unit_price === 0 || isBatchPending}
865
+ >
866
+ <option value="amount">Amt</option>
867
+ <option value="percent">%</option>
868
+ </select>
869
+ <input
870
+ type="number"
871
+ value={(discountTypes[line.uid] === 'percent' ? line.discount_percent : (line.quantity > 0 ? Number((line.discount_amount / line.quantity).toFixed(2)) : 0)) || ''}
872
+ placeholder="0.00"
873
+ onChange={(e) => {
874
+ const val = e.target.value;
875
+ updateLineInput(line.uid, discountTypes[line.uid] === 'percent' ? 'discount_percent' : 'discount_amount', val ? Number(val) : 0);
876
+ }}
877
+ disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || line.unit_price === 0 || isBatchPending}
878
+ 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"
879
+ min={0}
880
+ step="any"
881
+ />
882
+ </div>
730
883
  </TCell>
731
884
  )}
732
885
 
@@ -739,14 +892,15 @@ export function ProductSelectionPanel({
739
892
  <TCell className="text-center py-2.5">
740
893
  <button
741
894
  onClick={() => onRemoveLine(line.uid)}
742
- className="w-7 h-7 inline-flex items-center justify-center rounded-md text-foreground-disabled hover:bg-danger-alt/10 hover:text-danger-alt transition-colors"
895
+ 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"
743
896
  title="Remove Item"
744
897
  >
745
898
  <Trash2 size={16} />
746
899
  </button>
747
900
  </TCell>
748
901
  </TRow>
749
- ))}
902
+ );
903
+ })}
750
904
  </TBody>
751
905
  </Table>
752
906
  )}
@@ -788,10 +942,15 @@ export function ProductSelectionPanel({
788
942
  {lineItems.length > 0 && (
789
943
  <div className="mt-3 pt-3 border-t border-border-subtle">
790
944
  <div className="flex items-center justify-between text-[12px] text-foreground-subtle">
791
- <span>{lineItems.length} item{lineItems.length > 1 ? 's' : ''} · {lineItems.reduce((s, l) => s + l.quantity, 0)} units</span>
945
+ <span>{lineItems.length} item{lineItems.length > 1 ? 's' : ''} · {lineItems.reduce((s, l) => {
946
+ const uoms = l.item.uom?.uom_group?.uoms || [];
947
+ const currentUom = uoms.find((u: any) => u.id === (l.uom_id || l.item.uom_id));
948
+ const factor = Number(currentUom?.conversion_factor) || 1;
949
+ return s + (l.quantity * factor);
950
+ }, 0)} units</span>
792
951
  {config.showPrices && (
793
952
  <span className="text-[15px] font-bold text-foreground-0">
794
- {formatCurrency(lineItems.reduce((s, l) => s + l.line_total, 0))}
953
+ {formatCurrency(lineItems.reduce((s, l) => s + Number(l.line_total || 0), 0))}
795
954
  </span>
796
955
  )}
797
956
  </div>