Backoffice UAT results addressed, packages and other updates

This commit is contained in:
Stephanos A
2026-06-25 20:10:36 +03:00
parent 9e84a022df
commit cfbbd437e9
28 changed files with 1042 additions and 211 deletions

View File

@@ -689,8 +689,15 @@ async function seedKulubbiPackage() {
const [outboundSchedule, returnSchedule] = schedules;
await prisma.travelPackage.upsert({
where: { code: 'KULUBBI-2025' },
update: {},
where: { code: 'KULUBI-2025' },
update: {
validFrom: new Date(),
validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
boardingTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
departureTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
arrivalTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
status: 'ACTIVE',
},
create: {
code: 'KULUBBI-2025',
name: 'Kulubbi Gabriel Pilgrimage Package',
@@ -699,15 +706,15 @@ async function seedKulubbiPackage() {
returnScheduleId: returnSchedule.id,
originStationId: addisStation.id,
destinationStationId: direDawaStation.id,
boardingTime: new Date('2025-07-24T07:00:00+03:00'),
departureTime: new Date('2025-07-24T09:00:00+03:00'),
arrivalTime: new Date('2025-07-25T06:00:00+03:00'),
boardingTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
departureTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
arrivalTime: new Date(new Date().setMonth(new Date().getMonth() + 1)),
totalCapacity: 912,
coachConfiguration: '1 Locomotive + 2SBC + 2HBC + 6HSC',
busTransferIncluded: true,
busTransferRoute: 'Dire Dawa ↔ Kulubi Gabriel',
validFrom: new Date('2025-07-01'),
validUntil: new Date('2025-07-24T09:00:00+03:00'),
validFrom: new Date(),
validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
status: 'ACTIVE',
includedServices: [
'Round-trip train travel (Addis Ababa ↔ Dire Dawa)',

View File

@@ -1,4 +1,4 @@
// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM
// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM
// modules read process.env at module-load time (e.g. MinioModule.register reads MINIO_ENDPOINT),
// which happens before ConfigModule.forRoot() would populate it. Must be the very first import.
import "dotenv/config";
@@ -18,7 +18,7 @@ async function bootstrap() {
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under
// `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay
// version-neutral at their existing paths (e.g. /search, /bookings) unchanged for the frontend.
// version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend.
app.enableVersioning({ type: VersioningType.URI });
app.enableCors({
@@ -126,7 +126,7 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
- Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
- Complete audit trail per leg for compliance and reporting
- **Boarding pass delivered via email + SMS on every successful gate validation** includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode
- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode
### Booking Type Matrix
@@ -236,28 +236,28 @@ For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`ret
### Step 3: Passenger Information & Verification
**For Ethiopian Passengers:**
\`POST /passengers/verify-fayda\` Automatic Fayda verification for adults (5+ years)
\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years)
**For International Passengers:**
\`POST /passengers/register-international\` Passport information collection
\`POST /passengers/register-international\` — Passport information collection
### Step 4: View Seat Map
\`GET /seats/seatmap/{scheduleId}\` Show available coaches and seats.
\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats.
For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId.
### Step 5: Hold Seats
\`POST /seats/hold\` to reserve seats for 15 minutes.
- ONE_WAY / TRANSIT outbound leg: one hold call \`holdId\`
- TRANSIT leg-2: second hold call \`leg2HoldId\`
- ROUND_TRIP return: second hold call \`returnHoldId\`
- ROUND_TRIP_TRANSIT: four hold calls \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
- TRANSIT leg-2: second hold call → \`leg2HoldId\`
- ROUND_TRIP return: second hold call → \`returnHoldId\`
- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
### Step 6: Create Booking
Choose the right endpoint and bookingType:
- **ONE_WAY** \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
- **ROUND_TRIP** same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
- **TRANSIT** same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
- **ROUND_TRIP_TRANSIT** same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
### Step 7: Process Payment
\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian)
@@ -311,6 +311,8 @@ Payment providers send notifications to:
"JWT-auth",
)
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
.addTag("Excess Baggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.")
.addTag("Packages", "Bundled travel packages with tiered pricing. Public endpoints for browsing and booking; JWT-authenticated endpoints for purchase history; IAM-protected endpoints for admin CRUD and tier management.")
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. returnLegStatus filter for round-trip no-show management")
@@ -347,6 +349,50 @@ Payment providers send notifications to:
.build();
const document = SwaggerModule.createDocument(app, config);
// Collapse all IAM / platform-infrastructure tags into one Swagger tag so every
// endpoint from @tria-plc/iamapi-common and @tria-plc/api-common appears under
// a single "Corporate IAM & Platform Infrastructure" section.
const IAM_UNIFIED_TAG = 'Corporate IAM & Platform Infrastructure';
const IAM_SOURCE_TAGS = new Set([
'Auth', 'Sessions', 'API_COMMON_File Settings',
'IAM_USER__Users', 'IAM_USER__User Document', 'IAM_USER__User Roles',
'IAM_USER__Roles', 'IAM_USER__Role Permissions', 'IAM_USER__Permissions',
'IAM_USER__Applications', 'IAM_USER__Account Configurations', 'IAM_USER__Documentary Requirements',
'IAM_ORGANISATION_STRUCTURE__Organizations', 'IAM_ORGANISATION_STRUCTURE__Organization Types',
'IAM_ORGANISATION_STRUCTURE__Organization Configurations',
'IAM_ORGANISATION_STRUCTURE__Global Organization Configurations',
'IAM_ORGANISATION_STRUCTURE__Organization Settings',
'IAM_ORGANISATION_STRUCTURE__Units', 'IAM_ORGANISATION_STRUCTURE__Unit Settings',
'IAM_ORGANISATION_STRUCTURE__Global Unit Configurations', 'IAM_ORGANISATION_STRUCTURE__Unit Clusters',
'IAM_ORGANISATION_STRUCTURE__Positions', 'IAM_ORGANISATION_STRUCTURE__Position Types',
'IAM_ORGANISATION_STRUCTURE__Position Configurations',
'IAM_ORGANISATION_STRUCTURE__Position Type Configurations',
'IAM_ORGANISATION_STRUCTURE__Position Permissions', 'IAM_ORGANISATION_STRUCTURE__Position Type Permissions',
'IAM_ORGANISATION_STRUCTURE__Employees', 'IAM_ORGANISATION_STRUCTURE__Employee Positions',
'IAM_ORGANISATION_STRUCTURE__Locations', 'IAM_ORGANISATION_STRUCTURE__Location Types',
'IAM_ORGANISATION_STRUCTURE__Default Units', 'IAM_ORGANISATION_STRUCTURE__Default Positions',
'IAM_ORGANISATION_STRUCTURE__Projects', 'IAM_ORGANISATION_STRUCTURE__Migrate',
'IAM_RECORD__Headers', 'IAM_RECORD__Footers', 'IAM_RECORD__Seals',
'IAM_RECORD__Employee Signatures', 'IAM_RECORD__Employee Stamps',
]);
// Re-tag every operation whose tags overlap with IAM_SOURCE_TAGS
for (const pathItem of Object.values(document.paths)) {
for (const operation of Object.values(pathItem as Record<string, any>)) {
if (Array.isArray(operation?.tags)) {
const hasIam = operation.tags.some((t: string) => IAM_SOURCE_TAGS.has(t));
if (hasIam) operation.tags = [IAM_UNIFIED_TAG];
}
}
}
// Replace the individual source tag definitions with the single unified tag
document.tags = [
...(document.tags ?? []).filter((t: any) => !IAM_SOURCE_TAGS.has(t.name)),
{ name: IAM_UNIFIED_TAG, description: 'Back-office staff authentication, session management, organisation structure, user/role/permission management, and file settings. Provided by @tria-plc/iamapi-common and @tria-plc/api-common.' },
];
SwaggerModule.setup("api-docs", app, document, {
customSiteTitle: "EDR Passenger API",
swaggerOptions: {
@@ -360,7 +406,7 @@ Payment providers send notifications to:
const port = process.env.PORT ?? 4000;
await app.listen(port);
console.log(`🚀 EDR Passenger API running on port ${port}`);
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
console.log(`🚀 EDR Passenger API running on port ${port}`);
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
}
bootstrap();

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Post, Query, Request, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
@ApiTags('Agents')
@@ -16,6 +16,24 @@ export class AgentsController {
getMe(@Request() req: any) {
return this.service.getMe(req.user?.id ?? req.user?.sub);
}
@Get()
@ApiOperation({ summary: 'List all agents' })
findAll(@Query('search') search?: string, @Query('active') active?: string) {
return this.service.findAll({ search, active });
}
@Post()
@ApiOperation({ summary: 'Create agent profile linked to an IAM user' })
createAgent(@Body() dto: CreateAgentDto) {
return this.service.createAgent(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update agent profile' })
updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) {
return this.service.updateAgent(id, dto);
}
@Post('bookings')
@ApiOperation({ summary: 'Create agent booking with cash payment' })
createBooking(@Body() dto: CreateAgentBookingDto) {

View File

@@ -2,6 +2,12 @@ import { IsString, IsInt, IsBoolean, IsOptional, IsArray, ValidateNested } from
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateAgentDto {
@ApiProperty() @IsString() iamUserId: string;
@ApiPropertyOptional() @IsOptional() @IsString() agentCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() commissionRate?: number;
}
export class AgentPassengerDto {
@ApiProperty() @IsString() fullName: string;
@ApiProperty() @IsString() phone: string;

View File

@@ -1,4 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { IdDocumentType } from '@prisma/client';
@@ -10,7 +12,49 @@ function generateRef(): string {
@Injectable()
export class AgentsService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
async findAll(filters: { search?: string; active?: string }) {
const where: any = {};
if (filters.active !== undefined && filters.active !== '') {
where.active = filters.active === 'true';
}
const agents = await this.prisma.agent.findMany({
where,
orderBy: { createdAt: 'desc' },
});
// Enrich with IAM user data
const iamUserIds = agents.map(a => a.iamUserId).filter(Boolean) as string[];
type IamRow = { id: string; email: string; name: any; phone_number: string | null };
const iamRows: IamRow[] = iamUserIds.length > 0
? await this.dataSource.query<IamRow[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
).catch(() => [])
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
const items = agents
.map(a => {
const iam = a.iamUserId ? iamMap.get(a.iamUserId) ?? null : null;
const fullName = iam?.name?.en ?? iam?.name?.am ?? null;
if (filters.search) {
const q = filters.search.toLowerCase();
const matches = a.agentCode.toLowerCase().includes(q)
|| (iam?.email ?? '').toLowerCase().includes(q)
|| (fullName ?? '').toLowerCase().includes(q);
if (!matches) return null;
}
return {
...a,
user: iam ? { fullName, email: iam.email, phone: iam.phone_number } : null,
};
})
.filter(Boolean);
return { items, total: items.length };
}
async createAgentBooking(dto: CreateAgentBookingDto) {
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId } });
@@ -139,4 +183,31 @@ export class AgentsService {
if (!agent) throw new NotFoundException('No agent profile found for this user');
return agent;
}
async createAgent(dto: { iamUserId: string; agentCode?: string; commissionRate?: number }) {
const existing = await this.prisma.agent.findUnique({ where: { iamUserId: dto.iamUserId } });
if (existing) throw new BadRequestException('An agent profile already exists for this user');
const agentCode = dto.agentCode || `AG${String(Date.now()).slice(-4)}`;
return this.prisma.agent.create({
data: {
iamUserId: dto.iamUserId,
agentCode,
commissionRate: dto.commissionRate ?? 5,
active: true,
},
});
}
async updateAgent(id: string, dto: { agentCode?: string; commissionRate?: number; active?: boolean }) {
const agent = await this.prisma.agent.findUnique({ where: { id } });
if (!agent) throw new NotFoundException('Agent not found');
return this.prisma.agent.update({
where: { id },
data: {
...(dto.agentCode !== undefined && { agentCode: dto.agentCode }),
...(dto.commissionRate !== undefined && { commissionRate: dto.commissionRate }),
...(dto.active !== undefined && { active: dto.active }),
},
});
}
}

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsInt, IsPositive, IsString } from 'class-validator';
import { ExcessBaggageService } from './excess-baggage.service';
import {
LogExcessBaggageDto,
@@ -8,6 +9,13 @@ import {
} from './excess-baggage.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
class UpsertBaggageAllowanceDto {
@IsString() seatClassId: string;
@IsInt() @IsPositive() maxWeightKg: number;
@IsInt() @IsPositive() maxPiecesCount: number;
@IsInt() @IsPositive() excessFeePerKg: number;
}
// ── IAM-protected agent/supervisor routes ────────────────────────────────────
@ApiTags('Excess Baggage')
@Controller('agents/excess-baggage')
@@ -55,6 +63,30 @@ export class ExcessBaggageAgentController {
waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) {
return this.service.waiveCharge(id, dto);
}
@Get('allowances')
@ApiOperation({ summary: 'List all baggage allowance rules' })
getAllowances() {
return this.service.getAllowances();
}
@Post('allowances')
@ApiOperation({ summary: 'Create baggage allowance rule for a seat class' })
createAllowance(@Body() dto: UpsertBaggageAllowanceDto) {
return this.service.upsertAllowance(dto);
}
@Patch('allowances/:id')
@ApiOperation({ summary: 'Update baggage allowance rule' })
updateAllowance(@Param('id') id: string, @Body() dto: Partial<UpsertBaggageAllowanceDto>) {
return this.service.updateAllowance(id, dto);
}
@Delete('allowances/:id')
@ApiOperation({ summary: 'Delete baggage allowance rule' })
deleteAllowance(@Param('id') id: string) {
return this.service.deleteAllowance(id);
}
}
// ── Public pay-by-token routes (passenger self-service) ──────────────────────

View File

@@ -249,4 +249,30 @@ export class ExcessBaggageService {
return { items, total, page, pageSize };
}
async getAllowances() {
const [allowances, seatClasses] = await Promise.all([
this.prisma.baggageAllowance.findMany({ orderBy: { createdAt: 'asc' } }),
this.prisma.seatClass.findMany({ select: { id: true, name: true } }),
]);
const scMap = new Map(seatClasses.map(s => [s.id, s]));
return allowances.map(a => ({ ...a, seatClass: scMap.get(a.seatClassId) ?? null }));
}
async upsertAllowance(dto: { seatClassId: string; maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }) {
return this.prisma.baggageAllowance.upsert({
where: { seatClassId: dto.seatClassId } as any,
update: { maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg },
create: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg },
});
}
async updateAllowance(id: string, dto: Partial<{ maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }>) {
return this.prisma.baggageAllowance.update({ where: { id }, data: dto });
}
async deleteAllowance(id: string) {
await this.prisma.baggageAllowance.delete({ where: { id } });
return { deleted: true };
}
}

View File

@@ -153,6 +153,15 @@ export class FleetController {
return this.service.deleteTrain(id);
}
@Patch('trains/:id/restore')
@ApiOperation({ summary: 'Restore (reactivate) a deactivated train' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiResponse({ status: 200, description: 'Train restored' })
@ApiResponse({ status: 404, description: 'Train not found' })
restoreTrain(@Param('id') id: string) {
return this.service.restoreTrain(id);
}
// Coach Endpoints
@Get('coaches')
@ApiOperation({ summary: 'List coaches with seat status summary' })

View File

@@ -7,6 +7,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;
}
export class CreateCoachDto {
@@ -26,6 +27,8 @@ export class CreateCoachDto {
description: 'Beds per compartment/room. Must be even (split equally left/right). Defaults: VIP_BED=4, ECONOMY_BED=6. Only applies when bedCategory is set.',
})
@IsOptional() @IsInt() bedsPerRoom?: number;
@ApiPropertyOptional({ example: 1, description: 'Sequence number for ordering coaches in the train' })
@IsOptional() @IsInt() sequence?: number;
}
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {
@@ -66,6 +69,9 @@ export class CreateClassDto {
@ApiProperty({ example: 'Economy' }) @IsString() name: string;
@IsOptional() @IsString() description?: string;
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number;
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean;
}
export class UpdateClassDto {

View File

@@ -224,6 +224,9 @@ export class FleetService {
name: dto.name,
description: dto.description,
baseFareMinor: dto.baseFareMinor,
...(dto.premiumMinor !== undefined && { premiumMinor: dto.premiumMinor }),
...(dto.insuranceFeeMinor !== undefined && { insuranceFeeMinor: dto.insuranceFeeMinor }),
...(dto.isActive !== undefined && { isActive: dto.isActive }),
},
});
}
@@ -307,34 +310,54 @@ export class FleetService {
}
createTrain(dto: CreateTrainDto) {
return this.prisma.train.create({ data: dto });
return this.prisma.train.create({
data: {
number: dto.number,
name: dto.name,
operatorId: dto.operatorId,
operatorName: dto.operatorName,
description: dto.description,
isActive: dto.isActive ?? true,
},
});
}
async updateTrain(id: string, dto: CreateTrainDto) {
const train = await this.prisma.train.findUnique({ where: { id } });
if (!train) throw new NotFoundException('Train not found');
return this.prisma.train.update({ where: { id }, data: dto });
return this.prisma.train.update({
where: { id },
data: {
number: dto.number,
name: dto.name,
operatorId: dto.operatorId,
operatorName: dto.operatorName,
description: dto.description,
...(dto.isActive !== undefined && { isActive: dto.isActive }),
},
});
}
async deleteTrain(id: string) {
const train = await this.prisma.train.findUnique({
where: { id },
include: {
schedules: true,
},
include: { schedules: true },
});
if (!train) throw new NotFoundException('Train not found');
// Check for active schedules
if (train.schedules.length > 0) {
throw new BadRequestException(
`Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.`
);
}
return this.prisma.train.delete({ where: { id } });
}
async restoreTrain(id: string) {
const train = await this.prisma.train.findUnique({ where: { id } });
if (!train) throw new NotFoundException('Train not found');
return this.prisma.train.update({ where: { id }, data: { isActive: true } });
}
async getCoach(id: string) {
const coach = await this.prisma.coach.findUnique({
where: { id },
@@ -370,18 +393,20 @@ export class FleetService {
throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`);
}
// Get the next sequence number for this coach type
const lastCoach = await this.prisma.coach.findFirst({
where: { coachTypeId: dto.coachTypeId },
orderBy: { sequence: 'desc' },
});
const nextSequence = (lastCoach?.sequence ?? 0) + 1;
// Use user-provided sequence or auto-assign the next one
let resolvedSequence = dto.sequence;
if (resolvedSequence === undefined || resolvedSequence === null) {
const lastCoach = await this.prisma.coach.findFirst({
orderBy: { sequence: 'desc' },
});
resolvedSequence = (lastCoach?.sequence ?? 0) + 1;
}
const coach = await this.prisma.coach.create({
data: {
coachTypeId: dto.coachTypeId,
number: dto.number,
sequence: nextSequence,
sequence: resolvedSequence,
arrangement: dto.arrangement,
capacity: dto.capacity,
status: dto.status || 'ACTIVE',

View File

@@ -19,9 +19,8 @@ export class PackagesController {
}
@Get('all')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all packages (admin)' })
@IsPublic()
@ApiOperation({ summary: 'List all packages' })
listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20);
}
@@ -64,6 +63,14 @@ export class PackagesController {
return this.service.update(id, dto);
}
@Delete(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete package (admin)' })
remove(@Param('id') id: string) {
return this.service.remove(id);
}
@Patch(':id/activate')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')

View File

@@ -117,6 +117,28 @@ export class PackagesService {
return this.prisma.packagePriceTier.delete({ where: { id: tierId } });
}
async remove(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({
where: { id },
include: { bookings: { select: { id: true, status: true } } },
});
if (!pkg) throw new NotFoundException('Package not found');
const hasActive = pkg.bookings.some((b) => b.status === 'PENDING_PAYMENT' || b.status === 'CONFIRMED');
if (hasActive) throw new BadRequestException('Cannot delete a package with active bookings');
await this.prisma.$transaction(async (tx) => {
const bookingIds = pkg.bookings.map((b) => b.id);
if (bookingIds.length > 0) {
await tx.packageBookingPassenger.deleteMany({ where: { bookingId: { in: bookingIds } } });
await tx.packagePaymentIntent.deleteMany({ where: { packageBookingId: { in: bookingIds } } });
await tx.packageBooking.deleteMany({ where: { packageId: id } });
}
await tx.packagePriceTier.deleteMany({ where: { packageId: id } });
await tx.travelPackage.delete({ where: { id } });
});
return { deleted: true };
}
async activate(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');
@@ -228,7 +250,11 @@ export class PackagesService {
this.prisma.travelPackage.findMany({
skip,
take: pageSize,
include: { priceTiers: true },
include: {
priceTiers: true,
outboundSchedule: { include: { originStation: true, destinationStation: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.travelPackage.count(),

View File

@@ -432,10 +432,12 @@ export class PassengersService {
this.prisma.notification.deleteMany({ where: { passengerId: id } }),
this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }),
this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }),
this.prisma.journey.deleteMany({ where: { passengerId: id } }),
this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }),
this.prisma.ticket.deleteMany({ where: { booking: { passengerId: id } } }),
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }),
this.prisma.booking.deleteMany({ where: { passengerId: id } }),
this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId: id } } }),
this.prisma.journey.deleteMany({ where: { passengerId: id } }),
this.prisma.passenger.delete({ where: { id } }),
]);

View File

@@ -14,6 +14,7 @@ export class CreateRouteDto {
@ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string;
@ApiProperty({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsDateString() effectiveFrom: string;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ example: true, description: 'Whether the route is active (defaults to true)' }) @IsOptional() @IsBoolean() active?: boolean;
@ApiProperty({
type: [RouteStopInputDto],
description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.',

View File

@@ -26,6 +26,7 @@ export class RoutesService {
code: dto.code,
name: dto.name,
description: dto.description,
active: dto.active ?? true,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null,
stops: {

View File

@@ -306,8 +306,18 @@ export class SchedulesService {
}
async deleteSchedule(id: string) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id },
include: { _count: { select: { bookings: true } } },
});
if (!schedule) throw new NotFoundException('Schedule not found');
if ((schedule as any)._count.bookings > 0) {
throw new BadRequestException(
`Cannot delete schedule. It has ${(schedule as any)._count.bookings} booking(s). Cancel all bookings before deleting.`,
);
}
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
return this.prisma.trainSchedule.delete({ where: { id } });
}

View File

@@ -166,4 +166,12 @@ export class TicketsController {
delete(@Param('id') id: string) {
return this.service.delete(id);
}
@Patch(':id/restore')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
restore(@Param('id') id: string) {
return this.service.restore(id);
}
}

View File

@@ -567,4 +567,10 @@ export class TicketsService {
return { deleted: true, ticketId: id };
}
async restore(id: string) {
const ticket = await this.prisma.ticket.findUnique({ where: { id } });
if (!ticket) throw new NotFoundException('Ticket not found');
return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } });
}
}

