Merge branch 'dev' into reschedule

This commit is contained in:
Abubeker Yasin
2026-08-28 16:36:51 +03:00
637 changed files with 53614 additions and 8442 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair, Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
@@ -13,7 +13,7 @@ import { usePagination } from '@/lib/use-pagination';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
type Tab = 'types' | 'coaches' | 'utilization';
type Tab = 'types' | 'coaches';
const getBedLabel = (bedPosition: string | null): string => {
if (bedPosition === 'upper') return 'U';
@@ -152,8 +152,6 @@ function CoachesPageContent() {
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, item: null });
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const [isBedCoach, setIsBedCoach] = useState(false);
const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false);
const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
const queryClient = useQueryClient();
@@ -169,12 +167,6 @@ function CoachesPageContent() {
queryFn: () => fleetApi.getCoaches({}),
});
const { data: utilizationData, isLoading: utilizationLoading } = useQuery({
queryKey: ['coach-utilization'],
queryFn: () => apiClient.get<any[]>('/fleet/coaches/utilization'),
enabled: activeTab === 'utilization',
});
// Coach Type Mutations
const createCoachTypeMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
@@ -531,16 +523,6 @@ function CoachesPageContent() {
>
Coaches
</button>
<button
onClick={() => { setActiveTab('utilization'); setSearch(''); }}
className={`px-4 py-3 font-medium transition-colors ${
activeTab === 'utilization'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Utilization Report
</button>
</div>
{/* Coach Types Tab */}
@@ -591,105 +573,6 @@ function CoachesPageContent() {
</div>
)}
{/* Utilization Tab */}
{activeTab === 'utilization' && (() => {
const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || [];
const UTIL_COLS = [
{ key: 'number', label: 'Coach' },
{ key: 'coachType', label: 'Type' },
{ key: 'totalSeats', label: 'Total Seats' },
{ key: 'availableSeats', label: 'Available' },
{ key: 'bookedSeats', label: 'Booked' },
{ key: 'blockedSeats', label: 'Blocked' },
{ key: 'maintenanceSeats', label: 'Maintenance' },
{ key: 'utilizationRate', label: 'Utilization %' },
{ key: 'totalAssignments', label: 'Assignments' },
{ key: 'totalBookings', label: 'Total Bookings' },
];
const doExport = () => {
if (!rows.length) { alert('No data to export'); return; }
const headers = UTIL_COLS.map(c => c.label);
const exportRows = rows.map((r: any) => UTIL_COLS.map(({ key }) => String(r[key] ?? '')));
const dateStr = new Date().toISOString().split('T')[0];
if (exportUtilFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write(`<!DOCTYPE html><html><head><title>Coach Utilization Report</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>`);
w.document.write(`<h2>Coach Utilization Report — ${dateStr}</h2><table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
exportRows.forEach((r: string[]) => { w.document.write(`<tr>${r.map((v: string) => `<td>${v}</td>`).join('')}</tr>`); });
w.document.write('</tbody></table></body></html>');
w.document.close(); w.print();
} else if (exportUtilFormat === 'excel') {
const tsv = [headers.join('\t'), ...exportRows.map((r: string[]) => r.join('\t'))].join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url);
} else {
const csv = [headers.map(h => `"${h}"`).join(','), ...exportRows.map((r: string[]) => r.map((v: string) => `"${v.replace(/"/g, '""')}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url);
}
setExportUtilModalOpen(false);
};
return (
<div className="pt-6 space-y-4">
<div className="flex justify-end">
<ActionButton icon={Download} variant="secondary" onClick={() => setExportUtilModalOpen(true)}>Export</ActionButton>
</div>
<DataTable
columns={[
{ key: 'number', label: 'Coach', render: (r: any) => <span className="font-medium">{r.number}</span> },
{ key: 'coachType', label: 'Type', render: (r: any) => <span className="text-sm">{r.coachType || 'N/A'}</span> },
{ key: 'totalSeats', label: 'Total Seats', render: (r: any) => <span className="font-mono">{r.totalSeats}</span> },
{ key: 'availableSeats', label: 'Available', render: (r: any) => <span className="font-mono text-green-600">{r.availableSeats}</span> },
{ key: 'bookedSeats', label: 'Booked', render: (r: any) => <span className="font-mono text-red-600">{r.bookedSeats}</span> },
{ key: 'blockedSeats', label: 'Blocked', render: (r: any) => <span className="font-mono text-gray-500">{r.blockedSeats}</span> },
{ key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => <span className="font-mono text-orange-500">{r.maintenanceSeats}</span> },
{
key: 'utilizationRate', label: 'Utilization',
render: (r: any) => (
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${r.utilizationRate}%` }} />
</div>
<span className="font-mono text-sm">{r.utilizationRate}%</span>
</div>
),
},
{ key: 'totalAssignments', label: 'Assignments', render: (r: any) => <span className="font-mono">{r.totalAssignments}</span> },
{ key: 'totalBookings', label: 'Total Bookings', render: (r: any) => <span className="font-mono font-semibold">{r.totalBookings}</span> },
]}
data={rows}
actions={[]}
loading={utilizationLoading}
emptyMessage="No coach utilization data available"
/>
<Modal isOpen={exportUtilModalOpen} onClose={() => setExportUtilModalOpen(false)} title="Export Utilization Report" size="sm">
<div className="space-y-4">
<div>
<p className="text-sm font-medium mb-2">Export Format</p>
<div className="flex gap-3">
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="utilExportFormat" value={fmt} checked={exportUtilFormat === fmt} onChange={() => setExportUtilFormat(fmt)} className="w-4 h-4" />
<span className="text-sm font-medium capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportUtilModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={doExport}>Export</ActionButton>
</div>
</div>
</Modal>
</div>
);
})()}
</div>
{/* Delete Confirmation */}

View File

