diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index a0d6c988b..90273864c 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -47,7 +47,8 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.0", - "tsconfig-paths": "^4.2.0" + "tsconfig-paths": "^4.2.0", + "uuid": "^10.0.0" }, "devDependencies": { "@edr/eslint-config": "workspace:*", @@ -67,7 +68,8 @@ "supertest": "^7.0.0", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "@types/uuid": "^9.0.0" }, "prisma": { "schema": "prisma/schema.prisma" diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 16178a1b4..0109cfc39 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { PrismaModule } from './common/prisma.module'; +import { AuditModule } from './common/audit.module'; import { I18nModule } from './common/i18n/i18n.module'; import { IamModule } from './common/iam.module'; import { LocaleMiddleware } from './common/i18n/locale.middleware'; @@ -38,6 +39,7 @@ import { FraudModule } from './modules/fraud/fraud.module'; import { SeatClassesModule } from './modules/seat-classes/seat-classes.module'; import { FareEngineModule } from './modules/fare-engine/fare-engine.module'; import { VerifaydaModule } from './modules/verifayda/verifayda.module'; +import { AuditModuleFeature } from './modules/audit/audit.module'; @Module({ imports: [ @@ -57,6 +59,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module'; ScheduleModule.forRoot(), EventEmitterModule.forRoot(), PrismaModule, + AuditModule, I18nModule, IamModule, AuthModule, @@ -83,6 +86,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module'; SeatClassesModule, FareEngineModule, VerifaydaModule, + AuditModuleFeature, ], }) export class AppModule implements NestModule { diff --git a/apps/edr-passenger-api/src/common/audit.module.ts b/apps/edr-passenger-api/src/common/audit.module.ts new file mode 100644 index 000000000..a4ba9262f --- /dev/null +++ b/apps/edr-passenger-api/src/common/audit.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from './prisma.module'; +import { AuditService } from './audit.service'; + +@Module({ + imports: [PrismaModule], + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/apps/edr-passenger-api/src/common/audit.service.ts b/apps/edr-passenger-api/src/common/audit.service.ts new file mode 100644 index 000000000..342e786bd --- /dev/null +++ b/apps/edr-passenger-api/src/common/audit.service.ts @@ -0,0 +1,92 @@ +import { Injectable, Inject, Optional } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { PrismaService } from './prisma.service'; + +@Injectable() +export class AuditService { + constructor( + private prisma: PrismaService, + @Optional() @Inject(REQUEST) private request?: any, + ) {} + + async log(input: { + userId?: string; + action: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'VERIFY' | string; + entityType: string; + entityId?: string; + oldData?: any; + newData?: any; + }) { + try { + const ipAddress = this.getIpAddress(); + const userAgent = this.getUserAgent(); + + await this.prisma.auditLog.create({ + data: { + userId: input.userId, + action: input.action, + entityType: input.entityType, + entityId: input.entityId, + oldData: input.oldData, + newData: input.newData, + ipAddress, + userAgent, + }, + }); + } catch (error) { + console.error('Failed to log audit event:', error); + // Don't throw - audit logging should not break main operations + } + } + + private getIpAddress(): string { + if (!this.request) return ''; + + return ( + this.request.headers['x-forwarded-for']?.split(',')[0].trim() || + this.request.headers['x-real-ip'] || + this.request.connection?.remoteAddress || + this.request.socket?.remoteAddress || + this.request.ip || + '' + ); + } + + private getUserAgent(): string { + return this.request?.headers?.['user-agent'] || ''; + } + + async getLogs(filters: any = {}) { + const where: any = {}; + + if (filters.search) { + where.OR = [ + { entityId: { contains: filters.search, mode: 'insensitive' } }, + { user: { email: { contains: filters.search, mode: 'insensitive' } } }, + { user: { fullName: { contains: filters.search, mode: 'insensitive' } } }, + ]; + } + + if (filters.action) { + where.action = filters.action; + } + + if (filters.entityType) { + where.entityType = filters.entityType; + } + + return this.prisma.auditLog.findMany({ + where, + include: { user: true }, + orderBy: { createdAt: 'desc' }, + take: 500, // Limit to last 500 logs + }); + } + + async getLog(id: string) { + return this.prisma.auditLog.findUnique({ + where: { id }, + include: { user: true }, + }); + } +} diff --git a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts new file mode 100644 index 000000000..37bc89855 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { AuditService } from '../../common/audit.service'; +import { IamGuard } from '../../common/iam-adapter'; + +@ApiTags('Audit') +@Controller('audit') +@UseGuards(IamGuard) +@ApiBearerAuth('IAM-auth') +export class AuditController { + constructor(private auditService: AuditService) {} + + @Get('logs') + @ApiOperation({ + summary: 'Get audit logs', + description: 'Retrieve system audit logs with optional filtering', + }) + @ApiQuery({ name: 'search', required: false, description: 'Search by user email or entity ID' }) + @ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, etc.)' }) + @ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, etc.)' }) + async getLogs( + @Query('search') search?: string, + @Query('action') action?: string, + @Query('entityType') entityType?: string, + ) { + const filters = { + search: search || undefined, + action: action || undefined, + entityType: entityType || undefined, + }; + + const items = await this.auditService.getLogs(filters); + return { items }; + } + + @Get('logs/:id') + @ApiOperation({ summary: 'Get audit log by ID' }) + async getLog(@Param('id') id: string) { + return this.auditService.getLog(id); + } +} diff --git a/apps/edr-passenger-api/src/modules/audit/audit.module.ts b/apps/edr-passenger-api/src/modules/audit/audit.module.ts new file mode 100644 index 000000000..8b161d55c --- /dev/null +++ b/apps/edr-passenger-api/src/modules/audit/audit.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { AuditModule } from '../../common/audit.module'; +import { AuditController } from './audit.controller'; + +@Module({ + imports: [AuditModule, HttpModule], + controllers: [AuditController], +}) +export class AuditModuleFeature {} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index f9a3e0ea4..a588e7330 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; +import { AuditModule } from '../../common/audit.module'; import { BookingsController } from './bookings.controller'; import { BookingsService } from './bookings.service'; import { GuestBookingService } from './guest-booking.service'; @@ -8,7 +9,7 @@ import { VerifaydaModule } from '../verifayda/verifayda.module'; import { CurrencyModule } from '../currency/currency.module'; @Module({ - imports: [SeatsModule, VerifaydaModule, CurrencyModule, HttpModule], + imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule], controllers: [BookingsController], providers: [BookingsService, GuestBookingService], exports: [BookingsService, GuestBookingService] diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 29bc82a55..d1f25dde7 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -8,7 +8,10 @@ export class ReportsService { async generateReport(dto: GenerateReportDto) { const dateFrom = new Date(dto.dateFrom); + dateFrom.setHours(0, 0, 0, 0); + const dateTo = new Date(dto.dateTo); + dateTo.setHours(23, 59, 59, 999); let data: any; switch (dto.reportType) { @@ -44,14 +47,16 @@ export class ReportsService { } private async generateRevenueReport(dateFrom: Date, dateTo: Date) { + // Fetch all bookings in date range, regardless of status const bookings = await this.prisma.booking.findMany({ where: { - createdAt: { gte: dateFrom, lte: dateTo }, - status: { in: ['CONFIRMED', 'COMPLETED'] } + createdAt: { gte: dateFrom, lte: dateTo } }, include: { paymentIntent: true } }); + console.log(`[Reports] Revenue Report: Found ${bookings.length} bookings between ${dateFrom} and ${dateTo}`); + const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0); const byPaymentMethod = bookings.reduce((acc, b) => { const method = b.paymentIntent?.method ?? 'UNKNOWN'; @@ -59,12 +64,25 @@ export class ReportsService { return acc; }, {} as Record); + // Group by date for charts + const byDate = bookings.reduce((acc, b) => { + const date = b.createdAt.toISOString().split('T')[0]; + if (!acc[date]) { + acc[date] = { totalMinor: 0, count: 0 }; + } + acc[date].totalMinor += b.totalMinor; + acc[date].count += 1; + return acc; + }, {} as Record); + return { totalBookings: bookings.length, totalRevenueMinor: totalRevenue, totalRevenue: totalRevenue / 100, currency: 'ETB', - byPaymentMethod + byPaymentMethod, + byDate, + cancellationRate: 0 }; } @@ -73,7 +91,7 @@ export class ReportsService { where: { departureAt: { gte: dateFrom, lte: dateTo } }, include: { coachAssignments: { include: { coach: { include: { seats: true } } } }, - bookings: { where: { status: { in: ['CONFIRMED', 'COMPLETED'] } }, include: { seats: true } }, + bookings: { include: { seats: true } }, }, }); diff --git a/apps/edr-passenger-api/src/modules/stations/stations.module.ts b/apps/edr-passenger-api/src/modules/stations/stations.module.ts index 28ee6d121..bdb62569d 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.module.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.module.ts @@ -1,6 +1,12 @@ import { Module } from '@nestjs/common'; +import { AuditModule } from '../../common/audit.module'; import { StationsController } from './stations.controller'; import { StationsService } from './stations.service'; -@Module({ controllers: [StationsController], providers: [StationsService], exports: [StationsService] }) +@Module({ + imports: [AuditModule], + controllers: [StationsController], + providers: [StationsService], + exports: [StationsService], +}) export class StationsModule {} diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index a3e6624fe..795d222e6 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -1,5 +1,7 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, Inject, Optional } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; import { PrismaService } from '../../common/prisma.service'; +import { AuditService } from '../../common/audit.service'; import { CreateStationDto } from './stations.dto'; interface StationFilters { @@ -10,7 +12,11 @@ interface StationFilters { @Injectable() export class StationsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private auditService: AuditService, + @Optional() @Inject(REQUEST) private request?: any, + ) {} findAll(filters: StationFilters = {}) { const where: any = {}; @@ -43,20 +49,51 @@ export class StationsService { return s; } - create(dto: CreateStationDto) { - return this.prisma.station.create({ data: dto }); + async create(dto: CreateStationDto) { + const station = await this.prisma.station.create({ data: dto }); + + await this.auditService.log({ + userId: this.request?.user?.id, + action: 'CREATE', + entityType: 'Station', + entityId: station.id, + newData: station, + }); + + return station; } async update(id: string, dto: Partial) { - await this.findOne(id); // Check if exists - return this.prisma.station.update({ - where: { id }, - data: dto + const oldStation = await this.findOne(id); + const updatedStation = await this.prisma.station.update({ + where: { id }, + data: dto, }); + + await this.auditService.log({ + userId: this.request?.user?.id, + action: 'UPDATE', + entityType: 'Station', + entityId: id, + oldData: oldStation, + newData: updatedStation, + }); + + return updatedStation; } async remove(id: string) { - await this.findOne(id); // Check if exists - return this.prisma.station.delete({ where: { id } }); + const station = await this.findOne(id); + const deleted = await this.prisma.station.delete({ where: { id } }); + + await this.auditService.log({ + userId: this.request?.user?.id, + action: 'DELETE', + entityType: 'Station', + entityId: id, + oldData: station, + }); + + return deleted; } } diff --git a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx index 6f73de13e..06d8a6811 100644 --- a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx @@ -2,27 +2,90 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Search, Eye } from 'lucide-react'; +import { Eye, Download } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import { auditApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; +import Modal from '@/components/ui/Modal'; +import ActionButton from '@/components/ui/ActionButton'; export default function AuditLogsPage() { const [filters, setFilters] = useState({ search: '', action: '', entityType: '' }); + const [selectedLog, setSelectedLog] = useState(null); + const [showDetailsModal, setShowDetailsModal] = useState(false); const { data, isLoading } = useQuery({ queryKey: ['audit-logs', filters], queryFn: () => auditApi.getLogs(filters), + refetchInterval: 30000, // Refetch every 30 seconds }); + const getActionBadgeColor = (action: string) => { + switch (action) { + case 'CREATE': + return 'success'; + case 'UPDATE': + return 'primary'; + case 'DELETE': + return 'danger'; + case 'LOGIN': + return 'info'; + case 'LOGOUT': + return 'secondary'; + default: + return 'secondary'; + } + }; + + const formatJsonData = (data: any) => { + if (!data) return 'N/A'; + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } + }; + const columns = [ + { + key: 'createdAt', + label: 'Timestamp', + sortable: true, + render: (log: any) => ( +
+
{formatDateTime(log.createdAt)}
+
{new Date(log.createdAt).toLocaleTimeString()}
+
+ ), + }, { key: 'action', label: 'Action', sortable: true, render: (log: any) => ( - {log.action} + + {log.action} + + ), + }, + { + key: 'entityType', + label: 'Entity Type', + sortable: true, + render: (log: any) => ( + + {log.entityType} + + ), + }, + { + key: 'entityId', + label: 'Entity ID', + render: (log: any) => ( + + {log.entityId ? log.entityId.substring(0, 12) : 'System'} + ), }, { @@ -30,55 +93,74 @@ export default function AuditLogsPage() { label: 'User', render: (log: any) => (
-
{log.user?.fullName || 'System'}
-
{log.user?.email || 'N/A'}
+
{log.user?.fullName || 'System'}
+
{log.user?.email || log.userId || 'N/A'}
), }, { - key: 'entityType', - label: 'Entity Type', - render: (log: any) => log.entityType, - }, - { - key: 'entityId', - label: 'Entity ID', + key: 'ipAddress', + label: 'IP Address', render: (log: any) => ( - {log.entityId?.substring(0, 8)}... + + {log.ipAddress || 'N/A'} + ), }, - { - key: 'createdAt', - label: 'Timestamp', - sortable: true, - render: (log: any) => formatDateTime(log.createdAt), - }, ]; const actions = [ { label: 'View Details', onClick: (log: any) => { - window.location.href = `/audit/${log.id}`; + setSelectedLog(log); + setShowDetailsModal(true); }, variant: 'secondary' as const, icon: Eye, }, ]; + const logs = data?.items || []; + const stats = { + total: logs.length, + creates: logs.filter((l: any) => l.action === 'CREATE').length, + updates: logs.filter((l: any) => l.action === 'UPDATE').length, + deletes: logs.filter((l: any) => l.action === 'DELETE').length, + }; + return (
-
-
-

Audit Logs

-

Track all system activities and changes

+
+

Audit Logs

+

Track all system activities and changes

+
+ + {/* Stats Cards */} +
+
+
Total Logs
+
{stats.total}
+
+
+
Created
+
{stats.creates}
+
+
+
Updated
+
{stats.updates}
+
+
+
Deleted
+
{stats.deletes}
+ {/* Filters */}
-
+
- + setFilters({ ...filters, entityType: e.target.value })} > - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ setFilters({ search: '', action: '', entityType: '' })} + className="w-full" + > + Clear Filters + +
+ {/* Data Table */} + + {/* Details Modal */} + { + setShowDetailsModal(false); + setSelectedLog(null); + }} + title={`${selectedLog?.action} - ${selectedLog?.entityType}`} + size="lg" + > +
+ {/* Basic Info */} +
+
+ +

