Merge pull request #1397 from Tria-plc/alpha

Alpha
This commit is contained in:
Stephanos A.
2026-08-22 11:21:04 +03:00
committed by GitHub
5 changed files with 400 additions and 128 deletions

View File

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

View File

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

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

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

@@ -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 },