@apptimate/ui 4.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apptimate/ui",
3
- "version": "4.6.0",
3
+ "version": "4.7.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "dependencies": {
@@ -49,6 +49,8 @@ export interface SearchableSelectProps<T = Record<string, unknown>> {
49
49
  creatable?: CreatableConfig<T>;
50
50
  /** Additional className on the outer wrapper */
51
51
  className?: string;
52
+ /** Additional className on the control div (the trigger) */
53
+ controlClassName?: string;
52
54
  /** Enable search filtering (default: true) */
53
55
  isSearchable?: boolean;
54
56
  /** Show clear button when a value is selected */
@@ -119,6 +121,7 @@ function SelectCore<T extends Record<string, unknown>>({
119
121
  error,
120
122
  isRequired,
121
123
  surface = 0,
124
+ controlClassName,
122
125
  }: SelectCoreProps<T>) {
123
126
  const opt = { ...DEFAULT_OPTION_CONFIG, ...optionConfig } as SearchableSelectOptionConfig<T>;
124
127
 
@@ -560,11 +563,11 @@ function SelectCore<T extends Record<string, unknown>>({
560
563
  )}
561
564
 
562
565
  {/* Option label */}
563
- <span className="truncate">
566
+ <div className="flex-1 min-w-0">
564
567
  {opt.renderOption
565
568
  ? opt.renderOption(item)
566
- : getLabel(item)}
567
- </span>
569
+ : <span className="truncate block">{getLabel(item)}</span>}
570
+ </div>
568
571
  </li>
569
572
  ))}
570
573
 
