@apptimate/ui 4.0.0 → 4.1.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/transaction/MetadataPanel.tsx +20 -2
- package/src/common-components/transaction/ParentLineSelectionModal.tsx +149 -0
- package/src/common-components/transaction/ProductSelectionPanel.tsx +132 -82
- package/src/common-components/transaction/ProductTransactionScreen.tsx +12 -48
- package/src/common-components/transaction/types.ts +14 -5
package/package.json
CHANGED
|
@@ -22,11 +22,13 @@ interface MetadataPanelProps {
|
|
|
22
22
|
warehouseId: number | null;
|
|
23
23
|
warehouseName?: string | null;
|
|
24
24
|
transactionDate?: string;
|
|
25
|
+
expectedDeliveryDate?: string;
|
|
25
26
|
notes: string;
|
|
26
27
|
payments: PaymentEntry[];
|
|
27
28
|
onPartyChange: (id: number | null, party?: PartyLookup) => void;
|
|
28
29
|
onWarehouseChange: (id: number | null, name?: string) => void;
|
|
29
30
|
onDateChange?: (date: string) => void;
|
|
31
|
+
onExpectedDeliveryDateChange?: (date: string) => void;
|
|
30
32
|
onNotesChange: (v: string) => void;
|
|
31
33
|
onPaymentsChange: (payments: PaymentEntry[]) => void;
|
|
32
34
|
onSubmit: () => void;
|
|
@@ -35,8 +37,8 @@ interface MetadataPanelProps {
|
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export function MetadataPanel({
|
|
38
|
-
config, lineItems, partyId, partyName, warehouseId, warehouseName, transactionDate, notes, payments,
|
|
39
|
-
onPartyChange, onWarehouseChange, onDateChange, onNotesChange, onPaymentsChange, onSubmit, isSubmitting,
|
|
40
|
+
config, lineItems, partyId, partyName, warehouseId, warehouseName, transactionDate, expectedDeliveryDate, notes, payments,
|
|
41
|
+
onPartyChange, onWarehouseChange, onDateChange, onExpectedDeliveryDateChange, onNotesChange, onPaymentsChange, onSubmit, isSubmitting,
|
|
40
42
|
renderExtraMeta,
|
|
41
43
|
}: MetadataPanelProps) {
|
|
42
44
|
const [paymentModes, setPaymentModes] = useState<PaymentMode[]>([]);
|
|
@@ -150,6 +152,7 @@ export function MetadataPanel({
|
|
|
150
152
|
onChange={(p) => onPartyChange(p?.id ?? null, p ? { id: p.id, name: p.name, code: p.code, type: p.type } : undefined)}
|
|
151
153
|
label={config.partyLabel}
|
|
152
154
|
partyType={config.partyType}
|
|
155
|
+
isRequired
|
|
153
156
|
/>
|
|
154
157
|
)
|
|
155
158
|
)}
|
|
@@ -180,6 +183,21 @@ export function MetadataPanel({
|
|
|
180
183
|
</Section>
|
|
181
184
|
)}
|
|
182
185
|
|
|
186
|
+
{/* ── Expected Delivery Date ── */}
|
|
187
|
+
{config.showExpectedDeliveryDate && onExpectedDeliveryDateChange && (
|
|
188
|
+
<Section label="Expected Delivery">
|
|
189
|
+
<div className="relative">
|
|
190
|
+
<Calendar size={15} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-foreground-disabled pointer-events-none" />
|
|
191
|
+
<input
|
|
192
|
+
type="date"
|
|
193
|
+
value={expectedDeliveryDate || ''}
|
|
194
|
+
onChange={(e) => onExpectedDeliveryDateChange(e.target.value)}
|
|
195
|
+
className="w-full bg-surface-0 border-[1.5px] border-border-subtle rounded-[10px] pl-10 pr-3.5 py-3 text-[13.5px] text-foreground-1 outline-none transition-all hover:border-gray-300 focus:border-primary/40"
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
</Section>
|
|
199
|
+
)}
|
|
200
|
+
|
|
183
201
|
{/* ── Notes ── */}
|
|
184
202
|
{config.showNotes && (
|
|
185
203
|
<Section label="Notes">
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Modal, ModalFooter } from "../../base-components/Modal";
|
|
3
|
+
import { Button } from "../../base-components/Button";
|
|
4
|
+
import { Table, THeader, TBody, TRow, TCell } from "../../base-components/Table";
|
|
5
|
+
import { TransactionLineItem } from "./types";
|
|
6
|
+
import { cn } from "@apptimate/core-lib";
|
|
7
|
+
|
|
8
|
+
interface ParentLineSelectionModalProps {
|
|
9
|
+
isOpen: boolean;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
lineItems: TransactionLineItem[];
|
|
12
|
+
selectedUids?: string[];
|
|
13
|
+
onSelect: (uids: string[]) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ParentLineSelectionModal({
|
|
17
|
+
isOpen,
|
|
18
|
+
onClose,
|
|
19
|
+
lineItems,
|
|
20
|
+
selectedUids = [],
|
|
21
|
+
onSelect,
|
|
22
|
+
}: ParentLineSelectionModalProps) {
|
|
23
|
+
const [selected, setSelected] = useState<string[]>([]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (isOpen) {
|
|
27
|
+
setSelected(selectedUids || []);
|
|
28
|
+
}
|
|
29
|
+
}, [isOpen, selectedUids]);
|
|
30
|
+
|
|
31
|
+
const formatCurrency = (v: number | string) =>
|
|
32
|
+
Number(v).toLocaleString("en-US", {
|
|
33
|
+
minimumFractionDigits: 2,
|
|
34
|
+
maximumFractionDigits: 2,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const handleToggle = (uid: string) => {
|
|
38
|
+
setSelected((prev) =>
|
|
39
|
+
prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid]
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleSave = () => {
|
|
44
|
+
onSelect(selected);
|
|
45
|
+
onClose();
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Modal
|
|
50
|
+
isOpen={isOpen}
|
|
51
|
+
onClose={onClose}
|
|
52
|
+
title="Select Parent Lines"
|
|
53
|
+
size="xl"
|
|
54
|
+
>
|
|
55
|
+
<div className="p-4 max-h-[60vh] overflow-y-auto custom-scrollbar">
|
|
56
|
+
{lineItems.length === 0 ? (
|
|
57
|
+
<div className="text-center text-sm text-foreground-disabled py-8">
|
|
58
|
+
No parent lines available.
|
|
59
|
+
</div>
|
|
60
|
+
) : (
|
|
61
|
+
<Table>
|
|
62
|
+
<THeader>
|
|
63
|
+
<TRow>
|
|
64
|
+
<TCell isHeader className="w-10 text-center"></TCell>
|
|
65
|
+
<TCell isHeader>Item</TCell>
|
|
66
|
+
<TCell isHeader>Qty</TCell>
|
|
67
|
+
<TCell isHeader>Unit</TCell>
|
|
68
|
+
<TCell isHeader>Rate</TCell>
|
|
69
|
+
<TCell isHeader className="text-right">
|
|
70
|
+
Total
|
|
71
|
+
</TCell>
|
|
72
|
+
</TRow>
|
|
73
|
+
</THeader>
|
|
74
|
+
<TBody>
|
|
75
|
+
{lineItems.map((line) => {
|
|
76
|
+
const isSelected = selected.includes(line.uid);
|
|
77
|
+
return (
|
|
78
|
+
<TRow
|
|
79
|
+
key={line.uid}
|
|
80
|
+
onClick={() => handleToggle(line.uid)}
|
|
81
|
+
className={cn(
|
|
82
|
+
"cursor-pointer hover:bg-surface-hover transition-colors",
|
|
83
|
+
isSelected ? "bg-primary/5" : ""
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
<TCell className="text-center">
|
|
87
|
+
<input
|
|
88
|
+
type="checkbox"
|
|
89
|
+
checked={isSelected}
|
|
90
|
+
readOnly
|
|
91
|
+
className="rounded border-border-subtle text-primary focus:ring-primary h-4 w-4 mt-1 cursor-pointer"
|
|
92
|
+
/>
|
|
93
|
+
</TCell>
|
|
94
|
+
<TCell label="Item">
|
|
95
|
+
<div className="font-medium text-[13px] text-foreground-1">
|
|
96
|
+
{line.item.name}
|
|
97
|
+
</div>
|
|
98
|
+
{line.variant_id && (
|
|
99
|
+
<div className="text-[11px] text-foreground-subtle font-mono mt-0.5">
|
|
100
|
+
{line.item.variants?.find((v) => v.id === line.variant_id)
|
|
101
|
+
?.variant_name || ""}{" "}
|
|
102
|
+
•{" "}
|
|
103
|
+
{line.item.variants?.find((v) => v.id === line.variant_id)
|
|
104
|
+
?.sku || ""}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</TCell>
|
|
108
|
+
<TCell label="Qty">
|
|
109
|
+
<span className="text-[13px] font-medium">
|
|
110
|
+
{line.quantity}
|
|
111
|
+
</span>
|
|
112
|
+
</TCell>
|
|
113
|
+
<TCell label="Unit">
|
|
114
|
+
<span className="text-[13px] text-foreground-subtle">
|
|
115
|
+
{line.item.uom?.uom_group?.uoms?.find((u: any) => String(u.id) === String(line.uom_id))?.abbreviation ||
|
|
116
|
+
line.item.uom?.abbreviation ||
|
|
117
|
+
"pcs"}
|
|
118
|
+
</span>
|
|
119
|
+
</TCell>
|
|
120
|
+
<TCell label="Rate">
|
|
121
|
+
<span className="text-[13px] text-foreground-subtle">
|
|
122
|
+
{formatCurrency(line.unit_price)}
|
|
123
|
+
</span>
|
|
124
|
+
</TCell>
|
|
125
|
+
<TCell label="Total" className="text-right">
|
|
126
|
+
<span className="text-[13px] font-semibold text-foreground-1">
|
|
127
|
+
{formatCurrency(line.line_total)}
|
|
128
|
+
</span>
|
|
129
|
+
</TCell>
|
|
130
|
+
</TRow>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
133
|
+
</TBody>
|
|
134
|
+
</Table>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
<ModalFooter>
|
|
138
|
+
<div className="flex justify-end gap-3 w-full">
|
|
139
|
+
<Button variant="flat" color="secondary" onClick={onClose}>
|
|
140
|
+
Cancel
|
|
141
|
+
</Button>
|
|
142
|
+
<Button variant="solid" color="primary" onClick={handleSave}>
|
|
143
|
+
Save Selections
|
|
144
|
+
</Button>
|
|
145
|
+
</div>
|
|
146
|
+
</ModalFooter>
|
|
147
|
+
</Modal>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -11,7 +11,9 @@ import { Table, THeader, TBody, TRow, TCell } from '../../base-components/Table'
|
|
|
11
11
|
import { transactionLookup, getItemBatches } from '@apptimate/core-lib';
|
|
12
12
|
import type { TransactionItem, TransactionLineItem, TransactionScreenConfig } from './types';
|
|
13
13
|
import { BatchSelectionModal, SerialSelectionModal } from "../pickers";
|
|
14
|
+
import { ParentLineSelectionModal } from "./ParentLineSelectionModal";
|
|
14
15
|
import { HintIcon } from '../../base-components/HintIcon';
|
|
16
|
+
|
|
15
17
|
interface ProductSelectionPanelProps {
|
|
16
18
|
config: TransactionScreenConfig;
|
|
17
19
|
warehouseId?: number;
|
|
@@ -43,6 +45,7 @@ export function ProductSelectionPanel({
|
|
|
43
45
|
const [activeBatchLineUid, setActiveBatchLineUid] = useState<string | null>(null);
|
|
44
46
|
const [activeSerialItem, setActiveSerialItem] = useState<{ item: TransactionItem; variant?: any } | null>(null);
|
|
45
47
|
const [activeSerialLineUid, setActiveSerialLineUid] = useState<string | null>(null);
|
|
48
|
+
const [activeParentLineUidFor, setActiveParentLineUidFor] = useState<string | null>(null);
|
|
46
49
|
const [autoAddEnabled, setAutoAddEnabled] = useState(true);
|
|
47
50
|
const searchTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
48
51
|
const panelRef = React.useRef<HTMLDivElement>(null);
|
|
@@ -110,9 +113,9 @@ export function ProductSelectionPanel({
|
|
|
110
113
|
setSearchResults([]);
|
|
111
114
|
setShowResults(false);
|
|
112
115
|
|
|
113
|
-
if (item.tracking_type === 'batch') {
|
|
116
|
+
if (!config.disableTrackingSelection && item.tracking_type === 'batch') {
|
|
114
117
|
setActiveBatchItem({ item, variant });
|
|
115
|
-
} else if (item.tracking_type === 'serial') {
|
|
118
|
+
} else if (!config.disableTrackingSelection && item.tracking_type === 'serial') {
|
|
116
119
|
setActiveSerialItem({ item, variant });
|
|
117
120
|
} else {
|
|
118
121
|
onAddItem(item, variant);
|
|
@@ -134,28 +137,10 @@ export function ProductSelectionPanel({
|
|
|
134
137
|
const totalQty = batchesData.reduce((sum, b) => sum + b.quantity, 0);
|
|
135
138
|
|
|
136
139
|
if (activeBatchItem) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
if (existingLine) {
|
|
144
|
-
const newQty = totalQty > 0 ? totalQty : 1;
|
|
145
|
-
const discAmt = (existingLine.unit_price * newQty * existingLine.discount_percent) / 100;
|
|
146
|
-
|
|
147
|
-
onUpdateLine(existingLine.uid, {
|
|
148
|
-
batches: batchesData,
|
|
149
|
-
quantity: newQty,
|
|
150
|
-
discount_amount: discAmt,
|
|
151
|
-
line_total: existingLine.unit_price * newQty - discAmt,
|
|
152
|
-
});
|
|
153
|
-
} else {
|
|
154
|
-
onAddItem(activeBatchItem.item, activeBatchItem.variant, {
|
|
155
|
-
batches: batchesData,
|
|
156
|
-
quantity: totalQty > 0 ? totalQty : 1
|
|
157
|
-
});
|
|
158
|
-
}
|
|
140
|
+
onAddItem(activeBatchItem.item, activeBatchItem.variant, {
|
|
141
|
+
batches: batchesData,
|
|
142
|
+
quantity: totalQty > 0 ? totalQty : 1
|
|
143
|
+
});
|
|
159
144
|
setActiveBatchItem(null);
|
|
160
145
|
}
|
|
161
146
|
};
|
|
@@ -163,25 +148,7 @@ export function ProductSelectionPanel({
|
|
|
163
148
|
const handleSerialConfirm = (serials: string[]) => {
|
|
164
149
|
if (!activeSerialItem) return;
|
|
165
150
|
|
|
166
|
-
|
|
167
|
-
const existingLine = lineItems.find(
|
|
168
|
-
(l) => l.item.id === activeSerialItem.item.id &&
|
|
169
|
-
l.variant_id === resolvedVariantId
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
if (existingLine) {
|
|
173
|
-
const newQty = serials.length;
|
|
174
|
-
const discAmt = (existingLine.unit_price * newQty * existingLine.discount_percent) / 100;
|
|
175
|
-
|
|
176
|
-
onUpdateLine(existingLine.uid, {
|
|
177
|
-
serial_numbers: serials,
|
|
178
|
-
quantity: newQty,
|
|
179
|
-
discount_amount: discAmt,
|
|
180
|
-
line_total: existingLine.unit_price * newQty - discAmt,
|
|
181
|
-
});
|
|
182
|
-
} else {
|
|
183
|
-
onAddItem(activeSerialItem.item, activeSerialItem.variant, { serial_numbers: serials, quantity: serials.length });
|
|
184
|
-
}
|
|
151
|
+
onAddItem(activeSerialItem.item, activeSerialItem.variant, { serial_numbers: serials, quantity: serials.length });
|
|
185
152
|
setActiveSerialItem(null);
|
|
186
153
|
};
|
|
187
154
|
|
|
@@ -273,13 +240,34 @@ export function ProductSelectionPanel({
|
|
|
273
240
|
|
|
274
241
|
const discAmt = (price * qty * line.discount_percent) / 100;
|
|
275
242
|
|
|
276
|
-
if (field === 'free_qty') {
|
|
277
|
-
onUpdateLine(uid, { free_qty: Math.max(0, num) });
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
243
|
if (field === 'uom_id') {
|
|
282
|
-
|
|
244
|
+
const newUomId = Number(value);
|
|
245
|
+
const uoms = line.item.uom?.uom_group?.uoms || [];
|
|
246
|
+
|
|
247
|
+
const oldUom = uoms.find((u: any) => u.id === (line.uom_id || line.item.uom_id));
|
|
248
|
+
const newUom = uoms.find((u: any) => u.id === newUomId);
|
|
249
|
+
|
|
250
|
+
let newPrice = line.unit_price;
|
|
251
|
+
|
|
252
|
+
if (oldUom && newUom) {
|
|
253
|
+
const oldFactor = Number(oldUom.conversion_factor) || 1;
|
|
254
|
+
const newFactor = Number(newUom.conversion_factor) || 1;
|
|
255
|
+
|
|
256
|
+
// Calculate the base price then multiply by the new factor
|
|
257
|
+
const basePrice = line.unit_price / oldFactor;
|
|
258
|
+
newPrice = basePrice * newFactor;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const newDiscAmt = (newPrice * line.quantity * line.discount_percent) / 100;
|
|
262
|
+
|
|
263
|
+
setEditingRates(prev => ({ ...prev, [uid]: newPrice.toFixed(2) }));
|
|
264
|
+
|
|
265
|
+
onUpdateLine(uid, {
|
|
266
|
+
uom_id: newUomId,
|
|
267
|
+
unit_price: newPrice,
|
|
268
|
+
discount_amount: newDiscAmt,
|
|
269
|
+
line_total: (newPrice * line.quantity) - newDiscAmt
|
|
270
|
+
});
|
|
283
271
|
return;
|
|
284
272
|
}
|
|
285
273
|
|
|
@@ -291,6 +279,36 @@ export function ProductSelectionPanel({
|
|
|
291
279
|
});
|
|
292
280
|
};
|
|
293
281
|
|
|
282
|
+
const handleToggleFreeQty = (uid: string, isFree: boolean) => {
|
|
283
|
+
const line = lineItems.find(l => l.uid === uid);
|
|
284
|
+
if (!line) return;
|
|
285
|
+
|
|
286
|
+
let newPrice = 0;
|
|
287
|
+
if (!isFree) {
|
|
288
|
+
const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
|
|
289
|
+
const actualVariant = line.variant_id ? line.item.variants?.find(v => v.id === line.variant_id) : (line.item.variants?.[0] || null);
|
|
290
|
+
newPrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const discAmt = (newPrice * line.quantity * line.discount_percent) / 100;
|
|
294
|
+
onUpdateLine(uid, {
|
|
295
|
+
is_free_qty: isFree,
|
|
296
|
+
unit_price: newPrice,
|
|
297
|
+
discount_amount: discAmt,
|
|
298
|
+
line_total: newPrice * line.quantity - discAmt,
|
|
299
|
+
parent_line_uids: isFree ? line.parent_line_uids : undefined,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (isFree) {
|
|
303
|
+
lineItems.forEach(l => {
|
|
304
|
+
if (l.parent_line_uids?.includes(uid)) {
|
|
305
|
+
const newUids = l.parent_line_uids.filter(id => id !== uid);
|
|
306
|
+
onUpdateLine(l.uid, { parent_line_uids: newUids.length > 0 ? newUids : undefined });
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
294
312
|
return (
|
|
295
313
|
<div className="flex flex-col h-full">
|
|
296
314
|
{/* ── Search Bar ── */}
|
|
@@ -513,7 +531,6 @@ export function ProductSelectionPanel({
|
|
|
513
531
|
<TCell isHeader className="w-[45%]">Item</TCell>
|
|
514
532
|
<TCell isHeader>Qty</TCell>
|
|
515
533
|
{config.enablePurchaseUnit && <TCell isHeader>Unit</TCell>}
|
|
516
|
-
{config.enableFreeQty && <TCell isHeader>Free Qty</TCell>}
|
|
517
534
|
{config.showPrices && <TCell isHeader>Rate</TCell>}
|
|
518
535
|
{config.showPrices && <TCell isHeader className="text-right">Total</TCell>}
|
|
519
536
|
<TCell isHeader className="w-10 text-center"></TCell>
|
|
@@ -538,7 +555,7 @@ export function ProductSelectionPanel({
|
|
|
538
555
|
? `${line.item.variants.find(v => v.id === line.variant_id)?.variant_name} • ${line.item.variants.find(v => v.id === line.variant_id)?.sku}`
|
|
539
556
|
: line.item.sku}
|
|
540
557
|
</span>
|
|
541
|
-
{
|
|
558
|
+
{!config.disableTrackingSelection && (line.item.tracking_type === 'batch' || line.item.tracking_type === 'serial') && (
|
|
542
559
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
|
543
560
|
{line.item.tracking_type === 'batch' && (() => {
|
|
544
561
|
const batchesCount = line.batches?.length || 0;
|
|
@@ -593,6 +610,50 @@ export function ProductSelectionPanel({
|
|
|
593
610
|
})()}
|
|
594
611
|
</div>
|
|
595
612
|
)}
|
|
613
|
+
{(config.type === 'purchase' || config.type === 'grn') && line.unit_price === 0 && (
|
|
614
|
+
<div className="mt-2 flex items-center gap-2">
|
|
615
|
+
<label className="flex items-center gap-1.5 cursor-pointer">
|
|
616
|
+
<input
|
|
617
|
+
type="checkbox"
|
|
618
|
+
checked={line.is_free_qty || false}
|
|
619
|
+
onChange={(e) => handleToggleFreeQty(line.uid, e.target.checked)}
|
|
620
|
+
className="rounded border-border-subtle text-primary focus:ring-primary h-3 w-3"
|
|
621
|
+
/>
|
|
622
|
+
<span className="text-[11.5px] font-medium text-foreground-subtle hover:text-foreground-1 transition-colors">Free Quantity</span>
|
|
623
|
+
</label>
|
|
624
|
+
|
|
625
|
+
{line.is_free_qty && (
|
|
626
|
+
<div className="flex items-center">
|
|
627
|
+
<button
|
|
628
|
+
onClick={() => setActiveParentLineUidFor(line.uid)}
|
|
629
|
+
className="h-6 text-[11.5px] bg-surface-1 border border-border-subtle hover:bg-surface-hover rounded-md px-2 ml-2 min-w-[120px] max-w-[180px] text-left truncate transition-colors focus:border-primary/50 focus:outline-none flex justify-between items-center"
|
|
630
|
+
>
|
|
631
|
+
<span>
|
|
632
|
+
{line.parent_line_uids && line.parent_line_uids.length > 0
|
|
633
|
+
? (() => {
|
|
634
|
+
if (line.parent_line_uids.length === 1) {
|
|
635
|
+
const p = lineItems.find((l) => l.uid === line.parent_line_uids![0]);
|
|
636
|
+
return p
|
|
637
|
+
? `${p.item.name} ${
|
|
638
|
+
p.variant_id
|
|
639
|
+
? `(${
|
|
640
|
+
p.item.variants?.find((v) => v.id === p.variant_id)?.variant_name
|
|
641
|
+
})`
|
|
642
|
+
: ""
|
|
643
|
+
}`
|
|
644
|
+
: "Select Parent Line...";
|
|
645
|
+
} else {
|
|
646
|
+
return `${line.parent_line_uids.length} Parents Selected`;
|
|
647
|
+
}
|
|
648
|
+
})()
|
|
649
|
+
: "Select Parent Line..."}
|
|
650
|
+
</span>
|
|
651
|
+
</button>
|
|
652
|
+
<HintIcon text="Select the original line item that this free quantity is associated with." />
|
|
653
|
+
</div>
|
|
654
|
+
)}
|
|
655
|
+
</div>
|
|
656
|
+
)}
|
|
596
657
|
</div>
|
|
597
658
|
</div>
|
|
598
659
|
</TCell>
|
|
@@ -622,19 +683,6 @@ export function ProductSelectionPanel({
|
|
|
622
683
|
</TCell>
|
|
623
684
|
)}
|
|
624
685
|
|
|
625
|
-
{config.enableFreeQty && (
|
|
626
|
-
<TCell label="Free Qty" className="py-2.5">
|
|
627
|
-
<input
|
|
628
|
-
type="number"
|
|
629
|
-
value={line.free_qty || 0}
|
|
630
|
-
onChange={(e) => updateLineInput(line.uid, 'free_qty', e.target.value)}
|
|
631
|
-
className="w-20 h-8 px-2 text-[13px] text-center font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors"
|
|
632
|
-
min={0}
|
|
633
|
-
step="any"
|
|
634
|
-
/>
|
|
635
|
-
</TCell>
|
|
636
|
-
)}
|
|
637
|
-
|
|
638
686
|
{config.showPrices && (
|
|
639
687
|
<TCell label="Rate" className="py-2.5">
|
|
640
688
|
<input
|
|
@@ -654,10 +702,10 @@ export function ProductSelectionPanel({
|
|
|
654
702
|
return next;
|
|
655
703
|
});
|
|
656
704
|
}}
|
|
657
|
-
disabled={!config.allowEditPrice}
|
|
705
|
+
disabled={!config.allowEditPrice || line.is_free_qty}
|
|
658
706
|
className={cn(
|
|
659
707
|
"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",
|
|
660
|
-
!config.allowEditPrice && "opacity-60 cursor-not-allowed bg-surface-hover border-transparent"
|
|
708
|
+
(!config.allowEditPrice || line.is_free_qty) && "opacity-60 cursor-not-allowed bg-surface-hover border-transparent"
|
|
661
709
|
)}
|
|
662
710
|
/>
|
|
663
711
|
</TCell>
|
|
@@ -750,19 +798,9 @@ export function ProductSelectionPanel({
|
|
|
750
798
|
return lineItems.find(l => l.uid === activeBatchLineUid)?.batches || [];
|
|
751
799
|
}
|
|
752
800
|
if (activeBatchItem) {
|
|
753
|
-
const
|
|
754
|
-
const existingLine = lineItems.find(l => l.item.id === activeBatchItem.item.id && l.variant_id === resolvedVariantId);
|
|
755
|
-
|
|
756
|
-
const existingBatches = existingLine?.batches ? [...existingLine.batches] : [];
|
|
757
|
-
|
|
801
|
+
const existingBatches: any[] = [];
|
|
758
802
|
if (activeBatchItem.preSelectedBatch) {
|
|
759
|
-
|
|
760
|
-
if (existingBatchIndex >= 0) {
|
|
761
|
-
const b = existingBatches[existingBatchIndex];
|
|
762
|
-
existingBatches[existingBatchIndex] = { ...b, quantity: Number(b.quantity) + 1 };
|
|
763
|
-
} else {
|
|
764
|
-
existingBatches.push({ ...activeBatchItem.preSelectedBatch, batch_id: activeBatchItem.preSelectedBatch.id, quantity: 1 });
|
|
765
|
-
}
|
|
803
|
+
existingBatches.push({ ...activeBatchItem.preSelectedBatch, batch_id: activeBatchItem.preSelectedBatch.id, quantity: 1 });
|
|
766
804
|
}
|
|
767
805
|
return existingBatches;
|
|
768
806
|
}
|
|
@@ -804,12 +842,24 @@ export function ProductSelectionPanel({
|
|
|
804
842
|
variant={activeSerialItem?.variant || null}
|
|
805
843
|
warehouseId={warehouseId}
|
|
806
844
|
allowCreate={config.allowCreateBatchAndSerial}
|
|
807
|
-
initialSerials={
|
|
808
|
-
|
|
809
|
-
|
|
845
|
+
initialSerials={[]}
|
|
846
|
+
onConfirm={handleSerialConfirm}
|
|
847
|
+
/>
|
|
848
|
+
|
|
849
|
+
<ParentLineSelectionModal
|
|
850
|
+
isOpen={!!activeParentLineUidFor}
|
|
851
|
+
onClose={() => setActiveParentLineUidFor(null)}
|
|
852
|
+
lineItems={lineItems.filter((l) => l.uid !== activeParentLineUidFor && !l.is_free_qty)}
|
|
853
|
+
selectedUids={
|
|
854
|
+
activeParentLineUidFor
|
|
855
|
+
? lineItems.find((l) => l.uid === activeParentLineUidFor)?.parent_line_uids || []
|
|
810
856
|
: []
|
|
811
857
|
}
|
|
812
|
-
|
|
858
|
+
onSelect={(uids) => {
|
|
859
|
+
if (activeParentLineUidFor) {
|
|
860
|
+
onUpdateLine(activeParentLineUidFor, { parent_line_uids: uids.length > 0 ? uids : undefined });
|
|
861
|
+
}
|
|
862
|
+
}}
|
|
813
863
|
/>
|
|
814
864
|
</div>
|
|
815
865
|
);
|
|
@@ -35,6 +35,7 @@ export function ProductTransactionScreen({
|
|
|
35
35
|
const [transactionDate, setTransactionDate] = useState<string>(() => {
|
|
36
36
|
return new Date().toISOString().split('T')[0];
|
|
37
37
|
});
|
|
38
|
+
const [expectedDeliveryDate, setExpectedDeliveryDate] = useState<string>('');
|
|
38
39
|
const [notes, setNotes] = useState('');
|
|
39
40
|
const [payments, setPayments] = useState<PaymentEntry[]>([]);
|
|
40
41
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
@@ -62,51 +63,6 @@ export function ProductTransactionScreen({
|
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
// Check if item+variant already exists
|
|
66
|
-
const existingIdx = prevLineItems.findIndex(
|
|
67
|
-
(l) => l.item.id === itemWithVariants.id && l.variant_id === resolvedVariantId
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
if (existingIdx >= 0) {
|
|
71
|
-
// Increment quantity
|
|
72
|
-
const updated = [...prevLineItems];
|
|
73
|
-
const existing = updated[existingIdx];
|
|
74
|
-
let mergedSerials = existing.serial_numbers || [];
|
|
75
|
-
let newSerialsCount = 0;
|
|
76
|
-
if (extra?.serial_numbers?.length) {
|
|
77
|
-
const uniqueNewSerials = extra.serial_numbers.filter(s => !mergedSerials.includes(s));
|
|
78
|
-
mergedSerials = [...mergedSerials, ...uniqueNewSerials];
|
|
79
|
-
newSerialsCount = uniqueNewSerials.length;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// If this addition includes serial numbers, only increment quantity by the number of actually new serials added.
|
|
83
|
-
// Otherwise, fallback to the requested quantity or 1.
|
|
84
|
-
const addedQty = extra?.serial_numbers ? newSerialsCount : (extra?.quantity || 1);
|
|
85
|
-
|
|
86
|
-
if (addedQty === 0 && extra?.serial_numbers) {
|
|
87
|
-
// The serial number was already added, do nothing to avoid duplicates
|
|
88
|
-
return prevLineItems;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const newQty = existing.quantity + addedQty;
|
|
92
|
-
const discountAmt = (existing.unit_price * newQty * existing.discount_percent) / 100;
|
|
93
|
-
|
|
94
|
-
let mergedBatches = existing.batches;
|
|
95
|
-
if (extra?.batches?.length) {
|
|
96
|
-
mergedBatches = [...(mergedBatches || []), ...extra.batches];
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
updated[existingIdx] = {
|
|
100
|
-
...existing,
|
|
101
|
-
quantity: newQty,
|
|
102
|
-
discount_amount: discountAmt,
|
|
103
|
-
line_total: existing.unit_price * newQty - discountAmt,
|
|
104
|
-
batches: mergedBatches,
|
|
105
|
-
serial_numbers: mergedSerials,
|
|
106
|
-
};
|
|
107
|
-
return updated;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
66
|
const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
|
|
111
67
|
const unitPrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
|
|
112
68
|
const qty = extra?.quantity || 1;
|
|
@@ -164,6 +120,7 @@ export function ProductTransactionScreen({
|
|
|
164
120
|
party_id: partyId,
|
|
165
121
|
warehouse_id: warehouseId!,
|
|
166
122
|
transaction_date: transactionDate,
|
|
123
|
+
expected_delivery_date: expectedDeliveryDate || undefined,
|
|
167
124
|
notes: notes || undefined,
|
|
168
125
|
lines: lineItems.flatMap((l): TransactionPayload['lines'] => {
|
|
169
126
|
if (l.batches && l.batches.length > 0) {
|
|
@@ -178,7 +135,9 @@ export function ProductTransactionScreen({
|
|
|
178
135
|
selling_price: b.selling_price,
|
|
179
136
|
serial_numbers: undefined,
|
|
180
137
|
quantity: b.quantity,
|
|
181
|
-
|
|
138
|
+
uid: l.uid,
|
|
139
|
+
is_free_qty: l.is_free_qty,
|
|
140
|
+
parent_line_uids: l.parent_line_uids,
|
|
182
141
|
uom_id: l.uom_id,
|
|
183
142
|
unit_price: l.unit_price,
|
|
184
143
|
discount_percent: l.discount_percent,
|
|
@@ -197,7 +156,9 @@ export function ProductTransactionScreen({
|
|
|
197
156
|
selling_price: undefined,
|
|
198
157
|
serial_numbers: l.serial_numbers,
|
|
199
158
|
quantity: l.quantity,
|
|
200
|
-
|
|
159
|
+
uid: l.uid,
|
|
160
|
+
is_free_qty: l.is_free_qty,
|
|
161
|
+
parent_line_uids: l.parent_line_uids,
|
|
201
162
|
uom_id: l.uom_id,
|
|
202
163
|
unit_price: l.unit_price,
|
|
203
164
|
discount_percent: l.discount_percent,
|
|
@@ -219,6 +180,7 @@ export function ProductTransactionScreen({
|
|
|
219
180
|
setPayments([]);
|
|
220
181
|
setNotes('');
|
|
221
182
|
setTransactionDate(new Date().toISOString().split('T')[0]);
|
|
183
|
+
setExpectedDeliveryDate('');
|
|
222
184
|
if (!defaultWarehouseId) { setWarehouseId(null); setWarehouseName(null); }
|
|
223
185
|
} catch (error) {
|
|
224
186
|
// The parent usually displays a toast error. We catch it here
|
|
@@ -228,7 +190,7 @@ export function ProductTransactionScreen({
|
|
|
228
190
|
} finally {
|
|
229
191
|
setIsSubmitting(false);
|
|
230
192
|
}
|
|
231
|
-
}, [lineItems, config, partyId, warehouseId, transactionDate, notes, payments, subtotal, discountTotal, grandTotal, onSubmit, defaultWarehouseId]);
|
|
193
|
+
}, [lineItems, config, partyId, warehouseId, transactionDate, expectedDeliveryDate, notes, payments, subtotal, discountTotal, grandTotal, onSubmit, defaultWarehouseId]);
|
|
232
194
|
|
|
233
195
|
return (
|
|
234
196
|
<div className="flex flex-col h-full">
|
|
@@ -260,11 +222,13 @@ export function ProductTransactionScreen({
|
|
|
260
222
|
warehouseId={warehouseId}
|
|
261
223
|
warehouseName={warehouseName}
|
|
262
224
|
transactionDate={transactionDate}
|
|
225
|
+
expectedDeliveryDate={expectedDeliveryDate}
|
|
263
226
|
notes={notes}
|
|
264
227
|
payments={payments}
|
|
265
228
|
onPartyChange={(id, party) => { setPartyId(id); setPartyName(party?.name ?? null); }}
|
|
266
229
|
onWarehouseChange={(id, name) => { setWarehouseId(id); setWarehouseName(name ?? null); }}
|
|
267
230
|
onDateChange={setTransactionDate}
|
|
231
|
+
onExpectedDeliveryDateChange={setExpectedDeliveryDate}
|
|
268
232
|
onNotesChange={setNotes}
|
|
269
233
|
onPaymentsChange={setPayments}
|
|
270
234
|
onSubmit={handleSubmit}
|
|
@@ -25,7 +25,7 @@ export interface TransactionItem {
|
|
|
25
25
|
abbreviation: string;
|
|
26
26
|
uom_group?: {
|
|
27
27
|
id: number;
|
|
28
|
-
uoms: { id: number; name: string; abbreviation: string; base_uom_id?: number | null }[];
|
|
28
|
+
uoms: { id: number; name: string; abbreviation: string; base_uom_id?: number | null; conversion_factor: number }[];
|
|
29
29
|
};
|
|
30
30
|
};
|
|
31
31
|
variants?: TransactionItemVariant[];
|
|
@@ -74,7 +74,8 @@ export interface TransactionLineItem {
|
|
|
74
74
|
batches?: TransactionLineBatch[];
|
|
75
75
|
serial_numbers?: string[];
|
|
76
76
|
quantity: number;
|
|
77
|
-
|
|
77
|
+
is_free_qty?: boolean;
|
|
78
|
+
parent_line_uids?: string[];
|
|
78
79
|
uom_id?: number;
|
|
79
80
|
unit_price: number;
|
|
80
81
|
discount_percent: number;
|
|
@@ -144,10 +145,12 @@ export interface TransactionScreenConfig {
|
|
|
144
145
|
allowEditPrice?: boolean;
|
|
145
146
|
/** Whether to show available stock for variants in the search dropdown */
|
|
146
147
|
showStock?: boolean;
|
|
147
|
-
/** Enable free quantity input (dynamic setting) */
|
|
148
|
-
enableFreeQty?: boolean;
|
|
149
148
|
/** Enable purchase unit selection (dynamic setting) */
|
|
150
149
|
enablePurchaseUnit?: boolean;
|
|
150
|
+
/** Show expected delivery date */
|
|
151
|
+
showExpectedDeliveryDate?: boolean;
|
|
152
|
+
/** Disable batch/serial selection completely (e.g. for Purchase Orders and Quotations) */
|
|
153
|
+
disableTrackingSelection?: boolean;
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
// ── Module Presets ──
|
|
@@ -184,6 +187,7 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
|
|
|
184
187
|
allowCreateBatchAndSerial: false,
|
|
185
188
|
allowEditPrice: false,
|
|
186
189
|
showStock: true,
|
|
190
|
+
disableTrackingSelection: true,
|
|
187
191
|
},
|
|
188
192
|
purchase: {
|
|
189
193
|
type: 'purchase',
|
|
@@ -200,6 +204,8 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
|
|
|
200
204
|
allowCreateBatchAndSerial: true,
|
|
201
205
|
allowEditPrice: true,
|
|
202
206
|
showStock: true,
|
|
207
|
+
showExpectedDeliveryDate: true,
|
|
208
|
+
disableTrackingSelection: true,
|
|
203
209
|
},
|
|
204
210
|
transfer: {
|
|
205
211
|
type: 'transfer',
|
|
@@ -290,6 +296,7 @@ export interface TransactionPayload {
|
|
|
290
296
|
party_id?: number | null;
|
|
291
297
|
warehouse_id: number;
|
|
292
298
|
transaction_date?: string;
|
|
299
|
+
expected_delivery_date?: string;
|
|
293
300
|
notes?: string;
|
|
294
301
|
lines: Array<{
|
|
295
302
|
item_id: number;
|
|
@@ -302,7 +309,9 @@ export interface TransactionPayload {
|
|
|
302
309
|
selling_price?: number;
|
|
303
310
|
serial_numbers?: string[];
|
|
304
311
|
quantity: number;
|
|
305
|
-
|
|
312
|
+
uid: string;
|
|
313
|
+
is_free_qty?: boolean;
|
|
314
|
+
parent_line_uids?: string[];
|
|
306
315
|
uom_id?: number;
|
|
307
316
|
unit_price: number;
|
|
308
317
|
discount_percent: number;
|