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