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')
|
@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' })
|
@ApiResponse({ status: 200, description: 'Coach utilization data' })
|
||||||
getCoachUtilization() {
|
getCoachUtilization(@Query('scheduleId') scheduleId?: string) {
|
||||||
return this.service.getCoachUtilization();
|
return this.service.getCoachUtilization(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('coaches/:id')
|
@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({
|
const coaches = await this.prisma.coach.findMany({
|
||||||
|
where: scheduleId
|
||||||
|
? {
|
||||||
|
assignments: {
|
||||||
|
some: { scheduleId },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {},
|
||||||
include: {
|
include: {
|
||||||
coachType: true,
|
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: {
|
assignments: {
|
||||||
|
where,
|
||||||
include: {
|
include: {
|
||||||
schedule: {
|
schedule: {
|
||||||
select: { id: true, departureAt: true, status: true, _count: { select: { bookings: true } } },
|
select: { id: true, departureAt: true, status: true, _count: { select: { bookings: true } } },
|
||||||
@@ -842,10 +865,12 @@ export class FleetService {
|
|||||||
|
|
||||||
return coaches.map((coach) => {
|
return coaches.map((coach) => {
|
||||||
const totalSeats = coach.seats.length;
|
const totalSeats = coach.seats.length;
|
||||||
const bookedSeats = coach.seats.filter((s) => s.status === 'BOOKED').length;
|
const bookedSeats = coach.seats.filter((s) => (s.bookingSeats?.length ?? 0) > 0).length;
|
||||||
const blockedSeats = coach.seats.filter((s) => s.status === 'BLOCKED').length;
|
const blockedSeats = coach.seats.filter((s) => (s.blocks?.length ?? 0) > 0).length;
|
||||||
const maintenanceSeats = coach.seats.filter((s) => (s.status as string) === 'UNDER_MAINTENANCE').length;
|
const maintenanceSeats = coach.seats.filter((s) => (s.blocks ?? []).some((b) => b.reasonCategory === 'MAINTENANCE')).length;
|
||||||
const availableSeats = coach.seats.filter((s) => s.status === 'AVAILABLE').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 totalAssignments = coach.assignments.length;
|
||||||
const totalBookings = coach.assignments.reduce((sum, a) => sum + ((a.schedule as any)._count?.bookings ?? 0), 0);
|
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;
|
const utilizationRate = totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair, Download } from 'lucide-react';
|
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair, Download } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
@@ -13,7 +13,7 @@ import { usePagination } from '@/lib/use-pagination';
|
|||||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
type Tab = 'types' | 'coaches' | 'utilization';
|
type Tab = 'types' | 'coaches';
|
||||||
|
|
||||||
const getBedLabel = (bedPosition: string | null): string => {
|
const getBedLabel = (bedPosition: string | null): string => {
|
||||||
if (bedPosition === 'upper') return 'U';
|
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 [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, item: null });
|
||||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
||||||
const [isBedCoach, setIsBedCoach] = useState(false);
|
const [isBedCoach, setIsBedCoach] = useState(false);
|
||||||
const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false);
|
|
||||||
const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -169,12 +167,6 @@ function CoachesPageContent() {
|
|||||||
queryFn: () => fleetApi.getCoaches({}),
|
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
|
// Coach Type Mutations
|
||||||
const createCoachTypeMutation = useMutation({
|
const createCoachTypeMutation = useMutation({
|
||||||
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
|
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
|
||||||
@@ -531,16 +523,6 @@ function CoachesPageContent() {
|
|||||||
>
|
>
|
||||||
Coaches
|
Coaches
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Coach Types Tab */}
|
{/* Coach Types Tab */}
|
||||||
@@ -591,105 +573,6 @@ function CoachesPageContent() {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* 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: [
|
items: [
|
||||||
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
|
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
|
||||||
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, 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: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
|
||||||
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, 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 },
|
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
|
||||||
|
|||||||
Reference in New Issue
Block a user