@apptimate/ui 5.7.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.7.0",
3
+ "version": "5.8.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "dependencies": {
@@ -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
+ }
@@ -19,7 +19,7 @@ import toast from "react-hot-toast";
19
19
  interface PartyPickerProps {
20
20
  value?: number | string | null;
21
21
  displayValue?: string | null;
22
- 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;
23
23
  label?: string;
24
24
  placeholder?: string;
25
25
  isRequired?: boolean;
@@ -120,7 +120,14 @@ export function PartyPicker({
120
120
  };
121
121
 
122
122
  const handleSelect = (party: any) => {
123
- 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
+ });
124
131
  setIsOpen(false);
125
132
  };
126
133
 
@@ -150,7 +157,19 @@ export function PartyPicker({
150
157
  toast.success("Party created");
151
158
  const party = res.result;
152
159
  const partyName = party.name || party.full_name || [party.first_name, party.last_name].filter(Boolean).join(" ");
153
- 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
+ });
154
173
  setIsQuickAddOpen(false);
155
174
  setQuickAddFirstName("");
156
175
  setQuickAddLastName("");
@@ -343,7 +362,10 @@ export function PartyPicker({
343
362
  } catch { return []; }
344
363
  }}
345
364
  option={{ label: "displayLabel", value: "id", keysToSearch: ["name", "code"] }}
346
- onChange={(val) => setQuickAddCurrencyId(val as string | number)}
365
+ onChange={(val, selectedObj: any) => {
366
+ setQuickAddCurrencyId(val as string | number);
367
+ setQuickAddCurrencyDisplay(selectedObj);
368
+ }}
347
369
  key={quickAddCurrencyDisplay ? quickAddCurrencyDisplay.code : 'empty'}
348
370
  defaultValue={quickAddCurrencyDisplay || undefined}
349
371
  error={errors.default_currency_id?.[0]}
@@ -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';