|
|
|
|
@@ -2,55 +2,43 @@
|
|
|
|
|
|
|
|
|
|
import { useState } from 'react';
|
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
import { Edit, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
|
|
|
|
import { Edit, Loader2, Plus, Trash2 } from 'lucide-react';
|
|
|
|
|
import DataTable from '@/components/ui/DataTable';
|
|
|
|
|
import Modal from '@/components/ui/Modal';
|
|
|
|
|
import ActionButton from '@/components/ui/ActionButton';
|
|
|
|
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|
|
|
|
import { apiClient } from '@/lib/api-client';
|
|
|
|
|
|
|
|
|
|
interface CurrencyRate {
|
|
|
|
|
interface ExchangeRate {
|
|
|
|
|
id: string;
|
|
|
|
|
code: string;
|
|
|
|
|
name: string;
|
|
|
|
|
symbol: string;
|
|
|
|
|
baseCurrencyCode: string;
|
|
|
|
|
exchangeRate: number;
|
|
|
|
|
isActive: boolean;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
fromCurrency: string;
|
|
|
|
|
toCurrency: string;
|
|
|
|
|
rate: number;
|
|
|
|
|
source: string;
|
|
|
|
|
effectiveDate: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CURRENCY_META: Record<string, { name: string; symbol: string }> = {
|
|
|
|
|
ETB: { name: 'Ethiopian Birr', symbol: 'Br' },
|
|
|
|
|
DJF: { name: 'Djiboutian Franc', symbol: 'Fdj' },
|
|
|
|
|
USD: { name: 'US Dollar', symbol: '$' },
|
|
|
|
|
const CURRENCY_META: Record<string, { name: string }> = {
|
|
|
|
|
ETB: { name: 'Ethiopian Birr' },
|
|
|
|
|
DJF: { name: 'Djiboutian Franc' },
|
|
|
|
|
USD: { name: 'US Dollar' },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const CURRENCY_OPTIONS = ['ETB', 'DJF', 'USD'];
|
|
|
|
|
|
|
|
|
|
export default function CurrenciesPage() {
|
|
|
|
|
const [editingRate, setEditingRate] = useState<CurrencyRate | null>(null);
|
|
|
|
|
const [rateInput, setRateInput] = useState('');
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
|
|
|
const [addForm, setAddForm] = useState({ code: '', name: '', symbol: '', exchangeRate: '' });
|
|
|
|
|
const [deleteConfirm, setDeleteConfirm] = useState<CurrencyRate | null>(null);
|
|
|
|
|
const [editingRate, setEditingRate] = useState<ExchangeRate | null>(null);
|
|
|
|
|
const [rateInput, setRateInput] = useState('');
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
|
|
|
const [addForm, setAddForm] = useState({ fromCurrency: 'ETB', toCurrency: 'DJF', rate: '' });
|
|
|
|
|
const [deleteConfirm, setDeleteConfirm] = useState<ExchangeRate | null>(null);
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
|
|
|
|
|
const { data: currencies = [], isLoading } = useQuery<CurrencyRate[]>({
|
|
|
|
|
const { data: rates = [], isLoading } = useQuery<ExchangeRate[]>({
|
|
|
|
|
queryKey: ['currencies'],
|
|
|
|
|
queryFn: () => apiClient.get('/currencies'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const updateMutation = useMutation({
|
|
|
|
|
mutationFn: ({ id, exchangeRate }: { id: string; exchangeRate: number }) =>
|
|
|
|
|
apiClient.patch(`/currencies/${id}`, { exchangeRate }),
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
|
|
|
|
setEditingRate(null);
|
|
|
|
|
setError(null);
|
|
|
|
|
},
|
|
|
|
|
onError: (err: any) => {
|
|
|
|
|
setError(err.response?.data?.message || 'Failed to update exchange rate');
|
|
|
|
|
},
|
|
|
|
|
select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const createMutation = useMutation({
|
|
|
|
|
@@ -58,10 +46,21 @@ export default function CurrenciesPage() {
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
|
|
|
|
setShowAddModal(false);
|
|
|
|
|
setAddForm({ code: '', name: '', symbol: '', exchangeRate: '' });
|
|
|
|
|
setAddForm({ fromCurrency: 'ETB', toCurrency: 'DJF', rate: '' });
|
|
|
|
|
setError(null);
|
|
|
|
|
},
|
|
|
|
|
onError: (err: any) => setError(err.response?.data?.message || 'Failed to add currency'),
|
|
|
|
|
onError: (err: any) => setError(err.response?.data?.message || 'Failed to create rate'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const updateMutation = useMutation({
|
|
|
|
|
mutationFn: ({ id, rate }: { id: string; rate: number }) =>
|
|
|
|
|
apiClient.patch(`/currencies/${id}`, { rate }),
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
|
|
|
|
setEditingRate(null);
|
|
|
|
|
setError(null);
|
|
|
|
|
},
|
|
|
|
|
onError: (err: any) => setError(err.response?.data?.message || 'Failed to update rate'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const deleteMutation = useMutation({
|
|
|
|
|
@@ -70,88 +69,70 @@ export default function CurrenciesPage() {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
|
|
|
|
setDeleteConfirm(null);
|
|
|
|
|
},
|
|
|
|
|
onError: (err: any) => setError(err.response?.data?.message || 'Failed to delete currency'),
|
|
|
|
|
onError: (err: any) => setError(err.response?.data?.message || 'Failed to delete rate'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const syncMutation = useMutation({
|
|
|
|
|
mutationFn: () => apiClient.post('/currencies/sync-rates', {}),
|
|
|
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }),
|
|
|
|
|
onError: (err: any) => setError(err.response?.data?.message || 'Failed to sync rates'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handleEdit = (currency: CurrencyRate) => {
|
|
|
|
|
setEditingRate(currency);
|
|
|
|
|
setRateInput(currency.exchangeRate.toString());
|
|
|
|
|
setError(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
const rate = parseFloat(rateInput);
|
|
|
|
|
if (isNaN(rate) || rate <= 0) {
|
|
|
|
|
setError('Exchange rate must be a positive number');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await updateMutation.mutateAsync({ id: editingRate!.id, exchangeRate: rate });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items ?? [];
|
|
|
|
|
|
|
|
|
|
const columns = [
|
|
|
|
|
{
|
|
|
|
|
key: 'code',
|
|
|
|
|
label: 'Currency',
|
|
|
|
|
render: (c: CurrencyRate) => (
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<span className="text-2xl font-bold text-muted-foreground w-10 text-center">
|
|
|
|
|
{CURRENCY_META[c.code]?.symbol ?? c.symbol}
|
|
|
|
|
key: 'pair',
|
|
|
|
|
label: 'Pair',
|
|
|
|
|
render: (r: ExchangeRate) => (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="font-mono font-semibold">{r.fromCurrency}</span>
|
|
|
|
|
<span className="text-muted-foreground">→</span>
|
|
|
|
|
<span className="font-mono font-semibold">{r.toCurrency}</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground ml-1">
|
|
|
|
|
{CURRENCY_META[r.toCurrency]?.name ?? r.toCurrency}
|
|
|
|
|
</span>
|
|
|
|
|
<div>
|
|
|
|
|
<div className="font-semibold">{c.code}</div>
|
|
|
|
|
<div className="text-xs text-muted-foreground">{CURRENCY_META[c.code]?.name ?? c.name}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'baseCurrencyCode',
|
|
|
|
|
label: 'Base',
|
|
|
|
|
render: (c: CurrencyRate) => (
|
|
|
|
|
<span className="font-mono text-sm text-muted-foreground">{c.baseCurrencyCode}</span>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'exchangeRate',
|
|
|
|
|
label: 'Exchange Rate',
|
|
|
|
|
render: (c: CurrencyRate) => (
|
|
|
|
|
key: 'rate',
|
|
|
|
|
label: 'Rate',
|
|
|
|
|
render: (r: ExchangeRate) => (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="font-mono font-semibold">
|
|
|
|
|
1 {c.baseCurrencyCode} = {c.exchangeRate} {c.code}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
|
|
|
1 {c.code} = {(1 / c.exchangeRate).toFixed(6)} {c.baseCurrencyCode}
|
|
|
|
|
1 {r.fromCurrency} = {r.rate} {r.toCurrency}
|
|
|
|
|
</div>
|
|
|
|
|
{r.rate > 0 && (
|
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
|
|
|
1 {r.toCurrency} = {(1 / r.rate).toFixed(6)} {r.fromCurrency}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'createdAt',
|
|
|
|
|
label: 'Last Updated',
|
|
|
|
|
render: (c: CurrencyRate) => (
|
|
|
|
|
key: 'source',
|
|
|
|
|
label: 'Source',
|
|
|
|
|
render: (r: ExchangeRate) => (
|
|
|
|
|
<span className="text-sm text-muted-foreground">{r.source ?? '—'}</span>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'effectiveDate',
|
|
|
|
|
label: 'Effective Date',
|
|
|
|
|
render: (r: ExchangeRate) => (
|
|
|
|
|
<span className="text-sm text-muted-foreground">
|
|
|
|
|
{new Date(c.createdAt).toLocaleDateString()}
|
|
|
|
|
{r.effectiveDate ? new Date(r.effectiveDate).toLocaleDateString() : '—'}
|
|
|
|
|
</span>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const actions = [
|
|
|
|
|
{ label: 'Edit', onClick: handleEdit, variant: 'secondary' as const, icon: Edit },
|
|
|
|
|
{
|
|
|
|
|
label: 'Edit',
|
|
|
|
|
onClick: (r: ExchangeRate) => { setEditingRate(r); setRateInput(String(r.rate)); setError(null); },
|
|
|
|
|
variant: 'secondary' as const,
|
|
|
|
|
icon: Edit,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Delete',
|
|
|
|
|
onClick: (c: CurrencyRate) => setDeleteConfirm(c),
|
|
|
|
|
onClick: (r: ExchangeRate) => setDeleteConfirm(r),
|
|
|
|
|
variant: 'danger' as const,
|
|
|
|
|
icon: Trash2,
|
|
|
|
|
show: (c: CurrencyRate) => c.id !== 'etb-base',
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
@@ -160,64 +141,27 @@ export default function CurrenciesPage() {
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-3xl font-bold text-foreground">Exchange Rates</h1>
|
|
|
|
|
<p className="text-muted-foreground mt-1">
|
|
|
|
|
Manage ETB exchange rates for display currencies (DJF, USD)
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }}>Add Currency</ActionButton>
|
|
|
|
|
<ActionButton
|
|
|
|
|
icon={RefreshCw}
|
|
|
|
|
variant="secondary"
|
|
|
|
|
onClick={() => syncMutation.mutate()}
|
|
|
|
|
loading={syncMutation.isPending}
|
|
|
|
|
>
|
|
|
|
|
Sync Rates
|
|
|
|
|
</ActionButton>
|
|
|
|
|
<p className="text-muted-foreground mt-1">Manage currency exchange rates</p>
|
|
|
|
|
</div>
|
|
|
|
|
<ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }}>
|
|
|
|
|
Add Rate
|
|
|
|
|
</ActionButton>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error && !editingRate && (
|
|
|
|
|
{error && !editingRate && !showAddModal && (
|
|
|
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="card">
|
|
|
|
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
|
|
|
|
{(['ETB', 'DJF', 'USD'] as const).map((code) => {
|
|
|
|
|
const entry = currenciesArray.find((c: CurrencyRate) => c.code === code);
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={code}
|
|
|
|
|
className="p-4 rounded-lg border bg-muted/30 flex items-center justify-between"
|
|
|
|
|
>
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-xs text-muted-foreground font-medium">{CURRENCY_META[code].name}</div>
|
|
|
|
|
<div className="text-2xl font-bold mt-1">{code}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-right">
|
|
|
|
|
{entry ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="font-mono font-semibold text-lg">{entry.exchangeRate}</div>
|
|
|
|
|
<div className="text-xs text-muted-foreground">per ETB</div>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-xs text-muted-foreground">Not configured</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<div className="flex items-center justify-center py-12">
|
|
|
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<DataTable
|
|
|
|
|
data={currenciesArray}
|
|
|
|
|
data={rates}
|
|
|
|
|
columns={columns}
|
|
|
|
|
actions={actions}
|
|
|
|
|
loading={false}
|
|
|
|
|
@@ -226,117 +170,85 @@ export default function CurrenciesPage() {
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Modal
|
|
|
|
|
isOpen={showAddModal}
|
|
|
|
|
onClose={() => { setShowAddModal(false); setError(null); }}
|
|
|
|
|
title="Add Currency"
|
|
|
|
|
size="sm"
|
|
|
|
|
>
|
|
|
|
|
{/* Add Modal */}
|
|
|
|
|
<Modal isOpen={showAddModal} onClose={() => { setShowAddModal(false); setError(null); }} title="Add Exchange Rate" size="sm">
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{error}</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="label">Code *</label>
|
|
|
|
|
<input className="input uppercase" placeholder="e.g., EUR" maxLength={5}
|
|
|
|
|
value={addForm.code} onChange={(e) => setAddForm({ ...addForm, code: e.target.value.toUpperCase() })} />
|
|
|
|
|
<label className="label">From *</label>
|
|
|
|
|
<select className="input" value={addForm.fromCurrency} onChange={(e) => setAddForm({ ...addForm, fromCurrency: e.target.value })}>
|
|
|
|
|
{CURRENCY_OPTIONS.map(c => <option key={c} value={c}>{c} — {CURRENCY_META[c]?.name ?? c}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label className="label">Symbol *</label>
|
|
|
|
|
<input className="input" placeholder="e.g., €"
|
|
|
|
|
value={addForm.symbol} onChange={(e) => setAddForm({ ...addForm, symbol: e.target.value })} />
|
|
|
|
|
<label className="label">To *</label>
|
|
|
|
|
<select className="input" value={addForm.toCurrency} onChange={(e) => setAddForm({ ...addForm, toCurrency: e.target.value })}>
|
|
|
|
|
{CURRENCY_OPTIONS.map(c => <option key={c} value={c}>{c} — {CURRENCY_META[c]?.name ?? c}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label className="label">Name *</label>
|
|
|
|
|
<input className="input" placeholder="e.g., Euro"
|
|
|
|
|
value={addForm.name} onChange={(e) => setAddForm({ ...addForm, name: e.target.value })} />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label className="label">Exchange Rate (1 ETB = ? {addForm.code || '...'}) *</label>
|
|
|
|
|
<input type="number" min="0.0001" step="0.0001" className="input" placeholder="e.g., 0.018"
|
|
|
|
|
value={addForm.exchangeRate} onChange={(e) => setAddForm({ ...addForm, exchangeRate: e.target.value })} />
|
|
|
|
|
<label className="label">Rate (1 {addForm.fromCurrency} = ? {addForm.toCurrency}) *</label>
|
|
|
|
|
<input type="number" min="0.000001" step="0.000001" className="input" placeholder="e.g., 3.25"
|
|
|
|
|
value={addForm.rate} onChange={(e) => setAddForm({ ...addForm, rate: e.target.value })} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-2 justify-end pt-2">
|
|
|
|
|
<ActionButton variant="secondary" onClick={() => { setShowAddModal(false); setError(null); }}>Cancel</ActionButton>
|
|
|
|
|
<ActionButton
|
|
|
|
|
loading={createMutation.isPending}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!addForm.code || !addForm.name || !addForm.symbol || !addForm.exchangeRate) {
|
|
|
|
|
setError('All fields are required'); return;
|
|
|
|
|
}
|
|
|
|
|
const rate = parseFloat(addForm.exchangeRate);
|
|
|
|
|
if (isNaN(rate) || rate <= 0) { setError('Exchange rate must be a positive number'); return; }
|
|
|
|
|
createMutation.mutate({ code: addForm.code, name: addForm.name, symbol: addForm.symbol, exchangeRate: rate });
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Add Currency
|
|
|
|
|
<ActionButton loading={createMutation.isPending} onClick={() => {
|
|
|
|
|
if (addForm.fromCurrency === addForm.toCurrency) { setError('From and To currencies must differ'); return; }
|
|
|
|
|
const rate = parseFloat(addForm.rate);
|
|
|
|
|
if (isNaN(rate) || rate <= 0) { setError('Rate must be a positive number'); return; }
|
|
|
|
|
createMutation.mutate({ fromCurrency: addForm.fromCurrency, toCurrency: addForm.toCurrency, rate });
|
|
|
|
|
}}>
|
|
|
|
|
Add Rate
|
|
|
|
|
</ActionButton>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Modal>
|
|
|
|
|
|
|
|
|
|
{/* Edit Modal */}
|
|
|
|
|
<Modal isOpen={!!editingRate} onClose={() => { setEditingRate(null); setError(null); }} title={`Edit Rate — ${editingRate?.fromCurrency} → ${editingRate?.toCurrency}`} size="sm">
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{error}</div>
|
|
|
|
|
)}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="label">Rate (1 {editingRate?.fromCurrency} = ? {editingRate?.toCurrency})</label>
|
|
|
|
|
<input type="number" min="0.000001" step="0.000001" className="input w-full"
|
|
|
|
|
value={rateInput} onChange={(e) => setRateInput(e.target.value)} autoFocus />
|
|
|
|
|
{rateInput && parseFloat(rateInput) > 0 && (
|
|
|
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
|
|
|
≈ 1 {editingRate?.toCurrency} = {(1 / parseFloat(rateInput)).toFixed(6)} {editingRate?.fromCurrency}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-2 justify-end pt-2">
|
|
|
|
|
<ActionButton variant="secondary" onClick={() => { setEditingRate(null); setError(null); }}>Cancel</ActionButton>
|
|
|
|
|
<ActionButton loading={updateMutation.isPending} onClick={() => {
|
|
|
|
|
const rate = parseFloat(rateInput);
|
|
|
|
|
if (isNaN(rate) || rate <= 0) { setError('Rate must be a positive number'); return; }
|
|
|
|
|
updateMutation.mutate({ id: editingRate!.id, rate });
|
|
|
|
|
}}>
|
|
|
|
|
Save
|
|
|
|
|
</ActionButton>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Modal>
|
|
|
|
|
|
|
|
|
|
{/* Delete Confirm */}
|
|
|
|
|
<ConfirmDialog
|
|
|
|
|
isOpen={!!deleteConfirm}
|
|
|
|
|
onClose={() => setDeleteConfirm(null)}
|
|
|
|
|
onConfirm={() => deleteMutation.mutate(deleteConfirm!.id)}
|
|
|
|
|
title="Delete Currency"
|
|
|
|
|
message={`Delete ${deleteConfirm?.code} (${CURRENCY_META[deleteConfirm?.code ?? '']?.name ?? deleteConfirm?.code})? This will remove the exchange rate record.`}
|
|
|
|
|
title="Delete Exchange Rate"
|
|
|
|
|
message={`Delete rate ${deleteConfirm?.fromCurrency} → ${deleteConfirm?.toCurrency} (${deleteConfirm?.rate})?`}
|
|
|
|
|
confirmText="Delete"
|
|
|
|
|
isDanger
|
|
|
|
|
isLoading={deleteMutation.isPending}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<Modal
|
|
|
|
|
isOpen={!!editingRate}
|
|
|
|
|
onClose={() => { setEditingRate(null); setError(null); }}
|
|
|
|
|
title={`Update Rate — ${editingRate?.code}`}
|
|
|
|
|
size="sm"
|
|
|
|
|
>
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="p-3 bg-muted/40 rounded-lg text-sm">
|
|
|
|
|
<span className="text-muted-foreground">Currency: </span>
|
|
|
|
|
<span className="font-semibold">{editingRate?.code} — {CURRENCY_META[editingRate?.code ?? '']?.name}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="label">
|
|
|
|
|
1 {editingRate?.baseCurrencyCode} = ? {editingRate?.code}
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0.0001"
|
|
|
|
|
step="0.0001"
|
|
|
|
|
value={rateInput}
|
|
|
|
|
onChange={(e) => setRateInput(e.target.value)}
|
|
|
|
|
className="input w-full"
|
|
|
|
|
placeholder="e.g., 3.25"
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
{rateInput && parseFloat(rateInput) > 0 && (
|
|
|
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
|
|
|
≈ 1 {editingRate?.code} = {(1 / parseFloat(rateInput)).toFixed(6)} {editingRate?.baseCurrencyCode}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-2 justify-end pt-2">
|
|
|
|
|
<ActionButton variant="secondary" onClick={() => { setEditingRate(null); setError(null); }}>
|
|
|
|
|
Cancel
|
|
|
|
|
</ActionButton>
|
|
|
|
|
<ActionButton onClick={handleSave} loading={updateMutation.isPending}>
|
|
|
|
|
Save Rate
|
|
|
|
|
</ActionButton>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Modal>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|