@apptimate/ui 3.7.0 → 3.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apptimate/ui",
3
- "version": "3.7.0",
3
+ "version": "3.9.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "dependencies": {
@@ -535,7 +535,7 @@ function SelectCore<T extends Record<string, unknown>>({
535
535
  isSelected(item)
536
536
  ? 'bg-primary/10 text-primary font-medium'
537
537
  : 'text-foreground-1',
538
- idx === highlightedIndex && 'bg-surface-hover',
538
+ idx === highlightedIndex && 'bg-primary/5',
539
539
  isSelected(item) && idx === highlightedIndex && 'bg-primary/15'
540
540
  )}
541
541
  onClick={(e) => handleOptionClick(e, item)}
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import { cn } from '@apptimate/core-lib';
4
+ import React from 'react';
5
+ import { Label } from './Label';
6
+
7
+ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
8
+ label?: string;
9
+ error?: string;
10
+ isRequired?: boolean;
11
+ inputClassName?: string;
12
+ }
13
+
14
+ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
15
+ ({ label, error, isRequired, className, inputClassName, rows = 3, ...props }, ref) => {
16
+ return (
17
+ <div className={cn("flex flex-col gap-1.5 w-full", className)}>
18
+ {label && (
19
+ <Label isRequired={isRequired}>
20
+ {label}
21
+ </Label>
22
+ )}
23
+ <div className="relative group flex-1 flex flex-col justify-center">
24
+ <textarea
25
+ ref={ref}
26
+ rows={rows}
27
+ className={cn(
28
+ "w-full bg-surface-0 border-[1.5px] border-border-subtle rounded-[10px] px-3.5 py-2.5 text-[13.5px] text-foreground-1 outline-none transition-all hover:border-gray-300 focus:border-gray-300 focus:bg-surface-1 placeholder:text-foreground-disabled resize-none",
29
+ "disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500",
30
+ error && "border-danger-alt focus:border-danger-alt hover:border-danger-alt",
31
+ inputClassName
32
+ )}
33
+ {...props}
34
+ />
35
+ </div>
36
+ {error && <span className="text-[11px] text-danger-alt font-medium">{error}</span>}
37
+ </div>
38
+ );
39
+ }
40
+ );
41
+
42
+ Textarea.displayName = 'Textarea';
@@ -221,8 +221,20 @@ export function BatchSelectionModal({
221
221
  }}
222
222
  placeholder="Select Existing Batch..."
223
223
  option={{ label: 'batch_number', value: 'id', renderOption: (opt: any) => (
224
- <div className="flex justify-between w-full">
225
- <span>{opt.batch_number}</span>
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
230
+ </span>
231
+ )}
232
+ </div>
233
+ {opt.expiry_date && (
234
+ <span className="text-[10px] text-foreground-subtle">
235
+ Exp: {opt.expiry_date.substring(0, 10)}
236
+ </span>
237
+ )}
226
238
  </div>
227
239
  ) }}
228
240
  />
