fix conflict

This commit is contained in:
yaschalew
2026-06-26 12:47:20 +03:00
72 changed files with 2060 additions and 444 deletions

View File

@@ -145,7 +145,7 @@ jobs:
- name: Build ${{ matrix.service }} - name: Build ${{ matrix.service }}
run: | run: |
set -euo pipefail set -euo pipefail
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}" docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
- name: Deploy ${{ matrix.service }} - name: Deploy ${{ matrix.service }}
run: | run: |

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
/**
* Fix for fresh deployments: AddWarehouseInspection1750000000003 runs before
* the warehouse_inventory table exists, so it cannot add inspection_status.
*/
export class AddWarehouseInventoryInspectionStatusFix1791000000005 implements MigrationInterface {
private readonly table = 'freight.warehouse_inventory';
public async up(queryRunner: QueryRunner): Promise<void> {
if ((await queryRunner.hasTable(this.table)) && !(await queryRunner.hasColumn(this.table, 'inspection_status'))) {
await queryRunner.addColumn(
this.table,
new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
if ((await queryRunner.hasTable(this.table)) && (await queryRunner.hasColumn(this.table, 'inspection_status'))) {
await queryRunner.dropColumn(this.table, 'inspection_status');
}
}
}

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module'; import { BookingsModule } from '../bookings/bookings.module';
@@ -13,7 +13,7 @@ import { LastMileService } from './last-mile.service';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([LastMile]), TypeOrmModule.forFeature([LastMile]),
BookingsModule, forwardRef(() => BookingsModule),
VehiclesModule, VehiclesModule,
DriversModule, DriversModule,
NotificationsModule, NotificationsModule,

View File

@@ -15,6 +15,7 @@ import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'
import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesModule } from '../wagon-types/wagon-types.module'; import { WagonTypesModule } from '../wagon-types/wagon-types.module';
import { Wagon } from '../wagons/entities/wagon.entity'; import { Wagon } from '../wagons/entities/wagon.entity';
import { WarehousesModule } from '../warehouses/warehouses.module';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
@@ -44,6 +45,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
WagonTypesModule, WagonTypesModule,
TrainSetsModule, TrainSetsModule,
TrainSchedulesModule, TrainSchedulesModule,
forwardRef(() => WarehousesModule),
RuleEngineModule, RuleEngineModule,
], ],
controllers: [TrainSchedulingController], controllers: [TrainSchedulingController],

View File

@@ -147,6 +147,10 @@ describe('TrainSchedulingService', () => {
wagonAllocationBulkLoadsRepository as never, wagonAllocationBulkLoadsRepository as never,
trainCheckpointEventsRepository as never, trainCheckpointEventsRepository as never,
{} as never, // trainCompositionRemovalLogRepository {} as never, // trainCompositionRemovalLogRepository
{
autoUnloadArrivedBookings: jest.fn(),
autoUnloadExportAtDjibouti: jest.fn(),
} as never,
); );
const defaultFleetWagons = [ const defaultFleetWagons = [

View File

@@ -95,6 +95,8 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service';
import { import {
autoFillPlacements, autoFillPlacements,
findMissingContainerNumberIssues, findMissingContainerNumberIssues,
@@ -167,6 +169,7 @@ export class TrainSchedulingService {
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository, private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository, private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository, private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
private readonly warehouseInventoryService: WarehouseInventoryService,
private readonly configService?: ConfigService, private readonly configService?: ConfigService,
) {} ) {}
@@ -602,6 +605,74 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId); return this.getTrainScheduleById(scheduleId);
} }
private async runWarehouseArrivalAutomation(scheduleId: string) {
const [schedule]: Array<{
originCountry: string | null;
destinationCountry: string | null;
destinationCode: string | null;
destinationName: string | null;
}> = await this.dataSource.query(
`SELECT oy.country AS "originCountry",
dy.country AS "destinationCountry",
dy.code AS "destinationCode",
dy.name AS "destinationName"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.id = $1 AND ts.deleted_at IS NULL
LIMIT 1`,
[scheduleId],
);
if (!schedule) return { status: 'SKIPPED', reason: 'Train schedule not found' };
const direction = deriveTradeDirection(
{ country: schedule.originCountry },
{ country: schedule.destinationCountry },
);
try {
if (direction === 'IMPORT') {
return {
direction,
action: 'IMPORT_AUTO_UNLOAD',
status: 'COMPLETED',
result: await this.warehouseInventoryService.autoUnloadArrivedBookings(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
),
};
}
if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
return {
direction,
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
status: 'COMPLETED',
result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
),
};
}
return { direction, status: 'SKIPPED', reason: 'No warehouse arrival automation for this route' };
} catch (error) {
return {
direction,
status: 'FAILED',
reason: error instanceof Error ? error.message : String(error),
};
}
}
private isDjiboutiPortDestination(value: string | null | undefined): boolean {
const normalized = (value ?? '').toUpperCase();
return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) =>
normalized.includes(token),
);
}
async pinWagons(scheduleId: string, dto: PinWagonsDto) { async pinWagons(scheduleId: string, dto: PinWagonsDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) { if (!schedule) {
@@ -653,7 +724,9 @@ export class TrainSchedulingService {
} }
}); });
return this.getTrainScheduleById(scheduleId); const detail = await this.getTrainScheduleById(scheduleId);
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
return Object.assign(detail, { warehouseAutomation });
} }
async finalizeSchedule(scheduleId: string) { async finalizeSchedule(scheduleId: string) {

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
@@ -70,7 +70,7 @@ import { WarehousesService } from './warehouses.service';
]), ]),
FilesModule, FilesModule,
InterchangeDocumentsModule, InterchangeDocumentsModule,
LastMileModule, forwardRef(() => LastMileModule),
ExchangeModule.forRootAsync({ ExchangeModule.forRootAsync({
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions => useFactory: (config: ConfigService): ExchangeOptions =>

View File

@@ -12,6 +12,7 @@ import {
Paperclip, Paperclip,
Send, Send,
Settings, Settings,
ShieldCheck,
SlidersHorizontal, SlidersHorizontal,
Train, Train,
Truck, Truck,
@@ -27,6 +28,8 @@ import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
import NewBookingPage from "./pages/bookings/NewBookingPage"; import NewBookingPage from "./pages/bookings/NewBookingPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage"; import CustomersPage from "./pages/customers/CustomersPage";
@@ -111,6 +114,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{ {
title: "Operations", title: "Operations",
items: [ items: [
{
label: "Document Clearance",
href: "/dashboard/clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.bookings.reviewDocuments,
},
{ {
label: "Train Schedules", label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2", href: "/dashboard/operations/train-scheduling-v2",
@@ -388,6 +397,22 @@ const App = () => {
path="booking-requests/:id/contract" path="booking-requests/:id/contract"
element={<BookingContractPage />} element={<BookingContractPage />}
/> />
<Route
path="clearance"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
<DocumentClearanceListPage />
</RequirePermission>
}
/>
<Route
path="clearance/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
<DocumentClearanceDetailPage />
</RequirePermission>
}
/>
<Route path="warehouses" element={<WarehouseListPage />} /> <Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} /> <Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} /> <Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />

View File

@@ -1,6 +1,6 @@
import axios from "axios"; import axios from "axios";
import { API_BASE_URL } from "@/constants/apiConfig"; import { API_BASE_URL } from "@/pages/fleet/config/vehicles";
import { import {
AUTH_TOKEN_COOKIE, AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE,

View File

@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { API_BASE_URL } from '@/constants/apiConfig'; import { API_BASE_URL } from "@/pages/fleet/config/vehicles";
interface Cargo { interface Cargo {
id: string; id: string;

View File

@@ -1,3 +1,3 @@
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; // export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = 'http://localhost:3001'; //export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -9,6 +10,16 @@ import {
} from "@/services/bookings.service"; } from "@/services/bookings.service";
import { invalidateBookingDetail } from "@/utils/queryInvalidation"; import { invalidateBookingDetail } from "@/utils/queryInvalidation";
const parseApiError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
if (error instanceof Error && error.message) return error.message;
return fallback;
};
export function useBookingList(filter?: BookingListFilter, enabled = true) { export function useBookingList(filter?: BookingListFilter, enabled = true) {
return useQuery({ return useQuery({
queryKey: QUERY_KEYS.BOOKINGS.list(filter), queryKey: QUERY_KEYS.BOOKINGS.list(filter),
@@ -85,7 +96,7 @@ export function useBookingMutations(bookingId: string) {
requiredRole, requiredRole,
}), }),
onSuccess: (data) => onSuccess(data, "Approval step completed"), onSuccess: (data) => onSuccess(data, "Approval step completed"),
onError: () => toast.error("Failed to approve step"), onError: (error) => toast.error(parseApiError(error, "Failed to approve step")),
}); });
const rejectStep = useMutation({ const rejectStep = useMutation({
@@ -102,7 +113,7 @@ export function useBookingMutations(bookingId: string) {
reason, reason,
}), }),
onSuccess: (data) => onSuccess(data, "Booking rejected at approval step"), onSuccess: (data) => onSuccess(data, "Booking rejected at approval step"),
onError: () => toast.error("Failed to reject step"), onError: (error) => toast.error(parseApiError(error, "Failed to reject step")),
}); });
const generateContract = useMutation({ const generateContract = useMutation({

View File

@@ -42,19 +42,10 @@ import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api } from "@/auth/http";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { URL_CONSTANTS } from "@/constants/URLS";
import { api as appApi } from "@/services/api"; import { api as appApi } from "@/services/api";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
import { unwrap } from "@/utils/endpoint"; import { customersService } from "@/services/customers.service";
interface CompanyOption {
id: string;
name?: string | null;
tin?: string | null;
email?: string | null;
}
type FreightType = "CONTAINER" | "BULK"; type FreightType = "CONTAINER" | "BULK";
@@ -219,15 +210,12 @@ export default function NewBookingPage() {
queryFn: () => bookingsService.getReferenceData() as Promise<ReferenceData>, queryFn: () => bookingsService.getReferenceData() as Promise<ReferenceData>,
}); });
const { data: companies, isLoading: companiesLoading } = useQuery({ const { data: companiesPage, isLoading: companiesLoading } = useQuery({
queryKey: ["companies", "list"], queryKey: ["companies", "list"],
queryFn: async () => { queryFn: () => customersService.list({ page: 1, pageSize: 1000 }),
const res = await api.get(URL_CONSTANTS.COMPANIES.BASE);
return unwrap(res.data) as CompanyOption[];
},
}); });
const companyOptions = (companies ?? []).map((c) => ({ const companyOptions = (companiesPage?.items ?? []).map((c) => ({
value: c.id, value: c.id,
label: c.name || c.email || c.tin || c.id, label: c.name || c.email || c.tin || c.id,
})); }));

View File

@@ -92,3 +92,6 @@ export const vehiclesConfig: FleetResourceConfig = {
}; };
export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS }; export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS };
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -1,3 +1,2 @@
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = 'http://localhost:3001'; // export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -0,0 +1,2 @@
-- Migration already applied directly to the database.
-- This file exists only to satisfy Prisma's migration directory check (P3015).

View File

@@ -0,0 +1 @@
ALTER TABLE passenger."Booking" ADD COLUMN IF NOT EXISTS "paymentReminderSentAt" TIMESTAMP(3);

View File

@@ -534,6 +534,7 @@ model Booking {
source String @default("WEB") source String @default("WEB")
promoCode String? promoCode String?
paidAt DateTime? paidAt DateTime?
paymentReminderSentAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id]) passenger Passenger @relation(fields: [passengerId], references: [id])

View File

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

View File

@@ -4,7 +4,8 @@ import {
NestModule, NestModule,
OnApplicationBootstrap, OnApplicationBootstrap,
} from '@nestjs/common'; } from '@nestjs/common';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
import { DynamicThrottlerGuard } from './common/dynamic-throttler.guard';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@@ -63,6 +64,7 @@ import { SystemConfigModule } from './modules/system-config/system-config.module
import { PackagesModule } from './modules/packages/packages.module'; import { PackagesModule } from './modules/packages/packages.module';
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module'; import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
import { HealthModule } from './modules/health/health.module'; import { HealthModule } from './modules/health/health.module';
import { TasksModule } from './modules/tasks/tasks.module';
@Module({ @Module({
imports: [ imports: [
@@ -131,9 +133,11 @@ import { HealthModule } from './modules/health/health.module';
PackagesModule, PackagesModule,
ExcessBaggageModule, ExcessBaggageModule,
HealthModule, HealthModule,
TasksModule,
], ],
providers: [ providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard }, { provide: APP_GUARD, useClass: DynamicThrottlerGuard },
DynamicThrottlerGuard,
EdrPassengerOrgSeeder, EdrPassengerOrgSeeder,
PassengerStaffUsersSeeder, PassengerStaffUsersSeeder,
], ],

View File

@@ -0,0 +1,34 @@
import { Injectable, ExecutionContext, Inject } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerStorage, getOptionsToken, getStorageToken } from '@nestjs/throttler';
import { SystemConfigService, CONFIG_KEYS } from '../modules/system-config/system-config.service';
@Injectable()
export class DynamicThrottlerGuard extends ThrottlerGuard {
constructor(
@Inject(getOptionsToken()) options: any,
@Inject(getStorageToken()) storageService: ThrottlerStorage,
reflector: Reflector,
private readonly systemConfig: SystemConfigService,
) {
super(options, storageService, reflector);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] =
await Promise.all([
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT),
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_TTL_MS),
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_STRICT_LIMIT),
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_STRICT_TTL_MS),
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_DEFAULT_LIMIT),
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS),
]);
this.throttlers = [
{ name: 'default', ttl: defaultTtl, limit: defaultLimit },
];
return super.canActivate(context);
}
}

