mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
fix conflict
This commit is contained in:
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -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: |
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Migration already applied directly to the database.
|
||||||
|
-- This file exists only to satisfy Prisma's migration directory check (P3015).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE passenger."Booking" ADD COLUMN IF NOT EXISTS "paymentReminderSentAt" TIMESTAMP(3);
|
||||||
@@ -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])
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
34
apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts
Normal file
34
apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 +36,13 @@ 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,
|
||||||
pageSize: pageSize ? parseInt(pageSize) : 20
|
pageSize: pageSize ? parseInt(pageSize) : 20
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) ──────────────────────
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
10
apps/edr-passenger-api/src/modules/tasks/tasks.module.ts
Normal file
10
apps/edr-passenger-api/src/modules/tasks/tasks.module.ts
Normal 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 {}
|
||||||
205
apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
Normal file
205
apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
Normal 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)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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') {
|
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') {
|
||||||
const redirectUrl = data.clientAction.url;
|
|
||||||
|
|
||||||
// Store the intent ID for later verification
|
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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]'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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/ .
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user