@apptimate/ui 4.6.0 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apptimate/ui",
3
- "version": "4.6.0",
3
+ "version": "4.8.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "dependencies": {
@@ -49,6 +49,8 @@ export interface SearchableSelectProps<T = Record<string, unknown>> {
49
49
  creatable?: CreatableConfig<T>;
50
50
  /** Additional className on the outer wrapper */
51
51
  className?: string;
52
+ /** Additional className on the control div (the trigger) */
53
+ controlClassName?: string;
52
54
  /** Enable search filtering (default: true) */
53
55
  isSearchable?: boolean;
54
56
  /** Show clear button when a value is selected */
@@ -119,6 +121,7 @@ function SelectCore<T extends Record<string, unknown>>({
119
121
  error,
120
122
  isRequired,
121
123
  surface = 0,
124
+ controlClassName,
122
125
  }: SelectCoreProps<T>) {
123
126
  const opt = { ...DEFAULT_OPTION_CONFIG, ...optionConfig } as SearchableSelectOptionConfig<T>;
124
127
 
@@ -560,11 +563,11 @@ function SelectCore<T extends Record<string, unknown>>({
560
563
  )}
561
564
 
562
565
  {/* Option label */}
563
- <span className="truncate">
566
+ <div className="flex-1 min-w-0">
564
567
  {opt.renderOption
565
568
  ? opt.renderOption(item)
566
- : getLabel(item)}
567
- </span>
569
+ : <span className="truncate block">{getLabel(item)}</span>}
570
+ </div>
568
571
  </li>
569
572
  ))}
570
573
 
