mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: coach utilization report with schedule filtering and export functionality
This commit is contained in:
@@ -229,10 +229,11 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Get('coaches/utilization')
|
||||
@ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach' })
|
||||
@ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach for a selected schedule' })
|
||||
@ApiQuery({ name: 'scheduleId', required: false, description: 'Optional schedule UUID to scope the utilization report to that schedule.' })
|
||||
@ApiResponse({ status: 200, description: 'Coach utilization data' })
|
||||
getCoachUtilization() {
|
||||
return this.service.getCoachUtilization();
|
||||
getCoachUtilization(@Query('scheduleId') scheduleId?: string) {
|
||||
return this.service.getCoachUtilization(scheduleId);
|
||||
}
|
||||
|
||||
@Get('coaches/:id')
|
||||
|
||||
@@ -822,12 +822,35 @@ export class FleetService {
|
||||
};
|
||||
}
|
||||
|
||||
async getCoachUtilization() {
|
||||
async getCoachUtilization(scheduleId?: string) {
|
||||
const where = scheduleId ? { scheduleId } : {};
|
||||
|
||||
const coaches = await this.prisma.coach.findMany({
|
||||
where: scheduleId
|
||||
? {
|
||||
assignments: {
|
||||
some: { scheduleId },
|
||||
},
|
||||
}
|
||||
: {},
|
||||
include: {
|
||||
coachType: true,
|
||||
seats: { select: { id: true, status: true } },
|
||||
seats: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
bookingSeats: {
|
||||
where,
|
||||
select: { id: true },
|
||||
},
|
||||
blocks: {
|
||||
where,
|
||||
select: { id: true, reasonCategory: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
assignments: {
|
||||
where,
|
||||
include: {
|
||||
schedule: {
|
||||
select: { id: true, departureAt: true, status: true, _count: { select: { bookings: true } } },
|
||||
@@ -842,10 +865,12 @@ export class FleetService {
|
||||
|
||||
return coaches.map((coach) => {
|
||||
const totalSeats = coach.seats.length;
|
||||
const bookedSeats = coach.seats.filter((s) => s.status === 'BOOKED').length;
|
||||
const blockedSeats = coach.seats.filter((s) => s.status === 'BLOCKED').length;
|
||||
const maintenanceSeats = coach.seats.filter((s) => (s.status as string) === 'UNDER_MAINTENANCE').length;
|
||||
const availableSeats = coach.seats.filter((s) => s.status === 'AVAILABLE').length;
|
||||
const bookedSeats = coach.seats.filter((s) => (s.bookingSeats?.length ?? 0) > 0).length;
|
||||
const blockedSeats = coach.seats.filter((s) => (s.blocks?.length ?? 0) > 0).length;
|
||||
const maintenanceSeats = coach.seats.filter((s) => (s.blocks ?? []).some((b) => b.reasonCategory === 'MAINTENANCE')).length;
|
||||
const availableSeats = scheduleId
|
||||
? Math.max(totalSeats - bookedSeats - blockedSeats - maintenanceSeats, 0)
|
||||
: coach.seats.filter((s) => s.status === 'AVAILABLE').length;
|
||||
const totalAssignments = coach.assignments.length;
|
||||
const totalBookings = coach.assignments.reduce((sum, a) => sum + ((a.schedule as any)._count?.bookings ?? 0), 0);
|
||||
const utilizationRate = totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -124,6 +124,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 },
|
||||
|
||||
Reference in New Issue
Block a user