@@ -1,13 +1,13 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, RefreshCw, Send, Trash2 } from 'lucide-react';
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, apiClient } from '@/lib/api';
import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
@@ -28,7 +28,7 @@ export default function ExcessBaggagePage() {
const [waiveReason, setWaiveReason] = useState('');
const [waiveError, setWaiveError] = useState<string | null>(null);
const [logModal, setLogModal] = useState(false);
const [logForm, setLogForm] = useState({ bookingReference: '', excessWeightKg: '', collectCash: false });
const [logForm, setLogForm] = useState({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' });
const [logError, setLogError] = useState<string | null>(null);
const [resendModal, setResendModal] = useState<any>(null);
const [resendSuccess, setResendSuccess] = useState(false);
@@ -54,12 +54,36 @@ export default function ExcessBaggagePage() {
}),
});
useEffect(() => {
if (!logModal) return;
const bookingRef = logForm.bookingReference.trim();
if (!bookingRef) {
setLogForm((prev) => ({ ...prev, paymentPhone: '' }));
return;
}
const timeout = setTimeout(async () => {
try {
const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 });
const items = response?.items ?? [];
const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0];
if (!match) return;
const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? '';
setLogForm((prev) => ({ ...prev, paymentPhone: prev.paymentPhone || nextPhone }));
} catch {
// Ignore lookup failures: the agent can still override the number manually.
}
}, 250);
return () => clearTimeout(timeout);
}, [logForm.bookingReference, logModal]);
const logMutation = useMutation({
mutationFn: (data: any) => excessBaggageApi.logCharge(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['excess-baggage'] });
setLogModal(false);
setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false });
setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' });
setLogError(null);
},
onError: (e: any) => setLogError(e?.response?.data?.message || e?.message || 'Failed to log charge'),
@@ -178,7 +202,7 @@ export default function ExcessBaggagePage() {
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
</div>
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); }}>
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}>
Log Excess Luggage
</ActionButton>
</div>
@@ -256,6 +280,15 @@ export default function ExcessBaggagePage() {
onChange={(e) => setLogForm({ ...logForm, bookingReference: e.target.value })}
/>
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input
className="input"
placeholder="e.g. +251911223344"
value={logForm.paymentPhone}
onChange={(e) => setLogForm({ ...logForm, paymentPhone: e.target.value })}
/>
</div>
<div>
<label className="label">Excess Weight (kg)</label>
<input
@@ -282,7 +315,7 @@ export default function ExcessBaggagePage() {
</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.
The payment link will be sent to the phone above and the booking's saved email when present.
</p>
)}
</>
@@ -302,6 +335,7 @@ export default function ExcessBaggagePage() {
bookingReference: logForm.bookingReference.trim(),
excessWeightKg: parseInt(logForm.excessWeightKg),
collectCash: logForm.collectCash,
contactPhone: logForm.paymentPhone.trim() || undefined,
});
}}
>

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function GroupBookingLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import {
TicketCheck, Users, TrendingUp, ShieldCheck, MailCheck,
} from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
import { getErrorMessage } from '@/lib/api-client';
const EDR_GREEN = 'rgb(20, 113, 76)';
@@ -52,11 +53,11 @@ export default function LoginPage() {
await login(identifier.trim(), password);
router.push('/dashboard');
} catch (err: any) {
const msg = err.message || err.response?.data?.message || '';
if (msg === 'ACCESS_DENIED') {
const rawMessage = err.response?.data?.message;
if (rawMessage === 'ACCESS_DENIED') {
setError('This account does not have back-office access. Contact your administrator.');
} else {
setError(err.response?.data?.message || msg || 'Invalid credentials. Please try again.');
setError(getErrorMessage(err, 'Invalid credentials. Please try again.'));
}
} finally {
setLoading(false);
@@ -71,11 +72,11 @@ export default function LoginPage() {
await iamAuthApi.forgotPassword(forgotIdentifier.trim());
setForgotSent(true);
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '';
const rawMessage = err.response?.data?.message;
setForgotError(
msg === 'user_not_found'
rawMessage === 'user_not_found'
? 'No account found with that email or phone number.'
: msg || 'Failed to send the reset link. Please try again.'
: getErrorMessage(err, 'Failed to send the reset link. Please try again.')
);
} finally {
setForgotLoading(false);

View File

@@ -10,6 +10,7 @@ import Modal from '@/components/ui/Modal';
import Pagination from '@/components/ui/Pagination';
import { packagesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { getErrorMessage } from '@/lib/api-client';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
@@ -116,7 +117,7 @@ export default function PackageBookingsPage() {
<div className="card">
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
Error: {(error as any)?.response?.data?.message || (error as any)?.message || String(error)}
Error: {getErrorMessage(error)}
</div>
)}
<div className="flex flex-wrap gap-3 mb-4">

View File

@@ -2,15 +2,21 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2 } from 'lucide-react';
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2, ImagePlus, ImageOff, X } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal';
import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api';
import { getErrorMessage } from '@/lib/api-client';
import { formatDateTime, formatCurrency } from '@/lib/utils';
// Mirrors the backend's own limits (packages/package-image-upload.options.ts) so a bad file is
// rejected instantly client-side instead of round-tripping to the server first.
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const toLocal = (iso?: string) => {
if (!iso) return '';
const d = new Date(iso);
@@ -48,6 +54,15 @@ export default function PackagesPage() {
const [deletePackageConfirm, setDeletePackageConfirm] = useState<any>(null);
const [deletePackageError, setDeletePackageError] = useState<string | null>(null);
const [deletePackageCascade, setDeletePackageCascade] = useState(false);
// Image upload: `imageFile`/`imagePreviewUrl` track a newly-selected-but-not-yet-uploaded file
// (local object URL preview); `existingImageUrl` is the package's current server-side image
// when editing, shown until/unless the admin picks a replacement.
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
const [existingImageUrl, setExistingImageUrl] = useState<string | null>(null);
const [imageError, setImageError] = useState<string | null>(null);
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(null);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
@@ -141,6 +156,23 @@ export default function PackagesPage() {
onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to delete tier'),
});
const uploadImageMutation = useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) => packagesApi.uploadImage(id, file),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setImageUploadError(null); },
onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to upload package image')),
});
const removeImageMutation = useMutation({
mutationFn: (id: string) => packagesApi.removeImage(id),
onSuccess: (updated: any) => {
queryClient.invalidateQueries({ queryKey: ['packages'] });
setRemoveImageConfirm(null);
setExistingImageUrl(updated?.imageUrl ?? null);
setViewPackage((prev: any) => (prev && prev.id === updated?.id ? { ...prev, imageUrl: null } : prev));
},
onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to remove package image')),
});
const openEditTier = (tier: any) => {
setEditingTier(tier);
setTierForm({ seatClassId: tier.seatClassId ?? '', seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) });
@@ -163,13 +195,29 @@ export default function PackagesPage() {
}
};
// Deliberately does not touch imageUploadError — that's shown in a page-level banner (outside
// this modal) precisely because it can still be set after the modal has already auto-closed
// (see handleSubmit), and clearing it here would wipe it out before the user ever sees it.
const resetImageSelection = () => {
setImageFile(null);
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
setImagePreviewUrl(null);
setImageError(null);
};
const openCreate = () => {
setForm(emptyForm);
setEditingId(null);
resetImageSelection();
setImageUploadError(null);
setExistingImageUrl(null);
setModalMode('create');
};
const openEdit = (pkg: any) => {
resetImageSelection();
setImageUploadError(null);
setExistingImageUrl(pkg.imageUrl ?? null);
setForm({
code: pkg.code ?? '',
name: pkg.name ?? '',
@@ -216,11 +264,23 @@ export default function PackagesPage() {
validUntil: form.validUntil,
priceTiers: [],
};
// The image is uploaded as a separate follow-up call (the DTO here carries no image field —
// see packages.service.ts's uploadImage) so it must run after the package itself exists.
let targetId = editingId;
if (modalMode === 'edit' && editingId) {
await updateMutation.mutateAsync({ id: editingId, data: payload });
} else {
await createMutation.mutateAsync(payload);
const created = await createMutation.mutateAsync(payload);
targetId = created?.id ?? null;
}
if (imageFile && targetId) {
try {
await uploadImageMutation.mutateAsync({ id: targetId, file: imageFile });
} catch {
// surfaced via imageUploadError banner — the package itself was already saved successfully
}
}
resetImageSelection();
};
const field = (key: keyof typeof form) => ({
@@ -229,6 +289,24 @@ export default function PackagesPage() {
setForm((f) => ({ ...f, [key]: e.target.value })),
});
const handleImageFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ''; // allow re-selecting the same file after a validation error
if (!file) return;
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
setImageError('Image must be JPEG, PNG, WEBP, or GIF.');
return;
}
if (file.size > MAX_IMAGE_BYTES) {
setImageError(`Image must be ${Math.round(MAX_IMAGE_BYTES / (1024 * 1024))}MB or smaller.`);
return;
}
setImageError(null);
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
setImageFile(file);
setImagePreviewUrl(URL.createObjectURL(file));
};
const scheduleLabel = (s: any) => {
const from = s.originStation?.name ?? s.originStationId ?? '?';
const to = s.destinationStation?.name ?? s.destinationStationId ?? '?';
@@ -237,7 +315,19 @@ export default function PackagesPage() {
};
const columns = [
{ key: 'code', label: 'Package',
{
key: 'image', label: '',
render: (p: any) => (
p.imageUrl ? (
<img src={p.imageUrl} alt="" className="h-10 w-10 rounded object-cover border border-border" />
) : (
<div className="h-10 w-10 rounded border border-border bg-muted flex items-center justify-center">
<ImageOff className="h-4 w-4 text-muted-foreground" />
</div>
)
),
},
{ key: 'code', label: 'Package',
render: (pkg: any) => (
<div className="text-sm">
<div>{pkg.code}</div>
@@ -299,7 +389,7 @@ export default function PackagesPage() {
},
];
const isPending = createMutation.isPending || updateMutation.isPending;
const isPending = createMutation.isPending || updateMutation.isPending || uploadImageMutation.isPending;
const allItems: any[] = data?.items || [];
const filteredItems = allItems.filter((p) => {
@@ -320,6 +410,18 @@ export default function PackagesPage() {
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton>
</div>
{/* The package itself may already be saved and this modal closed by the time an image
upload/removal fails (see handleSubmit) — surfaced here rather than inside the modal
so it's never silently lost. */}
{imageUploadError && (
<div className="flex items-center justify-between rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-300">
<span>{imageUploadError}</span>
<button type="button" className="ml-3 shrink-0" onClick={() => setImageUploadError(null)}>
<X className="h-4 w-4" />
</button>
</div>
)}
<div className="card">
<div className="mb-4 space-y-3">
<div className="flex flex-wrap gap-3">
@@ -366,6 +468,14 @@ export default function PackagesPage() {
<Modal isOpen={!!viewPackage} onClose={() => setViewPackage(null)} title="Package Details" size="lg">
{viewPackage && (
<div className="space-y-4 text-sm">
{viewPackage.imageUrl ? (
<img src={viewPackage.imageUrl} alt={viewPackage.name} className="w-full max-h-56 rounded-lg object-cover border border-border" />
) : (
<div className="w-full h-32 rounded-lg border border-dashed border-border bg-muted flex items-center justify-center gap-2 text-muted-foreground">
<ImageOff className="h-5 w-5" />
<span>No image</span>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><span className="label">Code</span><p className="font-mono font-semibold">{viewPackage.code}</p></div>
<div><span className="label">Status</span><p>{viewPackage.status}</p></div>
@@ -549,10 +659,23 @@ export default function PackagesPage() {
error={tierError ?? undefined}
/>
{/* Remove Image Confirmation */}
<ConfirmDialog
isOpen={!!removeImageConfirm}
onClose={() => setRemoveImageConfirm(null)}
onConfirm={() => removeImageMutation.mutate(removeImageConfirm.id)}
title="Remove Package Image"
message={`Remove the image for "${removeImageConfirm?.name}"? The package itself will not be deleted.`}
confirmText="Remove Image"
isDanger
isLoading={removeImageMutation.isPending}
error={imageUploadError ?? undefined}
/>
{/* Create / Edit Modal */}
<Modal
isOpen={modalMode !== null}
onClose={() => setModalMode(null)}
onClose={() => { setModalMode(null); resetImageSelection(); }}
title={modalMode === 'edit' ? 'Edit Package' : 'New Package'}
size="lg"
>
@@ -571,6 +694,40 @@ export default function PackagesPage() {
<textarea className="input" rows={2} placeholder="Optional description" {...field('description')} />
</div>
<div className="col-span-2">
<label className="label">Package Image</label>
<div className="flex items-start gap-4">
{imagePreviewUrl ? (
<img src={imagePreviewUrl} alt="Preview" className="h-24 w-24 rounded-lg object-cover border border-border" />
) : existingImageUrl ? (
<img src={existingImageUrl} alt="Current" className="h-24 w-24 rounded-lg object-cover border border-border" />
) : (
<div className="h-24 w-24 rounded-lg border border-dashed border-border bg-muted flex items-center justify-center">
<ImagePlus className="h-6 w-6 text-muted-foreground" />
</div>
)}
<div className="flex-1 space-y-2">
<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="input" onChange={handleImageFileChange} />
<p className="text-xs text-muted-foreground">JPEG, PNG, WEBP, or GIF. Max 5MB.</p>
{imageError && <p className="text-xs text-red-600 dark:text-red-400">{imageError}</p>}
{imageFile && (
<button type="button" className="text-xs text-primary underline" onClick={resetImageSelection}>
<X className="h-3 w-3 inline -mt-0.5 mr-0.5" />Clear selected file
</button>
)}
{!imageFile && modalMode === 'edit' && existingImageUrl && (
<button
type="button"
className="text-xs text-red-600 dark:text-red-400 underline block"
onClick={() => { setImageUploadError(null); setRemoveImageConfirm({ id: editingId, name: form.name }); }}
>
Remove current image
</button>
)}
</div>
</div>
</div>
<div>
<label className="label">Origin Station *</label>
<select className="input" required {...field('originStationId')}>
@@ -660,7 +817,7 @@ export default function PackagesPage() {
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton type="button" variant="secondary" onClick={() => setModalMode(null)}>Cancel</ActionButton>
<ActionButton type="button" variant="secondary" onClick={() => { setModalMode(null); resetImageSelection(); }}>Cancel</ActionButton>
<ActionButton type="submit" loading={isPending}>
{modalMode === 'edit' ? 'Update Package' : 'Create Package'}
</ActionButton>

View File

@@ -1,9 +1,10 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { PlusCircle } from 'lucide-react';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import { bookingsApi } from '@/lib/api';
import { useCreateSupplementaryCharge } from './useSupplementaryCharges';
const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER'];
@@ -14,13 +15,37 @@ interface Props {
}
export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '', paymentPhone: '' });
const [formError, setFormError] = useState<string | null>(null);
const [createSuccess, setCreateSuccess] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) return;
const bookingRef = form.bookingRef.trim();
if (!bookingRef) {
setForm((prev) => ({ ...prev, paymentPhone: '' }));
return;
}
const timeout = setTimeout(async () => {
try {
const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 });
const items = response?.items ?? [];
const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0];
if (!match) return;
const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? '';
setForm((prev) => ({ ...prev, paymentPhone: prev.paymentPhone || nextPhone }));
} catch {
// Ignore lookup failures here; the staff member can still type a phone override manually.
}
}, 300);
return () => clearTimeout(timeout);
}, [form.bookingRef, isOpen]);
const createMutation = useCreateSupplementaryCharge(() => {
setCreateSuccess('Charge created and payment link sent.');
setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '', paymentPhone: '' });
setFormError(null);
setTimeout(() => { setCreateSuccess(null); onClose(); }, 2000);
});
@@ -31,7 +56,13 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
if (!form.bookingRef.trim()) return setFormError('Booking reference is required');
if (!form.amountEtb || isNaN(amountMinor) || amountMinor <= 0) return setFormError('Enter a valid amount');
try {
await createMutation.mutateAsync({ bookingRef: form.bookingRef.trim(), amountMinor, reason: form.reason, notes: form.notes || undefined });
await createMutation.mutateAsync({
bookingRef: form.bookingRef.trim(),
amountMinor,
reason: form.reason,
notes: form.notes || undefined,
contactPhone: form.paymentPhone.trim() || undefined,
});
} catch (e: any) {
setFormError(e?.response?.data?.message ?? e?.message ?? 'Failed to create charge');
}
@@ -52,6 +83,10 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
<label className="label">Booking Reference <span className="text-red-500">*</span></label>
<input className="input" placeholder="e.g. EDR-20240001" value={form.bookingRef} onChange={(e) => setForm({ ...form, bookingRef: e.target.value })} />
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input className="input" placeholder="e.g. +251911223344" value={form.paymentPhone} onChange={(e) => setForm({ ...form, paymentPhone: e.target.value })} />
</div>
<div>
<label className="label">Amount Owed (ETB) <span className="text-red-500">*</span></label>
<input className="input" type="number" min="0.01" step="0.01" placeholder="e.g. 50.00" value={form.amountEtb} onChange={(e) => setForm({ ...form, amountEtb: e.target.value })} />
@@ -69,7 +104,7 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
</div>
<p className="text-xs text-muted-foreground">
A payment link will be sent to the passenger's registered phone/email. The link expires in 72 hours.
The payment link sends to the phone above, falling back to the booking's saved contact details if left empty. The link expires in 72 hours.
</p>
<div className="flex justify-end gap-2 pt-2 border-t border-muted">

View File

@@ -13,8 +13,14 @@ export function useSupplementaryCharges(filters: { bookingRef?: string; status?:
export function useCreateSupplementaryCharge(onSuccess: () => void) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
paymentsApi.supplementary.create(data),
mutationFn: (data: {
bookingRef: string;
amountMinor: number;
reason: string;
notes?: string;
contactPhone?: string;
contactEmail?: string;
}) => paymentsApi.supplementary.create(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['supplementary-charges'] });
onSuccess();

View File

@@ -0,0 +1,362 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Activity, BarChart3, Download, Search } from "lucide-react";
import { apiClient } from "@/lib/api-client";
import ActionButton from "@/components/ui/ActionButton";
import Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination";
interface ScheduleOption {
id: string;
label: string;
}
interface CoachUtilizationRow {
id: string;
number: string;
coachType: string | null;
status: string | null;
totalSeats: number;
availableSeats: number;
bookedSeats: number;
blockedSeats: number;
maintenanceSeats: number;
utilizationRate: number;
totalAssignments: number;
totalBookings: number;
}
export default function CoachUtilizationReportPage() {
const [scheduleId, setScheduleId] = useState("");
const [search, setSearch] = useState("");
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
queryKey: ["report-schedules-all"],
queryFn: () => apiClient.get("/reports/schedules?all=true"),
});
const { data, isLoading, isError } = useQuery<CoachUtilizationRow[]>({
queryKey: ["coach-utilization-report", scheduleId],
queryFn: () => apiClient.get(`/fleet/coaches/utilization?scheduleId=${scheduleId}`),
enabled: !!scheduleId,
});
const schedules = schedulesRaw ?? [];
const rows = data ?? [];
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return rows;
return rows.filter((row) => {
const coachType = row.coachType ?? "";
const status = row.status ?? "";
return (
row.number.toLowerCase().includes(q) ||
coachType.toLowerCase().includes(q) ||
status.toLowerCase().includes(q)
);
});
}, [rows, search]);
const { paged, page, totalPages, setPage, reset } = usePagination(filteredRows, 25);
const summary = useMemo(() => {
if (!rows.length) return null;
const totals = rows.reduce(
(acc, row) => {
acc.totalSeats += row.totalSeats;
acc.availableSeats += row.availableSeats;
acc.bookedSeats += row.bookedSeats;
acc.blockedSeats += row.blockedSeats;
acc.maintenanceSeats += row.maintenanceSeats;
acc.totalBookings += row.totalBookings;
acc.totalAssignments += row.totalAssignments;
return acc;
},
{
totalSeats: 0,
availableSeats: 0,
bookedSeats: 0,
blockedSeats: 0,
maintenanceSeats: 0,
totalBookings: 0,
totalAssignments: 0,
},
);
const avgUtilization = rows.length
? rows.reduce((sum, row) => sum + row.utilizationRate, 0) / rows.length
: 0;
return {
totalCoaches: rows.length,
totalSeats: totals.totalSeats,
availableSeats: totals.availableSeats,
bookedSeats: totals.bookedSeats,
blockedSeats: totals.blockedSeats,
maintenanceSeats: totals.maintenanceSeats,
avgUtilization,
totalAssignments: totals.totalAssignments,
totalBookings: totals.totalBookings,
};
}, [rows]);
const doExport = () => {
if (!filteredRows.length) return;
const headers = [
"Coach",
"Type",
"Status",
"Total Seats",
"Available",
"Booked",
"Blocked",
"Maintenance",
"Utilization %",
"Assignments",
"Total Bookings",
];
const rowsCsv = filteredRows.map((row) => [
row.number,
row.coachType ?? "—",
row.status ?? "—",
String(row.totalSeats),
String(row.availableSeats),
String(row.bookedSeats),
String(row.blockedSeats),
String(row.maintenanceSeats),
`${row.utilizationRate}%`,
String(row.totalAssignments),
String(row.totalBookings),
]);
const csv = [
headers.map((header) => `"${header}"`).join(","),
...rowsCsv.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")),
].join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `coach-utilization-${scheduleId || "fleet"}-${new Date().toISOString().split("T")[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Coach Utilization Report</h1>
<p className="text-muted-foreground mt-1">
Occupancy, availability, and booking load by coach for a selected schedule.
</p>
</div>
<div className="card">
<div className="flex items-end gap-4 flex-wrap">
<div className="flex-1 min-w-72">
<label className="label">Schedule</label>
<select
className="input"
value={scheduleId}
onChange={(e) => {
setScheduleId(e.target.value);
setSearch("");
reset();
}}
disabled={loadingSchedules}
>
<option value="">
{loadingSchedules ? "Loading schedules…" : "Select a schedule…"}
</option>
{schedules.map((schedule) => (
<option key={schedule.id} value={schedule.id}>
{schedule.label}
</option>
))}
</select>
</div>
</div>
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading</p>}
{isError && <p className="text-xs text-red-500 mt-2">Failed to load coach utilization.</p>}
</div>
{!scheduleId && (
<div className="card py-16 text-center text-muted-foreground">
<Activity className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-base font-medium text-foreground">Choose a schedule to review coach occupancy</p>
<p className="text-xs mt-1">The report drills into availability, bookings, and maintenance status for each assigned coach.</p>
</div>
)}
{data && summary && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4">
<div className="card">
<p className="text-muted-foreground text-sm font-medium">Coaches</p>
<p className="text-2xl font-bold mt-2 text-foreground">{summary.totalCoaches}</p>
<p className="text-xs text-muted-foreground mt-1">Assigned coaches</p>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Total Seats</p>
<p className="text-2xl font-bold mt-2 text-foreground">{summary.totalSeats}</p>
<p className="text-xs text-muted-foreground mt-1">Across all coaches</p>
</div>
<BarChart3 className="h-8 w-8 text-emerald-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Booked</p>
<p className="text-2xl font-bold mt-2 text-rose-600 dark:text-rose-400">{summary.bookedSeats}</p>
<p className="text-xs text-muted-foreground mt-1">Occupied seats</p>
</div>
<Activity className="h-8 w-8 text-rose-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Available</p>
<p className="text-2xl font-bold mt-2 text-emerald-600 dark:text-emerald-400">{summary.availableSeats}</p>
<p className="text-xs text-muted-foreground mt-1">Open seats</p>
</div>
<Activity className="h-8 w-8 text-emerald-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Blocked</p>
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">{summary.blockedSeats}</p>
<p className="text-xs text-muted-foreground mt-1">Unavailable seats</p>
</div>
<Activity className="h-8 w-8 text-slate-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Avg Utilization</p>
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">
{summary.avgUtilization.toFixed(1)}%
</p>
<p className="text-xs text-muted-foreground mt-1">Across coach set</p>
</div>
<BarChart3 className="h-8 w-8 text-amber-500 opacity-30" />
</div>
</div>
</div>
<div className="card p-0">
<div className="flex items-center justify-between px-4 pt-4 pb-3 gap-4 flex-wrap">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Coach Details
</h3>
<div className="flex items-center gap-3 flex-wrap">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
className="input max-w-xs pl-10"
placeholder="Coach, type, or status…"
value={search}
onChange={(event) => {
setSearch(event.target.value);
reset();
}}
/>
</div>
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={!filteredRows.length}>
Export CSV
</ActionButton>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{[
"Coach",
"Type",
"Status",
"Total Seats",
"Available",
"Booked",
"Blocked",
"Maintenance",
"Utilization",
"Assignments",
"Bookings",
].map((header) => (
<th
key={header}
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap"
>
{header}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{paged.map((row) => (
<tr key={row.id} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<td className="px-4 py-3 font-medium whitespace-nowrap">{row.number}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">{row.coachType ?? "—"}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">{row.status ?? "—"}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums">{row.totalSeats}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-emerald-600 dark:text-emerald-400">{row.availableSeats}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-rose-600 dark:text-rose-400">{row.bookedSeats}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-600 dark:text-slate-400">{row.blockedSeats}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-amber-600 dark:text-amber-400">{row.maintenanceSeats}</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${Math.min(row.utilizationRate, 100)}%` }} />
</div>
<span className="font-mono text-sm">{row.utilizationRate.toFixed(1)}%</span>
</div>
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums">{row.totalAssignments}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums">{row.totalBookings}</td>
</tr>
))}
{paged.length === 0 && (
<tr>
<td colSpan={11} className="py-8 text-center text-sm text-muted-foreground">
No coach utilization rows found
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
</div>
</>
)}
{!data && !isLoading && scheduleId && (
<div className="card py-12 text-center text-muted-foreground">
No utilization data found for this schedule.
</div>
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Eye, EyeOff, ArrowRight, ArrowLeft, Loader2, CheckCircle2 } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
import { getErrorMessage } from '@/lib/api-client';
function ResetPasswordForm() {
const router = useRouter();
@@ -41,8 +42,7 @@ function ResetPasswordForm() {
setSuccess(true);
setTimeout(() => router.push('/login'), 2000);
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '';
setError(msg || 'Failed to reset password. The link may have expired — request a new one from the sign-in page.');
setError(getErrorMessage(err, 'Failed to reset password. The link may have expired — request a new one from the sign-in page.'));
} finally {
setLoading(false);
}

View File

@@ -30,9 +30,22 @@ interface Schedule {
destinationStation?: { id: string; name: string };
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
isPackageOnly?: boolean;
isGroupBookingOnly?: boolean;
liveStatus?: { delayMinutes: number } | null;
}
/** Mutually-exclusive UI view over the two independent isPackageOnly/isGroupBookingOnly flags
* the API actually stores — same pair, just presented as one choice instead of two checkboxes. */
type ScheduleVisibility = 'NORMAL' | 'PACKAGE_ONLY' | 'GROUP_ONLY';
function visibilityOf(isPackageOnly?: boolean, isGroupBookingOnly?: boolean): ScheduleVisibility {
if (isGroupBookingOnly) return 'GROUP_ONLY';
if (isPackageOnly) return 'PACKAGE_ONLY';
return 'NORMAL';
}
function visibilityFlags(v: ScheduleVisibility): { isPackageOnly: boolean; isGroupBookingOnly: boolean } {
return { isPackageOnly: v === 'PACKAGE_ONLY', isGroupBookingOnly: v === 'GROUP_ONLY' };
}
interface Train {
id: string;
name: string;
@@ -84,7 +97,7 @@ function SchedulesPageContent() {
const [bulkCoachRows, setBulkCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false });
const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({
@@ -122,6 +135,7 @@ function SchedulesPageContent() {
status: 'SCHEDULED',
coachIds: [] as string[],
isPackageOnly: false,
isGroupBookingOnly: false,
});
const [filters, setFilters] = useState({
@@ -188,7 +202,7 @@ function SchedulesPageContent() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['schedules'] });
setShowAddModal(false);
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false });
setAddCoachRows([]);
setError(null);
},
@@ -288,6 +302,8 @@ function SchedulesPageContent() {
routeId: addForm.routeId,
departureAt: dep.toISOString(),
arrivalAt: arr.toISOString(),
isPackageOnly: addForm.isPackageOnly,
isGroupBookingOnly: addForm.isGroupBookingOnly,
...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }),
});
};
@@ -312,6 +328,7 @@ function SchedulesPageContent() {
arrivalAt: eatLocalToISO(editForm.arrivalAt),
status: editForm.status,
isPackageOnly: editForm.isPackageOnly,
isGroupBookingOnly: editForm.isGroupBookingOnly,
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
coachId,
positionNumber: idx + 1,
@@ -356,6 +373,7 @@ function SchedulesPageContent() {
status: schedule.status,
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
isPackageOnly: schedule.isPackageOnly ?? false,
isGroupBookingOnly: schedule.isGroupBookingOnly ?? false,
});
setError(null);
setShowEditModal(true);
@@ -494,6 +512,9 @@ function SchedulesPageContent() {
{schedule.isPackageOnly && (
<span className="edr-badge edr-badge-warning">PKG</span>
)}
{schedule.isGroupBookingOnly && (
<span className="edr-badge edr-badge-warning">GROUP</span>
)}
</div>
),
},
@@ -778,7 +799,7 @@ function SchedulesPageContent() {
<Modal
isOpen={showAddModal}
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false }); setAddCoachRows([]); setError(null); }}
title="Add Schedule"
size="xl"
>
@@ -817,6 +838,37 @@ function SchedulesPageContent() {
/>
</div>
<div className="border-t pt-4">
<label className="label mb-2">Visibility</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{([
{ value: 'NORMAL', title: 'Normal', desc: 'Bookable through the passenger portal like any other schedule' },
{ value: 'PACKAGE_ONLY', title: 'Package Only', desc: 'Hide from public search — reserved for package bookings' },
{ value: 'GROUP_ONLY', title: 'Group Booking Only', desc: 'Hide from public search — reserved for staff group bookings' },
] as const).map((opt) => {
const selected = visibilityOf(addForm.isPackageOnly, addForm.isGroupBookingOnly) === opt.value;
return (
<label
key={opt.value}
className={`flex items-start gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}`}
>
<input
type="radio"
name="add-visibility"
className="w-4 h-4 mt-0.5"
checked={selected}
onChange={() => setAddForm({ ...addForm, ...visibilityFlags(opt.value) })}
/>
<span className="text-sm">
<span className="font-medium block">{opt.title}</span>
<span className="block text-xs text-muted-foreground">{opt.desc}</span>
</span>
</label>
);
})}
</div>
</div>
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<label className="label mb-0">Coaches</label>
@@ -875,7 +927,7 @@ function SchedulesPageContent() {
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
<ActionButton type="submit" loading={createScheduleMutation.isPending}>Create Schedule</ActionButton>
</div>
</form>
@@ -1155,18 +1207,35 @@ function SchedulesPageContent() {
</select>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg border border-border">
<input
type="checkbox"
id="isPackageOnly"
checked={editForm.isPackageOnly}
onChange={(e) => setEditForm({ ...editForm, isPackageOnly: e.target.checked })}
className="w-4 h-4 rounded"
/>
<label htmlFor="isPackageOnly" className="text-sm cursor-pointer">
<span className="font-medium">Package Only</span>
<span className="block text-xs text-muted-foreground">Hide from public search reserved for package bookings</span>
</label>
<div>
<label className="label mb-2">Visibility</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{([
{ value: 'NORMAL', title: 'Normal', desc: 'Bookable through the passenger portal like any other schedule' },
{ value: 'PACKAGE_ONLY', title: 'Package Only', desc: 'Hide from public search — reserved for package bookings' },
{ value: 'GROUP_ONLY', title: 'Group Booking Only', desc: 'Hide from public search — reserved for staff group bookings' },
] as const).map((opt) => {
const selected = visibilityOf(editForm.isPackageOnly, editForm.isGroupBookingOnly) === opt.value;
return (
<label
key={opt.value}
className={`flex items-start gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}`}
>
<input
type="radio"
name="edit-visibility"
className="w-4 h-4 mt-0.5"
checked={selected}
onChange={() => setEditForm({ ...editForm, ...visibilityFlags(opt.value) })}
/>
<span className="text-sm">
<span className="font-medium block">{opt.title}</span>
<span className="block text-xs text-muted-foreground">{opt.desc}</span>
</span>
</label>
);
})}
</div>
</div>
<div>

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { LogIn, ListCollapse, Trash2, Printer, Package } from 'lucide-react';
import { Download } from 'lucide-react';
@@ -39,6 +39,7 @@ export default function TicketsPage() {
const [excessTicket, setExcessTicket] = useState<any>(null);
const [excessKg, setExcessKg] = useState('');
const [excessCollectCash, setExcessCollectCash] = useState(false);
const [excessPaymentPhone, setExcessPaymentPhone] = useState('');
const [excessError, setExcessError] = useState<string | null>(null);
const [excessResult, setExcessResult] = useState<any>(null);
@@ -163,10 +164,35 @@ export default function TicketsPage() {
onError: (e: any) => setExcessError(e?.response?.data?.message || e?.message || 'Failed to log charge'),
});
useEffect(() => {
if (!excessModalOpen || !excessTicket) return;
const bookingRef = excessTicket?.booking?.bookingRef ?? '';
if (!bookingRef) {
setExcessPaymentPhone('');
return;
}
const timeout = setTimeout(async () => {
try {
const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 });
const items = response?.items ?? [];
const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0];
if (!match) return;
const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? '';
setExcessPaymentPhone((prev) => prev || nextPhone);
} catch {
// Ignore lookup failures here; the staff member can still type a phone override manually.
}
}, 250);
return () => clearTimeout(timeout);
}, [excessModalOpen, excessTicket]);
const openExcessModal = (ticket: any) => {
setExcessTicket(ticket);
setExcessKg('');
setExcessCollectCash(false);
setExcessPaymentPhone('');
setExcessError(null);
setExcessResult(null);
setExcessModalOpen(true);
@@ -179,6 +205,7 @@ export default function TicketsPage() {
bookingId: excessTicket.booking?.id ?? excessTicket.bookingId,
excessWeightKg: parseInt(excessKg),
collectCash: excessCollectCash,
contactPhone: excessPaymentPhone.trim() || undefined,
});
};
@@ -979,6 +1006,15 @@ export default function TicketsPage() {
required
/>
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input
className="input"
placeholder="e.g. +251911223344"
value={excessPaymentPhone}
onChange={(e) => setExcessPaymentPhone(e.target.value)}
/>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"

View File

@@ -40,6 +40,7 @@ import {
Activity,
Smartphone,
Layers,
UsersRound,
} from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
@@ -64,6 +65,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
title: 'Operations',
items: [
{ name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view },
{ name: 'Group Booking', href: '/group-booking', icon: UsersRound, permission: PERMS.bookings.manage },
{ name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view },
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
{ name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.manage },
@@ -126,6 +128,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
items: [
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view },
{ name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },

View File

@@ -2,6 +2,30 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.';
const NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.';
/**
* Extracts a user-facing message from a failed request. Prefers a real backend-provided message
* (joining a NestJS validation array into one line); otherwise falls back to a friendly generic
* message — never raw client/network text like "Request failed with status code 500" or
* "Network Error", which is what axios puts in `error.message` when there's nothing better.
* Every page in this app should use this (or rely on the response interceptor below, which
* normalizes the same error in place) instead of reading `err.message` directly.
*/
export function getErrorMessage(error: unknown, fallback: string = GENERIC_ERROR_MESSAGE): string {
const err = error as any;
const raw = err?.response?.data?.message;
if (Array.isArray(raw) && raw.length > 0) {
const joined = raw.filter((m: unknown) => typeof m === 'string' && m.trim()).join('; ');
if (joined) return joined;
} else if (typeof raw === 'string' && raw.trim()) {
return raw;
}
if (err?.isAxiosError && !err.response) return NETWORK_ERROR_MESSAGE;
return fallback;
}
class ApiClient {
private client: AxiosInstance;
@@ -30,6 +54,20 @@ class ApiClient {
window.location.href = '/login';
}
}
// Normalize in place so every existing `err?.response?.data?.message || err?.message ||
// '<fallback>'` call site across the app picks up a friendly message automatically,
// instead of raw axios/network text or an unjoined NestJS validation array.
try {
const friendly = getErrorMessage(error);
if (error.response?.data && typeof error.response.data === 'object') {
error.response.data.message = friendly;
}
error.message = friendly;
} catch {
// Best-effort — never let normalization itself break the original rejection.
}
return Promise.reject(error);
},
);

