@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 +1 -1
- package/src/common-components/PaymentSection.tsx +41 -7
- package/src/common-components/transaction/MetadataPanel.tsx +1 -1
- package/src/common-components/transaction/ProductSelectionPanel.tsx +107 -37
- package/src/common-components/transaction/ProductTransactionScreen.tsx +109 -10
- package/src/common-components/transaction/types.ts +16 -7
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
<
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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) =>
|
|
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
|
|
107
|
+
const basePrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
|
|
108
108
|
const qty = extra?.quantity || 1;
|
|
109
|
-
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
338
|
+
unit_discount: number;
|
|
330
339
|
line_total: number;
|
|
331
340
|
}>;
|
|
332
341
|
payments?: PaymentEntry[];
|