@apptimate/ui 3.9.0 → 4.1.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.9.0",
3
+ "version": "4.1.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "dependencies": {
@@ -112,15 +112,24 @@ export function Dropdown({ trigger, children, align = 'left', valign = 'bottom',
112
112
 
113
113
  interface DropdownItemProps {
114
114
  children: ReactNode;
115
- onClick?: () => void;
115
+ onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
116
116
  className?: string;
117
117
  isActive?: boolean;
118
+ icon?: ReactNode;
119
+ preventClose?: boolean;
118
120
  }
119
121
 
120
- export function DropdownItem({ children, onClick, className, isActive }: DropdownItemProps) {
122
+ export function DropdownItem({ children, onClick, className, isActive, icon, preventClose }: DropdownItemProps) {
123
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
124
+ if (preventClose) {
125
+ e.stopPropagation();
126
+ }
127
+ onClick?.(e);
128
+ };
129
+
121
130
  return (
122
131
  <div
123
- onClick={onClick}
132
+ onClick={handleClick}
124
133
  className={cn(
125
134
  "px-3 py-2 text-[13px] rounded-[6px] cursor-pointer transition-colors flex items-center gap-2",
126
135
  isActive
@@ -129,7 +138,9 @@ export function DropdownItem({ children, onClick, className, isActive }: Dropdow
129
138
  className
130
139
  )}
131
140
  >
141
+ {icon && <span className="flex items-center justify-center text-current opacity-70 shrink-0">{icon}</span>}
132
142
  {children}
133
143
  </div>
134
144
  );
135
145
  }
146
+
@@ -5,14 +5,23 @@ import { Inbox } from 'lucide-react';
5
5
 
6
6
  export interface EmptyStateProps {
7
7
  message?: string;
8
+ title?: string;
9
+ description?: string;
8
10
  icon?: React.ReactNode;
9
11
  }
10
12
 
11
- export const EmptyState = ({ message = "No records found.", icon }: EmptyStateProps) => {
13
+ export const EmptyState = ({ message = "No records found.", title, description, icon }: EmptyStateProps) => {
12
14
  return (
13
- <div className="flex flex-col items-center justify-center opacity-40">
15
+ <div className="flex flex-col items-center justify-center opacity-40 text-center">
14
16
  {icon || <Inbox size={42} strokeWidth={1.5} className="mb-3 text-foreground-disabled" />}
15
- <p className="text-[14px] font-medium text-foreground-disabled">{message}</p>
17
+ {title ? (
18
+ <>
19
+ <h3 className="text-[16px] font-semibold text-foreground-disabled mb-1">{title}</h3>
20
+ {description && <p className="text-[14px] font-medium text-foreground-disabled">{description}</p>}
21
+ </>
22
+ ) : (
23
+ <p className="text-[14px] font-medium text-foreground-disabled">{message}</p>
24
+ )}
16
25
  </div>
17
26
  );
18
27
  };
@@ -16,7 +16,7 @@ export interface PopConfirmProps {
16
16
  onConfirm: (callback: () => void, setLoading: (loading: boolean) => void) => void | Promise<any>;
17
17
 
18
18
  /** Called when user cancels. Call callback() to close. */
19
- onCancel: (callback: () => void) => void;
19
+ onCancel?: (callback: () => void) => void;
20
20
 
21
21
  /** Text for the confirm button (default: 'Confirm') */
22
22
  confirmText?: string;
@@ -92,7 +92,11 @@ export const PopConfirm = ({
92
92
  }, [onConfirm, close]);
93
93
 
94
94
  const handleCancel = useCallback(() => {
95
- onCancel(close);
95
+ if (onCancel) {
96
+ onCancel(close);
97
+ } else {
98
+ close();
99
+ }
96
100
  }, [onCancel, close]);
97
101
 
98
102
  const handleTriggerClick = useCallback(
@@ -22,11 +22,13 @@ interface MetadataPanelProps {
22
22
  warehouseId: number | null;
23
23
  warehouseName?: string | null;
24
24
  transactionDate?: string;
25
+ expectedDeliveryDate?: string;
25
26
  notes: string;
26
27
  payments: PaymentEntry[];
27
28
  onPartyChange: (id: number | null, party?: PartyLookup) => void;
28
29
  onWarehouseChange: (id: number | null, name?: string) => void;
29
30
  onDateChange?: (date: string) => void;
31
+ onExpectedDeliveryDateChange?: (date: string) => void;
30
32
  onNotesChange: (v: string) => void;
31
33
  onPaymentsChange: (payments: PaymentEntry[]) => void;
32
34
  onSubmit: () => void;
@@ -35,8 +37,8 @@ interface MetadataPanelProps {
35
37
  }
36
38
 
37
39
  export function MetadataPanel({
38
- config, lineItems, partyId, partyName, warehouseId, warehouseName, transactionDate, notes, payments,
39
- onPartyChange, onWarehouseChange, onDateChange, onNotesChange, onPaymentsChange, onSubmit, isSubmitting,
40
+ config, lineItems, partyId, partyName, warehouseId, warehouseName, transactionDate, expectedDeliveryDate, notes, payments,
41
+ onPartyChange, onWarehouseChange, onDateChange, onExpectedDeliveryDateChange, onNotesChange, onPaymentsChange, onSubmit, isSubmitting,
40
42
  renderExtraMeta,
41
43
  }: MetadataPanelProps) {
42
44
  const [paymentModes, setPaymentModes] = useState<PaymentMode[]>([]);
@@ -150,6 +152,7 @@ export function MetadataPanel({
150
152
  onChange={(p) => onPartyChange(p?.id ?? null, p ? { id: p.id, name: p.name, code: p.code, type: p.type } : undefined)}
151
153
  label={config.partyLabel}
152
154
  partyType={config.partyType}
155
+ isRequired
153
156
  />
154
157
  )
155
158
  )}
@@ -180,6 +183,21 @@ export function MetadataPanel({
180
183
  </Section>
181
184
  )}
182
185
 
186
+ {/* ── Expected Delivery Date ── */}
187
+ {config.showExpectedDeliveryDate && onExpectedDeliveryDateChange && (
188
+ <Section label="Expected Delivery">
189
+ <div className="relative">
190
+ <Calendar size={15} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-foreground-disabled pointer-events-none" />
191
+ <input
192
+ type="date"
193
+ value={expectedDeliveryDate || ''}
194
+ onChange={(e) => onExpectedDeliveryDateChange(e.target.value)}
195
+ className="w-full bg-surface-0 border-[1.5px] border-border-subtle rounded-[10px] pl-10 pr-3.5 py-3 text-[13.5px] text-foreground-1 outline-none transition-all hover:border-gray-300 focus:border-primary/40"
196
+ />
197
+ </div>
198
+ </Section>
199
+ )}
200
+
183
201
  {/* ── Notes ── */}
184
202
  {config.showNotes && (
185
203
  <Section label="Notes">
@@ -0,0 +1,149 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Modal, ModalFooter } from "../../base-components/Modal";
3
+ import { Button } from "../../base-components/Button";
4
+ import { Table, THeader, TBody, TRow, TCell } from "../../base-components/Table";
5
+ import { TransactionLineItem } from "./types";
6
+ import { cn } from "@apptimate/core-lib";
7
+
8
+ interface ParentLineSelectionModalProps {
9
+ isOpen: boolean;
10
+ onClose: () => void;
11
+ lineItems: TransactionLineItem[];
12
+ selectedUids?: string[];
13
+ onSelect: (uids: string[]) => void;
14
+ }
15
+
16
+ export function ParentLineSelectionModal({
17
+ isOpen,
18
+ onClose,
19
+ lineItems,
20
+ selectedUids = [],
21
+ onSelect,
22
+ }: ParentLineSelectionModalProps) {
23
+ const [selected, setSelected] = useState<string[]>([]);
24
+
25
+ useEffect(() => {
26
+ if (isOpen) {
27
+ setSelected(selectedUids || []);
28
+ }
29
+ }, [isOpen, selectedUids]);
30
+
31
+ const formatCurrency = (v: number | string) =>
32
+ Number(v).toLocaleString("en-US", {
33
+ minimumFractionDigits: 2,
34
+ maximumFractionDigits: 2,
35
+ });
36
+
37
+ const handleToggle = (uid: string) => {
38
+ setSelected((prev) =>
39
+ prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid]
40
+ );
41
+ };
42
+
43
+ const handleSave = () => {
44
+ onSelect(selected);
45
+ onClose();
46
+ };
47
+
48
+ return (
49
+ <Modal
50
+ isOpen={isOpen}
51
+ onClose={onClose}
52
+ title="Select Parent Lines"
53
+ size="xl"
54
+ >
55
+ <div className="p-4 max-h-[60vh] overflow-y-auto custom-scrollbar">
56
+ {lineItems.length === 0 ? (
57
+ <div className="text-center text-sm text-foreground-disabled py-8">
58
+ No parent lines available.
59
+ </div>
60
+ ) : (
61
+ <Table>
62
+ <THeader>
63
+ <TRow>
64
+ <TCell isHeader className="w-10 text-center"></TCell>
65
+ <TCell isHeader>Item</TCell>
66
+ <TCell isHeader>Qty</TCell>
67
+ <TCell isHeader>Unit</TCell>
68
+ <TCell isHeader>Rate</TCell>
69
+ <TCell isHeader className="text-right">
70
+ Total
71
+ </TCell>
72
+ </TRow>
73
+ </THeader>
74
+ <TBody>
75
+ {lineItems.map((line) => {
76
+ const isSelected = selected.includes(line.uid);
77
+ return (
78
+ <TRow
79
+ key={line.uid}
80
+ onClick={() => handleToggle(line.uid)}
81
+ className={cn(
82
+ "cursor-pointer hover:bg-surface-hover transition-colors",
83
+ isSelected ? "bg-primary/5" : ""
84
+ )}
85
+ >
86
+ <TCell className="text-center">
87
+ <input
88
+ type="checkbox"
89
+ checked={isSelected}
90
+ readOnly
91
+ className="rounded border-border-subtle text-primary focus:ring-primary h-4 w-4 mt-1 cursor-pointer"
92
+ />
93
+ </TCell>
94
+ <TCell label="Item">
95
+ <div className="font-medium text-[13px] text-foreground-1">
96
+ {line.item.name}
97
+ </div>
98
+ {line.variant_id && (
99
+ <div className="text-[11px] text-foreground-subtle font-mono mt-0.5">
100
+ {line.item.variants?.find((v) => v.id === line.variant_id)
101
+ ?.variant_name || ""}{" "}
102
+ •{" "}
103
+ {line.item.variants?.find((v) => v.id === line.variant_id)
104
+ ?.sku || ""}
105
+ </div>
106
+ )}
107
+ </TCell>
108
+ <TCell label="Qty">
109
+ <span className="text-[13px] font-medium">
110
+ {line.quantity}
111
+ </span>
112
+ </TCell>
113
+ <TCell label="Unit">
114
+ <span className="text-[13px] text-foreground-subtle">
115
+ {line.item.uom?.uom_group?.uoms?.find((u: any) => String(u.id) === String(line.uom_id))?.abbreviation ||
116
+ line.item.uom?.abbreviation ||
117
+ "pcs"}
118
+ </span>
119
+ </TCell>
120
+ <TCell label="Rate">
121
+ <span className="text-[13px] text-foreground-subtle">
122
+ {formatCurrency(line.unit_price)}
123
+ </span>
124
+ </TCell>
125
+ <TCell label="Total" className="text-right">
126
+ <span className="text-[13px] font-semibold text-foreground-1">
127
+ {formatCurrency(line.line_total)}
128
+ </span>
129
+ </TCell>
130
+ </TRow>
131
+ );
132
+ })}
133
+ </TBody>
134
+ </Table>
135
+ )}
136
+ </div>
137
+ <ModalFooter>
138
+ <div className="flex justify-end gap-3 w-full">
139
+ <Button variant="flat" color="secondary" onClick={onClose}>
140
+ Cancel
141
+ </Button>
142
+ <Button variant="solid" color="primary" onClick={handleSave}>
143
+ Save Selections
144
+ </Button>
145
+ </div>
146
+ </ModalFooter>
147
+ </Modal>
148
+ );
149
+ }
@@ -11,7 +11,9 @@ 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
+ import { ParentLineSelectionModal } from "./ParentLineSelectionModal";
14
15
  import { HintIcon } from '../../base-components/HintIcon';
16
+
15
17
  interface ProductSelectionPanelProps {
16
18
  config: TransactionScreenConfig;
17
19
  warehouseId?: number;
@@ -43,6 +45,7 @@ export function ProductSelectionPanel({
43
45
  const [activeBatchLineUid, setActiveBatchLineUid] = useState<string | null>(null);
44
46
  const [activeSerialItem, setActiveSerialItem] = useState<{ item: TransactionItem; variant?: any } | null>(null);
45
47
  const [activeSerialLineUid, setActiveSerialLineUid] = useState<string | null>(null);
48
+ const [activeParentLineUidFor, setActiveParentLineUidFor] = useState<string | null>(null);
46
49
  const [autoAddEnabled, setAutoAddEnabled] = useState(true);
47
50
  const searchTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
48
51
  const panelRef = React.useRef<HTMLDivElement>(null);
@@ -110,9 +113,9 @@ export function ProductSelectionPanel({
110
113
  setSearchResults([]);
111
114
  setShowResults(false);
112
115
 
113
- if (item.tracking_type === 'batch') {
116
+ if (!config.disableTrackingSelection && item.tracking_type === 'batch') {
114
117
  setActiveBatchItem({ item, variant });
115
- } else if (item.tracking_type === 'serial') {
118
+ } else if (!config.disableTrackingSelection && item.tracking_type === 'serial') {
116
119
  setActiveSerialItem({ item, variant });
117
120
  } else {
118
121
  onAddItem(item, variant);
@@ -134,28 +137,10 @@ export function ProductSelectionPanel({
134
137
  const totalQty = batchesData.reduce((sum, b) => sum + b.quantity, 0);
135
138
 
136
139
  if (activeBatchItem) {
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
- }
140
+ onAddItem(activeBatchItem.item, activeBatchItem.variant, {
141
+ batches: batchesData,
142
+ quantity: totalQty > 0 ? totalQty : 1
143
+ });
159
144
  setActiveBatchItem(null);
160
145
  }
161
146
  };
@@ -163,25 +148,7 @@ export function ProductSelectionPanel({
163
148
  const handleSerialConfirm = (serials: string[]) => {
164
149
  if (!activeSerialItem) return;
165
150
 
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
- }
151
+ onAddItem(activeSerialItem.item, activeSerialItem.variant, { serial_numbers: serials, quantity: serials.length });
185
152
  setActiveSerialItem(null);
186
153
  };
187
154
 
@@ -273,13 +240,34 @@ export function ProductSelectionPanel({
273
240
 
274
241
  const discAmt = (price * qty * line.discount_percent) / 100;
275
242
 
276
- if (field === 'free_qty') {
277
- onUpdateLine(uid, { free_qty: Math.max(0, num) });
278
- return;
279
- }
280
-
281
243
  if (field === 'uom_id') {
282
- onUpdateLine(uid, { uom_id: Number(value) });
244
+ const newUomId = Number(value);
245
+ const uoms = line.item.uom?.uom_group?.uoms || [];
246
+
247
+ const oldUom = uoms.find((u: any) => u.id === (line.uom_id || line.item.uom_id));
248
+ const newUom = uoms.find((u: any) => u.id === newUomId);
249
+
250
+ let newPrice = line.unit_price;
251
+
252
+ if (oldUom && newUom) {
253
+ const oldFactor = Number(oldUom.conversion_factor) || 1;
254
+ const newFactor = Number(newUom.conversion_factor) || 1;
255
+
256
+ // Calculate the base price then multiply by the new factor
257
+ const basePrice = line.unit_price / oldFactor;
258
+ newPrice = basePrice * newFactor;
259
+ }
260
+
261
+ const newDiscAmt = (newPrice * line.quantity * line.discount_percent) / 100;
262
+
263
+ setEditingRates(prev => ({ ...prev, [uid]: newPrice.toFixed(2) }));
264
+
265
+ onUpdateLine(uid, {
266
+ uom_id: newUomId,
267
+ unit_price: newPrice,
268
+ discount_amount: newDiscAmt,
269
+ line_total: (newPrice * line.quantity) - newDiscAmt
270
+ });
283
271
  return;
284
272
  }
285
273
 
@@ -291,6 +279,36 @@ export function ProductSelectionPanel({
291
279
  });
292
280
  };
293
281
 
282
+ const handleToggleFreeQty = (uid: string, isFree: boolean) => {
283
+ const line = lineItems.find(l => l.uid === uid);
284
+ if (!line) return;
285
+
286
+ let newPrice = 0;
287
+ if (!isFree) {
288
+ const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
289
+ const actualVariant = line.variant_id ? line.item.variants?.find(v => v.id === line.variant_id) : (line.item.variants?.[0] || null);
290
+ newPrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
291
+ }
292
+
293
+ const discAmt = (newPrice * line.quantity * line.discount_percent) / 100;
294
+ onUpdateLine(uid, {
295
+ is_free_qty: isFree,
296
+ unit_price: newPrice,
297
+ discount_amount: discAmt,
298
+ line_total: newPrice * line.quantity - discAmt,
299
+ parent_line_uids: isFree ? line.parent_line_uids : undefined,
300
+ });
301
+
302
+ if (isFree) {
303
+ lineItems.forEach(l => {
304
+ if (l.parent_line_uids?.includes(uid)) {
305
+ const newUids = l.parent_line_uids.filter(id => id !== uid);
306
+ onUpdateLine(l.uid, { parent_line_uids: newUids.length > 0 ? newUids : undefined });
307
+ }
308
+ });
309
+ }
310
+ };
311
+
294
312
  return (
295
313
  <div className="flex flex-col h-full">
296
314
  {/* ── Search Bar ── */}
@@ -513,7 +531,6 @@ export function ProductSelectionPanel({
513
531
  <TCell isHeader className="w-[45%]">Item</TCell>
514
532
  <TCell isHeader>Qty</TCell>
515
533
  {config.enablePurchaseUnit && <TCell isHeader>Unit</TCell>}
516
- {config.enableFreeQty && <TCell isHeader>Free Qty</TCell>}
517
534
  {config.showPrices && <TCell isHeader>Rate</TCell>}
518
535
  {config.showPrices && <TCell isHeader className="text-right">Total</TCell>}
519
536
  <TCell isHeader className="w-10 text-center"></TCell>
@@ -538,7 +555,7 @@ export function ProductSelectionPanel({
538
555
  ? `${line.item.variants.find(v => v.id === line.variant_id)?.variant_name} • ${line.item.variants.find(v => v.id === line.variant_id)?.sku}`
539
556
  : line.item.sku}
540
557
  </span>
541
- {((line.batches && line.batches.length > 0) || (line.serial_numbers && line.serial_numbers.length > 0)) && (
558
+ {!config.disableTrackingSelection && (line.item.tracking_type === 'batch' || line.item.tracking_type === 'serial') && (
542
559
  <div className="flex flex-wrap gap-1 mt-1.5">
543
560
  {line.item.tracking_type === 'batch' && (() => {
544
561
  const batchesCount = line.batches?.length || 0;
@@ -593,6 +610,50 @@ export function ProductSelectionPanel({
593
610
  })()}
594
611
  </div>
595
612
  )}
613
+ {(config.type === 'purchase' || config.type === 'grn') && line.unit_price === 0 && (
614
+ <div className="mt-2 flex items-center gap-2">
615
+ <label className="flex items-center gap-1.5 cursor-pointer">
616
+ <input
617
+ type="checkbox"
618
+ checked={line.is_free_qty || false}
619
+ onChange={(e) => handleToggleFreeQty(line.uid, e.target.checked)}
620
+ className="rounded border-border-subtle text-primary focus:ring-primary h-3 w-3"
621
+ />
622
+ <span className="text-[11.5px] font-medium text-foreground-subtle hover:text-foreground-1 transition-colors">Free Quantity</span>
623
+ </label>
624
+
625
+ {line.is_free_qty && (
626
+ <div className="flex items-center">
627
+ <button
628
+ onClick={() => setActiveParentLineUidFor(line.uid)}
629
+ className="h-6 text-[11.5px] bg-surface-1 border border-border-subtle hover:bg-surface-hover rounded-md px-2 ml-2 min-w-[120px] max-w-[180px] text-left truncate transition-colors focus:border-primary/50 focus:outline-none flex justify-between items-center"
630
+ >
631
+ <span>
632
+ {line.parent_line_uids && line.parent_line_uids.length > 0
633
+ ? (() => {
634
+ if (line.parent_line_uids.length === 1) {
635
+ const p = lineItems.find((l) => l.uid === line.parent_line_uids![0]);
636
+ return p
637
+ ? `${p.item.name} ${
638
+ p.variant_id
639
+ ? `(${
640
+ p.item.variants?.find((v) => v.id === p.variant_id)?.variant_name
641
+ })`
642
+ : ""
643
+ }`
644
+ : "Select Parent Line...";
645
+ } else {
646
+ return `${line.parent_line_uids.length} Parents Selected`;
647
+ }
648
+ })()
649
+ : "Select Parent Line..."}
650
+ </span>
651
+ </button>
652
+ <HintIcon text="Select the original line item that this free quantity is associated with." />
653
+ </div>
654
+ )}
655
+ </div>
656
+ )}
596
657
  </div>
597
658
  </div>
598
659
  </TCell>
@@ -622,19 +683,6 @@ export function ProductSelectionPanel({
622
683
  </TCell>
623
684
  )}
624
685
 
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
-
638
686
  {config.showPrices && (
639
687
  <TCell label="Rate" className="py-2.5">
640
688
  <input
@@ -654,10 +702,10 @@ export function ProductSelectionPanel({
654
702
  return next;
655
703
  });
656
704
  }}
657
- disabled={!config.allowEditPrice}
705
+ disabled={!config.allowEditPrice || line.is_free_qty}
658
706
  className={cn(
659
707
  "w-24 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",
660
- !config.allowEditPrice && "opacity-60 cursor-not-allowed bg-surface-hover border-transparent"
708
+ (!config.allowEditPrice || line.is_free_qty) && "opacity-60 cursor-not-allowed bg-surface-hover border-transparent"
661
709
  )}
662
710
  />
663
711
  </TCell>
@@ -750,19 +798,9 @@ export function ProductSelectionPanel({
750
798
  return lineItems.find(l => l.uid === activeBatchLineUid)?.batches || [];
751
799
  }
752
800
  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
-
801
+ const existingBatches: any[] = [];
758
802
  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
- }
803
+ existingBatches.push({ ...activeBatchItem.preSelectedBatch, batch_id: activeBatchItem.preSelectedBatch.id, quantity: 1 });
766
804
  }
767
805
  return existingBatches;
768
806
  }
@@ -804,12 +842,24 @@ export function ProductSelectionPanel({
804
842
  variant={activeSerialItem?.variant || null}
805
843
  warehouseId={warehouseId}
806
844
  allowCreate={config.allowCreateBatchAndSerial}
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 || []
845
+ initialSerials={[]}
846
+ onConfirm={handleSerialConfirm}
847
+ />
848
+
849
+ <ParentLineSelectionModal
850
+ isOpen={!!activeParentLineUidFor}
851
+ onClose={() => setActiveParentLineUidFor(null)}
852
+ lineItems={lineItems.filter((l) => l.uid !== activeParentLineUidFor && !l.is_free_qty)}
853
+ selectedUids={
854
+ activeParentLineUidFor
855
+ ? lineItems.find((l) => l.uid === activeParentLineUidFor)?.parent_line_uids || []
810
856
  : []
811
857
  }
812
- onConfirm={handleSerialConfirm}
858
+ onSelect={(uids) => {
859
+ if (activeParentLineUidFor) {
860
+ onUpdateLine(activeParentLineUidFor, { parent_line_uids: uids.length > 0 ? uids : undefined });
861
+ }
862
+ }}
813
863
  />
814
864
  </div>
815
865
  );
@@ -35,6 +35,7 @@ export function ProductTransactionScreen({
35
35
  const [transactionDate, setTransactionDate] = useState<string>(() => {
36
36
  return new Date().toISOString().split('T')[0];
37
37
  });
38
+ const [expectedDeliveryDate, setExpectedDeliveryDate] = useState<string>('');
38
39
  const [notes, setNotes] = useState('');
39
40
  const [payments, setPayments] = useState<PaymentEntry[]>([]);
40
41
  const [isSubmitting, setIsSubmitting] = useState(false);
@@ -62,51 +63,6 @@ export function ProductTransactionScreen({
62
63
  }
63
64
  }
64
65
 
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;
108
- }
109
-
110
66
  const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
111
67
  const unitPrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
112
68
  const qty = extra?.quantity || 1;
@@ -164,6 +120,7 @@ export function ProductTransactionScreen({
164
120
  party_id: partyId,
165
121
  warehouse_id: warehouseId!,
166
122
  transaction_date: transactionDate,
123
+ expected_delivery_date: expectedDeliveryDate || undefined,
167
124
  notes: notes || undefined,
168
125
  lines: lineItems.flatMap((l): TransactionPayload['lines'] => {
169
126
  if (l.batches && l.batches.length > 0) {
@@ -178,7 +135,9 @@ export function ProductTransactionScreen({
178
135
  selling_price: b.selling_price,
179
136
  serial_numbers: undefined,
180
137
  quantity: b.quantity,
181
- free_qty: l.free_qty,
138
+ uid: l.uid,
139
+ is_free_qty: l.is_free_qty,
140
+ parent_line_uids: l.parent_line_uids,
182
141
  uom_id: l.uom_id,
183
142
  unit_price: l.unit_price,
184
143
  discount_percent: l.discount_percent,
@@ -197,7 +156,9 @@ export function ProductTransactionScreen({
197
156
  selling_price: undefined,
198
157
  serial_numbers: l.serial_numbers,
199
158
  quantity: l.quantity,
200
- free_qty: l.free_qty,
159
+ uid: l.uid,
160
+ is_free_qty: l.is_free_qty,
161
+ parent_line_uids: l.parent_line_uids,
201
162
  uom_id: l.uom_id,
202
163
  unit_price: l.unit_price,
203
164
  discount_percent: l.discount_percent,
@@ -219,6 +180,7 @@ export function ProductTransactionScreen({
219
180
  setPayments([]);
220
181
  setNotes('');
221
182
  setTransactionDate(new Date().toISOString().split('T')[0]);
183
+ setExpectedDeliveryDate('');
222
184
  if (!defaultWarehouseId) { setWarehouseId(null); setWarehouseName(null); }
223
185
  } catch (error) {
224
186
  // The parent usually displays a toast error. We catch it here
@@ -228,7 +190,7 @@ export function ProductTransactionScreen({
228
190
  } finally {
229
191
  setIsSubmitting(false);
230
192
  }
231
- }, [lineItems, config, partyId, warehouseId, transactionDate, notes, payments, subtotal, discountTotal, grandTotal, onSubmit, defaultWarehouseId]);
193
+ }, [lineItems, config, partyId, warehouseId, transactionDate, expectedDeliveryDate, notes, payments, subtotal, discountTotal, grandTotal, onSubmit, defaultWarehouseId]);
232
194
 
233
195
  return (
234
196
  <div className="flex flex-col h-full">
@@ -260,11 +222,13 @@ export function ProductTransactionScreen({
260
222
  warehouseId={warehouseId}
261
223
  warehouseName={warehouseName}
262
224
  transactionDate={transactionDate}
225
+ expectedDeliveryDate={expectedDeliveryDate}
263
226
  notes={notes}
264
227
  payments={payments}
265
228
  onPartyChange={(id, party) => { setPartyId(id); setPartyName(party?.name ?? null); }}
266
229
  onWarehouseChange={(id, name) => { setWarehouseId(id); setWarehouseName(name ?? null); }}
267
230
  onDateChange={setTransactionDate}
231
+ onExpectedDeliveryDateChange={setExpectedDeliveryDate}
268
232
  onNotesChange={setNotes}
269
233
  onPaymentsChange={setPayments}
270
234
  onSubmit={handleSubmit}
@@ -25,7 +25,7 @@ export interface TransactionItem {
25
25
  abbreviation: string;
26
26
  uom_group?: {
27
27
  id: number;
28
- uoms: { id: number; name: string; abbreviation: string; base_uom_id?: number | null }[];
28
+ uoms: { id: number; name: string; abbreviation: string; base_uom_id?: number | null; conversion_factor: number }[];
29
29
  };
30
30
  };
31
31
  variants?: TransactionItemVariant[];
@@ -74,7 +74,8 @@ export interface TransactionLineItem {
74
74
  batches?: TransactionLineBatch[];
75
75
  serial_numbers?: string[];
76
76
  quantity: number;
77
- free_qty?: number;
77
+ is_free_qty?: boolean;
78
+ parent_line_uids?: string[];
78
79
  uom_id?: number;
79
80
  unit_price: number;
80
81
  discount_percent: number;
@@ -144,10 +145,12 @@ export interface TransactionScreenConfig {
144
145
  allowEditPrice?: boolean;
145
146
  /** Whether to show available stock for variants in the search dropdown */
146
147
  showStock?: boolean;
147
- /** Enable free quantity input (dynamic setting) */
148
- enableFreeQty?: boolean;
149
148
  /** Enable purchase unit selection (dynamic setting) */
150
149
  enablePurchaseUnit?: boolean;
150
+ /** Show expected delivery date */
151
+ showExpectedDeliveryDate?: boolean;
152
+ /** Disable batch/serial selection completely (e.g. for Purchase Orders and Quotations) */
153
+ disableTrackingSelection?: boolean;
151
154
  }
152
155
 
153
156
  // ── Module Presets ──
@@ -184,6 +187,7 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
184
187
  allowCreateBatchAndSerial: false,
185
188
  allowEditPrice: false,
186
189
  showStock: true,
190
+ disableTrackingSelection: true,
187
191
  },
188
192
  purchase: {
189
193
  type: 'purchase',
@@ -200,6 +204,8 @@ export const TRANSACTION_PRESETS: Record<TransactionType, TransactionScreenConfi
200
204
  allowCreateBatchAndSerial: true,
201
205
  allowEditPrice: true,
202
206
  showStock: true,
207
+ showExpectedDeliveryDate: true,
208
+ disableTrackingSelection: true,
203
209
  },
204
210
  transfer: {
205
211
  type: 'transfer',
@@ -290,6 +296,7 @@ export interface TransactionPayload {
290
296
  party_id?: number | null;
291
297
  warehouse_id: number;
292
298
  transaction_date?: string;
299
+ expected_delivery_date?: string;
293
300
  notes?: string;
294
301
  lines: Array<{
295
302
  item_id: number;
@@ -302,7 +309,9 @@ export interface TransactionPayload {
302
309
  selling_price?: number;
303
310
  serial_numbers?: string[];
304
311
  quantity: number;
305
- free_qty?: number;
312
+ uid: string;
313
+ is_free_qty?: boolean;
314
+ parent_line_uids?: string[];
306
315
  uom_id?: number;
307
316
  unit_price: number;
308
317
  discount_percent: number;
package/src/index.tsx CHANGED
@@ -21,6 +21,7 @@ export * from './base-components/DesktopFilterPopover';
21
21
  export * from './base-components/Input';
22
22
  export * from './base-components/Textarea';
23
23
  export * from './base-components/Label';
24
+ export * from './base-components/Textarea';
24
25
  export * from './base-components/Modal';
25
26
  export * from './base-components/ActionModal';
26
27
  export * from './base-components/Pagination';