Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into feature/group-booking

This commit is contained in:
Roba Boru
2026-08-24 18:05:21 +03:00
324 changed files with 25363 additions and 5666 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -126,6 +126,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
items: [
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view },
{ name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },

View File

@@ -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)