UAT findings resolutions and enhancements

This commit is contained in:
Stephanos A
2026-07-01 09:03:09 +03:00
parent be9c6c4adb
commit b50a386b25
14 changed files with 743 additions and 535 deletions

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

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

View File

@@ -131,9 +131,38 @@ export default function AuditLogsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Audit Logs</h1>
<p className="text-muted-foreground mt-1">Track all system activities and changes</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Audit Logs</h1>
<p className="text-muted-foreground mt-1">Track all system activities and changes</p>
</div>
<ActionButton
icon={Download}
variant="secondary"
onClick={() => {
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
</ActionButton>
</div>
{/* Stats Cards */}

View File

@@ -14,32 +14,181 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isScanning, setIsScanning] = useState(false);
const [isInitializing, setIsInitializing] = useState(false);
const [stream, setStream] = useState<MediaStream | null>(null);
const [cameraError, setCameraError] = useState<string | null>(null);
const scanIntervalRef = useRef<number | null>(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<void>((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 (
<div className="space-y-4">
{!isScanning ? (
{/* Debug info */}
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">
Debug: isScanning={String(isScanning)}, isInitializing={String(isInitializing)}, stream={stream ? 'active' : 'null'}
</div>
{/* Video viewer - always rendered, visibility controlled by display style */}
<div className={`space-y-3 ${!isScanning ? '!hidden' : ''}`}>
<div className="relative bg-black rounded-xl overflow-hidden" style={{ minHeight: '320px' }}>
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full min-h-[320px] object-cover block"
style={{ display: 'block', visibility: 'visible' }}
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="relative w-48 h-48">
<div className="absolute inset-0 border-2 border-white border-dashed rounded-lg animate-pulse"></div>
<div className="absolute top-0 left-0 w-6 h-6 border-t-4 border-l-4 border-blue-400 rounded-tl-lg"></div>
<div className="absolute top-0 right-0 w-6 h-6 border-t-4 border-r-4 border-blue-400 rounded-tr-lg"></div>
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-4 border-l-4 border-blue-400 rounded-bl-lg"></div>
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-4 border-r-4 border-blue-400 rounded-br-lg"></div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4 pointer-events-none">
<p className="text-white text-center text-sm font-medium">Position QR code within frame</p>
<p className="text-white/70 text-center text-xs mt-1">Scanning...</p>
</div>
</div>
<canvas ref={canvasRef} className="hidden" />
<button
onClick={stopCamera}
className="w-full bg-gray-600 hover:bg-gray-700 text-white font-semibold py-3 px-6 rounded-xl transition-colors flex items-center justify-center gap-2"
>
<CameraOff className="w-5 h-5" />
Stop Camera
</button>
</div>
{/* Start button and loading state */}
{!isScanning && !isInitializing && (
<div className="space-y-3">
<button
onClick={startCamera}
@@ -135,36 +325,35 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
</div>
)}
</div>
) : (
)}
{/* Loading state */}
{isInitializing && (
<div className="space-y-3">
<div className="relative bg-black rounded-xl overflow-hidden">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-64 object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative w-48 h-48">
<div className="absolute inset-0 border-2 border-white border-dashed rounded-lg"></div>
<div className="absolute top-0 left-0 w-6 h-6 border-t-4 border-l-4 border-blue-400 rounded-tl-lg"></div>
<div className="absolute top-0 right-0 w-6 h-6 border-t-4 border-r-4 border-blue-400 rounded-tr-lg"></div>
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-4 border-l-4 border-blue-400 rounded-bl-lg"></div>
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-4 border-r-4 border-blue-400 rounded-br-lg"></div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4">
<p className="text-white text-center text-sm font-medium">Position QR code within frame</p>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-6">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p className="text-blue-700 dark:text-blue-300 font-medium">Starting camera...</p>
<p className="text-blue-600 dark:text-blue-400 text-sm text-center">
Please allow camera access when prompted by your browser
</p>
<p className="text-blue-500 dark:text-blue-500 text-xs text-center">
Check console (F12) for detailed camera logs if this takes too long
</p>
</div>
</div>
<canvas ref={canvasRef} className="hidden" />
<button
onClick={stopCamera}
className="w-full bg-gray-600 hover:bg-gray-700 text-white font-semibold py-3 px-6 rounded-xl transition-colors flex items-center justify-center gap-2"
onClick={() => {
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
setIsInitializing(false);
setCameraError('Camera initialization cancelled by user');
}}
className="w-full bg-gray-500 hover:bg-gray-600 text-white font-semibold py-3 px-6 rounded-xl transition-colors"
>
<CameraOff className="w-5 h-5" />
Stop Camera
Cancel
</button>
</div>
)}
@@ -424,12 +613,19 @@ export default function BoardingPage() {
<h3 className="text-blue-800 dark:text-blue-200 font-semibold mb-3">How to scan:</h3>
<ul className="text-blue-700 dark:text-blue-300 space-y-2 text-sm">
<li> Tap "Scan QR Code" and point at ticket QR code</li>
<li> Allow camera access when your browser prompts you</li>
<li> Hold phone steady and position QR code within the frame</li>
<li> For manual option, type or paste booking reference</li>
<li> Tickets can only be boarded on their departure date</li>
<li> First scan boards outbound leg for round trips</li>
<li> Email & SMS sent automatically to passenger contacts</li>
<li> Red error shows validation issues</li>
</ul>
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-700">
<p className="text-blue-700 dark:text-blue-300 text-xs">
<strong>Tip:</strong> If camera doesn't open, check browser permissions in Settings Privacy Camera
</p>
</div>
</div>
{/* Quick Stats */}

View File

@@ -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<any[]>('/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
</button>
<button
onClick={() => { setActiveTab('utilization'); setSearch(''); }}
className={`px-4 py-3 font-medium transition-colors ${
activeTab === 'utilization'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Utilization Report
</button>
</div>
{/* Coach Types Tab */}
@@ -594,6 +610,44 @@ export default function CoachesPage() {
/>
</div>
)}
{/* Utilization Tab */}
{activeTab === 'utilization' && (() => {
const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || [];
return (
<div className="pt-6 space-y-4">
<DataTable
columns={[
{ key: 'sequence', label: 'Seq', render: (r: any) => <span className="font-mono">{r.sequence}</span> },
{ key: 'number', label: 'Coach', render: (r: any) => <span className="font-medium">{r.number}</span> },
{ key: 'coachType', label: 'Type', render: (r: any) => <span className="text-sm">{r.coachType || 'N/A'}</span> },
{ key: 'totalSeats', label: 'Total Seats', render: (r: any) => <span className="font-mono">{r.totalSeats}</span> },
{ key: 'availableSeats', label: 'Available', render: (r: any) => <span className="font-mono text-green-600">{r.availableSeats}</span> },
{ key: 'bookedSeats', label: 'Booked', render: (r: any) => <span className="font-mono text-red-600">{r.bookedSeats}</span> },
{ key: 'blockedSeats', label: 'Blocked', render: (r: any) => <span className="font-mono text-gray-500">{r.blockedSeats}</span> },
{ key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => <span className="font-mono text-orange-500">{r.maintenanceSeats}</span> },
{
key: 'utilizationRate', label: 'Utilization',
render: (r: any) => (
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${r.utilizationRate}%` }} />
</div>
<span className="font-mono text-sm">{r.utilizationRate}%</span>
</div>
),
},
{ key: 'totalAssignments', label: 'Assignments', render: (r: any) => <span className="font-mono">{r.totalAssignments}</span> },
{ key: 'totalBookings', label: 'Total Bookings', render: (r: any) => <span className="font-mono font-semibold">{r.totalBookings}</span> },
]}
data={rows}
actions={[]}
loading={utilizationLoading}
emptyMessage="No coach utilization data available"
/>
</div>
);
})()}
</div>
{/* Delete Confirmation */}

