@apptimate/ui 4.5.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apptimate/ui",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "dependencies": {
@@ -11,9 +11,10 @@ import { AsyncSearchableSelect } from "../base-components/SearchableSelect";
11
11
  export interface PaymentModeField {
12
12
  key: string;
13
13
  label: string;
14
- type: "text" | "number" | "date" | "bank_account_select" | "material_type_select";
14
+ type: "text" | "number" | "date" | "bank_account_select" | "material_type_select" | "select" | "multi_select";
15
15
  required: boolean;
16
16
  readonly?: boolean;
17
+ options?: { label: string; value: string }[];
17
18
  }
18
19
 
19
20
  export interface PaymentMode {
@@ -65,11 +66,13 @@ export interface PaymentSectionProps {
65
66
  allowedSpecialModes?: string[];
66
67
  /** Fetcher for cheque leaves (for auto-complete) */
67
68
  fetchChequeLeaves?: (query: string, page: number) => Promise<{ is_success: boolean; result?: any[] }>;
69
+ /** UI Variant */
70
+ variant?: "default" | "advance";
68
71
  }
69
72
 
70
73
  /* ── Component ── */
71
74
 
72
- export function PaymentSection({ totalAmount, payments, onPaymentsChange, compact, fetchPaymentModes, fetchBankAccounts, materialTypes = [], allowedSpecialModes = [], fetchChequeLeaves }: PaymentSectionProps) {
75
+ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compact, fetchPaymentModes, fetchBankAccounts, materialTypes = [], allowedSpecialModes = [], fetchChequeLeaves, variant = "default" }: PaymentSectionProps) {
73
76
  const [paymentModes, setPaymentModes] = useState<PaymentMode[]>([]);
74
77
  const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
75
78
  const [isLoading, setIsLoading] = useState(false);
@@ -280,14 +283,14 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
280
283
  {/* ── Totals ── */}
281
284
  {payments.length > 0 && (
282
285
  <div className="bg-surface-0 rounded-[12px] border border-border-subtle p-4 space-y-2">
283
- <TotalRow label="Amount to Pay" value={formatCurrency(totalAmount)} bold />
286
+ <TotalRow label={variant === "advance" ? "Target Amount" : "Amount to Pay"} value={formatCurrency(totalAmount)} bold />
284
287
  <div className="border-t border-border-subtle my-2" />
285
288
  <TotalRow label="Total Paid" value={formatCurrency(totalPaid)} />
286
289
  {creditAmount > 0.01 && (
287
290
  <TotalRow label="Credit Amount" value={formatCurrency(creditAmount)} className="text-amber-600" />
288
291
  )}
289
292
  <TotalRow
290
- label={balance > 0.01 ? "Outstanding Balance" : balance < -0.01 ? "Change Due" : "Settled"}
293
+ label={balance > 0.01 ? "Outstanding Balance" : balance < -0.01 ? "Change Due" : (variant === "advance" ? "Matched" : "Settled")}
291
294
  value={formatCurrency(Math.abs(balance))}
292
295
  className={balance > 0.01 ? "text-danger-alt" : "text-green-600"}
293
296
  bold
@@ -299,7 +302,7 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
299
302
  )}
300
303
  {Math.abs(balance) < 0.01 && creditAmount <= 0.01 && (
301
304
  <div className="flex items-center gap-1.5 mt-1 text-[11px] text-green-600 font-medium">
302
- <Check size={12} /><span>Fully paid</span>
305
+ <Check size={12} /><span>{variant === "advance" ? "Amount matched" : "Fully paid"}</span>
303
306
  </div>
304
307
  )}
305
308
  </div>
@@ -365,8 +368,8 @@ function PaymentEntryRow({ entry, mode, bankAccounts, materialTypes = [], onUpda
365
368
  <div className="flex flex-col gap-1">
366
369
  <label className="text-[11px] text-foreground-subtle font-bold uppercase tracking-wider">Amount</label>
367
370
  <input type="number" value={entry.amount || ""} onChange={(e) => onUpdate({ amount: Number(e.target.value) || 0 })}
368
- className="w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-3 py-2.5 text-[13px] font-medium text-foreground-1 outline-none focus:border-primary/40 transition-all [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
369
- step="0.01" min={0} />
371
+ className="w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-3 py-2.5 text-[13px] font-medium text-foreground-1 outline-none focus:border-primary/40 transition-all [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none disabled:opacity-50 disabled:cursor-not-allowed"
372
+ step="0.01" min={0} disabled={entry.mode_code === "advance"} />
370
373
  </div>
371
374
 
372
375
  {/* Dynamic sub-fields from field_config */}
@@ -414,6 +417,37 @@ function PaymentEntryRow({ entry, mode, bankAccounts, materialTypes = [], onUpda
414
417
  <option value="">Select Material...</option>
415
418
  {materialTypes.map((m) => (<option key={m.id} value={m.id}>{m.name}</option>))}
416
419
  </select>
420
+ ) : field.type === "select" ? (
421
+ <select value={entry.metadata[field.key] || ""}
422
+ onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value } })}
423
+ disabled={field.readonly}
424
+ className="w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-2.5 py-2.5 text-[12px] text-foreground-1 outline-none focus:border-primary/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
425
+ <option value="">Select...</option>
426
+ {field.options?.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}
427
+ </select>
428
+ ) : field.type === "multi_select" as any ? (
429
+ <AsyncSearchableSelect
430
+ multiple
431
+ option={{ label: 'label', value: 'value' }}
432
+ defaultValue={
433
+ Array.isArray(entry.metadata[field.key])
434
+ ? (field.options || []).filter(o => (entry.metadata[field.key] as any).includes(o.value))
435
+ : []
436
+ }
437
+ onChange={(val, selectedObjs: any) => {
438
+ const sum = Array.isArray(selectedObjs)
439
+ ? selectedObjs.reduce((acc, curr) => acc + (curr.unapplied_amount || 0), 0)
440
+ : 0;
441
+
442
+ onUpdate({
443
+ amount: sum,
444
+ metadata: { ...entry.metadata, [field.key]: val as any }
445
+ });
446
+ }}
447
+ disabled={field.readonly}
448
+ placeholder="Select..."
449
+ loadOptions={async () => field.options || []}
450
+ />
417
451
  ) : field.key === "cheque_number" && fetchChequeLeaves ? (
418
452
  <>
419
453
  <AsyncSearchableSelect
@@ -187,7 +187,7 @@ export function MetadataPanel({
187
187
 
188
188
  {/* ── Expected Delivery Date ── */}
189
189
  {config.showExpectedDeliveryDate && onExpectedDeliveryDateChange && (
190
- <Section label="Expected Delivery">
190
+ <Section label={config.expectedDeliveryDateLabel || "Expected Delivery"}>
191
191
  <div className="relative">
192
192
  <Calendar size={15} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-foreground-disabled pointer-events-none" />
193
193
  <input
@@ -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';
@@ -17,6 +17,7 @@ import { HintIcon } from '../../base-components/HintIcon';
17
17
  interface ProductSelectionPanelProps {
18
18
  config: TransactionScreenConfig;
19
19
  warehouseId?: number;
20
+ partyId?: number | null;
20
21
  lineItems: TransactionLineItem[];
21
22
  onAddItem: (item: TransactionItem, variant?: any, extraData?: { quantity?: number; batches?: any[]; serial_numbers?: string[] }) => void;
22
23
  onUpdateLine: (uid: string, updates: Partial<TransactionLineItem>) => void;
@@ -27,6 +28,7 @@ interface ProductSelectionPanelProps {
27
28
  export function ProductSelectionPanel({
28
29
  config,
29
30
  warehouseId,
31
+ partyId,
30
32
  lineItems,
31
33
  onAddItem,
32
34
  onUpdateLine,
@@ -40,7 +42,7 @@ export function ProductSelectionPanel({
40
42
  const [expandedItemIds, setExpandedItemIds] = useState<number[]>([]);
41
43
  const [editingRates, setEditingRates] = useState<Record<string, string>>({});
42
44
  const [focusedIndex, setFocusedIndex] = useState<number>(-1);
43
-
45
+ const [discountTypes, setDiscountTypes] = useState<Record<string, 'amount' | 'percent'>>({});
44
46
  const [activeBatchItem, setActiveBatchItem] = useState<{ item: TransactionItem; variant?: any; preSelectedBatch?: any } | null>(null);
45
47
  const [activeBatchLineUid, setActiveBatchLineUid] = useState<string | null>(null);
46
48
  const [activeSerialItem, setActiveSerialItem] = useState<{ item: TransactionItem; variant?: any } | null>(null);
@@ -51,6 +53,7 @@ export function ProductSelectionPanel({
51
53
  const panelRef = React.useRef<HTMLDivElement>(null);
52
54
  const inputRef = React.useRef<HTMLInputElement>(null);
53
55
 
56
+ const showDiscountCol = config.showPrices && ['sales', 'quotation', 'pos'].includes(config.type);
54
57
 
55
58
  // ── Search handler ──
56
59
  const handleSearch = useCallback(
@@ -69,6 +72,13 @@ export function ProductSelectionPanel({
69
72
  try {
70
73
  const params: Record<string, string | number> = { search: value, limit: 20, type: config.type };
71
74
  if (warehouseId) params.warehouse_id = warehouseId;
75
+
76
+ if (['sales', 'quotation', 'pos'].includes(config.type)) {
77
+ params.apply_sales_prices = 1;
78
+ params.transaction_type = config.type;
79
+ if (partyId) params.party_id = partyId;
80
+ }
81
+
72
82
  const res = await transactionLookup(params);
73
83
  if (res.is_success && res.result) {
74
84
  const payload = res.result as any;
@@ -227,18 +237,29 @@ export function ProductSelectionPanel({
227
237
  }
228
238
  };
229
239
 
230
- const updateLineInput = (uid: string, field: 'quantity' | 'unit_price' | 'free_qty' | 'uom_id', value: string) => {
240
+ const updateLineInput = (uid: string, field: 'quantity' | 'unit_price' | 'free_qty' | 'uom_id' | 'discount_amount' | 'discount_percent', value: string | number) => {
231
241
  const num = value === '' ? 0 : Number(value);
232
242
  const line = lineItems.find(l => l.uid === uid);
233
243
  if (!line) return;
234
244
 
235
245
  let qty = line.quantity;
236
246
  let price = line.unit_price;
247
+ let discAmt = line.discount_amount;
248
+ let discPercent = line.discount_percent;
237
249
 
238
250
  if (field === 'quantity') qty = Math.max(0.001, num);
239
251
  if (field === 'unit_price') price = Math.max(0, num);
240
-
241
- const discAmt = (price * qty * line.discount_percent) / 100;
252
+ if (field === 'discount_amount') {
253
+ const unitDiscount = Math.max(0, num);
254
+ discAmt = unitDiscount * qty;
255
+ discPercent = (price * qty) > 0 ? (discAmt * 100) / (price * qty) : 0;
256
+ } else if (field === 'discount_percent') {
257
+ discPercent = Math.max(0, num);
258
+ discAmt = (price * qty * discPercent) / 100;
259
+ } else {
260
+ // If price or qty changes, recalculate discAmt from discPercent
261
+ discAmt = (price * qty * discPercent) / 100;
262
+ }
242
263
 
243
264
  if (field === 'uom_id') {
244
265
  const newUomId = Number(value);
@@ -260,8 +281,6 @@ export function ProductSelectionPanel({
260
281
 
261
282
  const newDiscAmt = (newPrice * line.quantity * line.discount_percent) / 100;
262
283
 
263
- setEditingRates(prev => ({ ...prev, [uid]: newPrice.toFixed(2) }));
264
-
265
284
  onUpdateLine(uid, {
266
285
  uom_id: newUomId,
267
286
  unit_price: newPrice,
@@ -275,6 +294,7 @@ export function ProductSelectionPanel({
275
294
  quantity: qty,
276
295
  unit_price: price,
277
296
  discount_amount: discAmt,
297
+ discount_percent: discPercent,
278
298
  line_total: price * qty - discAmt,
279
299
  });
280
300
  };
@@ -531,9 +551,10 @@ export function ProductSelectionPanel({
531
551
  <THeader>
532
552
  <TRow>
533
553
  <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>}
554
+ <TCell isHeader className="text-center">Qty</TCell>
555
+ {(config.enablePurchaseUnit || config.enableSalesUnit) && <TCell isHeader>Unit</TCell>}
556
+ {config.showPrices && <TCell isHeader className="text-right">Rate</TCell>}
557
+ {showDiscountCol && <TCell isHeader className="text-right">Disc.</TCell>}
537
558
  {config.showPrices && <TCell isHeader className="text-right">Total</TCell>}
538
559
  <TCell isHeader className="w-10 text-center"></TCell>
539
560
  </TRow>
@@ -675,8 +696,7 @@ export function ProductSelectionPanel({
675
696
  </div>
676
697
  </div>
677
698
  </TCell>
678
-
679
- <TCell label="Qty" className="py-2.5">
699
+ <TCell label="Qty" className="py-2.5 text-center">
680
700
  <input
681
701
  type="number"
682
702
  value={line.quantity}
@@ -687,7 +707,7 @@ export function ProductSelectionPanel({
687
707
  />
688
708
  </TCell>
689
709
 
690
- {config.enablePurchaseUnit && (
710
+ {(config.enablePurchaseUnit || config.enableSalesUnit) && (
691
711
  <TCell label="Unit" className="py-2.5">
692
712
  <select
693
713
  value={line.uom_id || line.item.uom_id || ''}
@@ -703,30 +723,75 @@ export function ProductSelectionPanel({
703
723
  )}
704
724
 
705
725
  {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"
726
+ <TCell label="Rate" className="py-2.5 text-right relative">
727
+ <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
+ />
728
736
  )}
729
- />
737
+ <input
738
+ type="text"
739
+ value={editingRates[line.uid] ?? Number(line.unit_price || 0).toFixed(2)}
740
+ onChange={(e) => {
741
+ const val = e.target.value;
742
+ if (/^\d*\.?\d{0,2}$/.test(val)) {
743
+ setEditingRates(prev => ({ ...prev, [line.uid]: val }));
744
+ updateLineInput(line.uid, 'unit_price', val);
745
+ }
746
+ }}
747
+ onBlur={() => {
748
+ setEditingRates(prev => {
749
+ const next = { ...prev };
750
+ delete next[line.uid];
751
+ return next;
752
+ });
753
+ }}
754
+ disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable}
755
+ 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"
758
+ )}
759
+ />
760
+ </div>
761
+ </TCell>
762
+ )}
763
+
764
+ {showDiscountCol && (
765
+ <TCell label="Disc." className="py-2.5 text-right">
766
+ <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"
769
+ )}>
770
+ <select
771
+ 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"
772
+ value={discountTypes[line.uid] || 'amount'}
773
+ onChange={(e) => {
774
+ setDiscountTypes(prev => ({ ...prev, [line.uid]: e.target.value as 'amount' | 'percent' }));
775
+ }}
776
+ disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable}
777
+ >
778
+ <option value="amount">Amt</option>
779
+ <option value="percent">%</option>
780
+ </select>
781
+ <input
782
+ type="number"
783
+ value={(discountTypes[line.uid] === 'percent' ? line.discount_percent : (line.quantity > 0 ? Number((line.discount_amount / line.quantity).toFixed(2)) : 0)) || ''}
784
+ placeholder="0.00"
785
+ onChange={(e) => {
786
+ const val = e.target.value;
787
+ updateLineInput(line.uid, discountTypes[line.uid] === 'percent' ? 'discount_percent' : 'discount_amount', val ? Number(val) : 0);
788
+ }}
789
+ disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable}
790
+ 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
+ min={0}
792
+ step="any"
793
+ />
794
+ </div>
730
795
  </TCell>
731
796
  )}
732
797
 
@@ -788,7 +853,12 @@ export function ProductSelectionPanel({
788
853
  {lineItems.length > 0 && (
789
854
  <div className="mt-3 pt-3 border-t border-border-subtle">
790
855
  <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>
856
+ <span>{lineItems.length} item{lineItems.length > 1 ? 's' : ''} · {lineItems.reduce((s, l) => {
857
+ const uoms = l.item.uom?.uom_group?.uoms || [];
858
+ const currentUom = uoms.find((u: any) => u.id === (l.uom_id || l.item.uom_id));
859
+ const factor = Number(currentUom?.conversion_factor) || 1;
860
+ return s + (l.quantity * factor);
861
+ }, 0)} units</span>
792
862
  {config.showPrices && (
793
863
  <span className="text-[15px] font-bold text-foreground-0">
794
864
  {formatCurrency(lineItems.reduce((s, l) => s + l.line_total, 0))}
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useState, useCallback, useMemo, useEffect } from 'react';
4
- import { cn } from '@apptimate/core-lib';
4
+ import { cn, PriceCalculationService, evaluatePriceLists } from '@apptimate/core-lib';
5
5
  import toast from 'react-hot-toast';
6
6
  import { ProductSelectionPanel } from './ProductSelectionPanel';
7
7
  import { MetadataPanel } from './MetadataPanel';
@@ -104,9 +104,28 @@ export function ProductTransactionScreen({
104
104
  }
105
105
 
106
106
  const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
107
- const unitPrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
107
+ const basePrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
108
108
  const qty = extra?.quantity || 1;
109
- const discountAmt = 0; // Initial discount
109
+
110
+ const uomId = itemWithVariants.uom_id;
111
+ const uoms = itemWithVariants.uom?.uom_group?.uoms || [];
112
+ const currentUom = uoms.find((u: any) => u.id === uomId);
113
+ const factor = Number(currentUom?.conversion_factor) || 1;
114
+
115
+ let unitPrice = basePrice * factor;
116
+ let discountPercent = 0;
117
+ let discountAmt = 0;
118
+
119
+ let appliedRule = undefined;
120
+
121
+ if (actualVariant && actualVariant.pricing_rules) {
122
+ const effectiveQty = qty * factor;
123
+ const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, actualVariant.pricing_rules);
124
+ unitPrice = priceDetails.unitPrice * factor;
125
+ discountPercent = priceDetails.discountPercent;
126
+ discountAmt = priceDetails.discountAmount * factor;
127
+ appliedRule = priceDetails.appliedRule;
128
+ }
110
129
 
111
130
  const newLine: TransactionLineItem = {
112
131
  uid: `${itemWithVariants.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
@@ -116,11 +135,13 @@ export function ProductTransactionScreen({
116
135
  serial_numbers: extra?.serial_numbers || [],
117
136
  quantity: qty,
118
137
  unit_price: unitPrice,
119
- discount_percent: 0,
138
+ discount_percent: discountPercent,
120
139
  discount_amount: discountAmt,
121
- line_total: unitPrice * qty - discountAmt,
140
+ line_total: (unitPrice * qty) - discountAmt,
122
141
  expanded: false,
123
142
  uom_id: itemWithVariants.uom_id,
143
+ pricing_rules: actualVariant?.pricing_rules,
144
+ applied_price_rule: appliedRule,
124
145
  };
125
146
  return [...prevLineItems, newLine];
126
147
  });
@@ -131,9 +152,48 @@ export function ProductTransactionScreen({
131
152
  // ── Update line ──
132
153
  const handleUpdateLine = useCallback((uid: string, updates: Partial<TransactionLineItem>) => {
133
154
  setLineItems((prev) =>
134
- prev.map((l) => (l.uid === uid ? { ...l, ...updates } : l))
155
+ prev.map((l) => {
156
+ if (l.uid !== uid) return l;
157
+
158
+ const updatedLine = { ...l, ...updates };
159
+
160
+ const isQtyChanged = 'quantity' in updates && updates.quantity !== l.quantity;
161
+ const isUomChanged = 'uom_id' in updates && updates.uom_id !== l.uom_id;
162
+ const isPriceChanged = 'unit_price' in updates && updates.unit_price !== l.unit_price;
163
+ const isDiscountChanged = ('discount_amount' in updates && updates.discount_amount !== l.discount_amount) || ('discount_percent' in updates && updates.discount_percent !== l.discount_percent);
164
+
165
+ const isQtyOrUomChanged = isQtyChanged || isUomChanged;
166
+
167
+ if (isQtyOrUomChanged && updatedLine.pricing_rules && updatedLine.pricing_rules.length > 0) {
168
+ const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
169
+ const actualVariant = updatedLine.item.variants?.find((v: any) => v.id === updatedLine.variant_id);
170
+ const basePrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
171
+
172
+ const uoms = updatedLine.item.uom?.uom_group?.uoms || [];
173
+ const currentUom = uoms.find((u: any) => u.id === (updatedLine.uom_id || updatedLine.item.uom_id));
174
+ const factor = Number(currentUom?.conversion_factor) || 1;
175
+
176
+ const effectiveQty = updatedLine.quantity * factor;
177
+
178
+ const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, updatedLine.pricing_rules);
179
+ updatedLine.unit_price = priceDetails.unitPrice * factor;
180
+ updatedLine.discount_percent = priceDetails.discountPercent;
181
+ updatedLine.discount_amount = priceDetails.discountAmount * factor;
182
+ updatedLine.applied_price_rule = priceDetails.appliedRule;
183
+ updatedLine.line_total = (updatedLine.unit_price * updatedLine.quantity) - updatedLine.discount_amount;
184
+ } else if (isQtyChanged || isUomChanged || isPriceChanged || isDiscountChanged) {
185
+ updatedLine.line_total = (updatedLine.unit_price * updatedLine.quantity) - updatedLine.discount_amount;
186
+
187
+ const isManualPriceOverride = isPriceChanged && !isUomChanged;
188
+ if (isManualPriceOverride) {
189
+ updatedLine.applied_price_rule = undefined;
190
+ }
191
+ }
192
+
193
+ return updatedLine;
194
+ })
135
195
  );
136
- }, []);
196
+ }, [config.priceField]);
137
197
 
138
198
  // ── Remove line ──
139
199
  const handleRemoveLine = useCallback((uid: string) => {
@@ -210,7 +270,7 @@ export function ProductTransactionScreen({
210
270
  purchase_order_line_id: l.purchase_order_line_id,
211
271
  unit_price: l.unit_price,
212
272
  discount_percent: l.discount_percent,
213
- discount_amount: (l.discount_amount / l.quantity) * b.quantity,
273
+ unit_discount: l.discount_amount / l.quantity,
214
274
  line_total: (l.line_total / l.quantity) * b.quantity,
215
275
  }));
216
276
  }
@@ -232,7 +292,7 @@ export function ProductTransactionScreen({
232
292
  purchase_order_line_id: l.purchase_order_line_id,
233
293
  unit_price: l.unit_price,
234
294
  discount_percent: l.discount_percent,
235
- discount_amount: l.discount_amount,
295
+ unit_discount: l.discount_amount / l.quantity,
236
296
  line_total: l.line_total,
237
297
  }];
238
298
  }),
@@ -295,7 +355,46 @@ export function ProductTransactionScreen({
295
355
  expectedDeliveryDate={expectedDeliveryDate}
296
356
  notes={notes}
297
357
  payments={payments}
298
- onPartyChange={(id, party) => { setPartyId(id); setPartyName(party?.name ?? null); }}
358
+ onPartyChange={async (id, party) => {
359
+ 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];
372
+
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);
378
+
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' });
390
+ }
391
+ } catch (e) {
392
+ toast.error('Failed to recalculate prices', { id: 'price-calc' });
393
+ }
394
+ }
395
+ setPartyId(id);
396
+ setPartyName(party?.name ?? null);
397
+ }}
299
398
  onWarehouseChange={(id, name) => { setWarehouseId(id); setWarehouseName(name ?? null); }}
300
399
  onDateChange={setTransactionDate}
301
400
  onExpectedDeliveryDateChange={setExpectedDeliveryDate}
@@ -19,9 +19,9 @@ export interface TransactionItem {
19
19
  is_discountable: boolean;
20
20
  category?: { id: number; name: string };
21
21
  brand?: { id: number; name: string };
22
- uom?: {
23
- id: number;
24
- name: string;
22
+ uom?: {
23
+ id: number;
24
+ name: string;
25
25
  abbreviation: string;
26
26
  uom_group?: {
27
27
  id: number;
@@ -41,6 +41,7 @@ export interface TransactionItemVariant {
41
41
  cost_price?: number;
42
42
  sale_price?: number;
43
43
  additional_price?: number;
44
+ pricing_rules?: any[];
44
45
  }
45
46
 
46
47
  export interface TransactionBatch {
@@ -84,6 +85,8 @@ export interface TransactionLineItem {
84
85
  line_total: number;
85
86
  notes?: string;
86
87
  expanded?: boolean; // Whether the detail row is expanded
88
+ pricing_rules?: any[];
89
+ applied_price_rule?: any;
87
90
  }
88
91
 
89
92
  import type { PaymentModeField, PaymentMode, PaymentEntry, BankAccount } from '../PaymentSection';
@@ -150,8 +153,12 @@ export interface TransactionScreenConfig {
150
153
  showStock?: boolean;
151
154
  /** Enable purchase unit selection (dynamic setting) */
152
155
  enablePurchaseUnit?: boolean;
156
+ /** Enable sales unit selection (dynamic setting) */
157
+ enableSalesUnit?: boolean;
153
158
  /** Show expected delivery date */
154
159
  showExpectedDeliveryDate?: boolean;
160
+ /** Label for the expected delivery date field */
161
+ expectedDeliveryDateLabel?: string;
155
162
  /** Disable batch/serial selection completely (e.g. for Purchase Orders and Quotations) */
156
163
  disableTrackingSelection?: boolean;
157
164
  /** Enforce that the sum of payments equals the grand total before allowing submission */
@@ -174,7 +181,7 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
174
181
  submitLabel: 'Create Sales Order',
175
182
  title: 'New Sale',
176
183
  allowCreateBatchAndSerial: false,
177
- allowEditPrice: false,
184
+ allowEditPrice: true,
178
185
  showStock: true,
179
186
  },
180
187
  quotation: {
@@ -190,8 +197,10 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
190
197
  submitLabel: 'Create Quotation',
191
198
  title: 'New Quotation',
192
199
  allowCreateBatchAndSerial: false,
193
- allowEditPrice: false,
200
+ allowEditPrice: true,
194
201
  showStock: true,
202
+ showExpectedDeliveryDate: true,
203
+ expectedDeliveryDateLabel: 'Valid Until',
195
204
  disableTrackingSelection: true,
196
205
  },
197
206
  purchase: {
@@ -243,7 +252,7 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
243
252
  submitLabel: 'Complete Sale',
244
253
  title: 'POS Checkout',
245
254
  allowCreateBatchAndSerial: false,
246
- allowEditPrice: false,
255
+ allowEditPrice: true,
247
256
  showStock: true,
248
257
  },
249
258
  opening_stock: {
@@ -326,7 +335,7 @@ export interface TransactionPayload {
326
335
  purchase_order_line_id?: number | null;
327
336
  unit_price: number;
328
337
  discount_percent: number;
329
- discount_amount: number;
338
+ unit_discount: number;
330
339
  line_total: number;
331
340
  }>;
332
341
  payments?: PaymentEntry[];