mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 13:28:11 +00:00
Merge branch 'dev' into reschedule
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function GroupBookingLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
1474
apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx
Normal file
1474
apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
258
apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts
Normal file
258
apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts
Normal 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),
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,20 @@
|
||||
// Package images are served by edr-passenger-api's own origin (public/uploads/packages via
|
||||
// app.useStaticAssets — see apps/edr-passenger-api/src/main.ts), a different origin than this
|
||||
// app, so next/image needs it explicitly whitelisted or it 400s every package image at runtime.
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
let apiImagePattern;
|
||||
try {
|
||||
const apiUrl = new URL(API_URL);
|
||||
apiImagePattern = {
|
||||
protocol: apiUrl.protocol.replace(':', ''),
|
||||
hostname: apiUrl.hostname,
|
||||
port: apiUrl.port || '',
|
||||
pathname: '/uploads/**',
|
||||
};
|
||||
} catch {
|
||||
apiImagePattern = { protocol: 'http', hostname: 'localhost', port: '4000', pathname: '/uploads/**' };
|
||||
}
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
@@ -5,6 +22,7 @@ const nextConfig = {
|
||||
transpilePackages: ['@edr/types', '@edr/ui-common'],
|
||||
images: {
|
||||
unoptimized: false,
|
||||
remotePatterns: [apiImagePattern],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -659,8 +659,13 @@ export default function ConfirmationPage() {
|
||||
{(() => {
|
||||
// The server-confirmed settled amount is authoritative — prefer it over
|
||||
// any client-side session state, which can go stale (e.g. after a refresh).
|
||||
// Despite its name, PaymentIntent.amountMinor holds MAJOR units — it is the
|
||||
// charge amount produced by currencyService.*ToChargeMajor (see initiate() in
|
||||
// payments.service.ts; reports.service.ts multiplies it by 100 to get real
|
||||
// minor units). Do NOT divide by 100 here — the voucher does the same via
|
||||
// fareIsMajorUnits.
|
||||
if (_booking?.payment?.amountMinor != null) {
|
||||
return `${_booking.payment.currency || 'ETB'} ${(_booking.payment.amountMinor / 100).toFixed(2)}`;
|
||||
return `${_booking.payment.currency || 'ETB'} ${_booking.payment.amountMinor.toFixed(2)}`;
|
||||
}
|
||||
if (reviewedTotalMinor != null)
|
||||
return `${paidCurrency || 'ETB'} ${(reviewedTotalMinor / 100).toFixed(2)}`;
|
||||
|
||||
@@ -20,7 +20,7 @@ const COUNTRIES = [
|
||||
'Bolivia','Bosnia and Herzegovina','Botswana','Brazil','Brunei','Bulgaria','Burkina Faso','Burundi','Cabo Verde','Cambodia',
|
||||
'Cameroon','Canada','Central African Republic','Chad','Chile','China','Colombia','Comoros','Congo','Costa Rica',
|
||||
'Croatia','Cuba','Cyprus','Czech Republic','Denmark','Dominica','Dominican Republic','Ecuador','Egypt',
|
||||
'El Salvador','Equatorial Guinea','Eritrea','Estonia','Eswatini','Fiji','Finland','France','Gabon',
|
||||
'El Salvador','Equatorial Guinea','Eritrea','Estonia','Eswatini','Ethiopia','Fiji','Finland','France','Gabon',
|
||||
'Gambia','Georgia','Germany','Ghana','Greece','Grenada','Guatemala','Guinea','Guinea-Bissau','Guyana',
|
||||
'Haiti','Honduras','Hungary','Iceland','India','Indonesia','Iran','Iraq','Ireland','Israel',
|
||||
'Italy','Jamaica','Japan','Jordan','Kazakhstan','Kenya','Kiribati','Kuwait','Kyrgyzstan','Laos',
|
||||
|
||||
@@ -93,6 +93,12 @@ function emptyReasonCopy(
|
||||
message: `The only train between ${originStationName} and ${destinationStationName} on ${date} is bookable as part of a travel package, not as a standalone ticket. Please choose another date below.`,
|
||||
showAlternatives: true,
|
||||
};
|
||||
case Passenger.SearchEmptyReasonCode.GroupBookingOnly:
|
||||
return {
|
||||
title: "Reserved for a group booking",
|
||||
message: `The only train between ${originStationName} and ${destinationStationName} on ${date} is reserved for a group booking, not individual tickets. Please choose another date below.`,
|
||||
showAlternatives: true,
|
||||
};
|
||||
case Passenger.SearchEmptyReasonCode.NoScheduleOnDate:
|
||||
default:
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { apiClient } from '@/lib/api-client';
|
||||
import { format } from 'date-fns';
|
||||
import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { ChevronLeft, Loader2 } from 'lucide-react';
|
||||
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
|
||||
|
||||
// Helper function to decode JWT token and extract passengerId
|
||||
@@ -54,6 +54,17 @@ export default function ReviewPage() {
|
||||
const [fareBreakdown, setFareBreakdown] = useState<any>(null);
|
||||
const [returnFareBreakdown, setReturnFareBreakdown] = useState<any>(null);
|
||||
const [computedTotal, setComputedTotal] = useState<number>(0);
|
||||
// createBookingMutation.isPending only covers the mutation's own network call, but
|
||||
// handleConfirm does real async work (seat-class lookup, passengerId resolution) before ever
|
||||
// calling it — during that window the button showed no loading state and stayed clickable,
|
||||
// letting a double-click race through and create two bookings for the same seat hold. This
|
||||
// covers the whole handleConfirm run, not just the mutation's slice of it.
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// setIsSubmitting alone isn't enough to stop a fast double-click: React state updates aren't
|
||||
// synchronous, so a second click event dispatched before the first setIsSubmitting(true) has
|
||||
// actually re-rendered the button as disabled would still slip through. A ref updates
|
||||
// immediately, closing that gap regardless of render timing.
|
||||
const isSubmittingRef = useRef(false);
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
|
||||
@@ -228,6 +239,9 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
});
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isSubmittingRef.current || createBookingMutation.isPending) return;
|
||||
isSubmittingRef.current = true;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { searchCriteria } = useBookingStore.getState();
|
||||
|
||||
@@ -287,6 +301,8 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
|
||||
if (!seatClassId) {
|
||||
alert('Unable to determine seat class. Please go back and re-select your seats.');
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -489,8 +505,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
bookingData.reviewedTotalMinor = computedTotal;
|
||||
|
||||
await createBookingMutation.mutateAsync(bookingData);
|
||||
// Deliberately not resetting isSubmitting here: every path that reaches this point is
|
||||
// about to navigate away (router.push, either here or inside onSuccess's setTimeout above).
|
||||
// Clearing it now would flip the button back to its idle label for the gap between the
|
||||
// booking actually being created and the navigation landing — exactly the "did it get
|
||||
// stuck?" flash this state exists to prevent. It only needs resetting on a genuine failure,
|
||||
// handled in the catch block below, so the user can retry.
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -691,10 +715,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
)}
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={createBookingMutation.isPending}
|
||||
className="btn-primary w-full"
|
||||
disabled={isSubmitting || createBookingMutation.isPending}
|
||||
className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createBookingMutation.isPending ? 'Creating booking...' : `Confirm ${isAuthenticated ? '' : 'and pay'}`}
|
||||
{isSubmitting || createBookingMutation.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Creating booking...
|
||||
</span>
|
||||
) : (
|
||||
`Confirm ${isAuthenticated ? '' : 'and pay'}`
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => router.back()} className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
@@ -1032,10 +1062,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={createBookingMutation.isPending}
|
||||
className="btn-primary flex-1 py-2.5"
|
||||
disabled={isSubmitting || createBookingMutation.isPending}
|
||||
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createBookingMutation.isPending ? 'Creating...' : `Confirm ${isAuthenticated ? '' : '& pay'}`}
|
||||
{isSubmitting || createBookingMutation.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Creating...
|
||||
</span>
|
||||
) : (
|
||||
`Confirm ${isAuthenticated ? '' : '& pay'}`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,50 +1,295 @@
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useState, Suspense } from 'react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||
import { useState, useRef, Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Train, ShieldCheck, Eye, EyeOff } from 'lucide-react';
|
||||
import { Train, Eye, EyeOff, Pencil } from 'lucide-react';
|
||||
|
||||
const loginSchema = z.object({
|
||||
// Accepts either an email or a phone number. Passengers who registered without an
|
||||
// email sign in with their phone number, which is sent in the same `email` field —
|
||||
// the IAM matches on either identifier.
|
||||
email: z.string().min(1, 'Phone or email is required'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
});
|
||||
/**
|
||||
* Staged sign-in.
|
||||
*
|
||||
* The passenger gives one identifier — phone or email — and the server decides which of three
|
||||
* things happens next. Previously this page asked for identifier *and* password up front and
|
||||
* offered three competing links underneath ("Create account", "Already verified with Fayda?",
|
||||
* "Forgot password?"), which made the user guess something only the server knows: whether their
|
||||
* number has an account, and whether that account has a password yet. Guessing wrong dead-ended.
|
||||
*
|
||||
* Now exactly one branch is ever on screen.
|
||||
*/
|
||||
type Step = 'identifier' | 'password' | 'setup' | 'signup';
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/**
|
||||
* Loose enough to accept every shape a passenger might type (`+2519…`, `2519…`, `09…`) and the
|
||||
* occasional foreign number, strict enough that free text never reaches the signup branch — an
|
||||
* identifier that is neither an email nor a number would otherwise be stored as a phone the SMS
|
||||
* code can never reach. Mirrors the 7-digit floor in the API's `normalizePhoneVariants`.
|
||||
*/
|
||||
const looksLikePhone = (v: string) => v.replace(/[^\d]/g, '').length >= 7;
|
||||
|
||||
function LoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const registerUser = useAuthStore((s) => s.register);
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
|
||||
const [step, setStep] = useState<Step>('identifier');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema as any),
|
||||
});
|
||||
// Step 1
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const identifierRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
// Step 2 — sign in
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
// Step 3 — set a password (existing account with none, or a fresh signup)
|
||||
const [maskedPhone, setMaskedPhone] = useState('');
|
||||
const [setupMethod, setSetupMethod] = useState<'fayda' | 'pending' | 'new'>('pending');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [resendNote, setResendNote] = useState('');
|
||||
|
||||
// Step 4 — signup
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [secondaryContact, setSecondaryContact] = useState('');
|
||||
|
||||
const identifierIsEmail = EMAIL_RE.test(identifier.trim());
|
||||
|
||||
const finish = () => {
|
||||
const redirect = searchParams.get('redirect') || '/booking/search';
|
||||
router.push(redirect);
|
||||
};
|
||||
|
||||
const goBackToIdentifier = () => {
|
||||
setStep('identifier');
|
||||
setError('');
|
||||
setPassword('');
|
||||
setOtp('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setResendNote('');
|
||||
// Keep what they typed — they are usually fixing a typo, not starting over — but select
|
||||
// it, so typing replaces the value instead of appending to it. Without this, clicking
|
||||
// into a controlled input that still holds the old identifier silently concatenates.
|
||||
setTimeout(() => identifierRef.current?.select(), 0);
|
||||
};
|
||||
|
||||
const apiMessage = (err: any, fallback: string) =>
|
||||
err?.response?.data?.message || fallback;
|
||||
|
||||
// --- Step 1: who are you? ---------------------------------------------------
|
||||
const submitIdentifier = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const value = identifier.trim();
|
||||
if (!value) {
|
||||
setError('Enter your phone number or email');
|
||||
return;
|
||||
}
|
||||
if (!EMAIL_RE.test(value) && !looksLikePhone(value)) {
|
||||
setError('Enter a valid phone number or email address');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await login(data.email, data.password);
|
||||
const redirect = searchParams.get('redirect') || '/booking/search';
|
||||
router.push(redirect);
|
||||
const res = await iamAuthApi.lookupIdentifier(identifier.trim());
|
||||
const result = res.data.data;
|
||||
|
||||
if (result.status === 'PASSWORD') {
|
||||
setStep('password');
|
||||
return;
|
||||
}
|
||||
if (result.status === 'NEEDS_PASSWORD_SETUP') {
|
||||
setMaskedPhone(result.maskedPhone || '');
|
||||
setSetupMethod(result.method || 'pending');
|
||||
// Fire the code now so the next screen is already actionable. It resolves even for
|
||||
// an unknown identifier, so a failure here is a transport problem, not a verdict.
|
||||
await iamAuthApi.requestPasswordSetup(identifier.trim());
|
||||
setStep('setup');
|
||||
return;
|
||||
}
|
||||
setStep('signup');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Login failed. Please check your credentials.');
|
||||
setError(apiMessage(err, 'Something went wrong. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step 2: existing account, has a password -------------------------------
|
||||
const submitPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password) {
|
||||
setError('Enter your password');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await login(identifier.trim(), password);
|
||||
finish();
|
||||
} catch (err: any) {
|
||||
setError(apiMessage(err, 'Incorrect password. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step 3: set a password with the SMS code -------------------------------
|
||||
const submitSetup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!otp.trim()) {
|
||||
setError('Enter the code we sent you');
|
||||
return;
|
||||
}
|
||||
if (!isStrongPassword(newPassword)) {
|
||||
setError(PASSWORD_RULE);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await iamAuthApi.completePasswordSetup({
|
||||
identifier: identifier.trim(),
|
||||
otp: otp.trim(),
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
});
|
||||
const { token, user } = res.data.data;
|
||||
// The response carries a real session, so the user lands signed in instead of being
|
||||
// sent back to the form. `setUser` is the same action `login()` persists through.
|
||||
setUser(user as any, token);
|
||||
finish();
|
||||
} catch (err: any) {
|
||||
setError(apiMessage(err, 'That code is not valid. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resend = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResendNote('');
|
||||
try {
|
||||
await iamAuthApi.requestPasswordSetup(identifier.trim());
|
||||
setResendNote('We sent a new code.');
|
||||
} catch (err: any) {
|
||||
setError(apiMessage(err, 'Could not send a new code. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step 4: no account yet --------------------------------------------------
|
||||
const submitSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = fullName.trim();
|
||||
const other = secondaryContact.trim();
|
||||
if (name.length < 2) {
|
||||
setError('Enter your full name');
|
||||
return;
|
||||
}
|
||||
// A phone is always required — the verification code is sent by SMS and there is no
|
||||
// email channel for it. An email is optional.
|
||||
if (identifierIsEmail) {
|
||||
if (!other) {
|
||||
setError('Enter your phone number');
|
||||
return;
|
||||
}
|
||||
if (!looksLikePhone(other)) {
|
||||
setError('Enter a valid phone number — your verification code is sent by SMS');
|
||||
return;
|
||||
}
|
||||
} else if (other && !EMAIL_RE.test(other)) {
|
||||
// Only validate the shape when they actually typed something.
|
||||
setError('Enter a valid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
const phone = identifierIsEmail ? other : identifier.trim();
|
||||
// The IAM requires a non-empty account identifier in its `email` field but never checks
|
||||
// that it is email-shaped, so a passenger with no email address signs up under their phone
|
||||
// number — the same fallback `/register` uses. Both then match on either identifier.
|
||||
const email = identifierIsEmail ? identifier.trim() : other || phone;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await registerUser({ fullName: name, email, phone });
|
||||
setMaskedPhone(phone);
|
||||
setSetupMethod('new');
|
||||
setStep('setup');
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 409) {
|
||||
setError('An account with this email or phone number already exists. Go back and sign in.');
|
||||
} else {
|
||||
setError(apiMessage(err, 'Could not create your account. Please try again.'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The identifier, shown on every step after the first, with one way back to change it.
|
||||
*
|
||||
* It is a real `autocomplete="username"` input rather than a `<span>`, and it is rendered
|
||||
* *inside* each form. That is what makes password managers behave: a password field sitting
|
||||
* alone in a form gives Chrome nothing to match a saved credential against, so it fills
|
||||
* whichever password it holds for the origin — a password belonging to some other account.
|
||||
* Pairing it with the username lets the manager fill the right credential, or none at all.
|
||||
*/
|
||||
const identifierChip = (
|
||||
<div className="flex items-center justify-between gap-3 mb-4 px-3 py-2 rounded bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<input
|
||||
type="text"
|
||||
value={identifier}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
autoComplete="username"
|
||||
aria-label="Signing in as"
|
||||
onFocus={(e) => e.currentTarget.blur()}
|
||||
className="flex-1 min-w-0 truncate bg-transparent border-0 p-0 text-sm text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-0 cursor-default"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBackToIdentifier}
|
||||
className="flex items-center gap-1 text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline shrink-0"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const heading = {
|
||||
identifier: { title: 'Sign in', subtitle: 'Enter your phone number or email to continue' },
|
||||
password: { title: 'Welcome back', subtitle: 'Enter your password to sign in' },
|
||||
setup: { title: 'Set your password', subtitle: 'Enter the code we sent, then choose a password' },
|
||||
signup: { title: 'Create your account', subtitle: 'We just need a couple of details' },
|
||||
}[step];
|
||||
|
||||
const setupBlurb =
|
||||
setupMethod === 'fayda'
|
||||
? 'Your Fayda-verified account does not have a password yet.'
|
||||
: setupMethod === 'new'
|
||||
? 'Your account is almost ready.'
|
||||
: 'You started signing up but never chose a password.';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
@@ -54,86 +299,227 @@ function LoginContent() {
|
||||
<Train className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Sign in</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">Welcome back</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{heading.title}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">{heading.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone or email</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register('email')}
|
||||
className="input-field"
|
||||
placeholder="+251912345678 or your@email.com"
|
||||
autoComplete="username"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
|
||||
<div className="relative">
|
||||
{step === 'identifier' && (
|
||||
<form onSubmit={submitIdentifier} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Phone number or email
|
||||
</label>
|
||||
<input
|
||||
ref={identifierRef}
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(e) => { setIdentifier(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="+251912345678 or your@email.com"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Checking...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'password' && (
|
||||
<form onSubmit={submitPassword} className="space-y-4">
|
||||
{identifierChip}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
className="input-field pr-10"
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end mt-1">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'setup' && (
|
||||
<form onSubmit={submitSetup} className="space-y-4">
|
||||
{identifierChip}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{setupBlurb}{' '}
|
||||
{maskedPhone
|
||||
? <>We sent a code to <span className="font-medium">{maskedPhone}</span>.</>
|
||||
: 'We sent a code to your registered phone.'}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Verification code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => { setOtp(e.target.value); setError(''); }}
|
||||
className="input-field tracking-widest"
|
||||
placeholder="A1b2C3"
|
||||
// The IAM issues codes with generateRandomString(6): letters and digits,
|
||||
// and case-sensitive — so no numeric keypad and no autocapitalise.
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
New password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => { setNewPassword(e.target.value); setError(''); }}
|
||||
className="input-field pr-10"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{PASSWORD_RULE}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
{...register('password')}
|
||||
className="input-field pr-10"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
|
||||
)}
|
||||
<div className="flex justify-end mt-1">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Setting password...' : 'Set password and sign in'}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
{resendNote ? (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{resendNote}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resend}
|
||||
disabled={loading}
|
||||
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Didn't get a code? Send it again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
{step === 'signup' && (
|
||||
<form onSubmit={submitSignup} className="space-y-4">
|
||||
{identifierChip}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
We couldn't find an account for that {identifierIsEmail ? 'email' : 'number'}, so
|
||||
let's create one.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<div className="text-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Don't have an account? </span>
|
||||
<Link href="/register" className="text-sm font-medium text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline">
|
||||
Create account
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/fayda-setup"
|
||||
className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
Already verified with Fayda? Set up your password
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fullName}
|
||||
onChange={(e) => { setFullName(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="e.g. Abebe Kebede"
|
||||
autoComplete="name"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
{identifierIsEmail ? 'Phone number' : 'Email address (optional)'}
|
||||
</label>
|
||||
<input
|
||||
type={identifierIsEmail ? 'tel' : 'email'}
|
||||
value={secondaryContact}
|
||||
onChange={(e) => { setSecondaryContact(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder={identifierIsEmail ? '+251912345678' : 'your@email.com'}
|
||||
autoComplete={identifierIsEmail ? 'tel' : 'email'}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{identifierIsEmail
|
||||
? "We'll text your verification code to this number."
|
||||
: "For receipts and booking confirmations. Your verification code is sent by SMS either way."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center">
|
||||
<button
|
||||
onClick={() => router.push('/booking/search')}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400"
|
||||
|
||||
@@ -83,6 +83,7 @@ interface PackageDetail {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
imageUrl?: string | null;
|
||||
status: string;
|
||||
boardingTime: string;
|
||||
departureTime: string;
|
||||
@@ -1042,7 +1043,7 @@ export default function PackageDetailPage() {
|
||||
{/* Hero */}
|
||||
<div className="relative h-56 md:h-80 bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] overflow-hidden">
|
||||
<Image
|
||||
src="/packages/package.jpeg"
|
||||
src={pkg.imageUrl || "/packages/package.jpeg"}
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Train, CheckCircle, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const router = useRouter();
|
||||
@@ -24,8 +25,8 @@ function ResetPasswordContent() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters.');
|
||||
if (!isStrongPassword(newPassword)) {
|
||||
setError(PASSWORD_RULE);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
|
||||
@@ -6,18 +6,8 @@ import Link from 'next/link';
|
||||
import { Train, ArrowLeft, ShieldCheck } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { isStrongPassword } from '@/lib/password';
|
||||
|
||||
// Mirrors the IAM set-password requirement (class-validator @IsStrongPassword defaults):
|
||||
// min length 8, with lower- and upper-case letters, a number, and a symbol.
|
||||
function isStrongPassword(pw: string): boolean {
|
||||
return (
|
||||
pw.length >= 8 &&
|
||||
/[a-z]/.test(pw) &&
|
||||
/[A-Z]/.test(pw) &&
|
||||
/[0-9]/.test(pw) &&
|
||||
/[^A-Za-z0-9]/.test(pw)
|
||||
);
|
||||
}
|
||||
|
||||
function VerifyAccountContent() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -192,18 +192,12 @@ export default function AppSidebar() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-1 pt-1">
|
||||
<div className="px-1 pt-1">
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex-1 text-center px-3 py-2 text-sm font-medium text-gray-100 hover:bg-white/10 rounded-lg transition-colors"
|
||||
className="block text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex-1 text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Register
|
||||
Sign in or register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, CheckCircle } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -32,8 +33,8 @@ export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordM
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (newPassword.length < 6) {
|
||||
setError('New password must be at least 6 characters.');
|
||||
if (!isStrongPassword(newPassword)) {
|
||||
setError(PASSWORD_RULE);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Train, ShieldCheck, CheckCircle, Info, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||
|
||||
interface FaydaSetupWizardProps {
|
||||
// Prefilled OTP when landing from the SMS link (/set-password?verificationCode=...)
|
||||
@@ -41,8 +42,8 @@ export default function FaydaSetupWizard({ initialOtp }: FaydaSetupWizardProps)
|
||||
const handleSetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters.');
|
||||
if (!isStrongPassword(newPassword)) {
|
||||
setError(PASSWORD_RULE);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
|
||||
@@ -42,6 +42,7 @@ interface HolidayPackage {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
imageUrl?: string | null;
|
||||
status: string;
|
||||
departureTime: string;
|
||||
validFrom: string;
|
||||
@@ -165,7 +166,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
{/* ── Left: Image ── */}
|
||||
<div className="relative md:w-[46%] h-64 md:h-auto flex-shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src="/packages/package.jpeg"
|
||||
src={pkg.imageUrl || "/packages/package.jpeg"}
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
|
||||
@@ -330,11 +331,21 @@ function PackageCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
return (
|
||||
<Link href={`/packages/${pkg.id}`} className="group block h-full">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl overflow-hidden border border-gray-200 dark:border-gray-800 hover:border-primary/60 hover:shadow-xl transition-all duration-300 flex flex-col h-full">
|
||||
{/* Image / Gradient */}
|
||||
{/* Image / Gradient fallback */}
|
||||
<div className="relative h-44 overflow-hidden bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] flex-shrink-0">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-6xl opacity-20">🌍</span>
|
||||
</div>
|
||||
{pkg.imageUrl ? (
|
||||
<Image
|
||||
src={pkg.imageUrl}
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-6xl opacity-20">🌍</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2">
|
||||
|
||||
@@ -36,6 +36,42 @@ export const iamAuthApi = {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('auth_token')}` },
|
||||
}),
|
||||
|
||||
// --- Staged sign-in (/login) -------------------------------------------------
|
||||
// Step 1: hand the server one field and let it say which branch follows. `identifier`
|
||||
// is a phone number or an email; the server works out which.
|
||||
lookupIdentifier: (identifier: string) =>
|
||||
axios.post<{
|
||||
success: boolean;
|
||||
data: {
|
||||
status: 'PASSWORD' | 'NEEDS_PASSWORD_SETUP' | 'NOT_FOUND';
|
||||
method?: 'fayda' | 'pending';
|
||||
maskedPhone?: string;
|
||||
};
|
||||
}>(`${API_URL}/auth/identifier/lookup`, { identifier }),
|
||||
|
||||
// Step 2a: SMS the code for an account that exists but has no password yet.
|
||||
// Always resolves — the server reports { sent: true } even for an unknown identifier.
|
||||
requestPasswordSetup: (identifier: string) =>
|
||||
axios.post(`${API_URL}/auth/password-setup/request`, { identifier }),
|
||||
|
||||
// Step 2b: redeem the code and set the password. Unlike the older Fayda dance this
|
||||
// returns a usable session directly, so the user lands signed in rather than back on
|
||||
// the login form. Same response shape as POST /auth/login.
|
||||
completePasswordSetup: (data: {
|
||||
identifier: string;
|
||||
otp: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}) =>
|
||||
axios.post<{
|
||||
success: boolean;
|
||||
data: {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
user: { id: string; iamUserId: string; email: string | null; passengerId: string };
|
||||
};
|
||||
}>(`${API_URL}/auth/password-setup/complete`, data),
|
||||
|
||||
faydaRequestPasswordSetup: (phoneNumber: string) =>
|
||||
axios.post(`${API_URL}/auth/fayda/request-password-setup`, { phoneNumber }),
|
||||
|
||||
|
||||
@@ -113,13 +113,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
login: async (email: string, password: string) => {
|
||||
const response: any = await apiClient.post('/auth/login', { email, password });
|
||||
const { token, user } = response.data || response;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
set({ user, token, isAuthenticated: true });
|
||||
// `setUser` is the one place a session is persisted. The staged sign-in's
|
||||
// password-setup branch establishes a session without going through /auth/login,
|
||||
// so it calls the same action rather than duplicating the storage writes.
|
||||
get().setUser(user, token);
|
||||
},
|
||||
|
||||
register: async (data: RegisterData): Promise<RegisterResult> => {
|
||||
|
||||
21
apps/edr-passenger-web/portal/src/lib/password.ts
Normal file
21
apps/edr-passenger-web/portal/src/lib/password.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The one password rule the portal enforces.
|
||||
*
|
||||
* It mirrors class-validator's `@IsStrongPassword` defaults, which is what the IAM applies on
|
||||
* `PATCH /v1/auth/set-password` and what `POST /auth/password-setup/complete` applies on the
|
||||
* passenger API. Screens that used a looser check (`length < 6`) accepted passwords the server
|
||||
* then rejected with an opaque 400, so every screen shares this instead.
|
||||
*/
|
||||
export function isStrongPassword(pw: string): boolean {
|
||||
return (
|
||||
pw.length >= 8 &&
|
||||
/[a-z]/.test(pw) &&
|
||||
/[A-Z]/.test(pw) &&
|
||||
/[0-9]/.test(pw) &&
|
||||
/[^A-Za-z0-9]/.test(pw)
|
||||
);
|
||||
}
|
||||
|
||||
/** The rule stated for humans. Shown as helper text and reused as the validation message. */
|
||||
export const PASSWORD_RULE =
|
||||
'Password must be at least 8 characters and include an upper-case letter, a lower-case letter, a number and a symbol.';
|
||||
Reference in New Issue
Block a user