Generate ticket, dashboard, excess luggage rate updates

This commit is contained in:
Stephanos A
2026-07-16 08:41:35 +03:00
parent 2d2fcfbda9
commit b2b31f30bb
16 changed files with 585 additions and 315 deletions

View File

@@ -66,6 +66,18 @@ function BookingsPageContent() {
}),
});
const smartAssignMutation = useMutation({
mutationFn: (bookingId: string) => bookingsApi.smartAssign(bookingId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookings'] });
setSuccessMessage('Seats assigned and ticket generated successfully');
setTimeout(() => setSuccessMessage(''), 4000);
setGenerateTicketBooking(null);
setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' });
setGenerateTicketTouched({ paymentReference: false, paymentMethod: false });
},
});
const forceConfirmMutation = useMutation({
mutationFn: ({ bookingId, data }: { bookingId: string; data: { paymentReference?: string; paymentMethod?: string; notes?: string } }) =>
bookingsApi.forceConfirm(bookingId, data),
@@ -473,25 +485,6 @@ function BookingsPageContent() {
<Field label="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
</div>
{b.paymentIntent?.status !== 'SUCCEEDED' && canManage && (
<div className="mt-3 p-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20">
<p className="text-xs text-amber-700 dark:text-amber-400 mb-2">
Payment not confirmed by vendor. If you have verified the payment was completed externally, force-confirm to confirm the booking and generate the ticket.
</p>
<ActionButton
variant="secondary"
onClick={() => forceConfirmMutation.mutate({ bookingId: b.id, data: {} })}
disabled={forceConfirmMutation.isPending}
>
{forceConfirmMutation.isPending ? 'Confirming…' : 'Force Confirm & Generate Ticket'}
</ActionButton>
{forceConfirmMutation.isError && (
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
{(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()}
</p>
)}
</div>
)}
</section>
{/* Seats / Passengers */}
@@ -517,7 +510,9 @@ function BookingsPageContent() {
</div>
{isSeats && (
<div className="text-right">
<p className="text-sm font-mono font-semibold">{p.seat?.seatNumber || p.seatId || '—'}</p>
<p className="text-sm font-mono font-semibold">
{[p.seat?.coach?.number || p.coach ? `Coach ${p.seat?.coach?.number || p.coach}` : null, p.seat?.seatNumber || p.seatNumber ? `Seat ${p.seat?.seatNumber || p.seatNumber}` : (p.seatId ? `Seat ${p.seatId.slice(0, 8)}` : '—')].filter(Boolean).join(' · ')}
</p>
<p className="text-xs text-muted-foreground">{formatCurrency(p.fareMinor ?? 0, b.currency || 'ETB')}</p>
</div>
)}
@@ -564,7 +559,7 @@ function BookingsPageContent() {
{/* Generate Ticket Modal */}
<Modal
isOpen={!!generateTicketBooking}
onClose={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); }}
onClose={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); smartAssignMutation.reset(); }}
title="Generate Ticket"
size="md"
>
@@ -625,16 +620,31 @@ function BookingsPageContent() {
/>
</div>
{forceConfirmMutation.isError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-400">
{(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()}
</div>
)}
{(forceConfirmMutation.isError || smartAssignMutation.isError) && (() => {
const e = (forceConfirmMutation.error ?? smartAssignMutation.error) as any;
const m = e?.response?.data?.message;
const msg = Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment';
const isConflict = e?.response?.status === 409 || msg?.toLowerCase().includes('seat');
const isFullyBooked = msg?.toLowerCase().includes('no available seats');
return (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-400">
<p className="font-semibold mb-1">
{isFullyBooked ? '🚫 Schedule Fully Booked' : isConflict ? '⚠️ Seat Conflict Detected' : 'Error'}
</p>
<p>{msg}</p>
</div>
);
})()}
<div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 text-sm">
<p className="font-semibold text-amber-800 dark:text-amber-300 mb-0.5">Seat auto-assignment</p>
<p className="text-amber-700 dark:text-amber-400">The system will automatically assign the best available seat and generate the ticket upon confirmation.</p>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
<ActionButton
variant="secondary"
onClick={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); }}
onClick={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); smartAssignMutation.reset(); }}
>
Cancel
</ActionButton>
@@ -642,18 +652,12 @@ function BookingsPageContent() {
onClick={() => {
setGenerateTicketTouched({ paymentReference: true, paymentMethod: true });
if (!generateTicketForm.paymentReference || !generateTicketForm.paymentMethod) return;
forceConfirmMutation.mutate({
bookingId: generateTicketBooking.id,
data: {
paymentReference: generateTicketForm.paymentReference,
paymentMethod: generateTicketForm.paymentMethod,
notes: generateTicketForm.notes || undefined,
},
});
forceConfirmMutation.reset();
smartAssignMutation.mutate(generateTicketBooking.id);
}}
disabled={forceConfirmMutation.isPending}
disabled={forceConfirmMutation.isPending || smartAssignMutation.isPending}
>
{forceConfirmMutation.isPending ? 'Generating…' : 'Confirm & Generate Ticket'}
{(forceConfirmMutation.isPending || smartAssignMutation.isPending) ? 'Generating…' : 'Confirm & Generate Ticket'}
</ActionButton>
</div>
</div>

View File

@@ -3,62 +3,103 @@
import { useQuery } from '@tanstack/react-query';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { Ticket, Users, DollarSign, AlertCircle, Calendar } from 'lucide-react';
import StatCard from '@/components/dashboard/StatCard';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight } from 'lucide-react';
import { dashboardApi } from '@/lib/api/dashboard';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { apiClient } from '@/lib/api-client';
import { formatCurrency } from '@/lib/utils';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
import Link from 'next/link';
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
// Mock data for fallback when API fails
const MOCK_STATS = {
totalBookings: 1247,
totalRevenue: 892450,
totalPassengers: 2156,
};
function StatCard({
icon, iconBg, label, total, loading, rows, href,
}: {
icon: React.ReactNode;
iconBg: string;
label: string;
total: number;
loading: boolean;
rows: { label: string; value: number; icon?: React.ReactNode; href: string }[];
href: string;
}) {
return (
<div className="card flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className={`rounded-lg ${iconBg} p-1.5`}>{icon}</div>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</span>
</div>
<p className="text-3xl font-bold text-foreground tabular-nums">
{loading ? '—' : total.toLocaleString()}
</p>
<div className="flex flex-col gap-2 border-t border-border pt-3">
{rows.map((r) => (
<div key={r.label} className="flex items-center justify-between">
<span className="flex items-center gap-1 text-xs text-muted-foreground">{r.icon}{r.label}</span>
<Link href={r.href} className="text-sm font-semibold text-foreground tabular-nums hover:text-primary transition-colors">
{loading ? '—' : r.value.toLocaleString()}
</Link>
</div>
))}
</div>
<Link href={href} className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1">
View all <ArrowRight className="h-3 w-3" />
</Link>
</div>
);
}
const MOCK_RECENT_BOOKINGS = [
{
id: '1',
bookingRef: 'BK-2024-001',
passenger: { fullName: 'John Doe' },
totalMinor: 125000,
currency: 'ETB',
status: 'CONFIRMED',
createdAt: new Date().toISOString()
},
{
id: '2',
bookingRef: 'BK-2024-002',
passenger: { fullName: 'Jane Smith' },
totalMinor: 85000,
currency: 'ETB',
status: 'PENDING',
createdAt: new Date().toISOString()
}
];
function RevenueSection({
label, bookingCount, rows, subtotal, loading, renderRow,
}: {
label: React.ReactNode;
bookingCount: number;
rows: { currency: string; totalMinor: number }[];
subtotal: number;
loading: boolean;
renderRow: (r: { currency: string; totalMinor: number }) => React.ReactNode;
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between mb-1">
<span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{loading ? '—' : bookingCount.toLocaleString()} bookings
</span>
</div>
{rows.length === 0
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
: rows.map(renderRow)}
{rows.length > 0 && (
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
<span className="text-sm font-bold text-foreground tabular-nums">{formatCurrency(subtotal, 'ETB')}</span>
</div>
)}
</div>
);
}
function DashboardPageContent() {
const { data: exchangeRates = [] } = useQuery<any[]>({
queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'),
select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []),
});
const toEtbRate = (currency: string): number | null => {
if (currency === 'ETB') return 1;
const r = exchangeRates.find((x: any) => x.fromCurrency === 'ETB' && x.toCurrency === currency);
return r ? 1 / r.rate : null;
};
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: dashboardApi.getStats,
retry: 1,
staleTime: 60000, // 1 minute
});
const { data: recentBookingsData, isLoading: bookingsLoading, error: bookingsError } = useQuery<any[]>({
queryKey: ['recent-bookings'],
queryFn: () => dashboardApi.getRecentBookings(10),
retry: 1,
});
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
queryKey: ['upcoming-trips'],
queryFn: () => dashboardApi.getUpcomingTrips(5),
queryKey: ['backoffice-stats'],
queryFn: dashboardApi.getBackofficeStats,
retry: 1,
staleTime: 60000,
});
const { data: paymentMethods } = useQuery({
@@ -67,57 +108,38 @@ function DashboardPageContent() {
retry: 1,
});
// Use actual data or fallback to mock/empty states
const displayStats = stats || (statsError ? MOCK_STATS : null);
const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData :
(bookingsError ? MOCK_RECENT_BOOKINGS : []);
const calcGrand = (rows: { currency: string; totalMinor: number }[]) =>
rows.reduce((sum, { currency, totalMinor }) => {
const rate = toEtbRate(currency);
return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
}, 0);
const bookingColumns = [
{ key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference },
{
key: 'passenger',
label: 'Passenger',
render: (item: any) => {
if (item.passenger?.fullName) {
return item.passenger.fullName;
}
if (item.contactEmail) {
return item.contactEmail;
}
if (item.contactPhone) {
return item.contactPhone;
}
return 'N/A';
}
},
{ key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
{
key: 'status',
label: 'Status',
render: (item: any) => (
<Badge variant="status" status={item.status}>
{item.status}
</Badge>
)
},
{ key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) },
];
const normalRows = stats?.revenueByCurrency ?? [];
const packageRows = stats?.packageRevenueByCurrency ?? [];
const normalGrand = calcGrand(normalRows);
const packageGrand = calcGrand(packageRows);
const overallGrand = normalGrand + packageGrand;
const tripColumns = [
{ key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name },
{ key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name}${item.destinationStation?.name || item.destination?.name}` },
{ key: 'departure', label: 'Departure', render: (item: any) => formatDateTime(item.departureAt) },
{ key: 'seats', label: 'Seats', render: (item: any) => `${item.availableSeats || 0}/${item.totalSeats || 0}` },
{
key: 'status',
label: 'Status',
render: (item: any) => (
<Badge variant="status" status={item.status}>
{item.status}
</Badge>
)
},
];
const renderRevenueRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => {
const rate = toEtbRate(currency);
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
return (
<div key={currency} className="flex items-center justify-between rounded-md bg-muted/20 px-3 py-2">
<div className="flex items-center gap-1.5">
<Banknote className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{currency}</span>
</div>
<span className="text-sm font-semibold text-foreground tabular-nums">
{formatCurrency(totalMinor, currency)}
{currency !== 'ETB' && etbMinor !== null && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
({formatCurrency(etbMinor, 'ETB')})
</span>
)}
</span>
</div>
);
};
return (
<div className="space-y-6 p-6">
@@ -126,43 +148,105 @@ function DashboardPageContent() {
<p className="text-muted-foreground mt-1">Welcome back! Here&apos;s your operational summary.</p>
</div>
{/* Error Alert */}
{(statsError || bookingsError) && (
{statsError && (
<div className="rounded-lg border border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950/30 p-4">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
<div>
<h3 className="font-semibold text-orange-800 dark:text-orange-200">
Some data may be outdated
</h3>
<p className="text-sm text-orange-700 dark:text-orange-300">
Unable to fetch live data. Showing cached or sample information.
</p>
<h3 className="font-semibold text-orange-800 dark:text-orange-200">Some data may be outdated</h3>
<p className="text-sm text-orange-700 dark:text-orange-300">Unable to fetch live data. Showing cached or sample information.</p>
</div>
</div>
</div>
)}
{/* Primary Metrics */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{/* Stat cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard
title="Total Bookings"
value={statsLoading ? '...' : (displayStats?.totalBookings || 0).toLocaleString()}
icon={Ticket}
color="blue"
icon={<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
iconBg="bg-blue-100 dark:bg-blue-900/30"
label="Bookings"
total={stats?.totalBookings ?? 0}
loading={statsLoading}
href="/bookings"
rows={[
{ label: 'Regular', value: stats?.totalNormalBookings ?? 0, href: '/bookings' },
{ label: 'Package', value: stats?.totalPackageBookings ?? 0, href: '/package-bookings' },
]}
/>
<StatCard
title="Total Revenue"
value={statsLoading ? '...' : formatCurrency(displayStats?.totalRevenue || 0, 'ETB')}
icon={DollarSign}
color="green"
/>
<StatCard
title="Total Passengers"
value={statsLoading ? '...' : (displayStats?.totalPassengers || 0).toLocaleString()}
icon={Users}
color="purple"
icon={<Ticket className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />}
iconBg="bg-emerald-100 dark:bg-emerald-900/30"
label="Tickets"
total={stats?.totalTickets ?? 0}
loading={statsLoading}
href="/tickets"
rows={[
{ label: 'Regular', value: stats?.totalNormalTickets ?? 0, href: '/tickets' },
{ label: 'Package', value: stats?.totalPackageTickets ?? 0, href: '/tickets' },
]}
/>
{/* Revenue card */}
<div className="card flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
<Banknote className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Revenue</span>
</div>
{statsLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : (
<>
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
{formatCurrency(overallGrand, 'ETB')}
</p>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Regular</p>
<p className="text-sm font-semibold text-foreground tabular-nums">{formatCurrency(normalGrand, 'ETB')}</p>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Package</p>
<p className="text-sm font-semibold text-foreground tabular-nums">{formatCurrency(packageGrand, 'ETB')}</p>
</div>
</div>
<Link href="/payments" className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1">
View payments <ArrowRight className="h-3 w-3" />
</Link>
</>
)}
</div>
</div>
{/* Revenue breakdown */}
<div className="card">
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">Revenue Breakdown</h2>
{statsLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : !normalRows.length && !packageRows.length ? (
<p className="text-muted-foreground text-sm">No revenue data yet.</p>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<RevenueSection
label="Regular"
bookingCount={stats?.totalNormalBookings ?? 0}
rows={normalRows}
subtotal={normalGrand}
loading={statsLoading}
renderRow={renderRevenueRow}
/>
<RevenueSection
label="Package"
bookingCount={stats?.totalPackageBookings ?? 0}
rows={packageRows}
subtotal={packageGrand}
loading={statsLoading}
renderRow={renderRevenueRow}
/>
</div>
)}
</div>
{/* Payment Methods Distribution */}
@@ -171,16 +255,8 @@ function DashboardPageContent() {
<h2 className="mb-4 text-lg font-semibold text-foreground">Payment Methods Distribution</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={paymentMethods}
dataKey="count"
nameKey="method"
cx="50%"
cy="50%"
outerRadius={80}
label
>
{paymentMethods.map((entry, index) => (
<Pie data={paymentMethods} dataKey="count" nameKey="method" cx="50%" cy="50%" outerRadius={80} label>
{paymentMethods.map((_: any, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
@@ -189,35 +265,6 @@ function DashboardPageContent() {
</ResponsiveContainer>
</div>
)}
{/* Recent Bookings */}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<Ticket className="h-5 w-5" />
Recent Bookings
</h2>
<DataTable
data={recentBookings}
columns={bookingColumns}
loading={bookingsLoading}
emptyMessage="No recent bookings found"
/>
</div>
{/* Upcoming Trips */}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<Calendar className="h-5 w-5" />
Upcoming Trips
</h2>
<DataTable
data={upcomingTrips || []}
columns={tripColumns}
loading={tripsLoading}
emptyMessage="No upcoming trips scheduled"
/>
</div>
</div>
);
}
@@ -228,4 +275,4 @@ export default function DashboardPage() {
<DashboardPageContent />
</PermissionGuard>
);
}
}

View File

@@ -7,7 +7,7 @@ import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { excessBaggageApi } from '@/lib/api';
import { excessBaggageApi, apiClient } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
@@ -34,6 +34,15 @@ export default function ExcessBaggagePage() {
const [resendSuccess, setResendSuccess] = useState(false);
const [resendError, setResendError] = useState<string | null>(null);
const { data: allowancesData } = useQuery({
queryKey: ['baggage-allowances'],
queryFn: () => apiClient.get<any>('/agents/excess-baggage/allowances'),
});
const allowances: any[] = Array.isArray(allowancesData)
? allowancesData
: (allowancesData as any)?.items ?? (allowancesData as any)?.data ?? [];
const excessRate = allowances[0] ?? null;
const { data, isLoading } = useQuery({
queryKey: ['excess-baggage', filters],
queryFn: () => excessBaggageApi.getAll({
@@ -229,58 +238,76 @@ export default function ExcessBaggagePage() {
Logging as agent: <span className="font-semibold text-foreground">{user.fullName}</span>
</div>
)}
<div>
<label className="label">Booking ID</label>
<input
className="input"
placeholder="Booking UUID"
value={logForm.bookingId}
onChange={(e) => setLogForm({ ...logForm, bookingId: e.target.value })}
/>
</div>
<div>
<label className="label">Excess Weight (kg)</label>
<input
type="number"
min="1"
className="input"
placeholder="e.g. 5"
value={logForm.excessWeightKg}
onChange={(e) => setLogForm({ ...logForm, excessWeightKg: e.target.value })}
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={logForm.collectCash}
onChange={(e) => setLogForm({ ...logForm, collectCash: e.target.checked })}
/>
Collect cash now (no payment link sent)
</label>
{!logForm.collectCash && (
<p className="text-xs text-muted-foreground">
A payment link will be sent to the passenger's email and phone on file.
</p>
{!excessRate ? (
<div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 text-sm text-amber-800 dark:text-amber-200">
No excess luggage rate configured. Please set a rate in Tariff Rates before logging.
</div>
) : (
<>
<div className="rounded-lg bg-muted/50 px-3 py-2 text-sm">
Rate: <span className="font-semibold">{(excessRate.excessFeePerKg / 100).toFixed(2)} ETB/kg</span>
</div>
<div>
<label className="label">Booking ID</label>
<input
className="input"
placeholder="Booking UUID"
value={logForm.bookingId}
onChange={(e) => setLogForm({ ...logForm, bookingId: e.target.value })}
/>
</div>
<div>
<label className="label">Excess Weight (kg)</label>
<input
type="number"
min="1"
className="input"
placeholder="e.g. 5"
value={logForm.excessWeightKg}
onChange={(e) => setLogForm({ ...logForm, excessWeightKg: e.target.value })}
/>
</div>
{logForm.excessWeightKg && (
<p className="text-xs text-muted-foreground">
Estimated charge: <span className="font-semibold">{((excessRate.excessFeePerKg / 100) * parseInt(logForm.excessWeightKg || '0')).toFixed(2)} ETB</span>
</p>
)}
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={logForm.collectCash}
onChange={(e) => setLogForm({ ...logForm, collectCash: e.target.checked })}
/>
Collect cash now (no payment link sent)
</label>
{!logForm.collectCash && (
<p className="text-xs text-muted-foreground">
A payment link will be sent to the passenger's email and phone on file.
</p>
)}
</>
)}
{logError && <p className="text-sm text-red-600 dark:text-red-400">{logError}</p>}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setLogModal(false)}>Cancel</ActionButton>
<ActionButton
loading={logMutation.isPending}
onClick={() => {
if (!logForm.bookingId.trim() || !logForm.excessWeightKg) {
setLogError('Booking ID and excess weight are required');
return;
}
logMutation.mutate({
bookingId: logForm.bookingId.trim(),
excessWeightKg: parseInt(logForm.excessWeightKg),
collectCash: logForm.collectCash,
});
}}
>
{logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'}
</ActionButton>
{excessRate && (
<ActionButton
loading={logMutation.isPending}
onClick={() => {
if (!logForm.bookingId.trim() || !logForm.excessWeightKg) {
setLogError('Booking ID and excess weight are required');
return;
}
logMutation.mutate({
bookingId: logForm.bookingId.trim(),
excessWeightKg: parseInt(logForm.excessWeightKg),
collectCash: logForm.collectCash,
});
}}
>
{logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'}
</ActionButton>
)}
</div>
</div>
</Modal>

View File

@@ -663,10 +663,6 @@ export default function SeatsPage() {
<div className="w-5 h-5 rounded bg-gray-500"></div>
<span className="text-sm text-muted-foreground">Blocked</span>
</div>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded bg-orange-500"></div>
<span className="text-sm text-muted-foreground">Under Maintenance</span>
</div>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded border-2 border-dashed border-gray-400"></div>
<span className="text-sm text-muted-foreground">Removed</span>

View File

@@ -26,20 +26,19 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
const handleSave = async () => {
setError(null);
if (!form.seatClassId || !form.maxWeightKg || !form.maxPiecesCount || !form.excessFeePerKg) {
setError('All fields are required'); return;
if (!form.excessFeePerKg) {
setError('Excess fee per kg is required'); return;
}
const payload = {
seatClassId: form.seatClassId,
maxWeightKg: parseInt(form.maxWeightKg),
maxPiecesCount: parseInt(form.maxPiecesCount),
excessFeePerKg: Math.round(parseFloat(form.excessFeePerKg) * 100),
};
const feeMinor = Math.round(parseFloat(form.excessFeePerKg) * 100);
try {
if (editing) {
await update.mutateAsync({ id: editing.id, ...payload });
await update.mutateAsync({ id: editing.id, excessFeePerKg: feeMinor });
} else {
await create.mutateAsync(payload);
// Create a rule for every seat class that doesn't already have one
const existingClassIds = new Set(allowances.map((a: BaggageAllowance) => a.seatClassId));
const missing = allClasses.filter(sc => !existingClassIds.has(sc.id));
if (!missing.length) { setError('All seat classes already have a rule. Use Edit to update.'); return; }
await Promise.all(missing.map(sc => create.mutateAsync({ seatClassId: sc.id, excessFeePerKg: feeMinor })));
}
resetForm();
onClose();
@@ -58,9 +57,7 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
<DataTable
data={allowances}
columns={[
{ key: 'seatClass', label: 'Seat Class', render: (a: BaggageAllowance) => <span className="font-medium">{a.seatClass?.name ?? a.seatClassId}</span> },
{ key: 'maxWeightKg', label: 'Free Allowance', render: (a: BaggageAllowance) => <span>{a.maxWeightKg} kg, {a.maxPiecesCount} pcs</span> },
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: BaggageAllowance) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
{ key: 'excessFeePerKg', label: 'Fare per kg (ETB)', render: (a: BaggageAllowance) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
]}
actions={[
{
@@ -74,36 +71,18 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (a: BaggageAllowance) => setDeleteConfirm({ isOpen: true, id: a.id }) },
]}
loading={false}
emptyMessage='No baggage allowance rules defined. Click "Add Allowance Rule" to create one.'
emptyMessage='No excess luggage tariff rates defined. Click "Add Luggage Rate" to create one.'
/>
)}
<Modal
isOpen={isOpen || !!editing}
onClose={() => { resetForm(); onClose(); }}
title={editing ? 'Edit Allowance Rule' : 'Add Allowance Rule'}
title={editing ? 'Edit Excess Luggage Rate' : 'Add Excess Luggage Rate'}
size="md"
>
<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">Seat Class *</label>
<select value={form.seatClassId} onChange={e => setForm({ ...form, seatClassId: e.target.value })} className="input w-full" disabled={!!editing}>
<option value="">Select seat class...</option>
{allClasses.map(sc => <option key={sc.id} value={sc.id}>{sc.name}</option>)}
</select>
{editing && <p className="text-xs text-muted-foreground mt-1">Seat class cannot be changed. Delete and recreate to change.</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Free Allowance (kg) *</label>
<input type="number" min="0" className="input w-full" placeholder="e.g. 20" value={form.maxWeightKg} onChange={e => setForm({ ...form, maxWeightKg: e.target.value })} />
</div>
<div>
<label className="label">Max Pieces *</label>
<input type="number" min="1" className="input w-full" placeholder="e.g. 2" value={form.maxPiecesCount} onChange={e => setForm({ ...form, maxPiecesCount: e.target.value })} />
</div>
</div>
<div>
<label className="label">Excess Fee per kg (ETB) *</label>
<input type="number" min="0" step="0.01" className="input w-full" placeholder="e.g. 50.00" value={form.excessFeePerKg} onChange={e => setForm({ ...form, excessFeePerKg: e.target.value })} />

View File

@@ -31,4 +31,7 @@ export const bookingsApi = {
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
apiClient.post(`/payments/${bookingId}/force-confirm`, data),
smartAssign: (bookingId: string) =>
apiClient.post(`/tickets/smart-assign/${bookingId}`, {}),
};

View File

@@ -2,6 +2,21 @@ import { apiClient } from '@/lib/api-client';
import { DashboardStats, RevenueData } from '@/types';
export const dashboardApi = {
getBackofficeStats: async () => {
const response = await apiClient.get<{
totalBookings: number;
totalNormalBookings: number;
totalPackageBookings: number;
totalTickets: number;
totalNormalTickets: number;
totalPackageTickets: number;
totalPassengers: number;
revenueByCurrency: { currency: string; totalMinor: number }[];
packageRevenueByCurrency: { currency: string; totalMinor: number }[];
}>('/dashboard/backoffice-stats');
return response;
},
getStats: async () => {
try {
// Fetch bookings and passengers data in parallel

View File

@@ -46,6 +46,8 @@ export const bookingsApi = {
checkUsage: (id: string) => apiClient.get<any>(`/bookings/${id}/usage`),
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
apiClient.post<any>(`/payments/${bookingId}/force-confirm`, data),
smartAssign: (bookingId: string) =>
apiClient.post<any>(`/tickets/smart-assign/${bookingId}`, {}),
};
// Passengers API

View File

@@ -4,8 +4,9 @@ export const formatCurrency = (amount: number, currency: string = 'ETB'): string
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
currencyDisplay: 'code',
minimumFractionDigits: 2,
}).format(amount / 100);
}).format(amount / 100).replace(/^([A-Z]{3})/, '$1 ').trim();
};
export const formatDate = (date?: string | Date | null, formatStr: string = 'MMM dd, yyyy'): string => {