Booking and pricing related updates

This commit is contained in:
Stephanos A
2026-06-18 14:02:11 +03:00
parent aefee5027a
commit b491f39d42
10 changed files with 410 additions and 626 deletions

View File

@@ -2,14 +2,13 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Loader2, Edit, RefreshCw } from 'lucide-react';
import { Edit, Loader2, RefreshCw } 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 Currency {
interface CurrencyRate {
id: string;
code: string;
name: string;
@@ -18,203 +17,104 @@ interface Currency {
exchangeRate: number;
isActive: boolean;
createdAt: string;
updatedAt: 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: '$' },
};
export default function CurrenciesPage() {
const [showModal, setShowModal] = useState(false);
const [editingCurrency, setEditingCurrency] = useState<Currency | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null }>({
isOpen: false,
id: null,
});
const [editingRate, setEditingRate] = useState<CurrencyRate | null>(null);
const [rateInput, setRateInput] = useState('');
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const [currencyForm, setCurrencyForm] = useState({
code: '',
name: '',
symbol: '',
baseCurrencyCode: 'ETB',
exchangeRate: '',
});
const { data: currencies = [], isLoading } = useQuery({
const { data: currencies = [], isLoading } = useQuery<CurrencyRate[]>({
queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'),
});
const createMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/currencies', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
resetForm();
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to create currency');
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => apiClient.patch(`/currencies/${data.id}`, data),
mutationFn: ({ id, exchangeRate }: { id: string; exchangeRate: number }) =>
apiClient.patch(`/currencies/${id}`, { exchangeRate }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setEditingCurrency(null);
resetForm();
setEditingRate(null);
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to update currency');
setError(err.response?.data?.message || 'Failed to update exchange rate');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setDeleteConfirm({ isOpen: false, id: null });
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to delete currency');
},
});
const syncRatesMutation = useMutation({
const syncMutation = useMutation({
mutationFn: () => apiClient.post('/currencies/sync-rates', {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to sync exchange rates');
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }),
onError: (err: any) => setError(err.response?.data?.message || 'Failed to sync rates'),
});
const resetForm = () => {
setCurrencyForm({
code: '',
name: '',
symbol: '',
baseCurrencyCode: 'ETB',
exchangeRate: '',
});
setEditingCurrency(null);
setShowModal(false);
const handleEdit = (currency: CurrencyRate) => {
setEditingRate(currency);
setRateInput(currency.exchangeRate.toString());
setError(null);
};
const handleEditCurrency = (currency: Currency) => {
setEditingCurrency(currency);
setCurrencyForm({
code: currency.code,
name: currency.name,
symbol: currency.symbol,
baseCurrencyCode: currency.baseCurrencyCode,
exchangeRate: currency.exchangeRate.toString(),
});
setError(null);
setShowModal(true);
};
const handleSaveCurrency = async () => {
setError(null);
if (!currencyForm.code || !currencyForm.name || !currencyForm.symbol || !currencyForm.exchangeRate) {
setError('All fields are required');
return;
}
const rate = parseFloat(currencyForm.exchangeRate);
const handleSave = async () => {
const rate = parseFloat(rateInput);
if (isNaN(rate) || rate <= 0) {
setError('Exchange rate must be a positive number');
return;
}
const payload = {
code: currencyForm.code.toUpperCase(),
name: currencyForm.name,
symbol: currencyForm.symbol,
baseCurrencyCode: currencyForm.baseCurrencyCode,
exchangeRate: rate,
};
if (editingCurrency) {
await updateMutation.mutateAsync({ id: editingCurrency.id, ...payload });
} else {
await createMutation.mutateAsync(payload);
}
await updateMutation.mutateAsync({ id: editingRate!.id, exchangeRate: rate });
};
const confirmDelete = async () => {
if (deleteConfirm.id) {
await deleteMutation.mutateAsync(deleteConfirm.id);
}
};
const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items || [];
const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items ?? [];
const columns = [
{
key: 'code',
label: 'Code',
render: (currency: Currency) => (
<span className="font-mono font-semibold text-primary">{currency.code}</span>
),
},
{
key: 'name',
label: 'Name',
render: (currency: Currency) => (
<span className="font-medium">{currency.name}</span>
),
},
{
key: 'symbol',
label: 'Symbol',
render: (currency: Currency) => (
<span className="text-lg">{currency.symbol}</span>
),
},
{
key: 'baseCurrencyCode',
label: 'Base Currency',
render: (currency: Currency) => (
<span className="font-mono text-sm">{currency.baseCurrencyCode}</span>
),
},
{
key: 'exchangeRate',
label: 'Exchange Rate',
render: (currency: Currency) => (
<div className="space-y-1">
<div className="font-mono font-semibold">
1 {currency.baseCurrencyCode} = {currency.exchangeRate.toFixed(4)} {currency.code}
</div>
<div className="text-xs text-muted-foreground">
1 {currency.code} = {(1 / currency.exchangeRate).toFixed(6)} {currency.baseCurrencyCode}
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}
</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: 'isActive',
label: 'Status',
render: (currency: Currency) => (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
currency.isActive
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-gray-100 dark:bg-gray-900/20 text-gray-800 dark:text-gray-300'
}`}>
{currency.isActive ? 'Active' : 'Inactive'}
</span>
key: 'baseCurrencyCode',
label: 'Base',
render: (c: CurrencyRate) => (
<span className="font-mono text-sm text-muted-foreground">{c.baseCurrencyCode}</span>
),
},
{
key: 'updatedAt',
key: 'exchangeRate',
label: 'Exchange Rate',
render: (c: CurrencyRate) => (
<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}
</div>
</div>
),
},
{
key: 'createdAt',
label: 'Last Updated',
render: (currency: Currency) => (
render: (c: CurrencyRate) => (
<span className="text-sm text-muted-foreground">
{new Date(currency.updatedAt).toLocaleDateString()}
{new Date(c.createdAt).toLocaleDateString()}
</span>
),
},
@@ -222,140 +122,93 @@ export default function CurrenciesPage() {
const actions = [
{
label: 'Edit',
onClick: handleEditCurrency,
label: 'Edit Rate',
onClick: handleEdit,
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: (currency: Currency) => setDeleteConfirm({ isOpen: true, id: currency.id }),
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Currencies</h1>
<p className="text-muted-foreground mt-1">Manage exchange rates and display currencies</p>
</div>
<div className="flex gap-3">
<ActionButton
icon={RefreshCw}
variant="secondary"
onClick={() => syncRatesMutation.mutate()}
loading={syncRatesMutation.isPending}
>
Sync Rates
</ActionButton>
<ActionButton
icon={Plus}
onClick={() => {
setError(null);
setEditingCurrency(null);
setCurrencyForm({
code: '',
name: '',
symbol: '',
baseCurrencyCode: 'ETB',
exchangeRate: '',
});
setShowModal(true);
}}
>
Add Currency
</ActionButton>
<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>
<ActionButton
icon={RefreshCw}
variant="secondary"
onClick={() => syncMutation.mutate()}
loading={syncMutation.isPending}
>
Sync Rates
</ActionButton>
</div>
{error && !editingRate && (
<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="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="p-4 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-900/10 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="text-sm text-blue-600 dark:text-blue-400 font-medium">Total Currencies</div>
<div className="text-2xl font-bold text-blue-900 dark:text-blue-200 mt-2">
{currenciesArray.length}
<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>
<div className="p-4 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-900/10 rounded-lg border border-green-200 dark:border-green-800">
<div className="text-sm text-green-600 dark:text-green-400 font-medium">Active</div>
<div className="text-2xl font-bold text-green-900 dark:text-green-200 mt-2">
{currenciesArray.filter((c: Currency) => c.isActive).length}
</div>
</div>
<div className="p-4 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-900/10 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="text-sm text-purple-600 dark:text-purple-400 font-medium">Base Currency</div>
<div className="text-2xl font-bold text-purple-900 dark:text-purple-200 mt-2">ETB</div>
</div>
<div className="p-4 bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-900/10 rounded-lg border border-orange-200 dark:border-orange-800">
<div className="text-sm text-orange-600 dark:text-orange-400 font-medium">Last Sync</div>
<div className="text-lg font-bold text-orange-900 dark:text-orange-200 mt-2">
{currenciesArray.length > 0
? new Date(currenciesArray[0]?.updatedAt).toLocaleDateString()
: 'N/A'}
</div>
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : currenciesArray.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No currencies configured. Click "Add Currency" to create one.</p>
</div>
) : (
<DataTable
data={currenciesArray}
columns={columns}
actions={actions}
loading={false}
emptyMessage="No currencies found."
/>
)}
);
})}
</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}
columns={columns}
actions={actions}
loading={false}
emptyMessage="No exchange rates configured."
/>
)}
</div>
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-3">Currency Management</h3>
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
<li>
<strong>Base Currency:</strong> All exchange rates are calculated relative to this currency (typically ETB)
</li>
<li>
<strong>Exchange Rate:</strong> How many units of the currency equal 1 unit of the base currency
</li>
<li>
<strong>Display Currencies:</strong> Configure which currencies customers can view prices in
</li>
<li>
<strong>Sync Rates:</strong> Automatically update exchange rates from external sources
</li>
</ul>
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-sm text-blue-800 dark:text-blue-300 space-y-1">
<p className="font-semibold text-blue-900 dark:text-blue-200 mb-2">How it works</p>
<p> ETB is the transaction currency all fares are stored in ETB minor units (1 ETB = 100 minor)</p>
<p> DJF and USD rates are used to display prices to passengers in their preferred currency</p>
<p> Rates apply globally; changes take effect immediately on the next booking or fare quote</p>
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, id: null })}
onConfirm={confirmDelete}
title="Delete Currency"
message="Are you sure you want to delete this currency? This action cannot be undone."
confirmText="Delete"
isDanger={true}
warning="This will remove the currency from the system."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={resetForm}
title={`${editingCurrency ? 'Edit' : 'Add'} Currency`}
size="lg"
isOpen={!!editingRate}
onClose={() => { setEditingRate(null); setError(null); }}
title={`Update Rate — ${editingRate?.code}`}
size="sm"
>
<div className="space-y-4">
{error && (
@@ -364,108 +217,38 @@ export default function CurrenciesPage() {
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Currency Code *</label>
<input
type="text"
value={currencyForm.code}
onChange={(e) => setCurrencyForm({ ...currencyForm, code: e.target.value.toUpperCase() })}
className="input w-full"
placeholder="e.g., USD"
maxLength={3}
disabled={!!editingCurrency}
required
/>
<p className="text-xs text-muted-foreground mt-1">3-letter ISO code (e.g., USD, DJF, GBP)</p>
</div>
<div>
<label className="label">Currency Name *</label>
<input
type="text"
value={currencyForm.name}
onChange={(e) => setCurrencyForm({ ...currencyForm, name: e.target.value })}
className="input w-full"
placeholder="e.g., United States Dollar"
required
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Symbol *</label>
<input
type="text"
value={currencyForm.symbol}
onChange={(e) => setCurrencyForm({ ...currencyForm, symbol: e.target.value })}
className="input w-full"
placeholder="e.g., $"
maxLength={3}
required
/>
</div>
<div>
<label className="label">Base Currency *</label>
<select
value={currencyForm.baseCurrencyCode}
onChange={(e) => setCurrencyForm({ ...currencyForm, baseCurrencyCode: e.target.value })}
className="input w-full"
disabled
>
<option value="ETB">ETB (Ethiopian Birr)</option>
<option value="USD">USD (US Dollar)</option>
<option value="DJF">DJF (Djiboutian Franc)</option>
</select>
<p className="text-xs text-muted-foreground mt-1">All rates relative to this currency</p>
</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">Exchange Rate *</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
step="0.0001"
value={currencyForm.exchangeRate}
onChange={(e) => setCurrencyForm({ ...currencyForm, exchangeRate: e.target.value })}
className="input w-full"
placeholder="e.g., 0.018"
required
/>
<div className="text-sm text-muted-foreground whitespace-nowrap">
1 {currencyForm.baseCurrencyCode} = ? {currencyForm.code}
</div>
</div>
{currencyForm.exchangeRate && parseFloat(currencyForm.exchangeRate) > 0 && (
<p className="text-xs text-muted-foreground mt-2">
1 {currencyForm.code} = {(1 / parseFloat(currencyForm.exchangeRate)).toFixed(6)} {currencyForm.baseCurrencyCode}
<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="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3 rounded-lg text-xs text-blue-800 dark:text-blue-200">
<p className="font-semibold mb-1">Exchange Rate Example:</p>
<p>If 1 ETB = 0.018 USD, enter 0.018</p>
<p>If 1 ETB = 3.25 DJF, enter 3.25</p>
</div>
<div className="flex gap-2 justify-end pt-4">
<ActionButton
variant="secondary"
onClick={resetForm}
type="button"
>
<div className="flex gap-2 justify-end pt-2">
<ActionButton variant="secondary" onClick={() => { setEditingRate(null); setError(null); }}>
Cancel
</ActionButton>
<ActionButton
onClick={handleSaveCurrency}
loading={createMutation.isPending || updateMutation.isPending}
>
{editingCurrency ? 'Update Currency' : 'Add Currency'}
<ActionButton onClick={handleSave} loading={updateMutation.isPending}>
Save Rate
</ActionButton>
</div>
</div>

View File

@@ -731,14 +731,19 @@ export default function PricingPage() {
<div>
<label className="label">Route Code (Optional)</label>
<input
type="text"
<select
value={fareForm.route}
onChange={(e) => setFareForm({ ...fareForm, route: e.target.value })}
className="input w-full"
placeholder="e.g., ADD-DJI"
/>
<p className="text-xs text-muted-foreground mt-1">e.g., ADD-DJI for full route</p>
>
<option value="">All routes</option>
{routesArray.map((route: Route) => (
<option key={route.id} value={route.code}>
{route.code} {route.name}
</option>
))}
</select>
<p className="text-xs text-muted-foreground mt-1">Scope this fare to a specific route</p>
</div>
</div>