mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #296 from Tria-plc/alpha
Merge request for UAT results resolutions
This commit is contained in:
@@ -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)',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) ──────────────────────
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 } }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,4 +592,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' } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: string[]) => { w.document.write(`<tr>${r.map((v: string) => `<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: string[]) => 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: string[]) => r.map((v: string) => `"${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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: string[]) => { w.document.write(`<tr>${r.map((v: string) => `<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: string[]) => 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: string[]) => r.map((v: string) => `"${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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: string[]) => { w.document.write('<tr>' + r.map((v: string) => '<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: string[]) => r.join(' '))].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 = 'tickets-' + dateStr + '.xls'; a.click();
|
||||
} else {
|
||||
const csv = [headers.map((h: string) => '"' + h + '"').join(','), ...rows.map((r: string[]) => r.map((v: string) => '"' + 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-' + 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -144,8 +144,8 @@ export default function ResultsPage() {
|
||||
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
|
||||
: outboundSchedules.length > 0;
|
||||
|
||||
const handleSelectCoachType = (scheduleId: string, coachTypeCode: string, coachTypeName: string) => {
|
||||
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeCode, code: coachTypeCode, name: coachTypeName } }));
|
||||
const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string) => {
|
||||
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName } }));
|
||||
};
|
||||
|
||||
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
|
||||
@@ -158,7 +158,7 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
// Find the coach type to get pricing info
|
||||
const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.id);
|
||||
const coachType = schedule.coachTypes?.find(ct => ct.coachTypeId === selectedCoachType.id);
|
||||
const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0;
|
||||
|
||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||
@@ -535,14 +535,14 @@ export default function ResultsPage() {
|
||||
{coachTypes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
|
||||
{coachTypes.map((coachType: any, index: number) => {
|
||||
const isSelected = selectedCoachType?.id === coachType.coachTypeCode;
|
||||
const isSelected = selectedCoachType?.id === coachType.coachTypeId;
|
||||
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
|
||||
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={coachType.coachId}
|
||||
onClick={() => handleSelectCoachType(scheduleId, coachType.coachTypeCode, coachType.coachTypeName)}
|
||||
onClick={() => handleSelectCoachType(scheduleId, coachType.coachTypeId, coachType.coachTypeCode, coachType.coachTypeName)}
|
||||
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]'
|
||||
|
||||
Reference in New Issue
Block a user