View File

@@ -47,6 +47,11 @@ export class HttpExceptionFilter implements ExceptionFilter {
this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`); this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`);
} }
// When the thrown body is already a structured object (e.g. { status, message, code }),
// merge it into the envelope so callers receive all custom fields.
const customFields =
typeof messageRaw === 'object' && messageRaw !== null ? messageRaw : {};
response.status(status).json({ response.status(status).json({
success: false, success: false,
statusCode: status, statusCode: status,
@@ -54,6 +59,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
error: exception instanceof Error ? exception.name : 'Error', error: exception instanceof Error ? exception.name : 'Error',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
path: request.url, path: request.url,
...customFields,
}); });
} }
} }

View File

@@ -1,4 +1,4 @@
// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM // Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM
// modules read process.env at module-load time (e.g. MinioModule.register reads MINIO_ENDPOINT), // 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. // which happens before ConfigModule.forRoot() would populate it. Must be the very first import.
import "dotenv/config"; import "dotenv/config";
@@ -18,7 +18,7 @@ async function bootstrap() {
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under // 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 // `/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.enableVersioning({ type: VersioningType.URI });
app.enableCors({ 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) - Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
- Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets - Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
- Complete audit trail per leg for compliance and reporting - 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 ### Booking Type Matrix
@@ -236,28 +236,28 @@ For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`ret
### Step 3: Passenger Information & Verification ### Step 3: Passenger Information & Verification
**For Ethiopian Passengers:** **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:** **For International Passengers:**
\`POST /passengers/register-international\` Passport information collection \`POST /passengers/register-international\` — Passport information collection
### Step 4: View Seat Map ### 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. For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId.
### Step 5: Hold Seats ### Step 5: Hold Seats
\`POST /seats/hold\` to reserve seats for 15 minutes. \`POST /seats/hold\` to reserve seats for 15 minutes.
- ONE_WAY / TRANSIT outbound leg: one hold call \`holdId\` - ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
- TRANSIT leg-2: second hold call \`leg2HoldId\` - TRANSIT leg-2: second hold call → \`leg2HoldId\`
- ROUND_TRIP return: second hold call \`returnHoldId\` - ROUND_TRIP return: second hold call → \`returnHoldId\`
- ROUND_TRIP_TRANSIT: four hold calls \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\` - ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
### Step 6: Create Booking ### Step 6: Create Booking
Choose the right endpoint and bookingType: Choose the right endpoint and bookingType:
- **ONE_WAY** \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\` - **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\` - **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\` - **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\` - **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 ### Step 7: Process Payment
\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian) \`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian)
@@ -311,6 +311,8 @@ Payment providers send notifications to:
"JWT-auth", "JWT-auth",
) )
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation") .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("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management") .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") .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(); .build();
const document = SwaggerModule.createDocument(app, config); 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, { SwaggerModule.setup("api-docs", app, document, {
customSiteTitle: "EDR Passenger API", customSiteTitle: "EDR Passenger API",
swaggerOptions: { swaggerOptions: {
@@ -360,7 +406,7 @@ Payment providers send notifications to:
const port = process.env.PORT ?? 4000; const port = process.env.PORT ?? 4000;
await app.listen(port); await app.listen(port);
console.log(`🚀 EDR Passenger API running on port ${port}`); console.log(`🚀 EDR Passenger API running on port ${port}`);
console.log(`📚 Swagger: http://localhost:${port}/api-docs`); console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
} }
bootstrap(); bootstrap();

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Post, Query, Request, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service'; 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'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
@ApiTags('Agents') @ApiTags('Agents')
@@ -16,6 +16,24 @@ export class AgentsController {
getMe(@Request() req: any) { getMe(@Request() req: any) {
return this.service.getMe(req.user?.id ?? req.user?.sub); 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') @Post('bookings')
@ApiOperation({ summary: 'Create agent booking with cash payment' }) @ApiOperation({ summary: 'Create agent booking with cash payment' })
createBooking(@Body() dto: CreateAgentBookingDto) { createBooking(@Body() dto: CreateAgentBookingDto) {

View File

@@ -2,6 +2,12 @@ import { IsString, IsInt, IsBoolean, IsOptional, IsArray, ValidateNested } from
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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 { export class AgentPassengerDto {
@ApiProperty() @IsString() fullName: string; @ApiProperty() @IsString() fullName: string;
@ApiProperty() @IsString() phone: string; @ApiProperty() @IsString() phone: string;

View File

@@ -1,4 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { IdDocumentType } from '@prisma/client'; import { IdDocumentType } from '@prisma/client';
@@ -10,7 +12,49 @@ function generateRef(): string {
@Injectable() @Injectable()
export class AgentsService { 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) { async createAgentBooking(dto: CreateAgentBookingDto) {
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId } }); 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'); if (!agent) throw new NotFoundException('No agent profile found for this user');
return agent; return agent;
} }
async createAgent(dto: { iamUserId: string; agentCode?: string; commissionRate?: number }) {
const existing = await this.prisma.agent.findUnique({ where: { iamUserId: dto.iamUserId } });
if (existing) throw new BadRequestException('An agent profile already exists for this user');
const agentCode = dto.agentCode || `AG${String(Date.now()).slice(-4)}`;
return this.prisma.agent.create({
data: {
iamUserId: dto.iamUserId,
agentCode,
commissionRate: dto.commissionRate ?? 5,
active: true,
},
});
}
async updateAgent(id: string, dto: { agentCode?: string; commissionRate?: number; active?: boolean }) {
const agent = await this.prisma.agent.findUnique({ where: { id } });
if (!agent) throw new NotFoundException('Agent not found');
return this.prisma.agent.update({
where: { id },
data: {
...(dto.agentCode !== undefined && { agentCode: dto.agentCode }),
...(dto.commissionRate !== undefined && { commissionRate: dto.commissionRate }),
...(dto.active !== undefined && { active: dto.active }),
},
});
}
} }

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException, SetMetadata } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, SetMetadata, BadRequestException, UnauthorizedException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import { BookingsService } from './bookings.service'; import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service'; import { GuestBookingService } from './guest-booking.service';
@@ -35,9 +36,9 @@ export class BookingsController {
@Query('page') page?: string, @Query('page') page?: string,
@Query('pageSize') pageSize?: string, @Query('pageSize') pageSize?: string,
) { ) {
const passengerId = req.user?.passengerId; const iamUserId = req.user?.id;
if (!passengerId) throw new Error('Passenger ID not found in token'); if (!iamUserId) throw new UnauthorizedException();
return this.service.findByPassengerId(passengerId, { return this.service.findByIamUserId(iamUserId, {
search, search,
status, status,
page: page ? parseInt(page) : 1, page: page ? parseInt(page) : 1,

View File

@@ -44,6 +44,11 @@ export class BookingsService {
private readonly fareEngine: FareEngineService, private readonly fareEngine: FareEngineService,
) {} ) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
const passenger = await this.prisma.passenger.findUniqueOrThrow({ where: { iamUserId }, select: { id: true } });
return this.findByPassengerId(passenger.id, filters);
}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters; const { search, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsInt, IsPositive, IsString } from 'class-validator';
import { ExcessBaggageService } from './excess-baggage.service'; import { ExcessBaggageService } from './excess-baggage.service';
import { import {
LogExcessBaggageDto, LogExcessBaggageDto,
@@ -8,6 +9,13 @@ import {
} from './excess-baggage.dto'; } from './excess-baggage.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; 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 ──────────────────────────────────── // ── IAM-protected agent/supervisor routes ────────────────────────────────────
@ApiTags('Excess Baggage') @ApiTags('Excess Baggage')
@Controller('agents/excess-baggage') @Controller('agents/excess-baggage')
@@ -55,6 +63,30 @@ export class ExcessBaggageAgentController {
waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) { waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) {
return this.service.waiveCharge(id, dto); 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) ────────────────────── // ── Public pay-by-token routes (passenger self-service) ──────────────────────

View File

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

View File

@@ -153,6 +153,15 @@ export class FleetController {
return this.service.deleteTrain(id); 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 // Coach Endpoints
@Get('coaches') @Get('coaches')
@ApiOperation({ summary: 'List coaches with seat status summary' }) @ApiOperation({ summary: 'List coaches with seat status summary' })

View File

@@ -7,6 +7,7 @@ export class CreateTrainDto {
@ApiPropertyOptional({ example: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string; @ApiPropertyOptional({ example: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string;
@ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string; @ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string;
@ApiPropertyOptional({ example: 'Addis-Djibouti Express' }) @IsOptional() @IsString() description?: 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 { 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.', 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; @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)) { export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {
@@ -66,6 +69,9 @@ export class CreateClassDto {
@ApiProperty({ example: 'Economy' }) @IsString() name: string; @ApiProperty({ example: 'Economy' }) @IsString() name: string;
@IsOptional() @IsString() description?: string; @IsOptional() @IsString() description?: string;
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number; @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 { export class UpdateClassDto {

View File

@@ -44,7 +44,7 @@ const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = {
// Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type // Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type
function detectBedCategory(coachTypeName: string): BedCategory { function detectBedCategory(coachTypeName: string): BedCategory {
const name = coachTypeName.toLowerCase(); const name = coachTypeName.toLowerCase();
const isBed = name.includes('bed') || name.includes('sleeper') || name.includes('couchette'); const isBed = name.includes('bed') || name.includes('berth') || name.includes('sleeper') || name.includes('couchette');
if (!isBed) return null; if (!isBed) return null;
if (name.includes('vip')) return 'VIP_BED'; if (name.includes('vip')) return 'VIP_BED';
return 'ECONOMY_BED'; return 'ECONOMY_BED';
@@ -224,6 +224,9 @@ export class FleetService {
name: dto.name, name: dto.name,
description: dto.description, description: dto.description,
baseFareMinor: dto.baseFareMinor, 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) { 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) { async updateTrain(id: string, dto: CreateTrainDto) {
const train = await this.prisma.train.findUnique({ where: { id } }); const train = await this.prisma.train.findUnique({ where: { id } });
if (!train) throw new NotFoundException('Train not found'); 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) { async deleteTrain(id: string) {
const train = await this.prisma.train.findUnique({ const train = await this.prisma.train.findUnique({
where: { id }, where: { id },
include: { include: { schedules: true },
schedules: true,
},
}); });
if (!train) throw new NotFoundException('Train not found'); if (!train) throw new NotFoundException('Train not found');
// Check for active schedules
if (train.schedules.length > 0) { if (train.schedules.length > 0) {
throw new BadRequestException( throw new BadRequestException(
`Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.` `Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.`
); );
} }
return this.prisma.train.delete({ where: { id } }); 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) { async getCoach(id: string) {
const coach = await this.prisma.coach.findUnique({ const coach = await this.prisma.coach.findUnique({
where: { id }, where: { id },
@@ -370,18 +393,20 @@ export class FleetService {
throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`); throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`);
} }
// Get the next sequence number for this coach type // Use user-provided sequence or auto-assign the next one
const lastCoach = await this.prisma.coach.findFirst({ let resolvedSequence = dto.sequence;
where: { coachTypeId: dto.coachTypeId }, if (resolvedSequence === undefined || resolvedSequence === null) {
orderBy: { sequence: 'desc' }, const lastCoach = await this.prisma.coach.findFirst({
}); orderBy: { sequence: 'desc' },
const nextSequence = (lastCoach?.sequence ?? 0) + 1; });
resolvedSequence = (lastCoach?.sequence ?? 0) + 1;
}
const coach = await this.prisma.coach.create({ const coach = await this.prisma.coach.create({
data: { data: {
coachTypeId: dto.coachTypeId, coachTypeId: dto.coachTypeId,
number: dto.number, number: dto.number,
sequence: nextSequence, sequence: resolvedSequence,
arrangement: dto.arrangement, arrangement: dto.arrangement,
capacity: dto.capacity, capacity: dto.capacity,
status: dto.status || 'ACTIVE', status: dto.status || 'ACTIVE',

View File

@@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PackagesService } from './packages.service'; import { PackagesService } from './packages.service';
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto } from './packages.dto'; import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto } from './packages.dto';
import { IamGuard } from '../../common/iam-adapter';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
@@ -19,9 +20,9 @@ export class PackagesController {
} }
@Get('all') @Get('all')
@UseGuards(JwtGuard) @UseGuards(IamGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all packages (admin)' }) @ApiOperation({ summary: 'List all packages (backoffice)' })
listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) { listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20); return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20);
} }
@@ -49,48 +50,64 @@ export class PackagesController {
} }
@Post() @Post()
@UseGuards(JwtGuard) @UseGuards(IamGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create package (admin)' }) @ApiOperation({ summary: 'Create package (admin)' })
create(@Body() dto: CreatePackageDto) { create(@Body() dto: CreatePackageDto) {
return this.service.create(dto); return this.service.create(dto);
} }
@Patch(':id') @Patch(':id')
@UseGuards(JwtGuard) @UseGuards(IamGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update package (admin)' }) @ApiOperation({ summary: 'Update package (admin)' })
update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) { update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) {
return this.service.update(id, dto); return this.service.update(id, dto);
} }
@Delete(':id')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete package (admin)' })
remove(@Param('id') id: string) {
return this.service.remove(id);
}
@Patch(':id/activate') @Patch(':id/activate')
@UseGuards(JwtGuard) @UseGuards(IamGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Activate package (admin)' }) @ApiOperation({ summary: 'Activate package (admin)' })
activate(@Param('id') id: string) { activate(@Param('id') id: string) {
return this.service.activate(id); return this.service.activate(id);
} }
@Patch(':id/deactivate')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Deactivate package (admin)' })
deactivate(@Param('id') id: string) {
return this.service.deactivate(id);
}
@Post(':id/tiers') @Post(':id/tiers')
@UseGuards(JwtGuard) @UseGuards(IamGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Add price tier to package (admin)' }) @ApiOperation({ summary: 'Add price tier to package (admin)' })
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) { addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
return this.service.addTier(id, dto); return this.service.addTier(id, dto);
} }
@Patch('tiers/:tierId') @Patch('tiers/:tierId')
@UseGuards(JwtGuard) @UseGuards(IamGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update price tier (admin)' }) @ApiOperation({ summary: 'Update price tier (admin)' })
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) { updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {
return this.service.updateTier(tierId, dto); return this.service.updateTier(tierId, dto);
} }
@Delete('tiers/:tierId') @Delete('tiers/:tierId')
@UseGuards(JwtGuard) @UseGuards(IamGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete price tier (admin)' }) @ApiOperation({ summary: 'Delete price tier (admin)' })
deleteTier(@Param('tierId') tierId: string) { deleteTier(@Param('tierId') tierId: string) {
return this.service.deleteTier(tierId); return this.service.deleteTier(tierId);

View File

@@ -20,7 +20,7 @@ export class PackagesService {
listActive() { listActive() {
const now = new Date(); const now = new Date();
return this.prisma.travelPackage.findMany({ return this.prisma.travelPackage.findMany({
where: { status: 'ACTIVE', validFrom: { lte: now }, validUntil: { gte: now } }, where: { status: 'ACTIVE', validUntil: { gte: now } },
include: { include: {
priceTiers: true, priceTiers: true,
outboundSchedule: { include: { originStation: true, destinationStation: true } }, outboundSchedule: { include: { originStation: true, destinationStation: true } },
@@ -117,12 +117,40 @@ export class PackagesService {
return this.prisma.packagePriceTier.delete({ where: { id: tierId } }); 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) { async activate(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } }); const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found'); if (!pkg) throw new NotFoundException('Package not found');
return this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } }); return this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } });
} }
async deactivate(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');
return this.prisma.travelPackage.update({ where: { id }, data: { status: 'DRAFT' } });
}
async book(dto: BookPackageDto, passengerId?: string) { async book(dto: BookPackageDto, passengerId?: string) {
const pkg = await this.prisma.travelPackage.findUnique({ const pkg = await this.prisma.travelPackage.findUnique({
where: { id: dto.packageId }, where: { id: dto.packageId },
@@ -130,7 +158,6 @@ export class PackagesService {
}); });
if (!pkg) throw new NotFoundException('Package not found'); if (!pkg) throw new NotFoundException('Package not found');
if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking'); if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking');
if (new Date() > pkg.validUntil) throw new BadRequestException('Package has expired');
const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId); const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId);
if (!tier) throw new NotFoundException('Price tier not found'); if (!tier) throw new NotFoundException('Price tier not found');
@@ -228,7 +255,11 @@ export class PackagesService {
this.prisma.travelPackage.findMany({ this.prisma.travelPackage.findMany({
skip, skip,
take: pageSize, take: pageSize,
include: { priceTiers: true }, include: {
priceTiers: true,
outboundSchedule: { include: { originStation: true, destinationStation: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
},
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}), }),
this.prisma.travelPackage.count(), this.prisma.travelPackage.count(),

View File

@@ -432,10 +432,12 @@ export class PassengersService {
this.prisma.notification.deleteMany({ where: { passengerId: id } }), this.prisma.notification.deleteMany({ where: { passengerId: id } }),
this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }), this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }),
this.prisma.savedRoute.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.packageBooking.deleteMany({ where: { passengerId: id } }),
this.prisma.ticket.deleteMany({ where: { booking: { passengerId: id } } }),
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }), this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }),
this.prisma.booking.deleteMany({ where: { 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 } }), this.prisma.passenger.delete({ where: { id } }),
]); ]);

View File

@@ -14,6 +14,7 @@ export class CreateRouteDto {
@ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string; @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; @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: '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({ @ApiProperty({
type: [RouteStopInputDto], type: [RouteStopInputDto],
description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.', description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.',

View File

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

View File

@@ -306,8 +306,18 @@ export class SchedulesService {
} }
async deleteSchedule(id: string) { 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) 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 } }); return this.prisma.trainSchedule.delete({ where: { id } });
} }

View File

@@ -51,27 +51,30 @@ export class SeatsService {
: 0; : 0;
const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null; const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null;
const mappedSeats = allSeats.map((s: any) => ({ const mappedSeats = allSeats.map((s: any) => {
id: s.id, const resolvedBedPosition = isBedCoach
seatNumber: s.seatNumber, ? this.resolveBedPosition(s.col, s.bedPosition)
label: s.seatNumber, : s.bedPosition;
status: effectiveStatuses.get(s.id) ?? s.status, return {
kind: s.kind, id: s.id,
row: s.row, seatNumber: s.seatNumber,
col: s.col, label: s.seatNumber,
isWindow: s.isWindow, status: effectiveStatuses.get(s.id) ?? s.status,
isAisle: s.isAisle, kind: s.kind,
// Bed-specific fields row: s.row,
...(isBedCoach ? { col: s.col,
room_id: `${a.coach.id}-R${s.row}`, isWindow: s.isWindow,
category: bedCategory, isAisle: s.isAisle,
position: this.colToPosition(s.col), bedPosition: resolvedBedPosition,
bed_type: this.bedPositionToType(s.bedPosition), // Bed-specific fields (only when coach is a bed coach)
bedPosition: s.bedPosition, ...(isBedCoach ? {
} : { room_id: `${a.coach.id}-R${s.row}`,
bedPosition: s.bedPosition, category: bedCategory,
}), position: this.colToPosition(s.col, a.coach.arrangement),
})); bed_type: this.bedPositionToType(resolvedBedPosition),
} : {}),
};
});
const base = { const base = {
id: a.coach.id, id: a.coach.id,
@@ -116,7 +119,7 @@ export class SeatsService {
private isBedCoach(coachTypeName: string): boolean { private isBedCoach(coachTypeName: string): boolean {
const n = coachTypeName.toLowerCase(); const n = coachTypeName.toLowerCase();
return n.includes('bed') || n.includes('sleeper') || n.includes('couchette'); return n.includes('bed') || n.includes('berth') || n.includes('sleeper') || n.includes('couchette');
} }
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' { private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
@@ -128,9 +131,23 @@ export class SeatsService {
return 'ECONOMY_BED'; return 'ECONOMY_BED';
} }
// col format: L1, L2, L3, R1, R2, R3 // col format: L1, L2, L3, R1, R2, R3 (new) or A, B, C, D (legacy)
private colToPosition(col: string): 'LEFT' | 'RIGHT' { // arrangement e.g. "2+2", "3+3", "2+0" → "leftCount+rightCount"
return col?.startsWith('R') ? 'RIGHT' : 'LEFT'; private colToPosition(col: string, arrangement?: string): 'LEFT' | 'RIGHT' | null {
if (!col) return null;
// New named-col format: L1, L2, R1, R2 …
if (/^L\d+$/.test(col)) return 'LEFT';
if (/^R\d+$/.test(col)) return 'RIGHT';
// Legacy single-letter cols (A, B, C, D …): derive from arrangement
const colIndex = col.toUpperCase().charCodeAt(0) - 65; // A=0, B=1, C=2 …
if (arrangement) {
const [leftStr, rightStr] = arrangement.split('+');
const rightCount = parseInt(rightStr ?? '0', 10);
if (rightCount === 0) return 'LEFT'; // single-side berth coach — all LEFT
const leftCount = parseInt(leftStr, 10) || 0;
return colIndex < leftCount ? 'LEFT' : 'RIGHT';
}
return 'LEFT'; // safe default when no arrangement info
} }
private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null { private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null {
@@ -141,6 +158,22 @@ export class SeatsService {
return map[bedPosition.toLowerCase()] ?? null; return map[bedPosition.toLowerCase()] ?? null;
} }
// Derives bedPosition from col when the seat was created with legacy A/B/C columns
// (new coaches use L1/L2/L3/R1/R2/R3 and store bedPosition explicitly).
// Col-to-tier mapping: A → lower, B → middle, C → upper, D → upper (4-tier).
private resolveBedPosition(col: string, storedBedPosition: string | null): string | null {
if (storedBedPosition) return storedBedPosition;
const legacyMap: Record<string, string> = { A: 'lower', B: 'middle', C: 'upper', D: 'upper' };
// Also handle numeric suffix in L/R cols: L1→lower, L2→middle, L3→upper
if (/^[LR]\d+$/.test(col)) {
const tier = parseInt(col.slice(1), 10);
if (tier === 1) return 'lower';
if (tier === 2) return 'middle';
return 'upper';
}
return legacyMap[col?.toUpperCase()] ?? null;
}
async resolveEffectiveStatuses( async resolveEffectiveStatuses(
scheduleId: string, scheduleId: string,
seatIds: string[], seatIds: string[],

View File

@@ -4,11 +4,23 @@ import { PrismaService } from '../../common/prisma.service';
export const CONFIG_KEYS = { export const CONFIG_KEYS = {
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes', SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure', HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure',
THROTTLE_AUTH_LIMIT: 'throttle_auth_limit',
THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms',
THROTTLE_STRICT_LIMIT: 'throttle_strict_limit',
THROTTLE_STRICT_TTL_MS: 'throttle_strict_ttl_ms',
THROTTLE_DEFAULT_LIMIT: 'throttle_default_limit',
THROTTLE_DEFAULT_TTL_MS: 'throttle_default_ttl_ms',
} as const; } as const;
const DEFAULTS: Record<string, string> = { const DEFAULTS: Record<string, string> = {
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5', [CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
[CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2', [CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2',
[CONFIG_KEYS.THROTTLE_AUTH_LIMIT]: '5',
[CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000',
[CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20',
[CONFIG_KEYS.THROTTLE_STRICT_TTL_MS]: '60000',
[CONFIG_KEYS.THROTTLE_DEFAULT_LIMIT]: '100',
[CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS]: '60000',
}; };
@Injectable() @Injectable()

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { TasksService } from './tasks.service';
@Module({
imports: [PrismaModule, NotificationsModule],
providers: [TasksService],
})
export class TasksModule {}

View File

@@ -0,0 +1,205 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service';
/** Minutes before departure at which each action fires. */
const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS
const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking
/** Half-width of the reminder detection window (cron runs every 2 min). */
const REMINDER_WINDOW_MINUTES = 2;
function fmtTime(d: Date): string {
return d.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Africa/Addis_Ababa',
});
}
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsClientService,
) {}
// ─────────────────────────────────────────────────────────────────────────
// Every 2 min: advance TrainSchedule statuses (departure / arrival).
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/2 * * * *')
async syncScheduleStatuses() {
const now = new Date();
const [departed, arrived] = await Promise.all([
this.prisma.trainSchedule.updateMany({
where: { status: 'SCHEDULED', departureAt: { lte: now } },
data: { status: 'EN_ROUTE' },
}),
this.prisma.trainSchedule.updateMany({
where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } },
data: { status: 'ARRIVED' },
}),
]);
if (departed.count > 0 || arrived.count > 0) {
this.logger.log(
`Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Every 2 min: payment deadline enforcement.
//
// • 3 h before departure → send one SMS reminder to complete payment.
// • 2 h before departure → cancel booking if payment is still pending
// and notify the passenger by SMS.
//
// Example: train departs 08:00
// 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled")
// 06:00 → booking auto-cancelled, cancellation SMS sent
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/2 * * * *')
async enforcePaymentDeadlines() {
const now = new Date();
await Promise.all([
this.sendPaymentReminders(now),
this.cancelExpiredPendingBookings(now),
]);
}
// ── 3-hour reminder ───────────────────────────────────────────────────────
private async sendPaymentReminders(now: Date) {
// Narrow 4-minute window (±2 min around the 3-hour mark) so each booking
// is caught by exactly one cron tick and paymentReminderSentAt guards re-sends.
const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000;
const reminderMs = REMINDER_MINUTES * 60 * 1000;
const windowStart = new Date(now.getTime() + reminderMs - windowMs);
const windowEnd = new Date(now.getTime() + reminderMs + windowMs);
const bookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
paymentReminderSentAt: null,
schedule: { departureAt: { gte: windowStart, lte: windowEnd } },
} as any,
include: {
schedule: {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
},
},
});
for (const booking of bookings) {
try {
const dep = booking.schedule.departureAt as Date;
const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000);
const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? '';
const message =
`EDR: Your booking ${booking.bookingRef} ` +
`(${origin}${dest}) departs at ${fmtTime(dep)}. ` +
`Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
}
await this.prisma.booking.update({
where: { id: booking.id },
data: { paymentReminderSentAt: now } as any,
});
this.logger.log(
`Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`,
);
} catch (err) {
this.logger.error(
`Reminder failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
// ── 2-hour auto-cancel ────────────────────────────────────────────────────
private async cancelExpiredPendingBookings(now: Date) {
const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h
const expiredBookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
schedule: { departureAt: { lte: cutoff } },
},
include: {
schedule: {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
},
paymentIntent: { select: { method: true } },
},
});
for (const booking of expiredBookings) {
try {
// 1. Release held seats (Journey rows are the occupancy source of truth)
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
// 2. Audit record (no refund — payment was never completed)
await this.prisma.bookingCancellation.create({
data: {
bookingId: booking.id,
cancelledBy: 'SYSTEM',
reason: 'Payment not completed before departure deadline',
refundAmount: 0,
refundMethod: booking.paymentIntent?.method ?? 'NONE',
refundStatus: 'NOT_APPLICABLE',
},
}).catch(() => null); // booking may already have a cancellation record
// 3. Mark cancelled
await this.prisma.booking.update({
where: { id: booking.id },
data: { status: 'CANCELLED' },
});
// 4. Notify passenger
const dep = booking.schedule.departureAt as Date;
const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? '';
const message =
`EDR: Your booking ${booking.bookingRef} ` +
`(${origin}${dest}, departs ${fmtTime(dep)}) has been cancelled ` +
`because payment was not completed before the deadline.`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
}
this.logger.log(
`Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`,
);
} catch (err) {
this.logger.error(
`Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (expiredBookings.length > 0) {
this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`);
}
}
}

View File

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

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
@@ -127,12 +127,37 @@ export class TicketsService {
}); });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
// No payment intent record at all
if (!booking.paymentIntent) {
throw new HttpException(
{ status: 'error', message: 'Payment not completed', code: 400 },
HttpStatus.BAD_REQUEST,
);
}
// Payment intent exists but not yet succeeded
if (booking.paymentIntent.status !== 'SUCCEEDED') {
throw new HttpException(
{
status: 'error',
message: 'Payment not completed',
code: 400,
detail: `Payment status: ${booking.paymentIntent.status}`,
},
HttpStatus.BAD_REQUEST,
);
}
// Booking not in CONFIRMED state (safety net — should align with SUCCEEDED)
if (booking.status !== 'CONFIRMED') { if (booking.status !== 'CONFIRMED') {
const paymentStatus = booking.paymentIntent?.status ?? null; throw new HttpException(
throw new BadRequestException( {
`Payment not completed. Please complete your payment before accessing the ticket. ` + status: 'error',
`Booking status: ${booking.status}` + message: 'Payment not completed',
(paymentStatus ? `. Payment status: ${paymentStatus}` : ''), code: 400,
detail: `Booking status: ${booking.status}`,
},
HttpStatus.BAD_REQUEST,
); );
} }
@@ -567,4 +592,10 @@ export class TicketsService {
return { deleted: true, ticketId: id }; return { deleted: true, ticketId: id };
} }
async restore(id: string) {
const ticket = await this.prisma.ticket.findUnique({ where: { id } });
if (!ticket) throw new NotFoundException('Ticket not found');
return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } });
}
} }

View File

@@ -59,6 +59,9 @@ export class CompleteVerificationResultDto {
@ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' }) @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' })
gender?: string; gender?: string;
@ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' })
userDataSaved?: boolean;
} }
export class VerifaydaCallbackDto { export class VerifaydaCallbackDto {

View File

@@ -72,6 +72,7 @@ export interface CompleteVerificationResult {
phoneNumber?: string; phoneNumber?: string;
birthdate?: string; birthdate?: string;
gender?: string; gender?: string;
userDataSaved?: boolean;
} }
@Injectable() @Injectable()
@@ -219,8 +220,8 @@ export class VerifaydaService {
const login = await this.issueLoginToken(userId); const login = await this.issueLoginToken(userId);
result = { purpose: 'LOGIN', verified: true, ...login }; result = { purpose: 'LOGIN', verified: true, ...login };
} else { } else {
// VERIFY — prove identity and hand the verified attributes back to the // VERIFY — prove identity, save to IAM, return verified attributes.
// caller. No domain writes; the session row tracks status as usual. const { userDataSaved } = await this.upsertIamUser(normalized);
result = { result = {
purpose: 'VERIFY', purpose: 'VERIFY',
verified: true, verified: true,
@@ -229,6 +230,7 @@ export class VerifaydaService {
phoneNumber: normalized.phoneNumber, phoneNumber: normalized.phoneNumber,
birthdate: normalized.birthdate, birthdate: normalized.birthdate,
gender: normalized.gender, gender: normalized.gender,
userDataSaved,
}; };
} }
@@ -265,13 +267,13 @@ export class VerifaydaService {
} }
async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> { async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> {
const rows = await this.dataSource.query<{ metadata: Record<string, any> | null; name: { en: string; am: string } | null }[]>( const rows = await this.dataSource.query<{ verified_by: string | null; updated_at: Date | null; name: { en: string; am: string } | null }[]>(
`SELECT metadata, name FROM iam.users WHERE id = $1 LIMIT 1`, `SELECT verified_by, updated_at, name FROM iam.users WHERE id = $1 LIMIT 1`,
[iamUserId], [iamUserId],
); );
const iam = rows[0] ?? null; const iam = rows[0] ?? null;
const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true'; const faydaVerified = iam?.verified_by === 'fayda';
const faydaVerifiedAt = iam?.metadata?.faydaVerifiedAt ? new Date(iam.metadata.faydaVerifiedAt) : undefined; const faydaVerifiedAt = faydaVerified && iam?.updated_at ? new Date(iam.updated_at) : undefined;
const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined; const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined;
return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName }; return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName };
} }
@@ -387,18 +389,39 @@ export class VerifaydaService {
} }
private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo { private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo {
const nameEn = raw['name#en'] as string | undefined;
const nameAm = raw['name#am'] as string | undefined;
const genderEn = raw['gender#en'] as string | undefined;
const genderAm = raw['gender#am'] as string | undefined;
const addressEn = raw['address#en'] as string | undefined;
const addressAm = raw['address#am'] as string | undefined;
const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined;
return { return {
sub: raw.sub, sub: raw.sub,
fullName: raw.name ?? raw['name#en'] ?? raw['name#am'], fullName: (raw.name as string | undefined) ?? nameEn ?? nameAm,
phoneNumber: phoneNumber: rawPhone ? this.standardizePhoneNumber(rawPhone) : undefined,
raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone, rawPhoneNumber: rawPhone,
email: raw.email, email: raw.email as string | undefined,
gender: raw.gender, gender: genderEn ?? genderAm ?? (raw.gender as string | undefined),
birthdate: raw.birthdate, birthdate: raw.birthdate as string | undefined,
picture: raw.picture, picture: raw.picture as string | undefined,
nameEn,
nameAm,
genderEn,
genderAm,
addressEn,
addressAm,
}; };
} }
private standardizePhoneNumber(phone: string): string {
const digits = phone.replace(/\D/g, '');
if (digits.startsWith('251')) return `+${digits}`;
if (digits.startsWith('0')) return `+251${digits.slice(1)}`;
return `+${digits}`;
}
// LOGIN via Fayda is now handled entirely by the IAM package's own OIDC flow. // LOGIN via Fayda is now handled entirely by the IAM package's own OIDC flow.
// This method is kept as a stub so completeVerification() still compiles; // This method is kept as a stub so completeVerification() still compiles;
// it throws immediately without touching the database. // it throws immediately without touching the database.
@@ -411,6 +434,88 @@ export class VerifaydaService {
}); });
} }
private async upsertIamUser(
normalized: NormalizedFaydaUserInfo,
): Promise<{ iamUserId: string | null; userDataSaved: boolean }> {
try {
const iamMetadata = {
sub: normalized.sub,
address: { am: normalized.addressAm ?? '', en: normalized.addressEn ?? '' },
email: normalized.email ?? '',
gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' },
name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' },
phoneNumber: normalized.rawPhoneNumber ?? '',
};
// Step 1 — already verified with same Fayda sub
const bySub = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`,
[normalized.sub],
);
if (bySub.length > 0) {
return { iamUserId: bySub[0].id, userDataSaved: true };
}
// Step 2 — existing user by phone or email, not yet Fayda-verified
const conditions: string[] = [];
const params: unknown[] = [];
if (normalized.phoneNumber) {
params.push(normalized.phoneNumber);
conditions.push(`phone_number = $${params.length}`);
}
if (normalized.email) {
params.push(normalized.email);
conditions.push(`email = $${params.length}`);
}
if (conditions.length > 0) {
const byContact = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE ${conditions.join(' OR ')} LIMIT 1`,
params,
);
if (byContact.length > 0) {
const existingId = byContact[0].id;
await this.dataSource.query(
`UPDATE iam.users
SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb,
verified_by = 'fayda',
updated_at = NOW()
WHERE id = $2`,
[JSON.stringify(iamMetadata), existingId],
);
return { iamUserId: existingId, userDataSaved: true };
}
}
// Step 3 — new user
const name = { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' };
const username = normalized.phoneNumber ?? normalized.email ?? normalized.sub;
const inserted = await this.dataSource.query<{ id: string }[]>(
`INSERT INTO iam.users (
id, name, username, email, phone_number, metadata,
user_type, status, is_active, has_set_password,
is_phone_number_verified, verified_by,
created_at, updated_at
) VALUES (
gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb,
'individual', 'accepted', true, false,
false, 'fayda',
NOW(), NOW()
) RETURNING id`,
[
JSON.stringify(name),
username,
normalized.email ?? null,
normalized.phoneNumber ?? null,
JSON.stringify(iamMetadata),
],
);
return { iamUserId: inserted[0].id, userDataSaved: true };
} catch (err) {
this.logger.error(`Fayda IAM upsert failed: ${(err as Error).message}`);
return { iamUserId: null, userDataSaved: false };
}
}
private async markSessionFailed( private async markSessionFailed(
state: string, state: string,
errorCode: string, errorCode: string,

View File

@@ -27,10 +27,19 @@ export interface FaydaUserInfo {
export interface NormalizedFaydaUserInfo { export interface NormalizedFaydaUserInfo {
sub: string; sub: string;
// Convenience / display fields
fullName?: string; fullName?: string;
phoneNumber?: string; phoneNumber?: string; // standardized e.g. +251911234567
email?: string; email?: string;
gender?: string; gender?: string;
birthdate?: string; birthdate?: string;
picture?: string; picture?: string;
// Raw localized fields — preserved for IAM-identical writes
nameEn?: string;
nameAm?: string;
genderEn?: string;
genderAm?: string;
addressEn?: string;
addressAm?: string;
rawPhoneNumber?: string; // unstandardized, stored in IAM metadata
} }

View File

@@ -7,9 +7,11 @@
"noEmit": false, "noEmit": false,
"incremental": true, "incremental": true,
"tsBuildInfoFile": "./.tsbuildinfo", "tsBuildInfoFile": "./.tsbuildinfo",
"paths": { "@/*": ["./src/*"] }, "paths": {
"module": "node16", "@/*": ["./src/*"],
"moduleResolution": "node16", "@tria-plc/iamapi-common": ["./node_modules/@tria-plc/iamapi-common/dist/index"],
"@tria-plc/iamapi-common/*": ["./node_modules/@tria-plc/iamapi-common/dist/*"]
},
"strictPropertyInitialization": false, "strictPropertyInitialization": false,
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false "noUnusedParameters": false

View File

@@ -1,14 +1,15 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, DollarSign, Clock, Eye } from 'lucide-react'; import { Plus, Edit, Eye } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { agentsApi } from '@/lib/api'; import { agentsApi, apiClient } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils'; 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 }) => ( 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"> <div className="bg-muted/40 rounded-lg p-3">
@@ -24,8 +25,51 @@ const SectionHeader = ({ title }: { title: string }) => (
); );
export default function AgentsPage() { export default function AgentsPage() {
const { user } = useAuthStore();
const queryClient = useQueryClient();
const [filters, setFilters] = useState({ search: '', active: '' }); const [filters, setFilters] = useState({ search: '', active: '' });
const [selected, setSelected] = useState<any>(null); 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({ const { data, isLoading } = useQuery({
queryKey: ['agents', filters], queryKey: ['agents', filters],
@@ -67,39 +111,27 @@ export default function AgentsPage() {
const actions = [ 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), onClick: (agent: any) => setSelected(agent),
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Eye, 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Agent Operations</h1> <h1 className="text-2xl font-bold">Agents</h1>
<p className="text-muted-foreground">Manage booking agents and their operations</p> <p className="text-muted-foreground">Manage agents and their operations</p>
</div> </div>
<ActionButton icon={Plus}>Add Agent</ActionButton> <ActionButton icon={Plus} onClick={openCreateModal}>Add Agent</ActionButton>
</div> </div>
<div className="card"> <div className="card">
@@ -227,6 +259,100 @@ export default function AgentsPage() {
); );
})()} })()}
</Modal> </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> </div>
); );
} }

View File

@@ -28,16 +28,19 @@ const SectionHeader = ({ title }: { title: string }) => (
export default function BookingsPage() { export default function BookingsPage() {
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' }); 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 [selectedBooking, setSelectedBooking] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null); const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
const [exportDateFrom, setExportDateFrom] = useState(''); const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState(''); const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({ 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, 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' }, { 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); const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (!cols.length) { alert('Please select at least one column'); return; } 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; if (!exportDateFrom && !exportDateTo) return true;
const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null; const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false; if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false; if (exportDateTo && (!d || d > exportDateTo)) return false;
return true; return true;
}); });
const csv = [ const rows = exportItems.map((booking: any) =>
BOOKING_COLS.map(c => `"${c.label}"`).join(','), BOOKING_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
...exportItems.map((booking: any) => { switch (key) {
const values = BOOKING_COLS.filter(c => cols.includes(c.key)).map(({ key }) => { case 'bookingRef': return booking.bookingRef;
switch (key) { case 'journeyType': return booking.bookingType || 'N/A';
case 'bookingRef': return booking.bookingRef; case 'passengerNames': return booking.passengerNames?.join(', ') || 'N/A';
case 'journeyType': return booking.bookingType || 'N/A'; case 'contactPhone': return booking.contactPhone || 'N/A';
case 'passengerNames': return booking.passengerNames?.join(', ') || 'N/A'; case 'contactEmail': return booking.contactEmail || 'N/A';
case 'contactPhone': return booking.contactPhone || 'N/A'; case 'passengerCount': return String((booking.adultCount ?? 0) + (booking.childCount ?? 0));
case 'contactEmail': return booking.contactEmail || 'N/A'; case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING';
case 'passengerCount': return (booking.adultCount ?? 0) + (booking.childCount ?? 0); case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency);
case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING'; case 'status': return booking.status;
case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency); case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : '';
case 'status': return booking.status; default: return '';
case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : ''; }
default: return ''; })
} );
}); const headers = BOOKING_COLS.filter(c => cols.includes(c.key)).map(c => c.label);
return values.map(v => `"${v}"`).join(','); const dateStr = new Date().toISOString().split('T')[0];
}), if (exportFormat === 'pdf') {
].join('\n'); const w = window.open('', '_blank')!;
const blob = new Blob([csv], { type: 'text/csv' }); 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>`);
const url = window.URL.createObjectURL(blob); w.document.write(`<h2>Bookings Export — ${dateStr}</h2><table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
const a = document.createElement('a'); rows.forEach((r: string[]) => { w.document.write(`<tr>${r.map((v: string) => `<td>${v}</td>`).join('')}</tr>`); });
a.href = url; w.document.write('</tbody></table></body></html>');
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`; w.document.close();
a.click(); 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); setExportModalOpen(false);
}; };
@@ -205,19 +221,60 @@ export default function BookingsPage() {
Error loading bookings: {error instanceof Error ? error.message : 'Unknown error'} Error loading bookings: {error instanceof Error ? error.message : 'Unknown error'}
</div> </div>
)} )}
<div className="mb-4 flex flex-wrap gap-4"> <div className="mb-4 space-y-3">
<div className="flex-1"> <div className="flex flex-wrap gap-3">
<input type="text" placeholder="Search by reference, email, or phone..." className="input" <div className="flex-1 min-w-48">
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 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>
<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> </div>
<select className="input w-48" value={filters.status} {showExtraFilters && (
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-1">
<option value="">All Status</option> <div>
<option value="PENDING_PAYMENT">Pending Payment</option> <label className="label">Booking Type</label>
<option value="CONFIRMED">Confirmed</option> <select className="input" value={extraFilters.bookingType}
<option value="CANCELLED">Cancelled</option> onChange={(e) => setExtraFilters({ ...extraFilters, bookingType: e.target.value })}>
<option value="BOARDED">Boarded</option> <option value="">All Types</option>
</select> <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> </div>
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No bookings found" /> <DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No bookings found" />
{data?.meta && ( {data?.meta && (
@@ -397,9 +454,21 @@ export default function BookingsPage() {
))} ))}
</div> </div>
</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"> <div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton> <ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton> <ActionButton onClick={confirmExport}>Export</ActionButton>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@@ -34,11 +34,14 @@ export default function PackagesPage() {
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [viewPackage, setViewPackage] = useState<any>(null); const [viewPackage, setViewPackage] = useState<any>(null);
const [activateConfirm, setActivateConfirm] = useState<any>(null); const [activateConfirm, setActivateConfirm] = useState<any>(null);
const [deactivateConfirm, setDeactivateConfirm] = useState<any>(null);
const [tiersPackage, setTiersPackage] = useState<any>(null); const [tiersPackage, setTiersPackage] = useState<any>(null);
const [editingTier, setEditingTier] = useState<any>(null); const [editingTier, setEditingTier] = useState<any>(null);
const [tierForm, setTierForm] = useState({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); const [tierForm, setTierForm] = useState({ seatType: '', label: '', priceMinor: '', availableSeats: '' });
const [deleteTierConfirm, setDeleteTierConfirm] = useState<any>(null); const [deleteTierConfirm, setDeleteTierConfirm] = useState<any>(null);
const [tierError, setTierError] = useState<string | null>(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 queryClient = useQueryClient();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
@@ -74,6 +77,11 @@ export default function PackagesPage() {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setActivateConfirm(null); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setActivateConfirm(null); },
}); });
const deactivateMutation = useMutation({
mutationFn: packagesApi.deactivate,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setDeactivateConfirm(null); },
});
const emptyTierForm = { seatType: '', label: '', priceMinor: '', availableSeats: '' }; const emptyTierForm = { seatType: '', label: '', priceMinor: '', availableSeats: '' };
const addTierMutation = useMutation({ const addTierMutation = useMutation({
@@ -100,6 +108,16 @@ export default function PackagesPage() {
onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to update tier'), 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({ const deleteTierMutation = useMutation({
mutationFn: (tierId: string) => packagesApi.deleteTier(tierId), mutationFn: (tierId: string) => packagesApi.deleteTier(tierId),
onSuccess: (_, tierId) => { onSuccess: (_, tierId) => {
@@ -249,7 +267,16 @@ export default function PackagesPage() {
{ {
label: 'Activate', icon: CheckCircle, variant: 'primary' as const, label: 'Activate', icon: CheckCircle, variant: 'primary' as const,
onClick: (p: any) => setActivateConfirm(p), onClick: (p: any) => setActivateConfirm(p),
hidden: (p: any) => p.status === 'ACTIVE', show: (p: any) => p.status !== 'ACTIVE',
},
{
label: 'Deactivate', icon: CheckCircle, variant: 'secondary' as const,
onClick: (p: any) => setDeactivateConfirm(p),
show: (p: any) => p.status === 'ACTIVE',
},
{
label: 'Delete', icon: Trash2, variant: 'danger' as const,
onClick: (p: any) => { setDeletePackageError(null); setDeletePackageConfirm(p); },
}, },
]; ];
@@ -327,6 +354,18 @@ export default function PackagesPage() {
isDanger={false} isDanger={false}
/> />
{/* Deactivate Confirmation */}
<ConfirmDialog
isOpen={!!deactivateConfirm}
onClose={() => setDeactivateConfirm(null)}
onConfirm={() => deactivateMutation.mutate(deactivateConfirm.id)}
title="Deactivate Package"
message={`Deactivate "${deactivateConfirm?.name}"? It will no longer be available for booking.`}
confirmText="Deactivate"
isDanger={false}
isLoading={deactivateMutation.isPending}
/>
{/* Tiers Modal */} {/* Tiers Modal */}
<Modal isOpen={!!tiersPackage} onClose={() => { setTiersPackage(null); setEditingTier(null); setTierError(null); }} title={`Price Tiers — ${tiersPackage?.name ?? ''}`} size="lg"> <Modal isOpen={!!tiersPackage} onClose={() => { setTiersPackage(null); setEditingTier(null); setTierError(null); }} title={`Price Tiers — ${tiersPackage?.name ?? ''}`} size="lg">
{tiersPackage && ( {tiersPackage && (
@@ -401,6 +440,19 @@ export default function PackagesPage() {
)} )}
</Modal> </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 */} {/* Delete Tier Confirmation */}
<ConfirmDialog <ConfirmDialog
isOpen={!!deleteTierConfirm} isOpen={!!deleteTierConfirm}

View File

@@ -35,10 +35,13 @@ const TIER_COLORS: Record<string, string> = {
export default function PassengersPage() { export default function PassengersPage() {
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' }); 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 [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
const [exportDateFrom, setExportDateFrom] = useState(''); const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState(''); const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({ const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
@@ -70,40 +73,52 @@ export default function PassengersPage() {
{ key: 'nationality', label: 'Nationality' }, { key: 'verified', label: 'Verified' }, { key: 'nationality', label: 'Nationality' }, { key: 'verified', label: 'Verified' },
]; ];
const confirmExportPassengers = () => { const confirmExportPassengers = async () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (!cols.length) { alert('Please select at least one column'); return; } 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; if (!exportDateFrom && !exportDateTo) return true;
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null; const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false; if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false; if (exportDateTo && (!d || d > exportDateTo)) return false;
return true; return true;
}); });
const csv = [ const headers = PASSENGER_COLS.filter(c => cols.includes(c.key)).map(c => c.label);
PASSENGER_COLS.map(c => `"${c.label}"`).join(','), const rows = exportItems.map((p: any) =>
...exportItems.map((p: any) => { PASSENGER_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
const values = PASSENGER_COLS.filter(c => cols.includes(c.key)).map(({ key }) => { switch (key) {
switch (key) { case 'fullName': return p.fullName || '';
case 'fullName': return p.fullName; case 'email': return p.email || '';
case 'email': return p.email || ''; case 'phone': return p.phone || '';
case 'phone': return p.phone || ''; case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : '';
case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : ''; case 'gender': return p.gender || '';
case 'gender': return p.gender || ''; case 'nationality': return p.nationality || '';
case 'nationality': return p.nationality || ''; case 'verified': return p.faydaVerified ? 'Yes' : 'No';
case 'verified': return p.faydaVerified ? 'Yes' : 'No'; default: return '';
default: return ''; }
} })
}); );
return values.map(v => `"${v}"`).join(','); const dateStr = new Date().toISOString().split('T')[0];
}), if (exportFormat === 'pdf') {
].join('\n'); const w = window.open('', '_blank')!;
const blob = new Blob([csv], { type: 'text/csv' }); 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>`);
const url = window.URL.createObjectURL(blob); w.document.write(`<h2>Passengers Export — ${dateStr}</h2><table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
const a = document.createElement('a'); rows.forEach((r: string[]) => { w.document.write(`<tr>${r.map((v: string) => `<td>${v}</td>`).join('')}</tr>`); });
a.href = url; w.document.write('</tbody></table></body></html>');
a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`; w.document.close(); w.print();
a.click(); } 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); setExportModalOpen(false);
}; };
@@ -152,17 +167,52 @@ export default function PassengersPage() {
Error loading passengers: {error instanceof Error ? error.message : 'Unknown error'} Error loading passengers: {error instanceof Error ? error.message : 'Unknown error'}
</div> </div>
)} )}
<div className="mb-4 flex flex-wrap gap-4"> <div className="mb-4 space-y-3">
<div className="flex-1"> <div className="flex flex-wrap gap-3">
<input type="text" placeholder="Search by name, email, or phone..." className="input" <div className="flex-1 min-w-48">
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 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>
<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> </div>
<select className="input w-48" value={filters.verified?.toString() || ''} {showExtraFilters && (
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-1">
<option value="">All Passengers</option> <div>
<option value="true">Verified</option> <label className="label">Gender</label>
<option value="false">Unverified</option> <select className="input" value={extraFilters.gender}
</select> 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> </div>
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No passengers found" /> <DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No passengers found" />
{data?.meta && ( {data?.meta && (
@@ -367,9 +417,21 @@ export default function PassengersPage() {
))} ))}
</div> </div>
</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"> <div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton> <ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExportPassengers}>Export CSV</ActionButton> <ActionButton onClick={confirmExportPassengers}>Export</ActionButton>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@@ -34,7 +34,7 @@ interface SeatClass {
} }
export default function PricingPage() { 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 [showModal, setShowModal] = useState(false);
const [selectedSchedule, setSelectedSchedule] = useState<string>(''); const [selectedSchedule, setSelectedSchedule] = useState<string>('');
const [selectedRoute, setSelectedRoute] = useState<string>(''); const [selectedRoute, setSelectedRoute] = useState<string>('');
@@ -67,6 +67,17 @@ export default function PricingPage() {
validUntil: '', 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({ const { data: schedules = [] } = useQuery({
queryKey: ['schedules'], queryKey: ['schedules'],
queryFn: () => apiClient.get('/schedules'), queryFn: () => apiClient.get('/schedules'),
@@ -109,6 +120,48 @@ export default function PricingPage() {
enabled: !!selectedRoute && tab === 'segment', 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({ const createFareMutation = useMutation({
mutationFn: (data: any) => apiClient.post(`/schedules/fares`, data), mutationFn: (data: any) => apiClient.post(`/schedules/fares`, data),
onSuccess: () => { onSuccess: () => {
@@ -346,6 +399,7 @@ export default function PricingPage() {
const stationsArray = Array.isArray(stations) ? stations : (stations as any)?.items || []; const stationsArray = Array.isArray(stations) ? stations : (stations as any)?.items || [];
const faresArray = Array.isArray(fares) ? fares : (fares as any)?.items || []; const faresArray = Array.isArray(fares) ? fares : (fares as any)?.items || [];
const segmentFaresArray = Array.isArray(segmentFares) ? segmentFares : (segmentFares 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 currentRoute = routesArray.find((r: Route) => r.id === selectedRoute);
const fareColumns = [ const fareColumns = [
@@ -498,7 +552,12 @@ export default function PricingPage() {
onClick={() => { onClick={() => {
setError(null); setError(null);
setEditingFare(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({ setFareForm({
seatClassId: '', seatClassId: '',
baseFare: '', baseFare: '',
@@ -523,7 +582,7 @@ export default function PricingPage() {
setShowModal(true); setShowModal(true);
}} }}
> >
Add Fare Rule {tab === 'baggage' ? 'Add Allowance Rule' : 'Add Fare Rule'}
</ActionButton> </ActionButton>
</div> </div>
@@ -541,16 +600,21 @@ export default function PricingPage() {
Schedule Fares Schedule Fares
</button> </button>
<button <button
onClick={() => { onClick={() => { setTab('segment'); setError(null); }}
setTab('segment');
setError(null);
}}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${ className={`px-4 py-2 font-medium border-b-2 transition-colors ${
tab === 'segment' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground' tab === 'segment' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`} }`}
> >
Segment Fares Segment Fares
</button> </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>
<div className="space-y-6"> <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>
</div> </div>
@@ -1009,6 +1115,54 @@ export default function PricingPage() {
</div> </div>
</div> </div>
</Modal> </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> </div>
); );
} }

View File

@@ -107,6 +107,7 @@ export default function RoutesPage() {
code: formData.get('code') as string, code: formData.get('code') as string,
name: formData.get('name') as string, name: formData.get('name') as string,
description: formData.get('description') as string || undefined, description: formData.get('description') as string || undefined,
active: !editingRoute ? (formData.get('active') !== 'false') : undefined,
effectiveFrom: formData.get('effectiveFrom') as string, effectiveFrom: formData.get('effectiveFrom') as string,
effectiveUntil: formData.get('effectiveUntil') as string || undefined, effectiveUntil: formData.get('effectiveUntil') as string || undefined,
stops: stopsArray, stops: stopsArray,
@@ -403,6 +404,16 @@ export default function RoutesPage() {
/> />
</div> </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 className="grid grid-cols-2 gap-4">
<div> <div>
<label className="label">Effective From *</label> <label className="label">Effective From *</label>

View File

@@ -10,6 +10,9 @@ export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<Tab>('general'); const [activeTab, setActiveTab] = useState<Tab>('general');
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5'); const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
const [holdCutoffHours, setHoldCutoffHours] = useState('2'); const [holdCutoffHours, setHoldCutoffHours] = useState('2');
const [throttleAuthLimit, setThrottleAuthLimit] = useState('5');
const [throttleStrictLimit, setThrottleStrictLimit] = useState('20');
const [throttleDefaultLimit, setThrottleDefaultLimit] = useState('100');
const [configLoading, setConfigLoading] = useState(false); const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false); const [configSaving, setConfigSaving] = useState(false);
const [configMessage, setConfigMessage] = useState(''); const [configMessage, setConfigMessage] = useState('');
@@ -21,6 +24,9 @@ export default function SettingsPage() {
.then((data) => { .then((data) => {
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes); if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes);
if (data?.hold_cutoff_hours_before_departure) setHoldCutoffHours(data.hold_cutoff_hours_before_departure); if (data?.hold_cutoff_hours_before_departure) setHoldCutoffHours(data.hold_cutoff_hours_before_departure);
if (data?.throttle_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit);
if (data?.throttle_strict_limit) setThrottleStrictLimit(data.throttle_strict_limit);
if (data?.throttle_default_limit) setThrottleDefaultLimit(data.throttle_default_limit);
}) })
.catch(() => {}) .catch(() => {})
.finally(() => setConfigLoading(false)); .finally(() => setConfigLoading(false));
@@ -33,6 +39,9 @@ export default function SettingsPage() {
await systemConfigApi.update({ await systemConfigApi.update({
seat_hold_duration_minutes: seatHoldMinutes, seat_hold_duration_minutes: seatHoldMinutes,
hold_cutoff_hours_before_departure: holdCutoffHours, hold_cutoff_hours_before_departure: holdCutoffHours,
throttle_auth_limit: throttleAuthLimit,
throttle_strict_limit: throttleStrictLimit,
throttle_default_limit: throttleDefaultLimit,
}); });
setConfigMessage('Saved successfully.'); setConfigMessage('Saved successfully.');
} catch { } catch {
@@ -177,6 +186,41 @@ export default function SettingsPage() {
{activeTab === 'configurations' && ( {activeTab === 'configurations' && (
<div className="card space-y-6"> <div className="card space-y-6">
<h3 className="text-lg font-semibold text-foreground">Rate Limiting (requests / minute / IP)</h3>
{!configLoading && (
<div className="max-w-sm space-y-4">
<div className="space-y-2">
<label className="label" htmlFor="throttle-auth">Auth endpoints limit</label>
<input
id="throttle-auth"
type="number" min="1" className="input"
value={throttleAuthLimit}
onChange={(e) => setThrottleAuthLimit(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Login, register, OTP. Default: 5.</p>
</div>
<div className="space-y-2">
<label className="label" htmlFor="throttle-strict">Strict endpoints limit</label>
<input
id="throttle-strict"
type="number" min="1" className="input"
value={throttleStrictLimit}
onChange={(e) => setThrottleStrictLimit(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Sensitive operations. Default: 20.</p>
</div>
<div className="space-y-2">
<label className="label" htmlFor="throttle-default">Default endpoints limit</label>
<input
id="throttle-default"
type="number" min="1" className="input"
value={throttleDefaultLimit}
onChange={(e) => setThrottleDefaultLimit(e.target.value)}
/>
<p className="text-xs text-muted-foreground">All other endpoints including search. Default: 100.</p>
</div>
</div>
)}
<h3 className="text-lg font-semibold text-foreground">Seat Booking</h3> <h3 className="text-lg font-semibold text-foreground">Seat Booking</h3>
{configLoading ? ( {configLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p> <p className="text-sm text-muted-foreground">Loading...</p>

View File

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

View File

@@ -14,7 +14,7 @@ import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
export default function TicketsPage() { 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 [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null); const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -26,9 +26,10 @@ export default function TicketsPage() {
const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null); 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 [excessModalOpen, setExcessModalOpen] = useState(false);
const [excessTicket, setExcessTicket] = useState<any>(null); const [excessTicket, setExcessTicket] = useState<any>(null);
const [excessKg, setExcessKg] = useState(''); const [excessKg, setExcessKg] = useState('');
@@ -88,7 +89,8 @@ export default function TicketsPage() {
}); });
const boardMutation = useMutation({ 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] }); queryClient.invalidateQueries({ queryKey: ['tickets'] });
setBoardConfirmOpen(false); 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) => { const handleBoard = (ticket: any) => {
setTicketToBoard(ticket); setTicketToBoard(ticket);
setBoardConfirmOpen(true); setBoardConfirmOpen(true);
@@ -154,8 +166,11 @@ export default function TicketsPage() {
const handleConfirmBoard = async () => { const handleConfirmBoard = async () => {
if (!ticketToBoard) return; if (!ticketToBoard) return;
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id }); const isRoundTrip = ticketToBoard.booking?.bookingType === 'ROUND_TRIP' || ticketToBoard.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
printBoardingPass(ticketToBoard, 'outbound'); 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') => { const printBoardingPass = (ticket: any, leg: 'outbound' | 'inbound' = 'outbound') => {
@@ -249,54 +264,54 @@ export default function TicketsPage() {
{ key: 'boarded', label: 'Boarded' }, { key: 'boarded', label: 'Boarded' },
]; ];
const confirmExport = () => { const confirmExport = async () => {
const cols = Object.entries(selectedColumns) const cols = Object.entries(selectedColumns).filter(([, v]) => v).map(([k]) => k);
.filter(([, selected]) => selected) if (!cols.length) { alert('Please select at least one column'); return; }
.map(([col]) => col); 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 (cols.length === 0) {
alert('Please select at least one column');
return;
}
const exportItems = (data?.items || []).filter((ticket: any) => {
if (!exportDateFrom && !exportDateTo) return true; if (!exportDateFrom && !exportDateTo) return true;
const d = ticket.schedule?.arrivalAt const d = ticket.schedule?.arrivalAt ? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0] : null;
? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0]
: null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false; if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false; if (exportDateTo && (!d || d > exportDateTo)) return false;
return true; return true;
}); });
const headers = TICKET_COLS.filter(c => cols.includes(c.key)).map(c => c.label);
const csv = [ const rows = exportItems.map((ticket: any) =>
TICKET_COLS.map(c => `"${c.label}"`).join(','), TICKET_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
...exportItems.map((ticket: any) => { switch (key) {
const values = TICKET_COLS.filter(c => cols.includes(c.key)).map(({ key }) => { case 'ticketNumber': return ticket.ticketNumber || 'N/A';
switch (key) { case 'booking': return ticket.booking?.bookingRef || 'N/A';
case 'ticketNumber': return ticket.ticketNumber || 'N/A'; case 'passenger': return ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A';
case 'booking': return ticket.booking?.bookingRef || 'N/A'; case 'trip': return (ticket.schedule?.originStation?.name || 'N/A') + ' - ' + (ticket.schedule?.destinationStation?.name || 'N/A');
case 'passenger': return ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A'; case 'coach': return ticket.seat?.coach?.number || 'N/A';
case 'trip': return `${ticket.schedule?.originStation?.name || 'N/A'} - ${ticket.schedule?.destinationStation?.name || 'N/A'}`; case 'seat': return ticket.seat?.seatNumber || 'N/A';
case 'coach': return ticket.seat?.coach?.number || 'N/A'; case 'seatClass': return ticket.seat?.coach?.coachType?.type || 'N/A';
case 'seat': return ticket.seat?.seatNumber || 'N/A'; case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
case 'seatClass': return ticket.seat?.coach?.coachType?.type || 'N/A'; case 'status': return ticket.status || 'N/A';
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB'); case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
case 'status': return ticket.status || 'N/A'; default: return '';
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No'; }
default: return ''; })
} );
}); const dateStr = new Date().toISOString().split('T')[0];
return values.map(v => `"${v}"`).join(','); if (exportFormat === 'pdf') {
}), const w = window.open('', '_blank')!;
].join('\n'); 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>');
const blob = new Blob([csv], { type: 'text/csv' }); rows.forEach((r: string[]) => { w.document.write('<tr>' + r.map((v: string) => '<td>' + v + '</td>').join('') + '</tr>'); });
const url = window.URL.createObjectURL(blob); w.document.write('</tbody></table></body></html>');
const a = document.createElement('a'); w.document.close(); w.print();
a.href = url; } else if (exportFormat === 'excel') {
a.download = `tickets-${new Date().toISOString().split('T')[0]}.csv`; const tsv = [headers.join(' '), ...rows.map((r: string[]) => r.join(' '))].join('\n');
a.click(); 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); setExportModalOpen(false);
}; };
@@ -358,6 +373,13 @@ export default function TicketsPage() {
</div> </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', key: 'boardingTimes',
label: 'Boarding Times', label: 'Boarding Times',
@@ -422,6 +444,13 @@ export default function TicketsPage() {
variant: 'secondary' as const, variant: 'secondary' as const,
icon: ListCollapse, 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', label: 'Delete',
onClick: handleDeleteClick, onClick: handleDeleteClick,
@@ -836,9 +865,20 @@ export default function TicketsPage() {
</div> </div>
</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"> <div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton> <ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton> <ActionButton onClick={confirmExport}>Export</ActionButton>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

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

View File

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

View File

@@ -16,6 +16,8 @@ type BookingWithTicket = {
pnr?: string | null; pnr?: string | null;
status?: string; status?: string;
totalMinor?: number; totalMinor?: number;
createdAt?: string;
paymentMethod?: string;
ticket?: { ticket?: {
barcodePayload?: string; barcodePayload?: string;
qrPayload?: string; qrPayload?: string;
@@ -24,7 +26,8 @@ type BookingWithTicket = {
export default function ConfirmationPage() { export default function ConfirmationPage() {
const router = useRouter(); const router = useRouter();
const { bookingId, pnr, selectedSchedule, passengers, clearBooking } = useBookingStore(); const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking } = useBookingStore();
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
const confirmAttempted = useRef(false); const confirmAttempted = useRef(false);
@@ -193,12 +196,7 @@ export default function ConfirmationPage() {
<div className="flex flex-col md:flex-row gap-6"> <div className="flex flex-col md:flex-row gap-6">
{/* QR Code Section */} {/* QR Code Section */}
<div className="flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800 rounded-lg p-6 md:w-48 flex-shrink-0"> <div className="flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800 rounded-lg p-6 md:w-48 flex-shrink-0">
<QRCodeSVG <QRCodeSVG value={pnr} size={160} level="H" includeMargin={true} />
value={pnr}
size={160}
level="H"
includeMargin={true}
/>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-2 text-center font-semibold">Scan at gate</p> <p className="text-xs text-gray-600 dark:text-gray-400 mt-2 text-center font-semibold">Scan at gate</p>
</div> </div>
@@ -208,44 +206,129 @@ export default function ConfirmationPage() {
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center"> <div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-primary dark:text-primary-400" /> <Train className="w-6 h-6 text-primary dark:text-primary-400" />
</div> </div>
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Trip details</h2> <h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{isRoundTrip ? 'Round trip details' : 'Trip details'}
</h2>
</div> </div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-3"> {/* Outbound journey (round trip) or single journey */}
<div> {(() => {
<p className="text-sm text-gray-600 dark:text-gray-400">Train number</p> const schedule = isRoundTrip ? outboundSchedule : selectedSchedule;
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{selectedSchedule?.trainNumber}</p> if (!schedule) return null;
</div> return (
<div> <div className="mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400">Route</p> {isRoundTrip && (
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{selectedSchedule?.origin} {selectedSchedule?.destination}</p> <p className="text-xs font-bold uppercase tracking-wide text-primary mb-2">Outbound</p>
</div> )}
{selectedSchedule?.selectedSeatClassName && ( <div className="grid md:grid-cols-2 gap-4">
<div> <div className="space-y-3">
<p className="text-sm text-gray-600 dark:text-gray-400">Class</p> <div>
<p className="font-semibold text-gray-900 dark:text-gray-100">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</p> <p className="text-sm text-gray-600 dark:text-gray-400">Train number</p>
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{schedule.trainNumber}</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Route</p>
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{schedule.origin} {schedule.destination}</p>
</div>
{schedule.selectedSeatClassName && (
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Class</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{schedule.selectedSeatClassName.replace(/_/g, ' ')}</p>
</div>
)}
</div>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Departure</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{schedule.departureTime && format(new Date(schedule.departureTime), 'PPp')}
</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Arrival</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{schedule.arrivalTime && format(new Date(schedule.arrivalTime), 'PPp')}
</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Duration</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{schedule.duration}</p>
</div>
</div>
</div> </div>
)}
</div>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Departure</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{selectedSchedule?.departureTime && format(new Date(selectedSchedule.departureTime), 'PPp')}
</p>
</div> </div>
<div> );
<p className="text-sm text-gray-600 dark:text-gray-400">Arrival</p> })()}
<p className="font-semibold text-gray-900 dark:text-gray-100">
{selectedSchedule?.arrivalTime && format(new Date(selectedSchedule.arrivalTime), 'PPp')} {/* Return journey (round trip only) */}
</p> {isRoundTrip && inboundSchedule && (
</div> <div className="border-t border-dashed border-gray-200 dark:border-gray-700 pt-4">
<div> <p className="text-xs font-bold uppercase tracking-wide text-blue-500 mb-2">Return</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Duration</p> <div className="grid md:grid-cols-2 gap-4">
<p className="font-semibold text-gray-900 dark:text-gray-100">{selectedSchedule?.duration}</p> <div className="space-y-3">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Train number</p>
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{inboundSchedule.trainNumber}</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Route</p>
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{inboundSchedule.origin} {inboundSchedule.destination}</p>
</div>
{inboundSchedule.selectedSeatClassName && (
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Class</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{inboundSchedule.selectedSeatClassName.replace(/_/g, ' ')}</p>
</div>
)}
</div>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Departure</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{inboundSchedule.departureTime && format(new Date(inboundSchedule.departureTime), 'PPp')}
</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Arrival</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{inboundSchedule.arrivalTime && format(new Date(inboundSchedule.arrivalTime), 'PPp')}
</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Duration</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{inboundSchedule.duration}</p>
</div>
</div>
</div> </div>
</div> </div>
</div> )}
</div>
</div>
</div>
{/* Booking date & payment summary */}
<div className="card mb-6">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Booking details</h2>
<div className="grid sm:grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Booking date</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{format(new Date(_booking?.createdAt || new Date()), 'PPp')}
</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Status</p>
<p className="font-semibold text-green-600 dark:text-green-400">{_booking?.status || 'CONFIRMED'}</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Passengers</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{passengers.length}</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Total paid</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
ETB {((_booking?.totalMinor || passengers.reduce((s) => s + (selectedSchedule?.baseFareAdult || 0), 0)) / 100).toFixed(2)}
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -378,7 +378,7 @@ type FormData = z.infer<typeof formSchema>;
export default function PassengersPage() { export default function PassengersPage() {
const router = useRouter(); const router = useRouter();
const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore(); const { searchCriteria, passengers: storedPassengers, setPassengers, setCreateAccount } = useBookingStore();
const { user, isAuthenticated, updateUser } = useAuthStore(); const { user, isAuthenticated, updateUser } = useAuthStore();
const isInitialized = useAuthStore((s) => s.isInitialized); const isInitialized = useAuthStore((s) => s.isInitialized);
const [faydaEnabled, setFaydaEnabled] = useState(true); const [faydaEnabled, setFaydaEnabled] = useState(true);
@@ -393,22 +393,43 @@ export default function PassengersPage() {
resolver: zodResolver(formSchema as any), resolver: zodResolver(formSchema as any),
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
passengers: Array.from({ length: totalPassengers }, () => ({ passengers: Array.from({ length: totalPassengers }, (_, i) => {
name: '', const stored = storedPassengers[i];
dateOfBirth: '', if (stored?.name) {
gender: undefined, return {
nationality: searchCriteria?.nationality || 'ETHIOPIAN', name: stored.name,
phone: '', dateOfBirth: stored.dateOfBirth || '',
email: '', gender: (stored.gender as any) || undefined,
nationalId: '', nationality: stored.nationality || searchCriteria?.nationality || 'ETHIOPIAN',
passportNumber: '', phone: stored.phone || '',
passportCountry: '', email: stored.email || '',
passportIssueDate: '', nationalId: stored.nationalId || '',
passportExpiryDate: '', passportNumber: stored.passportNumber || '',
passportIssuingAuthority: '', passportCountry: stored.passportCountry || '',
faydaVerified: false, passportIssueDate: stored.passportIssueDate || '',
formExpanded: false, passportExpiryDate: stored.passportExpiryDate || '',
})), passportIssuingAuthority: stored.passportIssuingAuthority || '',
faydaVerified: stored.faydaVerified || false,
formExpanded: true,
};
}
return {
name: '',
dateOfBirth: '',
gender: undefined,
nationality: searchCriteria?.nationality || 'ETHIOPIAN',
phone: '',
email: '',
nationalId: '',
passportNumber: '',
passportCountry: '',
passportIssueDate: '',
passportExpiryDate: '',
passportIssuingAuthority: '',
faydaVerified: false,
formExpanded: false,
};
}),
createAccount: false, createAccount: false,
}, },
}); });

View File

@@ -29,6 +29,7 @@ export default function PaymentPage() {
usePaymentStore(); usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null); const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
@@ -60,57 +61,37 @@ export default function PaymentPage() {
const paymentMutation = useMutation({ const paymentMutation = useMutation({
mutationFn: async (data: any) => { mutationFn: async (data: any) => {
// For all payment methods, use the initiate endpoint return await apiClient.post("/payments/initiate", {
try { bookingId: data.bookingId,
return await apiClient.post("/payments/initiate", { method: data.method,
bookingId: data.bookingId, paymentMethodId: data.paymentMethodId,
method: data.method, platform: 'web',
paymentMethodId: data.paymentMethodId, });
platform: 'web',
});
} catch (error) {
console.log("Payment API not available, using mock payment");
// Mock payment response
return {
paymentIntentId: `mock-payment-${Date.now()}`,
status: "PENDING",
amountMinor: data.amountMinor,
currency: data.currency,
method: data.method,
};
}
}, },
onSuccess: async (data: any) => { onSuccess: async (data: any) => {
// Handle TELEBIRR/WAAFI redirect response setPaymentError(null);
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') {
const redirectUrl = data.clientAction.url;
// Store the intent ID for later verification if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') {
setPaymentIntent(data.intentId); setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION"); updateStatus("REQUIRES_ACTION");
window.location.href = data.clientAction.url;
// Redirect to payment gateway
window.location.href = redirectUrl;
return; return;
} }
setPaymentIntent(data.paymentIntentId || data.intentId); setPaymentIntent(data.paymentIntentId || data.intentId);
updateStatus("PROCESSING"); updateStatus("PROCESSING");
// Simulate payment processing
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
updateStatus("SUCCEEDED"); updateStatus("SUCCEEDED");
router.push("/booking/confirmation"); router.push("/booking/confirmation");
}, },
onError: (error: any) => { onError: (error: any) => {
console.error("Payment failed:", error); console.error("Payment failed:", error);
updateStatus("FAILED"); updateStatus("FAILED");
const errorMessage = setPaymentError(
error?.response?.data?.message || error?.response?.data?.message ||
error?.message || error?.message ||
"Payment failed. Please try again."; "Payment failed. Please try again.",
alert(errorMessage); );
setIsProcessing(false); setIsProcessing(false);
}, },
}); });
@@ -124,6 +105,7 @@ export default function PaymentPage() {
} }
setIsProcessing(true); setIsProcessing(true);
setPaymentError(null);
// Find the selected payment method to get its ID // Find the selected payment method to get its ID
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod); const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod);
@@ -575,11 +557,10 @@ export default function PaymentPage() {
</div> </div>
{/* Error Message */} {/* Error Message */}
{paymentMutation.isError && ( {paymentError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4"> <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4">
<p className="text-red-800 dark:text-red-200 text-sm font-medium"> <p className="text-red-800 dark:text-red-200 text-sm font-medium">
Payment failed. Please try again or contact support if the {paymentError}
problem persists.
</p> </p>
</div> </div>
)} )}

View File

@@ -14,7 +14,9 @@ export default function ResultsPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore(); const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore();
const [selectedCoachTypes, setSelectedCoachTypes] = useState<Record<string, { id: string; code: string; name: string }>>({}); const [selectedCoachTypes, setSelectedCoachTypes] = useState<Record<string, { id: string; code: string; name: string }>>({});
const [outboundScheduleData, setOutboundScheduleData] = useState<any>(null); const [outboundScheduleData, setOutboundScheduleData] = useState<any>(
() => useBookingStore.getState().outboundSchedule,
);
const [classModal, setClassModal] = useState<Schedule | null>(null); const [classModal, setClassModal] = useState<Schedule | null>(null);
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null); const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
@@ -142,8 +144,8 @@ export default function ResultsPage() {
? (outboundSchedules.length > 0 && inboundSchedules.length > 0) ? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
: outboundSchedules.length > 0; : outboundSchedules.length > 0;
const handleSelectCoachType = (scheduleId: string, coachTypeCode: string, coachTypeName: string) => { const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string) => {
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeCode, code: coachTypeCode, name: coachTypeName } })); setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName } }));
}; };
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
@@ -156,7 +158,7 @@ export default function ResultsPage() {
} }
// Find the coach type to get pricing info // 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.coachTypeCode === selectedCoachType.code);
const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0; const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0;
const hours = Math.floor((schedule.durationMinutes || 0) / 60); const hours = Math.floor((schedule.durationMinutes || 0) / 60);
@@ -533,14 +535,14 @@ export default function ResultsPage() {
{coachTypes.length > 0 ? ( {coachTypes.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2"> <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
{coachTypes.map((coachType: any, index: number) => { {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 minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
const CoachIcon = getCoachIcon(coachType.coachTypeName); const CoachIcon = getCoachIcon(coachType.coachTypeName);
return ( return (
<button <button
key={coachType.coachId} 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 ${ className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
isSelected 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]' ? '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]'

View File

@@ -648,24 +648,37 @@ export default function ReviewPage() {
<div className="card"> <div className="card">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Fare breakdown</h2> <h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Fare breakdown</h2>
<div className="space-y-2"> <div className="space-y-3">
{isRoundTrip ? ( {passengers.map((p, i) => {
<> const outFare = outboundSchedule?.baseFareAdult || 0;
<div className="flex justify-between"> const inFare = inboundSchedule?.baseFareAdult || 0;
<span className="text-gray-600 dark:text-gray-400">Outbound fare</span> const onewayFare = selectedSchedule?.baseFareAdult || 0;
<span className="text-gray-900 dark:text-gray-100">ETB {(outboundBaseFare / 100).toFixed(2)}</span> const passengerTotal = isRoundTrip ? outFare + inFare : onewayFare;
return (
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-3 last:border-0">
<div className="flex justify-between mb-1">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{p.name || `Passenger ${i + 1}`}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
ETB {(passengerTotal / 100).toFixed(2)}
</span>
</div>
{isRoundTrip && (
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between">
<span>Outbound</span>
<span>ETB {(outFare / 100).toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span>Return</span>
<span>ETB {(inFare / 100).toFixed(2)}</span>
</div>
</div>
)}
</div> </div>
<div className="flex justify-between"> );
<span className="text-gray-600 dark:text-gray-400">Return fare</span> })}
<span className="text-gray-900 dark:text-gray-100">ETB {(inboundBaseFare / 100).toFixed(2)}</span>
</div>
</>
) : (
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Base fare</span>
<span className="text-gray-900 dark:text-gray-100">ETB {(baseFare / 100).toFixed(2)}</span>
</div>
)}
<div className="flex justify-between text-lg font-bold border-t border-gray-200 dark:border-gray-700 pt-2"> <div className="flex justify-between text-lg font-bold border-t border-gray-200 dark:border-gray-700 pt-2">
<span className="text-gray-900 dark:text-gray-100">Total</span> <span className="text-gray-900 dark:text-gray-100">Total</span>
<span className="text-primary dark:text-gray-100">ETB {(total / 100).toFixed(2)}</span> <span className="text-primary dark:text-gray-100">ETB {(total / 100).toFixed(2)}</span>

View File

@@ -65,6 +65,16 @@ const searchSchema = z
message: "Return date must be after departure date", message: "Return date must be after departure date",
path: ["returnDate"], path: ["returnDate"],
}, },
)
.refine(
(d) =>
!d.originStationId ||
!d.destinationStationId ||
d.originStationId !== d.destinationStationId,
{
message: "Departure and destination cannot be the same station",
path: ["destinationStationId"],
},
); );
type SearchForm = z.infer<typeof searchSchema>; type SearchForm = z.infer<typeof searchSchema>;
@@ -583,7 +593,7 @@ export default function SearchPage() {
adultCount: 1, adultCount: 1,
childCount: 0, childCount: 0,
nationality: "ETHIOPIAN", nationality: "ETHIOPIAN",
departureDate: new Date().toISOString().split("T")[0], departureDate: "",
promoCode: "", promoCode: "",
}, },
}); });
@@ -609,6 +619,8 @@ export default function SearchPage() {
const adults = searchParams.get("adults"); const adults = searchParams.get("adults");
const children = searchParams.get("children"); const children = searchParams.get("children");
const nat = searchParams.get("nationality"); const nat = searchParams.get("nationality");
const tripType = searchParams.get("tripType");
const returnDate = searchParams.get("returnDate");
if (o) setValue("originStationId", o); if (o) setValue("originStationId", o);
if (d) setValue("destinationStationId", d); if (d) setValue("destinationStationId", d);
if (date) setValue("departureDate", date); if (date) setValue("departureDate", date);
@@ -616,6 +628,8 @@ export default function SearchPage() {
if (children) setValue("childCount", parseInt(children)); if (children) setValue("childCount", parseInt(children));
if (nat) if (nat)
setValue("nationality", nat as "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER"); setValue("nationality", nat as "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER");
if (tripType) setValue("tripType", tripType as "ONE_WAY" | "ROUND_TRIP");
if (returnDate) setValue("returnDate", returnDate);
}, [searchParams, setValue]); }, [searchParams, setValue]);
useEffect(() => { useEffect(() => {

View File

@@ -133,7 +133,6 @@ export default function SeatsPage() {
? outboundSchedule ? outboundSchedule
: selectedSchedule; : selectedSchedule;
const coachTypeId = (currentSchedule as any)?.selectedCoachTypeId; const coachTypeId = (currentSchedule as any)?.selectedCoachTypeId;
const coachTypeCode = (currentSchedule as any)?.selectedCoachTypeCode;
const { const {
data: seatMapData, data: seatMapData,
@@ -142,7 +141,7 @@ export default function SeatsPage() {
} = useQuery({ } = useQuery({
queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType], queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType],
queryFn: async () => { queryFn: async () => {
const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeCode}`; const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeId}`;
console.log("🪑 Seatmap Request:", { console.log("🪑 Seatmap Request:", {
endpoint, endpoint,
}); });

View File

@@ -11,7 +11,7 @@ export default registerAs("dmoney", () => ({
returnUrl: process.env.DMONEY_RETURN_URL ?? "", returnUrl: process.env.DMONEY_RETURN_URL ?? "",
timeoutExpress: process.env.DMONEY_TIMEOUT_EXPRESS ?? "120m", timeoutExpress: process.env.DMONEY_TIMEOUT_EXPRESS ?? "120m",
language: process.env.DMONEY_LANGUAGE ?? "en", language: process.env.DMONEY_LANGUAGE ?? "en",
currency: process.env.DMONEY_CURRENCY ?? "FDJ", currency: process.env.DMONEY_CURRENCY ?? "DJF",
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "", privateKey: process.env.DMONEY_PRIVATE_KEY ?? "",
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "", publicKey: process.env.DMONEY_PUBLIC_KEY ?? "",
insecureTls: process.env.DMONEY_INSECURE_TLS === "true", insecureTls: process.env.DMONEY_INSECURE_TLS === "true",

View File

@@ -28,8 +28,12 @@ FROM base AS builder
ARG TURBO_FILTER ARG TURBO_FILTER
ARG APP_PATH ARG APP_PATH
ARG VITE_API_URL ARG VITE_API_URL
ARG VITE_BASE_API_URL
ARG VITE_USER_MANAGEMENT_BASE
ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_API_URL
ENV VITE_API_URL=${VITE_API_URL} ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_BASE_API_URL=${VITE_BASE_API_URL}
ENV VITE_USER_MANAGEMENT_BASE=${VITE_USER_MANAGEMENT_BASE}
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
COPY --from=installer /app/ . COPY --from=installer /app/ .
COPY --from=pruner /app/out/full/ . COPY --from=pruner /app/out/full/ .

View File

@@ -206,18 +206,16 @@ export class DMoneyProvider implements PaymentProvider {
appid: this.merchantAppId, appid: this.merchantAppId,
merch_code: this.merchantCode, merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId, merch_order_id: input.merchantOrderId,
trade_type: "Checkout" as const, trade_type: "WebCheckout" as const,
business_type: "OnlineMerchant" as const,
title: `${input.orderRef}`, title: `${input.orderRef}`,
total_amount: totalAmount, total_amount: totalAmount,
trans_currency: 1 == 1 ? "DJF": this.currency, trans_currency: this.currency,
timeout_express: this.timeoutExpress, timeout_express: this.timeoutExpress,
...(redirectUrl ? { redirect_url: redirectUrl } : {}), ...(redirectUrl ? { redirect_url: redirectUrl } : {}),
}, },
}; };
console.log("\n\n\n")
console.log(req)
console.log("\n\n\n")
const sign = signRequestObject( const sign = signRequestObject(
req as unknown as Record<string, unknown>, req as unknown as Record<string, unknown>,
this.privateKey, this.privateKey,
@@ -265,7 +263,7 @@ export class DMoneyProvider implements PaymentProvider {
`sign=${sign}`, `sign=${sign}`,
"sign_type=SHA256WithRSA", "sign_type=SHA256WithRSA",
"version=1.0", "version=1.0",
"trade_type=Checkout", "trade_type=WebCheckout",
`language=${this.language}`, `language=${this.language}`,
].join("&"); ].join("&");
return `${this.webBaseUrl}/payment/web/paygate?${query}`; return `${this.webBaseUrl}/payment/web/paygate?${query}`;

View File

@@ -10,12 +10,12 @@ export interface DMoneyPreOrderBizContent {
appid: string; appid: string;
merch_code: string; merch_code: string;
merch_order_id: string; merch_order_id: string;
trade_type: 'Checkout'; trade_type: 'WebCheckout';
business_type: 'OnlineMerchant';
title: string; title: string;
total_amount: string; total_amount: string;
trans_currency: string; trans_currency: string;
timeout_express: string; timeout_express: string;
business_type?: string;
redirect_url?: string; redirect_url?: string;
callback_info?: string; callback_info?: string;
} }

View File

@@ -80,7 +80,7 @@ if [[ -f "${build_env}" ]]; then
set +a set +a
if [[ -n "${GITHUB_ENV:-}" ]]; then if [[ -n "${GITHUB_ENV:-}" ]]; then
grep -E '^[[:space:]]*export[[:space:]]+[A-Za-z_][A-Za-z0-9_]*=' "${build_env}" \ grep -E '^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*=' "${build_env}" \
| sed -E 's/^[[:space:]]*export[[:space:]]+//' >> "${GITHUB_ENV}" | sed -E 's/^[[:space:]]*export[[:space:]]+//' >> "${GITHUB_ENV}"
echo "Wrote build variables to GITHUB_ENV" echo "Wrote build variables to GITHUB_ENV"
fi fi