@apptimate/ui 5.6.0 → 5.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": "5.6.0",
3
+ "version": "5.8.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "dependencies": {
@@ -63,6 +63,9 @@ export interface ButtonProps {
63
63
 
64
64
  /** Test ID for testing */
65
65
  testId?: string;
66
+
67
+ /** Tooltip text on hover */
68
+ title?: string;
66
69
  }
67
70
 
68
71
  const iconSizeMap = {
@@ -219,6 +222,7 @@ export const Button = ({
219
222
  prefix = 'aceui',
220
223
  testId,
221
224
  disableWave = false,
225
+ title,
222
226
  }: ButtonProps) => {
223
227
  const isIconOnly = Boolean(!children && icon);
224
228
  const isActuallyDisabled = isDisabled || isLoading;
@@ -334,6 +338,7 @@ export const Button = ({
334
338
  aria-label={ariaLabel}
335
339
  aria-busy={isLoading ? 'true' : undefined}
336
340
  data-testid={testId}
341
+ title={title}
337
342
  >
338
343
  {wave && (
339
344
  <motion.span
@@ -33,11 +33,13 @@ export const DesktopFilterPopover = ({
33
33
  <div className="relative">
34
34
  <Button
35
35
  variant="soft"
36
- color="default"
37
- className={cn("!p-0 text-gray-500", triggerClassName)}
36
+ color={hasActiveFilter ? 'primary' : 'default'}
37
+ className={cn(triggerClassName)}
38
38
  icon={<SlidersHorizontal size={18} strokeWidth={2} />}
39
39
  ariaLabel={label}
40
- />
40
+ >
41
+ {label}
42
+ </Button>
41
43
  {hasActiveFilter && (
42
44
  <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-primary-500 rounded-full ring-2 ring-white" />
43
45
  )}
@@ -73,7 +73,9 @@ export function DashboardLayout({
73
73
 
74
74
  // Find which main menu should be active based on current path.
75
75
  // When basePath is set, prefer the menu whose items are mostly within basePath.
76
- const fullPath = basePath + (pathname === "/" && basePath ? "" : pathname);
76
+ const fullPath = (basePath && pathname.startsWith(basePath))
77
+ ? pathname
78
+ : basePath + (pathname === "/" && basePath ? "" : pathname);
77
79
  const currentMainMenu = (() => {
78
80
  let bestMenu = menus[0];
79
81
  let bestScore = -1;
@@ -211,15 +213,18 @@ export function DashboardLayout({
211
213
  setIsSubSidebarOpen(true);
212
214
  }
213
215
  }}
214
- 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]"
216
+ className={`flex flex-col items-center justify-center gap-1.5 cursor-pointer transition-all duration-200 w-full px-1 py-1 relative ${isActive ? "text-indigo-600" : "text-gray-400 hover:text-[#2D3142]"
215
217
  }`}
216
218
  >
219
+ {isActive && (
220
+ <span className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-indigo-600 rounded-r-full" />
221
+ )}
217
222
  {/* Clone the icon to dynamically apply styling based on active state */}
218
223
  {React.cloneElement(menu.icon as React.ReactElement<any>, {
219
224
  size: 22,
220
225
  className: isActive ? "stroke-[2.5]" : "stroke-[2]"
221
226
  })}
222
- <span className={`text-[11px] text-center leading-tight truncate block w-full max-w-[70px] ${isActive ? "font-bold" : "font-medium"}`}>{menu.label}</span>
227
+ <span className={`text-[11px] text-center leading-tight truncate block w-full max-w-[70px] ${isActive ? "font-bold text-indigo-700" : "font-medium"}`}>{menu.label}</span>
223
228
  </div>
224
229
  );
225
230
  })}
@@ -286,9 +291,9 @@ export function DashboardLayout({
286
291
  : !externalPaths.some(ext => item.path.startsWith(ext));
287
292
  const href = basePath && isInternal ? item.path.replace(basePath, "") || "/" : item.path;
288
293
 
289
- const className = `px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between ${isItemActive
290
- ? "bg-[#F4F5F7] text-[#2D3142] font-semibold"
291
- : "text-gray-500 font-medium hover:bg-gray-50"
294
+ const className = `pl-3 pr-4 py-2 rounded-r-lg rounded-l-none text-sm transition-all duration-200 flex items-center justify-between border-l-4 ${isItemActive
295
+ ? "bg-indigo-50/70 border-indigo-600 text-indigo-950 font-bold"
296
+ : "border-transparent text-gray-500 font-medium hover:bg-gray-50/50"
292
297
  }`;
293
298
 
294
299
  const content = (
@@ -0,0 +1,155 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect, useCallback, useRef } from "react";
4
+ import { Coins } from "lucide-react";
5
+ import {
6
+ EntityPickerModal,
7
+ PickerItem,
8
+ PickerTrigger,
9
+ } from "./EntityPickerModal";
10
+
11
+ interface CurrencyPickerProps {
12
+ /** Currently selected currency id */
13
+ value?: number | string | null;
14
+ /** Display label for the currently selected currency (e.g. "USD — US Dollar") */
15
+ displayValue?: string | null;
16
+ /** Callback when a currency is selected */
17
+ onChange: (currency: { id: number; code: string; name: string; symbol: string } | null) => void;
18
+ /** Label shown above the trigger */
19
+ label?: string;
20
+ /** Placeholder text when no currency is selected */
21
+ placeholder?: string;
22
+ /** Whether the field is required */
23
+ isRequired?: boolean;
24
+ /** Whether the picker is disabled */
25
+ disabled?: boolean;
26
+ /** Fetch function for loading currencies. Should accept { search, page, per_page } and return IApiResponse with data array. */
27
+ fetchCurrencies: (params: Record<string, string | number>) => Promise<any>;
28
+ /** Optional custom trigger renderer */
29
+ customTrigger?: (onClick: () => void) => React.ReactNode;
30
+ }
31
+
32
+ export function CurrencyPicker({
33
+ value,
34
+ displayValue,
35
+ onChange,
36
+ label = "Currency",
37
+ placeholder = "Select currency…",
38
+ isRequired = false,
39
+ disabled = false,
40
+ fetchCurrencies,
41
+ customTrigger,
42
+ }: CurrencyPickerProps) {
43
+ const [isOpen, setIsOpen] = useState(false);
44
+ const [search, setSearch] = useState("");
45
+ const [currencies, setCurrencies] = useState<any[]>([]);
46
+ const [isLoading, setIsLoading] = useState(false);
47
+ const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
48
+
49
+ const loadCurrencies = useCallback(
50
+ async (query: string) => {
51
+ setIsLoading(true);
52
+ try {
53
+ const res = await fetchCurrencies({ search: query, page: 1, per_page: 50 });
54
+ const data = res?.result?.data || res?.result || [];
55
+ setCurrencies(Array.isArray(data) ? data : []);
56
+ } catch (e) {
57
+ console.error("Failed to load currencies", e);
58
+ setCurrencies([]);
59
+ } finally {
60
+ setIsLoading(false);
61
+ }
62
+ },
63
+ [fetchCurrencies]
64
+ );
65
+
66
+ // Load currencies when modal opens
67
+ useEffect(() => {
68
+ if (isOpen) {
69
+ loadCurrencies("");
70
+ }
71
+ }, [isOpen, loadCurrencies]);
72
+
73
+ // Debounced search
74
+ useEffect(() => {
75
+ if (!isOpen) return;
76
+ if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
77
+ searchTimeoutRef.current = setTimeout(() => {
78
+ loadCurrencies(search);
79
+ }, 300);
80
+ return () => {
81
+ if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
82
+ };
83
+ }, [search, isOpen, loadCurrencies]);
84
+
85
+ const handleSelect = (currency: any) => {
86
+ onChange({
87
+ id: currency.id,
88
+ code: currency.code,
89
+ name: currency.name,
90
+ symbol: currency.symbol || currency.code,
91
+ });
92
+ setIsOpen(false);
93
+ setSearch("");
94
+ };
95
+
96
+ const handleClear = () => {
97
+ onChange(null);
98
+ };
99
+
100
+ return (
101
+ <>
102
+ {customTrigger ? (
103
+ customTrigger(() => setIsOpen(true))
104
+ ) : (
105
+ <PickerTrigger
106
+ label={label}
107
+ value={displayValue || undefined}
108
+ placeholder={placeholder}
109
+ isRequired={isRequired}
110
+ disabled={disabled}
111
+ onClick={() => setIsOpen(true)}
112
+ onClear={value ? handleClear : undefined}
113
+ />
114
+ )}
115
+
116
+ <EntityPickerModal
117
+ isOpen={isOpen}
118
+ onClose={() => { setIsOpen(false); setSearch(""); }}
119
+ title="Select Currency"
120
+ search={search}
121
+ onSearchChange={setSearch}
122
+ searchPlaceholder="Search by code or name…"
123
+ selectedId={value}
124
+ size="sm"
125
+ zIndex={210}
126
+ >
127
+ {isLoading ? (
128
+ <div className="py-8 text-center">
129
+ <div className="inline-block w-5 h-5 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" />
130
+ <p className="text-xs text-gray-400 mt-2">Loading currencies…</p>
131
+ </div>
132
+ ) : currencies.length === 0 ? (
133
+ <div className="py-8 text-center">
134
+ <Coins size={28} className="mx-auto text-gray-300 mb-2" />
135
+ <p className="text-sm text-gray-400 font-medium">
136
+ {search ? "No currencies match your search" : "No currencies available"}
137
+ </p>
138
+ </div>
139
+ ) : (
140
+ <div className="flex flex-col gap-0.5">
141
+ {currencies.map((c) => (
142
+ <PickerItem
143
+ key={c.id}
144
+ label={`${c.code}${c.symbol && c.symbol !== c.code ? ` (${c.symbol})` : ''}`}
145
+ sublabel={c.name}
146
+ isSelected={String(c.id) === String(value)}
147
+ onClick={() => handleSelect(c)}
148
+ />
149
+ ))}
150
+ </div>
151
+ )}
152
+ </EntityPickerModal>
153
+ </>
154
+ );
155
+ }
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
 
3
- import React, { useState, useCallback, useEffect } from "react";
3
+ import React, { useState, useCallback, useEffect, useRef } from "react";
4
4
  import { Button } from '../../base-components/Button';
5
5
  import { Input } from '../../base-components/Input';
6
6
  import { Modal } from '../../base-components/Modal';
7
7
  import { ModalFooter } from '../../base-components/Modal';
8
+ import { AsyncSearchableSelect } from '../../base-components/SearchableSelect';
8
9
 
9
10
  import { Plus, User } from "lucide-react";
10
11
  import {
@@ -12,13 +13,13 @@ import {
12
13
  PickerItem,
13
14
  PickerTrigger,
14
15
  } from "./EntityPickerModal";
15
- import { lookupParties, createParty } from "@apptimate/core-lib";
16
+ import { lookupParties, createParty, sendRequest } from "@apptimate/core-lib";
16
17
  import toast from "react-hot-toast";
17
18
 
18
19
  interface PartyPickerProps {
19
20
  value?: number | string | null;
20
21
  displayValue?: string | null;
21
- onChange: (party: { id: number; name: string; code?: string; type?: string } | null) => void;
22
+ onChange: (party: { id: number; name: string; code?: string; type?: string; default_currency_id?: number | null; default_currency?: { id: number; code: string; name: string; symbol: string } | null } | null) => void;
22
23
  label?: string;
23
24
  placeholder?: string;
24
25
  isRequired?: boolean;
@@ -52,9 +53,45 @@ export function PartyPicker({
52
53
  const [quickAddSecondaryContact, setQuickAddSecondaryContact] = useState("");
53
54
  const [quickAddGender, setQuickAddGender] = useState("");
54
55
  const [quickAddDOB, setQuickAddDOB] = useState("");
56
+ const [quickAddCurrencyId, setQuickAddCurrencyId] = useState<string | number | undefined>(undefined);
57
+ const [quickAddCurrencyDisplay, setQuickAddCurrencyDisplay] = useState<any>(null);
55
58
  const [errors, setErrors] = useState<Record<string, string[]>>({});
56
59
  const [isCreating, setIsCreating] = useState(false);
57
60
 
61
+ const hasInitializedQuickAddCurrency = useRef(false);
62
+
63
+ useEffect(() => {
64
+ if (isQuickAddOpen && !hasInitializedQuickAddCurrency.current) {
65
+ hasInitializedQuickAddCurrency.current = true;
66
+ sendRequest({ url: `${process.env.NEXT_PUBLIC_API_URL}/api/settings`, method: 'GET' })
67
+ .then((res: any) => {
68
+ if (res.responseData?.is_success && res.responseData?.result) {
69
+ const configs = Array.isArray(res.responseData.result) ? res.responseData.result : res.responseData.result.data;
70
+ const baseConfig = configs?.find((c: any) => c.key === 'base_currency');
71
+ if (baseConfig?.resolved_item) {
72
+ const currency = baseConfig.resolved_item;
73
+ setQuickAddCurrencyId(currency.id);
74
+ setQuickAddCurrencyDisplay({ id: currency.id, code: currency.code, displayLabel: `${currency.code} — ${currency.name}` });
75
+ } else if (baseConfig?.value) {
76
+ // Fallback just in case
77
+ const baseId = baseConfig.value;
78
+ sendRequest({ url: `${process.env.NEXT_PUBLIC_API_URL}/api/currencies`, method: 'GET', params: { search: '' } })
79
+ .then((cRes: any) => {
80
+ if (cRes.responseData?.is_success && cRes.responseData?.result?.data) {
81
+ const cData = Array.isArray(cRes.responseData.result.data) ? cRes.responseData.result.data : cRes.responseData.result;
82
+ const baseCurrency = cData.find((c: any) => String(c.id) === String(baseId));
83
+ if (baseCurrency) {
84
+ setQuickAddCurrencyId(baseCurrency.id);
85
+ setQuickAddCurrencyDisplay({ id: baseCurrency.id, code: baseCurrency.code, displayLabel: `${baseCurrency.code} — ${baseCurrency.name}` });
86
+ }
87
+ }
88
+ });
89
+ }
90
+ }
91
+ });
92
+ }
93
+ }, [isQuickAddOpen]);
94
+
58
95
  const fetchData = useCallback(async (query: string = "") => {
59
96
  setIsLoading(true);
60
97
  try {
@@ -83,7 +120,14 @@ export function PartyPicker({
83
120
  };
84
121
 
85
122
  const handleSelect = (party: any) => {
86
- onChange({ id: party.id, name: party.name, code: party.code, type: party.type });
123
+ onChange({
124
+ id: party.id,
125
+ name: party.name,
126
+ code: party.code,
127
+ type: party.type,
128
+ default_currency_id: party.default_currency_id,
129
+ default_currency: party.default_currency,
130
+ });
87
131
  setIsOpen(false);
88
132
  };
89
133
 
@@ -104,6 +148,7 @@ export function PartyPicker({
104
148
  secondary_contact: quickAddSecondaryContact.trim() || undefined,
105
149
  gender: quickAddGender || undefined,
106
150
  date_of_birth: quickAddDOB || undefined,
151
+ default_currency_id: quickAddCurrencyId || undefined,
107
152
  type: [partyType === "all" ? "customer" : partyType],
108
153
  status: "active"
109
154
  };
@@ -112,7 +157,19 @@ export function PartyPicker({
112
157
  toast.success("Party created");
113
158
  const party = res.result;
114
159
  const partyName = party.name || party.full_name || [party.first_name, party.last_name].filter(Boolean).join(" ");
115
- onChange({ id: party.id, name: partyName, code: party.code, type: party.type });
160
+ onChange({
161
+ id: party.id,
162
+ name: partyName,
163
+ code: party.code,
164
+ type: party.type,
165
+ default_currency_id: quickAddCurrencyDisplay?.id,
166
+ default_currency: quickAddCurrencyDisplay ? {
167
+ id: quickAddCurrencyDisplay.id,
168
+ code: quickAddCurrencyDisplay.code,
169
+ name: quickAddCurrencyDisplay.name,
170
+ symbol: quickAddCurrencyDisplay.symbol
171
+ } : undefined
172
+ });
116
173
  setIsQuickAddOpen(false);
117
174
  setQuickAddFirstName("");
118
175
  setQuickAddLastName("");
@@ -268,20 +325,52 @@ export function PartyPicker({
268
325
  error={errors.date_of_birth?.[0]}
269
326
  />
270
327
  </div>
271
- <div className="space-y-1">
272
- <label className="text-sm font-semibold text-gray-700">Gender</label>
273
- <select
274
- value={quickAddGender}
275
- onChange={(e) => setQuickAddGender(e.target.value)}
276
- className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white"
277
- >
278
- <option value="">Select gender</option>
279
- <option value="male">Male</option>
280
- <option value="female">Female</option>
281
- <option value="other">Other</option>
282
- <option value="prefer_not_to_say">Prefer not to say</option>
283
- </select>
284
- {errors.gender?.[0] && <p className="text-xs text-red-500">{errors.gender[0]}</p>}
328
+ <div className="grid grid-cols-2 gap-3">
329
+ <div className="space-y-1">
330
+ <label className="text-sm font-semibold text-gray-700">Gender</label>
331
+ <select
332
+ value={quickAddGender}
333
+ onChange={(e) => setQuickAddGender(e.target.value)}
334
+ className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white"
335
+ >
336
+ <option value="">Select gender</option>
337
+ <option value="male">Male</option>
338
+ <option value="female">Female</option>
339
+ <option value="other">Other</option>
340
+ <option value="prefer_not_to_say">Prefer not to say</option>
341
+ </select>
342
+ {errors.gender?.[0] && <p className="text-xs text-red-500">{errors.gender[0]}</p>}
343
+ </div>
344
+ {(partyType === "all" || partyType === "customer" || partyType === "supplier") && (
345
+ <AsyncSearchableSelect
346
+ label="Default Currency"
347
+ placeholder="Search currency..."
348
+ loadOptions={async (search, page) => {
349
+ try {
350
+ const res: any = await sendRequest({
351
+ url: `${process.env.NEXT_PUBLIC_API_URL}/api/currencies`,
352
+ method: 'GET',
353
+ params: { search, page, per_page: 20 }
354
+ });
355
+ if (res.responseData?.is_success && res.responseData?.result?.data) {
356
+ return res.responseData.result.data.map((c: any) => ({
357
+ ...c,
358
+ displayLabel: `${c.code} — ${c.name}`
359
+ }));
360
+ }
361
+ return [];
362
+ } catch { return []; }
363
+ }}
364
+ option={{ label: "displayLabel", value: "id", keysToSearch: ["name", "code"] }}
365
+ onChange={(val, selectedObj: any) => {
366
+ setQuickAddCurrencyId(val as string | number);
367
+ setQuickAddCurrencyDisplay(selectedObj);
368
+ }}
369
+ key={quickAddCurrencyDisplay ? quickAddCurrencyDisplay.code : 'empty'}
370
+ defaultValue={quickAddCurrencyDisplay || undefined}
371
+ error={errors.default_currency_id?.[0]}
372
+ />
373
+ )}
285
374
  </div>
286
375
  <ModalFooter>
287
376
  <Button
@@ -1,5 +1,6 @@
1
1
  export * from './BrandPicker';
2
2
  export * from './CategoryPicker';
3
+ export * from './CurrencyPicker';
3
4
  export * from './EntityPickerModal';
4
5
  export * from './UomGroupPicker';
5
6
  export * from './UomPicker';
@@ -13,6 +13,14 @@ interface PaymentFormProps {
13
13
  fetchPaymentModes: () => Promise<any>;
14
14
  fetchBankAccounts: () => Promise<any>;
15
15
  fetchChequeLeaves?: (query: string, page?: number) => Promise<any>;
16
+ /** Currency code of the invoice (e.g. 'USD'). If not provided, no multi-currency UI. */
17
+ currencyCode?: string;
18
+ /** Exchange rate used when the invoice was created */
19
+ invoiceExchangeRate?: number;
20
+ /** Whether this is a multi-currency (foreign currency) invoice */
21
+ isMultiCurrency?: boolean;
22
+ /** Base currency code (e.g. 'LKR') */
23
+ baseCurrencyCode?: string;
16
24
  }
17
25
 
18
26
  export function PaymentForm({
@@ -23,6 +31,10 @@ export function PaymentForm({
23
31
  fetchPaymentModes,
24
32
  fetchBankAccounts,
25
33
  fetchChequeLeaves,
34
+ currencyCode,
35
+ invoiceExchangeRate = 1,
36
+ isMultiCurrency = false,
37
+ baseCurrencyCode = 'LKR',
26
38
  }: PaymentFormProps) {
27
39
  const [isSubmitting, setIsSubmitting] = useState(false);
28
40
  const outstanding = Math.abs(Number(invoice.amount_due || 0) - Number(invoice.amount_settled || invoice.amount_received || 0));
@@ -32,6 +44,25 @@ export function PaymentForm({
32
44
  const [remarks, setRemarks] = useState("");
33
45
  const [payments, setPayments] = useState<PaymentEntry[]>([]);
34
46
 
47
+ // Exchange rate state (only used for multi-currency invoices)
48
+ const [exchangeRate, setExchangeRate] = useState<string>(String(invoiceExchangeRate));
49
+
50
+ const currentRate = Number(exchangeRate) || 0;
51
+ const totalPaymentAmount = payments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
52
+
53
+ // Compute exchange difference for display
54
+ const exchangeDifference = isMultiCurrency && currentRate > 0
55
+ ? (totalPaymentAmount * currentRate) - (totalPaymentAmount * invoiceExchangeRate)
56
+ : 0;
57
+
58
+ // For AP (Payables), paying less in base currency is a GAIN.
59
+ // For AR (Receivables), receiving less in base currency is a LOSS.
60
+ const isAP = invoice?.type === 'ap';
61
+ const isExchangeGain = isAP ? exchangeDifference < 0 : exchangeDifference > 0;
62
+ const absExchangeDiff = Math.abs(exchangeDifference);
63
+
64
+ const fmt = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
65
+
35
66
  const handleSubmit = async (e: React.FormEvent) => {
36
67
  e.preventDefault();
37
68
  if (payments.length === 0) {
@@ -45,6 +76,11 @@ export function PaymentForm({
45
76
  return;
46
77
  }
47
78
 
79
+ if (isMultiCurrency && (!exchangeRate || currentRate <= 0)) {
80
+ toast.error("Please enter a valid exchange rate");
81
+ return;
82
+ }
83
+
48
84
  setIsSubmitting(true);
49
85
  let successCount = 0;
50
86
 
@@ -61,6 +97,11 @@ export function PaymentForm({
61
97
  reference: reference || null,
62
98
  };
63
99
 
100
+ // Include exchange rate for multi-currency invoices
101
+ if (isMultiCurrency) {
102
+ payload.exchange_rate = currentRate;
103
+ }
104
+
64
105
  if (p.metadata) {
65
106
  if (p.metadata.reference) payload.reference = p.metadata.reference;
66
107
  if (p.metadata.bank_account_id) payload.bank_account_id = p.metadata.bank_account_id;
@@ -94,6 +135,49 @@ export function PaymentForm({
94
135
  />
95
136
  </div>
96
137
 
138
+ {/* Exchange Rate Section — only for multi-currency invoices */}
139
+ {isMultiCurrency && (
140
+ <div className="space-y-3">
141
+ <div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
142
+ <p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Invoice Rate</p>
143
+ <p className="text-sm font-medium text-gray-700">1 {currencyCode} = {invoiceExchangeRate.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 })} {baseCurrencyCode}</p>
144
+ </div>
145
+ <div className="flex flex-col gap-1.5">
146
+ <label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">
147
+ Current Exchange Rate (1 {currencyCode} = ? {baseCurrencyCode}) *
148
+ </label>
149
+ <Input
150
+ type="number"
151
+ step="any"
152
+ placeholder="0.000000"
153
+ value={exchangeRate}
154
+ onChange={(e) => setExchangeRate(e.target.value)}
155
+ />
156
+ </div>
157
+ {/* Live calculation */}
158
+ {totalPaymentAmount > 0 && currentRate > 0 && (
159
+ <div className="bg-gray-50 rounded-lg p-3 border border-gray-100 space-y-2">
160
+ <div className="flex items-center justify-between">
161
+ <p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Base Currency Amount</p>
162
+ <p className="text-sm font-bold text-gray-800">
163
+ {baseCurrencyCode} {fmt(totalPaymentAmount * currentRate)}
164
+ </p>
165
+ </div>
166
+ {absExchangeDiff >= 0.01 && (
167
+ <div className={`flex items-center justify-between px-2 py-1.5 rounded-md ${isExchangeGain ? 'bg-green-50 border border-green-100' : 'bg-red-50 border border-red-100'}`}>
168
+ <span className={`text-[10px] font-bold uppercase tracking-wider ${isExchangeGain ? 'text-green-600' : 'text-red-600'}`}>
169
+ Exchange {isExchangeGain ? 'Gain' : 'Loss'}
170
+ </span>
171
+ <span className={`text-xs font-bold ${isExchangeGain ? 'text-green-700' : 'text-red-700'}`}>
172
+ {baseCurrencyCode} {fmt(absExchangeDiff)}
173
+ </span>
174
+ </div>
175
+ )}
176
+ </div>
177
+ )}
178
+ </div>
179
+ )}
180
+
97
181
  <PaymentSection
98
182
  totalAmount={outstanding}
99
183
  payments={payments}
package/src/index.tsx CHANGED
@@ -43,6 +43,7 @@ export * from './base-components/WizardModal';
43
43
  // Pickers
44
44
  export * from './common-components/pickers/BrandPicker';
45
45
  export * from './common-components/pickers/CategoryPicker';
46
+ export * from './common-components/pickers/CurrencyPicker';
46
47
  export * from './common-components/pickers/EmployeePicker';
47
48
  export * from './common-components/pickers/EntityPickerModal';
48
49
  export * from './common-components/pickers/UomGroupPicker';