mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
UAT findings resolutions and enhancements
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 (<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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user