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 */}
+
+
+
+
+
+
Position QR code within frame
+
Scanning...
+
+
+
+
+
+
+ {/* Start button and loading state */}
+ {!isScanning && !isInitializing && (
)}
- ) : (
+ )}
+
+ {/* Loading state */}
+ {isInitializing && (
-
-
-
-
-
Position QR code within frame
+
+
+
+
Starting camera...
+
+ Please allow camera access when prompted by your browser
+
+
+ Check console (F12) for detailed camera logs if this takes too long
+
-
)}
@@ -424,12 +613,19 @@ export default function BoardingPage() {
How to scan:
- • Tap "Scan QR Code" and point at ticket QR code
+ - • Allow camera access when your browser prompts you
+ - • Hold phone steady and position QR code within the frame
- • For manual option, type or paste booking reference
- • Tickets can only be boarded on their departure date
- • First scan boards outbound leg for round trips
- • Email & SMS sent automatically to passenger contacts
- • Red error shows validation issues
+
+
+ Tip: If camera doesn't open, check browser permissions in Settings → Privacy → Camera
+
+
{/* 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">
+
+
);
}
-
-// 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
+
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
+
+
+
+
+
+
{ setShowMaintenanceModal(false); setSelectedSeat(null); setMaintenanceReason(''); }}
+ >
+ Cancel
+
+
maintenanceMutation.mutate({ seatId: selectedSeat.id, reason: maintenanceReason })}
+ loading={maintenanceMutation.isPending}
+ disabled={!maintenanceReason.trim()}
+ >
+ Set Maintenance
+
+
+
+
);
}
@@ -798,6 +877,8 @@ interface SeatIconProps {
handleRemoveSeat: (seat: any) => void;
handleUnblock: (seat: any) => void;
handleUndoRemove: (seat: any) => void;
+ handleSetMaintenance: (seat: any) => void;
+ handleClearMaintenance: (seat: any) => void;
}
function SeatIcon({
@@ -812,6 +893,8 @@ function SeatIcon({
handleRemoveSeat,
handleUnblock,
handleUndoRemove,
+ handleSetMaintenance,
+ handleClearMaintenance,
}: SeatIconProps) {
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
@@ -845,6 +928,8 @@ function SeatIcon({
const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED';
+ const canMaintenance = status === 'AVAILABLE' || status === 'BLOCKED';
+ const canClearMaintenance = status === 'UNDER_MAINTENANCE';
return (
@@ -872,7 +957,7 @@ function SeatIcon({
)}
- {(canBlock || canUnblock) && (
+ {(canBlock || canUnblock || canMaintenance || canClearMaintenance) && (
{canBlock && (
<>
@@ -901,6 +986,24 @@ function SeatIcon({
)}
+ {canMaintenance && (
+
+ )}
+ {canClearMaintenance && (
+
+ )}
)}
diff --git a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx
index b80ec8782..29386f8bd 100644
--- a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx
@@ -31,7 +31,6 @@ export default function TrainsPage() {
queryClient.invalidateQueries({ queryKey: ['trains'] });
setShowModal(false);
setEditingTrain(null);
- alert('Train created successfully');
},
onError: (error: any) => {
alert('Error creating train: ' + (error?.response?.data?.message || 'Unknown error'));
@@ -44,7 +43,6 @@ export default function TrainsPage() {
queryClient.invalidateQueries({ queryKey: ['trains'] });
setShowModal(false);
setEditingTrain(null);
- alert('Train updated successfully');
},
onError: (error: any) => {
alert('Error updating train: ' + (error?.response?.data?.message || 'Unknown error'));
@@ -55,7 +53,6 @@ export default function TrainsPage() {
mutationFn: (id: string) => fleetApi.deleteTrain(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trains'] });
- alert('Train deleted successfully');
},
});
@@ -63,7 +60,6 @@ export default function TrainsPage() {
mutationFn: (id: string) => fleetApi.restoreTrain(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trains'] });
- alert('Train restored successfully');
},
onError: (error: any) => {
alert('Error restoring train: ' + (error?.response?.data?.message || 'Unknown error'));
diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts
index c591af3c9..2da6e0d16 100644
--- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts
+++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts
@@ -151,6 +151,8 @@ export const seatsApi = {
unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`),
removeSeat: (seatId: string) => apiClient.patch(`/seats/${seatId}/remove`, {}),
undoRemove: (seatId: string) => apiClient.patch(`/seats/${seatId}/undo-remove`, {}),
+ setMaintenance: (seatId: string, reason: string) => apiClient.post(`/seats/${seatId}/maintenance`, { reason }),
+ clearMaintenance: (seatId: string) => apiClient.delete(`/seats/${seatId}/maintenance`),
};
// Payments API
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
index b38234732..4b46859d6 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
@@ -512,6 +512,8 @@ export default function PassengersPage() {
const [verificationStatus, setVerificationStatus] = useState>({});
const [saving, setSaving] = useState(false);
const [formInitialized, setFormInitialized] = useState(false);
+ const [faydaParams, setFaydaParams] = useState<{ code: string; state: string } | null>(null);
+ const [faydaCompleting, setFaydaCompleting] = useState(false);
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
@@ -576,6 +578,54 @@ export default function PassengersPage() {
checkFaydaStatus();
}, []);
+ // Read Fayda callback params from the URL on mount
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ const params = new URLSearchParams(window.location.search);
+ const code = params.get('code');
+ const state = params.get('state');
+ if (code && state) setFaydaParams({ code, state });
+ }, []);
+
+ // Complete Fayda verification once the form is ready and callback params are present
+ useEffect(() => {
+ if (!faydaParams || !formInitialized) return;
+
+ const complete = async () => {
+ setFaydaCompleting(true);
+ try {
+ const response: any = await apiClient.get(
+ `/fayda/verification/complete?code=${encodeURIComponent(faydaParams.code)}&state=${encodeURIComponent(faydaParams.state)}`
+ );
+
+ if (response?.success && response?.data?.verified) {
+ const d = response.data;
+ // Convert "1980/12/01" → "1980-12-01"
+ const dob = d.birthdate ? (d.birthdate as string).replace(/\//g, '-') : '';
+
+ setValue('passengers.0.name', d.fullName || '', { shouldValidate: true });
+ if (dob) setValue('passengers.0.dateOfBirth', dob, { shouldValidate: true });
+ if (d.email) setValue('passengers.0.email', d.email, { shouldValidate: true });
+ if (d.phoneNumber) setValue('passengers.0.phone', d.phoneNumber, { shouldValidate: true });
+ setValue('passengers.0.faydaVerified', true);
+ setValue('passengers.0.formExpanded', true);
+ setVerificationStatus((prev) => ({ ...prev, 0: 'success' }));
+
+ // Remove code/state from the URL so a refresh doesn't re-trigger
+ router.replace('/booking/passengers');
+ }
+ } catch (error) {
+ console.error('Failed to complete Fayda verification:', error);
+ } finally {
+ setFaydaCompleting(false);
+ setFaydaParams(null);
+ }
+ };
+
+ complete();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [faydaParams, formInitialized]);
+
useEffect(() => {
if (isAuthenticated && user?.faydaVerified) {
setVerificationStatus({ 0: 'success' });
@@ -752,13 +802,15 @@ export default function PassengersPage() {
if (!searchCriteria) return null;
- if (!formInitialized) {
+ if (!formInitialized || faydaCompleting) {
return (
-
Loading passenger details...
+
+ {faydaCompleting ? 'Completing Fayda verification...' : 'Loading passenger details...'}
+