@@ -616,6 +619,7 @@ function SelectCore<T extends Record<string, unknown>>({
616
619
  ref={controlRef}
617
620
  className={cn(
618
621
  'w-full border-[1.5px] rounded-[10px] px-3.5 py-2.5 flex items-center gap-2 cursor-pointer transition-all min-h-[42px]',
622
+ controlClassName,
619
623
  isOpen
620
624
  ? `border-gray-300 bg-surface-${surface === 0 ? 1 : 2}`
621
625
  : `border-border-subtle bg-surface-${surface} hover:border-gray-300`,
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import React, { useState } from 'react';
4
- import { Briefcase, CreditCard, Settings, Menu, X, LogOut, User as UserIcon, Building2, ChevronRight, Check } from 'lucide-react';
4
+ import { Briefcase, CreditCard, Settings, Menu, X, LogOut, User as UserIcon, Building2, ChevronRight, Check, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
5
5
  import { Dropdown, DropdownItem } from '../base-components/Dropdown';
6
6
 
7
7
  import Link from 'next/link';
@@ -67,6 +67,7 @@ export function DashboardLayout({
67
67
  }) {
68
68
  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
69
69
  const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
70
+ const [isSubSidebarOpen, setIsSubSidebarOpen] = useState(true);
70
71
  const pathname = usePathname() || "";
71
72
  const router = useRouter();
72
73
 
@@ -140,11 +141,22 @@ export function DashboardLayout({
140
141
  return (
141
142
  <div className="flex h-screen bg-[#F4F5F7] overflow-hidden font-sans print:h-auto print:overflow-visible print:bg-white">
142
143
  {/* Desktop Global Sidebar */}
143
- <aside className="hidden md:flex print:hidden w-[80px] bg-white border-r border-gray-200 flex-col items-center py-6 shrink-0 z-20">
144
+ <aside className="hidden md:flex print:hidden w-[80px] bg-white border-r border-gray-200 flex-col items-center py-6 shrink-0 z-30 relative group">
144
145
  <div className="w-12 h-12 flex items-center justify-center mb-8">
145
146
  {logo}
146
147
  </div>
147
148
 
149
+ {/* Floating Open Button when Sub-sidebar is closed */}
150
+ {!isSubSidebarOpen && activeMenu && activeMenu.groups.length > 0 && (
151
+ <button
152
+ onClick={() => setIsSubSidebarOpen(true)}
153
+ className="hidden md:flex absolute -right-4 top-6 z-40 w-8 h-8 bg-white border border-gray-200 rounded-full shadow-sm items-center justify-center text-gray-500 hover:text-[#2D3142] hover:bg-gray-50 transition-all outline-none opacity-0 group-hover:opacity-100 cursor-pointer"
154
+ title="Expand Sidebar"
155
+ >
156
+ <PanelLeftOpen size={16} />
157
+ </button>
158
+ )}
159
+
148
160
  <style dangerouslySetInnerHTML={{
149
161
  __html: `
150
162
  .sidebar-scroll {
@@ -179,7 +191,10 @@ export function DashboardLayout({
179
191
  return (
180
192
  <div
181
193
  key={menu.id}
182
- onClick={() => setActiveMenuId(menu.id)}
194
+ onClick={() => {
195
+ setActiveMenuId(menu.id);
196
+ setIsSubSidebarOpen(true);
197
+ }}
183
198
  className={`flex flex-col items-center justify-center gap-1.5 cursor-pointer transition-colors w-full px-1 ${isActive ? "text-[#2D3142]" : "text-gray-400 hover:text-[#2D3142]"
184
199
  }`}
185
200
  >
@@ -226,8 +241,17 @@ export function DashboardLayout({
226
241
 
227
242
  {/* Desktop Secondary Sidebar */}
228
243
  {activeMenu && activeMenu.groups.length > 0 && (
229
- <aside className="hidden md:flex print:hidden w-[210px] bg-white border-gray-200 flex-col py-8 shrink-0 z-10">
230
- <h2 className="px-6 text-xl font-bold text-[#2D3142]/80 mb-6">{activeMenu.label}</h2>
244
+ <>
245
+ <aside className={cn(
246
+ "hidden md:flex print:hidden bg-white border-gray-200 flex-col py-8 shrink-0 z-10 transition-all duration-300 overflow-hidden group",
247
+ isSubSidebarOpen ? "w-[210px]" : "w-0 opacity-0 px-0"
248
+ )}>
249
+ <div className="flex items-center justify-between px-6 mb-6 w-[210px]">
250
+ <h2 className="text-xl font-bold text-[#2D3142]/80 truncate">{activeMenu.label}</h2>
251
+ <button onClick={() => setIsSubSidebarOpen(false)} className="text-gray-400 hover:text-[#2D3142] transition-colors flex-shrink-0 outline-none opacity-0 group-hover:opacity-100 cursor-pointer">
252
+ <PanelLeftClose size={18} />
253
+ </button>
254
+ </div>
231
255
  <div className="flex-1 overflow-y-auto px-4 space-y-6">
232
256
  {activeMenu.groups.map((group) => (
233
257
  <div key={group.id}>
@@ -296,6 +320,8 @@ export function DashboardLayout({
296
320
  </div>
297
321
  )}
298
322
  </aside>
323
+
324
+ </>
299
325
  )}
300
326
 
301
327
  {/* Main Content Area & Mobile Header */}
@@ -172,6 +172,7 @@ export interface PickerTriggerProps {
172
172
  placeholder?: string;
173
173
  isRequired?: boolean;
174
174
  error?: string;
175
+ disabled?: boolean;
175
176
  onClick: () => void;
176
177
  onClear?: () => void;
177
178
  }
@@ -182,6 +183,7 @@ export function PickerTrigger({
182
183
  placeholder = "Select…",
183
184
  isRequired = false,
184
185
  error,
186
+ disabled = false,
185
187
  onClick,
186
188
  onClear,
187
189
  }: PickerTriggerProps) {
@@ -193,10 +195,12 @@ export function PickerTrigger({
193
195
  </label>
194
196
  <button
195
197
  type="button"
198
+ disabled={disabled}
196
199
  onClick={onClick}
197
200
  className={`
198
201
  w-full flex items-center justify-between px-3.5 py-2.5 bg-surface-0 border-[1.5px] rounded-[10px] text-[13.5px] text-left transition-all
199
- ${error
202
+ ${disabled ? "opacity-60 cursor-not-allowed bg-surface-hover" : ""}
203
+ ${error && !disabled
200
204
  ? "border-red-500 text-red-500"
201
205
  : value
202
206
  ? "border-border-subtle text-foreground-1"
@@ -18,6 +18,7 @@ interface PartyPickerProps {
18
18
  label?: string;
19
19
  placeholder?: string;
20
20
  isRequired?: boolean;
21
+ disabled?: boolean;
21
22
  partyType?: "customer" | "supplier" | "all" | "artisan";
22
23
  customTrigger?: (onClick: () => void) => React.ReactNode;
23
24
  }
@@ -29,6 +30,7 @@ export function PartyPicker({
29
30
  label = "Party",
30
31
  placeholder = "Select party…",
31
32
  isRequired = false,
33
+ disabled = false,
32
34
  partyType = "all",
33
35
  customTrigger,
34
36
  }: PartyPickerProps) {
@@ -126,6 +128,7 @@ export function PartyPicker({
126
128
  value={displayValue || null}
127
129
  placeholder={placeholder}
128
130
  isRequired={isRequired}
131
+ disabled={disabled}
129
132
  onClick={handleOpen}
130
133
  onClear={value ? handleClear : undefined}
131
134
  />
@@ -18,6 +18,7 @@ interface WarehousePickerProps {
18
18
  label?: string;
19
19
  placeholder?: string;
20
20
  isRequired?: boolean;
21
+ disabled?: boolean;
21
22
  fetchFromAllUserOrgs?: boolean;
22
23
  }
23
24
 
@@ -28,6 +29,7 @@ export function WarehousePicker({
28
29
  label = "Warehouse",
29
30
  placeholder = "Select warehouse…",
30
31
  isRequired = false,
32
+ disabled = false,
31
33
  fetchFromAllUserOrgs = false,
32
34
  }: WarehousePickerProps) {
33
35
  const [isOpen, setIsOpen] = useState(false);
@@ -86,6 +88,7 @@ export function WarehousePicker({
86
88
  value={displayValue || null}
87
89
  placeholder={placeholder}
88
90
  isRequired={isRequired}
91
+ disabled={disabled}
89
92
  onClick={handleOpen}
90
93
  onClear={value ? handleClear : undefined}
91
94
  />
@@ -146,6 +146,7 @@ export function MetadataPanel({
146
146
  label={config.partyLabel}
147
147
  isRequired
148
148
  fetchFromAllUserOrgs={true}
149
+ disabled={config.disablePartySelection}
149
150
  />
150
151
  ) : (
151
152
  <PartyPicker
@@ -155,6 +156,7 @@ export function MetadataPanel({
155
156
  label={config.partyLabel}
156
157
  partyType={config.partyType}
157
158
  isRequired
159
+ disabled={config.disablePartySelection}
158
160
  />
159
161
  )
160
162
  )}
@@ -167,6 +169,7 @@ export function MetadataPanel({
167
169
  onChange={(w) => onWarehouseChange(w?.id ?? null, w?.name)}
168
170
  label="Warehouse"
169
171
  isRequired
172
+ disabled={config.disableWarehouseSelection}
170
173
  />
171
174
  )}
172
175
 
@@ -13,6 +13,7 @@ import type { TransactionItem, TransactionLineItem, TransactionScreenConfig } fr
13
13
  import { BatchSelectionModal, SerialSelectionModal } from "../pickers";
14
14
  import { ParentLineSelectionModal } from "./ParentLineSelectionModal";
15
15
  import { HintIcon } from '../../base-components/HintIcon';
16
+ import { AsyncSearchableSelect } from '../../base-components/SearchableSelect';
16
17
 
17
18
  interface ProductSelectionPanelProps {
18
19
  config: TransactionScreenConfig;
@@ -94,8 +95,15 @@ export function ProductSelectionPanel({
94
95
  if (matchType === 'serial') {
95
96
  onAddItem(entity.item, entity.variant, { serial_numbers: [entity.serial_number], quantity: 1 });
96
97
  } else if (matchType === 'batch') {
97
- const batchVariant = entity.variant_id && entity.item?.variants ? entity.item.variants.find((v: any) => v.id === entity.variant_id) : null;
98
- setActiveBatchItem({ item: entity.item, variant: batchVariant, preSelectedBatch: entity });
98
+ const batchVariant = entity.variant || (entity.variant_id && entity.item?.variants ? entity.item.variants.find((v: any) => v.id === entity.variant_id) : null);
99
+ if (['sales', 'quotation'].includes(config.type)) {
100
+ onAddItem(entity.item, batchVariant, {
101
+ batches: [{ batch_id: entity.id, batch_number: entity.batch_number, selling_price: entity.selling_price, quantity: 1 }],
102
+ quantity: 1
103
+ });
104
+ } else {
105
+ setActiveBatchItem({ item: entity.item, variant: batchVariant, preSelectedBatch: entity });
106
+ }
99
107
  } else if (matchType === 'variant') {
100
108
  handleSelectItem(entity.item, entity);
101
109
  } else if (matchType === 'item') {
@@ -113,7 +121,7 @@ export function ProductSelectionPanel({
113
121
  }
114
122
  }, 250);
115
123
  },
116
- [warehouseId, autoAddEnabled, config.type]
124
+ [warehouseId, autoAddEnabled, config.type, partyId]
117
125
  );
118
126
 
119
127
  // ── Select item → check tracking type or add to cart ──
@@ -124,14 +132,18 @@ export function ProductSelectionPanel({
124
132
  setShowResults(false);
125
133
 
126
134
  if (!config.disableTrackingSelection && item.tracking_type === 'batch') {
127
- setActiveBatchItem({ item, variant });
135
+ if (['sales', 'quotation'].includes(config.type)) {
136
+ onAddItem(item, variant);
137
+ } else {
138
+ setActiveBatchItem({ item, variant });
139
+ }
128
140
  } else if (!config.disableTrackingSelection && item.tracking_type === 'serial') {
129
141
  setActiveSerialItem({ item, variant });
130
142
  } else {
131
143
  onAddItem(item, variant);
132
144
  }
133
145
  },
134
- [onAddItem]
146
+ [onAddItem, config.type, config.disableTrackingSelection]
135
147
  );
136
148
 
137
149
  const handleBatchConfirm = (selections: { batch: any; quantity: number }[]) => {
@@ -250,15 +262,24 @@ export function ProductSelectionPanel({
250
262
  if (field === 'quantity') qty = Math.max(0.001, num);
251
263
  if (field === 'unit_price') price = Math.max(0, num);
252
264
  if (field === 'discount_amount') {
253
- const unitDiscount = Math.max(0, num);
265
+ const maxDiscount = price;
266
+ const unitDiscount = Math.min(Math.max(0, num), maxDiscount);
254
267
  discAmt = unitDiscount * qty;
255
268
  discPercent = (price * qty) > 0 ? (discAmt * 100) / (price * qty) : 0;
256
269
  } else if (field === 'discount_percent') {
257
- discPercent = Math.max(0, num);
270
+ discPercent = Math.min(Math.max(0, num), 100);
258
271
  discAmt = (price * qty * discPercent) / 100;
259
272
  } else {
260
- // If price or qty changes, recalculate discAmt from discPercent
261
- discAmt = (price * qty * discPercent) / 100;
273
+ // If price or qty changes, check which discount type is active
274
+ const activeDiscountType = discountTypes[uid] || 'amount';
275
+
276
+ if (activeDiscountType === 'percent') {
277
+ // Preserve percentage, recalculate amount
278
+ discAmt = (price * qty * discPercent) / 100;
279
+ } else {
280
+ // Preserve amount (we will clamp on blur to avoid ruining it while typing), recalculate percentage
281
+ discPercent = (price * qty) > 0 ? (discAmt * 100) / (price * qty) : 0;
282
+ }
262
283
  }
263
284
 
264
285
  if (field === 'uom_id') {
@@ -560,7 +581,9 @@ export function ProductSelectionPanel({
560
581
  </TRow>
561
582
  </THeader>
562
583
  <TBody>
563
- {lineItems.map((line) => (
584
+ {lineItems.map((line) => {
585
+ const isBatchPending = ['sales', 'quotation'].includes(config.type) && line.item.tracking_type === 'batch' && (!line.batches || line.batches.length === 0);
586
+ return (
564
587
  <TRow key={line.uid}>
565
588
  <TCell label="Item" className="py-2.5">
566
589
  <div className="flex items-start gap-3">
@@ -585,9 +608,56 @@ export function ProductSelectionPanel({
585
608
  ? `${line.item.variants.find(v => v.id === line.variant_id)?.variant_name} • ${line.item.variants.find(v => v.id === line.variant_id)?.sku}`
586
609
  : line.item.sku}
587
610
  </span>
588
- {!config.disableTrackingSelection && (line.item.tracking_type === 'batch' || line.item.tracking_type === 'serial') && (
611
+ {((!config.disableTrackingSelection && (line.item.tracking_type === 'batch' || line.item.tracking_type === 'serial')) || (line.item.tracking_type === 'batch' && ['sales', 'quotation'].includes(config.type))) && (
589
612
  <div className="flex flex-wrap gap-1 mt-1.5">
590
613
  {line.item.tracking_type === 'batch' && (() => {
614
+ if (['sales', 'quotation'].includes(config.type)) {
615
+ return (
616
+ <div className="mt-1.5 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
617
+ {(!line.batches || line.batches.length === 0) && (
618
+ <AlertTriangle size={16} className="text-danger shrink-0" />
619
+ )}
620
+ <div className="w-56">
621
+ <AsyncSearchableSelect
622
+ controlClassName="!min-h-8 h-8 !py-1 text-sm"
623
+ placeholder="Select Batch..."
624
+ loadOptions={async (search) => {
625
+ const params: any = {};
626
+ if (line.variant_id) params.variant_id = line.variant_id;
627
+ if (warehouseId) params.warehouse_id = warehouseId;
628
+ const res = await getItemBatches(line.item.id, params);
629
+ const batches = res?.result?.data || res?.result || [];
630
+ return batches.filter((b: any) => b.batch_number.toLowerCase().includes(search.toLowerCase()));
631
+ }}
632
+ option={{
633
+ label: 'batch_number',
634
+ value: 'id',
635
+ renderOption: (b: any) => (
636
+ <div className="flex items-center justify-between w-full pr-1">
637
+ <span>{b.batch_number}</span>
638
+ {warehouseId ? (
639
+ <span className="text-foreground-subtle text-[11px] font-mono" title="Available Stock">{b.qty_available ?? 0}</span>
640
+ ) : null}
641
+ </div>
642
+ )
643
+ }}
644
+ defaultValue={line.batches?.[0] ? { id: line.batches[0].batch_id, batch_number: line.batches[0].batch_number, selling_price: line.batches[0].selling_price } : undefined}
645
+ onChange={(val, selected: any) => {
646
+ if (selected) {
647
+ onUpdateLine(line.uid, {
648
+ batches: [{ batch_id: selected.id, batch_number: selected.batch_number, selling_price: selected.selling_price, quantity: line.quantity }],
649
+ unit_price: selected.selling_price || 0
650
+ });
651
+ } else {
652
+ onUpdateLine(line.uid, { batches: [] });
653
+ }
654
+ }}
655
+ />
656
+ </div>
657
+ </div>
658
+ );
659
+ }
660
+
591
661
  const batchesCount = line.batches?.length || 0;
592
662
  const selectedTotal = line.batches?.reduce((sum, b) => sum + b.quantity, 0) || 0;
593
663
  const isMismatch = selectedTotal !== line.quantity;
@@ -640,22 +710,22 @@ export function ProductSelectionPanel({
640
710
  })()}
641
711
  </div>
642
712
  )}
643
- {(config.type === 'purchase' || config.type === 'grn') && Number(line.unit_price) === 0 && (
713
+ {Number(line.unit_price) === 0 && (
644
714
  <div className="mt-2 flex items-center gap-2">
645
- <label className={cn("flex items-center gap-1.5", !!line.purchase_order_line_id ? "cursor-not-allowed" : "cursor-pointer")}>
715
+ <label className={cn("flex items-center gap-1.5", !!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id) ? "cursor-not-allowed" : "cursor-pointer")}>
646
716
  <input
647
717
  type="checkbox"
648
718
  checked={line.is_free_qty || false}
649
719
  onChange={(e) => handleToggleFreeQty(line.uid, e.target.checked)}
650
- disabled={!!line.purchase_order_line_id}
720
+ disabled={!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id)}
651
721
  className={cn(
652
722
  "rounded border-border-subtle text-primary h-3 w-3",
653
- !!line.purchase_order_line_id ? "opacity-50 cursor-not-allowed" : "focus:ring-primary"
723
+ !!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id) ? "opacity-50 cursor-not-allowed" : "focus:ring-primary"
654
724
  )}
655
725
  />
656
726
  <span className={cn(
657
727
  "text-[11.5px] font-medium transition-colors",
658
- !!line.purchase_order_line_id ? "text-foreground-disabled" : "text-foreground-subtle hover:text-foreground-1"
728
+ !!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id) ? "text-foreground-disabled" : "text-foreground-subtle hover:text-foreground-1"
659
729
  )}>
660
730
  Free Quantity
661
731
  </span>
@@ -665,17 +735,17 @@ export function ProductSelectionPanel({
665
735
  <div className="flex items-center">
666
736
  <button
667
737
  onClick={() => setActiveParentLineUidFor(line.uid)}
668
- disabled={!!line.purchase_order_line_id}
738
+ disabled={!!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id)}
669
739
  className={cn(
670
740
  "h-6 text-[11.5px] bg-surface-1 border border-border-subtle rounded-md px-2 ml-2 min-w-[120px] max-w-[180px] text-left truncate transition-colors flex justify-between items-center",
671
- !!line.purchase_order_line_id ? "opacity-60 cursor-not-allowed" : "hover:bg-surface-hover focus:border-primary/50 focus:outline-none"
741
+ !!(line.purchase_order_line_id || line.sales_order_line_id || line.sales_quotation_line_id) ? "opacity-60 cursor-not-allowed" : "hover:bg-surface-hover focus:border-primary/50 focus:outline-none"
672
742
  )}
673
743
  >
674
744
  <span>
675
745
  {line.parent_line_uids && line.parent_line_uids.length > 0
676
746
  ? (() => {
677
747
  if (line.parent_line_uids.length === 1) {
678
- const p = lineItems.find((l) => l.uid === line.parent_line_uids![0]);
748
+ const p = lineItems.find((l) => l.uid === line.parent_line_uids?.[0]);
679
749
  return p
680
750
  ? p.item.has_variants && p.variant_id && p.item.variants?.find((v) => v.id === p.variant_id)
681
751
  ? `${p.item.name} (${p.item.variants.find((v) => v.id === p.variant_id)?.variant_name})`
@@ -701,7 +771,7 @@ export function ProductSelectionPanel({
701
771
  type="number"
702
772
  value={line.quantity}
703
773
  onChange={(e) => updateLineInput(line.uid, 'quantity', e.target.value)}
704
- 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"
774
+ className="w-20 h-10 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"
705
775
  min={0.001}
706
776
  step="any"
707
777
  />
@@ -712,8 +782,8 @@ export function ProductSelectionPanel({
712
782
  <select
713
783
  value={line.uom_id || line.item.uom_id || ''}
714
784
  onChange={(e) => updateLineInput(line.uid, 'uom_id', e.target.value)}
715
- disabled={line.item.tracking_type === 'serial'}
716
- 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] disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
785
+ disabled={line.item.tracking_type === 'serial' || isBatchPending}
786
+ className="h-10 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] disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
717
787
  >
718
788
  {line.item.uom?.uom_group?.uoms?.map((uom: any) => (
719
789
  <option key={uom.id} value={uom.id}>{uom.abbreviation}</option>
@@ -725,15 +795,29 @@ export function ProductSelectionPanel({
725
795
  {config.showPrices && (
726
796
  <TCell label="Rate" className="py-2.5 text-right relative">
727
797
  <div className="flex items-center justify-end w-full gap-2">
728
- {line.applied_price_rule?.price_list_name && (
729
- <HintIcon
730
- text={`Applied Rule: ${line.applied_price_rule.price_list_name}${
731
- line.applied_price_rule.rate ? `\nRate: ${line.applied_price_rule.rate}` :
732
- line.applied_price_rule.markdown_percentage ? `\nMarkdown: ${line.applied_price_rule.markdown_percentage}%` : ''
733
- }\nFinal Rate: ${Number(line.applied_price_rule.final_rate || 0).toFixed(2)}`}
734
- icon={<Tag size={15} className="text-primary-600 opacity-80 hover:opacity-100 transition-opacity" />}
735
- />
736
- )}
798
+ {line.applied_price_rule?.price_list_name && (() => {
799
+ const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
800
+ const actualVariant = line.variant_id ? line.item.variants?.find((v: any) => v.id === line.variant_id) : (line.item.variants?.[0] || null);
801
+ const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
802
+ const batchPrice = ['sales', 'quotation'].includes(config.type) && line.batches?.[0] ? (line.batches[0] as any)[batchPriceField] : undefined;
803
+ const basePrice = (batchPrice !== undefined && batchPrice !== null)
804
+ ? Number(batchPrice)
805
+ : (actualVariant
806
+ ? Number(actualVariant[variantPriceField] || line.item[config.priceField] || 0)
807
+ : Number(line.item[config.priceField] || 0));
808
+ const finalPrice = Number(line.applied_price_rule?.final_rate || 0);
809
+ if (basePrice === finalPrice) return null;
810
+
811
+ return (
812
+ <HintIcon
813
+ text={`Applied Rule: ${line.applied_price_rule?.price_list_name}\nBase Rate: ${basePrice.toFixed(2)}${
814
+ line.applied_price_rule?.rate ? `\nNew Rate: ${line.applied_price_rule?.rate}` :
815
+ line.applied_price_rule?.markdown_percentage ? `\nMarkdown: ${line.applied_price_rule?.markdown_percentage}%` : ''
816
+ }\nFinal Rate: ${Number(line.applied_price_rule?.final_rate || 0).toFixed(2)}`}
817
+ icon={<Tag size={15} className="text-primary-600 opacity-80 hover:opacity-100 transition-opacity" />}
818
+ />
819
+ );
820
+ })()}
737
821
  <input
738
822
  type="text"
739
823
  value={editingRates[line.uid] ?? Number(line.unit_price || 0).toFixed(2)}
@@ -750,11 +834,15 @@ export function ProductSelectionPanel({
750
834
  delete next[line.uid];
751
835
  return next;
752
836
  });
837
+ // Clamp the discount amount if it exceeds the new rate
838
+ if (discountTypes[line.uid] !== 'percent') {
839
+ updateLineInput(line.uid, 'discount_amount', line.quantity > 0 ? line.discount_amount / line.quantity : 0);
840
+ }
753
841
  }}
754
- disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable}
842
+ disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || isBatchPending}
755
843
  className={cn(
756
- "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",
757
- (!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable) && "opacity-60 cursor-not-allowed bg-surface-hover"
844
+ "w-24 h-10 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",
845
+ (!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || isBatchPending) && "opacity-60 cursor-not-allowed bg-surface-hover"
758
846
  )}
759
847
  />
760
848
  </div>
@@ -764,8 +852,8 @@ export function ProductSelectionPanel({
764
852
  {showDiscountCol && (
765
853
  <TCell label="Disc." className="py-2.5 text-right">
766
854
  <div className={cn(
767
- "flex items-center w-32 h-8 bg-surface-1 border border-border-subtle rounded-md overflow-hidden focus-within:border-primary/50 transition-colors ml-auto",
768
- (!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable) && "opacity-60 bg-surface-hover"
855
+ "flex items-center w-32 h-10 bg-surface-1 border border-border-subtle rounded-md overflow-hidden focus-within:border-primary/50 transition-colors ml-auto",
856
+ (!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || line.unit_price === 0 || isBatchPending) && "opacity-60 bg-surface-hover"
769
857
  )}>
770
858
  <select
771
859
  className="h-full bg-surface-hover text-[11px] font-medium text-foreground-subtle border-r border-border-subtle pl-1.5 pr-0.5 focus:outline-none cursor-pointer disabled:cursor-not-allowed"
@@ -773,7 +861,7 @@ export function ProductSelectionPanel({
773
861
  onChange={(e) => {
774
862
  setDiscountTypes(prev => ({ ...prev, [line.uid]: e.target.value as 'amount' | 'percent' }));
775
863
  }}
776
- disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable}
864
+ disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || line.unit_price === 0 || isBatchPending}
777
865
  >
778
866
  <option value="amount">Amt</option>
779
867
  <option value="percent">%</option>
@@ -786,7 +874,7 @@ export function ProductSelectionPanel({
786
874
  const val = e.target.value;
787
875
  updateLineInput(line.uid, discountTypes[line.uid] === 'percent' ? 'discount_percent' : 'discount_amount', val ? Number(val) : 0);
788
876
  }}
789
- disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable}
877
+ disabled={!config.allowEditPrice || line.is_free_qty || !line.item.is_discountable || line.unit_price === 0 || isBatchPending}
790
878
  className="flex-1 w-full min-w-0 h-full px-2 text-[13px] text-right font-medium bg-transparent focus:outline-none disabled:cursor-not-allowed"
791
879
  min={0}
792
880
  step="any"
@@ -804,14 +892,15 @@ export function ProductSelectionPanel({
804
892
  <TCell className="text-center py-2.5">
805
893
  <button
806
894
  onClick={() => onRemoveLine(line.uid)}
807
- className="w-7 h-7 inline-flex items-center justify-center rounded-md text-foreground-disabled hover:bg-danger-alt/10 hover:text-danger-alt transition-colors"
895
+ className="w-10 h-10 inline-flex items-center justify-center rounded-md text-foreground-disabled hover:bg-danger-alt/10 hover:text-danger-alt transition-colors"
808
896
  title="Remove Item"
809
897
  >
810
898
  <Trash2 size={16} />
811
899
  </button>
812
900
  </TCell>
813
901
  </TRow>
814
- ))}
902
+ );
903
+ })}
815
904
  </TBody>
816
905
  </Table>
817
906
  )}
@@ -861,7 +950,7 @@ export function ProductSelectionPanel({
861
950
  }, 0)} units</span>
862
951
  {config.showPrices && (
863
952
  <span className="text-[15px] font-bold text-foreground-0">
864
- {formatCurrency(lineItems.reduce((s, l) => s + l.line_total, 0))}
953
+ {formatCurrency(lineItems.reduce((s, l) => s + Number(l.line_total || 0), 0))}
865
954
  </span>
866
955
  )}
867
956
  </div>
@@ -1,10 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useState, useCallback, useMemo, useEffect } from 'react';
4
- import { cn, PriceCalculationService, evaluatePriceLists } from '@apptimate/core-lib';
4
+ import { cn, PriceCalculationService, evaluatePriceLists, checkHasActivePriceLists } from '@apptimate/core-lib';
5
5
  import toast from 'react-hot-toast';
6
6
  import { ProductSelectionPanel } from './ProductSelectionPanel';
7
7
  import { MetadataPanel } from './MetadataPanel';
8
+ import { Modal, ModalFooter } from '../../base-components/Modal';
9
+ import { Button } from '../../base-components/Button';
8
10
  import type {
9
11
  ProductTransactionScreenProps, TransactionItem, TransactionLineItem,
10
12
  TransactionPayload, PaymentEntry, PartyLookup,
@@ -42,6 +44,60 @@ export function ProductTransactionScreen({
42
44
  const [notes, setNotes] = useState('');
43
45
  const [payments, setPayments] = useState<PaymentEntry[]>([]);
44
46
  const [isSubmitting, setIsSubmitting] = useState(false);
47
+ const [hasActivePriceLists, setHasActivePriceLists] = useState<boolean>(false);
48
+ const [priceEvaluationModal, setPriceEvaluationModal] = useState<{
49
+ isOpen: boolean;
50
+ type: 'confirm' | 'force';
51
+ evaluatedRules: Record<number, any[]>;
52
+ pendingPartyId: number | null;
53
+ pendingParty: any;
54
+ } | null>(null);
55
+
56
+ const applyEvaluatedPrices = useCallback((evaluatedRules: Record<number, any[]>) => {
57
+ setLineItems(prev => prev.map(l => {
58
+ if (l.is_free_qty || !l.variant_id || !evaluatedRules[l.variant_id]) return l;
59
+ const rules = evaluatedRules[l.variant_id];
60
+
61
+ const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
62
+ const actualVariant = l.item.variants?.find((v: any) => v.id === l.variant_id);
63
+ const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
64
+ const batchPrice = ['sales', 'quotation'].includes(config.type) && l.batches?.[0] ? (l.batches[0] as any)[batchPriceField] : undefined;
65
+ const basePrice = (batchPrice !== undefined && batchPrice !== null)
66
+ ? Number(batchPrice)
67
+ : (actualVariant
68
+ ? Number(actualVariant[variantPriceField] || l.item[config.priceField] || 0)
69
+ : Number(l.item[config.priceField] || 0));
70
+
71
+ const uoms = l.item.uom?.uom_group?.uoms || [];
72
+ const currentUom = uoms.find((u: any) => u.id === (l.uom_id || l.item.uom_id));
73
+ const factor = Number(currentUom?.conversion_factor) || 1;
74
+ const effectiveQty = l.quantity * factor;
75
+
76
+ const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, rules, l.batches?.[0]?.batch_id);
77
+
78
+ return {
79
+ ...l,
80
+ pricing_rules: rules,
81
+ unit_price: priceDetails.unitPrice * factor,
82
+ discount_percent: priceDetails.discountPercent,
83
+ discount_amount: priceDetails.discountAmount * factor,
84
+ applied_price_rule: priceDetails.appliedRule,
85
+ line_total: (priceDetails.unitPrice * factor * l.quantity) - (priceDetails.discountAmount * factor)
86
+ };
87
+ }));
88
+ }, [config.priceField]);
89
+
90
+ useEffect(() => {
91
+ if (['sales', 'pos', 'quotation'].includes(config.type)) {
92
+ checkHasActivePriceLists().then(res => {
93
+ if (res.is_success) {
94
+ setHasActivePriceLists(res.result as boolean);
95
+ }
96
+ }).catch(() => {
97
+ // Silently ignore
98
+ });
99
+ }
100
+ }, [config.type]);
45
101
 
46
102
  const submitting = externalSubmitting ?? isSubmitting;
47
103
 
@@ -53,24 +109,32 @@ export function ProductTransactionScreen({
53
109
  }
54
110
  if (initialData.warehouse_id) {
55
111
  setWarehouseId(initialData.warehouse_id);
112
+ if (initialData.warehouse?.name) setWarehouseName(initialData.warehouse.name);
56
113
  }
114
+ if (initialData.transaction_date || initialData.order_date || initialData.quotation_date || initialData.date) {
115
+ setTransactionDate(initialData.transaction_date || initialData.order_date || initialData.quotation_date || initialData.date);
116
+ }
117
+ if (initialData.expected_delivery_date || initialData.expected_delivery) {
118
+ setExpectedDeliveryDate(initialData.expected_delivery_date || initialData.expected_delivery);
119
+ }
120
+
57
121
  if (initialData.lines && Array.isArray(initialData.lines)) {
58
122
  setLineItems(
59
123
  initialData.lines.map((l: any) => {
60
- const qty = l.quantity || 1;
61
- const unitPrice = l.unit_price || 0;
124
+ const qty = l.quantity !== undefined ? Number(l.quantity) : 1;
125
+ const unitPrice = l.unit_price !== undefined ? Number(l.unit_price) : 0;
62
126
  return {
63
127
  uid: l.uid || Math.random().toString(36).slice(2, 7),
64
128
  item: l.item,
65
129
  variant_id: l.variant_id || null,
66
- batches: [],
67
- serial_numbers: [],
130
+ batches: l.batches || [],
131
+ serial_numbers: l.serial_numbers || [],
68
132
  quantity: qty,
69
133
  unit_price: unitPrice,
70
- discount_percent: 0,
71
- discount_amount: 0,
72
- line_total: unitPrice * qty,
73
- expanded: false,
134
+ discount_percent: l.discount_percent !== undefined ? Number(l.discount_percent) : 0,
135
+ discount_amount: l.discount_amount !== undefined ? Number(l.discount_amount) : 0,
136
+ line_total: l.line_total !== undefined ? Number(l.line_total) : (unitPrice * qty - (l.discount_amount || 0)),
137
+ expanded: l.expanded || false,
74
138
  is_free_qty: l.is_free_qty,
75
139
  parent_line_uids: l.parent_line_uids,
76
140
  uom_id: l.uom_id,
@@ -78,6 +142,25 @@ export function ProductTransactionScreen({
78
142
  };
79
143
  })
80
144
  );
145
+
146
+ // Populate pricing rules for existing items if party is selected
147
+ if (initialData.party_id && ['sales', 'pos', 'quotation'].includes(config.type)) {
148
+ evaluatePriceLists({
149
+ party_id: initialData.party_id,
150
+ transaction_type: config.type,
151
+ items: initialData.lines.filter((l: any) => l.variant_id && !l.is_free_qty).map((l: any) => ({ variant_id: l.variant_id }))
152
+ }).then(res => {
153
+ if (res.is_success && res.result) {
154
+ const evaluatedRules = res.result as Record<number, any[]>;
155
+ setLineItems(prev => prev.map(line => {
156
+ if (!line.is_free_qty && line.variant_id && evaluatedRules[line.variant_id]) {
157
+ return { ...line, pricing_rules: evaluatedRules[line.variant_id] };
158
+ }
159
+ return line;
160
+ }));
161
+ }
162
+ }).catch(() => { });
163
+ }
81
164
  }
82
165
  }
83
166
  }, [initialData]);
@@ -104,9 +187,13 @@ export function ProductTransactionScreen({
104
187
  }
105
188
 
106
189
  const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
107
- const basePrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
190
+ const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
191
+ const batchPrice = ['sales', 'quotation'].includes(config.type) && extra?.batches?.[0] ? (extra.batches[0] as any)[batchPriceField] : undefined;
192
+ const basePrice = (batchPrice !== undefined && batchPrice !== null)
193
+ ? Number(batchPrice)
194
+ : (actualVariant ? Number(actualVariant[variantPriceField] || itemWithVariants[config.priceField] || 0) : Number(itemWithVariants[config.priceField] || 0));
108
195
  const qty = extra?.quantity || 1;
109
-
196
+
110
197
  const uomId = itemWithVariants.uom_id;
111
198
  const uoms = itemWithVariants.uom?.uom_group?.uoms || [];
112
199
  const currentUom = uoms.find((u: any) => u.id === uomId);
@@ -120,13 +207,13 @@ export function ProductTransactionScreen({
120
207
 
121
208
  if (actualVariant && actualVariant.pricing_rules) {
122
209
  const effectiveQty = qty * factor;
123
- const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, actualVariant.pricing_rules);
210
+ const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, actualVariant.pricing_rules, extra?.batches?.[0]?.batch_id);
124
211
  unitPrice = priceDetails.unitPrice * factor;
125
212
  discountPercent = priceDetails.discountPercent;
126
213
  discountAmt = priceDetails.discountAmount * factor;
127
214
  appliedRule = priceDetails.appliedRule;
128
215
  }
129
-
216
+
130
217
  const newLine: TransactionLineItem = {
131
218
  uid: `${itemWithVariants.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
132
219
  item: itemWithVariants,
@@ -161,21 +248,33 @@ export function ProductTransactionScreen({
161
248
  const isUomChanged = 'uom_id' in updates && updates.uom_id !== l.uom_id;
162
249
  const isPriceChanged = 'unit_price' in updates && updates.unit_price !== l.unit_price;
163
250
  const isDiscountChanged = ('discount_amount' in updates && updates.discount_amount !== l.discount_amount) || ('discount_percent' in updates && updates.discount_percent !== l.discount_percent);
251
+ const isBatchChanged = 'batches' in updates;
252
+ const isFreeQtyChanged = 'is_free_qty' in updates && updates.is_free_qty !== l.is_free_qty;
164
253
 
165
- const isQtyOrUomChanged = isQtyChanged || isUomChanged;
254
+ const isQtyOrUomOrBatchChanged = isQtyChanged || isUomChanged || isBatchChanged || (isFreeQtyChanged && !updates.is_free_qty);
166
255
 
167
- if (isQtyOrUomChanged && updatedLine.pricing_rules && updatedLine.pricing_rules.length > 0) {
256
+ if (isQtyOrUomOrBatchChanged && updatedLine.pricing_rules && updatedLine.pricing_rules.length > 0 && !updatedLine.is_free_qty) {
168
257
  const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
169
258
  const actualVariant = updatedLine.item.variants?.find((v: any) => v.id === updatedLine.variant_id);
170
- const basePrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
171
-
259
+ const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
260
+ const batchPrice = ['sales', 'quotation'].includes(config.type) && updatedLine.batches?.[0] ? (updatedLine.batches[0] as any)[batchPriceField] : undefined;
261
+ const basePrice = (batchPrice !== undefined && batchPrice !== null)
262
+ ? Number(batchPrice)
263
+ : (actualVariant
264
+ ? Number(actualVariant[variantPriceField] || updatedLine.item[config.priceField] || 0)
265
+ : Number(updatedLine.item[config.priceField] || 0));
266
+
172
267
  const uoms = updatedLine.item.uom?.uom_group?.uoms || [];
173
268
  const currentUom = uoms.find((u: any) => u.id === (updatedLine.uom_id || updatedLine.item.uom_id));
174
269
  const factor = Number(currentUom?.conversion_factor) || 1;
175
-
270
+
176
271
  const effectiveQty = updatedLine.quantity * factor;
177
272
 
178
- const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, updatedLine.pricing_rules);
273
+ console.log("DEBUG: handleUpdateLine rules -> ", {
274
+ basePrice, effectiveQty, rules: updatedLine.pricing_rules
275
+ });
276
+
277
+ const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, updatedLine.pricing_rules, updatedLine.batches?.[0]?.batch_id);
179
278
  updatedLine.unit_price = priceDetails.unitPrice * factor;
180
279
  updatedLine.discount_percent = priceDetails.discountPercent;
181
280
  updatedLine.discount_amount = priceDetails.discountAmount * factor;
@@ -183,13 +282,25 @@ export function ProductTransactionScreen({
183
282
  updatedLine.line_total = (updatedLine.unit_price * updatedLine.quantity) - updatedLine.discount_amount;
184
283
  } else if (isQtyChanged || isUomChanged || isPriceChanged || isDiscountChanged) {
185
284
  updatedLine.line_total = (updatedLine.unit_price * updatedLine.quantity) - updatedLine.discount_amount;
186
-
285
+
187
286
  const isManualPriceOverride = isPriceChanged && !isUomChanged;
188
287
  if (isManualPriceOverride) {
189
288
  updatedLine.applied_price_rule = undefined;
190
289
  }
191
290
  }
192
291
 
292
+ if (isQtyChanged && updatedLine.batches && updatedLine.batches.length === 1) {
293
+ updatedLine.batches[0].quantity = updatedLine.quantity;
294
+ }
295
+
296
+ if (updatedLine.is_free_qty) {
297
+ updatedLine.unit_price = 0;
298
+ updatedLine.discount_amount = 0;
299
+ updatedLine.discount_percent = 0;
300
+ updatedLine.line_total = 0;
301
+ updatedLine.applied_price_rule = undefined;
302
+ }
303
+
193
304
  return updatedLine;
194
305
  })
195
306
  );
@@ -197,7 +308,15 @@ export function ProductTransactionScreen({
197
308
 
198
309
  // ── Remove line ──
199
310
  const handleRemoveLine = useCallback((uid: string) => {
200
- setLineItems((prev) => prev.filter((l) => l.uid !== uid));
311
+ setLineItems((prev) => prev
312
+ .filter((l) => l.uid !== uid)
313
+ .map((l) => {
314
+ if (l.parent_line_uids && l.parent_line_uids.includes(uid)) {
315
+ return { ...l, parent_line_uids: l.parent_line_uids.filter((pUid) => pUid !== uid) };
316
+ }
317
+ return l;
318
+ })
319
+ );
201
320
  }, []);
202
321
 
203
322
  // ── Toggle expand ──
@@ -208,8 +327,8 @@ export function ProductTransactionScreen({
208
327
  }, []);
209
328
 
210
329
  // ── Calculations ──
211
- const subtotal = useMemo(() => lineItems.reduce((s, l) => s + l.unit_price * l.quantity, 0), [lineItems]);
212
- const discountTotal = useMemo(() => lineItems.reduce((s, l) => s + l.discount_amount, 0), [lineItems]);
330
+ const subtotal = useMemo(() => lineItems.reduce((s, l) => s + (Number(l.unit_price) || 0) * (Number(l.quantity) || 1), 0), [lineItems]);
331
+ const discountTotal = useMemo(() => lineItems.reduce((s, l) => s + (Number(l.discount_amount) || 0), 0), [lineItems]);
213
332
  const grandTotal = useMemo(() => subtotal - discountTotal, [subtotal, discountTotal]);
214
333
 
215
334
  // ── Submit handler ──
@@ -224,16 +343,25 @@ export function ProductTransactionScreen({
224
343
  }
225
344
  }
226
345
 
227
- if (!config.disableTrackingSelection) {
228
- for (const line of lineItems) {
229
- if (line.item.tracking_type === 'batch') {
346
+ if (config.showWarehouse && !warehouseId) {
347
+ toast.error('Please select a warehouse.');
348
+ return;
349
+ }
350
+
351
+ for (const line of lineItems) {
352
+ if (line.item.tracking_type === 'batch') {
353
+ const requiresBatch = !config.disableTrackingSelection || ['sales', 'quotation'].includes(config.type);
354
+ if (requiresBatch) {
230
355
  const selectedTotal = line.batches?.reduce((sum, b) => sum + b.quantity, 0) || 0;
231
356
  if (selectedTotal !== line.quantity) {
232
357
  toast.error(`Please select batches for ${line.item.name} to match the total quantity.`);
233
358
  return;
234
359
  }
235
360
  }
236
- if (line.item.tracking_type === 'serial') {
361
+ }
362
+ if (line.item.tracking_type === 'serial') {
363
+ const requiresSerial = !config.disableTrackingSelection;
364
+ if (requiresSerial) {
237
365
  const selectedCount = line.serial_numbers?.length || 0;
238
366
  if (selectedCount !== line.quantity) {
239
367
  toast.error(`Please select exactly ${line.quantity} serial numbers for ${line.item.name}.`);
@@ -250,6 +378,7 @@ export function ProductTransactionScreen({
250
378
  transaction_date: transactionDate,
251
379
  expected_delivery_date: expectedDeliveryDate || undefined,
252
380
  notes: notes || undefined,
381
+ revised_from_id: initialData?.revised_from_id || undefined,
253
382
  lines: lineItems.flatMap((l): TransactionPayload['lines'] => {
254
383
  if (l.batches && l.batches.length > 0) {
255
384
  return l.batches.map(b => ({
@@ -320,7 +449,7 @@ export function ProductTransactionScreen({
320
449
  } finally {
321
450
  setIsSubmitting(false);
322
451
  }
323
- }, [lineItems, config, partyId, warehouseId, transactionDate, expectedDeliveryDate, notes, payments, subtotal, discountTotal, grandTotal, onSubmit, defaultWarehouseId]);
452
+ }, [lineItems, config, partyId, warehouseId, transactionDate, expectedDeliveryDate, notes, payments, subtotal, discountTotal, grandTotal, onSubmit, defaultWarehouseId, initialData]);
324
453
 
325
454
  return (
326
455
  <div className="flex flex-col h-full">
@@ -330,6 +459,7 @@ export function ProductTransactionScreen({
330
459
  <div className="flex-[3] min-w-0 flex flex-col">
331
460
  <ProductSelectionPanel
332
461
  config={config}
462
+ partyId={partyId}
333
463
  warehouseId={warehouseId ?? undefined}
334
464
  lineItems={lineItems}
335
465
  onAddItem={handleAddItem}
@@ -355,45 +485,80 @@ export function ProductTransactionScreen({
355
485
  expectedDeliveryDate={expectedDeliveryDate}
356
486
  notes={notes}
357
487
  payments={payments}
358
- onPartyChange={async (id, party) => {
488
+ onPartyChange={async (id, party) => {
359
489
  if (partyId !== id && lineItems.length > 0 && ['sales', 'pos', 'quotation'].includes(config.type)) {
360
- toast.loading('Recalculating prices based on selected party...', { id: 'price-calc' });
361
- try {
362
- const res = await evaluatePriceLists({
363
- party_id: id,
364
- transaction_type: config.type,
365
- items: lineItems.filter(l => l.variant_id).map(l => ({ variant_id: l.variant_id! }))
366
- });
367
- if (res.is_success && res.result) {
368
- const evaluatedRules = res.result as Record<number, any[]>;
369
- setLineItems(prev => prev.map(l => {
370
- if (!l.variant_id || !evaluatedRules[l.variant_id]) return l;
371
- const rules = evaluatedRules[l.variant_id];
372
-
373
- const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
374
- const actualVariant = l.item.variants?.find((v: any) => v.id === l.variant_id);
375
- const basePrice = actualVariant ? Number(actualVariant[variantPriceField] || 0) : 0;
376
-
377
- const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, l.quantity, rules);
378
-
379
- return {
380
- ...l,
381
- pricing_rules: rules,
382
- unit_price: priceDetails.unitPrice,
383
- discount_percent: priceDetails.discountPercent,
384
- discount_amount: priceDetails.discountAmount,
385
- applied_price_rule: priceDetails.appliedRule,
386
- line_total: (priceDetails.unitPrice * l.quantity) - priceDetails.discountAmount
387
- };
388
- }));
389
- toast.success('Prices updated based on party selection', { id: 'price-calc' });
490
+ if (hasActivePriceLists) {
491
+ toast.loading('Checking price changes...', { id: 'price-calc' });
492
+ try {
493
+ const res = await evaluatePriceLists({
494
+ party_id: id || null,
495
+ transaction_type: config.type,
496
+ items: lineItems.filter(l => l.variant_id && !l.is_free_qty).map(l => ({ variant_id: l.variant_id! }))
497
+ });
498
+
499
+ toast.dismiss('price-calc');
500
+
501
+ if (res.is_success && res.result) {
502
+ const evaluatedRules = res.result as Record<number, any[]>;
503
+
504
+ let hasActualPriceChanges = false;
505
+ for (const l of lineItems) {
506
+ if (!l.is_free_qty && l.variant_id && evaluatedRules[l.variant_id]) {
507
+ const rules = evaluatedRules[l.variant_id];
508
+ const variantPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'sale_price';
509
+ const actualVariant = l.item.variants?.find((v: any) => v.id === l.variant_id);
510
+ const batchPriceField = config.priceField === 'default_cost_price' ? 'cost_price' : 'selling_price';
511
+ const batchPrice = ['sales', 'quotation'].includes(config.type) && l.batches?.[0] ? (l.batches[0] as any)[batchPriceField] : undefined;
512
+ const basePrice = (batchPrice !== undefined && batchPrice !== null)
513
+ ? Number(batchPrice)
514
+ : (actualVariant
515
+ ? Number(actualVariant[variantPriceField] || l.item[config.priceField] || 0)
516
+ : Number(l.item[config.priceField] || 0));
517
+
518
+ const uoms = l.item.uom?.uom_group?.uoms || [];
519
+ const currentUom = uoms.find((u: any) => u.id === (l.uom_id || l.item.uom_id));
520
+ const factor = Number(currentUom?.conversion_factor) || 1;
521
+ const effectiveQty = l.quantity * factor;
522
+
523
+ const priceDetails = PriceCalculationService.calculatePriceDetails(basePrice, effectiveQty, rules, l.batches?.[0]?.batch_id);
524
+
525
+ if (Math.abs((priceDetails.unitPrice * factor) - l.unit_price) > 0.001) {
526
+ hasActualPriceChanges = true;
527
+ break;
528
+ }
529
+ }
530
+ }
531
+
532
+ if (hasActualPriceChanges) {
533
+ const previousRulesApplied = lineItems.some(l => l.applied_price_rule);
534
+ setPriceEvaluationModal({
535
+ isOpen: true,
536
+ type: (partyId && previousRulesApplied) ? 'force' : 'confirm',
537
+ evaluatedRules,
538
+ pendingPartyId: id,
539
+ pendingParty: party
540
+ });
541
+ return;
542
+ } else {
543
+ // No immediate price changes for current quantities,
544
+ // but we still need to update the rules in the background
545
+ // so that if they change quantity later, it uses the new rules!
546
+ setLineItems(prev => prev.map(l => {
547
+ if (!l.is_free_qty && l.variant_id && evaluatedRules[l.variant_id]) {
548
+ return { ...l, pricing_rules: evaluatedRules[l.variant_id] };
549
+ }
550
+ return l;
551
+ }));
552
+ }
553
+ }
554
+ } catch (e) {
555
+ toast.dismiss('price-calc');
556
+ toast.error('Failed to check prices');
390
557
  }
391
- } catch (e) {
392
- toast.error('Failed to recalculate prices', { id: 'price-calc' });
393
558
  }
394
559
  }
395
- setPartyId(id);
396
- setPartyName(party?.name ?? null);
560
+ setPartyId(id);
561
+ setPartyName(party?.name ?? null);
397
562
  }}
398
563
  onWarehouseChange={(id, name) => { setWarehouseId(id); setWarehouseName(name ?? null); }}
399
564
  onDateChange={setTransactionDate}
@@ -407,6 +572,56 @@ export function ProductTransactionScreen({
407
572
  />
408
573
  </div>
409
574
  </div>
575
+
576
+ {/* ── Price Evaluation Modal ── */}
577
+ {priceEvaluationModal && (
578
+ <Modal
579
+ isOpen={priceEvaluationModal.isOpen}
580
+ onClose={() => {
581
+ if (priceEvaluationModal.type === 'confirm') {
582
+ setPartyId(priceEvaluationModal.pendingPartyId);
583
+ setPartyName(priceEvaluationModal.pendingParty?.name ?? null);
584
+ setPriceEvaluationModal(null);
585
+ }
586
+ }}
587
+ title="Price List Update"
588
+ backdrop="opaque"
589
+ size="sm"
590
+ >
591
+ <div className="text-[14px] text-foreground-1 leading-relaxed">
592
+ {priceEvaluationModal.type === 'confirm'
593
+ ? "A few items have price changes based on the price list. Do you want to apply them?"
594
+ : "Prices need to be changed based on the party selection."}
595
+ </div>
596
+ <ModalFooter>
597
+ {priceEvaluationModal.type === 'confirm' && (
598
+ <Button
599
+ variant="flat"
600
+ color="secondary"
601
+ onClick={() => {
602
+ setPartyId(priceEvaluationModal.pendingPartyId);
603
+ setPartyName(priceEvaluationModal.pendingParty?.name ?? null);
604
+ setPriceEvaluationModal(null);
605
+ }}
606
+ >
607
+ No
608
+ </Button>
609
+ )}
610
+ <Button
611
+ color="primary"
612
+ onClick={() => {
613
+ applyEvaluatedPrices(priceEvaluationModal.evaluatedRules);
614
+ setPartyId(priceEvaluationModal.pendingPartyId);
615
+ setPartyName(priceEvaluationModal.pendingParty?.name ?? null);
616
+ setPriceEvaluationModal(null);
617
+ toast.success('Prices updated based on party selection');
618
+ }}
619
+ >
620
+ {priceEvaluationModal.type === 'confirm' ? 'Yes' : 'OK'}
621
+ </Button>
622
+ </ModalFooter>
623
+ </Modal>
624
+ )}
410
625
  </div>
411
626
  );
412
627
  }
@@ -79,6 +79,8 @@ export interface TransactionLineItem {
79
79
  parent_line_uids?: string[];
80
80
  uom_id?: number;
81
81
  purchase_order_line_id?: number | null;
82
+ sales_order_line_id?: number | null;
83
+ sales_quotation_line_id?: number | null;
82
84
  unit_price: number;
83
85
  discount_percent: number;
84
86
  discount_amount: number;
@@ -163,6 +165,10 @@ export interface TransactionScreenConfig {
163
165
  disableTrackingSelection?: boolean;
164
166
  /** Enforce that the sum of payments equals the grand total before allowing submission */
165
167
  requireFullPayment?: boolean;
168
+ /** Disable party selection (e.g. for revisions) */
169
+ disablePartySelection?: boolean;
170
+ /** Disable warehouse selection (e.g. for revisions) */
171
+ disableWarehouseSelection?: boolean;
166
172
  }
167
173
 
168
174
  // ── Module Presets ──
@@ -317,6 +323,7 @@ export interface TransactionPayload {
317
323
  transaction_date?: string;
318
324
  expected_delivery_date?: string;
319
325
  notes?: string;
326
+ revised_from_id?: number | null;
320
327
  lines: Array<{
321
328
  item_id: number;
322
329
  variant_id?: number | null;
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { Button, Input, ModalFooter } from "../index";
5
+ import { PaymentSection, PaymentEntry } from "../common-components/PaymentSection";
6
+ import toast from "react-hot-toast";
7
+
8
+ interface PaymentFormProps {
9
+ invoice: any;
10
+ onSuccess: () => void;
11
+ onCancel: () => void;
12
+ onRecordPayment: (payload: any) => Promise<{ is_success: boolean; message?: string }>;
13
+ fetchPaymentModes: () => Promise<any>;
14
+ fetchBankAccounts: () => Promise<any>;
15
+ fetchChequeLeaves?: (query: string, page?: number) => Promise<any>;
16
+ }
17
+
18
+ export function PaymentForm({
19
+ invoice,
20
+ onSuccess,
21
+ onCancel,
22
+ onRecordPayment,
23
+ fetchPaymentModes,
24
+ fetchBankAccounts,
25
+ fetchChequeLeaves,
26
+ }: PaymentFormProps) {
27
+ const [isSubmitting, setIsSubmitting] = useState(false);
28
+ const outstanding = Math.abs(Number(invoice.amount_due || 0) - Number(invoice.amount_settled || invoice.amount_received || 0));
29
+
30
+ const [paymentDate, setPaymentDate] = useState(new Date().toISOString().split("T")[0]);
31
+ const [reference, setReference] = useState("");
32
+ const [remarks, setRemarks] = useState("");
33
+ const [payments, setPayments] = useState<PaymentEntry[]>([]);
34
+
35
+ const handleSubmit = async (e: React.FormEvent) => {
36
+ e.preventDefault();
37
+ if (payments.length === 0) {
38
+ toast.error("Please add at least one payment");
39
+ return;
40
+ }
41
+
42
+ const totalToPay = payments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
43
+ if (totalToPay > outstanding + 0.01) {
44
+ toast.error(`Payment amount exceeds outstanding balance (${outstanding.toFixed(2)})`);
45
+ return;
46
+ }
47
+
48
+ setIsSubmitting(true);
49
+ let successCount = 0;
50
+
51
+ try {
52
+ for (const p of payments) {
53
+ if (Number(p.amount) <= 0) continue;
54
+ const payload: any = {
55
+ invoice_id: invoice.id,
56
+ payment_date: paymentDate,
57
+ payment_amount: p.amount,
58
+ payment_method: p.mode_code,
59
+ payment_mode_id: p.mode_id,
60
+ remarks: remarks || null,
61
+ reference: reference || null,
62
+ };
63
+
64
+ if (p.metadata) {
65
+ if (p.metadata.reference) payload.reference = p.metadata.reference;
66
+ if (p.metadata.bank_account_id) payload.bank_account_id = p.metadata.bank_account_id;
67
+ payload.mode_metadata = p.metadata;
68
+ }
69
+
70
+ const res = await onRecordPayment(payload);
71
+ if (res.is_success) successCount++;
72
+ else toast.error(`Failed for ${p.mode_name}: ${res.message}`);
73
+ }
74
+
75
+ if (successCount > 0) {
76
+ toast.success("Payment recorded successfully");
77
+ onSuccess();
78
+ }
79
+ } catch (e: any) {
80
+ toast.error(e.message || "Error recording payment");
81
+ } finally {
82
+ setIsSubmitting(false);
83
+ }
84
+ };
85
+
86
+ return (
87
+ <form onSubmit={handleSubmit} className="space-y-4">
88
+ <div className="flex flex-col gap-1.5">
89
+ <label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Payment Date *</label>
90
+ <Input
91
+ type="date"
92
+ value={paymentDate}
93
+ onChange={(e) => setPaymentDate(e.target.value)}
94
+ />
95
+ </div>
96
+
97
+ <PaymentSection
98
+ totalAmount={outstanding}
99
+ payments={payments}
100
+ onPaymentsChange={setPayments}
101
+ fetchPaymentModes={async () => {
102
+ const res = await fetchPaymentModes();
103
+ if (res.is_success && Array.isArray(res.result)) {
104
+ res.result = res.result.filter((m: any) => {
105
+ const code = m.code?.toLowerCase();
106
+ return code !== "credit" && code !== "credit-metal";
107
+ });
108
+ }
109
+ return res;
110
+ }}
111
+ fetchBankAccounts={fetchBankAccounts}
112
+ fetchChequeLeaves={fetchChequeLeaves}
113
+ />
114
+
115
+ <div className="flex flex-col gap-4 mt-2">
116
+ <div className="flex flex-col gap-1.5">
117
+ <label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Reference No</label>
118
+ <Input
119
+ value={reference}
120
+ onChange={(e) => setReference(e.target.value)}
121
+ placeholder="Leave blank to auto-generate"
122
+ />
123
+ </div>
124
+ <div className="flex flex-col gap-1.5">
125
+ <label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Remarks</label>
126
+ <textarea
127
+ value={remarks}
128
+ onChange={(e) => setRemarks(e.target.value)}
129
+ rows={2}
130
+ placeholder="Optional notes..."
131
+ className="w-full bg-white border-[1.5px] border-gray-200 rounded-[10px] px-3.5 py-2.5 text-[13px] text-gray-800 outline-none transition-all hover:border-gray-300 focus:border-primary/50 resize-none"
132
+ />
133
+ </div>
134
+ </div>
135
+
136
+ <ModalFooter>
137
+ <Button type="button" variant="flat" color="default" onClick={onCancel}>Cancel</Button>
138
+ <Button type="submit" color="primary" isLoading={isSubmitting}>Record Payment</Button>
139
+ </ModalFooter>
140
+ </form>
141
+ );
142
+ }
package/src/index.tsx CHANGED
@@ -56,3 +56,4 @@ export * from './common-components/item-wizard/SkuConfigModal';
56
56
 
57
57
  // Transaction
58
58
  export * from './common-components/transaction';
59
+ export * from './finance-components/PaymentForm';