@@ -616,6 +619,7 @@ function SelectCore<T extends Record<string, unknown>>({
616
619
  ref={controlRef}
617
620
  className={cn(
618
621
  'w-full border-[1.5px] rounded-[10px] px-3.5 py-2.5 flex items-center gap-2 cursor-pointer transition-all min-h-[42px]',
622
+ controlClassName,
619
623
  isOpen
620
624
  ? `border-gray-300 bg-surface-${surface === 0 ? 1 : 2}`
621
625
  : `border-border-subtle bg-surface-${surface} hover:border-gray-300`,
@@ -172,6 +172,7 @@ export interface PickerTriggerProps {
172
172
  placeholder?: string;
173
173
  isRequired?: boolean;
174
174
  error?: string;
175
+ disabled?: boolean;
175
176
  onClick: () => void;
176
177
  onClear?: () => void;
177
178
  }
@@ -182,6 +183,7 @@ export function PickerTrigger({
182
183
  placeholder = "Select…",
183
184
  isRequired = false,
184
185
  error,
186
+ disabled = false,
185
187
  onClick,
186
188
  onClear,
187
189
  }: PickerTriggerProps) {
@@ -193,10 +195,12 @@ export function PickerTrigger({
193
195
  </label>
194
196
  <button
195
197
  type="button"
198
+ disabled={disabled}
196
199
  onClick={onClick}
197
200
  className={`
198
201
  w-full flex items-center justify-between px-3.5 py-2.5 bg-surface-0 border-[1.5px] rounded-[10px] text-[13.5px] text-left transition-all
199
- ${error
202
+ ${disabled ? "opacity-60 cursor-not-allowed bg-surface-hover" : ""}
203
+ ${error && !disabled
200
204
  ? "border-red-500 text-red-500"
201
205
  : value
202
206
  ? "border-border-subtle text-foreground-1"
@@ -18,6 +18,7 @@ interface PartyPickerProps {
18
18
  label?: string;
19
19
  placeholder?: string;
20
20
  isRequired?: boolean;
21
+ disabled?: boolean;
21
22
  partyType?: "customer" | "supplier" | "all" | "artisan";
22
23
  customTrigger?: (onClick: () => void) => React.ReactNode;
23
24
  }
@@ -29,6 +30,7 @@ export function PartyPicker({
29
30
  label = "Party",
30
31
  placeholder = "Select party…",
31
32
  isRequired = false,
33
+ disabled = false,
32
34
  partyType = "all",
33
35
  customTrigger,
34
36
  }: PartyPickerProps) {
@@ -126,6 +128,7 @@ export function PartyPicker({
126
128
  value={displayValue || null}
127
129
  placeholder={placeholder}
128
130
  isRequired={isRequired}
131
+ disabled={disabled}
129
132
  onClick={handleOpen}
130
133
  onClear={value ? handleClear : undefined}
131
134
  />
@@ -18,6 +18,7 @@ interface WarehousePickerProps {
18
18
  label?: string;
19
19
  placeholder?: string;
20
20
  isRequired?: boolean;
21
+ disabled?: boolean;
21
22
  fetchFromAllUserOrgs?: boolean;
22
23
  }
23
24
 
@@ -28,6 +29,7 @@ export function WarehousePicker({
28
29
  label = "Warehouse",
29
30
  placeholder = "Select warehouse…",
30
31
  isRequired = false,
32
+ disabled = false,
31
33
  fetchFromAllUserOrgs = false,
32
34
  }: WarehousePickerProps) {
33
35
  const [isOpen, setIsOpen] = useState(false);
@@ -86,6 +88,7 @@ export function WarehousePicker({
86
88
  value={displayValue || null}
87
89
  placeholder={placeholder}
88
90
  isRequired={isRequired}
91
+ disabled={disabled}
89
92
  onClick={handleOpen}
90
93
  onClear={value ? handleClear : undefined}
91
94
  />
@@ -146,6 +146,7 @@ export function MetadataPanel({
146
146
  label={config.partyLabel}
147
147
  isRequired
148
148
  fetchFromAllUserOrgs={true}
149
+ disabled={config.disablePartySelection}
149
150
  />
150
151
  ) : (
151
152
  <PartyPicker
@@ -155,6 +156,7 @@ export function MetadataPanel({
155
156
  label={config.partyLabel}
156
157
  partyType={config.partyType}
157
158
  isRequired
159
+ disabled={config.disablePartySelection}
158
160
  />
159
161
  )
160
162
  )}
@@ -167,6 +169,7 @@ export function MetadataPanel({
167
169
  onChange={(w) => onWarehouseChange(w?.id ?? null, w?.name)}
168
170
  label="Warehouse"
169
171
  isRequired
172
+ disabled={config.disableWarehouseSelection}
170
173
  />
171
174
  )}
172
175
 
@@ -13,6 +13,7 @@ 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;
@@ -94,8 +95,15 @@ export function ProductSelectionPanel({
94
95
  if (matchType === 'serial') {
95
96
  onAddItem(entity.item, entity.variant, { serial_numbers: [entity.serial_number], quantity: 1 });
96
97
  } else if (matchType === 'batch') {
97
- const batchVariant = entity.variant_id && entity.item?.variants ? entity.item.variants.find((v: any) => v.id === entity.variant_id) : null;
98
- 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
+ }
99
107
  } else if (matchType === 'variant') {
100
108
  handleSelectItem(entity.item, entity);
101
109
  } else if (matchType === 'item') {
@@ -113,7 +121,7 @@ export function ProductSelectionPanel({
113
121
  }
114
122
  }, 250);
115
123
  },
116
- [warehouseId, autoAddEnabled, config.type]
124
+ [warehouseId, autoAddEnabled, config.type, partyId]
117
125
  );
118
126
 
119
127
  // ── Select item → check tracking type or add to cart ──
@@ -124,14 +132,18 @@ export function ProductSelectionPanel({
124
132
  setShowResults(false);
125
133
 
126
134
  if (!config.disableTrackingSelection && item.tracking_type === 'batch') {
127
- setActiveBatchItem({ item, variant });
135
+ if (['sales', 'quotation'].includes(config.type)) {
136
+ onAddItem(item, variant);
137
+ } else {
138
+ setActiveBatchItem({ item, variant });
139
+ }
128
140
  } else if (!config.disableTrackingSelection && item.tracking_type === 'serial') {
129
141
  setActiveSerialItem({ item, variant });
130
142
  } else {
131
143
  onAddItem(item, variant);
132
144
  }
133
145
  },
134
- [onAddItem]
146
+ [onAddItem, config.type, config.disableTrackingSelection]
135
147
  );
136
148
 
137
149
  const handleBatchConfirm = (selections: { batch: any; quantity: number }[]) => {
@@ -250,15 +262,24 @@ export function ProductSelectionPanel({
250
262
  if (field === 'quantity') qty = Math.max(0.001, num);
251
263
  if (field === 'unit_price') price = Math.max(0, num);
252
264
  if (field === 'discount_amount') {
253
- const unitDiscount = Math.max(0, num);
265
+ const maxDiscount = price;
266
+ const unitDiscount = Math.min(Math.max(0, num), maxDiscount);
254
267
  discAmt = unitDiscount * qty;
255
268
  discPercent = (price * qty) > 0 ? (discAmt * 100) / (price * qty) : 0;
256
269
  } else if (field === 'discount_percent') {
257
- discPercent = Math.max(0, num);
270
+ discPercent = Math.min(Math.max(0, num), 100);
258
271
  discAmt = (price * qty * discPercent) / 100;
259
272
  } else {
260
- // If price or qty changes, recalculate discAmt from discPercent
261
- discAmt = (price * qty * discPercent) / 100;
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
+ }
262
283
  }
263
284
 
264
285
  if (field === 'uom_id') {
@@ -560,7 +581,9 @@ export function ProductSelectionPanel({
560
581
  </TRow>
561
582
  </THeader>
562
583
  <TBody>
563
- {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 (
564
587
  <TRow key={line.uid}>
565
588
  <TCell label="Item" className="py-2.5">
566
589
  <div className="flex items-start gap-3">
@@ -585,9 +608,56 @@ export function ProductSelectionPanel({
585
608
  ? `${line.item.variants.find(v => v.id === line.variant_id)?.variant_name} • ${line.item.variants.find(v => v.id === line.variant_id)?.sku}`
586
609
  : line.item.sku}
587
610
  </span>
588
- {!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))) && (
589
612
  <div className="flex flex-wrap gap-1 mt-1.5">
590
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
+
591
661
  const batchesCount = line.batches?.length || 0;
592
662
  const selectedTotal = line.batches?.reduce((sum, b) => sum + b.quantity, 0) || 0;
593
663
  const isMismatch = selectedTotal !== line.quantity;
@@ -640,22 +710,22 @@ export function ProductSelectionPanel({
640
710
  })()}
641
711
  </div>
642
712
  )}
643
- {(config.type === 'purchase' || config.type === 'grn') && Number(line.unit_price) === 0 && (
713
+ {Number(line.unit_price) === 0 && (
644
714
  <div className="mt-2 flex items-center gap-2">
645
- <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")}>
646
716
  <input
647
717
  type="checkbox"
648
718
  checked={line.is_free_qty || false}
649
719
  onChange={(e) => handleToggleFreeQty(line.uid, e.target.checked)}
650
- disabled={!!line.purchase_order_line_id}
720
+ disabled={!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id)}
651
721
  className={cn(
652
722
  "rounded border-border-subtle text-primary h-3 w-3",
653
- !!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"
654
724
  )}
655
725
  />
656
726
  <span className={cn(
657
727
  "text-[11.5px] font-medium transition-colors",
658
- !!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"
659
729
  )}>
660
730
  Free Quantity
661
731
  </span>
@@ -665,17 +735,17 @@ export function ProductSelectionPanel({
665
735
  <div className="flex items-center">
666
736
  <button
667
737
  onClick={() => setActiveParentLineUidFor(line.uid)}
668
- disabled={!!line.purchase_order_line_id}
738
+ disabled={!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id)}
669
739
  className={cn(
670
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",
671
- !!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"
672
742
  )}
673
743
  >
674
744
  <span>
675
745
  {line.parent_line_uids && line.parent_line_uids.length > 0
676
746
  ? (() => {
677
747
  if (line.parent_line_uids.length === 1) {
678
- 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]);
679
749
  return p
680
750
  ? p.item.has_variants && p.variant_id && p.item.variants?.find((v) => v.id === p.variant_id)
681
751
  ? `${p.item.name} (${p.item.variants.find((v) => v.id === p.variant_id)?.variant_name})`
@@ -701,7 +771,7 @@ export function ProductSelectionPanel({
701
771
  type="number"
702
772
  value={line.quantity}
703
773
  onChange={(e) => updateLineInput(line.uid, 'quantity', e.target.value)}
704
- 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"
705
775
  min={0.001}
706
776
  step="any"
707
777
  />
@@ -712,8 +782,8 @@ export function ProductSelectionPanel({
712
782
  <select
713
783
  value={line.uom_id || line.item.uom_id || ''}
714
784
  onChange={(e) => updateLineInput(line.uid, 'uom_id', e.target.value)}
715
- disabled={line.item.tracking_type === 'serial'}
716
- 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"
717
787
  >
718
788
  {line.item.uom?.uom_group?.uoms?.map((uom: any) => (
719
789
  <option key={uom.id} value={uom.id}>{uom.abbreviation}</option>
@@ -725,15 +795,29 @@ export function ProductSelectionPanel({
725
795
  {config.showPrices && (
726
796
  <TCell label="Rate" className="py-2.5 text-right relative">
727
797
  <div className="flex items-center justify-end w-full gap-2">
728
- {line.applied_price_rule?.price_list_name && (
729
- <HintIcon
730
- text={`Applied Rule: ${line.applied_price_rule.price_list_name}${
731
- line.applied_price_rule.rate ? `\nRate: ${line.applied_price_rule.rate}` :
732
- line.applied_price_rule.markdown_percentage ? `\nMarkdown: ${line.applied_price_rule.markdown_percentage}%` : ''
733
- }\nFinal Rate: ${Number(line.applied_price_rule.final_rate || 0).toFixed(2)}`}
734
- icon={<Tag size={15} className="text-primary-600 opacity-80 hover:opacity-100 transition-opacity" />}
735
- />
736
- )}
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
+ })()}
737
821
  <input
738
822
  type="text"
739
823
  value={editingRates[line.uid] ?? Number(line.unit_price || 0).toFixed(2)}
@@ -750,11 +834,15 @@ export function ProductSelectionPanel({
750
834
  delete next[line.uid];
751
835
  return next;
752
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
+ }
753
841
  }}
754
- disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable}
842
+ disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || isBatchPending}
755
843
  className={cn(
756
- "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",
757
- (!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable) && "opacity-60 cursor-not-allowed bg-surface-hover"
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"
758
846
  )}
759
847
  />
760
848
  </div>
@@ -764,8 +852,8 @@ export function ProductSelectionPanel({
764
852
  {showDiscountCol && (
765
853
  <TCell label="Disc." className="py-2.5 text-right">
766
854
  <div className={cn(
767
- "flex items-center w-32 h-8 bg-surface-1 border border-border-subtle rounded-md overflow-hidden focus-within:border-primary/50 transition-colors ml-auto",
768
- (!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable) && "opacity-60 bg-surface-hover"
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"
769
857
  )}>
770
858
  <select
771
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"
@@ -773,7 +861,7 @@ export function ProductSelectionPanel({
773
861
  onChange={(e) => {
774
862
  setDiscountTypes(prev => ({ ...prev, [line.uid]: e.target.value as 'amount' | 'percent' }));
775
863
  }}
776
- disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable}
864
+ disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || line.unit_price === 0 || isBatchPending}
777
865
  >
778
866
  <option value="amount">Amt</option>
779
867
  <option value="percent">%</option>
@@ -786,7 +874,7 @@ export function ProductSelectionPanel({
786
874
  const val = e.target.value;
787
875
  updateLineInput(line.uid, discountTypes[line.uid] === 'percent' ? 'discount_percent' : 'discount_amount', val ? Number(val) : 0);
788
876
  }}
789
- disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable}
877
+ disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || line.unit_price === 0 || isBatchPending}
790
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"
791
879
  min={0}
792
880
  step="any"
@@ -804,14 +892,15 @@ export function ProductSelectionPanel({
804
892
  <TCell className="text-center py-2.5">
805
893
  <button
806
894
  onClick={() => onRemoveLine(line.uid)}
807
- 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"
808
896
  title="Remove Item"
809
897
  >
810
898
  <Trash2 size={16} />
811
899
  </button>
812
900
  </TCell>
813
901
  </TRow>
814
- ))}
902
+ );
903
+ })}
815
904
  </TBody>
816
905
  </Table>
817
906
  )}
@@ -861,7 +950,7 @@ export function ProductSelectionPanel({
861
950
  }, 0)} units</span>
862
951
  {config.showPrices && (
863
952
  <span className="text-[15px] font-bold text-foreground-0">
864
- {formatCurrency(lineItems.reduce((s, l) => s + l.line_total, 0))}
953
+ {formatCurrency(lineItems.reduce((s, l) => s + Number(l.line_total || 0), 0))}
865
954
  </span>
866
955
  )}
867
956
  </div>
@@ -1,10 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useState, useCallback, useMemo, useEffect } from 'react';
4
- import { cn, PriceCalculationService, evaluatePriceLists } from '@apptimate/core-lib';
4
+ import { cn, PriceCalculationService, evaluatePriceLists, checkHasActivePriceLists } from '@apptimate/core-lib';
5
5
  import toast from 'react-hot-toast';
6
6
  import { ProductSelectionPanel } from './ProductSelectionPanel';
7
7
  import { MetadataPanel } from './MetadataPanel';
8
+ import { Modal, ModalFooter } from '../../base-components/Modal';
9
+ import { Button } from '../../base-components/Button';
8
10
  import type {
9
11
  ProductTransactionScreenProps, TransactionItem, TransactionLineItem,
10
12
  TransactionPayload, PaymentEntry, PartyLookup,
@@ -42,6 +44,60 @@ export function ProductTransactionScreen({
42
44
  const [notes, setNotes] = useState('');
43
45
  const [payments, setPayments] = useState<PaymentEntry[]>([]);
44
46
  const [isSubmitting, setIsSubmitting] = useState(false);
47
+ const [hasActivePriceLists, setHasActivePriceLists] = useState<boolean>(false);
48
+ const [priceEvaluationModal, setPriceEvaluationModal] = useState<{
49
+ isOpen: boolean;
50
+ type: 'confirm' | 'force';
51
+ evaluatedRules: Record<number, any[]>;
52
+ pendingPartyId: number | null;
53
+ pendingParty: any;
54
+ } | null>(null);
55
+
56
+ const applyEvaluatedPrices = useCallback((evaluatedRules: Record<number, any[]>) => {
57
+ setLineItems(prev => prev.map(l => {
58
+ if (l.is_free_qty || !l.variant_id || !evaluatedRules[l.variant_id]) return l;
59
+ const rules = evaluatedRules[l.variant_id];
60
+
61
+ const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
62
+ const actualVariant = l.item.variants?.find((v: any) => v.id === l.variant_id);
63
+ const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
64
+ const batchPrice = ['sales', 'quotation'].includes(config.type) && l.batches?.[0] ? (l.batches[0] as any)[batchPriceField] : undefined;
65
+ const basePrice = (batchPrice !== undefined && batchPrice !== null)
66
+ ? Number(batchPrice)
67
+ : (actualVariant
68
+ ? Number(actualVariant[variantPriceField] || l.item[config.priceField] || 0)
69
+ : Number(l.item[config.priceField] || 0));
70
+
71
+ const uoms = l.item.uom?.uom_group?.uoms || [];
72
+ const currentUom = uoms.find((u: any) => u.id === (l.uom_id || l.item.uom_id));
73
+ const factor = Number(currentUom?.conversion_factor) || 1;
74
+ const effectiveQty = l.quantity * factor;
75
+
76
+ const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, rules, l.batches?.[0]?.batch_id);
77
+
78
+ return {
79
+ ...l,
80
+ pricing_rules: rules,
81
+ unit_price: priceDetails.unitPrice * factor,
82
+ discount_percent: priceDetails.discountPercent,
83
+ discount_amount: priceDetails.discountAmount * factor,
84
+ applied_price_rule: priceDetails.appliedRule,
85
+ line_total: (priceDetails.unitPrice * factor * l.quantity) - (priceDetails.discountAmount * factor)
86
+ };
87
+ }));
88
+ }, [config.priceField]);
89
+
90
+ useEffect(() => {
91
+ if (['sales', 'pos', 'quotation'].includes(config.type)) {
92
+ checkHasActivePriceLists().then(res => {
93
+ if (res.is_success) {
94
+ setHasActivePriceLists(res.result as boolean);
95
+ }
96
+ }).catch(() => {
97
+ // Silently ignore
98
+ });
99
+ }
100
+ }, [config.type]);
45
101
 
46
102
  const submitting = externalSubmitting ?? isSubmitting;
47
103
 
@@ -53,24 +109,25 @@ export function ProductTransactionScreen({
53
109
  }
54
110
  if (initialData.warehouse_id) {
55
111
  setWarehouseId(initialData.warehouse_id);
112
+ if (initialData.warehouse?.name) setWarehouseName(initialData.warehouse.name);
56
113
  }
57
114
  if (initialData.lines && Array.isArray(initialData.lines)) {
58
115
  setLineItems(
59
116
  initialData.lines.map((l: any) => {
60
- const qty = l.quantity || 1;
61
- const unitPrice = l.unit_price || 0;
117
+ const qty = l.quantity !== undefined ? Number(l.quantity) : 1;
118
+ const unitPrice = l.unit_price !== undefined ? Number(l.unit_price) : 0;
62
119
  return {
63
120
  uid: l.uid || Math.random().toString(36).slice(2, 7),
64
121
  item: l.item,
65
122
  variant_id: l.variant_id || null,
66
- batches: [],
67
- serial_numbers: [],
123
+ batches: l.batches || [],
124
+ serial_numbers: l.serial_numbers || [],
68
125
  quantity: qty,
69
126
  unit_price: unitPrice,
70
- discount_percent: 0,
71
- discount_amount: 0,
72
- line_total: unitPrice * qty,
73
- expanded: false,
127
+ discount_percent: l.discount_percent !== undefined ? Number(l.discount_percent) : 0,
128
+ discount_amount: l.discount_amount !== undefined ? Number(l.discount_amount) : 0,
129
+ line_total: l.line_total !== undefined ? Number(l.line_total) : (unitPrice * qty - (l.discount_amount || 0)),
130
+ expanded: l.expanded || false,
74
131
  is_free_qty: l.is_free_qty,
75
132
  parent_line_uids: l.parent_line_uids,
76
133
  uom_id: l.uom_id,
@@ -78,6 +135,25 @@ export function ProductTransactionScreen({
78
135
  };
79
136
  })
80
137
  );
138
+
139
+ // Populate pricing rules for existing items if party is selected
140
+ if (initialData.party_id && ['sales', 'pos', 'quotation'].includes(config.type)) {
141
+ evaluatePriceLists({
142
+ party_id: initialData.party_id,
143
+ transaction_type: config.type,
144
+ items: initialData.lines.filter((l: any) => l.variant_id && !l.is_free_qty).map((l: any) => ({ variant_id: l.variant_id }))
145
+ }).then(res => {
146
+ if (res.is_success && res.result) {
147
+ const evaluatedRules = res.result as Record<number, any[]>;
148
+ setLineItems(prev => prev.map(line => {
149
+ if (!line.is_free_qty && line.variant_id && evaluatedRules[line.variant_id]) {
150
+ return { ...line, pricing_rules: evaluatedRules[line.variant_id] };
151
+ }
152
+ return line;
153
+ }));
154
+ }
155
+ }).catch(() => {});
156
+ }
81
157
  }
82
158
  }
83
159
  }, [initialData]);
@@ -104,7 +180,11 @@ export function ProductTransactionScreen({
104
180
  }
105
181
 
106
182
  const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
107
- const basePrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
183
+ const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
184
+ const batchPrice = ['sales', 'quotation'].includes(config.type) && extra?.batches?.[0] ? (extra.batches[0] as any)[batchPriceField] : undefined;
185
+ const basePrice = (batchPrice !== undefined && batchPrice !== null)
186
+ ? Number(batchPrice)
187
+ : (actualVariant ? Number(actualVariant[variantPriceField] || itemWithVariants[config.priceField] || 0) : Number(itemWithVariants[config.priceField] || 0));
108
188
  const qty = extra?.quantity || 1;
109
189
 
110
190
  const uomId = itemWithVariants.uom_id;
@@ -120,7 +200,7 @@ export function ProductTransactionScreen({
120
200
 
121
201
  if (actualVariant && actualVariant.pricing_rules) {
122
202
  const effectiveQty = qty * factor;
123
- const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, actualVariant.pricing_rules);
203
+ const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, actualVariant.pricing_rules, extra?.batches?.[0]?.batch_id);
124
204
  unitPrice = priceDetails.unitPrice * factor;
125
205
  discountPercent = priceDetails.discountPercent;
126
206
  discountAmt = priceDetails.discountAmount * factor;
@@ -161,13 +241,21 @@ export function ProductTransactionScreen({
161
241
  const isUomChanged = 'uom_id' in updates && updates.uom_id !== l.uom_id;
162
242
  const isPriceChanged = 'unit_price' in updates && updates.unit_price !== l.unit_price;
163
243
  const isDiscountChanged = ('discount_amount' in updates && updates.discount_amount !== l.discount_amount) || ('discount_percent' in updates && updates.discount_percent !== l.discount_percent);
244
+ const isBatchChanged = 'batches' in updates;
245
+ const isFreeQtyChanged = 'is_free_qty' in updates && updates.is_free_qty !== l.is_free_qty;
164
246
 
165
- const isQtyOrUomChanged = isQtyChanged || isUomChanged;
247
+ const isQtyOrUomOrBatchChanged = isQtyChanged || isUomChanged || isBatchChanged || (isFreeQtyChanged && !updates.is_free_qty);
166
248
 
167
- if (isQtyOrUomChanged && updatedLine.pricing_rules && updatedLine.pricing_rules.length > 0) {
249
+ if (isQtyOrUomOrBatchChanged && updatedLine.pricing_rules && updatedLine.pricing_rules.length > 0 && !updatedLine.is_free_qty) {
168
250
  const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
169
251
  const actualVariant = updatedLine.item.variants?.find((v: any) => v.id === updatedLine.variant_id);
170
- const basePrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
252
+ const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
253
+ const batchPrice = ['sales', 'quotation'].includes(config.type) && updatedLine.batches?.[0] ? (updatedLine.batches[0] as any)[batchPriceField] : undefined;
254
+ const basePrice = (batchPrice !== undefined && batchPrice !== null)
255
+ ? Number(batchPrice)
256
+ : (actualVariant
257
+ ? Number(actualVariant[variantPriceField] || updatedLine.item[config.priceField] || 0)
258
+ : Number(updatedLine.item[config.priceField] || 0));
171
259
 
172
260
  const uoms = updatedLine.item.uom?.uom_group?.uoms || [];
173
261
  const currentUom = uoms.find((u: any) => u.id === (updatedLine.uom_id || updatedLine.item.uom_id));
@@ -175,7 +263,11 @@ export function ProductTransactionScreen({
175
263
 
176
264
  const effectiveQty = updatedLine.quantity * factor;
177
265
 
178
- const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, updatedLine.pricing_rules);
266
+ console.log("DEBUG: handleUpdateLine rules -> ", {
267
+ basePrice, effectiveQty, rules: updatedLine.pricing_rules
268
+ });
269
+
270
+ const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, updatedLine.pricing_rules, updatedLine.batches?.[0]?.batch_id);
179
271
  updatedLine.unit_price = priceDetails.unitPrice * factor;
180
272
  updatedLine.discount_percent = priceDetails.discountPercent;
181
273
  updatedLine.discount_amount = priceDetails.discountAmount * factor;
@@ -190,6 +282,10 @@ export function ProductTransactionScreen({
190
282
  }
191
283
  }
192
284
 
285
+ if (isQtyChanged && updatedLine.batches && updatedLine.batches.length === 1) {
286
+ updatedLine.batches[0].quantity = updatedLine.quantity;
287
+ }
288
+
193
289
  return updatedLine;
194
290
  })
195
291
  );
@@ -197,7 +293,15 @@ export function ProductTransactionScreen({
197
293
 
198
294
  // ── Remove line ──
199
295
  const handleRemoveLine = useCallback((uid: string) => {
200
- setLineItems((prev) => prev.filter((l) => l.uid !== uid));
296
+ setLineItems((prev) => prev
297
+ .filter((l) => l.uid !== uid)
298
+ .map((l) => {
299
+ if (l.parent_line_uids && l.parent_line_uids.includes(uid)) {
300
+ return { ...l, parent_line_uids: l.parent_line_uids.filter((pUid) => pUid !== uid) };
301
+ }
302
+ return l;
303
+ })
304
+ );
201
305
  }, []);
202
306
 
203
307
  // ── Toggle expand ──
@@ -208,8 +312,8 @@ export function ProductTransactionScreen({
208
312
  }, []);
209
313
 
210
314
  // ── Calculations ──
211
- const subtotal = useMemo(() => lineItems.reduce((s, l) => s + l.unit_price * l.quantity, 0), [lineItems]);
212
- const discountTotal = useMemo(() => lineItems.reduce((s, l) => s + l.discount_amount, 0), [lineItems]);
315
+ const subtotal = useMemo(() => lineItems.reduce((s, l) => s + (Number(l.unit_price) || 0) * (Number(l.quantity) || 1), 0), [lineItems]);
316
+ const discountTotal = useMemo(() => lineItems.reduce((s, l) => s + (Number(l.discount_amount) || 0), 0), [lineItems]);
213
317
  const grandTotal = useMemo(() => subtotal - discountTotal, [subtotal, discountTotal]);
214
318
 
215
319
  // ── Submit handler ──
@@ -224,16 +328,25 @@ export function ProductTransactionScreen({
224
328
  }
225
329
  }
226
330
 
227
- if (!config.disableTrackingSelection) {
228
- for (const line of lineItems) {
229
- if (line.item.tracking_type === 'batch') {
331
+ if (config.showWarehouse && !warehouseId) {
332
+ toast.error('Please select a warehouse.');
333
+ return;
334
+ }
335
+
336
+ for (const line of lineItems) {
337
+ if (line.item.tracking_type === 'batch') {
338
+ const requiresBatch = !config.disableTrackingSelection || ['sales', 'quotation'].includes(config.type);
339
+ if (requiresBatch) {
230
340
  const selectedTotal = line.batches?.reduce((sum, b) => sum + b.quantity, 0) || 0;
231
341
  if (selectedTotal !== line.quantity) {
232
342
  toast.error(`Please select batches for ${line.item.name} to match the total quantity.`);
233
343
  return;
234
344
  }
235
345
  }
236
- if (line.item.tracking_type === 'serial') {
346
+ }
347
+ if (line.item.tracking_type === 'serial') {
348
+ const requiresSerial = !config.disableTrackingSelection;
349
+ if (requiresSerial) {
237
350
  const selectedCount = line.serial_numbers?.length || 0;
238
351
  if (selectedCount !== line.quantity) {
239
352
  toast.error(`Please select exactly ${line.quantity} serial numbers for ${line.item.name}.`);
@@ -250,6 +363,7 @@ export function ProductTransactionScreen({
250
363
  transaction_date: transactionDate,
251
364
  expected_delivery_date: expectedDeliveryDate || undefined,
252
365
  notes: notes || undefined,
366
+ revised_from_id: initialData?.revised_from_id || undefined,
253
367
  lines: lineItems.flatMap((l): TransactionPayload['lines'] => {
254
368
  if (l.batches && l.batches.length > 0) {
255
369
  return l.batches.map(b => ({
@@ -320,7 +434,7 @@ export function ProductTransactionScreen({
320
434
  } finally {
321
435
  setIsSubmitting(false);
322
436
  }
323
- }, [lineItems, config, partyId, warehouseId, transactionDate, expectedDeliveryDate, notes, payments, subtotal, discountTotal, grandTotal, onSubmit, defaultWarehouseId]);
437
+ }, [lineItems, config, partyId, warehouseId, transactionDate, expectedDeliveryDate, notes, payments, subtotal, discountTotal, grandTotal, onSubmit, defaultWarehouseId, initialData]);
324
438
 
325
439
  return (
326
440
  <div className="flex flex-col h-full">
@@ -330,6 +444,7 @@ export function ProductTransactionScreen({
330
444
  <div className="flex-[3] min-w-0 flex flex-col">
331
445
  <ProductSelectionPanel
332
446
  config={config}
447
+ partyId={partyId}
333
448
  warehouseId={warehouseId ?? undefined}
334
449
  lineItems={lineItems}
335
450
  onAddItem={handleAddItem}
@@ -357,39 +472,74 @@ export function ProductTransactionScreen({
357
472
  payments={payments}
358
473
  onPartyChange={async (id, party) => {
359
474
  if (partyId !== id && lineItems.length > 0 && ['sales', 'pos', 'quotation'].includes(config.type)) {
360
- toast.loading('Recalculating prices based on selected party...', { id: 'price-calc' });
361
- try {
362
- const res = await evaluatePriceLists({
363
- party_id: id,
364
- transaction_type: config.type,
365
- items: lineItems.filter(l => l.variant_id).map(l => ({ variant_id: l.variant_id! }))
366
- });
367
- if (res.is_success && res.result) {
368
- const evaluatedRules = res.result as Record<number, any[]>;
369
- setLineItems(prev => prev.map(l => {
370
- if (!l.variant_id || !evaluatedRules[l.variant_id]) return l;
371
- const rules = evaluatedRules[l.variant_id];
475
+ if (hasActivePriceLists) {
476
+ toast.loading('Checking price changes...', { id: 'price-calc' });
477
+ try {
478
+ const res = await evaluatePriceLists({
479
+ party_id: id || null,
480
+ transaction_type: config.type,
481
+ items: lineItems.filter(l => l.variant_id && !l.is_free_qty).map(l => ({ variant_id: l.variant_id! }))
482
+ });
483
+
484
+ toast.dismiss('price-calc');
485
+
486
+ if (res.is_success && res.result) {
487
+ const evaluatedRules = res.result as Record<number, any[]>;
372
488
 
373
- const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
374
- const actualVariant = l.item.variants?.find((v: any) => v.id === l.variant_id);
375
- const basePrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
376
-
377
- const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, l.quantity, rules);
489
+ let hasActualPriceChanges = false;
490
+ for (const l of lineItems) {
491
+ if (!l.is_free_qty && l.variant_id && evaluatedRules[l.variant_id]) {
492
+ const rules = evaluatedRules[l.variant_id];
493
+ const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
494
+ const actualVariant = l.item.variants?.find((v: any) => v.id === l.variant_id);
495
+ const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
496
+ const batchPrice = ['sales', 'quotation'].includes(config.type) && l.batches?.[0] ? (l.batches[0] as any)[batchPriceField] : undefined;
497
+ const basePrice = (batchPrice !== undefined && batchPrice !== null)
498
+ ? Number(batchPrice)
499
+ : (actualVariant
500
+ ? Number(actualVariant[variantPriceField] || l.item[config.priceField] || 0)
501
+ : Number(l.item[config.priceField] || 0));
502
+
503
+ const uoms = l.item.uom?.uom_group?.uoms || [];
504
+ const currentUom = uoms.find((u: any) => u.id === (l.uom_id || l.item.uom_id));
505
+ const factor = Number(currentUom?.conversion_factor) || 1;
506
+ const effectiveQty = l.quantity * factor;
507
+
508
+ const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, rules, l.batches?.[0]?.batch_id);
509
+
510
+ if (Math.abs((priceDetails.unitPrice * factor) - l.unit_price) > 0.001) {
511
+ hasActualPriceChanges = true;
512
+ break;
513
+ }
514
+ }
515
+ }
378
516
 
379
- return {
380
- ...l,
381
- pricing_rules: rules,
382
- unit_price: priceDetails.unitPrice,
383
- discount_percent: priceDetails.discountPercent,
384
- discount_amount: priceDetails.discountAmount,
385
- applied_price_rule: priceDetails.appliedRule,
386
- line_total: (priceDetails.unitPrice * l.quantity) - priceDetails.discountAmount
387
- };
388
- }));
389
- toast.success('Prices updated based on party selection', { id: 'price-calc' });
517
+ if (hasActualPriceChanges) {
518
+ const previousRulesApplied = lineItems.some(l => l.applied_price_rule);
519
+ setPriceEvaluationModal({
520
+ isOpen: true,
521
+ type: (partyId && previousRulesApplied) ? 'force' : 'confirm',
522
+ evaluatedRules,
523
+ pendingPartyId: id,
524
+ pendingParty: party
525
+ });
526
+ return;
527
+ } else {
528
+ // No immediate price changes for current quantities,
529
+ // but we still need to update the rules in the background
530
+ // so that if they change quantity later, it uses the new rules!
531
+ setLineItems(prev => prev.map(l => {
532
+ if (!l.is_free_qty && l.variant_id && evaluatedRules[l.variant_id]) {
533
+ return { ...l, pricing_rules: evaluatedRules[l.variant_id] };
534
+ }
535
+ return l;
536
+ }));
537
+ }
538
+ }
539
+ } catch (e) {
540
+ toast.dismiss('price-calc');
541
+ toast.error('Failed to check prices');
390
542
  }
391
- } catch (e) {
392
- toast.error('Failed to recalculate prices', { id: 'price-calc' });
393
543
  }
394
544
  }
395
545
  setPartyId(id);
@@ -407,6 +557,56 @@ export function ProductTransactionScreen({
407
557
  />
408
558
  </div>
409
559
  </div>
560
+
561
+ {/* ── Price Evaluation Modal ── */}
562
+ {priceEvaluationModal && (
563
+ <Modal
564
+ isOpen={priceEvaluationModal.isOpen}
565
+ onClose={() => {
566
+ if (priceEvaluationModal.type === 'confirm') {
567
+ setPartyId(priceEvaluationModal.pendingPartyId);
568
+ setPartyName(priceEvaluationModal.pendingParty?.name ?? null);
569
+ setPriceEvaluationModal(null);
570
+ }
571
+ }}
572
+ title="Price List Update"
573
+ backdrop="opaque"
574
+ size="sm"
575
+ >
576
+ <div className="text-[14px] text-foreground-1 leading-relaxed">
577
+ {priceEvaluationModal.type === 'confirm'
578
+ ? "A few items have price changes based on the price list. Do you want to apply them?"
579
+ : "Prices need to be changed based on the party selection."}
580
+ </div>
581
+ <ModalFooter>
582
+ {priceEvaluationModal.type === 'confirm' && (
583
+ <Button
584
+ variant="flat"
585
+ color="secondary"
586
+ onClick={() => {
587
+ setPartyId(priceEvaluationModal.pendingPartyId);
588
+ setPartyName(priceEvaluationModal.pendingParty?.name ?? null);
589
+ setPriceEvaluationModal(null);
590
+ }}
591
+ >
592
+ No
593
+ </Button>
594
+ )}
595
+ <Button
596
+ color="primary"
597
+ onClick={() => {
598
+ applyEvaluatedPrices(priceEvaluationModal.evaluatedRules);
599
+ setPartyId(priceEvaluationModal.pendingPartyId);
600
+ setPartyName(priceEvaluationModal.pendingParty?.name ?? null);
601
+ setPriceEvaluationModal(null);
602
+ toast.success('Prices updated based on party selection');
603
+ }}
604
+ >
605
+ {priceEvaluationModal.type === 'confirm' ? 'Yes' : 'OK'}
606
+ </Button>
607
+ </ModalFooter>
608
+ </Modal>
609
+ )}
410
610
  </div>
411
611
  );
412
612
  }
@@ -79,6 +79,8 @@ export interface TransactionLineItem {
79
79
  parent_line_uids?: string[];
80
80
  uom_id?: number;
81
81
  purchase_order_line_id?: number | null;
82
+ sales_order_line_id?: number | null;
83
+ sales_quotation_line_id?: number | null;
82
84
  unit_price: number;
83
85
  discount_percent: number;
84
86
  discount_amount: number;
@@ -163,6 +165,10 @@ export interface TransactionScreenConfig {
163
165
  disableTrackingSelection?: boolean;
164
166
  /** Enforce that the sum of payments equals the grand total before allowing submission */
165
167
  requireFullPayment?: boolean;
168
+ /** Disable party selection (e.g. for revisions) */
169
+ disablePartySelection?: boolean;
170
+ /** Disable warehouse selection (e.g. for revisions) */
171
+ disableWarehouseSelection?: boolean;
166
172
  }
167
173
 
168
174
  // ── Module Presets ──
@@ -317,6 +323,7 @@ export interface TransactionPayload {
317
323
  transaction_date?: string;
318
324
  expected_delivery_date?: string;
319
325
  notes?: string;
326
+ revised_from_id?: number | null;
320
327
  lines: Array<{
321
328
  item_id: number;
322
329
  variant_id?: number | null;