@apptimate/ui 7.1.0 → 7.3.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/FormattedNumberInput.tsx +118 -0
- package/src/base-components/Select.tsx +1 -1
- package/src/common-components/DashboardLayout.tsx +101 -57
- package/src/common-components/PaymentSection.tsx +200 -155
- package/src/common-components/item-wizard/ItemFormWizard.tsx +118 -60
- package/src/common-components/pickers/PartyPicker.tsx +28 -401
- package/src/common-components/pickers/QuickPartyAddModal.tsx +414 -0
- package/src/common-components/pickers/UomPicker.tsx +20 -20
- package/src/common-components/pickers/modals/BatchSelectionModal.tsx +167 -114
- package/src/common-components/transaction/MetadataPanel.tsx +26 -17
- package/src/common-components/transaction/ProductSelectionPanel.tsx +7 -5
- package/src/common-components/transaction/ProductTransactionScreen.tsx +35 -5
- package/src/common-components/transaction/types.ts +4 -0
- package/src/components/shared/ImageUploadComponent.tsx +3 -0
- package/src/finance-components/DirectTransactionDetailModal.tsx +153 -0
- package/src/index.tsx +3 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useState, useEffect } from "react";
|
|
2
|
-
import { Search, Plus, X } from "lucide-react";
|
|
2
|
+
import { Search, Plus, X, Save } from "lucide-react";
|
|
3
3
|
import { Modal, ModalFooter } from "../../../base-components/Modal";
|
|
4
4
|
import { Button } from "../../../base-components/Button";
|
|
5
5
|
import { Table, THeader, TBody, TRow, TCell } from "../../../base-components/Table";
|
|
@@ -84,7 +84,7 @@ export function BatchSelectionModal({
|
|
|
84
84
|
const params = new URLSearchParams({ item_id: String(item.id) });
|
|
85
85
|
if (variant) params.append("variant_id", String(variant.id));
|
|
86
86
|
if (warehouseId) params.append("warehouse_id", String(warehouseId));
|
|
87
|
-
|
|
87
|
+
|
|
88
88
|
const res = await sendRequest({ url: `${process.env.NEXT_PUBLIC_API_URL}/api/inventory/batches/lookup?${params.toString()}`, method: "GET" });
|
|
89
89
|
if (res.responseData?.result) {
|
|
90
90
|
setBatches(res.responseData.result);
|
|
@@ -127,7 +127,7 @@ export function BatchSelectionModal({
|
|
|
127
127
|
}
|
|
128
128
|
const batch = batches.find(b => String(b.id) === String(batchId));
|
|
129
129
|
if (!batch) return;
|
|
130
|
-
|
|
130
|
+
|
|
131
131
|
// Check if already in rows
|
|
132
132
|
if (rows.some(r => String(r.batch_id) === String(batch.id))) {
|
|
133
133
|
toast.error("Batch already added to the list.");
|
|
@@ -150,30 +150,69 @@ export function BatchSelectionModal({
|
|
|
150
150
|
setSelectKey(prev => prev + 1);
|
|
151
151
|
};
|
|
152
152
|
|
|
153
|
-
const updateRow = (uid: string, field: keyof BatchRow, value:
|
|
153
|
+
const updateRow = (uid: string, field: keyof BatchRow, value: any) => {
|
|
154
154
|
setRows(rows.map(r => r.uid === uid ? { ...r, [field]: value } : r));
|
|
155
155
|
};
|
|
156
156
|
|
|
157
|
+
const saveBatchToDB = async (row: BatchRow) => {
|
|
158
|
+
if (!item) return;
|
|
159
|
+
const price = parseFloat(row.selling_price);
|
|
160
|
+
if (isNaN(price) || price < 0) {
|
|
161
|
+
toast.error("Please enter a valid selling price to save.");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const payload = {
|
|
166
|
+
item_id: item.id,
|
|
167
|
+
variant_id: variant?.id,
|
|
168
|
+
warehouse_id: warehouseId,
|
|
169
|
+
batch_number: row.batch_number || undefined,
|
|
170
|
+
manufacturing_date: row.manufacturing_date || undefined,
|
|
171
|
+
expiry_date: row.expiry_date || undefined,
|
|
172
|
+
supplier_reference: row.supplier_reference || undefined,
|
|
173
|
+
selling_price: price,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const loadingToast = toast.loading("Saving batch...");
|
|
177
|
+
try {
|
|
178
|
+
const res = await sendRequest({
|
|
179
|
+
url: `${process.env.NEXT_PUBLIC_API_URL}/api/inventory/batches`,
|
|
180
|
+
method: "POST",
|
|
181
|
+
data: payload
|
|
182
|
+
});
|
|
183
|
+
if (res.ok) {
|
|
184
|
+
toast.success("Batch saved successfully", { id: loadingToast });
|
|
185
|
+
const newBatch = res.responseData?.result;
|
|
186
|
+
if (newBatch) {
|
|
187
|
+
setRows(rows.map(r => r.uid === row.uid ? {
|
|
188
|
+
...r,
|
|
189
|
+
isNew: false,
|
|
190
|
+
batch_id: newBatch.id,
|
|
191
|
+
batch_number: newBatch.batch_number
|
|
192
|
+
} : r));
|
|
193
|
+
fetchBatches();
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
toast.error(res.responseData?.message || "Failed to save batch", { id: loadingToast });
|
|
197
|
+
}
|
|
198
|
+
} catch (e: any) {
|
|
199
|
+
toast.error("Failed to save batch", { id: loadingToast });
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
157
203
|
const removeRow = (uid: string) => {
|
|
158
204
|
setRows(rows.filter(r => r.uid !== uid));
|
|
159
205
|
};
|
|
160
206
|
|
|
161
207
|
const handleConfirm = () => {
|
|
162
208
|
const selections: { batch: TransactionBatch; quantity: number }[] = [];
|
|
163
|
-
|
|
209
|
+
|
|
164
210
|
for (const row of rows) {
|
|
165
211
|
const qty = parseFloat(row.quantity);
|
|
166
212
|
if (isNaN(qty) || qty <= 0) {
|
|
167
213
|
toast.error("Please enter a valid quantity for all selected batches.");
|
|
168
214
|
return;
|
|
169
215
|
}
|
|
170
|
-
if (row.isNew) {
|
|
171
|
-
const price = parseFloat(row.selling_price);
|
|
172
|
-
if (isNaN(price) || price < 0) {
|
|
173
|
-
toast.error("Please enter a valid selling price for new batches.");
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
216
|
if (!row.isNew && !allowCreate && row.qty_available !== undefined && qty > row.qty_available) {
|
|
178
217
|
toast.error(`Quantity exceeds available stock for batch ${row.batch_number}.`);
|
|
179
218
|
return;
|
|
@@ -206,37 +245,39 @@ export function BatchSelectionModal({
|
|
|
206
245
|
return (
|
|
207
246
|
<Modal isOpen={isOpen} onClose={onClose} size="xl" title={`Batch Selection - ${item?.name} ${variant ? `(${variant.variant_name})` : ""}`}>
|
|
208
247
|
<div className="flex flex-col gap-4">
|
|
209
|
-
|
|
248
|
+
|
|
210
249
|
<div className="flex justify-between items-center">
|
|
211
250
|
<div className="flex gap-2 items-center">
|
|
212
251
|
{allowCreate && (
|
|
213
252
|
<Button size="small" onClick={handleAddRow} icon={<Plus size={16} />}>New Batch</Button>
|
|
214
253
|
)}
|
|
215
254
|
<div className="w-64">
|
|
216
|
-
<SearchableSelect
|
|
255
|
+
<SearchableSelect
|
|
217
256
|
key={selectKey}
|
|
218
257
|
options={batches as any[]}
|
|
219
258
|
onChange={(val) => {
|
|
220
|
-
if(val) handleSelectExisting(val as string | number);
|
|
259
|
+
if (val) handleSelectExisting(val as string | number);
|
|
221
260
|
}}
|
|
222
261
|
placeholder="Select Existing Batch..."
|
|
223
|
-
option={{
|
|
224
|
-
|
|
225
|
-
<div className="flex
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
262
|
+
option={{
|
|
263
|
+
label: 'batch_number', value: 'id', renderOption: (opt: any) => (
|
|
264
|
+
<div className="flex flex-col w-full py-0.5 gap-0.5">
|
|
265
|
+
<div className="flex justify-between items-center w-full gap-2">
|
|
266
|
+
<span className="truncate font-medium">{opt.batch_number}</span>
|
|
267
|
+
{opt.qty_available !== undefined && (
|
|
268
|
+
<span className="text-[10px] font-medium text-foreground-subtle bg-surface-hover px-1.5 py-0.5 rounded whitespace-nowrap shrink-0">
|
|
269
|
+
{Number(opt.qty_available).toFixed(2)} in stock
|
|
270
|
+
</span>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
{opt.expiry_date && (
|
|
274
|
+
<span className="text-[10px] text-foreground-subtle">
|
|
275
|
+
Exp: {opt.expiry_date.substring(0, 10)}
|
|
230
276
|
</span>
|
|
231
277
|
)}
|
|
232
278
|
</div>
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
Exp: {opt.expiry_date.substring(0, 10)}
|
|
236
|
-
</span>
|
|
237
|
-
)}
|
|
238
|
-
</div>
|
|
239
|
-
) }}
|
|
279
|
+
)
|
|
280
|
+
}}
|
|
240
281
|
/>
|
|
241
282
|
</div>
|
|
242
283
|
</div>
|
|
@@ -265,93 +306,105 @@ export function BatchSelectionModal({
|
|
|
265
306
|
rows.map(row => {
|
|
266
307
|
const batchInfo = batches.find(b => String(b.id) === String(row.batch_id));
|
|
267
308
|
const displayQtyAvailable = row.qty_available !== undefined ? row.qty_available : batchInfo?.qty_available;
|
|
268
|
-
|
|
309
|
+
|
|
269
310
|
return (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
)}
|
|
282
|
-
</TCell>
|
|
283
|
-
<TCell label="Mfg Batch#">
|
|
284
|
-
{row.isNew ? (
|
|
285
|
-
<input
|
|
286
|
-
value={row.supplier_reference}
|
|
287
|
-
onChange={(e) => updateRow(row.uid, 'supplier_reference', e.target.value)}
|
|
288
|
-
className="w-full 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"
|
|
289
|
-
/>
|
|
290
|
-
) : (
|
|
291
|
-
<div className="text-sm text-gray-500">{row.supplier_reference || '—'}</div>
|
|
292
|
-
)}
|
|
293
|
-
</TCell>
|
|
294
|
-
<TCell label="Mfg Date">
|
|
295
|
-
{row.isNew ? (
|
|
296
|
-
<input
|
|
297
|
-
type="date"
|
|
298
|
-
value={row.manufacturing_date}
|
|
299
|
-
onChange={(e) => updateRow(row.uid, 'manufacturing_date', e.target.value)}
|
|
300
|
-
className="w-full 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"
|
|
301
|
-
/>
|
|
302
|
-
) : (
|
|
303
|
-
<div className="text-sm text-gray-500">{row.manufacturing_date || '—'}</div>
|
|
304
|
-
)}
|
|
305
|
-
</TCell>
|
|
306
|
-
<TCell label="Expiry Date">
|
|
307
|
-
{row.isNew ? (
|
|
308
|
-
<input
|
|
309
|
-
type="date"
|
|
310
|
-
value={row.expiry_date}
|
|
311
|
-
onChange={(e) => updateRow(row.uid, 'expiry_date', e.target.value)}
|
|
312
|
-
className="w-full 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"
|
|
313
|
-
/>
|
|
314
|
-
) : (
|
|
315
|
-
<div className="text-sm text-gray-500">{row.expiry_date ? new Date(row.expiry_date).toLocaleDateString() : '—'}</div>
|
|
316
|
-
)}
|
|
317
|
-
</TCell>
|
|
318
|
-
<TCell label="Selling Price">
|
|
319
|
-
{row.isNew ? (
|
|
320
|
-
<input
|
|
321
|
-
type="number"
|
|
322
|
-
value={row.selling_price}
|
|
323
|
-
onChange={(e) => updateRow(row.uid, 'selling_price', e.target.value)}
|
|
324
|
-
className="w-full 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"
|
|
325
|
-
/>
|
|
326
|
-
) : (
|
|
327
|
-
<div className="text-sm text-gray-500">{row.selling_price || '—'}</div>
|
|
328
|
-
)}
|
|
329
|
-
</TCell>
|
|
330
|
-
<TCell label="Quantity">
|
|
331
|
-
<div className="flex flex-col items-end">
|
|
332
|
-
<input
|
|
333
|
-
type="number"
|
|
334
|
-
min="0"
|
|
335
|
-
max={!allowCreate && displayQtyAvailable !== undefined ? displayQtyAvailable : undefined}
|
|
336
|
-
value={row.quantity}
|
|
337
|
-
onChange={(e) => updateRow(row.uid, 'quantity', e.target.value)}
|
|
338
|
-
placeholder="0"
|
|
339
|
-
className="w-full 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"
|
|
340
|
-
/>
|
|
341
|
-
{!row.isNew && displayQtyAvailable !== undefined && (
|
|
342
|
-
<span className="text-[10px] text-gray-400 mt-0.5">Avail: {Number(displayQtyAvailable).toFixed(4)}</span>
|
|
311
|
+
<TRow key={row.uid}>
|
|
312
|
+
<TCell label="Batch Number">
|
|
313
|
+
{row.isNew ? (
|
|
314
|
+
<input
|
|
315
|
+
value={row.batch_number}
|
|
316
|
+
onChange={(e) => updateRow(row.uid, 'batch_number', e.target.value)}
|
|
317
|
+
placeholder="Auto generate if empty"
|
|
318
|
+
className="w-full 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"
|
|
319
|
+
/>
|
|
320
|
+
) : (
|
|
321
|
+
<div className="font-mono text-sm font-medium">{row.batch_number}</div>
|
|
343
322
|
)}
|
|
344
|
-
</
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
323
|
+
</TCell>
|
|
324
|
+
<TCell label="Mfg Batch#">
|
|
325
|
+
{row.isNew ? (
|
|
326
|
+
<input
|
|
327
|
+
value={row.supplier_reference}
|
|
328
|
+
onChange={(e) => updateRow(row.uid, 'supplier_reference', e.target.value)}
|
|
329
|
+
className="w-full 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"
|
|
330
|
+
/>
|
|
331
|
+
) : (
|
|
332
|
+
<div className="text-sm text-gray-500">{row.supplier_reference || '—'}</div>
|
|
333
|
+
)}
|
|
334
|
+
</TCell>
|
|
335
|
+
<TCell label="Mfg Date">
|
|
336
|
+
{row.isNew ? (
|
|
337
|
+
<input
|
|
338
|
+
type="date"
|
|
339
|
+
value={row.manufacturing_date}
|
|
340
|
+
onChange={(e) => updateRow(row.uid, 'manufacturing_date', e.target.value)}
|
|
341
|
+
className="w-full 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"
|
|
342
|
+
/>
|
|
343
|
+
) : (
|
|
344
|
+
<div className="text-sm text-gray-500">{row.manufacturing_date || '—'}</div>
|
|
345
|
+
)}
|
|
346
|
+
</TCell>
|
|
347
|
+
<TCell label="Expiry Date">
|
|
348
|
+
{row.isNew ? (
|
|
349
|
+
<input
|
|
350
|
+
type="date"
|
|
351
|
+
value={row.expiry_date}
|
|
352
|
+
onChange={(e) => updateRow(row.uid, 'expiry_date', e.target.value)}
|
|
353
|
+
className="w-full 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"
|
|
354
|
+
/>
|
|
355
|
+
) : (
|
|
356
|
+
<div className="text-sm text-gray-500">{row.expiry_date ? new Date(row.expiry_date).toLocaleDateString() : '—'}</div>
|
|
357
|
+
)}
|
|
358
|
+
</TCell>
|
|
359
|
+
<TCell label="Selling Price">
|
|
360
|
+
{row.isNew ? (
|
|
361
|
+
<input
|
|
362
|
+
type="number"
|
|
363
|
+
value={row.selling_price}
|
|
364
|
+
onChange={(e) => updateRow(row.uid, 'selling_price', e.target.value)}
|
|
365
|
+
className="w-full 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"
|
|
366
|
+
/>
|
|
367
|
+
) : (
|
|
368
|
+
<div className="text-sm text-gray-500">{row.selling_price || '—'}</div>
|
|
369
|
+
)}
|
|
370
|
+
</TCell>
|
|
371
|
+
<TCell label="Quantity">
|
|
372
|
+
<div className="flex flex-col items-end">
|
|
373
|
+
<input
|
|
374
|
+
type="number"
|
|
375
|
+
min="0"
|
|
376
|
+
max={!allowCreate && displayQtyAvailable !== undefined ? displayQtyAvailable : undefined}
|
|
377
|
+
value={row.quantity}
|
|
378
|
+
onChange={(e) => updateRow(row.uid, 'quantity', e.target.value)}
|
|
379
|
+
placeholder="0"
|
|
380
|
+
className="w-full 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"
|
|
381
|
+
/>
|
|
382
|
+
{!row.isNew && displayQtyAvailable !== undefined && (
|
|
383
|
+
<span className="text-[10px] text-gray-400 mt-0.5">Avail: {Number(displayQtyAvailable).toFixed(4)}</span>
|
|
384
|
+
)}
|
|
385
|
+
</div>
|
|
386
|
+
</TCell>
|
|
387
|
+
<TCell label="">
|
|
388
|
+
<div className="flex justify-center items-center gap-1 w-full">
|
|
389
|
+
{row.isNew && (
|
|
390
|
+
<button
|
|
391
|
+
onClick={() => saveBatchToDB(row)}
|
|
392
|
+
className="p-1 text-primary-600 hover:text-primary-700 rounded-md hover:bg-primary-50 transition-colors"
|
|
393
|
+
title="Save Batch"
|
|
394
|
+
>
|
|
395
|
+
<Save size={16} />
|
|
396
|
+
</button>
|
|
397
|
+
)}
|
|
398
|
+
<button
|
|
399
|
+
onClick={() => removeRow(row.uid)}
|
|
400
|
+
className="p-1 text-gray-400 hover:text-danger-500 rounded-md hover:bg-danger-50 transition-colors flex justify-center"
|
|
401
|
+
title="Remove"
|
|
402
|
+
>
|
|
403
|
+
<X size={16} />
|
|
404
|
+
</button>
|
|
405
|
+
</div>
|
|
406
|
+
</TCell>
|
|
407
|
+
</TRow>
|
|
355
408
|
);
|
|
356
409
|
})
|
|
357
410
|
) : (
|
|
@@ -36,12 +36,13 @@ interface MetadataPanelProps {
|
|
|
36
36
|
isSubmitting: boolean;
|
|
37
37
|
renderExtraMeta?: () => React.ReactNode;
|
|
38
38
|
fetchChequeLeaves?: (query: string, page: number) => Promise<{ is_success: boolean; result?: any[] }>;
|
|
39
|
+
isFullscreen?: boolean;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export function MetadataPanel({
|
|
42
43
|
config, lineItems, partyId, partyName, warehouseId, warehouseName, transactionDate, expectedDeliveryDate, notes, payments,
|
|
43
44
|
onPartyChange, onWarehouseChange, onDateChange, onExpectedDeliveryDateChange, onNotesChange, onPaymentsChange, onSubmit, isSubmitting,
|
|
44
|
-
renderExtraMeta, fetchChequeLeaves,
|
|
45
|
+
renderExtraMeta, fetchChequeLeaves, isFullscreen,
|
|
45
46
|
}: MetadataPanelProps) {
|
|
46
47
|
const [paymentModes, setPaymentModes] = useState<PaymentMode[]>([]);
|
|
47
48
|
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
|
|
@@ -132,7 +133,9 @@ export function MetadataPanel({
|
|
|
132
133
|
);
|
|
133
134
|
|
|
134
135
|
return (
|
|
135
|
-
<div className="flex flex-col h-full
|
|
136
|
+
<div className="flex flex-col h-full">
|
|
137
|
+
{/* ── Scrollable Content ── */}
|
|
138
|
+
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-6 pr-1">
|
|
136
139
|
{/* ── Extra Module-Specific Meta (e.g. adjustment type/reason) ── */}
|
|
137
140
|
{renderExtraMeta && renderExtraMeta()}
|
|
138
141
|
|
|
@@ -216,8 +219,8 @@ export function MetadataPanel({
|
|
|
216
219
|
</Section>
|
|
217
220
|
)}
|
|
218
221
|
|
|
219
|
-
{/* ── Totals ── */}
|
|
220
|
-
{config.showPrices && lineItems.length > 0 && (
|
|
222
|
+
{/* ── Totals (hidden for POS — shown at bottom of left panel instead) ── */}
|
|
223
|
+
{config.showPrices && config.type !== 'pos' && lineItems.length > 0 && (
|
|
221
224
|
<div className="bg-surface-0 rounded-[12px] border border-border-subtle p-4 space-y-2">
|
|
222
225
|
<TotalRow label="Subtotal" value={formatCurrency(subtotal)} />
|
|
223
226
|
{discountTotal > 0 && (
|
|
@@ -239,23 +242,29 @@ export function MetadataPanel({
|
|
|
239
242
|
fetchBankAccounts={getBankAccountsLookup}
|
|
240
243
|
fetchChequeLeaves={fetchChequeLeaves}
|
|
241
244
|
compact={true}
|
|
245
|
+
collapseModes={config.type === 'pos'}
|
|
242
246
|
/>
|
|
243
247
|
</Section>
|
|
244
248
|
)}
|
|
249
|
+
</div>
|
|
245
250
|
|
|
246
|
-
{/* ──
|
|
247
|
-
<div className="
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
251
|
+
{/* ── Submit Button (sticky bottom) ── */}
|
|
252
|
+
<div className="shrink-0 pt-4">
|
|
253
|
+
<Button
|
|
254
|
+
type="submit"
|
|
255
|
+
color="primary"
|
|
256
|
+
isDisabled={lineItems.length === 0}
|
|
257
|
+
isLoading={isSubmitting}
|
|
258
|
+
className={cn(
|
|
259
|
+
"!w-full !font-semibold transition-all",
|
|
260
|
+
isFullscreen
|
|
261
|
+
? "!h-[72px] !text-[18px] !rounded-[12px] shadow-lg shadow-primary/20"
|
|
262
|
+
: "!py-4 !text-[14px] !rounded-[10px]"
|
|
263
|
+
)}
|
|
264
|
+
>
|
|
265
|
+
{config.submitLabel}
|
|
266
|
+
</Button>
|
|
267
|
+
</div>
|
|
259
268
|
</div>
|
|
260
269
|
);
|
|
261
270
|
}
|
|
@@ -75,6 +75,7 @@ export function ProductSelectionPanel({
|
|
|
75
75
|
try {
|
|
76
76
|
const params: Record<string, string | number> = { search: value, limit: 20, type: config.type };
|
|
77
77
|
if (warehouseId) params.warehouse_id = warehouseId;
|
|
78
|
+
if (config.allowPhantomBOM) params.allow_phantom_bom = 1;
|
|
78
79
|
|
|
79
80
|
if (['sales', 'quotation', 'pos'].includes(config.type)) {
|
|
80
81
|
params.apply_sales_prices = 1;
|
|
@@ -261,7 +262,7 @@ export function ProductSelectionPanel({
|
|
|
261
262
|
let discAmt = line.discount_amount;
|
|
262
263
|
let discPercent = line.discount_percent;
|
|
263
264
|
|
|
264
|
-
if (field === 'quantity') qty = Math.max(0
|
|
265
|
+
if (field === 'quantity') qty = Math.max(0, num);
|
|
265
266
|
if (field === 'unit_price') price = Math.max(0, num);
|
|
266
267
|
if (field === 'discount_amount') {
|
|
267
268
|
const maxDiscount = price;
|
|
@@ -353,7 +354,7 @@ export function ProductSelectionPanel({
|
|
|
353
354
|
};
|
|
354
355
|
|
|
355
356
|
return (
|
|
356
|
-
<div className="flex flex-col h-full">
|
|
357
|
+
<div className="flex flex-col flex-1 min-h-0 w-full">
|
|
357
358
|
{/* ── Search Bar ── */}
|
|
358
359
|
{!config.disableItemSelection && (
|
|
359
360
|
<div ref={panelRef} className="relative mb-4">
|
|
@@ -637,7 +638,8 @@ export function ProductSelectionPanel({
|
|
|
637
638
|
<AsyncSearchableSelect
|
|
638
639
|
controlClassName="!min-h-8 h-8 !py-1 text-sm"
|
|
639
640
|
placeholder="Select Batch..."
|
|
640
|
-
loadOptions={async (search) => {
|
|
641
|
+
loadOptions={async (search, page) => {
|
|
642
|
+
if (page > 1) return []; // Backend is unpaginated, don't repeat on scroll
|
|
641
643
|
const params: any = {};
|
|
642
644
|
if (line.variant_id) params.variant_id = line.variant_id;
|
|
643
645
|
if (warehouseId) params.warehouse_id = warehouseId;
|
|
@@ -728,7 +730,7 @@ export function ProductSelectionPanel({
|
|
|
728
730
|
})()}
|
|
729
731
|
</div>
|
|
730
732
|
)}
|
|
731
|
-
{Number(line.unit_price) === 0 && (
|
|
733
|
+
{Number(line.unit_price) === 0 && ['purchase', 'grn', 'quotation', 'sales', 'pos'].includes(config.type) && (
|
|
732
734
|
<div className="mt-2 flex items-center gap-2">
|
|
733
735
|
<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")}>
|
|
734
736
|
<input
|
|
@@ -793,7 +795,7 @@ export function ProductSelectionPanel({
|
|
|
793
795
|
<TCell label="Qty" className="py-2.5 text-center">
|
|
794
796
|
<input
|
|
795
797
|
type="number"
|
|
796
|
-
value={line.quantity}
|
|
798
|
+
value={line.quantity === 0 ? '' : line.quantity}
|
|
797
799
|
onChange={(e) => updateLineInput(line.uid, 'quantity', e.target.value)}
|
|
798
800
|
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"
|
|
799
801
|
min={0.001}
|
|
@@ -31,6 +31,7 @@ export function ProductTransactionScreen({
|
|
|
31
31
|
fetchChequeLeaves,
|
|
32
32
|
initialData,
|
|
33
33
|
onAddNewItem,
|
|
34
|
+
isFullscreen,
|
|
34
35
|
}: ProductTransactionScreenProps) {
|
|
35
36
|
// ── State ──
|
|
36
37
|
const [lineItems, setLineItems] = useState<TransactionLineItem[]>([]);
|
|
@@ -62,7 +63,7 @@ export function ProductTransactionScreen({
|
|
|
62
63
|
const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
|
|
63
64
|
const actualVariant = l.item.variants?.find((v: any) => v.id === l.variant_id);
|
|
64
65
|
const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
|
|
65
|
-
const batchPrice = ['sales', 'quotation'].includes(config.type) && l.batches?.[0] ? (l.batches[0] as any)[batchPriceField] : undefined;
|
|
66
|
+
const batchPrice = ['sales', 'quotation', 'pos'].includes(config.type) && l.batches?.[0] ? (l.batches[0] as any)[batchPriceField] : undefined;
|
|
66
67
|
const basePrice = (batchPrice !== undefined && batchPrice !== null)
|
|
67
68
|
? Number(batchPrice)
|
|
68
69
|
: (actualVariant
|
|
@@ -260,7 +261,7 @@ export function ProductTransactionScreen({
|
|
|
260
261
|
const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
|
|
261
262
|
const actualVariant = updatedLine.item.variants?.find((v: any) => v.id === updatedLine.variant_id);
|
|
262
263
|
const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
|
|
263
|
-
const batchPrice = ['sales', 'quotation'].includes(config.type) && updatedLine.batches?.[0] ? (updatedLine.batches[0] as any)[batchPriceField] : undefined;
|
|
264
|
+
const batchPrice = ['sales', 'quotation', 'pos'].includes(config.type) && updatedLine.batches?.[0] ? (updatedLine.batches[0] as any)[batchPriceField] : undefined;
|
|
264
265
|
const basePrice = (batchPrice !== undefined && batchPrice !== null)
|
|
265
266
|
? Number(batchPrice)
|
|
266
267
|
: (actualVariant
|
|
@@ -503,7 +504,7 @@ export function ProductTransactionScreen({
|
|
|
503
504
|
{/* ── Two-Panel Layout ── */}
|
|
504
505
|
<div className="flex-1 flex gap-5 min-h-0">
|
|
505
506
|
{/* Left: Product Selection (75%) */}
|
|
506
|
-
<div className="flex-[3] min-w-0 flex flex-col">
|
|
507
|
+
<div className="flex-[3] min-w-0 flex flex-col min-h-0">
|
|
507
508
|
<ProductSelectionPanel
|
|
508
509
|
config={config}
|
|
509
510
|
partyId={partyId}
|
|
@@ -515,13 +516,41 @@ export function ProductTransactionScreen({
|
|
|
515
516
|
onToggleExpand={handleToggleExpand}
|
|
516
517
|
onAddNewItem={onAddNewItem}
|
|
517
518
|
/>
|
|
519
|
+
|
|
520
|
+
{/* ── POS: Amount to Pay Summary Bar ── */}
|
|
521
|
+
{config.type === 'pos' && lineItems.length > 0 && (
|
|
522
|
+
<div className="shrink-0 mt-3 bg-surface-0 rounded-[14px] border border-border-subtle px-5 py-3.5 flex items-center justify-between gap-6">
|
|
523
|
+
<div className="flex items-center gap-6">
|
|
524
|
+
<div className="flex items-center gap-2">
|
|
525
|
+
<span className="text-[11.5px] font-semibold text-foreground-subtle uppercase tracking-wide">Subtotal</span>
|
|
526
|
+
<span className="text-[13.5px] font-bold text-foreground-1">
|
|
527
|
+
{subtotal.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
528
|
+
</span>
|
|
529
|
+
</div>
|
|
530
|
+
{discountTotal > 0 && (
|
|
531
|
+
<div className="flex items-center gap-2">
|
|
532
|
+
<span className="text-[11.5px] font-semibold text-foreground-subtle uppercase tracking-wide">Discount</span>
|
|
533
|
+
<span className="text-[13.5px] font-bold text-green-600">
|
|
534
|
+
-{discountTotal.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
535
|
+
</span>
|
|
536
|
+
</div>
|
|
537
|
+
)}
|
|
538
|
+
</div>
|
|
539
|
+
<div className="flex items-center gap-3">
|
|
540
|
+
<span className="text-[12px] font-bold text-foreground-subtle uppercase tracking-wide">Amount to Pay</span>
|
|
541
|
+
<span className="text-[22px] font-extrabold text-foreground-0 tracking-tight tabular-nums">
|
|
542
|
+
{grandTotal.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
543
|
+
</span>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
)}
|
|
518
547
|
</div>
|
|
519
548
|
|
|
520
549
|
{/* Divider */}
|
|
521
550
|
<div className="w-px bg-border-subtle/60 shrink-0" />
|
|
522
551
|
|
|
523
552
|
{/* Right: Metadata (25%) */}
|
|
524
|
-
<div className="flex-1 min-w-0 flex flex-col
|
|
553
|
+
<div className="flex-1 min-w-0 flex flex-col">
|
|
525
554
|
<MetadataPanel
|
|
526
555
|
config={config}
|
|
527
556
|
lineItems={lineItems}
|
|
@@ -556,7 +585,7 @@ export function ProductTransactionScreen({
|
|
|
556
585
|
const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
|
|
557
586
|
const actualVariant = l.item.variants?.find((v: any) => v.id === l.variant_id);
|
|
558
587
|
const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
|
|
559
|
-
const batchPrice = ['sales', 'quotation'].includes(config.type) && l.batches?.[0] ? (l.batches[0] as any)[batchPriceField] : undefined;
|
|
588
|
+
const batchPrice = ['sales', 'quotation', 'pos'].includes(config.type) && l.batches?.[0] ? (l.batches[0] as any)[batchPriceField] : undefined;
|
|
560
589
|
const basePrice = (batchPrice !== undefined && batchPrice !== null)
|
|
561
590
|
? Number(batchPrice)
|
|
562
591
|
: (actualVariant
|
|
@@ -617,6 +646,7 @@ export function ProductTransactionScreen({
|
|
|
617
646
|
isSubmitting={submitting}
|
|
618
647
|
renderExtraMeta={renderExtraMeta}
|
|
619
648
|
fetchChequeLeaves={fetchChequeLeaves}
|
|
649
|
+
isFullscreen={isFullscreen}
|
|
620
650
|
/>
|
|
621
651
|
</div>
|
|
622
652
|
</div>
|
|
@@ -171,6 +171,8 @@ export interface TransactionScreenConfig {
|
|
|
171
171
|
disablePartySelection?: boolean;
|
|
172
172
|
/** Disable warehouse selection (e.g. for revisions) */
|
|
173
173
|
disableWarehouseSelection?: boolean;
|
|
174
|
+
/** Allow phantom BOMs to be selected (e.g. for stock decrease) */
|
|
175
|
+
allowPhantomBOM?: boolean;
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
// ── Module Presets ──
|
|
@@ -319,6 +321,8 @@ export interface ProductTransactionScreenProps {
|
|
|
319
321
|
initialData?: any;
|
|
320
322
|
/** Optional callback to create a new item directly from the search bar */
|
|
321
323
|
onAddNewItem?: (callbacks: { setItemSearch: (value: string) => void }) => void;
|
|
324
|
+
/** Whether the screen is currently in fullscreen mode */
|
|
325
|
+
isFullscreen?: boolean;
|
|
322
326
|
}
|
|
323
327
|
|
|
324
328
|
export interface TransactionPayload {
|
|
@@ -81,6 +81,9 @@ export default function ImageUploadComponent({
|
|
|
81
81
|
|
|
82
82
|
const getAuthHeaders = (): Record<string, string> => {
|
|
83
83
|
const headers: Record<string, string> = {};
|
|
84
|
+
if (typeof window !== "undefined") {
|
|
85
|
+
headers["X-Tenant-Domain"] = window.location.hostname;
|
|
86
|
+
}
|
|
84
87
|
// Add org header
|
|
85
88
|
try {
|
|
86
89
|
const orgRaw = typeof window !== "undefined" ? localStorage.getItem("selected_organization") : null;
|