@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 +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 +254 -25
- package/src/common-components/transaction/ProductTransactionScreen.tsx +78 -50
- package/src/common-components/transaction/types.ts +17 -1
- package/src/index.tsx +1 -0
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,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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
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)
|
|
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
|
-
{(
|
|
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={
|
|
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
|
-
|
|
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 ──
|
|
@@ -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?: {
|
|
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';
|