@apptimate/ui 3.7.0 → 3.8.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.8.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,6 +199,67 @@ 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 ──
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
+
140
263
  const updateLineInput = (uid: string, field: 'quantity' | 'unit_price', value: string) => {
141
264
  const num = value === '' ? 0 : Number(value);
142
265
  const line = lineItems.find(l => l.uid === uid);
@@ -165,6 +288,7 @@ export function ProductSelectionPanel({
165
288
  <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
289
  <Search size={18} className="text-foreground-disabled shrink-0" />
167
290
  <input
291
+ ref={inputRef}
168
292
  type="text"
169
293
  placeholder={
170
294
  config.requireWarehouseFirst && !warehouseId
@@ -174,6 +298,7 @@ export function ProductSelectionPanel({
174
298
  value={searchValue}
175
299
  onChange={(e) => handleSearch(e.target.value)}
176
300
  onFocus={() => searchResults.length > 0 && setShowResults(true)}
301
+ onKeyDown={handleKeyDown}
177
302
  disabled={config.requireWarehouseFirst && !warehouseId}
178
303
  className={cn(
179
304
  "flex-1 bg-transparent text-[13.5px] text-foreground-1 outline-none placeholder:text-foreground-disabled",
@@ -183,14 +308,48 @@ export function ProductSelectionPanel({
183
308
  spellCheck={false}
184
309
  />
185
310
  {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" />
311
+
312
+ <div className="flex items-center gap-1.5 ml-1">
313
+ <HintIcon
314
+ text="Automatically adds an item to the list when a barcode is scanned or an exact match is found."
315
+ icon={
316
+ <button
317
+ type="button"
318
+ onClick={() => setAutoAddEnabled(!autoAddEnabled)}
319
+ className={cn(
320
+ "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",
321
+ autoAddEnabled ? "bg-primary" : "bg-gray-300"
322
+ )}
323
+ >
324
+ <span
325
+ className={cn(
326
+ "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",
327
+ autoAddEnabled ? "translate-x-4" : "translate-x-1"
328
+ )}
329
+ />
330
+ </button>
331
+ }
332
+ />
333
+ <div className="w-[1px] h-5 bg-border-subtle mx-1" />
334
+ <button
335
+ type="button"
336
+ onClick={() => inputRef.current?.focus()}
337
+ title="Click to focus input for barcode scanning"
338
+ className="flex items-center justify-center outline-none"
339
+ >
340
+ <ScanBarcode
341
+ size={18}
342
+ className="text-foreground-disabled shrink-0 cursor-pointer hover:text-primary transition-colors"
343
+ />
344
+ </button>
345
+ </div>
187
346
  </div>
188
347
 
189
348
  {/* ── Search Results Dropdown ── */}
190
349
  {showResults && searchResults.length > 0 && (
191
350
  <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
351
  {searchResults.map((item) => (
193
- <div key={item.id} className="flex flex-col border-b border-border-subtle/50 last:border-0">
352
+ <div key={item.id} className="flex flex-col border-b border-border-subtle last:border-0">
194
353
  <button
195
354
  onClick={() => {
196
355
  if (item.has_variants) {
@@ -199,7 +358,13 @@ export function ProductSelectionPanel({
199
358
  handleSelectItem(item);
200
359
  }
201
360
  }}
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")}
361
+ id={`dropdown-item-item-${item.id}`}
362
+ className={cn(
363
+ "w-full flex items-center gap-3 px-4 py-3 text-left transition-colors",
364
+ expandedItemIds.includes(item.id) && "bg-surface-hover",
365
+ item.has_variants ? "cursor-default" : "cursor-pointer hover:bg-surface-hover",
366
+ focusedKey === `item-${item.id}` && "bg-primary/5"
367
+ )}
203
368
  >
204
369
  {/* Product thumbnail */}
205
370
  <div className="w-9 h-9 rounded-[8px] bg-surface-0 flex items-center justify-center shrink-0 overflow-hidden">
@@ -263,8 +428,12 @@ export function ProductSelectionPanel({
263
428
  {item.variants?.map((variant: any) => (
264
429
  <button
265
430
  key={variant.id}
431
+ id={`dropdown-item-variant-${variant.id}`}
266
432
  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"
433
+ className={cn(
434
+ "relative flex items-center justify-between py-2 px-3 rounded-[6px] hover:bg-surface-hover transition-colors text-left group cursor-pointer",
435
+ focusedKey === `variant-${variant.id}` && "bg-primary/5"
436
+ )}
268
437
  >
269
438
  {/* Horizontal connecting line to variant */}
270
439
  <div className="absolute left-[-18px] top-1/2 w-[18px] h-px bg-border-subtle" />
@@ -353,17 +522,12 @@ export function ProductSelectionPanel({
353
522
  <div className="flex flex-col flex-1 min-w-0">
354
523
  <span className="text-[13px] font-semibold text-foreground-1 leading-tight break-words">{line.item.name}</span>
355
524
  <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
525
+ {line.variant_id && line.item.variants?.find(v => v.id === line.variant_id)
526
+ ? `${line.item.variants.find(v => v.id === line.variant_id)?.variant_name} • ${line.item.variants.find(v => v.id === line.variant_id)?.sku}`
358
527
  : line.item.sku}
359
528
  </span>
360
- {(line.variant_id || (line.batches && line.batches.length > 0) || (line.serial_numbers && line.serial_numbers.length > 0)) && (
529
+ {((line.batches && line.batches.length > 0) || (line.serial_numbers && line.serial_numbers.length > 0)) && (
361
530
  <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
531
  {line.item.tracking_type === 'batch' && (() => {
368
532
  const batchesCount = line.batches?.length || 0;
369
533
  const selectedTotal = line.batches?.reduce((sum, b) => sum + b.quantity, 0) || 0;
@@ -542,7 +706,29 @@ export function ProductSelectionPanel({
542
706
  warehouseId={warehouseId}
543
707
  allowMultiple={true}
544
708
  allowCreate={config.allowCreateBatchAndSerial}
545
- initialBatches={lineItems.find(l => l.uid === activeBatchLineUid)?.batches || []}
709
+ initialBatches={(() => {
710
+ if (activeBatchLineUid) {
711
+ return lineItems.find(l => l.uid === activeBatchLineUid)?.batches || [];
712
+ }
713
+ if (activeBatchItem) {
714
+ const resolvedVariantId = activeBatchItem.variant?.id || (activeBatchItem.item.variants && activeBatchItem.item.variants.length > 0 ? activeBatchItem.item.variants[0].id : null);
715
+ const existingLine = lineItems.find(l => l.item.id === activeBatchItem.item.id && l.variant_id === resolvedVariantId);
716
+
717
+ const existingBatches = existingLine?.batches ? [...existingLine.batches] : [];
718
+
719
+ if (activeBatchItem.preSelectedBatch) {
720
+ const existingBatchIndex = existingBatches.findIndex((b: any) => b.batch_id === activeBatchItem.preSelectedBatch.id);
721
+ if (existingBatchIndex >= 0) {
722
+ const b = existingBatches[existingBatchIndex];
723
+ existingBatches[existingBatchIndex] = { ...b, quantity: Number(b.quantity) + 1 };
724
+ } else {
725
+ existingBatches.push({ ...activeBatchItem.preSelectedBatch, batch_id: activeBatchItem.preSelectedBatch.id, quantity: 1 });
726
+ }
727
+ }
728
+ return existingBatches;
729
+ }
730
+ return [];
731
+ })()}
546
732
  onConfirm={(selections) => {
547
733
  if (activeBatchItem) {
548
734
  handleBatchConfirm(selections);
@@ -579,7 +765,11 @@ export function ProductSelectionPanel({
579
765
  variant={activeSerialItem?.variant || null}
580
766
  warehouseId={warehouseId}
581
767
  allowCreate={config.allowCreateBatchAndSerial}
582
- initialSerials={[]}
768
+ initialSerials={
769
+ activeSerialItem
770
+ ? 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 || []
771
+ : []
772
+ }
583
773
  onConfirm={handleSerialConfirm}
584
774
  />
585
775
  </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 ──