@apptimate/ui 7.2.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.
@@ -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: string) => {
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={{ label: 'batch_number', value: 'id', renderOption: (opt: any) => (
224
- <div className="flex flex-col w-full py-0.5 gap-0.5">
225
- <div className="flex justify-between items-center w-full gap-2">
226
- <span className="truncate font-medium">{opt.batch_number}</span>
227
- {opt.qty_available !== undefined && (
228
- <span className="text-[10px] font-medium text-foreground-subtle bg-surface-hover px-1.5 py-0.5 rounded whitespace-nowrap shrink-0">
229
- {Number(opt.qty_available).toFixed(2)} in stock
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
- {opt.expiry_date && (
234
- <span className="text-[10px] text-foreground-subtle">
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
- <TRow key={row.uid}>
271
- <TCell label="Batch Number">
272
- {row.isNew ? (
273
- <input
274
- value={row.batch_number}
275
- onChange={(e) => updateRow(row.uid, 'batch_number', e.target.value)}
276
- placeholder="Auto generate if empty"
277
- 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"
278
- />
279
- ) : (
280
- <div className="font-mono text-sm font-medium">{row.batch_number}</div>
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
- </div>
345
- </TCell>
346
- <TCell label="">
347
- <button
348
- onClick={() => removeRow(row.uid)}
349
- className="p-1 text-gray-400 hover:text-danger-500 rounded-md hover:bg-danger-50 transition-colors w-full flex justify-center"
350
- >
351
- <X size={16} />
352
- </button>
353
- </TCell>
354
- </TRow>
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
  ) : (
@@ -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.001, num);
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;
@@ -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}
@@ -63,7 +63,7 @@ export function ProductTransactionScreen({
63
63
  const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
64
64
  const actualVariant = l.item.variants?.find((v: any) => v.id === l.variant_id);
65
65
  const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
66
- 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;
67
67
  const basePrice = (batchPrice !== undefined && batchPrice !== null)
68
68
  ? Number(batchPrice)
69
69
  : (actualVariant
@@ -261,7 +261,7 @@ export function ProductTransactionScreen({
261
261
  const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
262
262
  const actualVariant = updatedLine.item.variants?.find((v: any) => v.id === updatedLine.variant_id);
263
263
  const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
264
- 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;
265
265
  const basePrice = (batchPrice !== undefined && batchPrice !== null)
266
266
  ? Number(batchPrice)
267
267
  : (actualVariant
@@ -585,7 +585,7 @@ export function ProductTransactionScreen({
585
585
  const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
586
586
  const actualVariant = l.item.variants?.find((v: any) => v.id === l.variant_id);
587
587
  const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
588
- 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;
589
589
  const basePrice = (batchPrice !== undefined && batchPrice !== null)
590
590
  ? Number(batchPrice)
591
591
  : (actualVariant
@@ -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 ──
@@ -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;
@@ -60,7 +60,15 @@ export default function DirectTransactionDetailModal({
60
60
  </div>
61
61
  <div className="space-y-1">
62
62
  <p className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">
63
- Bank Account
63
+ Account
64
+ </p>
65
+ <p className="font-medium text-gray-900">
66
+ {transaction.account?.name || "-"}
67
+ </p>
68
+ </div>
69
+ <div className="space-y-1">
70
+ <p className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">
71
+ Paid Via
64
72
  </p>
65
73
  <p className="font-medium text-gray-900">
66
74
  {transaction.payment_method === "cash"
package/src/index.tsx CHANGED
@@ -11,6 +11,7 @@ export * from './base-components/ChipInput';
11
11
  export * from './base-components/Divider';
12
12
  export * from './base-components/Dropdown';
13
13
  export * from './base-components/EmptyState';
14
+ export * from './base-components/FormattedNumberInput';
14
15
  export * from './base-components/HintIcon';
15
16
  export * from './base-components/RecordCount';
16
17
  export * from './base-components/DateLabel';
@@ -49,6 +50,7 @@ export * from './common-components/pickers/EntityPickerModal';
49
50
  export * from './common-components/pickers/UomGroupPicker';
50
51
  export * from './common-components/pickers/UomPicker';
51
52
  export * from './common-components/pickers/PartyPicker';
53
+ export * from './common-components/pickers/QuickPartyAddModal';
52
54
  export * from './common-components/pickers/WarehousePicker';
53
55
 
54
56
  // Item Wizard