View File

@@ -1,14 +1,15 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, Edit, DollarSign, Clock, Eye } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Eye } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal';
import { agentsApi } from '@/lib/api';
import { agentsApi, apiClient } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
@@ -24,8 +25,51 @@ const SectionHeader = ({ title }: { title: string }) => (
);
export default function AgentsPage() {
const { user } = useAuthStore();
const queryClient = useQueryClient();
const [filters, setFilters] = useState({ search: '', active: '' });
const [selected, setSelected] = useState<any>(null);
const [createModal, setCreateModal] = useState(false);
const [createForm, setCreateForm] = useState({ iamUserId: '', agentCode: '', commissionRate: '5' });
const [createError, setCreateError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/agents', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agents'] });
setCreateModal(false);
setCreateError(null);
},
onError: (e: any) => setCreateError(e?.response?.data?.message || e?.message || 'Failed to create agent'),
});
const [editModal, setEditModal] = useState(false);
const [editForm, setEditForm] = useState({ agentCode: '', commissionRate: '5', active: true });
const [editingAgent, setEditingAgent] = useState<any>(null);
const [editError, setEditError] = useState<string | null>(null);
const editMutation = useMutation({
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agents'] });
setEditModal(false);
setEditError(null);
},
onError: (e: any) => setEditError(e?.response?.data?.message || e?.message || 'Failed to update agent'),
});
const openCreateModal = () => {
setCreateForm({ iamUserId: '', agentCode: '', commissionRate: '5' });
setCreateError(null);
setCreateModal(true);
};
const openEditModal = (agent: any) => {
setEditingAgent(agent);
setEditForm({ agentCode: agent.agentCode, commissionRate: String(agent.commissionRate ?? 5), active: agent.active });
setEditError(null);
setEditModal(true);
};
const { data, isLoading } = useQuery({
queryKey: ['agents', filters],
@@ -67,39 +111,27 @@ export default function AgentsPage() {
const actions = [
{
label: 'View Details',
label: 'Edit',
onClick: (agent: any) => openEditModal(agent),
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Details',
onClick: (agent: any) => setSelected(agent),
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'View Shifts',
onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/shifts`; },
variant: 'secondary' as const,
icon: Clock,
},
{
label: 'View Commissions',
onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/commissions`; },
variant: 'secondary' as const,
icon: DollarSign,
},
{
label: 'Edit',
onClick: (agent: any) => console.log('Edit', agent),
variant: 'secondary' as const,
icon: Edit,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Agent Operations</h1>
<p className="text-muted-foreground">Manage booking agents and their operations</p>
<h1 className="text-2xl font-bold">Agents</h1>
<p className="text-muted-foreground">Manage agents and their operations</p>
</div>
<ActionButton icon={Plus}>Add Agent</ActionButton>
<ActionButton icon={Plus} onClick={openCreateModal}>Add Agent</ActionButton>
</div>
<div className="card">
@@ -227,6 +259,100 @@ export default function AgentsPage() {
);
})()}
</Modal>
{/* Create Agent Modal */}
<Modal isOpen={createModal} onClose={() => setCreateModal(false)} title="Add Agent Profile" size="md">
<div className="space-y-4">
{createError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">
{createError}
</div>
)}
<div>
<label className="label">IAM User ID *</label>
<input
className="input"
value={createForm.iamUserId}
onChange={(e) => setCreateForm({ ...createForm, iamUserId: e.target.value })}
placeholder="IAM user UUID"
/>
{user?.id && createForm.iamUserId === user.id && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1"> Pre-filled with your logged-in user ID</p>
)}
<p className="text-xs text-muted-foreground mt-1">Links this agent profile to an IAM back-office user</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Agent Code (optional)</label>
<input
className="input"
value={createForm.agentCode}
onChange={(e) => setCreateForm({ ...createForm, agentCode: e.target.value })}
placeholder="e.g. AG0002 (auto-generated if empty)"
/>
</div>
<div>
<label className="label">Commission Rate (%)</label>
<input
type="number" min="0" max="100" className="input"
value={createForm.commissionRate}
onChange={(e) => setCreateForm({ ...createForm, commissionRate: e.target.value })}
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setCreateModal(false)}>Cancel</ActionButton>
<ActionButton
loading={createMutation.isPending}
onClick={() => createMutation.mutate({
iamUserId: createForm.iamUserId,
agentCode: createForm.agentCode || undefined,
commissionRate: parseInt(createForm.commissionRate) || 5,
})}
>
Create Agent
</ActionButton>
</div>
</div>
</Modal>
{/* Edit Agent Modal */}
<Modal isOpen={editModal} onClose={() => setEditModal(false)} title="Edit Agent" size="md">
<div className="space-y-4">
{editError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">
{editError}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Agent Code</label>
<input className="input" value={editForm.agentCode}
onChange={(e) => setEditForm({ ...editForm, agentCode: e.target.value })} />
</div>
<div>
<label className="label">Commission Rate (%)</label>
<input type="number" min="0" max="100" className="input" value={editForm.commissionRate}
onChange={(e) => setEditForm({ ...editForm, commissionRate: e.target.value })} />
</div>
</div>
<div>
<label className="label">Status</label>
<select className="input" value={editForm.active ? 'true' : 'false'}
onChange={(e) => setEditForm({ ...editForm, active: e.target.value === 'true' })}>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setEditModal(false)}>Cancel</ActionButton>
<ActionButton loading={editMutation.isPending} onClick={() => editMutation.mutate({
id: editingAgent.id,
agentCode: editForm.agentCode,
commissionRate: parseInt(editForm.commissionRate) || 5,
active: editForm.active,
})}>Save Changes</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -28,16 +28,19 @@ const SectionHeader = ({ title }: { title: string }) => (
export default function BookingsPage() {
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
const [showExtraFilters, setShowExtraFilters] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState('');
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
bookingRef: true, bookingType: false, passengerNames: true, contactPhone: true,
bookingRef: true, bookingType: true, passengerNames: true, contactPhone: true,
contactEmail: true, passengerCount: false, paymentStatus: true, totalMinor: true, status: true, createdAt: true,
});
@@ -87,43 +90,56 @@ export default function BookingsPage() {
{ key: 'status', label: 'Status' }, { key: 'createdAt', label: 'Created At' },
];
const confirmExport = () => {
const confirmExport = async () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (!cols.length) { alert('Please select at least one column'); return; }
const exportItems = (data?.items || []).filter((b: any) => {
// Fetch all records (not just current page)
const allData = await bookingsApi.getAll({ ...filters, page: 1, pageSize: 9999 });
const exportItems = (allData?.items || []).filter((b: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
BOOKING_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((booking: any) => {
const values = BOOKING_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'bookingRef': return booking.bookingRef;
case 'journeyType': return booking.bookingType || 'N/A';
case 'passengerNames': return booking.passengerNames?.join(', ') || 'N/A';
case 'contactPhone': return booking.contactPhone || 'N/A';
case 'contactEmail': return booking.contactEmail || 'N/A';
case 'passengerCount': return (booking.adultCount ?? 0) + (booking.childCount ?? 0);
case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING';
case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency);
case 'status': return booking.status;
case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : '';
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
const rows = exportItems.map((booking: any) =>
BOOKING_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'bookingRef': return booking.bookingRef;
case 'journeyType': return booking.bookingType || 'N/A';
case 'passengerNames': return booking.passengerNames?.join(', ') || 'N/A';
case 'contactPhone': return booking.contactPhone || 'N/A';
case 'contactEmail': return booking.contactEmail || 'N/A';
case 'passengerCount': return String((booking.adultCount ?? 0) + (booking.childCount ?? 0));
case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING';
case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency);
case 'status': return booking.status;
case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : '';
default: return '';
}
})
);
const headers = BOOKING_COLS.filter(c => cols.includes(c.key)).map(c => c.label);
const dateStr = new Date().toISOString().split('T')[0];
if (exportFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write(`<!DOCTYPE html><html><head><title>Bookings Export</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>`);
w.document.write(`<h2>Bookings Export — ${dateStr}</h2><table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
rows.forEach(r => { w.document.write(`<tr>${r.map(v => `<td>${v}</td>`).join('')}</tr>`); });
w.document.write('</tbody></table></body></html>');
w.document.close();
w.print();
} else if (exportFormat === 'excel') {
const tsv = [headers.join('\t'), ...rows.map(r => r.join('\t'))].join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `bookings-${dateStr}.xls`; a.click();
} else {
const csv = [headers.map(h => `"${h}"`).join(','), ...rows.map(r => r.map(v => `"${v}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `bookings-${dateStr}.csv`; a.click();
}
setExportModalOpen(false);
};
@@ -205,19 +221,60 @@ export default function BookingsPage() {
Error loading bookings: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<div className="mb-4 flex flex-wrap gap-4">
<div className="flex-1">
<input type="text" placeholder="Search by reference, email, or phone..." className="input"
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
<div className="mb-4 space-y-3">
<div className="flex flex-wrap gap-3">
<div className="flex-1 min-w-48">
<input type="text" placeholder="Search by reference, email, or phone..." className="input"
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
</div>
<select className="input w-44" value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}>
<option value="">All Status</option>
<option value="PENDING_PAYMENT">Pending Payment</option>
<option value="CONFIRMED">Confirmed</option>
<option value="CANCELLED">Cancelled</option>
<option value="BOARDED">Boarded</option>
</select>
<button type="button" className="input w-auto px-4 text-sm font-medium text-primary border-primary/40"
onClick={() => setShowExtraFilters(v => !v)}>
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
</button>
</div>
<select className="input w-48" value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}>
<option value="">All Status</option>
<option value="PENDING_PAYMENT">Pending Payment</option>
<option value="CONFIRMED">Confirmed</option>
<option value="CANCELLED">Cancelled</option>
<option value="BOARDED">Boarded</option>
</select>
{showExtraFilters && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-1">
<div>
<label className="label">Booking Type</label>
<select className="input" value={extraFilters.bookingType}
onChange={(e) => setExtraFilters({ ...extraFilters, bookingType: e.target.value })}>
<option value="">All Types</option>
<option value="ONE_WAY">One Way</option>
<option value="ROUND_TRIP">Round Trip</option>
<option value="ROUND_TRIP_TRANSIT">Round Trip Transit</option>
</select>
</div>
<div>
<label className="label">Payment Status</label>
<select className="input" value={extraFilters.paymentStatus}
onChange={(e) => setExtraFilters({ ...extraFilters, paymentStatus: e.target.value })}>
<option value="">All Payments</option>
<option value="PENDING">Pending</option>
<option value="PAID">Paid</option>
<option value="FAILED">Failed</option>
<option value="REFUNDED">Refunded</option>
</select>
</div>
<div>
<label className="label">Created From</label>
<input type="date" className="input" value={extraFilters.dateFrom}
onChange={(e) => setExtraFilters({ ...extraFilters, dateFrom: e.target.value })} />
</div>
<div>
<label className="label">Created To</label>
<input type="date" className="input" value={extraFilters.dateTo}
onChange={(e) => setExtraFilters({ ...extraFilters, dateTo: e.target.value })} />
</div>
</div>
)}
</div>
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No bookings found" />
{data?.meta && (
@@ -397,9 +454,21 @@ export default function BookingsPage() {
))}
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Export Format</p>
<div className="flex gap-3">
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="exportFormat" value={fmt} checked={exportFormat === fmt}
onChange={() => setExportFormat(fmt)} className="w-4 h-4" />
<span className="text-sm font-medium capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
<ActionButton onClick={confirmExport}>Export</ActionButton>
</div>
</div>
</Modal>

