mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 17:08:18 +00:00
Merge branch 'dev' into tests
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { LogIn, Users, CheckCircle, XCircle, BarChart3, Train, Download } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import ActionButton from "@/components/ui/ActionButton";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import Pagination from "@/components/ui/Pagination";
|
||||
import { usePagination } from "@/lib/use-pagination";
|
||||
|
||||
interface ScheduleOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface BoardingRow {
|
||||
bookingRef: string;
|
||||
passengerName: string;
|
||||
coachNumber: string | null;
|
||||
seatNumber: string | null;
|
||||
seatClassName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
boarded: boolean;
|
||||
boardedAt: string | null;
|
||||
validatorId: string | null;
|
||||
bookingStatus: string;
|
||||
}
|
||||
|
||||
interface BoardingReport {
|
||||
schedule: {
|
||||
id: string;
|
||||
trainName: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
departureAt: string;
|
||||
arrivalAt: string;
|
||||
};
|
||||
summary: {
|
||||
total: number;
|
||||
boardedCount: number;
|
||||
notBoardedCount: number;
|
||||
boardingRate: number;
|
||||
};
|
||||
byCoach: { coachNumber: string; total: number; boarded: number }[];
|
||||
rows: BoardingRow[];
|
||||
}
|
||||
|
||||
type Tab = "summary" | "details";
|
||||
|
||||
export default function BoardingReportPage() {
|
||||
const [scheduleId, setScheduleId] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("summary");
|
||||
const [search, setSearch] = useState("");
|
||||
const [filterBoarded, setFilterBoarded] = useState<"ALL" | "BOARDED" | "NOT_BOARDED">("ALL");
|
||||
|
||||
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
|
||||
queryKey: ["report-schedules-all"],
|
||||
queryFn: () => apiClient.get("/reports/schedules?all=true"),
|
||||
});
|
||||
const schedules = schedulesRaw ?? [];
|
||||
|
||||
const { data, isLoading, isError } = useQuery<BoardingReport>({
|
||||
queryKey: ["boarding-report", scheduleId],
|
||||
queryFn: () => apiClient.get(`/reports/boarding?scheduleId=${scheduleId}`),
|
||||
enabled: !!scheduleId,
|
||||
});
|
||||
|
||||
const filtered = (data?.rows ?? []).filter((r) => {
|
||||
if (filterBoarded === "BOARDED" && !r.boarded) return false;
|
||||
if (filterBoarded === "NOT_BOARDED" && r.boarded) return false;
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
r.passengerName.toLowerCase().includes(q) ||
|
||||
r.bookingRef.toLowerCase().includes(q) ||
|
||||
(r.seatNumber ?? "").toLowerCase().includes(q) ||
|
||||
(r.coachNumber ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const { paged, page, totalPages, setPage, reset } = usePagination(filtered, 50);
|
||||
|
||||
const doExport = () => {
|
||||
if (!filtered.length) return;
|
||||
const headers = ["Booking Ref", "Passenger", "Seat Class", "Coach", "Seat", "Origin", "Destination", "Boarded", "Boarded At", "Validator"];
|
||||
const rows = filtered.map((r) => [
|
||||
r.bookingRef,
|
||||
r.passengerName,
|
||||
r.seatClassName ?? "—",
|
||||
r.coachNumber ?? "—",
|
||||
r.seatNumber ?? "—",
|
||||
r.origin ?? "—",
|
||||
r.destination ?? "—",
|
||||
r.boarded ? "Yes" : "No",
|
||||
r.boardedAt ? formatDateTime(r.boardedAt) : "—",
|
||||
r.validatorId ?? "—",
|
||||
]);
|
||||
const csv = [
|
||||
headers.map((h) => `"${h}"`).join(","),
|
||||
...rows.map((row) => row.map((v) => `"${v}"`).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 = `boarding-${scheduleId}-${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">Boarding Report</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Boarding status and passenger breakdown for a schedule
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Schedule selector */}
|
||||
<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);
|
||||
setTab("summary");
|
||||
setSearch("");
|
||||
setFilterBoarded("ALL");
|
||||
}}
|
||||
disabled={loadingSchedules}
|
||||
>
|
||||
<option value="">
|
||||
{loadingSchedules ? "Loading schedules…" : "Select a schedule…"}
|
||||
</option>
|
||||
{schedules.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.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 report.</p>}
|
||||
</div>
|
||||
|
||||
{!scheduleId && (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<LogIn className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>Select a schedule above to load the boarding report</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* Schedule info */}
|
||||
<div className="card flex items-center gap-4">
|
||||
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-2.5">
|
||||
<Train className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{data.schedule.trainName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.schedule.origin} → {data.schedule.destination} ·
|
||||
Departure: {formatDateTime(data.schedule.departureAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-border flex">
|
||||
<button
|
||||
onClick={() => setTab("summary")}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "summary" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
Summary
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("details")}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "details" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
Passenger Details{data.rows.length > 0 ? ` (${data.rows.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary Tab */}
|
||||
{tab === "summary" && (
|
||||
<div className="space-y-6">
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Total Tickets</p>
|
||||
<p className="text-2xl font-bold mt-2">{data.summary.total}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Confirmed passengers</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-blue-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">Boarded</p>
|
||||
<p className="text-2xl font-bold mt-2 text-green-600 dark:text-green-400">
|
||||
{data.summary.boardedCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Scanned at gate</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-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">Not Boarded</p>
|
||||
<p className="text-2xl font-bold mt-2 text-red-600 dark:text-red-400">
|
||||
{data.summary.notBoardedCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">No-shows / pending</p>
|
||||
</div>
|
||||
<XCircle className="h-8 w-8 text-red-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">Boarding Rate</p>
|
||||
<p className="text-2xl font-bold mt-2 text-purple-600 dark:text-purple-400">
|
||||
{data.summary.boardingRate}%
|
||||
</p>
|
||||
<div className="w-full bg-muted rounded-full h-1.5 mt-2">
|
||||
<div
|
||||
className="bg-purple-500 h-1.5 rounded-full"
|
||||
style={{ width: `${data.summary.boardingRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<BarChart3 className="h-8 w-8 text-purple-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* By Coach */}
|
||||
{data.byCoach.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
|
||||
<th className="pb-2 pr-4">Coach</th>
|
||||
<th className="pb-2 pr-4 text-right">Total</th>
|
||||
<th className="pb-2 pr-4 text-right">Boarded</th>
|
||||
<th className="pb-2 pr-4 text-right">Not Boarded</th>
|
||||
<th className="pb-2">Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{data.byCoach.map((c) => {
|
||||
const rate = c.total > 0 ? +((c.boarded / c.total) * 100).toFixed(1) : 0;
|
||||
return (
|
||||
<tr key={c.coachNumber} className="hover:bg-muted/30">
|
||||
<td className="py-2 pr-4 font-semibold">{c.coachNumber}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{c.total}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums text-green-600 dark:text-green-400">{c.boarded}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums text-red-600 dark:text-red-400">{c.total - c.boarded}</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-muted rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-emerald-500 h-1.5 rounded-full"
|
||||
style={{ width: `${rate}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums text-xs w-10 text-right">{rate}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Tab */}
|
||||
{tab === "details" && (
|
||||
<div className="card p-0">
|
||||
<div className="flex items-center gap-2 px-4 pt-4 pb-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
className="input max-w-xs"
|
||||
placeholder="Name, booking ref, seat…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); reset(); }}
|
||||
/>
|
||||
<select
|
||||
className="input w-44"
|
||||
value={filterBoarded}
|
||||
onChange={(e) => { setFilterBoarded(e.target.value as "ALL" | "BOARDED" | "NOT_BOARDED"); reset(); }}
|
||||
>
|
||||
<option value="ALL">All Passengers</option>
|
||||
<option value="BOARDED">Boarded Only</option>
|
||||
<option value="NOT_BOARDED">Not Boarded</option>
|
||||
</select>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={!filtered.length}>
|
||||
Export CSV
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{["Booking Ref", "Passenger", "Seat Class · Coach · Seat", "Route", "Status", "Boarded At"].map((h) => (
|
||||
<th key={h} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{paged.map((row, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-4 py-3 font-mono font-semibold whitespace-nowrap">{row.bookingRef}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">{row.passengerName}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs">
|
||||
<span className="font-medium">{row.seatClassName ?? "—"}</span>
|
||||
{row.coachNumber && <span className="text-muted-foreground"> · {row.coachNumber}</span>}
|
||||
{row.seatNumber && <span className="text-muted-foreground"> · #{row.seatNumber}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.origin && row.destination ? `${row.origin} → ${row.destination}` : (row.origin ?? row.destination ?? "—")}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<Badge variant="status" status={row.boarded ? "BOARDED" : "PENDING"}>
|
||||
{row.boarded ? "Boarded" : "Not Boarded"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.boardedAt ? formatDateTime(row.boardedAt) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{paged.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-sm text-muted-foreground">
|
||||
No passengers 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 data found for this schedule.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1222,6 +1222,15 @@ export default function SchedulesPage() {
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
icon={RefreshCw}
|
||||
loading={recalculateStopsMutation.isPending}
|
||||
onClick={() => editingSchedule && recalculateStopsMutation.mutate(editingSchedule.id)}
|
||||
>
|
||||
Recalculate Stop Times
|
||||
</ActionButton>
|
||||
<ActionButton type="submit" loading={updateScheduleMutation.isPending}>
|
||||
Update Schedule
|
||||
</ActionButton>
|
||||
|
||||
@@ -124,6 +124,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
|
||||
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
|
||||
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
|
||||
{ name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: PERMS.reports.view },
|
||||
{ name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view },
|
||||
// { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view },
|
||||
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { UserPlus, ChevronLeft } from 'lucide-react';
|
||||
// import { LogIn } from 'lucide-react'; // TODO: re-enable auth — used by commented-out SignIn/Register button
|
||||
import { UserPlus, ChevronLeft, LogIn } from 'lucide-react';
|
||||
|
||||
function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -96,7 +95,6 @@ export default function AuthCheckPage() {
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* TODO: re-enable auth — SignIn or Register button commented out until auth integration
|
||||
<Tooltip content={[
|
||||
'Saved passenger details',
|
||||
'View booking history',
|
||||
@@ -110,7 +108,6 @@ export default function AuthCheckPage() {
|
||||
SignIn or Register
|
||||
</button>
|
||||
</Tooltip>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
|
||||
@@ -933,27 +933,60 @@ function PassengersForm() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch passenger profile from backend
|
||||
const passengerData: any = await apiClient.get(`/passengers/me`);
|
||||
// Fetch passenger profile from backend. This may be null (e.g. no Passenger row linked
|
||||
// yet) — in that case fall back to the `user` object, which /auth/profile hydrates from
|
||||
// the same IAM record (name, gender, DOB, nationality, Fayda status). Merging the two
|
||||
// means an already-verified passenger's form still prefills from whichever source has
|
||||
// the data, rather than being left blank.
|
||||
const passengerData: any = (await apiClient.get(`/passengers/me`)) || {};
|
||||
|
||||
if (!passengerData) {
|
||||
const pick = (a: any, b: any) => (a !== undefined && a !== null && a !== '' ? a : b);
|
||||
|
||||
// Nationality for THIS booking is the one chosen at search — the passengers-page
|
||||
// nationality field is read-only. It, not the account's stored nationality, decides
|
||||
// whether the Fayda gate applies, so a logged-in user who picked "Other"/"Djiboutian"
|
||||
// isn't wrongly forced into Fayda (Fayda is only for Ethiopian nationals).
|
||||
const nationality = searchCriteria?.nationality || pick(passengerData.nationality, user.nationality) || 'ETHIOPIAN';
|
||||
const isEthiopian = String(nationality).toUpperCase() === 'ETHIOPIAN';
|
||||
const isVerified = Boolean(pick(passengerData.faydaVerified, user.faydaVerified));
|
||||
|
||||
// A logged-in but NOT Fayda-verified Ethiopian must pass the Fayda gate exactly like a
|
||||
// guest. Prefilling their identity and expanding the form would let them submit the
|
||||
// booking without ever verifying — only a verified passenger may pass. When Fayda is
|
||||
// globally disabled there is no gate, so the restriction doesn't apply.
|
||||
const mustVerifyFayda = isEthiopian && !isVerified && faydaEnabled;
|
||||
|
||||
// Nationality + contact aren't identity-verifying, so they're safe to prefill either way.
|
||||
setValue('passengers.0.nationality', nationality);
|
||||
const phoneVal = pick(passengerData.phone, user.phone);
|
||||
if (phoneVal) setValue('passengers.0.phone', phoneVal);
|
||||
const emailVal = pick(passengerData.email, user.email);
|
||||
if (emailVal) setValue('passengers.0.email', emailVal);
|
||||
|
||||
if (mustVerifyFayda) {
|
||||
// Force the Fayda gate: leave name/DOB/gender empty and keep the form collapsed so the
|
||||
// "Verify with Fayda" screen is shown instead of an editable, pre-filled form.
|
||||
setValue('passengers.0.faydaVerified', false);
|
||||
setValue('passengers.0.formExpanded', false);
|
||||
setFormInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only populate first passenger
|
||||
setValue('passengers.0.name', passengerData?.fullName || user.fullName || '');
|
||||
setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || '');
|
||||
if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any);
|
||||
setValue('passengers.0.nationality', passengerData?.nationality || user.nationality || 'ETHIOPIAN');
|
||||
if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || '');
|
||||
if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || '');
|
||||
if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber);
|
||||
setValue('passengers.0.passportCountry', passengerData?.passportCountry || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : ''));
|
||||
if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate);
|
||||
if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate);
|
||||
if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority);
|
||||
setValue('passengers.0.faydaVerified', passengerData?.faydaVerified || user.faydaVerified || false);
|
||||
// Verified Ethiopian, or a non-Ethiopian (passport flow): prefill everything and expand.
|
||||
setValue('passengers.0.name', pick(passengerData.fullName, user.fullName) || '');
|
||||
setValue('passengers.0.dateOfBirth', pick(passengerData.dateOfBirth, user.dateOfBirth) || '');
|
||||
const genderVal = pick(passengerData.gender, user.gender);
|
||||
if (genderVal) setValue('passengers.0.gender', genderVal as any);
|
||||
const passportNumberVal = pick(passengerData.passportNumber, user.passportNumber);
|
||||
if (passportNumberVal) setValue('passengers.0.passportNumber', passportNumberVal);
|
||||
setValue('passengers.0.passportCountry', pick(passengerData.passportCountry, user.passportCountry) || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : ''));
|
||||
const passportIssueVal = pick(passengerData.passportIssueDate, user.passportIssueDate);
|
||||
if (passportIssueVal) setValue('passengers.0.passportIssueDate', passportIssueVal);
|
||||
const passportExpiryVal = pick(passengerData.passportExpiryDate, user.passportExpiryDate);
|
||||
if (passportExpiryVal) setValue('passengers.0.passportExpiryDate', passportExpiryVal);
|
||||
const passportAuthVal = pick(passengerData.passportIssuingAuthority, user.passportIssuingAuthority);
|
||||
if (passportAuthVal) setValue('passengers.0.passportIssuingAuthority', passportAuthVal);
|
||||
setValue('passengers.0.faydaVerified', isVerified);
|
||||
setValue('passengers.0.formExpanded', true);
|
||||
|
||||
setFormInitialized(true);
|
||||
@@ -1126,10 +1159,21 @@ function PassengersForm() {
|
||||
// Identity fields sourced from a completed Fayda verification are locked — the
|
||||
// passenger can't edit the verified name / date of birth / gender.
|
||||
const isFaydaLocked = !!passengers[index]?.faydaVerified;
|
||||
// ...but lock each field only when it actually carries a value. A verified profile
|
||||
// can be missing a field (e.g. a Fayda *login* record whose metadata has no date of
|
||||
// birth) — locking an empty, required input would strand the user with no way to
|
||||
// fill it or submit. A missing field stays editable so they can complete it.
|
||||
const isNameLocked = isFaydaLocked && !!passengers[index]?.name;
|
||||
const isDobLocked = isFaydaLocked && !!passengers[index]?.dateOfBirth;
|
||||
const isGenderLocked = isFaydaLocked && !!passengers[index]?.gender;
|
||||
// Contact fields lock only when Fayda actually supplied them; a value Fayda left
|
||||
// blank stays editable so the passenger can add their own phone/email.
|
||||
const isPhoneLocked = !!passengers[index]?.faydaPhoneLocked;
|
||||
const isEmailLocked = !!passengers[index]?.faydaEmailLocked;
|
||||
// A logged-in, already-verified primary passenger's contact details also come from
|
||||
// their verified profile — lock those too, alongside name/DOB/gender. Only lock a
|
||||
// field that actually has a value, so an incomplete profile can't strand the user
|
||||
// on an unfillable required field.
|
||||
const isPhoneLocked = !!passengers[index]?.faydaPhoneLocked || (isLoggedInAndVerified && !!passengers[index]?.phone);
|
||||
const isEmailLocked = !!passengers[index]?.faydaEmailLocked || (isLoggedInAndVerified && !!passengers[index]?.email);
|
||||
|
||||
return (
|
||||
<div key={field.id} className="card">
|
||||
@@ -1227,8 +1271,8 @@ function PassengersForm() {
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.name`)}
|
||||
readOnly={isFaydaLocked}
|
||||
className={`input-field ${isFaydaLocked ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''} ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
|
||||
readOnly={isNameLocked}
|
||||
className={`input-field ${isNameLocked ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''} ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
|
||||
placeholder="Full name as per ID"
|
||||
/>
|
||||
{errors.passengers?.[index]?.name && (
|
||||
@@ -1244,14 +1288,14 @@ function PassengersForm() {
|
||||
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
||||
disabled={isFaydaLocked}
|
||||
disabled={isDobLocked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gender */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
||||
{isFaydaLocked ? (
|
||||
{isGenderLocked ? (
|
||||
<input
|
||||
value={passengers[index]?.gender || ''}
|
||||
readOnly
|
||||
@@ -1344,14 +1388,14 @@ function PassengersForm() {
|
||||
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
|
||||
disabled={isFaydaLocked}
|
||||
disabled={isDobLocked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gender */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
||||
{isFaydaLocked ? (
|
||||
{isGenderLocked ? (
|
||||
<input
|
||||
value={passengers[index]?.gender || ''}
|
||||
readOnly
|
||||
@@ -1398,6 +1442,7 @@ function PassengersForm() {
|
||||
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
|
||||
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.phone?.message}
|
||||
disabled={isPhoneLocked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -192,22 +192,20 @@ export default function AppSidebar() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// TODO: re-enable auth — Sign in / Register links commented out until auth integration
|
||||
// <div className="flex items-center gap-2 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"
|
||||
// >
|
||||
// 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
|
||||
// </Link>
|
||||
// </div>
|
||||
null
|
||||
<div className="flex items-center gap-2 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"
|
||||
>
|
||||
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
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Home, Phone, Ticket } from 'lucide-react';
|
||||
// import { User } from 'lucide-react'; // TODO: re-enable auth — used by commented-out Sign in tab
|
||||
import { Home, Phone, Ticket, User } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
// import { useAuthStore } from '@/lib/auth-store'; // TODO: re-enable auth
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
// The linear, one-screen-at-a-time booking flow — each of these pages already
|
||||
// has its own sticky mobile CTA bar (and the mobile step strip at the top),
|
||||
@@ -22,7 +21,7 @@ const LINEAR_FLOW_PREFIXES = [
|
||||
|
||||
export default function BottomTabBar() {
|
||||
const pathname = usePathname() ?? '';
|
||||
// const isAuthenticated = useAuthStore((s) => s.isAuthenticated); // TODO: re-enable auth
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
|
||||
const isInLinearFlow = LINEAR_FLOW_PREFIXES.some((p) => pathname.startsWith(p));
|
||||
if (isInLinearFlow) return null;
|
||||
@@ -31,13 +30,12 @@ export default function BottomTabBar() {
|
||||
{ href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' },
|
||||
{ href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') },
|
||||
{ href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') },
|
||||
// TODO: re-enable auth — auth login/register tab commented out until auth integration
|
||||
// {
|
||||
// href: isAuthenticated ? '/profile' : '/login',
|
||||
// label: isAuthenticated ? 'Account' : 'Sign in',
|
||||
// icon: User,
|
||||
// match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'),
|
||||
// },
|
||||
{
|
||||
href: isAuthenticated ? '/profile' : '/login',
|
||||
label: isAuthenticated ? 'Account' : 'Sign in',
|
||||
icon: User,
|
||||
match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user