From b50a386b2541b1b22470c60b4d68c4815a414d4b Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 1 Jul 2026 09:03:09 +0300 Subject: [PATCH] UAT findings resolutions and enhancements --- .../src/modules/bookings/bookings.dto.ts | 19 +- .../src/modules/fleet/fleet.controller.ts | 7 + .../src/modules/fleet/fleet.dto.ts | 3 +- .../src/modules/fleet/fleet.service.ts | 52 ++ .../src/modules/seats/seats.controller.ts | 21 + .../src/modules/seats/seats.service.ts | 17 + .../backoffice/src/app/audit/page.tsx | 35 +- .../backoffice/src/app/boarding/page.tsx | 272 +++++++- .../backoffice/src/app/coaches/page.tsx | 56 +- .../src/app/fare-management/page.tsx | 652 +++++------------- .../backoffice/src/app/schedules/page.tsx | 31 +- .../backoffice/src/app/seats/page.tsx | 107 ++- .../backoffice/src/app/trains/page.tsx | 4 - .../backoffice/src/lib/api/index.ts | 2 + 14 files changed, 743 insertions(+), 535 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 52603e776..4062acddc 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -1,5 +1,5 @@ -import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString } from 'class-validator'; -import { Type } from 'class-transformer'; +import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString, MaxDate } from 'class-validator'; +import { Type, Transform } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; @@ -9,7 +9,11 @@ export class PassengerInputDto { @ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID' }) @IsOptional() @IsString() returnSeatId?: string; @ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string; @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; - @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string; + @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD). Must not be a future date.' }) + @IsDateString() + @Transform(({ value }) => value) + @MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' }) + dateOfBirth: string; @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string; @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string; @@ -38,9 +42,12 @@ export class RoundTripPassengerDto { @ApiProperty({ example: '1990-05-15', - description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first child FREE), Age ≥5 = ADULT (full fare for both legs)' - }) - @IsDateString() dateOfBirth: string; + description: 'Date of birth (YYYY-MM-DD). Must not be a future date.' + }) + @IsDateString() + @Transform(({ value }) => value) + @MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' }) + dateOfBirth: string; @ApiProperty({ example: 'NATIONAL_ID', 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 0cc75a438..24f9981fc 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -206,6 +206,13 @@ export class FleetController { return this.service.listCoaches(dto); } + @Get('coaches/utilization') + @ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach' }) + @ApiResponse({ status: 200, description: 'Coach utilization data' }) + getCoachUtilization() { + return this.service.getCoachUtilization(); + } + @Get('coaches/:id') @ApiOperation({ summary: 'Get single coach with seat layout' }) @ApiParam({ name: 'id', description: 'Coach UUID' }) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts index 410db914e..9e1f20c01 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -1,4 +1,5 @@ import { IsString, IsInt, IsOptional, IsArray, IsBoolean } from 'class-validator'; +import { Transform } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger'; export class CreateTrainDto { @@ -7,7 +8,7 @@ export class CreateTrainDto { @ApiPropertyOptional({ example: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string; @ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string; @ApiPropertyOptional({ example: 'Addis-Djibouti Express' }) @IsOptional() @IsString() description?: string; - @ApiPropertyOptional({ example: true, description: 'Whether the train is active' }) @IsOptional() @IsBoolean() isActive?: boolean; + @ApiPropertyOptional({ example: true, description: 'Whether the train is active' }) @IsOptional() @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) @IsBoolean() isActive?: boolean; } export class CreateCoachDto { 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 dc71dd4b4..df5eea8a1 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -593,6 +593,58 @@ export class FleetService { }; } + async getCoachUtilization() { + const coaches = await this.prisma.coach.findMany({ + include: { + coachType: true, + seats: { select: { id: true, status: true } }, + assignments: { + include: { + schedule: { + select: { id: true, departureAt: true, status: true, _count: { select: { bookings: true } } }, + }, + }, + orderBy: { schedule: { departureAt: 'desc' } }, + take: 10, + }, + }, + orderBy: { sequence: 'asc' }, + }); + + 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 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; + + return { + id: coach.id, + number: coach.number, + sequence: coach.sequence, + coachType: coach.coachType?.name, + status: coach.status, + totalSeats, + availableSeats, + bookedSeats, + blockedSeats, + maintenanceSeats, + utilizationRate, + totalAssignments, + totalBookings, + recentSchedules: coach.assignments.slice(0, 5).map((a) => ({ + scheduleId: a.scheduleId, + departureAt: a.schedule.departureAt, + scheduleStatus: a.schedule.status, + bookings: (a.schedule as any)._count?.bookings ?? 0, + })), + }; + }); + } + async getAnalytics() { const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([ this.prisma.train.count(), diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 690b42a43..a8d5d724c 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -184,6 +184,27 @@ This makes it clear which segment of the route each seat is held for, enabling s return this.service.unblockSeat(seatId); } + // ── Maintenance ─────────────────────────────────────────────────────────── + @Post(":seatId/maintenance") + @UseGuards(IamGuard) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ summary: "Set seat status to Under Maintenance" }) + @ApiParam({ name: "seatId", description: "Seat UUID" }) + @ApiResponse({ status: 200, description: "Seat set to under maintenance" }) + setMaintenance(@Param("seatId") seatId: string, @Body() body: { reason: string }) { + return this.service.setMaintenance(seatId, body.reason); + } + + @Delete(":seatId/maintenance") + @UseGuards(IamGuard) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ summary: "Clear seat maintenance status" }) + @ApiParam({ name: "seatId", description: "Seat UUID" }) + @ApiResponse({ status: 200, description: "Seat cleared from maintenance" }) + clearMaintenance(@Param("seatId") seatId: string) { + return this.service.clearMaintenance(seatId); + } + // ── Remove Seat ──────────────────────────────────────────────────────────── @Patch(":seatId/remove") @UseGuards(IamGuard) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index fbb8cc552..1562a600b 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -742,6 +742,23 @@ export class SeatsService { return { unblocked: true, seatId }; } + async setMaintenance(seatId: string, reason: string) { + const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); + if (!seat) throw new NotFoundException('Seat not found'); + if (seat.status === 'BOOKED') throw new BadRequestException('Cannot set a booked seat to maintenance'); + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'UNDER_MAINTENANCE' as any } }); + await this.prisma.seatBlock.create({ data: { seatId, reason: `MAINTENANCE: ${reason}`, blockedBy: 'system' } }); + return { maintenance: true, seatId, reason }; + } + + async clearMaintenance(seatId: string) { + const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); + if (!seat) throw new NotFoundException('Seat not found'); + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' as any } }); + await this.prisma.seatBlock.deleteMany({ where: { seatId } }); + return { maintenance: false, seatId }; + } + async removeSeat(seatId: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); diff --git a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx index 0153887ae..4478fce74 100644 --- a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx @@ -131,9 +131,38 @@ export default function AuditLogsPage() { return (
-
-

Audit Logs

-

Track all system activities and changes

+
+
+

Audit Logs

+

Track all system activities and changes

+
+ { + const items = data?.items || []; + if (!items.length) return; + const headers = ['Timestamp', 'Action', 'Entity Type', 'Entity ID', 'User ID', 'IP Address']; + const rows = items.map((l: any) => [ + formatDateTime(l.createdAt), + l.action, + l.entityType, + l.entityId || '', + l.iamUserId || l.userId || '', + l.ipAddress || '', + ]); + const csv = [headers, ...rows].map(r => r.map((v: string) => `"${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 = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + }} + > + Export CSV +
{/* Stats Cards */} diff --git a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx index a808d44f1..fd55a9c88 100644 --- a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx @@ -14,32 +14,181 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro const videoRef = useRef(null); const canvasRef = useRef(null); const [isScanning, setIsScanning] = useState(false); + const [isInitializing, setIsInitializing] = useState(false); const [stream, setStream] = useState(null); const [cameraError, setCameraError] = useState(null); const scanIntervalRef = useRef(null); const startCamera = async () => { try { + setIsInitializing(true); setCameraError(null); - const mediaStream = await navigator.mediaDevices.getUserMedia({ - video: { - facingMode: 'environment', // Use back camera - width: { ideal: 1280 }, - height: { ideal: 720 } + + console.log('Starting camera...'); + + // Check if mediaDevices is supported + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + const errorMsg = 'Camera not supported in this browser. Please use a modern browser like Chrome, Firefox, or Safari.'; + setCameraError(errorMsg); + onError(errorMsg); + setIsInitializing(false); + return; + } + + // First, stop any existing stream + if (stream) { + stream.getTracks().forEach(track => track.stop()); + setStream(null); + } + + // Request camera access with simpler fallback + let mediaStream: MediaStream | null = null; + + try { + console.log('Requesting back camera...'); + // Try with environment (back) camera first + mediaStream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'environment', + width: { ideal: 1280 }, + height: { ideal: 720 } + }, + audio: false + }); + console.log('Back camera acquired'); + } catch (err) { + console.warn('Back camera not available, trying default camera:', err); + // Fallback to any available camera with simple constraints + try { + mediaStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: false + }); + console.log('Default camera acquired'); + } catch (fallbackErr) { + console.error('All camera attempts failed:', fallbackErr); + throw fallbackErr; } + } + + if (!mediaStream) { + throw new Error('Failed to get media stream'); + } + + if (!videoRef.current) { + throw new Error('Video element not found'); + } + + console.log('Setting video source...'); + const video = videoRef.current; + video.srcObject = mediaStream; + + // Wait for video to be ready with proper event handling + await new Promise((resolve, reject) => { + let resolved = false; + + const cleanup = () => { + video.removeEventListener('loadedmetadata', onLoadedMetadata); + video.removeEventListener('loadeddata', onLoadedData); + video.removeEventListener('canplay', onCanPlay); + video.removeEventListener('error', onVideoError); + }; + + const finishResolve = () => { + if (!resolved) { + resolved = true; + cleanup(); + console.log('Video ready!'); + resolve(); + } + }; + + const onLoadedMetadata = () => { + console.log('Metadata loaded'); + finishResolve(); + }; + + const onLoadedData = () => { + console.log('Data loaded'); + finishResolve(); + }; + + const onCanPlay = () => { + console.log('Can play'); + finishResolve(); + }; + + const onVideoError = (e: Event) => { + cleanup(); + console.error('Video error:', e); + reject(new Error('Video failed to load')); + }; + + // Add multiple event listeners for better compatibility + video.addEventListener('loadedmetadata', onLoadedMetadata); + video.addEventListener('loadeddata', onLoadedData); + video.addEventListener('canplay', onCanPlay); + video.addEventListener('error', onVideoError); + + // Fallback timeout - but shorter since we have multiple events + setTimeout(() => { + console.log('Video load timeout, proceeding anyway'); + finishResolve(); + }, 2000); }); - if (videoRef.current) { - videoRef.current.srcObject = mediaStream; - await videoRef.current.play(); - setStream(mediaStream); - setIsScanning(true); + // Play the video + console.log('Playing video...'); + try { + await video.play(); + console.log('Video playing'); + } catch (playError) { + console.warn('Play attempt 1 failed, retrying:', playError); + // Retry play after a short delay + await new Promise(resolve => setTimeout(resolve, 100)); + try { + await video.play(); + console.log('Video playing (retry succeeded)'); + } catch (retryError) { + console.warn('Play retry also failed (continuing anyway):', retryError); + } } + + // Set state to show video + setStream(mediaStream); + setIsScanning(true); + setIsInitializing(false); + console.log('Camera started successfully'); + console.log('isScanning state set to:', true); + console.log('isInitializing state set to:', false); + } catch (error: any) { - const errorMsg = 'Camera access denied. Please enable camera permissions in browser settings.'; + console.error('Camera start error:', error); + + let errorMsg = 'Camera access failed. Please check permissions and try again.'; + + if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') { + errorMsg = 'Camera permission denied. Please allow camera access in your browser settings and try again.'; + } else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') { + errorMsg = 'No camera found. Please connect a camera and try again.'; + } else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') { + errorMsg = 'Camera is already in use by another application. Please close other apps using the camera.'; + } else if (error.name === 'OverconstrainedError') { + errorMsg = 'Camera does not meet the requirements. Please try a different camera.'; + } else if (error.name === 'SecurityError') { + errorMsg = 'Camera access blocked due to security settings. Please use HTTPS or check your browser security settings.'; + } + setCameraError(errorMsg); onError(errorMsg); - console.error('Camera error:', error); + + // Clean up on error + if (stream) { + stream.getTracks().forEach(track => track.stop()); + setStream(null); + } + setIsScanning(false); + setIsInitializing(false); } }; @@ -120,7 +269,48 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro return (
- {!isScanning ? ( + {/* Debug info */} +
+ Debug: isScanning={String(isScanning)}, isInitializing={String(isInitializing)}, stream={stream ? 'active' : 'null'} +
+ + {/* Video viewer - always rendered, visibility controlled by display style */} +
+
+
+ + +
+ + {/* Start button and loading state */} + {!isScanning && !isInitializing && (
)}
- ) : ( + )} + + {/* Loading state */} + {isInitializing && (
-
-
{/* Quick Stats */} 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 f8ee29091..b25a40498 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -9,7 +9,7 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { fleetApi, apiClient } from '@/lib/api'; -type Tab = 'types' | 'coaches'; +type Tab = 'types' | 'coaches' | 'utilization'; const getBedLabel = (bedPosition: string | null): string => { if (bedPosition === 'upper') return 'U'; @@ -163,6 +163,12 @@ export default function CoachesPage() { 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), @@ -547,6 +553,16 @@ export default function CoachesPage() { > Coaches +
{/* Coach Types Tab */} @@ -594,6 +610,44 @@ export default function CoachesPage() { />
)} + + {/* Utilization Tab */} + {activeTab === 'utilization' && (() => { + const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || []; + return ( +
+ {r.sequence} }, + { key: 'number', label: 'Coach', render: (r: any) => {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" + /> +
+ ); + })()}
{/* Delete Confirmation */} diff --git a/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx b/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx index 218fbd832..fdd61ef19 100644 --- a/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx @@ -2,187 +2,121 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Settings, Play, Square, Trash2, TestTube, History, Download } from 'lucide-react'; +import { Plus, Edit, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; +import { formatDateTime } from '@/lib/utils'; -interface FareConfiguration { - id: string; - name: string; - description?: string; - effective_date: string; - expiry_date?: string; - is_active: boolean; - is_default: boolean; - created_by?: string; - approved_by?: string; - approved_at?: string; - created_at: string; - updated_at: string; - rate_rules_count: number; - components_count: number; - age_rules_count: number; -} - -interface SystemStatus { - configurableFaresEnabled: boolean; - rolloutPercentage: number; - totalConfigurations: number; - activeConfiguration: string | null; - activeConfigurationName: string | null; - systemReady: boolean; -} - -interface FareTestResult { - baseFareMinor: number; - componentsTotal: number; - finalTotalMinor: number; - breakdown?: Array<{ - description: string; - runningTotal: number; - }>; -} - -export default function ConfigurableFarePage() { - const [showCreateModal, setShowCreateModal] = useState(false); - const [showTestModal, setShowTestModal] = useState(false); - const [selectedConfig, setSelectedConfig] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; config: FareConfiguration | null }>({ isOpen: false, config: null }); +export default function FareManagementPage() { + const [filters, setFilters] = useState({ scheduleId: '' }); + const [showModal, setShowModal] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; rule: any | null; error?: string }>({ isOpen: false, rule: null }); + const [formError, setFormError] = useState(null); const queryClient = useQueryClient(); - // Queries - const { data: configurations = [], isLoading: configsLoading } = useQuery({ - queryKey: ['fare-configurations'], - queryFn: () => apiClient.get('/admin/fare-configurations'), - }); - - const { data: systemStatus } = useQuery({ - queryKey: ['fare-system-status'], - queryFn: () => apiClient.get('/admin/fare-migration/status'), - }); - - // Mutations - const activateMutation = useMutation({ - mutationFn: (id: string) => apiClient.post(`/admin/fare-configurations/${id}/activate`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); + const { data: fareRules, isLoading } = useQuery({ + queryKey: ['fare-rules', filters], + queryFn: async () => { + const params = new URLSearchParams(); + if (filters.scheduleId) params.append('scheduleId', filters.scheduleId); + const res = await apiClient.get(`/schedules/fares?${params}`); + return Array.isArray(res) ? res : (res as any)?.items || (res as any)?.data || []; }, }); + const { data: schedulesData } = useQuery({ + queryKey: ['schedules'], + queryFn: () => apiClient.get('/schedules'), + }); + + const { data: seatClassesData } = useQuery({ + queryKey: ['seat-classes'], + queryFn: () => apiClient.get('/fleet/classes'), + }); + + const schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData as any)?.items || []; + const seatClasses = Array.isArray(seatClassesData) ? seatClassesData : (seatClassesData as any)?.items || (seatClassesData as any)?.data || []; + + const createMutation = useMutation({ + mutationFn: (data: any) => apiClient.post('/schedules/fares', data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setShowModal(false); setEditingRule(null); setFormError(null); }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save fare rule'), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/schedules/fares/${id}`, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setShowModal(false); setEditingRule(null); setFormError(null); }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to update fare rule'), + }); + const deleteMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/admin/fare-configurations/${id}`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - setDeleteConfirm({ isOpen: false, config: null }); - }, + mutationFn: (id: string) => apiClient.delete(`/schedules/fares/${id}`), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setDeleteConfirm({ isOpen: false, rule: null }); }, + onError: (e: any) => setDeleteConfirm(prev => ({ ...prev, error: e?.response?.data?.message || e?.message || 'Failed to delete' })), }); - const toggleSystemMutation = useMutation({ - mutationFn: (enabled: boolean) => - enabled - ? apiClient.post('/admin/fare-configurations/system/enable-configurable-fares', { rolloutPercentage: 100 }) - : apiClient.post('/admin/fare-configurations/system/disable-configurable-fares'), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); - }, - }); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + const fd = new FormData(e.currentTarget); + const payload: any = { + seatClassId: fd.get('seatClassId') as string, + baseFareMinor: Math.round(parseFloat(fd.get('baseFareMinor') as string) * 100), + validFrom: new Date(fd.get('validFrom') as string).toISOString(), + }; + const scheduleId = fd.get('scheduleId') as string; + const nationality = fd.get('nationality') as string; + const passengerCategory = fd.get('passengerCategory') as string; + const validUntil = fd.get('validUntil') as string; + if (scheduleId) payload.scheduleId = scheduleId; + if (nationality) payload.nationality = nationality; + if (passengerCategory) payload.passengerCategory = passengerCategory; + if (validUntil) payload.validUntil = new Date(validUntil).toISOString(); - const setupSystemMutation = useMutation({ - mutationFn: () => apiClient.post('/admin/fare-migration/complete-setup', { - activateNewFormula: true, - enableFeature: true - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); - }, - }); - - const handleActivate = async (config: FareConfiguration) => { - await activateMutation.mutateAsync(config.id); - }; - - const handleDelete = (config: FareConfiguration) => { - setDeleteConfirm({ isOpen: true, config }); - }; - - const confirmDelete = async () => { - if (deleteConfirm.config) { - await deleteMutation.mutateAsync(deleteConfirm.config.id); + if (editingRule) { + await updateMutation.mutateAsync({ id: editingRule.id, data: payload }); + } else { + await createMutation.mutateAsync(payload); } }; - const handleTest = (config: FareConfiguration) => { - setSelectedConfig(config); - setShowTestModal(true); - }; - const columns = [ { - key: 'name', - label: 'Configuration Name', - sortable: true, - render: (config: FareConfiguration) => ( -
-
{config.name}
- {config.description && ( -
{config.description}
- )} -
- ), + key: 'seatClass', label: 'Seat Class', + render: (r: any) => {r.seatClass?.name || r.seatClassId}, }, { - key: 'status', - label: 'Status', - render: (config: FareConfiguration) => ( -
- - {config.is_active ? 'Active' : 'Inactive'} - - {config.is_default && ( - Default - )} -
- ), + key: 'schedule', label: 'Schedule', + render: (r: any) => r.trip + ? {r.trip.originStation?.name} → {r.trip.destinationStation?.name}
{r.trip.departureAt ? new Date(r.trip.departureAt).toLocaleDateString() : ''}
+ : All schedules, }, { - key: 'rules', - label: 'Rules Count', - render: (config: FareConfiguration) => ( -
-
{config.rate_rules_count} rate rules
-
{config.components_count} components
-
{config.age_rules_count} age rules
-
- ), + key: 'passengerCategory', label: 'Category', + render: (r: any) => r.passengerCategory + ? {r.passengerCategory} + : All, }, { - key: 'dates', - label: 'Validity Period', - render: (config: FareConfiguration) => ( -
-
From: {new Date(config.effective_date).toLocaleDateString()}
- {config.expiry_date && ( -
Until: {new Date(config.expiry_date).toLocaleDateString()}
- )} -
- ), + key: 'nationality', label: 'Nationality', + render: (r: any) => {r.nationality || 'All'}, }, { - key: 'created_at', - label: 'Created', - sortable: true, - render: (config: FareConfiguration) => ( -
-
{new Date(config.created_at).toLocaleDateString()}
- {config.created_by && ( -
by {config.created_by}
- )} + key: 'baseFareMinor', label: 'Base Fare (ETB)', + render: (r: any) => {(r.baseFareMinor / 100).toFixed(2)}, + }, + { + key: 'validity', label: 'Validity', + render: (r: any) => ( +
+
From: {formatDateTime(r.validFrom)}
+ {r.validUntil &&
Until: {formatDateTime(r.validUntil)}
} + {!r.validUntil &&
No expiry
}
), }, @@ -190,24 +124,12 @@ export default function ConfigurableFarePage() { const actions = [ { - label: 'Activate', - onClick: handleActivate, - variant: 'secondary' as const, - icon: Play, - show: (config: FareConfiguration) => !config.is_active, + label: 'Edit', icon: Edit, variant: 'secondary' as const, + onClick: (r: any) => { setEditingRule(r); setFormError(null); setShowModal(true); }, }, { - label: 'Test', - onClick: handleTest, - variant: 'secondary' as const, - icon: TestTube, - }, - { - label: 'Delete', - onClick: handleDelete, - variant: 'danger' as const, - icon: Trash2, - show: (config: FareConfiguration) => !config.is_active, + label: 'Delete', icon: Trash2, variant: 'danger' as const, + onClick: (r: any) => setDeleteConfirm({ isOpen: true, rule: r }), }, ]; @@ -215,321 +137,107 @@ export default function ConfigurableFarePage() {
-

Configurable Fare Management

-

- Manage dynamic fare configurations with flexible rules, components, and pricing -

-
-
- setupSystemMutation.mutate()} - loading={setupSystemMutation.isPending} - disabled={systemStatus?.systemReady} - > - {systemStatus?.systemReady ? 'System Ready' : 'Setup System'} - - setShowCreateModal(true)} - > - New Configuration - +

Fare Management

+

Configure fare rules by seat class, passenger category, and nationality

+ { setEditingRule(null); setFormError(null); setShowModal(true); }}>Add Fare Rule
- {/* System Status */} -
-
-
-
-
System Status
-
- {systemStatus?.systemReady ? 'Ready' : 'Setup Required'} -
-
- - {systemStatus?.configurableFaresEnabled ? 'Enabled' : 'Disabled'} - -
-
- -
-
Total Configurations
-
{systemStatus?.totalConfigurations || 0}
-
- -
-
Rollout Percentage
-
{systemStatus?.rolloutPercentage || 0}%
-
- -
-
Active Configuration
-
- {systemStatus?.activeConfigurationName || 'None'} -
-
-
- - {/* System Controls */}
-
-
-

System Control

-

- Enable or disable the configurable fare system globally -

-
-
- - {systemStatus?.configurableFaresEnabled ? 'System Enabled' : 'Using Legacy System'} - - toggleSystemMutation.mutate(!systemStatus?.configurableFaresEnabled)} - loading={toggleSystemMutation.isPending} - icon={systemStatus?.configurableFaresEnabled ? Square : Play} - > - {systemStatus?.configurableFaresEnabled ? 'Disable' : 'Enable'} - -
+
+
+
- {/* Configurations Table */} -
-
-

Fare Configurations

-

- Manage fare calculation configurations with custom rates, components, and age-based pricing -

-
- - -
- - {/* Delete Confirmation */} setDeleteConfirm({ isOpen: false, config: null })} - onConfirm={confirmDelete} - title="Delete Configuration" - message={`Are you sure you want to delete "${deleteConfirm.config?.name}"? This action cannot be undone.`} - confirmText="Delete" - isDanger={true} - isLoading={deleteMutation.isPending} - warning="Active configurations cannot be deleted. Deactivate first if needed." + onClose={() => setDeleteConfirm({ isOpen: false, rule: null })} + onConfirm={() => deleteMutation.mutate(deleteConfirm.rule?.id)} + title="Delete Fare Rule" + message={`Delete fare rule for ${deleteConfirm.rule?.seatClass?.name || 'this class'}?`} + confirmText="Delete" isDanger isLoading={deleteMutation.isPending} + error={deleteConfirm.error} /> - {/* Test Modal */} - {showTestModal && selectedConfig && ( - { - setShowTestModal(false); - setSelectedConfig(null); - }} - /> - )} - - {/* Create/Edit Modal */} - {showCreateModal && ( - setShowCreateModal(false)} - onSuccess={() => { - setShowCreateModal(false); - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - }} - /> - )} + { setShowModal(false); setEditingRule(null); }} + title={`${editingRule ? 'Edit' : 'Add'} Fare Rule`} size="lg"> +
+ {formError && ( +
{formError}
+ )} +
+
+ + +
+
+ + +
+
+ + +

