@apptimate/ui 4.5.0 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/base-components/SearchableSelect.tsx +7 -3
- package/src/common-components/PaymentSection.tsx +41 -7
- package/src/common-components/pickers/EntityPickerModal.tsx +5 -1
- package/src/common-components/pickers/PartyPicker.tsx +3 -0
- package/src/common-components/pickers/WarehousePicker.tsx +3 -0
- package/src/common-components/transaction/MetadataPanel.tsx +4 -1
- package/src/common-components/transaction/ProductSelectionPanel.tsx +218 -59
- package/src/common-components/transaction/ProductTransactionScreen.tsx +325 -26
- package/src/common-components/transaction/types.ts +23 -7
|
@@ -1,10 +1,12 @@
|
|
|
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, 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
|
|
61
|
-
const unitPrice = l.unit_price
|
|
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,9 +180,32 @@ export function ProductTransactionScreen({
|
|
|
104
180
|
}
|
|
105
181
|
|
|
106
182
|
const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
|
|
107
|
-
const
|
|
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
|
+
|
|
190
|
+
const uomId = itemWithVariants.uom_id;
|
|
191
|
+
const uoms = itemWithVariants.uom?.uom_group?.uoms || [];
|
|
192
|
+
const currentUom = uoms.find((u: any) => u.id === uomId);
|
|
193
|
+
const factor = Number(currentUom?.conversion_factor) || 1;
|
|
194
|
+
|
|
195
|
+
let unitPrice = basePrice * factor;
|
|
196
|
+
let discountPercent = 0;
|
|
197
|
+
let discountAmt = 0;
|
|
198
|
+
|
|
199
|
+
let appliedRule = undefined;
|
|
200
|
+
|
|
201
|
+
if (actualVariant && actualVariant.pricing_rules) {
|
|
202
|
+
const effectiveQty = qty * factor;
|
|
203
|
+
const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, actualVariant.pricing_rules, extra?.batches?.[0]?.batch_id);
|
|
204
|
+
unitPrice = priceDetails.unitPrice * factor;
|
|
205
|
+
discountPercent = priceDetails.discountPercent;
|
|
206
|
+
discountAmt = priceDetails.discountAmount * factor;
|
|
207
|
+
appliedRule = priceDetails.appliedRule;
|
|
208
|
+
}
|
|
110
209
|
|
|
111
210
|
const newLine: TransactionLineItem = {
|
|
112
211
|
uid: `${itemWithVariants.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
@@ -116,11 +215,13 @@ export function ProductTransactionScreen({
|
|
|
116
215
|
serial_numbers: extra?.serial_numbers || [],
|
|
117
216
|
quantity: qty,
|
|
118
217
|
unit_price: unitPrice,
|
|
119
|
-
discount_percent:
|
|
218
|
+
discount_percent: discountPercent,
|
|
120
219
|
discount_amount: discountAmt,
|
|
121
|
-
line_total: unitPrice * qty - discountAmt,
|
|
220
|
+
line_total: (unitPrice * qty) - discountAmt,
|
|
122
221
|
expanded: false,
|
|
123
222
|
uom_id: itemWithVariants.uom_id,
|
|
223
|
+
pricing_rules: actualVariant?.pricing_rules,
|
|
224
|
+
applied_price_rule: appliedRule,
|
|
124
225
|
};
|
|
125
226
|
return [...prevLineItems, newLine];
|
|
126
227
|
});
|
|
@@ -131,13 +232,76 @@ export function ProductTransactionScreen({
|
|
|
131
232
|
// ── Update line ──
|
|
132
233
|
const handleUpdateLine = useCallback((uid: string, updates: Partial<TransactionLineItem>) => {
|
|
133
234
|
setLineItems((prev) =>
|
|
134
|
-
prev.map((l) =>
|
|
235
|
+
prev.map((l) => {
|
|
236
|
+
if (l.uid !== uid) return l;
|
|
237
|
+
|
|
238
|
+
const updatedLine = { ...l, ...updates };
|
|
239
|
+
|
|
240
|
+
const isQtyChanged = 'quantity' in updates && updates.quantity !== l.quantity;
|
|
241
|
+
const isUomChanged = 'uom_id' in updates && updates.uom_id !== l.uom_id;
|
|
242
|
+
const isPriceChanged = 'unit_price' in updates && updates.unit_price !== l.unit_price;
|
|
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;
|
|
246
|
+
|
|
247
|
+
const isQtyOrUomOrBatchChanged = isQtyChanged || isUomChanged || isBatchChanged || (isFreeQtyChanged && !updates.is_free_qty);
|
|
248
|
+
|
|
249
|
+
if (isQtyOrUomOrBatchChanged && updatedLine.pricing_rules && updatedLine.pricing_rules.length > 0 && !updatedLine.is_free_qty) {
|
|
250
|
+
const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
|
|
251
|
+
const actualVariant = updatedLine.item.variants?.find((v: any) => v.id === updatedLine.variant_id);
|
|
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));
|
|
259
|
+
|
|
260
|
+
const uoms = updatedLine.item.uom?.uom_group?.uoms || [];
|
|
261
|
+
const currentUom = uoms.find((u: any) => u.id === (updatedLine.uom_id || updatedLine.item.uom_id));
|
|
262
|
+
const factor = Number(currentUom?.conversion_factor) || 1;
|
|
263
|
+
|
|
264
|
+
const effectiveQty = updatedLine.quantity * factor;
|
|
265
|
+
|
|
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);
|
|
271
|
+
updatedLine.unit_price = priceDetails.unitPrice * factor;
|
|
272
|
+
updatedLine.discount_percent = priceDetails.discountPercent;
|
|
273
|
+
updatedLine.discount_amount = priceDetails.discountAmount * factor;
|
|
274
|
+
updatedLine.applied_price_rule = priceDetails.appliedRule;
|
|
275
|
+
updatedLine.line_total = (updatedLine.unit_price * updatedLine.quantity) - updatedLine.discount_amount;
|
|
276
|
+
} else if (isQtyChanged || isUomChanged || isPriceChanged || isDiscountChanged) {
|
|
277
|
+
updatedLine.line_total = (updatedLine.unit_price * updatedLine.quantity) - updatedLine.discount_amount;
|
|
278
|
+
|
|
279
|
+
const isManualPriceOverride = isPriceChanged && !isUomChanged;
|
|
280
|
+
if (isManualPriceOverride) {
|
|
281
|
+
updatedLine.applied_price_rule = undefined;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (isQtyChanged && updatedLine.batches && updatedLine.batches.length === 1) {
|
|
286
|
+
updatedLine.batches[0].quantity = updatedLine.quantity;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return updatedLine;
|
|
290
|
+
})
|
|
135
291
|
);
|
|
136
|
-
}, []);
|
|
292
|
+
}, [config.priceField]);
|
|
137
293
|
|
|
138
294
|
// ── Remove line ──
|
|
139
295
|
const handleRemoveLine = useCallback((uid: string) => {
|
|
140
|
-
setLineItems((prev) => prev
|
|
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
|
+
);
|
|
141
305
|
}, []);
|
|
142
306
|
|
|
143
307
|
// ── Toggle expand ──
|
|
@@ -148,8 +312,8 @@ export function ProductTransactionScreen({
|
|
|
148
312
|
}, []);
|
|
149
313
|
|
|
150
314
|
// ── Calculations ──
|
|
151
|
-
const subtotal = useMemo(() => lineItems.reduce((s, l) => s + l.unit_price * l.quantity, 0), [lineItems]);
|
|
152
|
-
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]);
|
|
153
317
|
const grandTotal = useMemo(() => subtotal - discountTotal, [subtotal, discountTotal]);
|
|
154
318
|
|
|
155
319
|
// ── Submit handler ──
|
|
@@ -164,16 +328,25 @@ export function ProductTransactionScreen({
|
|
|
164
328
|
}
|
|
165
329
|
}
|
|
166
330
|
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
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) {
|
|
170
340
|
const selectedTotal = line.batches?.reduce((sum, b) => sum + b.quantity, 0) || 0;
|
|
171
341
|
if (selectedTotal !== line.quantity) {
|
|
172
342
|
toast.error(`Please select batches for ${line.item.name} to match the total quantity.`);
|
|
173
343
|
return;
|
|
174
344
|
}
|
|
175
345
|
}
|
|
176
|
-
|
|
346
|
+
}
|
|
347
|
+
if (line.item.tracking_type === 'serial') {
|
|
348
|
+
const requiresSerial = !config.disableTrackingSelection;
|
|
349
|
+
if (requiresSerial) {
|
|
177
350
|
const selectedCount = line.serial_numbers?.length || 0;
|
|
178
351
|
if (selectedCount !== line.quantity) {
|
|
179
352
|
toast.error(`Please select exactly ${line.quantity} serial numbers for ${line.item.name}.`);
|
|
@@ -190,6 +363,7 @@ export function ProductTransactionScreen({
|
|
|
190
363
|
transaction_date: transactionDate,
|
|
191
364
|
expected_delivery_date: expectedDeliveryDate || undefined,
|
|
192
365
|
notes: notes || undefined,
|
|
366
|
+
revised_from_id: initialData?.revised_from_id || undefined,
|
|
193
367
|
lines: lineItems.flatMap((l): TransactionPayload['lines'] => {
|
|
194
368
|
if (l.batches && l.batches.length > 0) {
|
|
195
369
|
return l.batches.map(b => ({
|
|
@@ -210,7 +384,7 @@ export function ProductTransactionScreen({
|
|
|
210
384
|
purchase_order_line_id: l.purchase_order_line_id,
|
|
211
385
|
unit_price: l.unit_price,
|
|
212
386
|
discount_percent: l.discount_percent,
|
|
213
|
-
|
|
387
|
+
unit_discount: l.discount_amount / l.quantity,
|
|
214
388
|
line_total: (l.line_total / l.quantity) * b.quantity,
|
|
215
389
|
}));
|
|
216
390
|
}
|
|
@@ -232,7 +406,7 @@ export function ProductTransactionScreen({
|
|
|
232
406
|
purchase_order_line_id: l.purchase_order_line_id,
|
|
233
407
|
unit_price: l.unit_price,
|
|
234
408
|
discount_percent: l.discount_percent,
|
|
235
|
-
|
|
409
|
+
unit_discount: l.discount_amount / l.quantity,
|
|
236
410
|
line_total: l.line_total,
|
|
237
411
|
}];
|
|
238
412
|
}),
|
|
@@ -260,7 +434,7 @@ export function ProductTransactionScreen({
|
|
|
260
434
|
} finally {
|
|
261
435
|
setIsSubmitting(false);
|
|
262
436
|
}
|
|
263
|
-
}, [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]);
|
|
264
438
|
|
|
265
439
|
return (
|
|
266
440
|
<div className="flex flex-col h-full">
|
|
@@ -270,6 +444,7 @@ export function ProductTransactionScreen({
|
|
|
270
444
|
<div className="flex-[3] min-w-0 flex flex-col">
|
|
271
445
|
<ProductSelectionPanel
|
|
272
446
|
config={config}
|
|
447
|
+
partyId={partyId}
|
|
273
448
|
warehouseId={warehouseId ?? undefined}
|
|
274
449
|
lineItems={lineItems}
|
|
275
450
|
onAddItem={handleAddItem}
|
|
@@ -295,7 +470,81 @@ export function ProductTransactionScreen({
|
|
|
295
470
|
expectedDeliveryDate={expectedDeliveryDate}
|
|
296
471
|
notes={notes}
|
|
297
472
|
payments={payments}
|
|
298
|
-
onPartyChange={(id, party) => {
|
|
473
|
+
onPartyChange={async (id, party) => {
|
|
474
|
+
if (partyId !== id && lineItems.length > 0 && ['sales', 'pos', 'quotation'].includes(config.type)) {
|
|
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[]>;
|
|
488
|
+
|
|
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
|
+
}
|
|
516
|
+
|
|
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');
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
setPartyId(id);
|
|
546
|
+
setPartyName(party?.name ?? null);
|
|
547
|
+
}}
|
|
299
548
|
onWarehouseChange={(id, name) => { setWarehouseId(id); setWarehouseName(name ?? null); }}
|
|
300
549
|
onDateChange={setTransactionDate}
|
|
301
550
|
onExpectedDeliveryDateChange={setExpectedDeliveryDate}
|
|
@@ -308,6 +557,56 @@ export function ProductTransactionScreen({
|
|
|
308
557
|
/>
|
|
309
558
|
</div>
|
|
310
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
|
+
)}
|
|
311
610
|
</div>
|
|
312
611
|
);
|
|
313
612
|
}
|
|
@@ -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 {
|
|
@@ -78,12 +79,16 @@ export interface TransactionLineItem {
|
|
|
78
79
|
parent_line_uids?: string[];
|
|
79
80
|
uom_id?: number;
|
|
80
81
|
purchase_order_line_id?: number | null;
|
|
82
|
+
sales_order_line_id?: number | null;
|
|
83
|
+
sales_quotation_line_id?: number | null;
|
|
81
84
|
unit_price: number;
|
|
82
85
|
discount_percent: number;
|
|
83
86
|
discount_amount: number;
|
|
84
87
|
line_total: number;
|
|
85
88
|
notes?: string;
|
|
86
89
|
expanded?: boolean; // Whether the detail row is expanded
|
|
90
|
+
pricing_rules?: any[];
|
|
91
|
+
applied_price_rule?: any;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
import type { PaymentModeField, PaymentMode, PaymentEntry, BankAccount } from '../PaymentSection';
|
|
@@ -150,12 +155,20 @@ export interface TransactionScreenConfig {
|
|
|
150
155
|
showStock?: boolean;
|
|
151
156
|
/** Enable purchase unit selection (dynamic setting) */
|
|
152
157
|
enablePurchaseUnit?: boolean;
|
|
158
|
+
/** Enable sales unit selection (dynamic setting) */
|
|
159
|
+
enableSalesUnit?: boolean;
|
|
153
160
|
/** Show expected delivery date */
|
|
154
161
|
showExpectedDeliveryDate?: boolean;
|
|
162
|
+
/** Label for the expected delivery date field */
|
|
163
|
+
expectedDeliveryDateLabel?: string;
|
|
155
164
|
/** Disable batch/serial selection completely (e.g. for Purchase Orders and Quotations) */
|
|
156
165
|
disableTrackingSelection?: boolean;
|
|
157
166
|
/** Enforce that the sum of payments equals the grand total before allowing submission */
|
|
158
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;
|
|
159
172
|
}
|
|
160
173
|
|
|
161
174
|
// ── Module Presets ──
|
|
@@ -174,7 +187,7 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
|
|
|
174
187
|
submitLabel: 'Create Sales Order',
|
|
175
188
|
title: 'New Sale',
|
|
176
189
|
allowCreateBatchAndSerial: false,
|
|
177
|
-
allowEditPrice:
|
|
190
|
+
allowEditPrice: true,
|
|
178
191
|
showStock: true,
|
|
179
192
|
},
|
|
180
193
|
quotation: {
|
|
@@ -190,8 +203,10 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
|
|
|
190
203
|
submitLabel: 'Create Quotation',
|
|
191
204
|
title: 'New Quotation',
|
|
192
205
|
allowCreateBatchAndSerial: false,
|
|
193
|
-
allowEditPrice:
|
|
206
|
+
allowEditPrice: true,
|
|
194
207
|
showStock: true,
|
|
208
|
+
showExpectedDeliveryDate: true,
|
|
209
|
+
expectedDeliveryDateLabel: 'Valid Until',
|
|
195
210
|
disableTrackingSelection: true,
|
|
196
211
|
},
|
|
197
212
|
purchase: {
|
|
@@ -243,7 +258,7 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
|
|
|
243
258
|
submitLabel: 'Complete Sale',
|
|
244
259
|
title: 'POS Checkout',
|
|
245
260
|
allowCreateBatchAndSerial: false,
|
|
246
|
-
allowEditPrice:
|
|
261
|
+
allowEditPrice: true,
|
|
247
262
|
showStock: true,
|
|
248
263
|
},
|
|
249
264
|
opening_stock: {
|
|
@@ -308,6 +323,7 @@ export interface TransactionPayload {
|
|
|
308
323
|
transaction_date?: string;
|
|
309
324
|
expected_delivery_date?: string;
|
|
310
325
|
notes?: string;
|
|
326
|
+
revised_from_id?: number | null;
|
|
311
327
|
lines: Array<{
|
|
312
328
|
item_id: number;
|
|
313
329
|
variant_id?: number | null;
|
|
@@ -326,7 +342,7 @@ export interface TransactionPayload {
|
|
|
326
342
|
purchase_order_line_id?: number | null;
|
|
327
343
|
unit_price: number;
|
|
328
344
|
discount_percent: number;
|
|
329
|
-
|
|
345
|
+
unit_discount: number;
|
|
330
346
|
line_total: number;
|
|
331
347
|
}>;
|
|
332
348
|
payments?: PaymentEntry[];
|