@apptimate/ui 4.0.0 → 4.2.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/DashboardLayout.tsx +23 -2
- package/src/common-components/transaction/MetadataPanel.tsx +32 -74
- package/src/common-components/transaction/ParentLineSelectionModal.tsx +149 -0
- package/src/common-components/transaction/ProductSelectionPanel.tsx +158 -89
- package/src/common-components/transaction/ProductTransactionScreen.tsx +84 -49
- package/src/common-components/transaction/types.ts +26 -6
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ import { Briefcase, CreditCard, Settings, Menu, X, LogOut, User as UserIcon, Bui
|
|
|
5
5
|
import { Dropdown, DropdownItem } from '../base-components/Dropdown';
|
|
6
6
|
|
|
7
7
|
import Link from 'next/link';
|
|
8
|
-
import { usePathname } from 'next/navigation';
|
|
8
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
9
9
|
import { cn } from '@apptimate/core-lib';
|
|
10
10
|
|
|
11
11
|
export type DashboardMenuConfig = {
|
|
@@ -68,6 +68,7 @@ export function DashboardLayout({
|
|
|
68
68
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
69
69
|
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
|
|
70
70
|
const pathname = usePathname() || "";
|
|
71
|
+
const router = useRouter();
|
|
71
72
|
|
|
72
73
|
// Find which main menu should be active based on current path.
|
|
73
74
|
// When basePath is set, prefer the menu whose items are mostly within basePath.
|
|
@@ -106,6 +107,26 @@ export function DashboardLayout({
|
|
|
106
107
|
|
|
107
108
|
const activeMenu = menus.find(m => m.id === activeMenuId) || menus[0];
|
|
108
109
|
|
|
110
|
+
const handleMainMenuSelect = (menu: DashboardMenuConfig) => {
|
|
111
|
+
setActiveMenuId(menu.id);
|
|
112
|
+
|
|
113
|
+
const firstItem = menu.groups.flatMap(group => group.items)[0];
|
|
114
|
+
if (!firstItem) return;
|
|
115
|
+
|
|
116
|
+
const isInternal = basePath
|
|
117
|
+
? firstItem.path.startsWith(basePath)
|
|
118
|
+
: !externalPaths.some(path => firstItem.path.startsWith(path));
|
|
119
|
+
const href = basePath && isInternal
|
|
120
|
+
? firstItem.path.replace(basePath, "") || "/"
|
|
121
|
+
: firstItem.path;
|
|
122
|
+
|
|
123
|
+
if (isInternal) {
|
|
124
|
+
router.push(href);
|
|
125
|
+
} else {
|
|
126
|
+
window.location.href = href;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
109
130
|
const handleSelectOrg = (org: OrganizationConfig) => {
|
|
110
131
|
onOrganizationChange?.(org);
|
|
111
132
|
setIsOrgModalOpen(false);
|
|
@@ -158,7 +179,7 @@ export function DashboardLayout({
|
|
|
158
179
|
return (
|
|
159
180
|
<div
|
|
160
181
|
key={menu.id}
|
|
161
|
-
onClick={() =>
|
|
182
|
+
onClick={() => handleMainMenuSelect(menu)}
|
|
162
183
|
className={`flex flex-col items-center justify-center gap-1.5 cursor-pointer transition-colors w-full px-1 ${isActive ? "text-[#2D3142]" : "text-gray-400 hover:text-[#2D3142]"
|
|
163
184
|
}`}
|
|
164
185
|
>
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from 'lucide-react';
|
|
10
10
|
import { getPaymentModes, getBankAccountsLookup } from '@apptimate/core-lib';
|
|
11
11
|
import { PartyPicker, WarehousePicker } from '../pickers';
|
|
12
|
+
import { PaymentSection } from '../PaymentSection';
|
|
12
13
|
import type {
|
|
13
14
|
TransactionScreenConfig, PaymentMode, PaymentEntry, BankAccount,
|
|
14
15
|
PartyLookup, PaymentModeField, TransactionLineItem,
|
|
@@ -22,22 +23,25 @@ interface MetadataPanelProps {
|
|
|
22
23
|
warehouseId: number | null;
|
|
23
24
|
warehouseName?: string | null;
|
|
24
25
|
transactionDate?: string;
|
|
26
|
+
expectedDeliveryDate?: string;
|
|
25
27
|
notes: string;
|
|
26
28
|
payments: PaymentEntry[];
|
|
27
29
|
onPartyChange: (id: number | null, party?: PartyLookup) => void;
|
|
28
30
|
onWarehouseChange: (id: number | null, name?: string) => void;
|
|
29
31
|
onDateChange?: (date: string) => void;
|
|
32
|
+
onExpectedDeliveryDateChange?: (date: string) => void;
|
|
30
33
|
onNotesChange: (v: string) => void;
|
|
31
34
|
onPaymentsChange: (payments: PaymentEntry[]) => void;
|
|
32
35
|
onSubmit: () => void;
|
|
33
36
|
isSubmitting: boolean;
|
|
34
37
|
renderExtraMeta?: () => React.ReactNode;
|
|
38
|
+
fetchChequeLeaves?: (query: string, page: number) => Promise<{ is_success: boolean; result?: any[] }>;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export function MetadataPanel({
|
|
38
|
-
config, lineItems, partyId, partyName, warehouseId, warehouseName, transactionDate, notes, payments,
|
|
39
|
-
onPartyChange, onWarehouseChange, onDateChange, onNotesChange, onPaymentsChange, onSubmit, isSubmitting,
|
|
40
|
-
renderExtraMeta,
|
|
42
|
+
config, lineItems, partyId, partyName, warehouseId, warehouseName, transactionDate, expectedDeliveryDate, notes, payments,
|
|
43
|
+
onPartyChange, onWarehouseChange, onDateChange, onExpectedDeliveryDateChange, onNotesChange, onPaymentsChange, onSubmit, isSubmitting,
|
|
44
|
+
renderExtraMeta, fetchChequeLeaves,
|
|
41
45
|
}: MetadataPanelProps) {
|
|
42
46
|
const [paymentModes, setPaymentModes] = useState<PaymentMode[]>([]);
|
|
43
47
|
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
|
|
@@ -150,6 +154,7 @@ export function MetadataPanel({
|
|
|
150
154
|
onChange={(p) => onPartyChange(p?.id ?? null, p ? { id: p.id, name: p.name, code: p.code, type: p.type } : undefined)}
|
|
151
155
|
label={config.partyLabel}
|
|
152
156
|
partyType={config.partyType}
|
|
157
|
+
isRequired
|
|
153
158
|
/>
|
|
154
159
|
)
|
|
155
160
|
)}
|
|
@@ -180,6 +185,21 @@ export function MetadataPanel({
|
|
|
180
185
|
</Section>
|
|
181
186
|
)}
|
|
182
187
|
|
|
188
|
+
{/* ── Expected Delivery Date ── */}
|
|
189
|
+
{config.showExpectedDeliveryDate && onExpectedDeliveryDateChange && (
|
|
190
|
+
<Section label="Expected Delivery">
|
|
191
|
+
<div className="relative">
|
|
192
|
+
<Calendar size={15} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-foreground-disabled pointer-events-none" />
|
|
193
|
+
<input
|
|
194
|
+
type="date"
|
|
195
|
+
value={expectedDeliveryDate || ''}
|
|
196
|
+
onChange={(e) => onExpectedDeliveryDateChange(e.target.value)}
|
|
197
|
+
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"
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
</Section>
|
|
201
|
+
)}
|
|
202
|
+
|
|
183
203
|
{/* ── Notes ── */}
|
|
184
204
|
{config.showNotes && (
|
|
185
205
|
<Section label="Notes">
|
|
@@ -208,77 +228,15 @@ export function MetadataPanel({
|
|
|
208
228
|
{/* ── Payment Section ── */}
|
|
209
229
|
{config.showPayment && (
|
|
210
230
|
<Section label="Payment">
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
return (
|
|
221
|
-
<PaymentEntryRow
|
|
222
|
-
key={idx}
|
|
223
|
-
entry={entry}
|
|
224
|
-
mode={mode}
|
|
225
|
-
bankAccounts={bankAccounts}
|
|
226
|
-
onUpdate={(updates) => updatePayment(idx, updates)}
|
|
227
|
-
onRemove={() => removePayment(idx)}
|
|
228
|
-
/>
|
|
229
|
-
);
|
|
230
|
-
})}
|
|
231
|
-
|
|
232
|
-
{/* Add payment mode buttons */}
|
|
233
|
-
{(lineItems.length > 0) && (
|
|
234
|
-
<div className="flex flex-wrap gap-2 pt-1">
|
|
235
|
-
{paymentModes
|
|
236
|
-
.filter((m) => m.is_active)
|
|
237
|
-
.map((mode) => {
|
|
238
|
-
const modeIcon = mode.code === 'cash' ? <Banknote size={14} /> :
|
|
239
|
-
mode.code === 'card' ? <CreditCard size={14} /> :
|
|
240
|
-
mode.code === 'bank_transfer' ? <Building size={14} /> :
|
|
241
|
-
<CreditCard size={14} />;
|
|
242
|
-
return (
|
|
243
|
-
<button
|
|
244
|
-
key={mode.id}
|
|
245
|
-
onClick={() => addPayment(mode)}
|
|
246
|
-
className="inline-flex items-center gap-2 px-4 py-2 text-[12px] font-semibold rounded-[10px] bg-surface-0 border border-border-subtle text-foreground-1 hover:border-primary/40 hover:bg-primary/5 hover:text-primary transition-all"
|
|
247
|
-
>
|
|
248
|
-
{modeIcon}
|
|
249
|
-
{mode.name}
|
|
250
|
-
</button>
|
|
251
|
-
);
|
|
252
|
-
})}
|
|
253
|
-
</div>
|
|
254
|
-
)}
|
|
255
|
-
|
|
256
|
-
{/* Balance */}
|
|
257
|
-
{payments.length > 0 && (
|
|
258
|
-
<div className="bg-surface-0 rounded-[10px] border border-border-subtle p-3.5 mt-2 space-y-1.5">
|
|
259
|
-
<TotalRow label="Total Paid" value={formatCurrency(totalPaid)} />
|
|
260
|
-
<TotalRow
|
|
261
|
-
label={balance > 0.01 ? "Balance Due" : balance < -0.01 ? "Change Due" : "Settled"}
|
|
262
|
-
value={formatCurrency(Math.abs(balance))}
|
|
263
|
-
className={balance > 0.01 ? 'text-danger-alt' : balance < -0.01 ? 'text-amber-600' : 'text-green-600'}
|
|
264
|
-
bold
|
|
265
|
-
/>
|
|
266
|
-
{balance < -0.01 && (
|
|
267
|
-
<div className="flex items-center gap-1.5 mt-1 text-[11px] text-amber-600 font-medium">
|
|
268
|
-
<AlertCircle size={12} />
|
|
269
|
-
<span>Change: {formatCurrency(Math.abs(balance))}</span>
|
|
270
|
-
</div>
|
|
271
|
-
)}
|
|
272
|
-
{Math.abs(balance) < 0.01 && (
|
|
273
|
-
<div className="flex items-center gap-1.5 mt-1 text-[11px] text-green-600 font-medium">
|
|
274
|
-
<Check size={12} />
|
|
275
|
-
<span>Fully paid</span>
|
|
276
|
-
</div>
|
|
277
|
-
)}
|
|
278
|
-
</div>
|
|
279
|
-
)}
|
|
280
|
-
</div>
|
|
281
|
-
)}
|
|
231
|
+
<PaymentSection
|
|
232
|
+
totalAmount={grandTotal}
|
|
233
|
+
payments={payments}
|
|
234
|
+
onPaymentsChange={onPaymentsChange}
|
|
235
|
+
fetchPaymentModes={getPaymentModes}
|
|
236
|
+
fetchBankAccounts={getBankAccountsLookup}
|
|
237
|
+
fetchChequeLeaves={fetchChequeLeaves}
|
|
238
|
+
compact={true}
|
|
239
|
+
/>
|
|
282
240
|
</Section>
|
|
283
241
|
)}
|
|
284
242
|
|
|
@@ -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,12 +279,43 @@ 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 ── */}
|
|
297
|
-
|
|
298
|
-
<div className="
|
|
299
|
-
<
|
|
315
|
+
{!config.disableItemSelection && (
|
|
316
|
+
<div ref={panelRef} className="relative mb-4">
|
|
317
|
+
<div className="flex items-center gap-2 bg-surface-0 border-[1.5px] border-border-subtle rounded-[12px] px-4 py-2.5 focus-within:border-primary/50 transition-all">
|
|
318
|
+
<Search size={18} className="text-foreground-disabled shrink-0" />
|
|
300
319
|
<input
|
|
301
320
|
ref={inputRef}
|
|
302
321
|
type="text"
|
|
@@ -495,6 +514,7 @@ export function ProductSelectionPanel({
|
|
|
495
514
|
</div>
|
|
496
515
|
)}
|
|
497
516
|
</div>
|
|
517
|
+
)}
|
|
498
518
|
|
|
499
519
|
{/* ── Selected Items Table ── */}
|
|
500
520
|
<div className="flex-1 overflow-y-auto custom-scrollbar w-full">
|
|
@@ -513,7 +533,6 @@ export function ProductSelectionPanel({
|
|
|
513
533
|
<TCell isHeader className="w-[45%]">Item</TCell>
|
|
514
534
|
<TCell isHeader>Qty</TCell>
|
|
515
535
|
{config.enablePurchaseUnit && <TCell isHeader>Unit</TCell>}
|
|
516
|
-
{config.enableFreeQty && <TCell isHeader>Free Qty</TCell>}
|
|
517
536
|
{config.showPrices && <TCell isHeader>Rate</TCell>}
|
|
518
537
|
{config.showPrices && <TCell isHeader className="text-right">Total</TCell>}
|
|
519
538
|
<TCell isHeader className="w-10 text-center"></TCell>
|
|
@@ -532,13 +551,20 @@ export function ProductSelectionPanel({
|
|
|
532
551
|
)}
|
|
533
552
|
</div>
|
|
534
553
|
<div className="flex flex-col flex-1 min-w-0">
|
|
535
|
-
<span className="text-[13px] font-semibold text-foreground-1 leading-tight break-words">
|
|
554
|
+
<span className="text-[13px] font-semibold text-foreground-1 leading-tight break-words">
|
|
555
|
+
{line.item.name}
|
|
556
|
+
{line.is_free_qty && (
|
|
557
|
+
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold bg-green-100 text-green-700 uppercase tracking-wider align-middle">
|
|
558
|
+
Free
|
|
559
|
+
</span>
|
|
560
|
+
)}
|
|
561
|
+
</span>
|
|
536
562
|
<span className="text-[11px] text-foreground-subtle font-mono mt-0.5 break-words">
|
|
537
|
-
{line.variant_id && line.item.variants?.find(v => v.id === line.variant_id)
|
|
563
|
+
{line.item.has_variants && line.variant_id && line.item.variants?.find(v => v.id === line.variant_id)
|
|
538
564
|
? `${line.item.variants.find(v => v.id === line.variant_id)?.variant_name} • ${line.item.variants.find(v => v.id === line.variant_id)?.sku}`
|
|
539
565
|
: line.item.sku}
|
|
540
566
|
</span>
|
|
541
|
-
{
|
|
567
|
+
{!config.disableTrackingSelection && (line.item.tracking_type === 'batch' || line.item.tracking_type === 'serial') && (
|
|
542
568
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
|
543
569
|
{line.item.tracking_type === 'batch' && (() => {
|
|
544
570
|
const batchesCount = line.batches?.length || 0;
|
|
@@ -593,6 +619,59 @@ export function ProductSelectionPanel({
|
|
|
593
619
|
})()}
|
|
594
620
|
</div>
|
|
595
621
|
)}
|
|
622
|
+
{(config.type === 'purchase' || config.type === 'grn') && Number(line.unit_price) === 0 && (
|
|
623
|
+
<div className="mt-2 flex items-center gap-2">
|
|
624
|
+
<label className={cn("flex items-center gap-1.5", !!line.purchase_order_line_id ? "cursor-not-allowed" : "cursor-pointer")}>
|
|
625
|
+
<input
|
|
626
|
+
type="checkbox"
|
|
627
|
+
checked={line.is_free_qty || false}
|
|
628
|
+
onChange={(e) => handleToggleFreeQty(line.uid, e.target.checked)}
|
|
629
|
+
disabled={!!line.purchase_order_line_id}
|
|
630
|
+
className={cn(
|
|
631
|
+
"rounded border-border-subtle text-primary h-3 w-3",
|
|
632
|
+
!!line.purchase_order_line_id ? "opacity-50 cursor-not-allowed" : "focus:ring-primary"
|
|
633
|
+
)}
|
|
634
|
+
/>
|
|
635
|
+
<span className={cn(
|
|
636
|
+
"text-[11.5px] font-medium transition-colors",
|
|
637
|
+
!!line.purchase_order_line_id ? "text-foreground-disabled" : "text-foreground-subtle hover:text-foreground-1"
|
|
638
|
+
)}>
|
|
639
|
+
Free Quantity
|
|
640
|
+
</span>
|
|
641
|
+
</label>
|
|
642
|
+
|
|
643
|
+
{line.is_free_qty && (
|
|
644
|
+
<div className="flex items-center">
|
|
645
|
+
<button
|
|
646
|
+
onClick={() => setActiveParentLineUidFor(line.uid)}
|
|
647
|
+
disabled={!!line.purchase_order_line_id}
|
|
648
|
+
className={cn(
|
|
649
|
+
"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",
|
|
650
|
+
!!line.purchase_order_line_id ? "opacity-60 cursor-not-allowed" : "hover:bg-surface-hover focus:border-primary/50 focus:outline-none"
|
|
651
|
+
)}
|
|
652
|
+
>
|
|
653
|
+
<span>
|
|
654
|
+
{line.parent_line_uids && line.parent_line_uids.length > 0
|
|
655
|
+
? (() => {
|
|
656
|
+
if (line.parent_line_uids.length === 1) {
|
|
657
|
+
const p = lineItems.find((l) => l.uid === line.parent_line_uids![0]);
|
|
658
|
+
return p
|
|
659
|
+
? p.item.has_variants && p.variant_id && p.item.variants?.find((v) => v.id === p.variant_id)
|
|
660
|
+
? `${p.item.name} (${p.item.variants.find((v) => v.id === p.variant_id)?.variant_name})`
|
|
661
|
+
: p.item.name
|
|
662
|
+
: "Select Parent Line...";
|
|
663
|
+
} else {
|
|
664
|
+
return `${line.parent_line_uids.length} Parents Selected`;
|
|
665
|
+
}
|
|
666
|
+
})()
|
|
667
|
+
: "Select Parent Line..."}
|
|
668
|
+
</span>
|
|
669
|
+
</button>
|
|
670
|
+
<HintIcon text="Select the original line item that this free quantity is associated with." />
|
|
671
|
+
</div>
|
|
672
|
+
)}
|
|
673
|
+
</div>
|
|
674
|
+
)}
|
|
596
675
|
</div>
|
|
597
676
|
</div>
|
|
598
677
|
</TCell>
|
|
@@ -613,7 +692,8 @@ export function ProductSelectionPanel({
|
|
|
613
692
|
<select
|
|
614
693
|
value={line.uom_id || line.item.uom_id || ''}
|
|
615
694
|
onChange={(e) => updateLineInput(line.uid, 'uom_id', e.target.value)}
|
|
616
|
-
|
|
695
|
+
disabled={line.item.tracking_type === 'serial'}
|
|
696
|
+
className="h-8 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"
|
|
617
697
|
>
|
|
618
698
|
{line.item.uom?.uom_group?.uoms?.map((uom: any) => (
|
|
619
699
|
<option key={uom.id} value={uom.id}>{uom.abbreviation}</option>
|
|
@@ -622,24 +702,11 @@ export function ProductSelectionPanel({
|
|
|
622
702
|
</TCell>
|
|
623
703
|
)}
|
|
624
704
|
|
|
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
705
|
{config.showPrices && (
|
|
639
706
|
<TCell label="Rate" className="py-2.5">
|
|
640
707
|
<input
|
|
641
708
|
type="text"
|
|
642
|
-
value={editingRates[line.uid] ?? line.unit_price.toFixed(2)}
|
|
709
|
+
value={editingRates[line.uid] ?? Number(line.unit_price || 0).toFixed(2)}
|
|
643
710
|
onChange={(e) => {
|
|
644
711
|
const val = e.target.value;
|
|
645
712
|
if (/^\d*\.?\d{0,2}$/.test(val)) {
|
|
@@ -654,10 +721,10 @@ export function ProductSelectionPanel({
|
|
|
654
721
|
return next;
|
|
655
722
|
});
|
|
656
723
|
}}
|
|
657
|
-
disabled={!config.allowEditPrice}
|
|
724
|
+
disabled={!config.allowEditPrice || line.is_free_qty}
|
|
658
725
|
className={cn(
|
|
659
726
|
"w-24 h-8 px-2 text-[13px] text-right font-medium bg-surface-1 border border-border-subtle rounded-md focus:border-primary/50 focus:outline-none transition-colors",
|
|
660
|
-
!config.allowEditPrice && "opacity-60 cursor-not-allowed bg-surface-hover border-transparent"
|
|
727
|
+
(!config.allowEditPrice || line.is_free_qty) && "opacity-60 cursor-not-allowed bg-surface-hover border-transparent"
|
|
661
728
|
)}
|
|
662
729
|
/>
|
|
663
730
|
</TCell>
|
|
@@ -750,19 +817,9 @@ export function ProductSelectionPanel({
|
|
|
750
817
|
return lineItems.find(l => l.uid === activeBatchLineUid)?.batches || [];
|
|
751
818
|
}
|
|
752
819
|
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
|
-
|
|
820
|
+
const existingBatches: any[] = [];
|
|
758
821
|
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
|
-
}
|
|
822
|
+
existingBatches.push({ ...activeBatchItem.preSelectedBatch, batch_id: activeBatchItem.preSelectedBatch.id, quantity: 1 });
|
|
766
823
|
}
|
|
767
824
|
return existingBatches;
|
|
768
825
|
}
|
|
@@ -804,12 +861,24 @@ export function ProductSelectionPanel({
|
|
|
804
861
|
variant={activeSerialItem?.variant || null}
|
|
805
862
|
warehouseId={warehouseId}
|
|
806
863
|
allowCreate={config.allowCreateBatchAndSerial}
|
|
807
|
-
initialSerials={
|
|
808
|
-
|
|
809
|
-
|
|
864
|
+
initialSerials={[]}
|
|
865
|
+
onConfirm={handleSerialConfirm}
|
|
866
|
+
/>
|
|
867
|
+
|
|
868
|
+
<ParentLineSelectionModal
|
|
869
|
+
isOpen={!!activeParentLineUidFor}
|
|
870
|
+
onClose={() => setActiveParentLineUidFor(null)}
|
|
871
|
+
lineItems={lineItems.filter((l) => l.uid !== activeParentLineUidFor && !l.is_free_qty)}
|
|
872
|
+
selectedUids={
|
|
873
|
+
activeParentLineUidFor
|
|
874
|
+
? lineItems.find((l) => l.uid === activeParentLineUidFor)?.parent_line_uids || []
|
|
810
875
|
: []
|
|
811
876
|
}
|
|
812
|
-
|
|
877
|
+
onSelect={(uids) => {
|
|
878
|
+
if (activeParentLineUidFor) {
|
|
879
|
+
onUpdateLine(activeParentLineUidFor, { parent_line_uids: uids.length > 0 ? uids : undefined });
|
|
880
|
+
}
|
|
881
|
+
}}
|
|
813
882
|
/>
|
|
814
883
|
</div>
|
|
815
884
|
);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState, useCallback, useMemo } from 'react';
|
|
3
|
+
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
|
4
4
|
import { cn } from '@apptimate/core-lib';
|
|
5
|
+
import toast from 'react-hot-toast';
|
|
5
6
|
import { ProductSelectionPanel } from './ProductSelectionPanel';
|
|
6
7
|
import { MetadataPanel } from './MetadataPanel';
|
|
7
8
|
import type {
|
|
@@ -25,6 +26,8 @@ export function ProductTransactionScreen({
|
|
|
25
26
|
onSubmit,
|
|
26
27
|
isSubmitting: externalSubmitting,
|
|
27
28
|
renderExtraMeta,
|
|
29
|
+
fetchChequeLeaves,
|
|
30
|
+
initialData,
|
|
28
31
|
}: ProductTransactionScreenProps) {
|
|
29
32
|
// ── State ──
|
|
30
33
|
const [lineItems, setLineItems] = useState<TransactionLineItem[]>([]);
|
|
@@ -35,12 +38,50 @@ export function ProductTransactionScreen({
|
|
|
35
38
|
const [transactionDate, setTransactionDate] = useState<string>(() => {
|
|
36
39
|
return new Date().toISOString().split('T')[0];
|
|
37
40
|
});
|
|
41
|
+
const [expectedDeliveryDate, setExpectedDeliveryDate] = useState<string>('');
|
|
38
42
|
const [notes, setNotes] = useState('');
|
|
39
43
|
const [payments, setPayments] = useState<PaymentEntry[]>([]);
|
|
40
44
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
41
45
|
|
|
42
46
|
const submitting = externalSubmitting ?? isSubmitting;
|
|
43
47
|
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (initialData) {
|
|
50
|
+
if (initialData.party_id) {
|
|
51
|
+
setPartyId(initialData.party_id);
|
|
52
|
+
if (initialData.party?.name) setPartyName(initialData.party.name);
|
|
53
|
+
}
|
|
54
|
+
if (initialData.warehouse_id) {
|
|
55
|
+
setWarehouseId(initialData.warehouse_id);
|
|
56
|
+
}
|
|
57
|
+
if (initialData.lines && Array.isArray(initialData.lines)) {
|
|
58
|
+
setLineItems(
|
|
59
|
+
initialData.lines.map((l: any) => {
|
|
60
|
+
const qty = l.quantity || 1;
|
|
61
|
+
const unitPrice = l.unit_price || 0;
|
|
62
|
+
return {
|
|
63
|
+
uid: l.uid || Math.random().toString(36).slice(2, 7),
|
|
64
|
+
item: l.item,
|
|
65
|
+
variant_id: l.variant_id || null,
|
|
66
|
+
batches: [],
|
|
67
|
+
serial_numbers: [],
|
|
68
|
+
quantity: qty,
|
|
69
|
+
unit_price: unitPrice,
|
|
70
|
+
discount_percent: 0,
|
|
71
|
+
discount_amount: 0,
|
|
72
|
+
line_total: unitPrice * qty,
|
|
73
|
+
expanded: false,
|
|
74
|
+
is_free_qty: l.is_free_qty,
|
|
75
|
+
parent_line_uids: l.parent_line_uids,
|
|
76
|
+
uom_id: l.uom_id,
|
|
77
|
+
purchase_order_line_id: l.purchase_order_line_id,
|
|
78
|
+
};
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}, [initialData]);
|
|
84
|
+
|
|
44
85
|
// ── Add item to line items ──
|
|
45
86
|
const handleAddItem = useCallback(
|
|
46
87
|
(
|
|
@@ -62,51 +103,6 @@ export function ProductTransactionScreen({
|
|
|
62
103
|
}
|
|
63
104
|
}
|
|
64
105
|
|
|
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
106
|
const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
|
|
111
107
|
const unitPrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
|
|
112
108
|
const qty = extra?.quantity || 1;
|
|
@@ -124,6 +120,7 @@ export function ProductTransactionScreen({
|
|
|
124
120
|
discount_amount: discountAmt,
|
|
125
121
|
line_total: unitPrice * qty - discountAmt,
|
|
126
122
|
expanded: false,
|
|
123
|
+
uom_id: itemWithVariants.uom_id,
|
|
127
124
|
};
|
|
128
125
|
return [...prevLineItems, newLine];
|
|
129
126
|
});
|
|
@@ -159,11 +156,39 @@ export function ProductTransactionScreen({
|
|
|
159
156
|
const handleSubmit = useCallback(async () => {
|
|
160
157
|
if (lineItems.length === 0) return;
|
|
161
158
|
|
|
159
|
+
if (config.requireFullPayment) {
|
|
160
|
+
const totalPayments = payments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
|
|
161
|
+
if (Math.abs(totalPayments - grandTotal) > 0.01) {
|
|
162
|
+
toast.error('Total payments must equal the Grand Total (Use Credit mode to pay later).');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!config.disableTrackingSelection) {
|
|
168
|
+
for (const line of lineItems) {
|
|
169
|
+
if (line.item.tracking_type === 'batch') {
|
|
170
|
+
const selectedTotal = line.batches?.reduce((sum, b) => sum + b.quantity, 0) || 0;
|
|
171
|
+
if (selectedTotal !== line.quantity) {
|
|
172
|
+
toast.error(`Please select batches for ${line.item.name} to match the total quantity.`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (line.item.tracking_type === 'serial') {
|
|
177
|
+
const selectedCount = line.serial_numbers?.length || 0;
|
|
178
|
+
if (selectedCount !== line.quantity) {
|
|
179
|
+
toast.error(`Please select exactly ${line.quantity} serial numbers for ${line.item.name}.`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
162
186
|
const payload: TransactionPayload = {
|
|
163
187
|
type: config.type,
|
|
164
188
|
party_id: partyId,
|
|
165
189
|
warehouse_id: warehouseId!,
|
|
166
190
|
transaction_date: transactionDate,
|
|
191
|
+
expected_delivery_date: expectedDeliveryDate || undefined,
|
|
167
192
|
notes: notes || undefined,
|
|
168
193
|
lines: lineItems.flatMap((l): TransactionPayload['lines'] => {
|
|
169
194
|
if (l.batches && l.batches.length > 0) {
|
|
@@ -178,8 +203,11 @@ export function ProductTransactionScreen({
|
|
|
178
203
|
selling_price: b.selling_price,
|
|
179
204
|
serial_numbers: undefined,
|
|
180
205
|
quantity: b.quantity,
|
|
181
|
-
|
|
206
|
+
uid: l.uid,
|
|
207
|
+
is_free_qty: l.is_free_qty,
|
|
208
|
+
parent_line_uids: l.parent_line_uids,
|
|
182
209
|
uom_id: l.uom_id,
|
|
210
|
+
purchase_order_line_id: l.purchase_order_line_id,
|
|
183
211
|
unit_price: l.unit_price,
|
|
184
212
|
discount_percent: l.discount_percent,
|
|
185
213
|
discount_amount: (l.discount_amount / l.quantity) * b.quantity,
|
|
@@ -197,8 +225,11 @@ export function ProductTransactionScreen({
|
|
|
197
225
|
selling_price: undefined,
|
|
198
226
|
serial_numbers: l.serial_numbers,
|
|
199
227
|
quantity: l.quantity,
|
|
200
|
-
|
|
228
|
+
uid: l.uid,
|
|
229
|
+
is_free_qty: l.is_free_qty,
|
|
230
|
+
parent_line_uids: l.parent_line_uids,
|
|
201
231
|
uom_id: l.uom_id,
|
|
232
|
+
purchase_order_line_id: l.purchase_order_line_id,
|
|
202
233
|
unit_price: l.unit_price,
|
|
203
234
|
discount_percent: l.discount_percent,
|
|
204
235
|
discount_amount: l.discount_amount,
|
|
@@ -219,6 +250,7 @@ export function ProductTransactionScreen({
|
|
|
219
250
|
setPayments([]);
|
|
220
251
|
setNotes('');
|
|
221
252
|
setTransactionDate(new Date().toISOString().split('T')[0]);
|
|
253
|
+
setExpectedDeliveryDate('');
|
|
222
254
|
if (!defaultWarehouseId) { setWarehouseId(null); setWarehouseName(null); }
|
|
223
255
|
} catch (error) {
|
|
224
256
|
// The parent usually displays a toast error. We catch it here
|
|
@@ -228,7 +260,7 @@ export function ProductTransactionScreen({
|
|
|
228
260
|
} finally {
|
|
229
261
|
setIsSubmitting(false);
|
|
230
262
|
}
|
|
231
|
-
}, [lineItems, config, partyId, warehouseId, transactionDate, notes, payments, subtotal, discountTotal, grandTotal, onSubmit, defaultWarehouseId]);
|
|
263
|
+
}, [lineItems, config, partyId, warehouseId, transactionDate, expectedDeliveryDate, notes, payments, subtotal, discountTotal, grandTotal, onSubmit, defaultWarehouseId]);
|
|
232
264
|
|
|
233
265
|
return (
|
|
234
266
|
<div className="flex flex-col h-full">
|
|
@@ -260,16 +292,19 @@ export function ProductTransactionScreen({
|
|
|
260
292
|
warehouseId={warehouseId}
|
|
261
293
|
warehouseName={warehouseName}
|
|
262
294
|
transactionDate={transactionDate}
|
|
295
|
+
expectedDeliveryDate={expectedDeliveryDate}
|
|
263
296
|
notes={notes}
|
|
264
297
|
payments={payments}
|
|
265
298
|
onPartyChange={(id, party) => { setPartyId(id); setPartyName(party?.name ?? null); }}
|
|
266
299
|
onWarehouseChange={(id, name) => { setWarehouseId(id); setWarehouseName(name ?? null); }}
|
|
267
300
|
onDateChange={setTransactionDate}
|
|
301
|
+
onExpectedDeliveryDateChange={setExpectedDeliveryDate}
|
|
268
302
|
onNotesChange={setNotes}
|
|
269
303
|
onPaymentsChange={setPayments}
|
|
270
304
|
onSubmit={handleSubmit}
|
|
271
305
|
isSubmitting={submitting}
|
|
272
306
|
renderExtraMeta={renderExtraMeta}
|
|
307
|
+
fetchChequeLeaves={fetchChequeLeaves}
|
|
273
308
|
/>
|
|
274
309
|
</div>
|
|
275
310
|
</div>
|
|
@@ -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,8 +74,10 @@ 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;
|
|
80
|
+
purchase_order_line_id?: number | null;
|
|
79
81
|
unit_price: number;
|
|
80
82
|
discount_percent: number;
|
|
81
83
|
discount_amount: number;
|
|
@@ -136,6 +138,8 @@ export interface TransactionScreenConfig {
|
|
|
136
138
|
partyIsWarehouse?: boolean;
|
|
137
139
|
/** Whether to show the party selector at all (defaults to true) */
|
|
138
140
|
showParty?: boolean;
|
|
141
|
+
/** Disable the search bar (useful for PO to GRN conversions where items are fixed) */
|
|
142
|
+
disableItemSelection?: boolean;
|
|
139
143
|
/** Allow creating new batches and serials during selection */
|
|
140
144
|
allowCreateBatchAndSerial?: boolean;
|
|
141
145
|
/** If true, user must select a warehouse before searching/adding items */
|
|
@@ -144,10 +148,14 @@ export interface TransactionScreenConfig {
|
|
|
144
148
|
allowEditPrice?: boolean;
|
|
145
149
|
/** Whether to show available stock for variants in the search dropdown */
|
|
146
150
|
showStock?: boolean;
|
|
147
|
-
/** Enable free quantity input (dynamic setting) */
|
|
148
|
-
enableFreeQty?: boolean;
|
|
149
151
|
/** Enable purchase unit selection (dynamic setting) */
|
|
150
152
|
enablePurchaseUnit?: boolean;
|
|
153
|
+
/** Show expected delivery date */
|
|
154
|
+
showExpectedDeliveryDate?: boolean;
|
|
155
|
+
/** Disable batch/serial selection completely (e.g. for Purchase Orders and Quotations) */
|
|
156
|
+
disableTrackingSelection?: boolean;
|
|
157
|
+
/** Enforce that the sum of payments equals the grand total before allowing submission */
|
|
158
|
+
requireFullPayment?: boolean;
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
// ── Module Presets ──
|
|
@@ -184,6 +192,7 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
|
|
|
184
192
|
allowCreateBatchAndSerial: false,
|
|
185
193
|
allowEditPrice: false,
|
|
186
194
|
showStock: true,
|
|
195
|
+
disableTrackingSelection: true,
|
|
187
196
|
},
|
|
188
197
|
purchase: {
|
|
189
198
|
type: 'purchase',
|
|
@@ -200,6 +209,8 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
|
|
|
200
209
|
allowCreateBatchAndSerial: true,
|
|
201
210
|
allowEditPrice: true,
|
|
202
211
|
showStock: true,
|
|
212
|
+
showExpectedDeliveryDate: true,
|
|
213
|
+
disableTrackingSelection: true,
|
|
203
214
|
},
|
|
204
215
|
transfer: {
|
|
205
216
|
type: 'transfer',
|
|
@@ -257,7 +268,7 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
|
|
|
257
268
|
priceField: 'default_cost_price',
|
|
258
269
|
showPrices: true,
|
|
259
270
|
allowDiscount: true,
|
|
260
|
-
showPayment:
|
|
271
|
+
showPayment: true,
|
|
261
272
|
partyLabel: 'Supplier',
|
|
262
273
|
partyType: 'supplier',
|
|
263
274
|
showWarehouse: true,
|
|
@@ -267,6 +278,7 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
|
|
|
267
278
|
allowCreateBatchAndSerial: true,
|
|
268
279
|
allowEditPrice: true,
|
|
269
280
|
showStock: true,
|
|
281
|
+
requireFullPayment: true,
|
|
270
282
|
},
|
|
271
283
|
};
|
|
272
284
|
|
|
@@ -283,6 +295,10 @@ export interface ProductTransactionScreenProps {
|
|
|
283
295
|
isSubmitting?: boolean;
|
|
284
296
|
/** Optional extra content rendered at the top of the metadata (right) panel */
|
|
285
297
|
renderExtraMeta?: () => React.ReactNode;
|
|
298
|
+
/** Fetcher for cheque leaves (for auto-complete) */
|
|
299
|
+
fetchChequeLeaves?: (query: string, page: number) => Promise<{ is_success: boolean; result?: any[] }>;
|
|
300
|
+
/** Initial data for populating the transaction screen (e.g., from a PO) */
|
|
301
|
+
initialData?: any;
|
|
286
302
|
}
|
|
287
303
|
|
|
288
304
|
export interface TransactionPayload {
|
|
@@ -290,6 +306,7 @@ export interface TransactionPayload {
|
|
|
290
306
|
party_id?: number | null;
|
|
291
307
|
warehouse_id: number;
|
|
292
308
|
transaction_date?: string;
|
|
309
|
+
expected_delivery_date?: string;
|
|
293
310
|
notes?: string;
|
|
294
311
|
lines: Array<{
|
|
295
312
|
item_id: number;
|
|
@@ -302,8 +319,11 @@ export interface TransactionPayload {
|
|
|
302
319
|
selling_price?: number;
|
|
303
320
|
serial_numbers?: string[];
|
|
304
321
|
quantity: number;
|
|
305
|
-
|
|
322
|
+
uid: string;
|
|
323
|
+
is_free_qty?: boolean;
|
|
324
|
+
parent_line_uids?: string[];
|
|
306
325
|
uom_id?: number;
|
|
326
|
+
purchase_order_line_id?: number | null;
|
|
307
327
|
unit_price: number;
|
|
308
328
|
discount_percent: number;
|
|
309
329
|
discount_amount: number;
|