@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
|
@@ -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({
|
|
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({
|
|
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) =>
|
|
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]}
|
|
@@ -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';
|