View File

@@ -39,6 +39,8 @@ export default function PackagesPage() {
const [tierForm, setTierForm] = useState({ seatType: '', label: '', priceMinor: '', availableSeats: '' });
const [deleteTierConfirm, setDeleteTierConfirm] = useState<any>(null);
const [tierError, setTierError] = useState<string | null>(null);
const [deletePackageConfirm, setDeletePackageConfirm] = useState<any>(null);
const [deletePackageError, setDeletePackageError] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
@@ -100,6 +102,16 @@ export default function PackagesPage() {
onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to update tier'),
});
const deletePackageMutation = useMutation({
mutationFn: (id: string) => packagesApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['packages'] });
setDeletePackageConfirm(null);
setDeletePackageError(null);
},
onError: (e: any) => setDeletePackageError(e?.response?.data?.message || e?.message || 'Failed to delete package'),
});
const deleteTierMutation = useMutation({
mutationFn: (tierId: string) => packagesApi.deleteTier(tierId),
onSuccess: (_, tierId) => {
@@ -251,6 +263,10 @@ export default function PackagesPage() {
onClick: (p: any) => setActivateConfirm(p),
hidden: (p: any) => p.status === 'ACTIVE',
},
{
label: 'Delete', icon: Trash2, variant: 'danger' as const,
onClick: (p: any) => { setDeletePackageError(null); setDeletePackageConfirm(p); },
},
];
const isPending = createMutation.isPending || updateMutation.isPending;
@@ -401,6 +417,19 @@ export default function PackagesPage() {
)}
</Modal>
{/* Delete Package Confirmation */}
<ConfirmDialog
isOpen={!!deletePackageConfirm}
onClose={() => { setDeletePackageConfirm(null); setDeletePackageError(null); }}
onConfirm={() => deletePackageMutation.mutate(deletePackageConfirm.id)}
title="Delete Package"
message={`Delete "${deletePackageConfirm?.name}"? This will also remove all price tiers and cannot be undone.`}
confirmText="Delete"
isDanger
isLoading={deletePackageMutation.isPending}
error={deletePackageError ?? undefined}
/>
{/* Delete Tier Confirmation */}
<ConfirmDialog
isOpen={!!deleteTierConfirm}

View File

@@ -35,10 +35,13 @@ const TIER_COLORS: Record<string, string> = {
export default function PassengersPage() {
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
const [showExtraFilters, setShowExtraFilters] = useState(false);
const [extraFilters, setExtraFilters] = useState({ gender: '', nationality: '', dateFrom: '', dateTo: '' });
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
const [deleteError, setDeleteError] = useState<string | null>(null);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
@@ -70,40 +73,52 @@ export default function PassengersPage() {
{ key: 'nationality', label: 'Nationality' }, { key: 'verified', label: 'Verified' },
];
const confirmExportPassengers = () => {
const confirmExportPassengers = async () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (!cols.length) { alert('Please select at least one column'); return; }
const exportItems = (data?.items || []).filter((p: any) => {
// Fetch all records
const allData = await passengersApi.getAll({ ...filters, page: 1, pageSize: 9999 });
const exportItems = (allData?.items || []).filter((p: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
PASSENGER_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((p: any) => {
const values = PASSENGER_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'fullName': return p.fullName;
case 'email': return p.email || '';
case 'phone': return p.phone || '';
case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : '';
case 'gender': return p.gender || '';
case 'nationality': return p.nationality || '';
case 'verified': return p.faydaVerified ? 'Yes' : 'No';
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
const headers = PASSENGER_COLS.filter(c => cols.includes(c.key)).map(c => c.label);
const rows = exportItems.map((p: any) =>
PASSENGER_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'fullName': return p.fullName || '';
case 'email': return p.email || '';
case 'phone': return p.phone || '';
case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : '';
case 'gender': return p.gender || '';
case 'nationality': return p.nationality || '';
case 'verified': return p.faydaVerified ? 'Yes' : 'No';
default: return '';
}
})
);
const dateStr = new Date().toISOString().split('T')[0];
if (exportFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write(`<!DOCTYPE html><html><head><title>Passengers Export</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>`);
w.document.write(`<h2>Passengers Export — ${dateStr}</h2><table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
rows.forEach(r => { w.document.write(`<tr>${r.map(v => `<td>${v}</td>`).join('')}</tr>`); });
w.document.write('</tbody></table></body></html>');
w.document.close(); w.print();
} else if (exportFormat === 'excel') {
const tsv = [headers.join('\t'), ...rows.map(r => r.join('\t'))].join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `passengers-${dateStr}.xls`; a.click();
} else {
const csv = [headers.map(h => `"${h}"`).join(','), ...rows.map(r => r.map(v => `"${v}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `passengers-${dateStr}.csv`; a.click();
}
setExportModalOpen(false);
};
@@ -152,17 +167,52 @@ export default function PassengersPage() {
Error loading passengers: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<div className="mb-4 flex flex-wrap gap-4">
<div className="flex-1">
<input type="text" placeholder="Search by name, email, or phone..." className="input"
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
<div className="mb-4 space-y-3">
<div className="flex flex-wrap gap-3">
<div className="flex-1 min-w-48">
<input type="text" placeholder="Search by name, email, or phone..." className="input"
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
</div>
<select className="input w-44" value={filters.verified?.toString() || ''}
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}>
<option value="">All Passengers</option>
<option value="true">Verified</option>
<option value="false">Unverified</option>
</select>
<button type="button" className="input w-auto px-4 text-sm font-medium text-primary border-primary/40"
onClick={() => setShowExtraFilters(v => !v)}>
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
</button>
</div>
<select className="input w-48" value={filters.verified?.toString() || ''}
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}>
<option value="">All Passengers</option>
<option value="true">Verified</option>
<option value="false">Unverified</option>
</select>
{showExtraFilters && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-1">
<div>
<label className="label">Gender</label>
<select className="input" value={extraFilters.gender}
onChange={(e) => setExtraFilters({ ...extraFilters, gender: e.target.value })}>
<option value="">All Genders</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
</div>
<div>
<label className="label">Nationality</label>
<input type="text" className="input" placeholder="e.g. Ethiopian"
value={extraFilters.nationality}
onChange={(e) => setExtraFilters({ ...extraFilters, nationality: e.target.value })} />
</div>
<div>
<label className="label">Registered From</label>
<input type="date" className="input" value={extraFilters.dateFrom}
onChange={(e) => setExtraFilters({ ...extraFilters, dateFrom: e.target.value })} />
</div>
<div>
<label className="label">Registered To</label>
<input type="date" className="input" value={extraFilters.dateTo}
onChange={(e) => setExtraFilters({ ...extraFilters, dateTo: e.target.value })} />
</div>
</div>
)}
</div>
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No passengers found" />
{data?.meta && (
@@ -367,9 +417,21 @@ export default function PassengersPage() {
))}
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Export Format</p>
<div className="flex gap-3">
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="exportFormatP" value={fmt} checked={exportFormat === fmt}
onChange={() => setExportFormat(fmt)} className="w-4 h-4" />
<span className="text-sm font-medium capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExportPassengers}>Export CSV</ActionButton>
<ActionButton onClick={confirmExportPassengers}>Export</ActionButton>
</div>
</div>
</Modal>

View File

@@ -34,7 +34,7 @@ interface SeatClass {
}
export default function PricingPage() {
const [tab, setTab] = useState<'schedule' | 'segment'>('schedule');
const [tab, setTab] = useState<'schedule' | 'segment' | 'baggage'>('schedule');
const [showModal, setShowModal] = useState(false);
const [selectedSchedule, setSelectedSchedule] = useState<string>('');
const [selectedRoute, setSelectedRoute] = useState<string>('');
@@ -67,6 +67,17 @@ export default function PricingPage() {
validUntil: '',
});
const [baggageForm, setBaggageForm] = useState({
seatClassId: '',
maxWeightKg: '',
maxPiecesCount: '',
excessFeePerKg: '',
});
const [editingAllowance, setEditingAllowance] = useState<any>(null);
const [baggageError, setBaggageError] = useState<string | null>(null);
const [baggageModal, setBaggageModal] = useState(false);
const [deleteAllowanceConfirm, setDeleteAllowanceConfirm] = useState<{ isOpen: boolean; id: string | null }>({ isOpen: false, id: null });
const { data: schedules = [] } = useQuery({
queryKey: ['schedules'],
queryFn: () => apiClient.get('/schedules'),
@@ -109,6 +120,48 @@ export default function PricingPage() {
enabled: !!selectedRoute && tab === 'segment',
});
const { data: allowances = [], isLoading: allowancesLoading, refetch: refetchAllowances } = useQuery({
queryKey: ['baggage-allowances'],
queryFn: () => apiClient.get<any[]>('/agents/excess-baggage/allowances'),
enabled: tab === 'baggage',
});
const createAllowanceMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/agents/excess-baggage/allowances', data),
onSuccess: () => { refetchAllowances(); setBaggageModal(false); setBaggageError(null); },
onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to save'),
});
const updateAllowanceMutation = useMutation({
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/excess-baggage/allowances/${id}`, data),
onSuccess: () => { refetchAllowances(); setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); },
onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to update'),
});
const deleteAllowanceMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/agents/excess-baggage/allowances/${id}`),
onSuccess: () => { refetchAllowances(); setDeleteAllowanceConfirm({ isOpen: false, id: null }); },
onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to delete'),
});
const handleSaveAllowance = async () => {
setBaggageError(null);
if (!baggageForm.seatClassId || !baggageForm.maxWeightKg || !baggageForm.maxPiecesCount || !baggageForm.excessFeePerKg) {
setBaggageError('All fields are required'); return;
}
const payload = {
seatClassId: baggageForm.seatClassId,
maxWeightKg: parseInt(baggageForm.maxWeightKg),
maxPiecesCount: parseInt(baggageForm.maxPiecesCount),
excessFeePerKg: Math.round(parseFloat(baggageForm.excessFeePerKg) * 100),
};
if (editingAllowance) {
await updateAllowanceMutation.mutateAsync({ id: editingAllowance.id, ...payload });
} else {
await createAllowanceMutation.mutateAsync(payload);
}
};
const createFareMutation = useMutation({
mutationFn: (data: any) => apiClient.post(`/schedules/fares`, data),
onSuccess: () => {
@@ -346,6 +399,7 @@ export default function PricingPage() {
const stationsArray = Array.isArray(stations) ? stations : (stations as any)?.items || [];
const faresArray = Array.isArray(fares) ? fares : (fares as any)?.items || [];
const segmentFaresArray = Array.isArray(segmentFares) ? segmentFares : (segmentFares as any)?.items || [];
const allowancesArray = Array.isArray(allowances) ? allowances : (allowances as any)?.items || [];
const currentRoute = routesArray.find((r: Route) => r.id === selectedRoute);
const fareColumns = [
@@ -498,7 +552,12 @@ export default function PricingPage() {
onClick={() => {
setError(null);
setEditingFare(null);
if (tab === 'schedule') {
if (tab === 'baggage') {
setBaggageForm({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' });
setEditingAllowance(null);
setBaggageError(null);
setBaggageModal(true);
} else if (tab === 'schedule') {
setFareForm({
seatClassId: '',
baseFare: '',
@@ -523,7 +582,7 @@ export default function PricingPage() {
setShowModal(true);
}}
>
Add Fare Rule
{tab === 'baggage' ? 'Add Allowance Rule' : 'Add Fare Rule'}
</ActionButton>
</div>
@@ -541,16 +600,21 @@ export default function PricingPage() {
Schedule Fares
</button>
<button
onClick={() => {
setTab('segment');
setError(null);
}}
onClick={() => { setTab('segment'); setError(null); }}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
tab === 'segment' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
Segment Fares
</button>
<button
onClick={() => { setTab('baggage'); setError(null); }}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
Excess Baggage Rates
</button>
</div>
<div className="space-y-6">
@@ -658,6 +722,48 @@ export default function PricingPage() {
)}
</>
)}
{tab === 'baggage' && (
<>
{allowancesLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="h-6 w-6 animate-spin" /></div>
) : allowancesArray.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No baggage allowance rules defined. Click "Add Allowance Rule" to create one.
</div>
) : (
<DataTable
data={allowancesArray}
columns={[
{ key: 'seatClass', label: 'Seat Class', render: (a: any) => <span className="font-medium">{a.seatClass?.name ?? a.seatClassId}</span> },
{ key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => <span>{a.maxWeightKg} kg, {a.maxPiecesCount} pcs</span> },
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
]}
actions={[
{
label: 'Edit', icon: Edit, variant: 'secondary' as const,
onClick: (a: any) => {
setEditingAllowance(a);
setBaggageForm({
seatClassId: a.seatClassId,
maxWeightKg: String(a.maxWeightKg),
maxPiecesCount: String(a.maxPiecesCount),
excessFeePerKg: (a.excessFeePerKg / 100).toFixed(2),
});
setBaggageError(null);
setBaggageModal(true);
},
},
{
label: 'Delete', icon: Trash2, variant: 'danger' as const,
onClick: (a: any) => setDeleteAllowanceConfirm({ isOpen: true, id: a.id }),
},
]}
loading={false}
emptyMessage="No allowance rules found."
/>
)}
</>
)}
</div>
</div>
@@ -1009,6 +1115,54 @@ export default function PricingPage() {
</div>
</div>
</Modal>
{/* Baggage Allowance Modal */}
<Modal isOpen={baggageModal} onClose={() => { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }} title={editingAllowance ? 'Edit Allowance Rule' : 'Add Allowance Rule'} size="md">
<div className="space-y-4">
{baggageError && <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{baggageError}</div>}
<div>
<label className="label">Seat Class *</label>
<select value={baggageForm.seatClassId} onChange={(e) => setBaggageForm({ ...baggageForm, seatClassId: e.target.value })} className="input w-full" disabled={!!editingAllowance}>
<option value="">Select seat class...</option>
{seatClassesArray.map((sc: SeatClass) => <option key={sc.id} value={sc.id}>{sc.name}</option>)}
</select>
{editingAllowance && <p className="text-xs text-muted-foreground mt-1">Seat class cannot be changed. Delete and recreate to change.</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Free Allowance (kg) *</label>
<input type="number" min="0" className="input w-full" placeholder="e.g. 20" value={baggageForm.maxWeightKg} onChange={(e) => setBaggageForm({ ...baggageForm, maxWeightKg: e.target.value })} />
</div>
<div>
<label className="label">Max Pieces *</label>
<input type="number" min="1" className="input w-full" placeholder="e.g. 2" value={baggageForm.maxPiecesCount} onChange={(e) => setBaggageForm({ ...baggageForm, maxPiecesCount: e.target.value })} />
</div>
</div>
<div>
<label className="label">Excess Fee per kg (ETB) *</label>
<input type="number" min="0" step="0.01" className="input w-full" placeholder="e.g. 50.00" value={baggageForm.excessFeePerKg} onChange={(e) => setBaggageForm({ ...baggageForm, excessFeePerKg: e.target.value })} />
<p className="text-xs text-muted-foreground mt-1">Amount charged per kg above the free allowance</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}>Cancel</ActionButton>
<ActionButton onClick={handleSaveAllowance} loading={createAllowanceMutation.isPending || updateAllowanceMutation.isPending}>
{editingAllowance ? 'Update' : 'Save'}
</ActionButton>
</div>
</div>
</Modal>
{/* Delete Allowance Confirm */}
<ConfirmDialog
isOpen={deleteAllowanceConfirm.isOpen}
onClose={() => setDeleteAllowanceConfirm({ isOpen: false, id: null })}
onConfirm={() => deleteAllowanceMutation.mutateAsync(deleteAllowanceConfirm.id!)}
title="Delete Allowance Rule"
message="Are you sure you want to delete this baggage allowance rule?"
confirmText="Delete"
isDanger
isLoading={deleteAllowanceMutation.isPending}
warning="The excess baggage fallback rate (50 ETB/kg) will apply until a new rule is created."
/>
</div>
);
}

View File

@@ -107,6 +107,7 @@ export default function RoutesPage() {
code: formData.get('code') as string,
name: formData.get('name') as string,
description: formData.get('description') as string || undefined,
active: !editingRoute ? (formData.get('active') !== 'false') : undefined,
effectiveFrom: formData.get('effectiveFrom') as string,
effectiveUntil: formData.get('effectiveUntil') as string || undefined,
stops: stopsArray,
@@ -403,6 +404,16 @@ export default function RoutesPage() {
/>
</div>
{!editingRoute && (
<div>
<label className="label">Status</label>
<select name="active" className="input" defaultValue="true">
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Effective From *</label>

View File

@@ -25,6 +25,7 @@ export default function StationsPage() {
const [showModal, setShowModal] = useState(false);
const [editingStation, setEditingStation] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null }>({ isOpen: false, station: null });
const [formError, setFormError] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
@@ -38,7 +39,9 @@ export default function StationsPage() {
queryClient.invalidateQueries({ queryKey: ['stations'] });
setShowModal(false);
setEditingStation(null);
setFormError(null);
},
onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to create station'),
});
const updateMutation = useMutation({
@@ -47,7 +50,9 @@ export default function StationsPage() {
queryClient.invalidateQueries({ queryKey: ['stations'] });
setShowModal(false);
setEditingStation(null);
setFormError(null);
},
onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to update station'),
});
const deleteMutation = useMutation({
@@ -152,6 +157,7 @@ export default function StationsPage() {
label: 'Edit',
onClick: (station: any) => {
setEditingStation(station);
setFormError(null);
setShowModal(true);
},
variant: 'secondary' as const,
@@ -176,6 +182,7 @@ export default function StationsPage() {
icon={Plus}
onClick={() => {
setEditingStation(null);
setFormError(null);
setShowModal(true);
}}
>
@@ -247,11 +254,17 @@ export default function StationsPage() {
onClose={() => {
setShowModal(false);
setEditingStation(null);
setFormError(null);
}}
title={`${editingStation ? 'Edit' : 'Add'} Station`}
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>
)}
{editingStation && (
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3 text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-semibold"> Warning</p>

View File

@@ -14,7 +14,7 @@ import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils
import { useAuthStore } from '@/lib/auth-store';
export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '', dateFrom: '', dateTo: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -26,9 +26,10 @@ export default function TicketsPage() {
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null);
const { user } = useAuthStore();
const [showExtraFilters, setShowExtraFilters] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
// Excess baggage state
const { user } = useAuthStore();
const [excessModalOpen, setExcessModalOpen] = useState(false);
const [excessTicket, setExcessTicket] = useState<any>(null);
const [excessKg, setExcessKg] = useState('');
@@ -88,7 +89,8 @@ export default function TicketsPage() {
});
const boardMutation = useMutation({
mutationFn: ({ ticketId }: any) => ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString() }),
mutationFn: ({ ticketId, leg }: { ticketId: string; leg?: 'outbound' | 'inbound' }) =>
ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString(), leg: leg === 'inbound' ? 'RETURN' : 'OUTBOUND' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setBoardConfirmOpen(false);
@@ -147,6 +149,16 @@ export default function TicketsPage() {
},
});
const restoreMutation = useMutation({
mutationFn: (id: string) => apiClient.patch(`/tickets/${id}/restore`, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setSuccessMessage('Ticket restored successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => alert(error?.response?.data?.message || error?.message || 'Failed to restore ticket'),
});
const handleBoard = (ticket: any) => {
setTicketToBoard(ticket);
setBoardConfirmOpen(true);
@@ -154,8 +166,11 @@ export default function TicketsPage() {
const handleConfirmBoard = async () => {
if (!ticketToBoard) return;
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id });
printBoardingPass(ticketToBoard, 'outbound');
const isRoundTrip = ticketToBoard.booking?.bookingType === 'ROUND_TRIP' || ticketToBoard.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const outboundDone = !!ticketToBoard.validatedAt || !!ticketToBoard.booking?.outboundBoardedAt;
const leg: 'outbound' | 'inbound' = isRoundTrip && outboundDone ? 'inbound' : 'outbound';
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id, leg });
printBoardingPass(ticketToBoard, leg);
};
const printBoardingPass = (ticket: any, leg: 'outbound' | 'inbound' = 'outbound') => {
@@ -249,54 +264,54 @@ export default function TicketsPage() {
{ key: 'boarded', label: 'Boarded' },
];
const confirmExport = () => {
const cols = Object.entries(selectedColumns)
.filter(([, selected]) => selected)
.map(([col]) => col);
if (cols.length === 0) {
alert('Please select at least one column');
return;
}
const exportItems = (data?.items || []).filter((ticket: any) => {
const confirmExport = async () => {
const cols = Object.entries(selectedColumns).filter(([, v]) => v).map(([k]) => k);
if (!cols.length) { alert('Please select at least one column'); return; }
const allData = await ticketsApi.getAll({ search: filters.search || undefined, status: filters.status || undefined, skip: 0, take: 9999 });
const exportItems = (allData?.items || []).filter((ticket: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = ticket.schedule?.arrivalAt
? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0]
: null;
const d = ticket.schedule?.arrivalAt ? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
TICKET_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((ticket: any) => {
const values = TICKET_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'ticketNumber': return ticket.ticketNumber || 'N/A';
case 'booking': return ticket.booking?.bookingRef || 'N/A';
case 'passenger': return ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A';
case 'trip': return `${ticket.schedule?.originStation?.name || 'N/A'} - ${ticket.schedule?.destinationStation?.name || 'N/A'}`;
case 'coach': return ticket.seat?.coach?.number || 'N/A';
case 'seat': return ticket.seat?.seatNumber || 'N/A';
case 'seatClass': return ticket.seat?.coach?.coachType?.type || 'N/A';
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
case 'status': return ticket.status || 'N/A';
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tickets-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
const headers = TICKET_COLS.filter(c => cols.includes(c.key)).map(c => c.label);
const rows = exportItems.map((ticket: any) =>
TICKET_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'ticketNumber': return ticket.ticketNumber || 'N/A';
case 'booking': return ticket.booking?.bookingRef || 'N/A';
case 'passenger': return ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A';
case 'trip': return (ticket.schedule?.originStation?.name || 'N/A') + ' - ' + (ticket.schedule?.destinationStation?.name || 'N/A');
case 'coach': return ticket.seat?.coach?.number || 'N/A';
case 'seat': return ticket.seat?.seatNumber || 'N/A';
case 'seatClass': return ticket.seat?.coach?.coachType?.type || 'N/A';
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
case 'status': return ticket.status || 'N/A';
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
default: return '';
}
})
);
const dateStr = new Date().toISOString().split('T')[0];
if (exportFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write('<!DOCTYPE html><html><head><title>Tickets Export</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>');
w.document.write('<h2>Tickets Export - ' + dateStr + '</h2><table><thead><tr>' + headers.map(h => '<th>' + h + '</th>').join('') + '</tr></thead><tbody>');
rows.forEach(r => { w.document.write('<tr>' + r.map(v => '<td>' + v + '</td>').join('') + '</tr>'); });
w.document.write('</tbody></table></body></html>');
w.document.close(); w.print();
} else if (exportFormat === 'excel') {
const tsv = [headers.join(' '), ...rows.map(r => r.join(' '))].join('');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'tickets-' + dateStr + '.xls'; a.click();
} else {
const csv = [headers.map(h => '"' + h + '"').join(','), ...rows.map(r => r.map(v => '"' + v + '"').join(','))].join('');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'tickets-' + dateStr + '.csv'; a.click();
}
setExportModalOpen(false);
};
@@ -358,6 +373,13 @@ export default function TicketsPage() {
</div>
),
},
{
key: 'arrivalDate',
label: 'Arrival Date',
render: (ticket: any) => (
<span className="text-sm">{ticket.schedule?.arrivalAt ? new Date(ticket.schedule.arrivalAt).toLocaleDateString() : '—'}</span>
),
},
{
key: 'boardingTimes',
label: 'Boarding Times',
@@ -422,6 +444,13 @@ export default function TicketsPage() {
variant: 'secondary' as const,
icon: ListCollapse,
},
{
label: 'Restore',
onClick: (ticket: any) => restoreMutation.mutate(ticket.id),
variant: 'secondary' as const,
icon: ListCollapse,
show: (ticket: any) => ticket.status === 'CANCELLED',
},
{
label: 'Delete',
onClick: handleDeleteClick,
@@ -836,9 +865,20 @@ export default function TicketsPage() {
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Export Format</p>
<div className="flex gap-4">
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="ticketExportFmt" value={fmt} checked={exportFormat === fmt} onChange={() => setExportFormat(fmt)} className="w-4 h-4" />
<span className="text-sm font-medium">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
<ActionButton onClick={confirmExport}>Export</ActionButton>
</div>
</div>
</Modal>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Train, Search } from 'lucide-react';
import { Plus, Edit, Trash2, Train, Search, RotateCcw } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
@@ -59,6 +59,17 @@ export default function TrainsPage() {
},
});
const restoreTrainMutation = useMutation({
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'));
},
});
const handleDelete = (train: TrainType) => {
setDeleteConfirm({ isOpen: true, train });
};
@@ -156,6 +167,13 @@ export default function TrainsPage() {
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Restore',
onClick: (train: TrainType) => restoreTrainMutation.mutate(train.id),
variant: 'secondary' as const,
icon: RotateCcw,
show: (train: TrainType) => !train.isActive,
},
{
label: 'Delete',
onClick: handleDelete,

View File

@@ -109,6 +109,7 @@ export const fleetApi = {
createTrain: (data: any) => apiClient.post<any>('/fleet/trains', data),
updateTrain: (id: string, data: any) => apiClient.patch<any>(`/fleet/trains/${id}`, data),
deleteTrain: (id: string) => apiClient.delete(`/fleet/trains/${id}`),
restoreTrain: (id: string) => apiClient.patch<any>(`/fleet/trains/${id}/restore`, {}),
createCoach: (data: any) => apiClient.post<any>('/fleet/coaches', data),
updateCoach: (id: string, data: any) => apiClient.patch<any>(`/fleet/coaches/${id}`, data),
deleteCoach: (id: string) => apiClient.delete(`/fleet/coaches/${id}`),
@@ -389,6 +390,7 @@ export const packagesApi = {
create: (data: any) => apiClient.post<any>('/packages', data),
update: (id: string, data: any) => apiClient.patch<any>(`/packages/${id}`, data),
activate: (id: string) => apiClient.patch<any>(`/packages/${id}/activate`, {}),
remove: (id: string) => apiClient.delete(`/packages/${id}`),
addTier: (packageId: string, data: any) => apiClient.post<any>(`/packages/${packageId}/tiers`, data),
updateTier: (tierId: string, data: any) => apiClient.patch<any>(`/packages/tiers/${tierId}`, data),
deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`),