View File

@@ -0,0 +1,258 @@
import { apiClient } from '@/lib/api-client';
// ── Search (POST /search) ──────────────────────────────────────────────────
export interface SearchTripsRequest {
originStationId: string;
destinationStationId: string;
date: string;
adultCount: number;
childCount?: number;
journeyType: 'ONE_WAY' | 'ROUND_TRIP';
/** Required when journeyType is ROUND_TRIP. */
returnDate?: string;
/** Drives which fare tier (Local vs International) gets quoted — see fareTier in the page component. */
nationality?: string;
/** Always 'GROUP_BOOKING' for this app — search returns ONLY schedules marked
* isGroupBookingOnly (an exclusive partition, not additive): normal passenger-facing
* schedules never show up here, and group-only schedules never show up in the portal. */
channel?: 'PORTAL' | 'GROUP_BOOKING';
}
export interface ScheduleClassOption {
name: string;
baseFareMinor: number;
displayCurrency: string;
displayAmountMinor: number;
available: number;
}
export interface ScheduleCoachType {
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
coachId: string;
classes: ScheduleClassOption[];
}
export interface ScheduleResult {
type: 'DIRECT';
scheduleId: string;
trainNumber: string;
trainName: string;
origin: { id: string; code: string; name: string; city: string; sequence: number };
destination: { id: string; code: string; name: string; city: string; sequence: number };
departureAt: string;
arrivalAt: string;
durationMinutes: number;
status: string;
hasAvailability: boolean;
displayCurrency: string;
coachTypes: ScheduleCoachType[];
}
export type SearchEmptyReasonCode =
| 'NO_ROUTE'
| 'NO_SCHEDULE_ON_DATE'
| 'CANCELLED'
| 'PACKAGE_ONLY'
| 'GROUP_BOOKING_ONLY'
| 'CHECKIN_CLOSED'
| 'FULLY_BOOKED';
/** Structured, not a string — always render via a code→message lookup, never directly. */
export interface SearchEmptyReason {
code: SearchEmptyReasonCode;
originStationName: string;
destinationStationName: string;
}
export interface SearchTripsResponse {
journeyType: string;
outbound: ScheduleResult[];
requestedDate: string;
outboundReason?: SearchEmptyReason;
/** Nearby schedules for the same station pair on a different date, offered when `outbound` is empty. */
alternativeOutbound?: ScheduleResult[];
/** Return-leg schedules — present when the request's journeyType was ROUND_TRIP. */
inbound?: ScheduleResult[];
requestedReturnDate?: string;
inboundReason?: SearchEmptyReason;
alternativeInbound?: ScheduleResult[];
}
// ── Seat classes (GET /seat-classes) ───────────────────────────────────────
export interface SeatClassOption {
id: string;
name: string;
}
// ── Auto-assign + hold (POST /seats/auto-assign-hold) ─────────────────────
export interface AutoAssignHoldRequest {
scheduleId: string;
originStationId: string;
destinationStationId: string;
seatClassName: string;
adultCount: number;
childCount?: number;
/** Round-trip leg tag — omit for a one-way booking. */
journeyDirection?: 'OUTBOUND' | 'RETURN';
}
export interface HeldPassengerSeat {
passengerId: string;
seat: {
id: string;
label?: string;
seatNumber?: string;
coach?: string;
row?: number;
col?: string;
};
}
export interface AutoAssignHoldResponse {
holdId: string;
expiresAt: string;
ttlSeconds: number;
schedule: { id: string; trainNumber: string; trainName: string; departureAt: string; arrivalAt: string } | null;
passengers: HeldPassengerSeat[];
}
// ── Group booking creation (POST /bookings/group) ──────────────────────────
export interface GroupBookingPassengerInput {
/** Omit for a free child (ONE_WAY only) — matches guest-booking.dto.ts's own optional seatId. */
seatId?: string;
passengerName: string;
dateOfBirth: string;
idDocumentType: 'NATIONAL_ID' | 'PASSPORT' | 'DRIVING_LICENSE' | 'OTHER';
idDocumentNumber?: string;
passportNumber?: string;
passportCountry?: string;
nationality?: string;
phone?: string;
email?: string;
/** Return-leg seat ID — required when the booking is ROUND_TRIP. */
returnSeatId?: string;
}
export interface CreateGroupBookingRequest {
scheduleId: string;
holdId: string;
originStationId: string;
destinationStationId: string;
seatClassId: string;
bookingType: 'ONE_WAY' | 'ROUND_TRIP';
passengers: GroupBookingPassengerInput[];
/** ROUND_TRIP only. */
returnScheduleId?: string;
returnHoldId?: string;
returnOriginStationId?: string;
returnDestinationStationId?: string;
/** Falls back to seatClassId on the backend if omitted. */
returnSeatClassId?: string;
}
export interface GroupBookingSeat {
seatId: string;
passengerName: string;
passengerCategory: 'ADULT' | 'CHILD';
/** 1 = outbound leg, 2 = return leg. Absent on a plain ONE_WAY booking. */
leg?: number;
seat: { seatNumber: string; bedPosition?: string | null; coach: { number: string } };
}
export interface CreateGroupBookingResponse {
id: string;
bookingRef: string;
status: string;
totalMinor: number;
currency: string;
adultCount: number;
childCount: number;
seats: GroupBookingSeat[];
schedule: {
departureAt: string;
arrivalAt: string;
train: { number: string; name: string };
originStation: { name: string };
destinationStation: { name: string };
};
}
// ── Payment (GET /payments/methods, POST /payments/initiate) ───────────────
export type PaymentMethodType =
| 'TELEBIRR' | 'CBE_BIRR' | 'EBIRR' | 'WAAFI' | 'DMONEY' | 'CAC_BANK' | 'CARD' | 'WALLET' | 'CBE_BILL';
export interface SupportedPaymentMethod {
id: string;
type: PaymentMethodType;
displayName: string;
region: string;
currency: string;
enabled: boolean;
}
export interface InitiatePaymentRequest {
bookingId: string;
method: PaymentMethodType;
paymentMethodId?: string;
platform?: 'web' | 'mobile' | 'inapp';
}
export interface PaymentClientAction {
type: 'REDIRECT' | 'LAUNCH_APP' | 'INVOKE_BRIDGE' | 'COLLECT_OTP' | 'AWAIT_PUSH' | 'SHOW_BILL_REFERENCE';
url?: string;
/** Set when type=SHOW_BILL_REFERENCE (CBE bill payment) — the number the payer enters at any CBE channel. */
billReference?: string;
instructions?: string;
expiresAt?: string;
message?: string;
payerAccountMasked?: string;
}
export interface InitiatePaymentResponse {
intentId: string;
status: string;
clientAction?: PaymentClientAction;
merchantOrderId?: string;
failureCode?: string;
failureMessage?: string;
sessionExpiresAt?: string;
paymentDeadline?: string;
}
export const groupBookingApi = {
searchTrips: (dto: SearchTripsRequest) =>
apiClient.post<SearchTripsResponse>('/search', dto),
getSeatClasses: () => apiClient.get<SeatClassOption[]>('/seat-classes'),
autoAssignHold: (dto: AutoAssignHoldRequest) =>
apiClient.post<AutoAssignHoldResponse>('/seats/auto-assign-hold', dto),
/** Best-effort early release — e.g. freeing an outbound hold when the return leg's auto-assign fails. */
releaseHold: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
createGroupBooking: (dto: CreateGroupBookingRequest) =>
apiClient.post<CreateGroupBookingResponse>('/bookings/group', dto),
// Guards against a non-array response the same way dashboardApi.getPaymentMethods /
// paymentsApi.getMethods already do elsewhere in this app — never lets a bad/unexpected
// response shape reach a caller expecting a plain array.
getPaymentMethods: async (): Promise<SupportedPaymentMethod[]> => {
try {
const response = await apiClient.get<SupportedPaymentMethod[]>('/payments/methods');
return Array.isArray(response) ? response : [];
} catch {
return [];
}
},
initiatePayment: (dto: InitiatePaymentRequest) =>
apiClient.post<InitiatePaymentResponse>('/payments/initiate', dto),
};