@@ -250,7 +262,11 @@ export function BatchSelectionModal({
250
262
  </THeader>
251
263
  <TBody>
252
264
  {rows.length > 0 ? (
253
- rows.map(row => (
265
+ rows.map(row => {
266
+ const batchInfo = batches.find(b => String(b.id) === String(row.batch_id));
267
+ const displayQtyAvailable = row.qty_available !== undefined ? row.qty_available : batchInfo?.qty_available;
268
+
269
+ return (
254
270
  <TRow key={row.uid}>
255
271
  <TCell label="Batch Number">
256
272
  {row.isNew ? (
@@ -316,14 +332,14 @@ export function BatchSelectionModal({
316
332
  <input
317
333
  type="number"
318
334
  min="0"
319
- max={!allowCreate ? row.qty_available : undefined}
335
+ max={!allowCreate && displayQtyAvailable !== undefined ? displayQtyAvailable : undefined}
320
336
  value={row.quantity}
321
337
  onChange={(e) => updateRow(row.uid, 'quantity', e.target.value)}
322
338
  placeholder="0"
323
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"
324
340
  />
325
- {!row.isNew && row.qty_available !== undefined && (
326
- <span className="text-[10px] text-gray-400 mt-0.5">Avail: {row.qty_available}</span>
341
+ {!row.isNew && displayQtyAvailable !== undefined && (
342
+ <span className="text-[10px] text-gray-400 mt-0.5">Avail: {Number(displayQtyAvailable).toFixed(4)}</span>
327
343
  )}
328
344
  </div>
329
345
  </TCell>
@@ -336,7 +352,8 @@ export function BatchSelectionModal({
336
352
  </button>
337
353
  </TCell>
338
354
  </TRow>
339
- ))
355
+ );
356
+ })
340
357
  ) : (
341
358
  <TRow>
342
359
  <TCell colSpan={7} className="p-8 text-center text-gray-500">
@@ -11,7 +11,7 @@ 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
-
14
+ import { HintIcon } from '../../base-components/HintIcon';
15
15
  interface ProductSelectionPanelProps {
16
16
  config: TransactionScreenConfig;
17
17
  warehouseId?: number;
@@ -37,13 +37,16 @@ export function ProductSelectionPanel({
37
37
  const [isSearching, setIsSearching] = useState(false);
38
38
  const [expandedItemIds, setExpandedItemIds] = useState<number[]>([]);
39
39
  const [editingRates, setEditingRates] = useState<Record<string, string>>({});
40
+ const [focusedIndex, setFocusedIndex] = useState<number>(-1);
40
41
 
41
- const [activeBatchItem, setActiveBatchItem] = useState<{ item: TransactionItem; variant?: any } | null>(null);
42
+ const [activeBatchItem, setActiveBatchItem] = useState<{ item: TransactionItem; variant?: any; preSelectedBatch?: any } | null>(null);
42
43
  const [activeBatchLineUid, setActiveBatchLineUid] = useState<string | null>(null);
43
44
  const [activeSerialItem, setActiveSerialItem] = useState<{ item: TransactionItem; variant?: any } | null>(null);
44
45
  const [activeSerialLineUid, setActiveSerialLineUid] = useState<string | null>(null);
46
+ const [autoAddEnabled, setAutoAddEnabled] = useState(true);
45
47
  const searchTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
46
48
  const panelRef = React.useRef<HTMLDivElement>(null);
49
+ const inputRef = React.useRef<HTMLInputElement>(null);
47
50
 
48
51
 
49
52
  // ── Search handler ──
@@ -64,8 +67,30 @@ export function ProductSelectionPanel({
64
67
  const params: Record<string, string | number> = { search: value, limit: 20, type: config.type };
65
68
  if (warehouseId) params.warehouse_id = warehouseId;
66
69
  const res = await transactionLookup(params);
67
- if (res.is_success) {
68
- const results = res.result || [];
70
+ if (res.is_success && res.result) {
71
+ const payload = res.result as any;
72
+ const results = payload.data || (Array.isArray(payload) ? payload : []);
73
+ const exactMatch = autoAddEnabled ? payload.exact_match : null;
74
+
75
+ if (exactMatch) {
76
+ setSearchValue('');
77
+ setSearchResults([]);
78
+ setShowResults(false);
79
+
80
+ const { type: matchType, entity } = exactMatch;
81
+ if (matchType === 'serial') {
82
+ onAddItem(entity.item, entity.variant, { serial_numbers: [entity.serial_number], quantity: 1 });
83
+ } else if (matchType === 'batch') {
84
+ const batchVariant = entity.variant_id && entity.item?.variants ? entity.item.variants.find((v: any) => v.id === entity.variant_id) : null;
85
+ setActiveBatchItem({ item: entity.item, variant: batchVariant, preSelectedBatch: entity });
86
+ } else if (matchType === 'variant') {
87
+ handleSelectItem(entity.item, entity);
88
+ } else if (matchType === 'item') {
89
+ handleSelectItem(entity);
90
+ }
91
+ return;
92
+ }
93
+
69
94
  setSearchResults(results);
70
95
  setExpandedItemIds(results.filter((i: any) => i.has_variants).map((i: any) => i.id));
71
96
  setShowResults(true);
@@ -75,7 +100,7 @@ export function ProductSelectionPanel({
75
100
  }
76
101
  }, 250);
77
102
  },
78
- [warehouseId]
103
+ [warehouseId, autoAddEnabled, config.type]
79
104
  );
80
105
 
81
106
  // ── Select item → check tracking type or add to cart ──
@@ -109,17 +134,54 @@ export function ProductSelectionPanel({
109
134
  const totalQty = batchesData.reduce((sum, b) => sum + b.quantity, 0);
110
135
 
111
136
  if (activeBatchItem) {
112
- onAddItem(activeBatchItem.item, activeBatchItem.variant, {
113
- batches: batchesData,
114
- quantity: totalQty > 0 ? totalQty : 1
115
- });
137
+ const resolvedVariantId = activeBatchItem.variant?.id || (activeBatchItem.item.variants && activeBatchItem.item.variants.length > 0 ? activeBatchItem.item.variants[0].id : null);
138
+ const existingLine = lineItems.find(
139
+ (l) => l.item.id === activeBatchItem.item.id &&
140
+ l.variant_id === resolvedVariantId
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
+ }
116
159
  setActiveBatchItem(null);
117
160
  }
118
161
  };
119
162
 
120
163
  const handleSerialConfirm = (serials: string[]) => {
121
164
  if (!activeSerialItem) return;
122
- onAddItem(activeSerialItem.item, activeSerialItem.variant, { serial_numbers: serials, quantity: serials.length });
165
+
166
+ const resolvedVariantId = activeSerialItem.variant?.id || (activeSerialItem.item.variants && activeSerialItem.item.variants.length > 0 ? activeSerialItem.item.variants[0].id : null);
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
+ }
123
185
  setActiveSerialItem(null);
124
186
  };
125
187
 
@@ -137,7 +199,68 @@ export function ProductSelectionPanel({
137
199
  const formatCurrency = (v: number | string) => Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
138
200
 
139
201
  // ── Update inline input ──
140
- const updateLineInput = (uid: string, field: 'quantity' | 'unit_price', value: string) => {
202
+ // ── Keyboard Navigation ──
203
+ const flattenedItems = useMemo(() => {
204
+ const list: Array<{ type: 'item' | 'variant', item: TransactionItem, variant?: any, key: string }> = [];
205
+ searchResults.forEach(item => {
206
+ if (!item.has_variants) {
207
+ list.push({ type: 'item', item, key: `item-${item.id}` });
208
+ }
209
+ if (item.has_variants && expandedItemIds.includes(item.id)) {
210
+ item.variants?.forEach(variant => {
211
+ list.push({ type: 'variant', item, variant, key: `variant-${variant.id}` });
212
+ });
213
+ }
214
+ });
215
+ return list;
216
+ }, [searchResults, expandedItemIds]);
217
+
218
+ useEffect(() => {
219
+ setFocusedIndex(searchResults.length > 0 ? 0 : -1);
220
+ }, [searchResults]);
221
+
222
+ const focusedKey = flattenedItems[focusedIndex]?.key;
223
+
224
+ useEffect(() => {
225
+ if (focusedKey) {
226
+ const el = document.getElementById(`dropdown-item-${focusedKey}`);
227
+ if (el) el.scrollIntoView({ block: 'nearest' });
228
+ }
229
+ }, [focusedKey]);
230
+
231
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
232
+ if (!showResults || searchResults.length === 0) return;
233
+
234
+ if (e.key === 'ArrowDown') {
235
+ e.preventDefault();
236
+ setFocusedIndex(prev => Math.min(prev + 1, flattenedItems.length - 1));
237
+ } else if (e.key === 'ArrowUp') {
238
+ e.preventDefault();
239
+ setFocusedIndex(prev => Math.max(prev - 1, 0));
240
+ } else if (e.key === 'Enter') {
241
+ e.preventDefault();
242
+ if (focusedIndex >= 0 && focusedIndex < flattenedItems.length) {
243
+ const focused = flattenedItems[focusedIndex];
244
+ if (focused.type === 'item') {
245
+ if (focused.item.has_variants) {
246
+ setExpandedItemIds(prev =>
247
+ prev.includes(focused.item.id)
248
+ ? prev.filter(id => id !== focused.item.id)
249
+ : [...prev, focused.item.id]
250
+ );
251
+ } else {
252
+ handleSelectItem(focused.item);
253
+ }
254
+ } else if (focused.type === 'variant') {
255
+ handleSelectItem(focused.item, focused.variant);
256
+ }
257
+ }
258
+ } else if (e.key === 'Escape') {
259
+ setShowResults(false);
260
+ }
261
+ };
262
+
263
+ const updateLineInput = (uid: string, field: 'quantity' | 'unit_price' | 'free_qty' | 'uom_id', value: string) => {
141
264
  const num = value === '' ? 0 : Number(value);
142
265
  const line = lineItems.find(l => l.uid === uid);
143
266
  if (!line) return;
@@ -150,6 +273,16 @@ export function ProductSelectionPanel({
150
273
 
151
274
  const discAmt = (price * qty * line.discount_percent) / 100;
152
275
 
276
+ if (field === 'free_qty') {
277
+ onUpdateLine(uid, { free_qty: Math.max(0, num) });
278
+ return;
279
+ }
280
+
281
+ if (field === 'uom_id') {
282
+ onUpdateLine(uid, { uom_id: Number(value) });
283
+ return;
284
+ }
285
+
153
286
  onUpdateLine(uid, {
154
287
  quantity: qty,
155
288
  unit_price: price,
@@ -165,6 +298,7 @@ export function ProductSelectionPanel({
165
298
  <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">
166
299
  <Search size={18} className="text-foreground-disabled shrink-0" />
167
300
  <input
301
+ ref={inputRef}
168
302
  type="text"
169
303
  placeholder={
170
304
  config.requireWarehouseFirst && !warehouseId
@@ -174,6 +308,7 @@ export function ProductSelectionPanel({
174
308
  value={searchValue}
175
309
  onChange={(e) => handleSearch(e.target.value)}
176
310
  onFocus={() => searchResults.length > 0 && setShowResults(true)}
311
+ onKeyDown={handleKeyDown}
177
312
  disabled={config.requireWarehouseFirst && !warehouseId}
178
313
  className={cn(
179
314
  "flex-1 bg-transparent text-[13.5px] text-foreground-1 outline-none placeholder:text-foreground-disabled",
@@ -183,14 +318,48 @@ export function ProductSelectionPanel({
183
318
  spellCheck={false}
184
319
  />
185
320
  {isSearching && <Loader2 size={16} className="animate-spin text-primary shrink-0" />}
186
- <ScanBarcode size={18} className="text-foreground-disabled shrink-0 cursor-pointer hover:text-primary transition-colors" />
321
+
322
+ <div className="flex items-center gap-1.5 ml-1">
323
+ <HintIcon
324
+ text="Automatically adds an item to the list when a barcode is scanned or an exact match is found."
325
+ icon={
326
+ <button
327
+ type="button"
328
+ onClick={() => setAutoAddEnabled(!autoAddEnabled)}
329
+ className={cn(
330
+ "relative inline-flex h-5 w-8 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none",
331
+ autoAddEnabled ? "bg-primary" : "bg-gray-300"
332
+ )}
333
+ >
334
+ <span
335
+ className={cn(
336
+ "pointer-events-none inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out",
337
+ autoAddEnabled ? "translate-x-4" : "translate-x-1"
338
+ )}
339
+ />
340
+ </button>
341
+ }
342
+ />
343
+ <div className="w-[1px] h-5 bg-border-subtle mx-1" />
344
+ <button
345
+ type="button"
346
+ onClick={() => inputRef.current?.focus()}
347
+ title="Click to focus input for barcode scanning"
348
+ className="flex items-center justify-center outline-none"
349
+ >
350
+ <ScanBarcode
351
+ size={18}
352
+ className="text-foreground-disabled shrink-0 cursor-pointer hover:text-primary transition-colors"
353
+ />
354
+ </button>
355
+ </div>
187
356
  </div>
188
357
 
189
358
  {/* ── Search Results Dropdown ── */}
190
359
  {showResults && searchResults.length > 0 && (
191
360
  <div className="absolute z-50 top-full mt-1 left-0 right-0 bg-surface-1 border border-border-subtle rounded-[12px] shadow-xl max-h-[calc(100vh-220px)] overflow-y-auto custom-scrollbar">
192
361
  {searchResults.map((item) => (
193
- <div key={item.id} className="flex flex-col border-b border-border-subtle/50 last:border-0">
362
+ <div key={item.id} className="flex flex-col border-b border-border-subtle last:border-0">
194
363
  <button
195
364
  onClick={() => {
196
365
  if (item.has_variants) {
@@ -199,7 +368,13 @@ export function ProductSelectionPanel({
199
368
  handleSelectItem(item);
200
369
  }
201
370
  }}
202
- className={cn("w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-surface-hover transition-colors", expandedItemIds.includes(item.id) && "bg-surface-hover")}
371
+ id={`dropdown-item-item-${item.id}`}
372
+ className={cn(
373
+ "w-full flex items-center gap-3 px-4 py-3 text-left transition-colors",
374
+ expandedItemIds.includes(item.id) && "bg-surface-hover",
375
+ item.has_variants ? "cursor-default" : "cursor-pointer hover:bg-surface-hover",
376
+ focusedKey === `item-${item.id}` && "bg-primary/5"
377
+ )}
203
378
  >
204
379
  {/* Product thumbnail */}
205
380
  <div className="w-9 h-9 rounded-[8px] bg-surface-0 flex items-center justify-center shrink-0 overflow-hidden">
@@ -263,8 +438,12 @@ export function ProductSelectionPanel({
263
438
  {item.variants?.map((variant: any) => (
264
439
  <button
265
440
  key={variant.id}
441
+ id={`dropdown-item-variant-${variant.id}`}
266
442
  onClick={() => handleSelectItem(item, variant)}
267
- className="relative flex items-center justify-between py-2 px-3 rounded-[6px] hover:bg-surface-hover transition-colors text-left group"
443
+ className={cn(
444
+ "relative flex items-center justify-between py-2 px-3 rounded-[6px] hover:bg-surface-hover transition-colors text-left group cursor-pointer",
445
+ focusedKey === `variant-${variant.id}` && "bg-primary/5"
446
+ )}
268
447
  >
269
448
  {/* Horizontal connecting line to variant */}
270
449
  <div className="absolute left-[-18px] top-1/2 w-[18px] h-px bg-border-subtle" />
@@ -333,6 +512,8 @@ export function ProductSelectionPanel({
333
512
  <TRow>
334
513
  <TCell isHeader className="w-[45%]">Item</TCell>
335
514
  <TCell isHeader>Qty</TCell>
515
+ {config.enablePurchaseUnit && <TCell isHeader>Unit</TCell>}
516
+ {config.enableFreeQty && <TCell isHeader>Free Qty</TCell>}
336
517
  {config.showPrices && <TCell isHeader>Rate</TCell>}
337
518
  {config.showPrices && <TCell isHeader className="text-right">Total</TCell>}
338
519
  <TCell isHeader className="w-10 text-center"></TCell>
@@ -353,17 +534,12 @@ export function ProductSelectionPanel({
353
534
  <div className="flex flex-col flex-1 min-w-0">
354
535
  <span className="text-[13px] font-semibold text-foreground-1 leading-tight break-words">{line.item.name}</span>
355
536
  <span className="text-[11px] text-foreground-subtle font-mono mt-0.5 break-words">
356
- {line.variant_id && line.item.variants?.find(v => v.id === line.variant_id)?.sku
357
- ? line.item.variants.find(v => v.id === line.variant_id)?.sku
537
+ {line.variant_id && line.item.variants?.find(v => v.id === line.variant_id)
538
+ ? `${line.item.variants.find(v => v.id === line.variant_id)?.variant_name} • ${line.item.variants.find(v => v.id === line.variant_id)?.sku}`
358
539
  : line.item.sku}
359
540
  </span>
360
- {(line.variant_id || (line.batches && line.batches.length > 0) || (line.serial_numbers && line.serial_numbers.length > 0)) && (
541
+ {((line.batches && line.batches.length > 0) || (line.serial_numbers && line.serial_numbers.length > 0)) && (
361
542
  <div className="flex flex-wrap gap-1 mt-1.5">
362
- {line.variant_id && line.item.variants?.find(v => v.id === line.variant_id) && (
363
- <Badge variant="flat" color="primary" className="text-[10px] px-1.5 py-0">
364
- {line.item.variants.find(v => v.id === line.variant_id)?.variant_name}
365
- </Badge>
366
- )}
367
543
  {line.item.tracking_type === 'batch' && (() => {
368
544
  const batchesCount = line.batches?.length || 0;
369
545
  const selectedTotal = line.batches?.reduce((sum, b) => sum + b.quantity, 0) || 0;
@@ -432,6 +608,33 @@ export function ProductSelectionPanel({
432
608
  />
433
609
  </TCell>
434
610
 
611
+ {config.enablePurchaseUnit && (
612
+ <TCell label="Unit" className="py-2.5">
613
+ <select
614
+ value={line.uom_id || line.item.uom_id || ''}
615
+ onChange={(e) => updateLineInput(line.uid, 'uom_id', e.target.value)}
616
+ 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]"
617
+ >
618
+ {line.item.uom?.uom_group?.uoms?.map((uom: any) => (
619
+ <option key={uom.id} value={uom.id}>{uom.abbreviation}</option>
620
+ ))}
621
+ </select>
622
+ </TCell>
623
+ )}
624
+
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
+
435
638
  {config.showPrices && (
436
639
  <TCell label="Rate" className="py-2.5">
437
640
  <input
@@ -542,7 +745,29 @@ export function ProductSelectionPanel({
542
745
  warehouseId={warehouseId}
543
746
  allowMultiple={true}
544
747
  allowCreate={config.allowCreateBatchAndSerial}
545
- initialBatches={lineItems.find(l => l.uid === activeBatchLineUid)?.batches || []}
748
+ initialBatches={(() => {
749
+ if (activeBatchLineUid) {
750
+ return lineItems.find(l => l.uid === activeBatchLineUid)?.batches || [];
751
+ }
752
+ if (activeBatchItem) {
753
+ const resolvedVariantId = activeBatchItem.variant?.id || (activeBatchItem.item.variants && activeBatchItem.item.variants.length > 0 ? activeBatchItem.item.variants[0].id : null);
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
+
758
+ if (activeBatchItem.preSelectedBatch) {
759
+ const existingBatchIndex = existingBatches.findIndex((b: any) => b.batch_id === activeBatchItem.preSelectedBatch.id);
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
+ }
766
+ }
767
+ return existingBatches;
768
+ }
769
+ return [];
770
+ })()}
546
771
  onConfirm={(selections) => {
547
772
  if (activeBatchItem) {
548
773
  handleBatchConfirm(selections);
@@ -579,7 +804,11 @@ export function ProductSelectionPanel({
579
804
  variant={activeSerialItem?.variant || null}
580
805
  warehouseId={warehouseId}
581
806
  allowCreate={config.allowCreateBatchAndSerial}
582
- initialSerials={[]}
807
+ initialSerials={
808
+ activeSerialItem
809
+ ? lineItems.find(l => l.item.id === activeSerialItem.item.id && l.variant_id === (activeSerialItem.variant?.id || (activeSerialItem.item.variants && activeSerialItem.item.variants.length > 0 ? activeSerialItem.item.variants[0].id : null)))?.serial_numbers || []
810
+ : []
811
+ }
583
812
  onConfirm={handleSerialConfirm}
584
813
  />
585
814
  </div>
@@ -48,63 +48,87 @@ export function ProductTransactionScreen({
48
48
  variant?: any,
49
49
  extra?: { quantity?: number; batches?: any[]; serial_numbers?: string[] }
50
50
  ) => {
51
- // Check if item+variant already exists
52
- const existingIdx = lineItems.findIndex(
53
- (l) => l.item.id === item.id && l.variant_id === (variant?.id || null)
54
- );
55
-
56
- if (existingIdx >= 0) {
57
- // Increment quantity
58
- const updated = [...lineItems];
59
- const existing = updated[existingIdx];
60
- const addedQty = extra?.quantity || 1;
61
- const newQty = existing.quantity + addedQty;
62
- const discountAmt = (existing.unit_price * newQty * existing.discount_percent) / 100;
63
-
64
- let mergedBatches = existing.batches;
65
- if (extra?.batches?.length) {
66
- mergedBatches = [...(mergedBatches || []), ...extra.batches];
51
+ setLineItems((prevLineItems) => {
52
+ const actualVariant = variant || (item.variants && item.variants.length > 0 ? item.variants[0] : null);
53
+ const resolvedVariantId = actualVariant?.id || null;
54
+
55
+ // Ensure the item's variants array includes the actualVariant
56
+ // so that the UI can resolve the variant name when it renders.
57
+ const itemWithVariants = { ...item };
58
+ if (actualVariant) {
59
+ const variantsList = itemWithVariants.variants || [];
60
+ if (!variantsList.find((v) => v.id === actualVariant.id)) {
61
+ itemWithVariants.variants = [...variantsList, actualVariant];
62
+ }
67
63
  }
68
64
 
69
- let mergedSerials = existing.serial_numbers;
70
- if (extra?.serial_numbers?.length) {
71
- mergedSerials = [...(mergedSerials || []), ...extra.serial_numbers];
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;
72
108
  }
73
109
 
74
- updated[existingIdx] = {
75
- ...existing,
76
- quantity: newQty,
110
+ const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
111
+ const unitPrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
112
+ const qty = extra?.quantity || 1;
113
+ const discountAmt = 0; // Initial discount
114
+
115
+ const newLine: TransactionLineItem = {
116
+ uid: `${itemWithVariants.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
117
+ item: itemWithVariants,
118
+ variant_id: resolvedVariantId,
119
+ batches: extra?.batches || [],
120
+ serial_numbers: extra?.serial_numbers || [],
121
+ quantity: qty,
122
+ unit_price: unitPrice,
123
+ discount_percent: 0,
77
124
  discount_amount: discountAmt,
78
- line_total: existing.unit_price * newQty - discountAmt,
79
- batches: mergedBatches,
80
- serial_numbers: mergedSerials,
125
+ line_total: unitPrice * qty - discountAmt,
126
+ expanded: false,
81
127
  };
82
- setLineItems(updated);
83
- return;
84
- }
85
-
86
- const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
87
- const actualVariant = variant || (item.variants && item.variants.length > 0 ? item.variants[0] : null);
88
- const unitPrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
89
- const qty = extra?.quantity || 1;
90
- const discountAmt = 0; // Initial discount
91
-
92
- const newLine: TransactionLineItem = {
93
- uid: `${item.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
94
- item,
95
- variant_id: variant?.id || null,
96
- batches: extra?.batches || [],
97
- serial_numbers: extra?.serial_numbers || [],
98
- quantity: qty,
99
- unit_price: unitPrice,
100
- discount_percent: 0,
101
- discount_amount: discountAmt,
102
- line_total: unitPrice * qty - discountAmt,
103
- expanded: false,
104
- };
105
- setLineItems((prev) => [...prev, newLine]);
128
+ return [...prevLineItems, newLine];
129
+ });
106
130
  },
107
- [lineItems, config.priceField]
131
+ [config.priceField]
108
132
  );
109
133
 
110
134
  // ── Update line ──
@@ -154,6 +178,8 @@ export function ProductTransactionScreen({
154
178
  selling_price: b.selling_price,
155
179
  serial_numbers: undefined,
156
180
  quantity: b.quantity,
181
+ free_qty: l.free_qty,
182
+ uom_id: l.uom_id,
157
183
  unit_price: l.unit_price,
158
184
  discount_percent: l.discount_percent,
159
185
  discount_amount: (l.discount_amount / l.quantity) * b.quantity,
@@ -171,6 +197,8 @@ export function ProductTransactionScreen({
171
197
  selling_price: undefined,
172
198
  serial_numbers: l.serial_numbers,
173
199
  quantity: l.quantity,
200
+ free_qty: l.free_qty,
201
+ uom_id: l.uom_id,
174
202
  unit_price: l.unit_price,
175
203
  discount_percent: l.discount_percent,
176
204
  discount_amount: l.discount_amount,
@@ -19,7 +19,15 @@ export interface TransactionItem {
19
19
  is_discountable: boolean;
20
20
  category?: { id: number; name: string };
21
21
  brand?: { id: number; name: string };
22
- uom?: { id: number; name: string; abbreviation: string };
22
+ uom?: {
23
+ id: number;
24
+ name: string;
25
+ abbreviation: string;
26
+ uom_group?: {
27
+ id: number;
28
+ uoms: { id: number; name: string; abbreviation: string; base_uom_id?: number | null }[];
29
+ };
30
+ };
23
31
  variants?: TransactionItemVariant[];
24
32
  primary_image?: { id: number; url: string } | null;
25
33
  stock?: { qty_on_hand: number; qty_allocated: number; qty_available: number };
@@ -66,6 +74,8 @@ export interface TransactionLineItem {
66
74
  batches?: TransactionLineBatch[];
67
75
  serial_numbers?: string[];
68
76
  quantity: number;
77
+ free_qty?: number;
78
+ uom_id?: number;
69
79
  unit_price: number;
70
80
  discount_percent: number;
71
81
  discount_amount: number;
@@ -134,6 +144,10 @@ export interface TransactionScreenConfig {
134
144
  allowEditPrice?: boolean;
135
145
  /** Whether to show available stock for variants in the search dropdown */
136
146
  showStock?: boolean;
147
+ /** Enable free quantity input (dynamic setting) */
148
+ enableFreeQty?: boolean;
149
+ /** Enable purchase unit selection (dynamic setting) */
150
+ enablePurchaseUnit?: boolean;
137
151
  }
138
152
 
139
153
  // ── Module Presets ──
@@ -288,6 +302,8 @@ export interface TransactionPayload {
288
302
  selling_price?: number;
289
303
  serial_numbers?: string[];
290
304
  quantity: number;
305
+ free_qty?: number;
306
+ uom_id?: number;
291
307
  unit_price: number;
292
308
  discount_percent: number;
293
309
  discount_amount: number;
package/src/index.tsx CHANGED
@@ -19,6 +19,7 @@ export * from './base-components/TableMobileOptions';
19
19
  export * from './base-components/DesktopSearchPopover';
20
20
  export * from './base-components/DesktopFilterPopover';
21
21
  export * from './base-components/Input';
22
+ export * from './base-components/Textarea';
22
23
  export * from './base-components/Label';
23
24
  export * from './base-components/Modal';
24
25
  export * from './base-components/ActionModal';