diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index ee2a048f0..a8172e223 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -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') diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index 103cbbc4b..6a081f9ec 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -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; diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index 408ebb352..0b836f141 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -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(''); 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('/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 - {/* Coach Types Tab */} @@ -591,105 +573,6 @@ function CoachesPageContent() { )} - {/* 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(`Coach Utilization Report`); - w.document.write(`

Coach Utilization Report — ${dateStr}

${headers.map(h => ``).join('')}`); - exportRows.forEach((r: string[]) => { w.document.write(`${r.map((v: string) => ``).join('')}`); }); - w.document.write('
${h}
${v}
'); - 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 ( -
-
- setExportUtilModalOpen(true)}>Export -
- {r.number} }, - { key: 'coachType', label: 'Type', render: (r: any) => {r.coachType || 'N/A'} }, - { key: 'totalSeats', label: 'Total Seats', render: (r: any) => {r.totalSeats} }, - { key: 'availableSeats', label: 'Available', render: (r: any) => {r.availableSeats} }, - { key: 'bookedSeats', label: 'Booked', render: (r: any) => {r.bookedSeats} }, - { key: 'blockedSeats', label: 'Blocked', render: (r: any) => {r.blockedSeats} }, - { key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => {r.maintenanceSeats} }, - { - key: 'utilizationRate', label: 'Utilization', - render: (r: any) => ( -
-
-
-
- {r.utilizationRate}% -
- ), - }, - { key: 'totalAssignments', label: 'Assignments', render: (r: any) => {r.totalAssignments} }, - { key: 'totalBookings', label: 'Total Bookings', render: (r: any) => {r.totalBookings} }, - ]} - data={rows} - actions={[]} - loading={utilizationLoading} - emptyMessage="No coach utilization data available" - /> - - setExportUtilModalOpen(false)} title="Export Utilization Report" size="sm"> -
-
-

Export Format

-
- {(['csv', 'excel', 'pdf'] as const).map(fmt => ( - - ))} -
-
-
- setExportUtilModalOpen(false)}>Cancel - Export -
-
-
-
- ); - })()}
{/* Delete Confirmation */} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/coach-utilization/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/coach-utilization/page.tsx new file mode 100644 index 000000000..6a3837c68 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/coach-utilization/page.tsx @@ -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({ + queryKey: ["report-schedules-all"], + queryFn: () => apiClient.get("/reports/schedules?all=true"), + }); + + const { data, isLoading, isError } = useQuery({ + 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 ( +
+
+

Coach Utilization Report

+

+ Occupancy, availability, and booking load by coach for a selected schedule. +

+
+ +
+
+
+ + +
+
+ {isLoading &&

Loading…

} + {isError &&

Failed to load coach utilization.

} +
+ + {!scheduleId && ( +
+ +

Choose a schedule to review coach occupancy

+

The report drills into availability, bookings, and maintenance status for each assigned coach.

+
+ )} + + {data && summary && ( + <> +
+
+

Coaches

+

{summary.totalCoaches}

+

Assigned coaches

+
+ +
+
+
+

Total Seats

+

{summary.totalSeats}

+

Across all coaches

+
+ +
+
+ +
+
+
+

Booked

+

{summary.bookedSeats}

+

Occupied seats

+
+ +
+
+ +
+
+
+

Available

+

{summary.availableSeats}

+

Open seats

+
+ +
+
+ +
+
+
+

Blocked

+

{summary.blockedSeats}

+

Unavailable seats

+
+ +
+
+ +
+
+
+

Avg Utilization

+

+ {summary.avgUtilization.toFixed(1)}% +

+

Across coach set

+
+ +
+
+
+ +
+
+

+ Coach Details +

+
+
+ + { + setSearch(event.target.value); + reset(); + }} + /> +
+ + Export CSV + +
+
+ +
+ + + + {[ + "Coach", + "Type", + "Status", + "Total Seats", + "Available", + "Booked", + "Blocked", + "Maintenance", + "Utilization", + "Assignments", + "Bookings", + ].map((header) => ( + + ))} + + + + {paged.map((row) => ( + + + + + + + + + + + + + + ))} + + {paged.length === 0 && ( + + + + )} + +
+ {header} +
{row.number}{row.coachType ?? "—"}{row.status ?? "—"}{row.totalSeats}{row.availableSeats}{row.bookedSeats}{row.blockedSeats}{row.maintenanceSeats} +
+
+
+
+ {row.utilizationRate.toFixed(1)}% +
+
{row.totalAssignments}{row.totalBookings}
+ No coach utilization rows found +
+
+ +
+ + )} + + {!data && !isLoading && scheduleId && ( +
+ No utilization data found for this schedule. +
+ )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index de8619577..3fc80428b 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -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 },