@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 +1 -1
- package/src/base-components/SearchableSelect.tsx +1 -1
- package/src/base-components/Textarea.tsx +42 -0
- package/src/common-components/pickers/modals/BatchSelectionModal.tsx +24 -7
- package/src/common-components/transaction/ProductSelectionPanel.tsx +214 -24
- package/src/common-components/transaction/ProductTransactionScreen.tsx +74 -50
package/package.json
CHANGED
|
@@ -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-
|
|
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
|
|
225
|
-
<
|
|
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 ?
|
|
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 &&
|
|
326
|
-
<span className="text-[10px] text-gray-400 mt-0.5">Avail: {
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
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)
|
|
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
|
-
{(
|
|
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={
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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:
|
|
79
|
-
|
|
80
|
-
serial_numbers: mergedSerials,
|
|
125
|
+
line_total: unitPrice * qty - discountAmt,
|
|
126
|
+
expanded: false,
|
|
81
127
|
};
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
[
|
|
131
|
+
[config.priceField]
|
|
108
132
|
);
|
|
109
133
|
|
|
110
134
|
// ── Update line ──
|