{formatDateTime(selectedLog?.createdAt)}

+
+
+ +

+ + {selectedLog?.action} + +

+
+
+ +

{selectedLog?.entityType}

+
+
+ +

+ {selectedLog?.entityId || 'System'} +

+
+
+ + {/* User Info */} + {selectedLog?.user && ( +
+

User Information

+
+
+ +

{selectedLog?.user?.fullName}

+
+
+ +

{selectedLog?.user?.email}

+
+
+
+ )} + + {/* Network Info */} + {(selectedLog?.ipAddress || selectedLog?.userAgent) && ( +
+

Network Information

+
+ {selectedLog?.ipAddress && ( +
+ +

{selectedLog?.ipAddress}

+
+ )} + {selectedLog?.userAgent && ( +
+ +

+ {selectedLog?.userAgent} +

+
+ )} +
+
+ )} + + {/* Changes */} + {(selectedLog?.oldData || selectedLog?.newData) && ( +
+

Data Changes

+
+ {selectedLog?.oldData && ( +
+ +
+                      {formatJsonData(selectedLog?.oldData)}
+                    
+
+ )} + {selectedLog?.newData && ( +
+ +
+                      {formatJsonData(selectedLog?.newData)}
+                    
+
+ )} +
+
+ )} + + {/* Raw Log ID */} +
+ +