View File

@@ -200,8 +200,14 @@ export const paymentsApi = {
updateMethod: (id: string, data: any) => apiClient.patch(`/payments/methods/${id}`, data),
deleteMethod: (id: string) => apiClient.delete(`/payments/methods/${id}`),
supplementary: {
create: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
apiClient.post<any>('/payments/supplementary', data),
create: (data: {
bookingRef: string;
amountMinor: number;
reason: string;
notes?: string;
contactPhone?: string;
contactEmail?: string;
}) => apiClient.post<any>('/payments/supplementary', data),
getAll: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([, v]) => v !== '' && v !== undefined && v !== null)
@@ -469,6 +475,15 @@ export const packagesApi = {
addTier: (packageId: string, data: any) => apiClient.post<any>(`/packages/${packageId}/tiers`, data),
updateTier: (tierId: string, data: any) => apiClient.patch<any>(`/packages/tiers/${tierId}`, data),
deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`),
// `apiClient` pins Content-Type: application/json on every request — clearing it (rather than
// setting multipart/form-data by hand, which omits the boundary) is what lets the browser
// generate a proper boundary of its own. Same pattern as features/support/supportApi.ts.
uploadImage: (id: string, file: File) => {
const form = new FormData();
form.append('image', file);
return apiClient.post<any>(`/packages/${id}/image`, form, { headers: { 'Content-Type': undefined } });
},
removeImage: (id: string) => apiClient.delete<any>(`/packages/${id}/image`),
};
// Package Inquiries API

View File

@@ -0,0 +1,149 @@
import ExcelJS from 'exceljs';
// Brand palette — matches finance-workbook.ts / ActionButton's primary variant, kept as a
// small local copy rather than a shared import since these are two unrelated export domains.
const BRAND = 'FF14714C';
const BRAND_TINT = 'FFEAF5EF';
const INK = 'FF1F2937';
const MUTED = 'FF6B7280';
const BORDER = 'FFE2E5E1';
const WHITE = 'FFFFFFFF';
const THIN_BORDER: Partial<ExcelJS.Borders> = {
top: { style: 'thin', color: { argb: BORDER } },
left: { style: 'thin', color: { argb: BORDER } },
bottom: { style: 'thin', color: { argb: BORDER } },
right: { style: 'thin', color: { argb: BORDER } },
};
/** Column order is the contract — passenger-excel.ts reads by this same header order. */
export const PASSENGER_TEMPLATE_COLUMNS = [
'Full Name',
'Date of Birth (YYYY-MM-DD)',
'Passenger Type',
'ID Document Type',
'ID Document Number',
'Passport Number',
'Passport Country',
'Nationality',
'Phone',
'Email',
] as const;
const REQUIRED_ROW = 200;
export interface PassengerTemplateInput {
trainNumber: string;
origin: string;
destination: string;
travelDate: string;
seatClassName: string;
adultCount: number;
childCount: number;
}
export async function buildPassengerTemplate(input: PassengerTemplateInput): Promise<Blob> {
const wb = new ExcelJS.Workbook();
wb.creator = 'EDR Passenger Backoffice';
wb.created = new Date();
const ws = wb.addWorksheet('Passengers', { views: [{ state: 'frozen', ySplit: 5 }] });
ws.columns = PASSENGER_TEMPLATE_COLUMNS.map((h) => ({ width: h.length < 14 ? 18 : h.length + 4 }));
// ── Title + trip context banner ──────────────────────────────────────────
ws.mergeCells(1, 1, 1, PASSENGER_TEMPLATE_COLUMNS.length);
const title = ws.getCell(1, 1);
title.value = 'EDR Group Booking — Passenger Template';
title.font = { bold: true, size: 16, color: { argb: WHITE } };
title.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } };
title.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(1).height = 30;
for (let c = 1; c <= PASSENGER_TEMPLATE_COLUMNS.length; c++) ws.getCell(1, c).fill = title.fill;
ws.mergeCells(2, 1, 2, PASSENGER_TEMPLATE_COLUMNS.length);
const subtitle = ws.getCell(2, 1);
subtitle.value = `Train ${input.trainNumber} · ${input.origin}${input.destination} · ${input.travelDate} · ${input.seatClassName}`;
subtitle.font = { size: 11, color: { argb: INK } };
subtitle.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(2).height = 20;
ws.mergeCells(3, 1, 3, PASSENGER_TEMPLATE_COLUMNS.length);
const requirement = ws.getCell(3, 1);
const total = input.adultCount + input.childCount;
requirement.value = `Fill in exactly ${total} passenger row${total === 1 ? '' : 's'} below — ${input.adultCount} Adult${input.adultCount === 1 ? '' : 's'} + ${input.childCount} Child${input.childCount === 1 ? '' : 'ren'}. One row per passenger, in any order.`;
requirement.font = { italic: true, size: 10, color: { argb: MUTED } };
requirement.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(3).height = 18;
ws.mergeCells(4, 1, 4, PASSENGER_TEMPLATE_COLUMNS.length);
const instructions = ws.getCell(4, 1);
instructions.value =
'Columns marked * are required. Date of Birth must be YYYY-MM-DD and not in the future — it determines Adult/Child pricing (under 5 = Child). ' +
'ID Document Type must be one of: NATIONAL_ID, PASSPORT, DRIVING_LICENSE, OTHER. Do not rename or reorder columns.';
instructions.font = { size: 9, color: { argb: MUTED } };
instructions.alignment = { vertical: 'middle', horizontal: 'left', indent: 1, wrapText: true };
ws.getRow(4).height = 28;
// ── Header row ────────────────────────────────────────────────────────────
const headerRow = ws.getRow(5);
const requiredCols = new Set([0, 1, 2, 3]); // Full Name, DOB, Passenger Type, ID Document Type
PASSENGER_TEMPLATE_COLUMNS.forEach((h, i) => {
const cell = headerRow.getCell(i + 1);
cell.value = requiredCols.has(i) ? `${h} *` : h;
cell.font = { bold: true, color: { argb: WHITE }, size: 11 };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } };
cell.alignment = { vertical: 'middle', horizontal: 'left', wrapText: true };
cell.border = THIN_BORDER;
});
headerRow.height = 30;
// ── One filled example row so the format is obvious at a glance ──────────
const example = ws.getRow(6);
const exampleValues = [
'Abebe Kebede',
'1990-05-15',
'Adult',
'NATIONAL_ID',
'ET123456789',
'',
'',
'Ethiopian',
'+251911234567',
'abebe@example.com',
];
exampleValues.forEach((v, i) => {
const cell = example.getCell(i + 1);
cell.value = v;
cell.font = { italic: true, color: { argb: MUTED } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND_TINT } };
cell.border = THIN_BORDER;
});
// ── Blank rows with borders + dropdown validation for Passenger Type / ID Document Type ──
// exceljs's types only expose per-cell `cell.dataValidation`, not a worksheet-level range API.
for (let r = 7; r <= REQUIRED_ROW; r++) {
const row = ws.getRow(r);
for (let c = 1; c <= PASSENGER_TEMPLATE_COLUMNS.length; c++) {
row.getCell(c).border = THIN_BORDER;
}
row.getCell(3).dataValidation = {
type: 'list',
allowBlank: true,
formulae: ['"Adult,Child"'],
showErrorMessage: true,
errorTitle: 'Invalid Passenger Type',
error: 'Choose Adult or Child.',
};
row.getCell(4).dataValidation = {
type: 'list',
allowBlank: true,
formulae: ['"NATIONAL_ID,PASSPORT,DRIVING_LICENSE,OTHER"'],
showErrorMessage: true,
errorTitle: 'Invalid ID Document Type',
error: 'Choose NATIONAL_ID, PASSPORT, DRIVING_LICENSE, or OTHER.',
};
}
const buffer = await wb.xlsx.writeBuffer();
return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}

View File

@@ -0,0 +1,258 @@
import ExcelJS from 'exceljs';
const VALID_ID_TYPES = ['NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENSE', 'OTHER'];
/** Exact mirror of guest-booking.service.ts's calculateAge — calendar-based, not a 365.25-day
* approximation, so this file's CHILD/ADULT determination never disagrees with the backend's. */
function calculateAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) age--;
return age;
}
/**
* Mirrors guest-booking.service.ts's per-passenger nationality inference exactly (NATIONAL_ID
* always forces 'Ethiopian'; PASSPORT falls back to Djiboutian/Other by passport country), so a
* row that will actually be priced at a different fare tier than the one quoted at search time
* is caught here instead of silently mispricing the group later.
*/
function inferredFareTier(docType: string, nationality: string, passportCountry: string): 'LOCAL' | 'INTERNATIONAL' {
const natUpper = nationality.trim().toUpperCase();
const isEthiopian = natUpper === 'ETHIOPIAN' || docType === 'NATIONAL_ID';
let resolved = nationality;
if (isEthiopian && docType === 'NATIONAL_ID') resolved = 'Ethiopian';
else if (!isEthiopian && docType === 'PASSPORT') resolved = nationality || (passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
else if (isEthiopian && docType === 'PASSPORT') resolved = 'Ethiopian';
const resolvedUpper = resolved.trim().toUpperCase();
return resolvedUpper === 'ETHIOPIAN' || resolvedUpper === 'DJIBOUTIAN' ? 'LOCAL' : 'INTERNATIONAL';
}
export interface ParsedPassengerRow {
/** 1-based row number in the sheet, for error messages ("row 8"). */
rowNumber: number;
fullName: string;
dateOfBirth: string; // normalized YYYY-MM-DD, empty if invalid/missing
passengerType: 'Adult' | 'Child' | '';
/** Backend-authoritative category from DOB alone (age < 5), regardless of the Type column. */
isChildByAge: boolean;
idDocumentType: string;
idDocumentNumber: string;
passportNumber: string;
passportCountry: string;
nationality: string;
phone: string;
email: string;
errors: string[];
warnings: string[];
}
export interface ParsePassengerExcelResult {
rows: ParsedPassengerRow[];
/** Structural problems (wrong file, missing columns) — nothing in `rows` can be trusted if this is non-empty. */
fileErrors: string[];
}
function cellText(row: ExcelJS.Row, colIndex: number): string {
if (colIndex < 1) return '';
const v = row.getCell(colIndex).value;
if (v === null || v === undefined) return '';
if (v instanceof Date) return v.toISOString().split('T')[0];
if (typeof v === 'object') {
const anyV = v as any;
if (typeof anyV.text === 'string') return anyV.text.trim();
if (anyV.result !== undefined) return String(anyV.result).trim();
if (anyV.richText) return anyV.richText.map((t: any) => t.text).join('').trim();
}
return String(v).trim();
}
/** Strips a trailing " *" (required-column marker) so header matching survives the template's own formatting. */
function normalizeHeader(h: string): string {
return h.replace(/\s*\*\s*$/, '').trim();
}
export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' | 'INTERNATIONAL'): Promise<ParsePassengerExcelResult> {
const buffer = await file.arrayBuffer();
const wb = new ExcelJS.Workbook();
try {
await wb.xlsx.load(buffer);
} catch {
return {
rows: [],
fileErrors: ['Could not read this file. Make sure it is a valid .xlsx or .xls file exported from the downloaded template.'],
};
}
const ws = wb.worksheets[0];
if (!ws) return { rows: [], fileErrors: ['The workbook has no sheets.'] };
// Locate the header row by scanning the first several rows for one starting with "Full Name" —
// the template puts it at row 5 (after the title/instruction banners), but scanning is more
// forgiving of an edited file than hardcoding a row number.
let headerRowIndex = -1;
let headers: string[] = [];
for (let r = 1; r <= 10; r++) {
const row = ws.getRow(r);
const values: string[] = [];
for (let c = 1; c <= 12; c++) values.push(normalizeHeader(cellText(row, c)));
if (values.some((v) => v.toLowerCase().startsWith('full name'))) {
headerRowIndex = r;
headers = values;
break;
}
}
if (headerRowIndex === -1) {
return {
rows: [],
fileErrors: ['Could not find the expected header row (starting with "Full Name"). Please use the downloaded template without changing its structure.'],
};
}
const colFor = (label: string) => headers.findIndex((h) => h.toLowerCase().startsWith(label.toLowerCase())) + 1;
const idx = {
fullName: colFor('Full Name'),
dob: colFor('Date of Birth'),
type: colFor('Passenger Type'),
docType: colFor('ID Document Type'),
docNumber: colFor('ID Document Number'),
passportNumber: colFor('Passport Number'),
passportCountry: colFor('Passport Country'),
nationality: colFor('Nationality'),
phone: colFor('Phone'),
email: colFor('Email'),
};
if (idx.fullName < 1 || idx.dob < 1 || idx.type < 1 || idx.docType < 1) {
return {
rows: [],
fileErrors: ['One or more required columns (Full Name, Date of Birth, Passenger Type, ID Document Type) are missing. Please use the downloaded template.'],
};
}
const rows: ParsedPassengerRow[] = [];
const lastRow = ws.actualRowCount || ws.rowCount;
for (let r = headerRowIndex + 1; r <= lastRow; r++) {
const row = ws.getRow(r);
const fullName = cellText(row, idx.fullName);
const dobRaw = cellText(row, idx.dob);
const typeRaw = cellText(row, idx.type);
const docTypeRaw = cellText(row, idx.docType).toUpperCase();
const docNumber = cellText(row, idx.docNumber);
const passportNumber = cellText(row, idx.passportNumber);
const passportCountry = cellText(row, idx.passportCountry);
const nationality = cellText(row, idx.nationality);
const phone = cellText(row, idx.phone);
const email = cellText(row, idx.email);
// Skip fully blank trailing rows (the template pre-formats borders down to row 200).
if (![fullName, dobRaw, typeRaw, docTypeRaw, docNumber, passportNumber, nationality, phone, email].some((v) => v)) {
continue;
}
const errors: string[] = [];
const warnings: string[] = [];
if (!fullName) errors.push('Full Name is required');
let dateOfBirth = '';
let isChildByAge = false;
if (!dobRaw) {
errors.push('Date of Birth is required');
} else {
const parsed = new Date(dobRaw);
if (isNaN(parsed.getTime())) {
errors.push(`Date of Birth "${dobRaw}" is not a valid date (use YYYY-MM-DD)`);
} else if (parsed.getTime() > Date.now()) {
errors.push('Date of Birth cannot be in the future');
} else {
dateOfBirth = parsed.toISOString().split('T')[0];
isChildByAge = calculateAge(parsed) < 5;
}
}
let passengerType: 'Adult' | 'Child' | '' = '';
const normalizedType = typeRaw.trim().toLowerCase();
if (normalizedType === 'adult') passengerType = 'Adult';
else if (normalizedType === 'child') passengerType = 'Child';
else errors.push(`Passenger Type "${typeRaw}" must be "Adult" or "Child"`);
// The backend computes ADULT/CHILD from date of birth alone (under 5 = Child), regardless
// of this column — flag a mismatch so the uploader notices before it surprises them later.
if (passengerType && dateOfBirth) {
const impliedType = isChildByAge ? 'Child' : 'Adult';
if (impliedType !== passengerType) {
warnings.push(`Date of Birth implies ${impliedType}, but Passenger Type is set to ${passengerType} — seats/fare are priced by age, not this column`);
}
}
if (!docTypeRaw) {
errors.push('ID Document Type is required');
} else if (!VALID_ID_TYPES.includes(docTypeRaw)) {
errors.push(`ID Document Type "${docTypeRaw}" must be one of NATIONAL_ID, PASSPORT, DRIVING_LICENSE, OTHER`);
}
if (docTypeRaw === 'PASSPORT' && !passportNumber) {
warnings.push('Passport Number is empty for a PASSPORT document type');
}
// The whole group is priced at one uniform fare tier (the one quoted at search time) — a
// row whose document type/nationality would actually resolve to the other tier will be
// priced wrong (over- or under-charged) with no per-passenger fare split to fix it.
if (quotedFareTier && VALID_ID_TYPES.includes(docTypeRaw)) {
const rowTier = inferredFareTier(docTypeRaw, nationality, passportCountry);
if (rowTier !== quotedFareTier) {
warnings.push(
`This passenger's documents imply ${rowTier === 'LOCAL' ? 'Local (Ethiopian/Djiboutian)' : 'International'} pricing, but the group was quoted at ${quotedFareTier === 'LOCAL' ? 'Local' : 'International'} rates — this passenger's actual fare will differ from the group rate`,
);
}
}
rows.push({
rowNumber: r,
fullName,
dateOfBirth,
passengerType,
isChildByAge,
idDocumentType: docTypeRaw,
idDocumentNumber: docNumber,
passportNumber,
passportCountry,
nationality,
phone,
email,
errors,
warnings,
});
}
if (rows.length === 0) {
return { rows: [], fileErrors: ['No passenger rows found below the header. Fill in at least one row and try again.'] };
}
return { rows, fileErrors: [] };
}
export function countByType(rows: ParsedPassengerRow[]): { adults: number; children: number } {
return {
adults: rows.filter((r) => !r.isChildByAge).length,
children: rows.filter((r) => r.isChildByAge).length,
};
}
/**
* Mirrors the passenger portal's isFirstChild rule exactly (fare-utils.ts): the first
* `adultCount` children in passenger order travel free with no assigned seat; any child
* beyond that gets a real seat and pays the child fare. Returns one boolean per row, true
* where that row is a free, unseated child.
*/
export function resolveFreeChildIndexes(rows: ParsedPassengerRow[], adultCount: number): boolean[] {
let childrenSeen = 0;
return rows.map((row) => {
if (!row.isChildByAge) return false;
const free = childrenSeen < adultCount;
childrenSeen++;
return free;
});
}