Leave blank to apply to all passengers

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ { setShowModal(false); setEditingRule(null); }}>Cancel + + {editingRule ? 'Update' : 'Create'} Fare Rule + +
+
+
); } - -// Test Modal Component -function FareTestModal({ - configuration, - isOpen, - onClose -}: { - configuration: FareConfiguration; - isOpen: boolean; - onClose: () => void; -}) { - const [testData, setTestData] = useState({ - distanceKm: 100, - nationality: 'Ethiopian', - coachType: 'REGULAR_SEAT', - bedPosition: '', - adultCount: 2, - childCount: 1, - }); - - const testMutation = useMutation({ - mutationFn: () => apiClient.post(`/admin/fare-configurations/${configuration.id}/test`, testData), - }); - - const handleTest = () => { - testMutation.mutate(); - }; - - return ( - -
-
-
- - setTestData({ ...testData, distanceKm: +e.target.value })} - /> -
-
- - -
-
- - -
- {(testData.coachType === 'ECONOMY_BED' || testData.coachType === 'VIP_BED') && ( -
- - -
- )} -
- - setTestData({ ...testData, adultCount: +e.target.value })} - /> -
-
- - setTestData({ ...testData, childCount: +e.target.value })} - /> -
-
- - - Calculate Fare - - - {testMutation.data && ( -
-

Calculation Result

-
-
- Base Fare: - {(testMutation.data.baseFareMinor / 100).toFixed(2)} ETB -
-
- Components: - {(testMutation.data.componentsTotal / 100).toFixed(2)} ETB -
-
- Total: - {(testMutation.data.finalTotalMinor / 100).toFixed(2)} ETB -
-
- - {testMutation.data.breakdown && ( -
-
Calculation Breakdown:
-
- {testMutation.data.breakdown.map((step: any, index: number) => ( -
- {step.description} - {(step.runningTotal / 100).toFixed(2)} ETB -
- ))} -
-
- )} -
- )} - - {testMutation.error && ( -
- {(testMutation.error as any)?.response?.data?.message || 'Test failed'} -
- )} -
-
- ); -} - -// Create Configuration Form Modal -function ConfigurationFormModal({ - isOpen, - onClose, - onSuccess -}: { - isOpen: boolean; - onClose: () => void; - onSuccess: () => void; -}) { - return ( - -
-