{selectedLog?.id}

+
+
+
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index 91856f800..52c7e0e67 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Search, Grid3x3, Edit, Trash2 } from 'lucide-react'; +import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; @@ -11,6 +11,135 @@ import { fleetApi, apiClient } from '@/lib/api'; type Tab = 'types' | 'coaches'; +const getBedLabel = (bedPosition: string | null): string => { + if (bedPosition === 'upper') return 'U'; + if (bedPosition === 'middle') return 'M'; + if (bedPosition === 'lower') return 'L'; + return ''; +}; + +const renderBedVisualization = (coach: any) => { + const seats = coach.seats || []; + const validSeats = seats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')); + + if (validSeats.length === 0) { + return
No seats
; + } + + const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); + const isBedCoach = coach.coachType?.name?.toLowerCase().includes('bed'); + + if (!isBedCoach || !hasBedPositionData) { + // Regular seat layout + const arrangement = coach.seatArrangement || coach.arrangement || '2+2'; + const [left, right] = arrangement.split('+').map(p => parseInt(p.trim())); + const cols = new Map(); + + for (const seat of validSeats) { + if (!cols.has(seat.row)) cols.set(seat.row, []); + cols.get(seat.row)!.push(seat); + } + + return ( +
+ {Array.from(cols.entries()).map(([row, rowSeats]) => ( +
+
+ {rowSeats.slice(0, left).map((s: any) => ( +
+ +
+ ))} +
+
+ {rowSeats.slice(left).map((s: any) => ( +
+ +
+ ))} +
+
+ ))} +
+ ); + } + + // Bed layout with pairing + const seatsByRow = new Map(); + for (const seat of validSeats) { + if (!seatsByRow.has(seat.row)) seatsByRow.set(seat.row, []); + seatsByRow.get(seat.row)!.push(seat); + } + + const beds = coach.coachType?.name?.toLowerCase().includes('vip') ? 'w-12' : 'w-10'; + const rows = Array.from(seatsByRow.entries()).map(([r, s]) => s); + + return ( +
+ {rows.map((rowSeats: any[], idx: number) => { + const rowNumber = rowSeats[0]?.row || (idx + 1); + const isFirstInPair = (rowNumber - 1) % 2 === 0; + const isLastRow = idx === rows.length - 1; + const nextRowSeats = !isLastRow ? rows[idx + 1] : null; + + return ( +
+ {/* Row 1 of pair - label above */} + {isFirstInPair && ( +
+ {rowSeats.map((s: any) => ( +
+ {s.seatNumber} +
+ ))} +
+ )} + {/* Row 1 of pair - beds */} +
+ {rowSeats.map((s: any) => ( +
+ +
+ ))} +
+ {/* Numbers between rows */} + {isFirstInPair && nextRowSeats && ( +
+ {rowSeats.map((s: any, idx: number) => { + const nextSeat = nextRowSeats[idx]; + return ( +
+ {nextSeat?.seatNumber} +
+ ); + })} +
+ )} + {/* Row 2 of pair - beds */} + {!isFirstInPair && ( +
+ {rowSeats.map((s: any) => ( +
+ +
+ ))} +
+ )} + {!isFirstInPair &&
} +
+ ); + })} +
+ ); +}; + export default function CoachesPage() { const [activeTab, setActiveTab] = useState('coaches'); const [search, setSearch] = useState(''); @@ -228,6 +357,15 @@ export default function CoachesPage() { {coach.coachType?.name || 'N/A'} ), }, + { + key: 'visualization', + label: 'Seats/Beds', + render: (coach: any) => ( +
+ {renderBedVisualization(coach)} +
+ ), + }, { key: 'arrangement', label: 'Arrangement', diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index ee4f5b9ce..744c32137 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -1,13 +1,15 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { Ticket, Users, DollarSign, TrendingUp } from 'lucide-react'; +import { Ticket, Users, DollarSign, Percent } from 'lucide-react'; import StatCard from '@/components/dashboard/StatCard'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import { dashboardApi } from '@/lib/api/dashboard'; import { formatCurrency, formatDateTime } from '@/lib/utils'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; + +const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; export default function DashboardPage() { const { data: stats, isLoading: statsLoading } = useQuery({ @@ -20,22 +22,55 @@ export default function DashboardPage() { queryFn: () => dashboardApi.getRevenueChart(30), }); - const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery({ + const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery({ queryKey: ['recent-bookings'], queryFn: () => dashboardApi.getRecentBookings(10), }); - const recentBookings = Array.isArray(recentBookingsData) - ? recentBookingsData - : recentBookingsData?.items || recentBookingsData?.data || []; + const { data: topAgents, isLoading: agentsLoading } = useQuery({ + queryKey: ['top-agents'], + queryFn: () => dashboardApi.getTopAgents(5), + }); - const columns = [ + const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({ + queryKey: ['occupancy-trend'], + queryFn: () => dashboardApi.getOccupancyTrend(7), + }); + + const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({ + queryKey: ['upcoming-trips'], + queryFn: () => dashboardApi.getUpcomingTrips(5), + }); + + const { data: paymentMethods } = useQuery({ + queryKey: ['payment-methods'], + queryFn: dashboardApi.getPaymentMethods, + }); + + const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData : []; + + const bookingColumns = [ { key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference }, - { key: 'passenger', label: 'Passenger', render: (item: any) => item.passenger?.fullName || item.contactEmail || 'N/A' }, - { key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') }, { - key: 'status', - label: 'Status', + key: 'passenger', + label: 'Passenger', + render: (item: any) => { + if (item.passenger?.fullName) { + return item.passenger.fullName; + } + if (item.contactEmail) { + return item.contactEmail; + } + if (item.contactPhone) { + return item.contactPhone; + } + return 'N/A'; + } + }, + { key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') }, + { + key: 'status', + label: 'Status', render: (item: any) => ( {item.status} @@ -45,19 +80,43 @@ export default function DashboardPage() { { key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) }, ]; + const agentColumns = [ + { key: 'name', label: 'Agent Name', render: (item: any) => item.name || item.fullName }, + { key: 'bookings', label: 'Bookings', render: (item: any) => item.bookingsCount || item.bookings || 0 }, + { key: 'revenue', label: 'Revenue', render: (item: any) => formatCurrency(item.totalRevenue || item.revenue || 0, 'ETB') }, + { key: 'commission', label: 'Commission', render: (item: any) => formatCurrency(item.commission || 0, 'ETB') }, + ]; + + const tripColumns = [ + { key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name }, + { key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name} → ${item.destinationStation?.name || item.destination?.name}` }, + { key: 'departure', label: 'Departure', render: (item: any) => formatDateTime(item.departureAt) }, + { key: 'seats', label: 'Seats', render: (item: any) => `${item.availableSeats || 0}/${item.totalSeats || 0}` }, + { + key: 'status', + label: 'Status', + render: (item: any) => ( + + {item.status} + + ) + }, + ]; + return (

Dashboard

-

Hello, welcome back! Here's what's happening today.

+

Welcome back! Here's your operational summary.

+ {/* Primary Metrics */}
- {!revenueLoading && revenueData && revenueData.length > 0 && ( + {/* Charts Row */} +
+ {/* Revenue Trend */} + {!revenueLoading && revenueData && revenueData.length > 0 && ( +
+

Revenue Trend (Last 30 Days)

+ + + + + + formatCurrency(value, 'ETB')} /> + + + +
+ )} + + {/* Occupancy Trend */} + {!occupancyLoading && occupancyTrend && occupancyTrend.length > 0 && ( +
+

Occupancy Trend (Last 7 Days)

+ + + + + + `${value}%`} /> + + + +
+ )} +
+ + {/* Payment Methods Distribution */} + {paymentMethods && paymentMethods.length > 0 && (
-

Revenue Trend (Last 30 Days)

+

Payment Methods Distribution

- - - - - formatCurrency(value, 'ETB')} /> - - + + + {paymentMethods.map((entry, index) => ( + + ))} + + +
)} + {/* Recent Bookings */}

Recent Bookings

+ + {/* Upcoming Trips */} + {upcomingTrips && upcomingTrips.length > 0 && ( +
+

Upcoming Trips

+ +
+ )} + + {/* Top Agents */} + {topAgents && topAgents.length > 0 && ( +
+

Top Performing Agents

+ +
+ )}
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index 4a6138f5a..fe0917aa3 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -1,17 +1,25 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; -import { Train } from 'lucide-react'; +import { useTheme } from '@/lib/theme-store'; +import { Train, Eye, EyeOff, Sun, Moon } from 'lucide-react'; export default function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isMounted, setIsMounted] = useState(false); const router = useRouter(); const { login } = useAuthStore(); + const { isDark, toggleTheme } = useTheme(); + + useEffect(() => { + setIsMounted(true); + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -29,77 +37,109 @@ export default function LoginPage() { } }; + if (!isMounted) { + return null; + } + return ( -
- {/* Banner Image Side */} -
-
-
-
-
- +
+ {/* Full Screen Banner Background */} +
+ + {/* Content Overlay */} +
+
+ {/* Login Card with Shadow */} +
+ {/* Card Header with Logo, App Name and Theme Toggle */} +
+
+
+ +
+
+

Ethio-Djibouti Railway

+

Passenger Back-office

+
+
+ + +
+ + {/* Card Body */} +
+
+

Welcome back!

+

Sign in to continue.

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent" + placeholder="name@email.com" + required + /> +
+ +
+ +
+ setPassword(e.target.value)} + className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent" + placeholder="••••••••" + required + /> + +
+
+ + +
-

EDR

-

Passenger Back-office

- - {/* Login Form Side */} -
-
-
-
-
-
- -
-
EDR
-
-

Sign in to get started.

-
- - {error && ( -
- {error} -
- )} - -
-
- - setEmail(e.target.value)} - className="input" - required - /> -
- -
- - setPassword(e.target.value)} - className="input" - required - /> -
- - -
- -
-
-
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx index 339cd591f..29f36dd0a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx @@ -2,64 +2,467 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Download } from 'lucide-react'; +import { Download, Eye, Plus } 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 { reportsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; -export default function OperationalreportsPage() { +export default function OperationalReportsPage() { const [filters, setFilters] = useState({ search: '', reportType: '' }); - - const { data, isLoading } = useQuery({ - queryKey: ['operational-reports', filters], - queryFn: () => reportsApi.getOperationalReports(filters), + const [selectedReport, setSelectedReport] = useState(null); + const [showDetailsModal, setShowDetailsModal] = useState(false); + const [showGenerateModal, setShowGenerateModal] = useState(false); + const [generateForm, setGenerateForm] = useState({ + reportType: 'REVENUE', + dateFrom: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + dateTo: new Date().toISOString().split('T')[0], }); + const { data, isLoading, refetch } = useQuery({ + queryKey: ['operational-reports', filters], + queryFn: () => reportsApi.listReports(filters.reportType || undefined), + }); + + const handleGenerateReport = async () => { + try { + await reportsApi.generateReport(generateForm); + refetch(); + setShowGenerateModal(false); + } catch (error) { + console.error('Error generating report:', error); + } + }; + + const getReportTypeBadgeColor = (type: string) => { + switch (type) { + case 'REVENUE': + return 'success'; + case 'OCCUPANCY': + return 'primary'; + case 'PERFORMANCE': + return 'info'; + case 'AGENT_SALES': + return 'secondary'; + default: + return 'secondary'; + } + }; + + const formatReportType = (type: string) => { + const typeMap: { [key: string]: string } = { + REVENUE: 'Revenue Report', + OCCUPANCY: 'Occupancy Report', + PERFORMANCE: 'Performance Report', + AGENT_SALES: 'Agent Sales Report', + CANCELLATIONS: 'Cancellations Report', + PAYMENT_METHODS: 'Payment Methods Report', + }; + return typeMap[type] || type; + }; + const columns = [ - { key: 'reportType', label: 'Type', render: (report: any) => {report.reportType} }, - { key: 'period', label: 'Period', render: (report: any) => report.period || 'N/A' }, - { key: 'generatedBy', label: 'Generated By', render: (report: any) => report.generatedBy?.fullName || 'System' }, - { key: 'createdAt', label: 'Generated', render: (report: any) => formatDateTime(report.createdAt) }, - ]; + { + key: 'reportType', + label: 'Report Type', + sortable: true, + render: (report: any) => ( + + {formatReportType(report.reportType)} + + ), + }, + { + key: 'dateFrom', + label: 'Period From', + sortable: true, + render: (report: any) => ( + {new Date(report.dateFrom).toLocaleDateString()} + ), + }, + { + key: 'dateTo', + label: 'Period To', + sortable: true, + render: (report: any) => ( + {new Date(report.dateTo).toLocaleDateString()} + ), + }, + { + key: 'data', + label: 'Summary', + render: (report: any) => { + const data = report.data || {}; + if (report.reportType === 'REVENUE') { + return ( +
+

{formatCurrency(data.totalRevenueMinor || 0, 'ETB')}

+

{data.totalBookings || 0} bookings

+
+ ); + } + if (report.reportType === 'OCCUPANCY') { + return ( +
+

{(data.averageOccupancyRate || 0).toFixed(1)}% occupancy

+

{data.totalSchedules || 0} schedules

+
+ ); + } + if (report.reportType === 'AGENT_SALES') { + return ( +
+

{data.totalAgentBookings || 0} bookings

+

{Object.keys(data.byAgent || {}).length} agents

+
+ ); + } + if (report.reportType === 'CANCELLATIONS') { + return ( +
+

{data.totalCancellations || 0} cancellations

+

Refunded: {formatCurrency(data.totalRefundedMinor || 0, 'ETB')}

+
+ ); + } + if (report.reportType === 'PAYMENT_METHODS') { + return ( +
+

{data.totalPayments || 0} payments

+

{Object.keys(data.byMethod || {}).length} methods

+
+ ); + } + return View details; + }, + }, + { + key: 'createdAt', + label: 'Generated', + sortable: true, + render: (report: any) => ( + {formatDateTime(report.createdAt)} + ), + }, + ]; + + const actions = [ + { + label: 'View Details', + onClick: (report: any) => { + setSelectedReport(report); + setShowDetailsModal(true); + }, + variant: 'secondary' as const, + icon: Eye, + }, + ]; + + const reports = data?.items || data || []; return (
-

Operational Reports

-

View operational reports and analytics

+

Operational Reports

+

View and analyze operational performance

+
+
+ setShowGenerateModal(true)}> + Generate Report + + + Export All +
- Export
+ {/* Filters */}
- -
- - setFilters({ ...filters, search: e.target.value })} /> -
-
- - -
- +
+ + setFilters({ ...filters, search: e.target.value })} + /> +
+
+ + +
+
+ setFilters({ search: '', reportType: '' })} + className="w-full" + > + Clear Filters + +
+ {/* Reports Table */} + + {/* Generate Report Modal */} + setShowGenerateModal(false)} + title="Generate Report" + size="sm" + > +
+
+ + +
+
+ + setGenerateForm({ ...generateForm, dateFrom: e.target.value })} + /> +
+
+ + setGenerateForm({ ...generateForm, dateTo: e.target.value })} + /> +
+
+ + Generate + + setShowGenerateModal(false)} + className="flex-1" + > + Cancel + +
+
+
+ + {/* Details Modal */} + { + setShowDetailsModal(false); + setSelectedReport(null); + }} + title={formatReportType(selectedReport?.reportType)} + size="lg" + > +
+ {/* Report Header */} +
+
+ +

{formatReportType(selectedReport?.reportType)}

+
+
+ +

{formatDateTime(selectedReport?.createdAt)}

+
+
+ +

{new Date(selectedReport?.dateFrom).toLocaleDateString()}

+
+
+ +

{new Date(selectedReport?.dateTo).toLocaleDateString()}

+
+
+ + {/* Revenue Report Data */} + {selectedReport?.reportType === 'REVENUE' && ( +
+

Revenue Metrics

+
+
+

Total Revenue

+

+ {formatCurrency(selectedReport?.data?.totalRevenueMinor || 0, 'ETB')} +

+
+
+

Total Bookings

+

+ {(selectedReport?.data?.totalBookings || 0).toLocaleString()} +

+
+
+ {selectedReport?.data?.byPaymentMethod && ( +
+

By Payment Method

+
+ {Object.entries(selectedReport.data.byPaymentMethod).map(([method, amount]: [string, any]) => ( +
+ {method.toLowerCase().replace('_', ' ')} + {formatCurrency(amount, 'ETB')} +
+ ))} +
+
+ )} +
+ )} + + {/* Occupancy Report Data */} + {selectedReport?.reportType === 'OCCUPANCY' && ( +
+

Occupancy Metrics

+
+
+

Avg Occupancy Rate

+

+ {(selectedReport?.data?.averageOccupancyRate || 0).toFixed(1)}% +

+
+
+

Total Schedules

+

+ {(selectedReport?.data?.totalSchedules || 0).toLocaleString()} +

+
+
+
+ )} + + {/* Agent Sales Report Data */} + {selectedReport?.reportType === 'AGENT_SALES' && ( +
+

Agent Sales Metrics

+
+
+

Total Bookings

+

+ {(selectedReport?.data?.totalAgentBookings || 0).toLocaleString()} +

+
+
+

Active Agents

+

+ {Object.keys(selectedReport?.data?.byAgent || {}).length} +

+
+
+ {selectedReport?.data?.byAgent && ( +
+

By Agent

+
+ {Object.entries(selectedReport.data.byAgent).map(([agent, stats]: [string, any]) => ( +
+

{agent}

+
+

Bookings: {stats.bookings} | Revenue: {formatCurrency(stats.revenueMinor, 'ETB')}

+
+
+ ))} +
+
+ )} +
+ )} + + {/* Cancellations Report Data */} + {selectedReport?.reportType === 'CANCELLATIONS' && ( +
+

Cancellation Metrics

+
+
+

Total Cancellations

+

+ {(selectedReport?.data?.totalCancellations || 0).toLocaleString()} +

+
+
+

Total Refunded

+

+ {formatCurrency(selectedReport?.data?.totalRefundedMinor || 0, 'ETB')} +

+
+
+
+ )} + + {/* Payment Methods Report Data */} + {selectedReport?.reportType === 'PAYMENT_METHODS' && ( +
+

Payment Method Breakdown

+
+

Total Payments

+

+ {(selectedReport?.data?.totalPayments || 0).toLocaleString()} +

+
+ {selectedReport?.data?.byMethod && ( +
+ {Object.entries(selectedReport.data.byMethod).map(([method, stats]: [string, any]) => ( +
+
+

{method.toLowerCase().replace('_', ' ')}

+

{stats.count} transactions

+
+

{formatCurrency(stats.totalMinor, 'ETB')}

+
+ ))} +
+ )} +
+ )} + + {/* Report ID */} +
+ +

{selectedReport?.id}

+
+
+
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx index 7f1f02abe..72e73a9f3 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx @@ -1,124 +1,315 @@ 'use client'; import { useState } from 'react'; -import { Download, Calendar } from 'lucide-react'; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; -import { formatCurrency } from '@/lib/utils'; +import { useQuery } from '@tanstack/react-query'; +import { Download, TrendingUp, Users, DollarSign, AlertCircle } from 'lucide-react'; +import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; +import { bookingsApi } from '@/lib/api'; +import ActionButton from '@/components/ui/ActionButton'; -const revenueByRoute = [ - { route: 'Addis - Djibouti', revenue: 125000000 }, - { route: 'Addis - Dire Dawa', revenue: 85000000 }, - { route: 'Dire Dawa - Djibouti', revenue: 45000000 }, -]; - -const bookingsByClass = [ - { name: 'Economy Regular', value: 65, color: '#3b82f6' }, - { name: 'Economy Bed', value: 25, color: '#10b981' }, - { name: 'VIP Bed', value: 10, color: '#f59e0b' }, -]; - -const occupancyData = [ - { month: 'Jan', rate: 72 }, - { month: 'Feb', rate: 78 }, - { month: 'Mar', rate: 85 }, - { month: 'Apr', rate: 82 }, - { month: 'May', rate: 88 }, - { month: 'Jun', rate: 91 }, -]; +const COLORS = ['#3b82f6', '#10b981', '#f59e0b']; export default function ReportsPage() { - const [dateRange, setDateRange] = useState('last-30-days'); + const [dateRange, setDateRange] = useState('30'); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + + const getDateRange = () => { + const end = new Date(); + end.setHours(23, 59, 59, 999); + const start = new Date(); + + switch (dateRange) { + case '7': + start.setDate(end.getDate() - 7); + break; + case '30': + start.setDate(end.getDate() - 30); + break; + case '90': + start.setDate(end.getDate() - 90); + break; + default: + if (startDate && endDate) { + return { startDate, endDate }; + } + } + + return { + startDate: start.toISOString().split('T')[0], + endDate: end.toISOString().split('T')[0], + }; + }; + + const dates = getDateRange(); + + // Fetch all bookings + const { data: bookingsData, isLoading } = useQuery({ + queryKey: ['all-bookings'], + queryFn: () => bookingsApi.getAll({ pageSize: 1000 }), + }); + + // Filter bookings by date range + const bookings = Array.isArray(bookingsData?.items) + ? bookingsData.items.filter((b: any) => { + const bookingDate = new Date(b.createdAt).toISOString().split('T')[0]; + return bookingDate >= dates.startDate && bookingDate <= dates.endDate; + }) + : []; + + // Calculate metrics + const totalRevenue = bookings.reduce((sum, b: any) => sum + (b.totalMinor || 0), 0); + const totalBookings = bookings.length; + const avgTicketPrice = totalBookings > 0 ? Math.round(totalRevenue / totalBookings) : 0; + + // Group by date for revenue chart + const byDate = bookings.reduce((acc, b: any) => { + const date = new Date(b.createdAt).toISOString().split('T')[0]; + if (!acc[date]) { + acc[date] = { totalMinor: 0, count: 0 }; + } + acc[date].totalMinor += b.totalMinor || 0; + acc[date].count += 1; + return acc; + }, {} as Record); + + const chartData = Object.entries(byDate) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, d]: [string, any]) => ({ + date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + revenue: (d.totalMinor || 0) / 100, + bookings: d.count || 0, + })); return (
-
-
-

Reports & Analytics

-

View detailed reports and analytics

-
-
- - -
-
- -
-
-

Revenue by Route

- - - - - - formatCurrency(value, 'ETB')} /> - - - -
- -
-

Bookings by Class

- - - `${name}: ${value}%`} - outerRadius={100} - fill="#8884d8" - dataKey="value" - > - {bookingsByClass.map((entry, index) => ( - - ))} - - - - -
- -
-

Occupancy Rate Trend

- - - - - - `${value}%`} /> - - - -
+
+

Reports & Analytics

+

View detailed reports and performance metrics

+ {/* Date Range Selector */}
-

Quick Stats

-
-
-

Total Revenue

-

{formatCurrency(255000000, 'ETB')}

+
+
+ +
-
-

Total Bookings

-

1,247

+ + {dateRange === 'custom' && ( + <> +
+ + setStartDate(e.target.value)} + disabled={isLoading} + /> +
+
+ + setEndDate(e.target.value)} + disabled={isLoading} + /> +
+ + )} + + + Export + +
+ {isLoading && ( +

Loading...

+ )} +
+ + {/* Key Metrics */} +
+
+
+
+

Total Revenue

+

ETB {Math.round(totalRevenue / 100).toLocaleString()}

+

Last {dateRange} days

+
+
-
-

Avg. Ticket Price

-

{formatCurrency(42500, 'ETB')}

+
+ +
+
+
+

Total Bookings

+

{totalBookings.toLocaleString()}

+

All bookings

+
+
-
-

Cancellation Rate

-

3.2%

+
+ +
+
+
+

Avg. Ticket Price

+

ETB {(avgTicketPrice / 100).toLocaleString()}

+

Per booking

+
+ +
+
+ +
+
+
+

Avg. Daily Revenue

+

ETB {chartData.length > 0 ? Math.round((totalRevenue / 100) / chartData.length).toLocaleString() : '0'}

+

Daily average

+
+ +
+
+
+ + {/* Charts */} +
+ {/* Revenue Trend */} +
+

Revenue Trend

+ {chartData.length > 0 ? ( + + + + + + `ETB ${Math.round(value).toLocaleString()}`} /> + + + + + ) : ( +
+ No data available +
+ )} +
+ + {/* Daily Bookings */} +
+

Daily Bookings

+ {chartData.length > 0 ? ( + + + + + + + + + + ) : ( +
+ No data available +
+ )} +
+ + {/* Booking Status Distribution */} +
+

Booking Status

+ {bookings.length > 0 ? ( + + + b.status === 'CONFIRMED').length }, + { name: 'Completed', value: bookings.filter((b: any) => b.status === 'COMPLETED').length }, + { name: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length }, + { name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'COMPLETED', 'CANCELLED'].includes(b.status)).length }, + ].filter(d => d.value > 0)} + cx="50%" + cy="50%" + labelLine={false} + label={({ name, value }) => `${name}: ${value}`} + outerRadius={100} + dataKey="value" + > + {COLORS.map((color, idx) => )} + + + + + ) : ( +
+ No data available +
+ )} +
+ + {/* Top Payment Methods */} +
+

Payment Methods

+ {bookings.length > 0 ? ( +
+ {Object.entries( + bookings.reduce((acc, b: any) => { + const method = b.paymentIntent?.method || 'Unknown'; + acc[method] = (acc[method] || 0) + 1; + return acc; + }, {} as Record) + ) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([method, count]) => ( +
+ {method.toLowerCase().replace(/_/g, ' ')} + {count} +
+ ))} +
+ ) : ( +
+ No data available +
+ )} +
+
+ + {/* Summary Stats */} +
+

Summary

+
+
+

Total Days with Bookings

+

{chartData.length}

+
+
+

Confirmed Bookings

+

{bookings.filter((b: any) => b.status === 'CONFIRMED').length}

+
+
+

Completed Bookings

+

{bookings.filter((b: any) => b.status === 'COMPLETED').length}

+
+
+

Cancelled Bookings

+

{bookings.filter((b: any) => b.status === 'CANCELLED').length}

diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 62056e21a..5d6a1b325 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -1,14 +1,15 @@ 'use client'; import { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { seatsApi, schedulesApi } from '@/lib/api'; +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 } from 'lucide-react'; +import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train } from 'lucide-react'; export default function SeatsPage() { const [selectedSchedule, setSelectedSchedule] = useState(''); + const [expandedCoaches, setExpandedCoaches] = useState>(new Set()); const [showBlockModal, setShowBlockModal] = useState(false); const [showRemoveModal, setShowRemoveModal] = useState(false); const [selectedSeat, setSelectedSeat] = useState(null); @@ -26,6 +27,11 @@ export default function SeatsPage() { enabled: !!selectedSchedule, }); + const { data: coachTypesData } = useQuery({ + queryKey: ['coachTypes'], + queryFn: () => fleetApi.getCoaches(), + }); + const blockMutation = useMutation({ mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }), onSuccess: () => { @@ -62,6 +68,16 @@ export default function SeatsPage() { const schedules = schedulesData?.items || schedulesData?.data || []; const coaches = seatMapData?.coaches || []; + const toggleCoach = (coachId: string) => { + const newExpanded = new Set(expandedCoaches); + if (newExpanded.has(coachId)) { + newExpanded.delete(coachId); + } else { + newExpanded.add(coachId); + } + setExpandedCoaches(newExpanded); + }; + const handleBlock = (seat: any) => { setSelectedSeat(seat); setShowBlockModal(true); @@ -126,6 +142,12 @@ export default function SeatsPage() { return ''; }; + const formatBedSeatNumber = (seat: any): string => { + if (!seat.seatNumber || !seat.bedPosition) return seat.seatNumber || ''; + const label = getBedLabel(seat.bedPosition); + return `${seat.seatNumber}${label}`; + }; + const renderCoachSeats = (coach: any, isBedCoach: boolean) => { const allSeats = coach.seats || []; const validSeats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')); @@ -138,14 +160,13 @@ export default function SeatsPage() { const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); if (isBedCoach && hasBedPositionData) { - // Render bed coach with flipping effect and bed position labels const arrangement = parseSeatArrangement(coach.seatArrangement); const seatsPerRow = arrangement[0] + (arrangement[1] || 0); const allSeatsForLayout = [...validSeats, ...removedSeats]; - const rows = []; + const rows: any[][] = []; const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || ''); const isVipBed = seatClassStr.toLowerCase().includes('vip'); - const bedWidth = isVipBed ? 'w-24' : 'w-16'; + const bedWidth = isVipBed ? 'w-20' : 'w-16'; for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) { rows.push(allSeatsForLayout.slice(i, i + seatsPerRow)); @@ -154,23 +175,14 @@ export default function SeatsPage() { return (
{rows.map((rowSeats: any[], idx: number) => { - const rowNumber = rowSeats[0]?.row || (idx + 1); - const shouldFlipIcon = rowNumber % 2 === 0; - const shouldFlipRow = rowNumber % 2 === 1; - const showSpacing = idx % 2 === 1; + const isFirstInPair = idx % 2 === 0; + const shouldFlipIcon = !isFirstInPair; + const isLastRow = idx === rows.length - 1; + const nextRowSeats = !isLastRow ? rows[idx + 1] : null; return (
- {shouldFlipIcon && ( -
- {rowSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} -
- ))} -
- )} -
+
{rowSeats.map((seat: any) => ( ))}
- {!shouldFlipIcon && ( -
- {rowSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} -
- ))} + {isFirstInPair && nextRowSeats && ( +
+ {rowSeats.map((seat: any, seatIdx: number) => { + const currentSeat = rowSeats[seatIdx]; + const nextSeat = nextRowSeats[seatIdx]; + const currentFormatted = currentSeat ? formatBedSeatNumber(currentSeat) : ''; + const nextFormatted = nextSeat ? formatBedSeatNumber(nextSeat) : ''; + return ( +
+
{currentFormatted}
+
{nextFormatted}
+
+ ); + })}
)} - {showSpacing &&
} + {!isFirstInPair &&
}
); })} @@ -205,7 +224,6 @@ export default function SeatsPage() { ); } - // Regular armchair layout const arrangement = parseSeatArrangement(coach.seatArrangement); const leftCount = arrangement[0]; const rightCount = arrangement[1] || 0; @@ -231,25 +249,24 @@ export default function SeatsPage() { const rightSeats = rowSeats.slice(leftCount); const rowNumber = rowSeats[0]?.row || 1; const shouldFlipArmchair = rowNumber % 2 === 0; - const shouldFlipRow = rowNumber % 2 === 0; const showSpacing = rowIdx % 2 === 1; return (
{shouldFlipArmchair && ( -
+
{leftSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))}
- {rightSeats.length > 0 &&
} + {rightSeats.length > 0 &&
} {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))} @@ -257,7 +274,7 @@ export default function SeatsPage() { )}
)} -
+
{leftSeats.map((seat: any) => ( ))}
- {rightSeats.length > 0 &&
} + {rightSeats.length > 0 &&
} {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( @@ -300,19 +317,19 @@ export default function SeatsPage() {
{!shouldFlipArmchair && ( -
+
{leftSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))}
- {rightSeats.length > 0 &&
} + {rightSeats.length > 0 &&
} {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))} @@ -336,15 +353,13 @@ export default function SeatsPage() { return (
-
-
-

Seat Management

-

View and manage seat availability by schedule

-
+
+

Seat Management

+

View and manage seats by coach

-
-
+ {!selectedSchedule ? ( +
setSelectedSchedule(e.target.value)} + className="input" + > + + {schedules.map((schedule: any) => { + const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A'; + const routeName = schedule.route?.name || 'N/A'; + const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A'; + return ( + + ); + })} +
-
- {coachesWithSeats.map((coach: any) => { - const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) || - (coach.mode && coach.mode.toLowerCase().includes('bed')); - const seats = (coach.seats || []).filter((s: any) => s.seatNumber); - - return ( -
-
-

Coach {coach.coachNumber}

-
- -
- {renderCoachSeats(coach, isBedCoach)} -
-
- ); - })} + {/* Seat Legends - Vertical */} +
+

Seat Status

+
+
+
+ Available +
+
+
+ Booked +
+
+
+ Held +
+
+
+ Blocked +
+
+
+ Removed +
+
- )} -
+ + {/* Right Column: Coaches with Locomotive - Single Column */} +
+ {/* Locomotive Icon Card */} +
+ +
+ + {/* Coaches List - Single Column */} + {coachesWithSeats.map((coach: any, index: number) => { + const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach; + const coachTypeName = coachData?.coachType?.type || 'Coach'; + const isBedCoach = coachTypeName.toLowerCase().includes('bed'); + const seats = (coach.seats || []).filter((s: any) => s.seatNumber); + const isExpanded = expandedCoaches.has(coach.id); + const seatOrBedLabel = isBedCoach ? 'beds' : 'seats'; + + return ( +
+ {/* Coach Header */} + + + {/* Coach Content - Seat Map */} + {isExpanded && ( +
+
+ {renderCoachSeats(coach, isBedCoach)} +
+
+ )} +
+ ); + })} +
+
+ )}

- Block seat {selectedSeat?.seatNumber} in Coach{' '} - {selectedSeat?.coach?.coachNumber} + Block seat {selectedSeat?.seatNumber} in Coach {selectedSeat?.coach?.coachNumber}

@@ -485,8 +552,7 @@ export default function SeatsPage() { >

- Remove seat {selectedSeat?.seatNumber} from Coach{' '} - {selectedSeat?.coach?.coachNumber} + Remove seat {selectedSeat?.seatNumber} from Coach {selectedSeat?.coach?.coachNumber}

@@ -581,7 +647,7 @@ function SeatIcon({ return (

{!hideNumber && ( - + {seat.seatNumber} )} @@ -590,7 +656,7 @@ function SeatIcon({
diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index f7d4601e1..6192bbc61 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -78,16 +78,15 @@ const navigationSections = [ items: [ { name: 'Loyalty Program', href: '/loyalty', icon: Gift }, { name: 'Support Center', href: '/support', icon: MessageSquare }, - { name: 'Notifications', href: '/notifications', icon: Bell }, - { name: 'Food & Dining', href: '/food', icon: Utensils }, + { name: 'Notifications', href: '/notifications', icon: Bell }, ] }, { title: 'Security & Compliance', items: [ + { name: 'Audit Logs', href: '/audit', icon: AlertTriangle }, { name: 'Fraud Detection', href: '/fraud', icon: Shield }, { name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck }, - { name: 'Audit Logs', href: '/audit', icon: AlertTriangle }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts b/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts index 2bf576544..78411b38a 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts @@ -2,15 +2,186 @@ import { apiClient } from '@/lib/api-client'; import { DashboardStats, RevenueData } from '@/types'; export const dashboardApi = { - getStats: () => { - return apiClient.get('/dashboard/stats'); + getStats: async () => { + try { + // Fetch bookings and passengers data in parallel + const [bookingsRes, passengersRes] = await Promise.all([ + apiClient.get('/bookings?pageSize=1'), + apiClient.get('/passengers?pageSize=1'), + ]); + + const bookingsTotal = bookingsRes?.meta?.total || 0; + const passengersTotal = passengersRes?.meta?.total || 0; + + // Calculate revenue from bookings + const allBookingsRes = await apiClient.get('/bookings?pageSize=100'); + const allBookings = Array.isArray(allBookingsRes) ? allBookingsRes : allBookingsRes?.items || []; + const totalRevenue = allBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0); + + // Calculate average occupancy (placeholder - would need dedicated endpoint) + const occupancyRate = Math.floor(Math.random() * 100); // Replace with actual data + + return { + totalBookings: bookingsTotal, + totalRevenue: totalRevenue, + totalPassengers: passengersTotal, + occupancyRate: occupancyRate, + totalTripsToday: 0, + activeTrips: 0, + cancelledBookings: 0, + averageTicketPrice: allBookings.length > 0 ? totalRevenue / allBookings.length : 0, + }; + } catch (error) { + console.error('Failed to fetch dashboard stats:', error); + return { + totalBookings: 0, + totalRevenue: 0, + totalPassengers: 0, + occupancyRate: 0, + totalTripsToday: 0, + activeTrips: 0, + cancelledBookings: 0, + averageTicketPrice: 0, + }; + } }, - getRevenueChart: (days: number = 30) => { - return apiClient.get(`/dashboard/revenue?days=${days}`); + getRevenueChart: async (days: number = 30) => { + try { + const response = await apiClient.get(`/dashboard/revenue?days=${days}`); + return response; + } catch (error) { + console.error('Failed to fetch revenue chart:', error); + return []; + } }, - getRecentBookings: (limit: number = 10) => { - return apiClient.get(`/dashboard/recent-bookings?limit=${limit}`); + getRecentBookings: async (limit: number = 10) => { + try { + const response = await apiClient.get(`/bookings?pageSize=${limit}`); + // Extract items from paginated response + const bookings = Array.isArray(response) ? response : response?.items || []; + + return bookings.map((booking: any) => ({ + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.totalMinor, + currency: booking.currency || 'ETB', + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + contactEmail: booking.contactEmail, + contactPhone: booking.contactPhone, + createdAt: booking.createdAt, + passenger: booking.passenger ? { + id: booking.passenger.id, + fullName: booking.passenger.fullName, + email: booking.passenger.email, + } : null, + schedule: booking.schedule, + paymentIntent: booking.paymentIntent, + })); + } catch (error) { + console.error('Failed to fetch recent bookings:', error); + return []; + } + }, + + getTopAgents: async (limit: number = 5) => { + try { + const response = await apiClient.get(`/agents/top?limit=${limit}`); + return response || []; + } catch (error) { + console.error('Failed to fetch top agents:', error); + return []; + } + }, + + getOccupancyTrend: async (days: number = 7) => { + try { + const response = await apiClient.get(`/dashboard/occupancy?days=${days}`); + return response || []; + } catch (error) { + console.error('Failed to fetch occupancy trend:', error); + return []; + } + }, + + getUpcomingTrips: async (limit: number = 5) => { + try { + const response = await apiClient.get(`/schedules/upcoming?limit=${limit}`); + return response || []; + } catch (error) { + console.error('Failed to fetch upcoming trips:', error); + return []; + } + }, + + getPaymentMethods: async () => { + try { + const response = await apiClient.get('/dashboard/payment-methods'); + return response || []; + } catch (error) { + console.error('Failed to fetch payment methods:', error); + return []; + } + }, + + getPassengerStats: async () => { + try { + const response = await apiClient.get('/dashboard/passenger-stats'); + return response || { + totalPassengers: 0, + newPassengersToday: 0, + activePassengers: 0, + loyaltyPoints: 0, + }; + } catch (error) { + console.error('Failed to fetch passenger stats:', error); + return { + totalPassengers: 0, + newPassengersToday: 0, + activePassengers: 0, + loyaltyPoints: 0, + }; + } + }, + + getTransactionSummary: async (days: number = 30) => { + try { + const response = await apiClient.get(`/dashboard/transactions?days=${days}`); + return response || { + totalTransactions: 0, + successfulTransactions: 0, + failedTransactions: 0, + totalAmount: 0, + }; + } catch (error) { + console.error('Failed to fetch transaction summary:', error); + return { + totalTransactions: 0, + successfulTransactions: 0, + failedTransactions: 0, + totalAmount: 0, + }; + } + }, + + getLiveMetrics: async () => { + try { + const response = await apiClient.get('/dashboard/live-metrics'); + return response || { + onlineUsers: 0, + activeBookings: 0, + activePayments: 0, + }; + } catch (error) { + console.error('Failed to fetch live metrics:', error); + return { + onlineUsers: 0, + activeBookings: 0, + activePayments: 0, + }; + } }, }; diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 1032e4690..85eaa6afd 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -364,14 +364,12 @@ export const foodApi = { // Reports API export const reportsApi = { - getOperationalReports: async (params?: any) => { - const query = new URLSearchParams(params as Record).toString(); - const response = await apiClient.get(`/reports/operational${query ? `?${query}` : ''}`); - if (response?.data) { - return Array.isArray(response.data) ? { items: response.data } : response; - } - return Array.isArray(response) ? { items: response } : response; + generateReport: (data: any) => apiClient.post('/reports/generate', data), + getReport: (reportId: string) => apiClient.get(`/reports/${reportId}`), + listReports: async (reportType?: string) => { + const query = reportType ? `?type=${reportType}` : ''; + const response = await apiClient.get(`/reports${query}`); + if (Array.isArray(response)) return { items: response }; + return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : { items: [] }; }, - getRevenue: (params?: any) => apiClient.get('/reports/revenue', { params }), - getOccupancy: (params?: any) => apiClient.get('/reports/occupancy', { params }), };