View File

@@ -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<FareConfiguration | null>(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<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; rule: any | null; error?: string }>({ isOpen: false, rule: null });
const [formError, setFormError] = useState<string | null>(null);
const queryClient = useQueryClient();
// Queries
const { data: configurations = [], isLoading: configsLoading } = useQuery<FareConfiguration[]>({
queryKey: ['fare-configurations'],
queryFn: () => apiClient.get('/admin/fare-configurations'),
});
const { data: systemStatus } = useQuery<SystemStatus>({
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<any[]>({
queryKey: ['fare-rules', filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters.scheduleId) params.append('scheduleId', filters.scheduleId);
const res = await apiClient.get<any>(`/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<any>('/schedules'),
});
const { data: seatClassesData } = useQuery({
queryKey: ['seat-classes'],
queryFn: () => apiClient.get<any>('/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<HTMLFormElement>) => {
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) => (
<div>
<div className="font-medium">{config.name}</div>
{config.description && (
<div className="text-sm text-muted-foreground">{config.description}</div>
)}
</div>
),
key: 'seatClass', label: 'Seat Class',
render: (r: any) => <span className="font-medium">{r.seatClass?.name || r.seatClassId}</span>,
},
{
key: 'status',
label: 'Status',
render: (config: FareConfiguration) => (
<div className="space-y-1">
<Badge variant="status" status={config.is_active ? 'CONFIRMED' : 'PENDING'}>
{config.is_active ? 'Active' : 'Inactive'}
</Badge>
{config.is_default && (
<Badge variant="status" status="INFO">Default</Badge>
)}
</div>
),
key: 'schedule', label: 'Schedule',
render: (r: any) => r.trip
? <span className="text-sm">{r.trip.originStation?.name} {r.trip.destinationStation?.name}<br /><span className="text-xs text-muted-foreground">{r.trip.departureAt ? new Date(r.trip.departureAt).toLocaleDateString() : ''}</span></span>
: <span className="text-xs text-muted-foreground">All schedules</span>,
},
{
key: 'rules',
label: 'Rules Count',
render: (config: FareConfiguration) => (
<div className="text-sm">
<div>{config.rate_rules_count} rate rules</div>
<div>{config.components_count} components</div>
<div>{config.age_rules_count} age rules</div>
</div>
),
key: 'passengerCategory', label: 'Category',
render: (r: any) => r.passengerCategory
? <Badge variant="status" status={r.passengerCategory === 'ADULT' ? 'CONFIRMED' : 'INFO'}>{r.passengerCategory}</Badge>
: <span className="text-xs text-muted-foreground">All</span>,
},
{
key: 'dates',
label: 'Validity Period',
render: (config: FareConfiguration) => (
<div className="text-sm">
<div>From: {new Date(config.effective_date).toLocaleDateString()}</div>
{config.expiry_date && (
<div>Until: {new Date(config.expiry_date).toLocaleDateString()}</div>
)}
</div>
),
key: 'nationality', label: 'Nationality',
render: (r: any) => <span className="text-sm">{r.nationality || 'All'}</span>,
},
{
key: 'created_at',
label: 'Created',
sortable: true,
render: (config: FareConfiguration) => (
<div className="text-sm">
<div>{new Date(config.created_at).toLocaleDateString()}</div>
{config.created_by && (
<div className="text-muted-foreground">by {config.created_by}</div>
)}
key: 'baseFareMinor', label: 'Base Fare (ETB)',
render: (r: any) => <span className="font-mono">{(r.baseFareMinor / 100).toFixed(2)}</span>,
},
{
key: 'validity', label: 'Validity',
render: (r: any) => (
<div className="text-xs">
<div>From: {formatDateTime(r.validFrom)}</div>
{r.validUntil && <div>Until: {formatDateTime(r.validUntil)}</div>}
{!r.validUntil && <div className="text-muted-foreground">No expiry</div>}
</div>
),
},
@@ -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() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Configurable Fare Management</h1>
<p className="text-muted-foreground mt-1">
Manage dynamic fare configurations with flexible rules, components, and pricing
</p>
</div>
<div className="flex gap-2">
<ActionButton
icon={Settings}
variant="secondary"
onClick={() => setupSystemMutation.mutate()}
loading={setupSystemMutation.isPending}
disabled={systemStatus?.systemReady}
>
{systemStatus?.systemReady ? 'System Ready' : 'Setup System'}
</ActionButton>
<ActionButton
icon={Plus}
onClick={() => setShowCreateModal(true)}
>
New Configuration
</ActionButton>
<h1 className="text-2xl font-bold">Fare Management</h1>
<p className="text-muted-foreground">Configure fare rules by seat class, passenger category, and nationality</p>
</div>
<ActionButton icon={Plus} onClick={() => { setEditingRule(null); setFormError(null); setShowModal(true); }}>Add Fare Rule</ActionButton>
</div>
{/* System Status */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-muted-foreground">System Status</div>
<div className={`font-semibold ${systemStatus?.systemReady ? 'text-green-600' : 'text-yellow-600'}`}>
{systemStatus?.systemReady ? 'Ready' : 'Setup Required'}
</div>
</div>
<Badge variant="status" status={systemStatus?.configurableFaresEnabled ? 'CONFIRMED' : 'CANCELLED'}>
{systemStatus?.configurableFaresEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</div>
<div className="card">
<div className="text-sm text-muted-foreground">Total Configurations</div>
<div className="text-2xl font-bold">{systemStatus?.totalConfigurations || 0}</div>
</div>
<div className="card">
<div className="text-sm text-muted-foreground">Rollout Percentage</div>
<div className="text-2xl font-bold">{systemStatus?.rolloutPercentage || 0}%</div>
</div>
<div className="card">
<div className="text-sm text-muted-foreground">Active Configuration</div>
<div className="font-medium">
{systemStatus?.activeConfigurationName || 'None'}
</div>
</div>
</div>
{/* System Controls */}
<div className="card">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">System Control</h3>
<p className="text-sm text-muted-foreground mt-1">
Enable or disable the configurable fare system globally
</p>
</div>
<div className="flex items-center gap-4">
<span className="text-sm">
{systemStatus?.configurableFaresEnabled ? 'System Enabled' : 'Using Legacy System'}
</span>
<ActionButton
variant={systemStatus?.configurableFaresEnabled ? 'danger' : 'secondary'}
onClick={() => toggleSystemMutation.mutate(!systemStatus?.configurableFaresEnabled)}
loading={toggleSystemMutation.isPending}
icon={systemStatus?.configurableFaresEnabled ? Square : Play}
>
{systemStatus?.configurableFaresEnabled ? 'Disable' : 'Enable'}
</ActionButton>
</div>
<div className="flex flex-wrap gap-3 mb-4">
<select className="input w-64" value={filters.scheduleId}
onChange={(e) => setFilters({ ...filters, scheduleId: e.target.value })}>
<option value="">All Schedules</option>
{schedules.map((s: any) => (
<option key={s.id} value={s.id}>
{s.originStation?.name} {s.destinationStation?.name} ({s.departureAt ? new Date(s.departureAt).toLocaleDateString() : s.id.slice(0, 8)})
</option>
))}
</select>
</div>
<DataTable data={fareRules || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No fare rules found" />
</div>
{/* Configurations Table */}
<div className="card">
<div className="mb-6">
<h3 className="text-lg font-semibold">Fare Configurations</h3>
<p className="text-sm text-muted-foreground mt-1">
Manage fare calculation configurations with custom rates, components, and age-based pricing
</p>
</div>
<DataTable
data={configurations}
columns={columns}
actions={actions}
loading={configsLoading}
emptyMessage="No fare configurations found. Create your first configuration to get started."
/>
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => 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 && (
<FareTestModal
configuration={selectedConfig}
isOpen={showTestModal}
onClose={() => {
setShowTestModal(false);
setSelectedConfig(null);
}}
/>
)}
{/* Create/Edit Modal */}
{showCreateModal && (
<ConfigurationFormModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
setShowCreateModal(false);
queryClient.invalidateQueries({ queryKey: ['fare-configurations'] });
}}
/>
)}
<Modal isOpen={showModal} onClose={() => { setShowModal(false); setEditingRule(null); }}
title={`${editingRule ? 'Edit' : 'Add'} Fare Rule`} size="lg">
<form onSubmit={handleSubmit} className="space-y-4">
{formError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-200">{formError}</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Seat Class *</label>
<select name="seatClassId" className="input" defaultValue={editingRule?.seatClassId || ''} required>
<option value="">Select Seat Class</option>
{seatClasses.map((sc: any) => (
<option key={sc.id} value={sc.id}>{sc.name}</option>
))}
</select>
</div>
<div>
<label className="label">Schedule (optional)</label>
<select name="scheduleId" className="input" defaultValue={editingRule?.tripId || ''}>
<option value="">All Schedules</option>
{schedules.map((s: any) => (
<option key={s.id} value={s.id}>
{s.originStation?.name} {s.destinationStation?.name} ({s.departureAt ? new Date(s.departureAt).toLocaleDateString() : s.id.slice(0, 8)})
</option>
))}
</select>
</div>
<div>
<label className="label">Passenger Category (optional)</label>
<select name="passengerCategory" className="input" defaultValue={editingRule?.passengerCategory || ''}>
<option value="">All Categories</option>
<option value="ADULT">Adult (5 years)</option>
<option value="CHILD">Child (&lt;5 years)</option>
</select>
<p className="text-xs text-muted-foreground mt-1">Leave blank to apply to all passengers</p>
</div>
<div>
<label className="label">Nationality (optional)</label>
<select name="nationality" className="input" defaultValue={editingRule?.nationality || ''}>
<option value="">All Nationalities</option>
<option value="Ethiopian">Ethiopian</option>
<option value="Djiboutian">Djiboutian</option>
<option value="Other">Other (International)</option>
</select>
</div>
<div>
<label className="label">Base Fare (ETB) *</label>
<input type="number" name="baseFareMinor" className="input" min="0" step="0.01"
defaultValue={editingRule ? (editingRule.baseFareMinor / 100).toFixed(2) : ''} required
placeholder="e.g. 350.00" />
</div>
<div>
<label className="label">Valid From *</label>
<input type="datetime-local" name="validFrom" className="input" required
defaultValue={editingRule?.validFrom ? new Date(editingRule.validFrom).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16)} />
</div>
<div>
<label className="label">Valid Until (optional)</label>
<input type="datetime-local" name="validUntil" className="input"
defaultValue={editingRule?.validUntil ? new Date(editingRule.validUntil).toISOString().slice(0, 16) : ''} />
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton type="button" variant="secondary" onClick={() => { setShowModal(false); setEditingRule(null); }}>Cancel</ActionButton>
<ActionButton type="submit" loading={createMutation.isPending || updateMutation.isPending}>
{editingRule ? 'Update' : 'Create'} Fare Rule
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}
// 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<FareTestResult>({
mutationFn: () => apiClient.post(`/admin/fare-configurations/${configuration.id}/test`, testData),
});
const handleTest = () => {
testMutation.mutate();
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={`Test Configuration: ${configuration.name}`} size="lg">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Distance (km) *</label>
<input
type="number"
className="input"
value={testData.distanceKm}
onChange={(e) => setTestData({ ...testData, distanceKm: +e.target.value })}
/>
</div>
<div>
<label className="label">Nationality *</label>
<select
className="input"
value={testData.nationality}
onChange={(e) => setTestData({ ...testData, nationality: e.target.value })}
>
<option value="Ethiopian">Ethiopian</option>
<option value="Djiboutian">Djiboutian</option>
<option value="Other">International</option>
</select>
</div>
<div>
<label className="label">Coach Type *</label>
<select
className="input"
value={testData.coachType}
onChange={(e) => setTestData({ ...testData, coachType: e.target.value })}
>
<option value="REGULAR_SEAT">Regular Seat</option>
<option value="ECONOMY_BED">Economy Bed</option>
<option value="VIP_BED">VIP Bed</option>
</select>
</div>
{(testData.coachType === 'ECONOMY_BED' || testData.coachType === 'VIP_BED') && (
<div>
<label className="label">Bed Position</label>
<select
className="input"
value={testData.bedPosition}
onChange={(e) => setTestData({ ...testData, bedPosition: e.target.value })}
>
<option value="">Select position</option>
<option value="UPPER">Upper</option>
<option value="MIDDLE">Middle</option>
<option value="LOWER">Lower</option>
</select>
</div>
)}
<div>
<label className="label">Adults *</label>
<input
type="number"
min="1"
className="input"
value={testData.adultCount}
onChange={(e) => setTestData({ ...testData, adultCount: +e.target.value })}
/>
</div>
<div>
<label className="label">Children</label>
<input
type="number"
min="0"
className="input"
value={testData.childCount}
onChange={(e) => setTestData({ ...testData, childCount: +e.target.value })}
/>
</div>
</div>
<ActionButton
onClick={handleTest}
loading={testMutation.isPending}
className="w-full"
>
Calculate Fare
</ActionButton>
{testMutation.data && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<h4 className="font-semibold text-green-900 dark:text-green-200 mb-3">Calculation Result</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Base Fare:</span>
<span className="font-mono">{(testMutation.data.baseFareMinor / 100).toFixed(2)} ETB</span>
</div>
<div className="flex justify-between">
<span>Components:</span>
<span className="font-mono">{(testMutation.data.componentsTotal / 100).toFixed(2)} ETB</span>
</div>
<div className="flex justify-between font-semibold border-t pt-2">
<span>Total:</span>
<span className="font-mono">{(testMutation.data.finalTotalMinor / 100).toFixed(2)} ETB</span>
</div>
</div>
{testMutation.data.breakdown && (
<div className="mt-4">
<h5 className="font-medium mb-2">Calculation Breakdown:</h5>
<div className="space-y-1 text-xs">
{testMutation.data.breakdown.map((step: any, index: number) => (
<div key={index} className="flex justify-between">
<span>{step.description}</span>
<span className="font-mono">{(step.runningTotal / 100).toFixed(2)} ETB</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{testMutation.error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-800 dark:text-red-200 text-sm">
{(testMutation.error as any)?.response?.data?.message || 'Test failed'}
</div>
)}
</div>
</Modal>
);
}
// Create Configuration Form Modal
function ConfigurationFormModal({
isOpen,
onClose,
onSuccess
}: {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Create Configuration" size="xl">
<div className="p-8 text-center">
<h3 className="text-lg font-semibold mb-2">Configuration Form</h3>
<p className="text-muted-foreground mb-4">
This would contain a comprehensive form for creating fare configurations with rate rules, components, and age pricing.
</p>
<ActionButton onClick={onSuccess} variant="secondary">
Close for Now
</ActionButton>
</div>
</Modal>
);
}

View File

@@ -49,7 +49,7 @@ export default function SchedulesPage() {
const [showEditModal, setShowEditModal] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(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<string | null>(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<HTMLFormElement>) => {
@@ -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."
/>
<Modal

View File

@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { seatsApi, schedulesApi, fleetApi } from '@/lib/api';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton'
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train } from 'lucide-react';
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench } from 'lucide-react';
export default function SeatsPage() {
const [selectedSchedule, setSelectedSchedule] = useState('');
@@ -19,6 +19,8 @@ export default function SeatsPage() {
const [blockCoachReason, setBlockCoachReason] = useState('');
const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false);
const [coachToUnblock, setCoachToUnblock] = useState<any>(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() {
<div className="w-5 h-5 rounded bg-gray-500"></div>
<span className="text-sm text-muted-foreground">Blocked</span>
</div>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded bg-orange-500"></div>
<span className="text-sm text-muted-foreground">Under Maintenance</span>
</div>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded border-2 border-dashed border-gray-400"></div>
<span className="text-sm text-muted-foreground">Removed</span>
@@ -782,6 +823,44 @@ export default function SeatsPage() {
</div>
</div>
</Modal>
<Modal
isOpen={showMaintenanceModal}
onClose={() => { setShowMaintenanceModal(false); setSelectedSeat(null); setMaintenanceReason(''); }}
title="Set Seat Under Maintenance"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Set seat <strong>{selectedSeat?.seatNumber}</strong> to Under Maintenance
</p>
<div>
<label className="label">Reason *</label>
<textarea
className="input"
rows={3}
value={maintenanceReason}
onChange={(e) => setMaintenanceReason(e.target.value)}
placeholder="e.g., Seat mechanism broken, Upholstery replacement"
/>
</div>
<div className="flex justify-end gap-2">
<ActionButton
variant="secondary"
onClick={() => { setShowMaintenanceModal(false); setSelectedSeat(null); setMaintenanceReason(''); }}
>
Cancel
</ActionButton>
<ActionButton
onClick={() => maintenanceMutation.mutate({ seatId: selectedSeat.id, reason: maintenanceReason })}
loading={maintenanceMutation.isPending}
disabled={!maintenanceReason.trim()}
>
Set Maintenance
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}
@@ -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 (
<div className="relative group flex flex-col items-center">
@@ -872,7 +957,7 @@ function SeatIcon({
</div>
)}
{(canBlock || canUnblock) && (
{(canBlock || canUnblock || canMaintenance || canClearMaintenance) && (
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
{canBlock && (
<>
@@ -901,6 +986,24 @@ function SeatIcon({
<Unlock className="h-3 w-3 text-gray-700" />
</button>
)}
{canMaintenance && (
<button
onClick={() => handleSetMaintenance(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Set under maintenance"
>
<Wrench className="h-3 w-3 text-orange-600" />
</button>
)}
{canClearMaintenance && (
<button
onClick={() => handleClearMaintenance(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Clear maintenance"
>
<Unlock className="h-3 w-3 text-orange-600" />
</button>
)}
</div>
)}
</div>

View File

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

View File

@@ -151,6 +151,8 @@ export const seatsApi = {
unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`),
removeSeat: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/remove`, {}),
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}),
setMaintenance: (seatId: string, reason: string) => apiClient.post<any>(`/seats/${seatId}/maintenance`, { reason }),
clearMaintenance: (seatId: string) => apiClient.delete(`/seats/${seatId}/maintenance`),
};
// Payments API