Configuration Form

-

- This would contain a comprehensive form for creating fare configurations with rate rules, components, and age pricing. -

- - Close for Now - -
-
- ); -} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index d06d9e98e..32efb080d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -49,7 +49,7 @@ export default function SchedulesPage() { const [showEditModal, setShowEditModal] = useState(false); const [editingSchedule, setEditingSchedule] = useState(null); const [selectedSchedules, setSelectedSchedules] = useState>(new Set()); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean }>( + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string }>( { isOpen: false, item: null } ); const [error, setError] = useState(null); @@ -149,6 +149,10 @@ export default function SchedulesPage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['schedules'] }); }, + onError: (err: any) => { + const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedule'; + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + }, }); const bulkDeleteMutation = useMutation({ @@ -159,6 +163,10 @@ export default function SchedulesPage() { queryClient.invalidateQueries({ queryKey: ['schedules'] }); setSelectedSchedules(new Set()); }, + onError: (err: any) => { + const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedules'; + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + }, }); const handleBulkSubmit = async (e: React.FormEvent) => { @@ -227,13 +235,18 @@ export default function SchedulesPage() { }; const confirmDelete = async () => { - if (deleteConfirm.isBulk) { - const ids = deleteConfirm.item as string[]; - await bulkDeleteMutation.mutateAsync(ids); - } else if (deleteConfirm.item) { - await deleteScheduleMutation.mutateAsync(deleteConfirm.item.id); + setDeleteConfirm(prev => ({ ...prev, error: undefined })); + try { + if (deleteConfirm.isBulk) { + const ids = deleteConfirm.item as string[]; + await bulkDeleteMutation.mutateAsync(ids); + } else if (deleteConfirm.item) { + await deleteScheduleMutation.mutateAsync(deleteConfirm.item.id); + } + setDeleteConfirm({ isOpen: false, item: null }); + } catch { + // error is set by onError handler } - setDeleteConfirm({ isOpen: false, item: null }); }; const handleEditClick = (schedule: Schedule) => { @@ -531,7 +544,9 @@ export default function SchedulesPage() { } confirmText="Delete" isDanger={true} - warning="This schedule may have bookings. Deleting it may impact these systems." + isLoading={deleteScheduleMutation.isPending || bulkDeleteMutation.isPending} + error={deleteConfirm.error} + warning="Schedules with existing bookings cannot be deleted." /> (null); + const [showMaintenanceModal, setShowMaintenanceModal] = useState(false); + const [maintenanceReason, setMaintenanceReason] = useState(''); const queryClient = useQueryClient(); const { data: schedulesData } = useQuery({ @@ -70,6 +72,22 @@ export default function SeatsPage() { }, }); + const maintenanceMutation = useMutation({ + mutationFn: ({ seatId, reason }: { seatId: string; reason: string }) => + seatsApi.setMaintenance(seatId, reason), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + setShowMaintenanceModal(false); + setSelectedSeat(null); + setMaintenanceReason(''); + }, + }); + + const clearMaintenanceMutation = useMutation({ + mutationFn: (seatId: string) => seatsApi.clearMaintenance(seatId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seatmap'] }), + }); + const schedules = schedulesData?.items || schedulesData?.data || []; const coaches = seatMapData?.coaches || []; @@ -132,6 +150,17 @@ export default function SeatsPage() { } }; + const handleSetMaintenance = (seat: any) => { + setSelectedSeat(seat); + setShowMaintenanceModal(true); + }; + + const handleClearMaintenance = async (seat: any) => { + if (confirm('Clear maintenance status for this seat?')) { + await clearMaintenanceMutation.mutateAsync(seat.id); + } + }; + const handleBlockCoach = (coach: any) => { setSelectedCoach(coach); setShowBlockCoachModal(true); @@ -182,6 +211,7 @@ export default function SeatsPage() { }; const getSeatStatus = (seat: any) => { + if (seat.status === 'UNDER_MAINTENANCE') return 'UNDER_MAINTENANCE'; if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED'; if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED'; if (seat.status === 'HELD') return 'HELD'; @@ -194,6 +224,7 @@ export default function SeatsPage() { case 'BOOKED': return 'bg-red-500'; case 'HELD': return 'bg-yellow-500'; case 'BLOCKED': return 'bg-gray-500'; + case 'UNDER_MAINTENANCE': return 'bg-orange-500'; default: return 'bg-gray-300'; } }; @@ -265,6 +296,8 @@ export default function SeatsPage() { handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} handleUndoRemove={handleUndoRemove} + handleSetMaintenance={handleSetMaintenance} + handleClearMaintenance={handleClearMaintenance} hideNumber={true} /> ))} @@ -358,6 +391,8 @@ export default function SeatsPage() { handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} handleUndoRemove={handleUndoRemove} + handleSetMaintenance={handleSetMaintenance} + handleClearMaintenance={handleClearMaintenance} hideNumber={true} /> ))} @@ -378,6 +413,8 @@ export default function SeatsPage() { handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} handleUndoRemove={handleUndoRemove} + handleSetMaintenance={handleSetMaintenance} + handleClearMaintenance={handleClearMaintenance} hideNumber={true} /> ))} @@ -532,6 +569,10 @@ export default function SeatsPage() {
Blocked
+
+
+ Under Maintenance +
Removed @@ -782,6 +823,44 @@ export default function SeatsPage() {
+ + { setShowMaintenanceModal(false); setSelectedSeat(null); setMaintenanceReason(''); }} + title="Set Seat Under Maintenance" + size="md" + > +
+

+ Set seat {selectedSeat?.seatNumber} to Under Maintenance +

+
+ +