Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx
2026-06-25 22:23:44 +03:00

441 lines
24 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, Trash2, ShieldCheck, ShieldOff, Star, Wallet } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { passengersApi, apiClient } from '@/lib/api';
import { formatDate, formatDateTime } from '@/lib/utils';
import { PassengerFilters } from '@/types';
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">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
const TIER_COLORS: Record<string, string> = {
BRONZE: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 border-orange-200 dark:border-orange-800',
SILVER: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600',
GOLD: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800',
};
export default function PassengersPage() {
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
const [showExtraFilters, setShowExtraFilters] = useState(false);
const [extraFilters, setExtraFilters] = useState({ gender: '', nationality: '', dateFrom: '', dateTo: '' });
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
const [deleteError, setDeleteError] = useState<string | null>(null);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
fullName: true, email: true, phone: true, gender: true, nationality: true, verified: true,
});
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['passengers'] });
setDeleteConfirm({ isOpen: false, passenger: null });
setDeleteError(null);
},
onError: (error: any) => {
setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete passenger');
},
});
const { data, isLoading, error } = useQuery({
queryKey: ['passengers', filters],
queryFn: () => passengersApi.getAll(filters),
});
const PASSENGER_COLS = [
{ key: 'fullName', label: 'Full Name' }, { key: 'email', label: 'Email' }, { key: 'phone', label: 'Phone' },
{ key: 'dateOfBirth', label: 'Date of Birth' }, { key: 'gender', label: 'Gender' },
{ key: 'nationality', label: 'Nationality' }, { key: 'verified', label: 'Verified' },
];
const confirmExportPassengers = async () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (!cols.length) { alert('Please select at least one column'); return; }
// Fetch all records
const allData = await passengersApi.getAll({ ...filters, page: 1, pageSize: 9999 });
const exportItems = (allData?.items || []).filter((p: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const headers = PASSENGER_COLS.filter(c => cols.includes(c.key)).map(c => c.label);
const rows = exportItems.map((p: any) =>
PASSENGER_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'fullName': return p.fullName || '';
case 'email': return p.email || '';
case 'phone': return p.phone || '';
case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : '';
case 'gender': return p.gender || '';
case 'nationality': return p.nationality || '';
case 'verified': return p.faydaVerified ? 'Yes' : 'No';
default: return '';
}
})
);
const dateStr = new Date().toISOString().split('T')[0];
if (exportFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write(`<!DOCTYPE html><html><head><title>Passengers Export</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>Passengers Export — ${dateStr}</h2><table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
rows.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 (exportFormat === 'excel') {
const tsv = [headers.join('\t'), ...rows.map((r: string[]) => r.join('\t'))].join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `passengers-${dateStr}.xls`; a.click();
} else {
const csv = [headers.map(h => `"${h}"`).join(','), ...rows.map((r: string[]) => r.map((v: string) => `"${v}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `passengers-${dateStr}.csv`; a.click();
}
setExportModalOpen(false);
};
const columns = [
{
key: 'fullName', label: 'Name', sortable: true,
render: (p: any) => (
<div>
<div className="font-medium">{p.fullName}</div>
<div className="text-sm text-muted-foreground">{p.email}</div>
</div>
),
},
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || 'N/A' },
{ key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' },
{ key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
{ key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' },
{
key: 'verified', label: 'Status',
render: (p: any) => (
<Badge variant="status" status={p.faydaVerified ? 'CONFIRMED' : 'PENDING'}>
{p.faydaVerified ? 'Verified' : 'Unverified'}
</Badge>
),
},
];
const actions = [
{ label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye },
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2 },
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Passengers</h1>
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
</div>
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
<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 loading passengers: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<div className="mb-4 space-y-3">
<div className="flex flex-wrap gap-3">
<div className="flex-1 min-w-48">
<input type="text" placeholder="Search by name, email, or phone..." className="input"
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
</div>
<select className="input w-44" value={filters.verified?.toString() || ''}
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}>
<option value="">All Passengers</option>
<option value="true">Verified</option>
<option value="false">Unverified</option>
</select>
<button type="button" className="input w-auto px-4 text-sm font-medium text-primary border-primary/40"
onClick={() => setShowExtraFilters(v => !v)}>
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
</button>
</div>
{showExtraFilters && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-1">
<div>
<label className="label">Gender</label>
<select className="input" value={extraFilters.gender}
onChange={(e) => setExtraFilters({ ...extraFilters, gender: e.target.value })}>
<option value="">All Genders</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
</div>
<div>
<label className="label">Nationality</label>
<input type="text" className="input" placeholder="e.g. Ethiopian"
value={extraFilters.nationality}
onChange={(e) => setExtraFilters({ ...extraFilters, nationality: e.target.value })} />
</div>
<div>
<label className="label">Registered From</label>
<input type="date" className="input" value={extraFilters.dateFrom}
onChange={(e) => setExtraFilters({ ...extraFilters, dateFrom: e.target.value })} />
</div>
<div>
<label className="label">Registered To</label>
<input type="date" className="input" value={extraFilters.dateTo}
onChange={(e) => setExtraFilters({ ...extraFilters, dateTo: e.target.value })} />
</div>
</div>
)}
</div>
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No passengers found" />
{data?.meta && (
<Pagination currentPage={data.meta.page} totalPages={data.meta.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })} />
)}
</div>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => { setDeleteConfirm({ isOpen: false, passenger: null }); setDeleteError(null); }}
onConfirm={async () => {
if (deleteConfirm.passenger) {
await deleteMutation.mutateAsync(deleteConfirm.passenger.id);
}
}}
title="Delete Passenger"
message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`}
confirmText="Delete" isDanger
isLoading={deleteMutation.isPending}
warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records."
error={deleteError ?? undefined}
/>
{/* Passenger Details Modal */}
<Modal isOpen={!!selectedPassenger} onClose={() => setSelectedPassenger(null)} title="Passenger Details" size="xl">
{selectedPassenger && (() => {
const p = selectedPassenger;
const isVerified = !!p.faydaVerified || !!p.nationalId;
const tier = p.loyalty?.tier || p.loyaltyTier;
const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE;
return (
<div>
{/* Gradient header with avatar */}
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
<span className="text-white text-2xl font-bold">{(p.fullName || p.email || '?')[0].toUpperCase()}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-xl font-bold truncate">{p.fullName}</p>
<p className="text-emerald-200 text-sm truncate">{p.email}</p>
</div>
<div className="text-right shrink-0 space-y-1">
<div>
<Badge variant="status" status={isVerified ? 'CONFIRMED' : 'PENDING'}>
{isVerified ? '✓ Verified' : 'Unverified'}
</Badge>
</div>
{tier && (
<span className={`inline-flex items-center gap-1 text-xs font-bold px-2.5 py-0.5 rounded-full border ${tierColor}`}>
<Star className="w-3 h-3" />{tier}
</span>
)}
</div>
</div>
{/* Quick stats */}
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Loyalty Points', value: (p.passenger?.loyalty?.pointsBalance ?? p.loyalty?.pointsBalance ?? 0).toLocaleString() },
{ label: 'Wallet Balance', value: p.passenger?.wallet || p.wallet ? `ETB ${((p.passenger?.wallet?.balanceMinor ?? p.wallet?.balanceMinor ?? 0) / 100).toFixed(2)}` : '—' },
{ label: 'Nationality', value: p.nationality || '—' },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-emerald-200 text-xs">{label}</p>
<p className="text-white text-sm font-bold truncate">{value}</p>
</div>
))}
</div>
</div>
<div className="space-y-6">
{/* Personal */}
<section>
<SectionHeader title="Personal Information" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Full Name" value={p.fullName} />
<Field label="Date of Birth" value={p.dateOfBirth ? formatDate(p.dateOfBirth) : ''} />
<Field label="Gender" value={p.gender} />
<Field label="Nationality" value={p.nationality} />
<Field label="Nationality Code" value={p.nationalityCode} />
<Field label="Preferred Language" value={p.passenger?.preferredLanguage || p.preferredLanguage} />
<Field label="Last Login" value={p.lastLoginAt ? formatDateTime(p.lastLoginAt) : 'Never'} />
<Field label="Role" value={p.role} />
</div>
</section>
{/* Contact */}
<section>
<SectionHeader title="Contact Information" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Email" value={p.email} />
<Field label="Phone" value={p.phone} />
<Field label="Address" value={p.address} />
</div>
</section>
{/* Identification */}
<section>
<SectionHeader title="Identification & Verification" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-muted/40 rounded-lg p-3 col-span-2 md:col-span-1">
<p className="text-xs text-muted-foreground mb-2">Fayda (National ID)</p>
<div className="flex items-center gap-2">
{isVerified
? <ShieldCheck className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
: <ShieldOff className="w-4 h-4 text-muted-foreground shrink-0" />}
<span className={`text-sm font-semibold ${isVerified ? 'text-emerald-700 dark:text-emerald-400' : 'text-muted-foreground'}`}>
{isVerified ? 'Verified' : 'Not verified'}
</span>
</div>
{p.faydaVerifiedAt && <p className="text-xs text-muted-foreground mt-1">{formatDateTime(p.faydaVerifiedAt)}</p>}
</div>
<Field label="Passport Number" value={p.passportNumber} mono />
<Field label="Passport Country" value={p.passportCountry} />
<Field label="Passport Expiry" value={p.passportExpiryDate ? formatDate(p.passportExpiryDate) : ''} />
</div>
</section>
{/* Loyalty & Wallet */}
{(p.passenger?.loyalty || p.loyalty || p.passenger?.wallet || p.wallet) && (
<section>
<SectionHeader title="Loyalty & Wallet" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{(p.passenger?.loyalty || p.loyalty) && (() => {
const loyalty = p.passenger?.loyalty || p.loyalty;
return (
<>
<div className={`rounded-lg p-3 border ${tierColor}`}>
<p className="text-xs font-medium mb-1 opacity-70">Tier</p>
<div className="flex items-center gap-1.5">
<Star className="w-4 h-4" />
<span className="text-sm font-bold">{loyalty.tier}</span>
</div>
</div>
<Field label="Points Balance" value={(loyalty.pointsBalance ?? 0).toLocaleString()} />
<Field label="Lifetime Points" value={(loyalty.lifetimePoints ?? 0).toLocaleString()} />
</>
);
})()}
{(p.passenger?.wallet || p.wallet) && (() => {
const wallet = p.passenger?.wallet || p.wallet;
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-3">
<p className="text-xs text-blue-600 dark:text-blue-400 mb-1 flex items-center gap-1"><Wallet className="w-3 h-3" />Wallet Balance</p>
<p className="text-base font-bold text-blue-800 dark:text-blue-300">
ETB {((wallet.balanceMinor ?? 0) / 100).toFixed(2)}
</p>
</div>
);
})()}
</div>
</section>
)}
{/* Account */}
<section>
<SectionHeader title="Account IDs" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="User ID" value={p.id || p.userId} mono truncate />
<Field label="Passenger ID" value={p.passenger?.id || p.passengerId} mono truncate />
</div>
</section>
{/* Timestamps */}
<section>
<SectionHeader title="Timestamps" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Registered" value={p.createdAt ? formatDateTime(p.createdAt) : ''} />
<Field label="Last Updated" value={p.updatedAt ? formatDateTime(p.updatedAt) : ''} />
<Field label="Fayda Verified At" value={p.faydaVerifiedAt ? formatDateTime(p.faydaVerifiedAt) : 'N/A'} />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelectedPassenger(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Passengers" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="label">Date From (Registered)</label><input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} /></div>
<div><label className="label">Date To (Registered)</label><input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} /></div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto">
{PASSENGER_COLS.map((col) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input type="checkbox" checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300" />
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</div>
<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="exportFormatP" value={fmt} checked={exportFormat === fmt}
onChange={() => setExportFormat(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={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExportPassengers}>Export</ActionButton>
</div>
</div>
</Modal>
</div>
);
}