mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/develop
This commit is contained in:
243
.github/workflows/polinrider-scan.yml
vendored
243
.github/workflows/polinrider-scan.yml
vendored
@@ -1,243 +0,0 @@
|
||||
name: PolinRider Malware Scan
|
||||
|
||||
# ── Triggers ──────────────────────────────────────────────────────────────────
|
||||
# Runs on every push and every PR targeting main/master/develop.
|
||||
# Also available as a manual trigger (workflow_dispatch) and on a nightly
|
||||
# schedule so dormant infections in older branches are caught too.
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
schedule:
|
||||
# Nightly full-repo scan at 02:00 UTC
|
||||
- cron: "0 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# ── Permissions ───────────────────────────────────────────────────────────────
|
||||
permissions:
|
||||
contents: read # checkout
|
||||
security-events: write # upload SARIF to GitHub Security tab
|
||||
actions: read
|
||||
checks: write # annotate PRs with scan findings
|
||||
|
||||
# ── Deployment gate ───────────────────────────────────────────────────────────
|
||||
# All other jobs (build, test, deploy) should list this job under `needs:`.
|
||||
# If this job fails (exit code 1 from the scanner), the whole workflow stops.
|
||||
jobs:
|
||||
polinrider-scan:
|
||||
name: "PolinRider / Famous Chollima Scan"
|
||||
runs-on: ubuntu-latest
|
||||
# Prevent CI from being disabled by any workflow override
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
# ── 1. Checkout full history ─────────────────────────────────────────────
|
||||
# Full depth so we can inspect recent commits for temp_auto_push.bat traces
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# ── 2. Detect suspicious force-push patterns in git history ──────────────
|
||||
- name: Check git history for force-push and timestamp manipulation
|
||||
id: git-check
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== Checking for suspicious git history patterns ==="
|
||||
|
||||
# Check for .gitignore entries hiding known malware artifacts
|
||||
GITIGNORE_HITS=0
|
||||
if [ -f .gitignore ]; then
|
||||
for pattern in "branch_structure.json" "temp_auto_push.bat" "temp_interactive_push.bat"; do
|
||||
if grep -qF "$pattern" .gitignore 2>/dev/null; then
|
||||
echo "::warning file=.gitignore::SUSPICIOUS: .gitignore hides known PolinRider artifact: $pattern"
|
||||
GITIGNORE_HITS=$((GITIGNORE_HITS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Check if malware persistence artifacts exist anywhere in the tree
|
||||
ARTIFACTS_FOUND=0
|
||||
for artifact in "temp_auto_push.bat" "temp_interactive_push.bat" "branch_structure.json"; do
|
||||
FOUND=$(find . -name "$artifact" -not -path "./.git/*" 2>/dev/null)
|
||||
if [ -n "$FOUND" ]; then
|
||||
echo "::error ::CRITICAL: PolinRider persistence artifact found: $artifact"
|
||||
echo "$FOUND"
|
||||
ARTIFACTS_FOUND=$((ARTIFACTS_FOUND + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Scan recent commit messages for --no-verify (used by temp_auto_push.bat)
|
||||
NO_VERIFY_COMMITS=$(git log --oneline -50 --format="%H %s" 2>/dev/null | grep -i "no.verify\|force.*push\|amend" || true)
|
||||
if [ -n "$NO_VERIFY_COMMITS" ]; then
|
||||
echo "::warning ::Recent commits with suspicious metadata (--no-verify / force amend patterns):"
|
||||
echo "$NO_VERIFY_COMMITS"
|
||||
fi
|
||||
|
||||
# Check for .woff2 files with unusually large sizes (>50KB is suspicious)
|
||||
find . -name "*.woff2" -not -path "./.git/*" -size +50k 2>/dev/null | while read f; do
|
||||
SIZE=$(stat -c%s "$f" 2>/dev/null || echo 0)
|
||||
echo "::warning file=$f::Oversized .woff2 font file ($SIZE bytes) — may contain embedded payload"
|
||||
done
|
||||
|
||||
echo "GITIGNORE_HITS=$GITIGNORE_HITS" >> "$GITHUB_OUTPUT"
|
||||
echo "ARTIFACTS_FOUND=$ARTIFACTS_FOUND" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── 3. Run the JavaScript malware scanner ────────────────────────────────
|
||||
- name: Run PolinRider malware scanner
|
||||
id: scanner
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== Running PolinRider IOC scanner ==="
|
||||
|
||||
# The scanner is zero-dependency — just needs Node.js (always present on ubuntu-latest)
|
||||
node .github/scripts/scan.js \
|
||||
--json \
|
||||
--output scan-report.json \
|
||||
.
|
||||
|
||||
SCANNER_EXIT=$?
|
||||
echo "SCANNER_EXIT=$SCANNER_EXIT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Also emit a human-readable summary to the Actions log
|
||||
node .github/scripts/scan.js . || true
|
||||
|
||||
exit $SCANNER_EXIT
|
||||
|
||||
# ── 4. Upload scan report as artifact ────────────────────────────────────
|
||||
# - name: Upload scan report
|
||||
# if: always()
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: polinrider-scan-report
|
||||
# path: scan-report.json
|
||||
# retention-days: 90
|
||||
|
||||
# # ── 5. Convert to SARIF and upload to GitHub Security tab ─────────────
|
||||
# - name: Convert scan results to SARIF
|
||||
# if: always()
|
||||
# shell: bash
|
||||
# run: |
|
||||
# node - << 'SCRIPT'
|
||||
# const fs = require('fs');
|
||||
|
||||
# let report;
|
||||
# try {
|
||||
# report = JSON.parse(fs.readFileSync('scan-report.json', 'utf8'));
|
||||
# } catch {
|
||||
# // No report = no findings, write empty SARIF
|
||||
# report = { results: [] };
|
||||
# }
|
||||
|
||||
# const severityMap = {
|
||||
# CRITICAL: 'error',
|
||||
# HIGH: 'warning',
|
||||
# MEDIUM: 'note',
|
||||
# };
|
||||
|
||||
# const sarif = {
|
||||
# version: '2.1.0',
|
||||
# $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json',
|
||||
# runs: [{
|
||||
# tool: {
|
||||
# driver: {
|
||||
# name: 'PolinRider Malware Scanner',
|
||||
# version: '1.0.0',
|
||||
# informationUri: 'https://github.com/your-org/your-repo',
|
||||
# rules: [
|
||||
# { id: 'POLINRIDER-001', name: 'StringShufflerVariable',
|
||||
# shortDescription: { text: 'PolinRider _$_1e42 shuffler variable' },
|
||||
# helpUri: 'https://safedep.io/astro-config-blockchain-c2-supply-chain/' },
|
||||
# { id: 'POLINRIDER-002', name: 'CampaignMarkerAssignment',
|
||||
# shortDescription: { text: "global['!'] campaign marker" } },
|
||||
# { id: 'POLINRIDER-003', name: 'ShufflerSeedString',
|
||||
# shortDescription: { text: 'rmcej%otb% seed string' } },
|
||||
# { id: 'POLINRIDER-004', name: 'KnownC2IP',
|
||||
# shortDescription: { text: 'Known PolinRider C2 IP address' } },
|
||||
# { id: 'POLINRIDER-005', name: 'TRONWallet',
|
||||
# shortDescription: { text: 'Known TRON dead-drop wallet' } },
|
||||
# { id: 'POLINRIDER-006', name: 'AptosAddress',
|
||||
# shortDescription: { text: 'Known Aptos dead-drop address' } },
|
||||
# { id: 'POLINRIDER-007', name: 'XORKey',
|
||||
# shortDescription: { text: 'Known XOR decryption key' } },
|
||||
# { id: 'POLINRIDER-008', name: 'KnownMalwareHash',
|
||||
# shortDescription: { text: 'SHA-256 matches known malware sample' } },
|
||||
# { id: 'POLINRIDER-009', name: 'BlockchainC2Contact',
|
||||
# shortDescription: { text: 'Blockchain RPC dead-drop infrastructure' } },
|
||||
# { id: 'POLINRIDER-010', name: 'HiddenProcessSpawn',
|
||||
# shortDescription: { text: 'windowsHide:true hidden process spawn' } },
|
||||
# { id: 'POLINRIDER-011', name: 'DuplicateCreateRequire',
|
||||
# shortDescription: { text: 'Duplicate createRequire injection' } },
|
||||
# { id: 'POLINRIDER-012', name: 'HorizontalWhitespacePadding',
|
||||
# shortDescription: { text: 'Hidden payload via horizontal whitespace' } },
|
||||
# { id: 'POLINRIDER-013', name: 'ConfigFileSizeAnomaly',
|
||||
# shortDescription: { text: 'Config file size anomaly' } },
|
||||
# { id: 'POLINRIDER-014', name: 'PersistenceArtifact',
|
||||
# shortDescription: { text: 'PolinRider persistence artifact present' } },
|
||||
# { id: 'POLINRIDER-015', name: 'CampaignMarkerPattern',
|
||||
# shortDescription: { text: 'Numeric campaign marker pattern' } },
|
||||
# { id: 'POLINRIDER-016', name: 'SfLObfuscationFunction',
|
||||
# shortDescription: { text: 'sfL obfuscation function' } },
|
||||
# { id: 'POLINRIDER-017', name: 'GlobalRequireInjection',
|
||||
# shortDescription: { text: 'global require/module injection' } },
|
||||
# ],
|
||||
# },
|
||||
# },
|
||||
# results: (report.results || []).flatMap(file =>
|
||||
# (file.findings || []).map(finding => ({
|
||||
# ruleId: finding.id,
|
||||
# level: severityMap[finding.severity] || 'warning',
|
||||
# message: { text: finding.description + ' — ' + finding.matches.join('; ') },
|
||||
# locations: [{
|
||||
# physicalLocation: {
|
||||
# artifactLocation: { uri: file.filePath.replace(/^\.\//,''), uriBaseId: '%SRCROOT%' },
|
||||
# region: { startLine: 1 },
|
||||
# },
|
||||
# }],
|
||||
# }))
|
||||
# ),
|
||||
# }],
|
||||
# };
|
||||
|
||||
# fs.writeFileSync('scan-results.sarif', JSON.stringify(sarif, null, 2));
|
||||
# console.log('SARIF written.');
|
||||
# SCRIPT
|
||||
|
||||
# - name: Upload SARIF to GitHub Security tab
|
||||
# if: always()
|
||||
# uses: github/codeql-action/upload-sarif@v3
|
||||
# with:
|
||||
# sarif_file: scan-results.sarif
|
||||
# category: polinrider-malware-scan
|
||||
|
||||
# ── 6. Block deployment if infected ──────────────────────────────────────
|
||||
- name: Enforce clean-scan gate
|
||||
if: steps.scanner.outputs.SCANNER_EXIT == '1' || steps.git-check.outputs.ARTIFACTS_FOUND != '0'
|
||||
shell: bash
|
||||
run: |
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ DEPLOYMENT BLOCKED — PolinRider malware signatures detected ║"
|
||||
echo "║ ║"
|
||||
echo "║ This repository contains code signatures consistent with the ║"
|
||||
echo "║ PolinRider supply-chain campaign (DPRK / Famous Chollima). ║"
|
||||
echo "║ ║"
|
||||
echo "║ DO NOT run npm install, build, or deploy until remediated. ║"
|
||||
echo "║ ║"
|
||||
echo "║ See scan-report.json artifact for full details. ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════╝"
|
||||
exit 1
|
||||
|
||||
# ── Dependent jobs — add `needs: polinrider-scan` to block on clean scan ─────
|
||||
# Example: your existing build/deploy jobs should look like this:
|
||||
#
|
||||
# build:
|
||||
# needs: polinrider-scan
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# ...
|
||||
#
|
||||
# deploy:
|
||||
# needs: [polinrider-scan, build]
|
||||
# ...
|
||||
@@ -15,6 +15,7 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"type-check": "tsc --noEmit",
|
||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
||||
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -48,6 +48,7 @@ import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
|
||||
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||
@@ -121,6 +122,7 @@ import { OverviewModule } from './modules/overview/overview.module';
|
||||
PricingDataSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
DemoFreightDataSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -133,6 +135,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
) { }
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
@@ -144,5 +147,8 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.demoBookingsSeeder.run();
|
||||
await this.pricingDataSeeder.run();
|
||||
await this.fileUploadSettingsSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
await this.demoFreightDataSeeder.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,10 @@ export const TrainSchedulingView = () =>
|
||||
|
||||
export const TrainSchedulingManage = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
|
||||
|
||||
export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view);
|
||||
|
||||
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
|
||||
|
||||
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
||||
|
||||
@@ -14,8 +14,12 @@ export class NormalizeWeightLimitTradeDirectionBoth1749000000000
|
||||
UPDATE freight.weight_limit_rules
|
||||
SET trade_direction = 'BOTH'
|
||||
WHERE trade_direction::text = 'ANY';
|
||||
|
||||
UPDATE freight.weight_limit_rules
|
||||
SET trade_direction = 'IMPORT'
|
||||
WHERE trade_direction IS NULL;
|
||||
EXCEPTION WHEN undefined_table OR undefined_column THEN NULL;
|
||||
END $$;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { BackofficeService } from "./backoffice.service";
|
||||
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
|
||||
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
|
||||
|
||||
@ApiTags("backoffice")
|
||||
@Controller("backoffice")
|
||||
@FreightAdmin()
|
||||
export class BackofficeController {
|
||||
constructor(private readonly backofficeService: BackofficeService) {}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { BillingService } from "./billing.service";
|
||||
|
||||
@ApiTags("billing")
|
||||
@Controller("billing")
|
||||
@FreightAdmin()
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
||||
import { LoadCargoDto } from './dto/load-cargo.dto';
|
||||
@@ -18,10 +19,12 @@ import { CargoesService } from './cargoes.service';
|
||||
|
||||
@ApiTags('cargoes')
|
||||
@Controller('cargoes')
|
||||
@FleetView()
|
||||
export class CargoesController {
|
||||
constructor(private readonly cargoesService: CargoesService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create a new cargo' })
|
||||
create(@Body() dto: CreateCargoDto) {
|
||||
return this.cargoesService.create(dto);
|
||||
@@ -40,30 +43,35 @@ export class CargoesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a cargo' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
|
||||
return this.cargoesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Delete a cargo' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.cargoesService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/load')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Load cargo into a container' })
|
||||
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
|
||||
return this.cargoesService.loadCargo(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unload')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Unload cargo from container' })
|
||||
unload(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.cargoesService.unloadCargo(id);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Mark cargo as delivered' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
|
||||
return this.cargoesService.deliverCargo(id, dto);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { FreightAdmin } from '../../common/booking-guards';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
@@ -82,6 +83,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
|
||||
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.createCompany(dto);
|
||||
@@ -119,6 +121,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Update a company' })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -129,6 +132,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Soft-delete a company' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
@@ -147,6 +151,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post(':companyId/profiles')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
||||
async createProfile(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@@ -175,6 +180,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post('ff-clients')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Link a forwarder to a client company' })
|
||||
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
|
||||
const client = await this.companiesService.createFFClient(dto);
|
||||
@@ -191,6 +197,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Delete('ff-clients/:id')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
|
||||
@@ -9,16 +9,19 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FleetManage, FleetView } from "../../common/booking-guards";
|
||||
import { ConsignmentsService } from "./consignments.service";
|
||||
import { CreateConsignmentDto } from "./dto/create-consignment.dto";
|
||||
import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
|
||||
|
||||
@ApiTags("consignments")
|
||||
@Controller("consignments")
|
||||
@FleetView()
|
||||
export class ConsignmentsController {
|
||||
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: "Create a new consignment" })
|
||||
create(@Body() dto: CreateConsignmentDto) {
|
||||
return this.consignmentsService.create(dto);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { CreateContainerDto } from './dto/create-container.dto';
|
||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
@@ -17,10 +18,12 @@ import { ContainersService } from './containers.service';
|
||||
|
||||
@ApiTags('containers')
|
||||
@Controller('containers')
|
||||
@FleetView()
|
||||
export class ContainersController {
|
||||
constructor(private readonly containersService: ContainersService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create a new container' })
|
||||
create(@Body() dto: CreateContainerDto) {
|
||||
return this.containersService.create(dto);
|
||||
@@ -39,24 +42,28 @@ export class ContainersController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a container' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
|
||||
return this.containersService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Delete a container' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.containersService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/assign-wagon')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Assign container to a wagon' })
|
||||
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
|
||||
return this.containersService.assignToWagon(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unassign-wagon')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Unassign container from wagon' })
|
||||
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.containersService.unassignFromWagon(id);
|
||||
|
||||
@@ -16,12 +16,14 @@ import {
|
||||
|
||||
import { ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
|
||||
@Controller("customers")
|
||||
@FreightAdmin()
|
||||
export class CustomersController {
|
||||
constructor(private readonly customersService: CustomersService) {}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
||||
@@ -24,6 +25,9 @@ import { DropdownSettingsService } from "./dropdown-settings.service";
|
||||
export class DropdownSettingsController {
|
||||
constructor(private readonly service: DropdownSettingsService) {}
|
||||
|
||||
// Reads stay open: the customer portal fetches these to render dynamic
|
||||
// dropdowns (by-code). Only writes are admin-guarded.
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all dropdown settings" })
|
||||
list() {
|
||||
@@ -43,12 +47,14 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Create a new dropdown setting" })
|
||||
create(@Body() dto: CreateDropdownSettingDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Update a dropdown setting's metadata" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -58,6 +64,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Soft-delete a dropdown setting" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@@ -67,6 +74,7 @@ export class DropdownSettingsController {
|
||||
/* ------------------------- option routes ------------------------- */
|
||||
|
||||
@Put(":id/options")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Replace the full option list for a setting" })
|
||||
replaceOptions(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -76,6 +84,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Post(":id/options")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Append a single option to a setting" })
|
||||
addOption(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -85,6 +94,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Patch("options/:optionId")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Update a single option" })
|
||||
updateOption(
|
||||
@Param("optionId", ParseUUIDPipe) optionId: string,
|
||||
@@ -94,6 +104,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Delete("options/:optionId")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Soft-delete a single option" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto";
|
||||
import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto";
|
||||
import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto";
|
||||
@@ -24,6 +25,9 @@ import { FileUploadSettingsService } from "./file-upload-settings.service";
|
||||
export class FileUploadSettingsController {
|
||||
constructor(private readonly service: FileUploadSettingsService) {}
|
||||
|
||||
// Reads stay open: the customer portal fetches these to render dynamic
|
||||
// upload forms (by-code / by-entity). Only writes are admin-guarded.
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all file upload settings" })
|
||||
list() {
|
||||
@@ -49,12 +53,14 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Create a new file upload setting" })
|
||||
create(@Body() dto: CreateFileUploadSettingDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Update a file upload setting's metadata" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -64,6 +70,7 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Soft-delete a file upload setting" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@@ -73,6 +80,7 @@ export class FileUploadSettingsController {
|
||||
/* ------------------------- field routes ------------------------- */
|
||||
|
||||
@Put(":id/fields")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Replace the full field list for a setting" })
|
||||
replaceFields(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -82,6 +90,7 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Post(":id/fields")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Append a single field to a setting" })
|
||||
addField(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -91,6 +100,7 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Patch("fields/:fieldId")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Update a single field" })
|
||||
updateField(
|
||||
@Param("fieldId", ParseUUIDPipe) fieldId: string,
|
||||
@@ -100,6 +110,7 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Delete("fields/:fieldId")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Soft-delete a single field" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
@@ -9,6 +10,7 @@ import { LocomotivesService } from './locomotives.service';
|
||||
@ApiTags('locomotives')
|
||||
@ApiBearerAuth()
|
||||
@Controller('locomotives')
|
||||
@FleetView()
|
||||
export class LocomotivesController {
|
||||
constructor(private readonly locomotivesService: LocomotivesService) {}
|
||||
|
||||
@@ -25,18 +27,21 @@ export class LocomotivesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create a locomotive' })
|
||||
create(@Body() dto: CreateLocomotiveDto) {
|
||||
return this.locomotivesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a locomotive' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
|
||||
return this.locomotivesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/decommission')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Decommission a locomotive' })
|
||||
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.locomotivesService.decommission(id);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
export class PaymentClientService {
|
||||
private readonly logger = new Logger(PaymentClientService.name);
|
||||
private readonly baseUrl = (
|
||||
process.env.PAYMENT_API_URL ?? "http://localhost:3003"
|
||||
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
|
||||
).replace(/\/$/, "");
|
||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { BookingView, FreightAdmin } from "../../common/booking-guards";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
@@ -32,8 +33,16 @@ import {
|
||||
export class PaymentController {
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
|
||||
@Get("summary")
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: "Payment count/amount summary for dashboard cards" })
|
||||
getSummary() {
|
||||
return this.paymentService.getSummary();
|
||||
}
|
||||
|
||||
@Get("all")
|
||||
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: "Get all payments with filters (view-only, any staff)" })
|
||||
@ApiQuery({ name: "search", required: false })
|
||||
@ApiQuery({ name: "status", required: false })
|
||||
@ApiQuery({ name: "method", required: false })
|
||||
@@ -73,6 +82,7 @@ export class PaymentController {
|
||||
}
|
||||
|
||||
@Post("refund")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Refund a paid booking (staff/admin only)" })
|
||||
refund(@Body() dto: RefundDto) {
|
||||
return this.paymentService.refund(dto);
|
||||
|
||||
@@ -105,6 +105,40 @@ export class PaymentService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Aggregate counts across ALL payments for the dashboard summary cards. */
|
||||
async getSummary() {
|
||||
const rows = await this.paymentRepo
|
||||
.createQueryBuilder("payment")
|
||||
.select("payment.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.groupBy("payment.status")
|
||||
.getRawMany<{ status: string; count: number }>();
|
||||
|
||||
const byStatus: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
byStatus[row.status] = row.count;
|
||||
total += row.count;
|
||||
}
|
||||
|
||||
// Sum of successfully collected amounts.
|
||||
const paidAgg = await this.paymentRepo
|
||||
.createQueryBuilder("payment")
|
||||
.select("COALESCE(SUM(payment.amount), 0)", "sum")
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.getRawOne<{ sum: string }>();
|
||||
|
||||
return {
|
||||
total,
|
||||
success: byStatus["success"] ?? 0,
|
||||
processing:
|
||||
(byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0),
|
||||
failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0),
|
||||
refunded: byStatus["refunded"] ?? 0,
|
||||
paidAmount: Number(paidAgg?.sum ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { CreateRouteDto } from './dto/create-route.dto';
|
||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||
@@ -9,6 +10,7 @@ import { RoutesService } from './routes.service';
|
||||
@ApiTags('routes')
|
||||
@ApiBearerAuth()
|
||||
@Controller('routes')
|
||||
@FleetView()
|
||||
export class RoutesController {
|
||||
constructor(private readonly routesService: RoutesService) {}
|
||||
|
||||
@@ -25,18 +27,21 @@ export class RoutesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create route' })
|
||||
create(@Body() dto: CreateRouteDto) {
|
||||
return this.routesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update route' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
|
||||
return this.routesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Deactivate route' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.routesService.deactivate(id);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { SignaturesRepository } from './signatures.repository';
|
||||
import { SavedSignature } from './entities/saved-signature.entity';
|
||||
@@ -19,6 +21,7 @@ export class SignaturesService {
|
||||
private readonly signaturesRepository: SignaturesRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** Saved signature for a user, with the image inlined as a data URL (or null). */
|
||||
@@ -47,18 +50,32 @@ export class SignaturesService {
|
||||
path: '',
|
||||
};
|
||||
|
||||
const fileRecord = await this.filesService.upsertByCode({
|
||||
// Capture the previously referenced file so we can remove it only AFTER the
|
||||
// saved_signatures row is repointed — deleting it first would violate the
|
||||
// FK constraint (saved_signatures.signature_file_id -> files.id).
|
||||
const existing = await this.signaturesRepository.findByUserId(input.userId);
|
||||
const previousFileId = existing?.signatureFileId ?? null;
|
||||
|
||||
const fileRecord = await this.filesService.upload({
|
||||
resourceId: input.userId,
|
||||
resource: 'saved_signatures',
|
||||
code: 'signature',
|
||||
file,
|
||||
});
|
||||
|
||||
return this.signaturesRepository.upsert({
|
||||
const saved = await this.signaturesRepository.upsert({
|
||||
userId: input.userId,
|
||||
signerDisplayName: input.signerDisplayName,
|
||||
signatureFileId: fileRecord.id,
|
||||
});
|
||||
|
||||
if (previousFileId && previousFileId !== fileRecord.id) {
|
||||
await this.dataSource
|
||||
.getRepository(FileRecord)
|
||||
.delete({ id: previousFileId });
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
private async inlineImageUrl(
|
||||
|
||||
@@ -96,7 +96,8 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Get("bookable-schedules")
|
||||
// @TrainSchedulingView()
|
||||
// No staff guard: customers hit this while creating a booking to find OPEN
|
||||
// same-route schedules. Do not attach train_scheduling permissions here.
|
||||
@ApiOperation({
|
||||
summary: "OPEN same-route schedules a new booking can target",
|
||||
})
|
||||
|
||||
@@ -11,16 +11,19 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FleetManage, FleetView } from "../../common/booking-guards";
|
||||
import { CreateTrainDto } from "./dto/create-train.dto";
|
||||
import { UpdateTrainDto } from "./dto/update-train.dto";
|
||||
import { TrainsService } from "./trains.service";
|
||||
|
||||
@ApiTags("trains")
|
||||
@Controller("trains")
|
||||
@FleetView()
|
||||
export class TrainsController {
|
||||
constructor(private readonly trainsService: TrainsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: "Register a new train" })
|
||||
create(@Body() dto: CreateTrainDto) {
|
||||
return this.trainsService.create(dto);
|
||||
@@ -39,12 +42,14 @@ export class TrainsController {
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: "Update a train" })
|
||||
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) {
|
||||
return this.trainsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: "Delete a train" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainsService.remove(id);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||
@@ -19,10 +20,12 @@ import { WagonsService } from './wagons.service';
|
||||
|
||||
@ApiTags('wagons')
|
||||
@Controller('wagons')
|
||||
@FleetView()
|
||||
export class WagonsController {
|
||||
constructor(private readonly wagonsService: WagonsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create a new wagon' })
|
||||
create(@Body() dto: CreateWagonDto) {
|
||||
return this.wagonsService.create(dto);
|
||||
@@ -41,24 +44,28 @@ export class WagonsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a wagon' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
|
||||
return this.wagonsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Delete a wagon' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/assign-train')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Assign wagon to a train' })
|
||||
assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
|
||||
return this.wagonsService.assignToTrain(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unassign-train')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Unassign wagon from train' })
|
||||
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.unassignFromTrain(id);
|
||||
@@ -67,10 +74,12 @@ export class WagonsController {
|
||||
|
||||
// Separate controller for train‑specific reorder (registered in module)
|
||||
@Controller('trains/:trainId/reorder-wagons')
|
||||
@FleetView()
|
||||
export class TrainWagonsReorderController {
|
||||
constructor(private readonly wagonsService: WagonsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Reorder wagons of a train' })
|
||||
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
|
||||
return this.wagonsService.reorderWagons(trainId, dto);
|
||||
|
||||
28
apps/edr-freight-api/src/scripts/seed-freight-demo.ts
Normal file
28
apps/edr-freight-api/src/scripts/seed-freight-demo.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../app.module';
|
||||
import { DemoFreightDataSeeder } from '../seed/demo-freight-data.seeder';
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
try {
|
||||
const seeder = app.get(DemoFreightDataSeeder);
|
||||
await seeder.run();
|
||||
console.log('Freight demo data seeded (wagons, approval rules, staff users).');
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Freight demo seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
180
apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts
Normal file
180
apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { hashPassword } from '@tria-plc/api-common/utils/argon';
|
||||
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||
import {
|
||||
Employee,
|
||||
Organization,
|
||||
Role,
|
||||
User,
|
||||
UserCredential,
|
||||
UserRole,
|
||||
} from '@tria-plc/iamapi-common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
|
||||
import { Wagon } from '../modules/wagons/entities/wagon.entity';
|
||||
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
|
||||
import { ApprovalRule } from '../modules/rule-engine/entities/approval-rule.entity';
|
||||
import { DEFAULT_APPROVAL_RULE_ROWS } from '../modules/rule-engine/approval-rules.defaults';
|
||||
|
||||
const EDR_ORG_KEY = 'edr_freight';
|
||||
const MIN_WAGONS_PER_TYPE = 100;
|
||||
|
||||
/** The four demo staff users, each mapped to a seeded freight role. */
|
||||
const DEMO_STAFF_USERS = [
|
||||
{ email: 'marketing@edr.local', username: 'marketing', roleKey: 'edr_marketing' },
|
||||
{ email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' },
|
||||
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
||||
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* One-shot demo data: at least 100 wagons per wagon type, the default approval
|
||||
* chains, and four staff users with distinct permissions. Every block guards on
|
||||
* an "is it already populated?" check, so this is safe to run on every boot and
|
||||
* does nothing once the data exists.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DemoFreightDataSeeder {
|
||||
private readonly logger = new Logger(DemoFreightDataSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run() {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.seedWagons(manager);
|
||||
await this.seedApprovalRules(manager);
|
||||
await this.seedStaffUsers(manager);
|
||||
});
|
||||
}
|
||||
|
||||
/** Ensure every wagon type has at least MIN_WAGONS_PER_TYPE wagons. */
|
||||
private async seedWagons(manager: EntityManager) {
|
||||
const wagonTypeRepo = manager.getRepository(WagonType);
|
||||
const wagonRepo = manager.getRepository(Wagon);
|
||||
|
||||
const wagonTypes = await wagonTypeRepo.find();
|
||||
if (wagonTypes.length === 0) {
|
||||
this.logger.warn('No wagon types found; skipping wagon seed');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const type of wagonTypes) {
|
||||
const existing = await wagonRepo.count({ where: { wagonTypeId: type.id } });
|
||||
if (existing >= MIN_WAGONS_PER_TYPE) {
|
||||
this.logger.log(
|
||||
`Wagon type ${type.code} already has ${existing} wagons; skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const toCreate = MIN_WAGONS_PER_TYPE - existing;
|
||||
const tare = Number(type.tareWeightTons ?? 20);
|
||||
const maxPayload = Number(type.capacityTons ?? 60);
|
||||
const rows = Array.from({ length: toCreate }, (_, i) => {
|
||||
const seq = existing + i + 1;
|
||||
return wagonRepo.create({
|
||||
wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`,
|
||||
wagonTypeId: type.id,
|
||||
tareWeight: tare,
|
||||
maxPayloadWeight: maxPayload,
|
||||
status: WagonStatus.Available,
|
||||
});
|
||||
});
|
||||
await wagonRepo.save(rows);
|
||||
this.logger.log(`Seeded ${toCreate} wagons for type ${type.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed the default approval chains when the table is empty. */
|
||||
private async seedApprovalRules(manager: EntityManager) {
|
||||
const repo = manager.getRepository(ApprovalRule);
|
||||
const count = await repo.count();
|
||||
if (count > 0) {
|
||||
this.logger.log(`Approval rules already populated (${count}); skipping`);
|
||||
return;
|
||||
}
|
||||
await repo.save(DEFAULT_APPROVAL_RULE_ROWS.map((row) => repo.create(row)));
|
||||
this.logger.log(`Seeded ${DEFAULT_APPROVAL_RULE_ROWS.length} approval rules`);
|
||||
}
|
||||
|
||||
/** Create the four demo staff users with their roles (idempotent per email). */
|
||||
private async seedStaffUsers(manager: EntityManager) {
|
||||
const organization = await manager.getRepository(Organization).findOne({
|
||||
where: { key: EDR_ORG_KEY },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
if (!organization) {
|
||||
this.logger.warn(`Missing organization ${EDR_ORG_KEY}; skipping staff users`);
|
||||
return;
|
||||
}
|
||||
|
||||
const roleRepo = manager.getRepository(Role);
|
||||
const userRepo = manager.getRepository(User);
|
||||
const credentialRepo = manager.getRepository(UserCredential);
|
||||
const userRoleRepo = manager.getRepository(UserRole);
|
||||
const employeeRepo = manager.getRepository(Employee);
|
||||
|
||||
const password = process.env.DEFAULT_PASSWORD?.trim() || '12345678';
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
for (const staff of DEMO_STAFF_USERS) {
|
||||
const role = await roleRepo.findOne({
|
||||
where: { key: staff.roleKey },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
if (!role) {
|
||||
this.logger.warn(`Missing role ${staff.roleKey}; skipping ${staff.email}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let user = await userRepo.findOne({
|
||||
where: { email: staff.email },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
if (!user) {
|
||||
user = await userRepo.save(
|
||||
userRepo.create({
|
||||
email: staff.email,
|
||||
username: staff.username,
|
||||
name: { en: staff.username },
|
||||
isActive: true,
|
||||
hasSetPassword: true,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
}),
|
||||
);
|
||||
this.logger.log(`Seeded staff user ${staff.email}`);
|
||||
}
|
||||
|
||||
const hasCredential = await credentialRepo.exists({
|
||||
where: { userId: user.id, isActive: true },
|
||||
});
|
||||
if (!hasCredential) {
|
||||
await credentialRepo.insert({
|
||||
userId: user.id,
|
||||
password: hashedPassword,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
await userRoleRepo.upsert(
|
||||
{ userId: user.id, roleId: role.id, organizationId: organization.id },
|
||||
{ conflictPaths: { userId: true, roleId: true } },
|
||||
);
|
||||
|
||||
const hasEmployee = await employeeRepo.exists({
|
||||
where: { userId: user.id, organizationId: organization.id, isCurrent: true },
|
||||
});
|
||||
if (!hasEmployee) {
|
||||
await employeeRepo.insert({
|
||||
userId: user.id,
|
||||
organizationId: organization.id,
|
||||
isCurrent: true,
|
||||
name: { en: staff.username },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('Ensured demo staff users (marketing@, operations@, director@, ceo@)');
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,11 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
||||
name: { en: "EDR Line Staff" },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff],
|
||||
},
|
||||
{
|
||||
key: "edr_operations_officer",
|
||||
name: { en: "EDR Operations Officer" },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsOfficer],
|
||||
},
|
||||
{
|
||||
key: "edr_director",
|
||||
name: { en: "EDR Director" },
|
||||
|
||||
@@ -54,6 +54,9 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'),
|
||||
perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
|
||||
perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
|
||||
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
|
||||
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
|
||||
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
|
||||
];
|
||||
|
||||
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
||||
@@ -109,6 +112,11 @@ export const FREIGHT_PERMS = {
|
||||
view: 'edr_freight_app:train_scheduling:view',
|
||||
manage: 'edr_freight_app:train_scheduling:manage',
|
||||
},
|
||||
fleet: {
|
||||
view: 'edr_freight_app:fleet:view',
|
||||
manage: 'edr_freight_app:fleet:manage',
|
||||
},
|
||||
admin: 'edr_freight_app:admin',
|
||||
ruleEngine: {
|
||||
view: (slug: RuleEngineResourceSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
|
||||
@@ -121,6 +129,9 @@ const allRuleEngineViewKeys = () =>
|
||||
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
|
||||
|
||||
export const ROLE_PERMISSION_PRESETS = {
|
||||
// Marketing / line staff: drives a booking from intake through line-staff
|
||||
// approval and contract generation/signing — i.e. until the contract is ready
|
||||
// and signed. No director/CEO approval, no scheduling, no operations.
|
||||
lineStaff: [
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.staffAccept,
|
||||
@@ -129,8 +140,17 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
FREIGHT_PERMS.bookings.cancel,
|
||||
...allRuleEngineViewKeys(),
|
||||
],
|
||||
// Operations Officer: train scheduling + wagon allocation + transit/complete
|
||||
// + fleet management (wagons, trains, locomotives, routes, containers, cargo).
|
||||
operationsOfficer: [
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.operations,
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.trainScheduling.manage,
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
FREIGHT_PERMS.fleet.manage,
|
||||
...allRuleEngineViewKeys(),
|
||||
],
|
||||
director: [
|
||||
@@ -147,8 +167,15 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
...allRuleEngineViewKeys(),
|
||||
],
|
||||
finance: [FREIGHT_PERMS.bookings.view],
|
||||
// Marketing handles intake through contract (same as line staff here).
|
||||
marketing: [
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.staffAccept,
|
||||
FREIGHT_PERMS.bookings.requestChanges,
|
||||
FREIGHT_PERMS.bookings.reject,
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
FREIGHT_PERMS.bookings.cancel,
|
||||
FREIGHT_PERMS.bookings.generateContract,
|
||||
FREIGHT_PERMS.bookings.signStaff,
|
||||
],
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Container,
|
||||
Package,
|
||||
Users,
|
||||
Wallet,
|
||||
//TrainTrack,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -23,6 +24,7 @@ import LoginPage from "./pages/auth/LoginPage";
|
||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage";
|
||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||
@@ -47,6 +49,8 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
|
||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||
|
||||
@@ -70,6 +74,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
},
|
||||
{
|
||||
label: "Payments",
|
||||
href: "/dashboard/payments",
|
||||
icon: <Wallet />,
|
||||
permission: FREIGHT_PERMS.bookings.view,
|
||||
},
|
||||
...demoItems,
|
||||
],
|
||||
},
|
||||
@@ -80,11 +90,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Train Schedules",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
},
|
||||
{
|
||||
label: "Batch Board",
|
||||
href: "/dashboard/operations/batch-board",
|
||||
icon: <LayoutGrid />,
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -95,11 +107,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Routes",
|
||||
href: "/dashboard/routes",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Locomotives",
|
||||
href: "/dashboard/locomotives",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
// {
|
||||
// label: "Trains",
|
||||
@@ -115,6 +129,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Wagons",
|
||||
href: "/dashboard/wagons",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
// {
|
||||
// label: "Containers",
|
||||
@@ -135,6 +150,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "User management",
|
||||
href: "/dashboard/user-management",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
children: [
|
||||
{
|
||||
label: "Users",
|
||||
@@ -162,11 +178,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "File settings",
|
||||
href: "/dashboard/file-settings",
|
||||
icon: <Paperclip />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
{
|
||||
label: "Dropdown settings",
|
||||
href: "/dashboard/dropdown-settings",
|
||||
icon: <Settings />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -196,18 +214,25 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
},
|
||||
];
|
||||
|
||||
const hasPermission = (
|
||||
/** Keep only items the user is permitted to see; drop now-empty sections. */
|
||||
const filterSidebarByPermission = (
|
||||
sections: SidebarSection[],
|
||||
user: ReturnType<typeof useAuth>["user"],
|
||||
key: string,
|
||||
) => {
|
||||
if (!user) return false;
|
||||
if (user.permissions?.some((p) => p.key === key)) return true;
|
||||
): SidebarSection[] => {
|
||||
const itemAllowed = (item: SidebarItem): boolean => {
|
||||
if (!item.permission) return true;
|
||||
const keys = Array.isArray(item.permission)
|
||||
? item.permission
|
||||
: [item.permission];
|
||||
return keys.some((key) => hasFreightPermission(user, key));
|
||||
};
|
||||
|
||||
return (user.employee ?? []).some((emp) =>
|
||||
(emp.positions ?? []).some((pos) =>
|
||||
(pos.permissions ?? []).some((p) => p.key === key),
|
||||
),
|
||||
);
|
||||
return sections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter(itemAllowed),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
};
|
||||
|
||||
const DashboardShell = () => {
|
||||
@@ -217,7 +242,10 @@ const DashboardShell = () => {
|
||||
|
||||
const demoItems: SidebarItem[] = [];
|
||||
|
||||
const sidebarSections = buildSidebarSections(demoItems);
|
||||
const sidebarSections = filterSidebarByPermission(
|
||||
buildSidebarSections(demoItems),
|
||||
user,
|
||||
);
|
||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||
|
||||
return (
|
||||
@@ -261,6 +289,14 @@ const App = () => {
|
||||
<Route path="profile" element={<MyProfilePage />} />
|
||||
|
||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||
<Route
|
||||
path="payments"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
|
||||
<PaymentsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||
<Route
|
||||
@@ -271,30 +307,102 @@ const App = () => {
|
||||
path="operations/train-scheduling"
|
||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
||||
/>
|
||||
<Route path="operations/batch-board" element={<BatchBoardPage />} />
|
||||
<Route
|
||||
path="operations/batch-board"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<BatchBoardPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/batch-board/:scheduleId"
|
||||
element={<BatchScheduleDetailPage />}
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<BatchScheduleDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2"
|
||||
element={<TrainScheduleV2ListPage />}
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleV2ListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId"
|
||||
element={<TrainScheduleV2DetailPage />}
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleV2DetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId/track"
|
||||
element={<TrainScheduleTrackPage />}
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleTrackPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="routes" element={<RoutesPage />} />
|
||||
<Route path="locomotives" element={<FleetResourcePage />} />
|
||||
<Route path="trains" element={<FleetResourcePage />} />
|
||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||
<Route path="wagons" element={<FleetResourcePage />} />
|
||||
<Route path="containers" element={<FleetResourcePage />} />
|
||||
<Route path="cargoes" element={<FleetResourcePage />} />
|
||||
|
||||
{/* iframe-based user management module */}
|
||||
<Route path="um/*" element={<UserManagementHostPage />} />
|
||||
@@ -307,8 +415,22 @@ const App = () => {
|
||||
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
||||
<Route path="user-management/roles" element={<RolesPage />} />
|
||||
|
||||
<Route path="file-settings" element={<FileUploadSettingsPage />} />
|
||||
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
|
||||
<Route
|
||||
path="file-settings"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<FileUploadSettingsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="dropdown-settings"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<DropdownSettingsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="configuration"
|
||||
@@ -316,7 +438,11 @@ const App = () => {
|
||||
/>
|
||||
<Route
|
||||
path="configuration/train-scheduling-rules"
|
||||
element={<TrainSchedulingGlobalRulesPage />}
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainSchedulingGlobalRulesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import {
|
||||
AUTH_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
@@ -16,7 +17,7 @@ type RetriableRequest = {
|
||||
};
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${import.meta.env.VITE_BASE_API_URL}/api`,
|
||||
baseURL: `${API_BASE_URL}/api`,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
|
||||
interface RequirePermissionProps {
|
||||
/** Permission key(s); access is granted if the user has ANY of them. */
|
||||
permission: string | string[];
|
||||
/** Where to send users who lack the permission. */
|
||||
redirectTo?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-level guard: renders children only when the current user holds one of
|
||||
* the given permissions, otherwise redirects (default: overview).
|
||||
*/
|
||||
export function RequirePermission({
|
||||
permission,
|
||||
redirectTo = "/dashboard/overview",
|
||||
children,
|
||||
}: RequirePermissionProps) {
|
||||
const { user } = useAuth();
|
||||
const keys = Array.isArray(permission) ? permission : [permission];
|
||||
const allowed = keys.some((key) => hasPermission(user, key));
|
||||
|
||||
if (!allowed) return <Navigate to={redirectTo} replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { BookingActionsMenu } from "./BookingActionsMenu";
|
||||
import { SectionCard } from "./detail/SectionCard";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canManageScheduling } from "@/lib/permissions";
|
||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
|
||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||
@@ -19,9 +21,11 @@ interface BookingActionsToolbarProps {
|
||||
|
||||
/** Detail-page actions: primary toolbar + downloads. */
|
||||
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
||||
const { user } = useAuth();
|
||||
const row = toBookingListRow(booking);
|
||||
const { status } = booking;
|
||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||
const canAllocate = canManageScheduling(user);
|
||||
|
||||
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
||||
const blob = await fn();
|
||||
@@ -127,7 +131,7 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{canAllocateBooking(booking) ? (
|
||||
{canAllocate && canAllocateBooking(booking) ? (
|
||||
<AllocateBookingWizard
|
||||
booking={booking}
|
||||
opened={allocateOpen}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Building2,
|
||||
FileCheck,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Group, Stack, Text, Divider } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
interface InfoRowProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Icon size={15} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export interface BookingCompanyCardProps {
|
||||
booking: BookingDetail;
|
||||
}
|
||||
|
||||
/** Customer (company) information for the booking. */
|
||||
export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
|
||||
const company = booking.company;
|
||||
|
||||
// Government bookings may not carry a company; show the institution instead.
|
||||
if (!company && booking.isGovernment) {
|
||||
return (
|
||||
<SectionCard icon={Building2} title="Customer" accent="blue">
|
||||
<InfoRow
|
||||
icon={Building2}
|
||||
label="Government"
|
||||
value={booking.governmentInstitution}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!company) {
|
||||
return (
|
||||
<SectionCard icon={Building2} title="Customer" accent="blue">
|
||||
<Text size="sm" c="dimmed">
|
||||
No customer linked to this booking.
|
||||
</Text>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const companyName = company.companyName ?? company.name ?? company.label;
|
||||
|
||||
const rows: InfoRowProps[] = [
|
||||
{ icon: FileCheck, label: "TIN", value: company.tin },
|
||||
{ icon: Mail, label: "Email", value: company.email },
|
||||
{ icon: Phone, label: "Phone", value: company.phone },
|
||||
{ icon: MapPin, label: "Address", value: company.address },
|
||||
{ icon: User, label: "Contact person", value: company.contactPersonName },
|
||||
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
|
||||
].filter((r) => r.value);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={Building2}
|
||||
title="Customer"
|
||||
subtitle={companyName}
|
||||
accent="blue"
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{rows.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No additional company details available.
|
||||
</Text>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<div key={row.label}>
|
||||
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
|
||||
<InfoRow {...row} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -122,6 +122,7 @@ export interface BookingFileView {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface BookingDetailView {
|
||||
|
||||
@@ -17,3 +17,4 @@ export * from "./BookingRouteServiceCard";
|
||||
export * from "./BookingMileServicesCard";
|
||||
export * from "./BookingCargoCard";
|
||||
export * from "./BookingContractSummaryCard";
|
||||
export * from "./BookingCompanyCard";
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { API_BASE_URL } from '@/constants/apiConfig';
|
||||
|
||||
interface Cargo {
|
||||
id: string;
|
||||
@@ -32,8 +33,6 @@ interface CargoFormDialogProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function CargoFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
||||
@@ -43,6 +43,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Manage your account and signature",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/payments",
|
||||
meta: {
|
||||
title: "Payments",
|
||||
subtitle: "View booking payment transactions",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/operations/train-scheduling-v2/",
|
||||
meta: {
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface SidebarItem {
|
||||
href?: string;
|
||||
icon?: ReactNode;
|
||||
children?: SidebarItem[];
|
||||
/** Permission key(s) required to see this item; ANY grants access. */
|
||||
permission?: string | string[];
|
||||
}
|
||||
|
||||
export interface SidebarSection {
|
||||
|
||||
@@ -124,6 +124,11 @@ export const URL_CONSTANTS = {
|
||||
VERIFY: "/api/otp/verify",
|
||||
},
|
||||
|
||||
PAYMENTS: {
|
||||
ALL: "/payments/all",
|
||||
SUMMARY: "/payments/summary",
|
||||
},
|
||||
|
||||
LOCOMOTIVES: {
|
||||
BASE: "/locomotives",
|
||||
BY_ID: (id: string) => `/locomotives/${id}`,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
|
||||
// export const API_BASE_URL = 'http://localhost:3001';
|
||||
@@ -201,6 +201,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
||||
startTransit: FREIGHT_PERMS.bookings.operations,
|
||||
complete: FREIGHT_PERMS.bookings.operations,
|
||||
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
|
||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||
};
|
||||
|
||||
|
||||
23
apps/edr-freight-web/backoffice/src/hooks/usePayments.ts
Normal file
23
apps/edr-freight-web/backoffice/src/hooks/usePayments.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
paymentsService,
|
||||
type PaymentListFilter,
|
||||
} from "@/services/payments.service";
|
||||
|
||||
export function usePaymentList(filter?: PaymentListFilter, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["payments", "list", filter ?? {}],
|
||||
queryFn: () => paymentsService.list(filter),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePaymentSummary(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["payments", "summary"],
|
||||
queryFn: () => paymentsService.getSummary(),
|
||||
staleTime: 30_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,15 @@ export const FREIGHT_PERMS = {
|
||||
operations: "edr_freight_app:bookings:operations",
|
||||
cancel: "edr_freight_app:bookings:cancel",
|
||||
},
|
||||
trainScheduling: {
|
||||
view: "edr_freight_app:train_scheduling:view",
|
||||
manage: "edr_freight_app:train_scheduling:manage",
|
||||
},
|
||||
fleet: {
|
||||
view: "edr_freight_app:fleet:view",
|
||||
manage: "edr_freight_app:fleet:manage",
|
||||
},
|
||||
admin: "edr_freight_app:admin",
|
||||
} as const;
|
||||
|
||||
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
||||
@@ -70,6 +79,22 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.bookings.view);
|
||||
}
|
||||
|
||||
export function canViewScheduling(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
|
||||
}
|
||||
|
||||
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.trainScheduling.manage);
|
||||
}
|
||||
|
||||
export function canViewFleet(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.fleet.view);
|
||||
}
|
||||
|
||||
export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.admin);
|
||||
}
|
||||
|
||||
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
|
||||
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
|
||||
}
|
||||
|
||||
@@ -50,11 +50,8 @@ export default function BookingContractPage() {
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const signRole: "CUSTOMER" | "STAFF" | null = data?.canSignCustomer
|
||||
? "CUSTOMER"
|
||||
: data?.canSignStaff
|
||||
? "STAFF"
|
||||
: null;
|
||||
// Backoffice only ever signs as STAFF — customers sign in the portal.
|
||||
const canSign = Boolean(data?.canSignStaff);
|
||||
|
||||
const savedSignature = data?.savedSignature ?? null;
|
||||
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||
@@ -106,12 +103,12 @@ export default function BookingContractPage() {
|
||||
};
|
||||
|
||||
const confirmSign = () => {
|
||||
if (!signRole || !signerName.trim()) return;
|
||||
if (!canSign || !signerName.trim()) return;
|
||||
// Approve the saved signature, or submit the freshly drawn one.
|
||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||
if (!image) return;
|
||||
signMutation.mutate({
|
||||
role: signRole,
|
||||
role: "STAFF",
|
||||
signatureImageBase64: image,
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I agree to the terms of this contract.",
|
||||
@@ -167,10 +164,10 @@ export default function BookingContractPage() {
|
||||
<Download className="size-4" />
|
||||
Download PDF
|
||||
</Button>
|
||||
{signRole && (
|
||||
{canSign && (
|
||||
<Button size="sm" className="gap-2" onClick={openSign}>
|
||||
<FileSignature className="size-4" />
|
||||
Sign as {signRole === "CUSTOMER" ? "Customer" : "Staff"}
|
||||
{usingSaved ? "Approve & sign" : "Sign contract"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -194,9 +191,7 @@ export default function BookingContractPage() {
|
||||
<Dialog open={signOpen} onOpenChange={setSignOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"}
|
||||
</DialogTitle>
|
||||
<DialogTitle>Staff signature</DialogTitle>
|
||||
<DialogDescription>
|
||||
{usingSaved
|
||||
? `Review your saved signature and approve it to execute the contract for ${data.reference}.`
|
||||
|
||||
@@ -24,11 +24,25 @@ import {
|
||||
BookingRouteServiceCard,
|
||||
BookingMileServicesCard,
|
||||
BookingCargoCard,
|
||||
BookingCompanyCard,
|
||||
BookingContractSummaryCard,
|
||||
BookingDocumentsCard,
|
||||
type BookingFileView,
|
||||
} from "@/components/bookings/detail";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// Signature / generated-contract files are surfaced on the contract page, not
|
||||
// in the booking's Documents list.
|
||||
const SIGNATURE_FILE_CODES = new Set([
|
||||
"signature",
|
||||
"signature_customer",
|
||||
"signature_staff",
|
||||
"contract",
|
||||
]);
|
||||
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -36,6 +50,14 @@ export default function BookingRequestDetailPage() {
|
||||
const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
|
||||
const mutations = useBookingMutations(id ?? "");
|
||||
|
||||
const handleDownloadFile = async (file: BookingFileView) => {
|
||||
try {
|
||||
await downloadBookingFile(file.id, file.name);
|
||||
} catch {
|
||||
toast.error("Could not download file.");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box style={detailStyles.page}>
|
||||
@@ -147,6 +169,12 @@ export default function BookingRequestDetailPage() {
|
||||
{booking.contractSummary && (
|
||||
<BookingContractSummaryCard summary={booking.contractSummary} />
|
||||
)}
|
||||
<BookingDocumentsCard
|
||||
files={(booking.files ?? []).filter(
|
||||
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
|
||||
)}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -154,6 +182,7 @@ export default function BookingRequestDetailPage() {
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingCompanyCard booking={booking} />
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<BookingActionsToolbar booking={booking} mutations={mutations} />
|
||||
{showContractButton && (
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Search,
|
||||
X,
|
||||
XCircle,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
|
||||
import type {
|
||||
PaymentMethod,
|
||||
PaymentRow,
|
||||
} from "@/services/payments.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Badge,
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
type ColumnDef,
|
||||
usePagination,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: "all", label: "All", statuses: undefined as string | undefined },
|
||||
{ key: "success", label: "Success", statuses: "success" },
|
||||
{ key: "processing", label: "Processing", statuses: "processing,action-required" },
|
||||
{ key: "failed", label: "Failed", statuses: "failed,canceled" },
|
||||
{ key: "refunded", label: "Refunded", statuses: "refunded" },
|
||||
] as const;
|
||||
|
||||
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
|
||||
|
||||
const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
|
||||
{ value: "telebirr", label: "Telebirr" },
|
||||
{ value: "waafi", label: "Waafi" },
|
||||
{ value: "cbe-birr", label: "CBE Birr" },
|
||||
{ value: "ebirr", label: "E-Birr" },
|
||||
{ value: "card", label: "Card" },
|
||||
{ value: "dmoney", label: "D-Money" },
|
||||
{ value: "cac-bank", label: "CAC Bank" },
|
||||
];
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
success: "green",
|
||||
processing: "yellow",
|
||||
"action-required": "yellow",
|
||||
failed: "red",
|
||||
canceled: "gray",
|
||||
refunded: "indigo",
|
||||
};
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
accent,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string | number;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
style={{
|
||||
flex: "1 1 180px",
|
||||
minWidth: 160,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 11,
|
||||
background: `var(--mantine-color-${accent}-1)`,
|
||||
color: `var(--mantine-color-${accent}-7)`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</Box>
|
||||
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} c="dimmed" truncate>
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, currency: string): string {
|
||||
return `${currency} ${Number(amount).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
const tableHeader = "text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusTab, setStatusTab] = useState<StatusTabKey>("all");
|
||||
const [method, setMethod] = useState<string | null>(null);
|
||||
|
||||
const statuses = STATUS_TABS.find((t) => t.key === statusTab)?.statuses;
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
search: query.trim() || undefined,
|
||||
status: statuses,
|
||||
method: method ?? undefined,
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
}),
|
||||
[query, statuses, method, pagination.pageIndex, pagination.pageSize],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = usePaymentList(filter);
|
||||
const { data: summary, isLoading: summaryLoading } = usePaymentSummary();
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
|
||||
|
||||
const columns: ColumnDef<PaymentRow>[] = [
|
||||
{
|
||||
id: "order",
|
||||
header: () => <span className={tableHeader}>Order</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-0 py-1">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{row.original.merchantOrderId ?? row.original.id.slice(0, 8)}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
Booking {row.original.bookingId?.slice(0, 8) ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
header: () => <span className={tableHeader}>Amount</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
|
||||
{formatAmount(row.original.amount, row.original.currency)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "method",
|
||||
header: () => <span className={tableHeader}>Method</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" radius="sm">
|
||||
{METHOD_OPTIONS.find((m) => m.value === row.original.method)?.label ??
|
||||
row.original.method}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={tableHeader}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
color={STATUS_COLORS[row.original.status] ?? "gray"}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
tt="capitalize"
|
||||
>
|
||||
{row.original.status.replace(/-/g, " ")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "date",
|
||||
header: () => <span className={tableHeader}>Date</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDate(row.original.paidAt ?? row.original.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
|
||||
<Container size="xxl" py="xl">
|
||||
<Breadcrumbs items={[{ label: "Operations" }, { label: "Payments" }]} />
|
||||
|
||||
<Stack gap="lg" mt="md">
|
||||
<Group grow gap="md" align="stretch" wrap="wrap">
|
||||
<StatCard
|
||||
icon={CircleDollarSign}
|
||||
label="Total collected"
|
||||
value={
|
||||
summaryLoading
|
||||
? "—"
|
||||
: `ETB ${Number(summary?.paidAmount ?? 0).toLocaleString()}`
|
||||
}
|
||||
accent="teal"
|
||||
/>
|
||||
<StatCard
|
||||
icon={CheckCircle2}
|
||||
label="Successful"
|
||||
value={val(summary?.success)}
|
||||
accent="green"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Loader2}
|
||||
label="Processing"
|
||||
value={val(summary?.processing)}
|
||||
accent="yellow"
|
||||
/>
|
||||
<StatCard
|
||||
icon={XCircle}
|
||||
label="Failed"
|
||||
value={val(summary?.failed)}
|
||||
accent="red"
|
||||
/>
|
||||
<StatCard
|
||||
icon={RotateCcw}
|
||||
label="Refunded"
|
||||
value={val(summary?.refunded)}
|
||||
accent="indigo"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Tabs
|
||||
value={statusTab}
|
||||
onChange={(value) => {
|
||||
setStatusTab((value as StatusTabKey) ?? "all");
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
{STATUS_TABS.map((t) => (
|
||||
<Tabs.Tab key={t.key} value={t.key}>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<Card
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ background: "white", border: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search order, booking, or transaction…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
rightSection={
|
||||
query && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Select
|
||||
placeholder="All methods"
|
||||
clearable
|
||||
data={METHOD_OPTIONS}
|
||||
value={method}
|
||||
onChange={(value) => {
|
||||
setMethod(value);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
radius="lg"
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName={cn(
|
||||
"border-0 shadow-none",
|
||||
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
|
||||
"[&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
|
||||
)}
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { api as client } from "../auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
|
||||
const F = URL_CONSTANTS.FILES;
|
||||
|
||||
export const filesService = {
|
||||
/** Stream a stored file by id (backend route: GET /files/:id). */
|
||||
download: async (id: string): Promise<Blob> => {
|
||||
const response = await client.get(F.BY_ID(id), { responseType: "blob" });
|
||||
return response.data as Blob;
|
||||
},
|
||||
};
|
||||
|
||||
/** Download a file blob and trigger a browser save with the given name. */
|
||||
export async function downloadBookingFile(
|
||||
id: string,
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
const blob = await filesService.download(id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
|
||||
const P = URL_CONSTANTS.PAYMENTS;
|
||||
|
||||
export type PaymentStatus =
|
||||
| "action-required"
|
||||
| "processing"
|
||||
| "success"
|
||||
| "failed"
|
||||
| "canceled"
|
||||
| "refunded";
|
||||
|
||||
export type PaymentMethod =
|
||||
| "telebirr"
|
||||
| "cbe-birr"
|
||||
| "ebirr"
|
||||
| "waafi"
|
||||
| "card"
|
||||
| "dmoney"
|
||||
| "cac-bank";
|
||||
|
||||
export interface PaymentRow {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
method: PaymentMethod;
|
||||
status: PaymentStatus;
|
||||
merchantOrderId: string | null;
|
||||
paidAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PaymentListFilter {
|
||||
search?: string;
|
||||
status?: string;
|
||||
method?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedPayments {
|
||||
items: PaymentRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface PaymentSummary {
|
||||
total: number;
|
||||
success: number;
|
||||
processing: number;
|
||||
failed: number;
|
||||
refunded: number;
|
||||
paidAmount: number;
|
||||
}
|
||||
|
||||
export const paymentsService = {
|
||||
list: async (filter?: PaymentListFilter): Promise<PaginatedPayments> => {
|
||||
const params: Record<string, string | number | undefined> = {};
|
||||
if (filter) {
|
||||
if (filter.search) params.search = filter.search;
|
||||
if (filter.status) params.status = filter.status;
|
||||
if (filter.method) params.method = filter.method;
|
||||
if (filter.page != null) params.page = filter.page;
|
||||
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
||||
}
|
||||
const response = await client.get<PaginatedPayments>(P.ALL, { params });
|
||||
const data = unwrap(response.data) as PaginatedPayments;
|
||||
return {
|
||||
items: data.items ?? [],
|
||||
total: data.total ?? 0,
|
||||
page: data.page ?? 1,
|
||||
pageSize: data.pageSize ?? 10,
|
||||
};
|
||||
},
|
||||
|
||||
getSummary: async (): Promise<PaymentSummary> => {
|
||||
const response = await client.get<PaymentSummary>(P.SUMMARY);
|
||||
return unwrap(response.data) as PaymentSummary;
|
||||
},
|
||||
};
|
||||
@@ -30,6 +30,27 @@ export interface BookingNamedRef {
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
/** Full company record the booking response joins in (subset used by the UI). */
|
||||
export interface BookingCompany {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
tin?: string | null;
|
||||
vatNumber?: string | null;
|
||||
businessLicense?: string | null;
|
||||
country?: string | null;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
contactPersonName?: string | null;
|
||||
contactPersonPhone?: string | null;
|
||||
generalManagerName?: string | null;
|
||||
generalManagerEmail?: string | null;
|
||||
generalManagerPhone?: string | null;
|
||||
website?: string | null;
|
||||
}
|
||||
|
||||
export interface BookingContainerLine {
|
||||
id: string;
|
||||
containerTypeId: string;
|
||||
@@ -81,6 +102,8 @@ export interface BookingFile {
|
||||
name: string;
|
||||
mimeType?: string;
|
||||
code?: string;
|
||||
url?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface BookingDetail {
|
||||
@@ -121,7 +144,7 @@ export interface BookingDetail {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
// customer?: BookingNamedRef & { companyName?: string };
|
||||
company?: BookingNamedRef;
|
||||
company?: BookingNamedRef & Partial<BookingCompany>;
|
||||
originYard?: BookingNamedRef;
|
||||
destinationYard?: BookingNamedRef;
|
||||
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number };
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import type { ViteDevServer, PreviewServer } from "vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function userManagementSpaFallback() {
|
||||
const rewrite = (req) => {
|
||||
const url = req.url || '';
|
||||
const rewrite = (req: IncomingMessage) => {
|
||||
const url = req.url ?? '';
|
||||
if (!url.startsWith('/_um/') && url !== '/_um') return;
|
||||
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return;
|
||||
req.url = '/_um/index.html';
|
||||
};
|
||||
return {
|
||||
name: 'user-management-spa-fallback',
|
||||
configureServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); },
|
||||
configurePreviewServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); },
|
||||
configureServer(s: ViteDevServer) {
|
||||
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
|
||||
rewrite(req);
|
||||
next();
|
||||
});
|
||||
|
||||
},
|
||||
configurePreviewServer(s: PreviewServer) {
|
||||
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
|
||||
rewrite(req);
|
||||
next();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +42,11 @@ export default defineConfig({
|
||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
||||
},
|
||||
// Force a single copy of these singletons so MantineProvider context is
|
||||
// shared between the backoffice app and @edr/ui-common (which ships its
|
||||
// own node_modules copy). Without this, two separate @mantine/core
|
||||
// instances are bundled and the context lookup fails at runtime.
|
||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||
},
|
||||
server: {
|
||||
port: 5183,
|
||||
|
||||
@@ -22,6 +22,7 @@ import useAuth from "./hooks/useAuth";
|
||||
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
||||
import MyPortalPage from "./pages/MyPortalPage";
|
||||
import ProfilePage from "./pages/ProfilePage";
|
||||
import MySignaturePage from "./pages/MySignaturePage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import LoginPage from "./pages/accounts/LoginPage";
|
||||
import OnboardingPage from "./pages/accounts/OnboardingPage";
|
||||
@@ -201,6 +202,7 @@ const App = () => {
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<BillingPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/signature" element={<MySignaturePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
FileSignature,
|
||||
LogOut,
|
||||
Menu as MenuIcon,
|
||||
Moon,
|
||||
@@ -312,6 +313,12 @@ export function AppLayout({
|
||||
>
|
||||
Profile
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<FileSignature size={15} />}
|
||||
onClick={() => navigate("/signature")}
|
||||
>
|
||||
My signature
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Settings size={15} />}
|
||||
onClick={() => navigate("/settings")}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState } from "react";
|
||||
import { FileSignature, Loader2 } from "lucide-react";
|
||||
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import {
|
||||
useMySignature,
|
||||
useSaveSignature,
|
||||
} from "@/hooks/useSavedSignature";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Lets the signed-in customer view and update the reusable signature stored on
|
||||
* their profile. The same signature is offered for approval when signing a
|
||||
* booking contract.
|
||||
*/
|
||||
export function MySignatureCard() {
|
||||
const { user } = useAuth();
|
||||
const { data: saved, isPending } = useMySignature();
|
||||
const saveMutation = useSaveSignature();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
|
||||
const defaultName = user?.name?.en || user?.username || user?.email || "";
|
||||
|
||||
const openDialog = () => {
|
||||
setSignerName(saved?.signerDisplayName ?? defaultName);
|
||||
setSignatureData(null);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!signatureData || !signerName.trim()) return;
|
||||
saveMutation.mutate(
|
||||
{
|
||||
signerDisplayName: signerName.trim(),
|
||||
signatureImageBase64: signatureData,
|
||||
},
|
||||
{ onSuccess: () => setOpen(false) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileSignature className="size-5 text-primary" />
|
||||
My signature
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Reused to approve and sign booking contracts.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{isPending ? (
|
||||
<div className="flex h-36 items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : saved?.signatureImageUrl ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||
<img
|
||||
src={saved.signatureImageUrl}
|
||||
alt="My saved signature"
|
||||
className="mx-auto h-36 w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved as {saved.signerDisplayName}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You have not saved a signature yet.
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={openDialog}>
|
||||
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save your signature</DialogTitle>
|
||||
<DialogDescription>
|
||||
Draw your signature below. It will be stored on your profile for
|
||||
future contracts.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profileSignerName">Full name</Label>
|
||||
<Input
|
||||
id="profileSignerName"
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.target.value)}
|
||||
placeholder="As shown on contracts"
|
||||
/>
|
||||
</div>
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
saveMutation.isPending || !signatureData || !signerName.trim()
|
||||
}
|
||||
onClick={save}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Save signature"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
3
apps/edr-freight-web/portal/src/constants/apiConfig.ts
Normal file
3
apps/edr-freight-web/portal/src/constants/apiConfig.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
// export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
30
apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts
Normal file
30
apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
signaturesService,
|
||||
type SaveSignaturePayload,
|
||||
} from "@/services/signatures.service";
|
||||
|
||||
const SAVED_SIGNATURE_KEY = ["me", "signature"] as const;
|
||||
|
||||
export function useMySignature() {
|
||||
return useQuery({
|
||||
queryKey: SAVED_SIGNATURE_KEY,
|
||||
queryFn: () => signaturesService.getMySignature(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveSignature() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: SaveSignaturePayload) =>
|
||||
signaturesService.saveMySignature(payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Signature saved");
|
||||
void qc.invalidateQueries({ queryKey: SAVED_SIGNATURE_KEY });
|
||||
},
|
||||
onError: () => toast.error("Failed to save signature"),
|
||||
});
|
||||
}
|
||||
19
apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx
Normal file
19
apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { MySignatureCard } from "@/components/profile/MySignatureCard";
|
||||
|
||||
export default function MySignaturePage() {
|
||||
return (
|
||||
<div className="px-4 py-8">
|
||||
<div className="mx-auto flex max-w-md flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-tight text-foreground">
|
||||
My signature
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Saved and reused to approve and sign booking contracts.
|
||||
</p>
|
||||
</div>
|
||||
<MySignatureCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,9 @@ export default function BookingContractPage() {
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
// When a saved signature exists we offer it for approval first; the customer
|
||||
// can switch to drawing a fresh one.
|
||||
const [drawNew, setDrawNew] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["booking-contract-view", id],
|
||||
@@ -32,6 +35,31 @@ export default function BookingContractPage() {
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const savedSignature = data?.savedSignature ?? null;
|
||||
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||
|
||||
const openSign = () => {
|
||||
// Prefill from the saved signature so the customer only has to approve it.
|
||||
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||
setSignatureData(null);
|
||||
setDrawNew(false);
|
||||
setSignOpen(true);
|
||||
};
|
||||
|
||||
const confirmSign = () => {
|
||||
if (!signerName.trim()) return;
|
||||
// Approve the saved signature, or submit the freshly drawn one.
|
||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||
if (!image) return;
|
||||
signMutation.mutate({
|
||||
role: "CUSTOMER",
|
||||
signatureImageBase64: image,
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I agree to the terms of this contract.",
|
||||
});
|
||||
};
|
||||
|
||||
const signMutation = useMutation({
|
||||
mutationFn: (payload: SignContractPayload) =>
|
||||
bookingsService.signContract(id!, payload),
|
||||
@@ -102,9 +130,9 @@ export default function BookingContractPage() {
|
||||
PDF
|
||||
</Button>
|
||||
{data.canSignCustomer && (
|
||||
<Button size="sm" onClick={() => setSignOpen(true)}>
|
||||
<Button size="sm" onClick={openSign}>
|
||||
<FileSignature className="mr-2 size-4" />
|
||||
Sign contract
|
||||
{usingSaved ? "Approve & sign" : "Sign contract"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -122,9 +150,13 @@ export default function BookingContractPage() {
|
||||
{signOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 print:hidden">
|
||||
<div className="w-full max-w-md rounded-xl bg-background p-6 shadow-xl">
|
||||
<h2 className="text-lg font-semibold">Sign contract</h2>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{usingSaved ? "Approve signature" : "Sign contract"}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{data.reference} — your signature will be stored securely.
|
||||
{usingSaved
|
||||
? `${data.reference} — review your saved signature and approve it.`
|
||||
: `${data.reference} — your signature will be stored securely.`}
|
||||
</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="text-sm font-medium" htmlFor="portalSigner">
|
||||
@@ -136,7 +168,29 @@ export default function BookingContractPage() {
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.target.value)}
|
||||
/>
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
{usingSaved ? (
|
||||
<div className="space-y-2">
|
||||
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||
<img
|
||||
src={savedSignatureImage ?? undefined}
|
||||
alt="Saved signature"
|
||||
className="mx-auto h-36 w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary underline"
|
||||
onClick={() => {
|
||||
setDrawNew(true);
|
||||
setSignatureData(null);
|
||||
}}
|
||||
>
|
||||
Draw a new signature instead
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||
@@ -145,19 +199,12 @@ export default function BookingContractPage() {
|
||||
<Button
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
!signatureData ||
|
||||
(!usingSaved && !signatureData) ||
|
||||
!signerName.trim()
|
||||
}
|
||||
onClick={() =>
|
||||
signMutation.mutate({
|
||||
role: "CUSTOMER",
|
||||
signatureImageBase64: signatureData!,
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I agree to the terms of this contract.",
|
||||
})
|
||||
}
|
||||
onClick={confirmSign}
|
||||
>
|
||||
Confirm signature
|
||||
{usingSaved ? "Approve & sign" : "Confirm signature"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,17 +28,18 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
const status = booking.status as string;
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
|
||||
// Two-step flow: POST /payments/initiate to create the intent, then send the
|
||||
// browser to the public /payments/checkout page which redirects to the
|
||||
// selected provider to complete payment.
|
||||
// POST /payments/initiate creates the intent and returns the provider's
|
||||
// redirect URL (clientAction.url). Send the browser straight there; fall back
|
||||
// to the public /payments/checkout page if no redirect URL came back.
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) =>
|
||||
api.payments.initiate.call({ bookingId: booking.id, method }),
|
||||
onSuccess: (_data, method) => {
|
||||
window.location.href = paymentsService.checkoutUrl({
|
||||
bookingId: booking.id,
|
||||
method,
|
||||
});
|
||||
onSuccess: (data, method) => {
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrl({ bookingId: booking.id, method });
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
Banknote,
|
||||
Building2,
|
||||
CreditCard,
|
||||
Smartphone,
|
||||
Wallet,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Smartphone, type LucideIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { PaymentMethod } from "@/services/payments.service";
|
||||
@@ -18,6 +11,7 @@ interface ProviderOption {
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
// Only Telebirr and Waafi are enabled for now.
|
||||
const PROVIDERS: ProviderOption[] = [
|
||||
{
|
||||
method: "TELEBIRR",
|
||||
@@ -25,42 +19,12 @@ const PROVIDERS: ProviderOption[] = [
|
||||
description: "Ethiopian mobile money",
|
||||
icon: Smartphone,
|
||||
},
|
||||
{
|
||||
method: "CBE_BIRR",
|
||||
label: "CBE Birr",
|
||||
description: "Commercial Bank of Ethiopia",
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
method: "EBIRR",
|
||||
label: "E-Birr",
|
||||
description: "Electronic payment gateway",
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
method: "WAAFI",
|
||||
label: "WAAFI",
|
||||
label: "Waafi",
|
||||
description: "Djibouti mobile money",
|
||||
icon: Smartphone,
|
||||
},
|
||||
{
|
||||
method: "CARD",
|
||||
label: "Card",
|
||||
description: "Visa / Mastercard",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
method: "DMONEY",
|
||||
label: "D-Money",
|
||||
description: "Djibouti D-money",
|
||||
icon: Banknote,
|
||||
},
|
||||
{
|
||||
method: "CAC_BANK",
|
||||
label: "CAC Bank",
|
||||
description: "CAC Int Bank (OTP)",
|
||||
icon: Building2,
|
||||
},
|
||||
];
|
||||
|
||||
function ProviderRow({
|
||||
@@ -141,7 +105,7 @@ export function PaymentMethodModal({
|
||||
processing?: boolean;
|
||||
error?: string | null;
|
||||
}) {
|
||||
const [method, setMethod] = useState<PaymentMethod | null>(null);
|
||||
const [method, setMethod] = useState<PaymentMethod>(PROVIDERS[0].method);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -184,9 +148,9 @@ export function PaymentMethodModal({
|
||||
mt={6}
|
||||
radius={10}
|
||||
color="edr-green"
|
||||
disabled={!method || processing}
|
||||
disabled={processing}
|
||||
loading={processing}
|
||||
onClick={() => method && onConfirm(method)}
|
||||
onClick={() => onConfirm(method)}
|
||||
styles={{
|
||||
root: { height: 46 },
|
||||
label: { fontSize: 14, fontWeight: 800 },
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface ContractView {
|
||||
signedAt: string;
|
||||
signatureImageUrl?: string | null;
|
||||
}>;
|
||||
/** Current viewer's reusable saved signature, if they have one. */
|
||||
savedSignature?: {
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface PriceLineItem {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import { client } from "../utils/api";
|
||||
|
||||
const P = URL_CONSTANTS.PAYMENTS;
|
||||
@@ -57,7 +58,7 @@ function buildCheckoutUrl(payload: {
|
||||
method: PaymentMethod;
|
||||
platform?: PaymentPlatform;
|
||||
}): string {
|
||||
const base = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
||||
const base = API_BASE_URL.replace(/\/$/, "");
|
||||
const params = new URLSearchParams({
|
||||
bookingId: payload.bookingId,
|
||||
method: payload.method,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { client } from "../utils/api";
|
||||
|
||||
const SIGNATURE_URL = "/api/me/signature";
|
||||
|
||||
export interface SavedSignature {
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface SaveSignaturePayload {
|
||||
signerDisplayName: string;
|
||||
signatureImageBase64: string;
|
||||
}
|
||||
|
||||
export const signaturesService = {
|
||||
/** The current user's reusable saved signature, or null if none. */
|
||||
getMySignature: async (): Promise<SavedSignature | null> => {
|
||||
const { data } = await client.get(SIGNATURE_URL);
|
||||
return (data.data ?? data) ?? null;
|
||||
},
|
||||
|
||||
saveMySignature: async (
|
||||
payload: SaveSignaturePayload,
|
||||
): Promise<SavedSignature | null> => {
|
||||
const { data } = await client.put(SIGNATURE_URL, payload);
|
||||
return (data.data ?? data) ?? null;
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
baseURL: API_BASE_URL,
|
||||
});
|
||||
|
||||
function getCookie(name: string): string | undefined {
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Pin Mantine to this app's copy. pnpm can install a second @mantine/core under
|
||||
// @edr/ui-common (linked to react@18) while the portal uses react@19 — dedupe
|
||||
// alone does not merge those into one module in production builds.
|
||||
const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core");
|
||||
const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks");
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
||||
"@mantine/core": mantineCore,
|
||||
"@mantine/hooks": mantineHooks,
|
||||
},
|
||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Booking" ADD COLUMN "returnDestinationStationId" TEXT,
|
||||
ADD COLUMN "returnHoldId" TEXT,
|
||||
ADD COLUMN "returnOriginStationId" TEXT,
|
||||
ADD COLUMN "returnScheduleId" TEXT,
|
||||
ADD COLUMN "returnSeatClassId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -494,38 +494,44 @@ model FareRule {
|
||||
}
|
||||
|
||||
model Booking {
|
||||
id String @id @default(uuid())
|
||||
bookingRef String @unique
|
||||
passengerId String
|
||||
scheduleId String
|
||||
status BookingStatus @default(DRAFT)
|
||||
currency String @default("ETB")
|
||||
totalMinor Int
|
||||
adultCount Int @default(1)
|
||||
childCount Int @default(0)
|
||||
displayCurrency Currency?
|
||||
displayTotalMinor Int?
|
||||
bookingType String @default("ONE_WAY")
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
userAgent String?
|
||||
source String @default("WEB")
|
||||
promoCode String?
|
||||
paidAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
seats BookingSeat[]
|
||||
paymentIntent PaymentIntent?
|
||||
ticket Ticket?
|
||||
foodOrders FoodOrder[]
|
||||
agentBooking AgentBooking?
|
||||
modifications BookingModification[]
|
||||
cancellation BookingCancellation?
|
||||
baggage BaggageBooking[]
|
||||
id String @id @default(uuid())
|
||||
bookingRef String @unique
|
||||
passengerId String
|
||||
scheduleId String
|
||||
bookingType String @default("ONE_WAY")
|
||||
status BookingStatus @default(DRAFT)
|
||||
currency String @default("ETB")
|
||||
totalMinor Int
|
||||
adultCount Int @default(1)
|
||||
childCount Int @default(0)
|
||||
displayCurrency Currency?
|
||||
displayTotalMinor Int?
|
||||
returnScheduleId String?
|
||||
returnOriginStationId String?
|
||||
returnDestinationStationId String?
|
||||
returnHoldId String?
|
||||
returnSeatClassId String?
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
userAgent String?
|
||||
source String @default("WEB")
|
||||
promoCode String?
|
||||
paidAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
seats BookingSeat[]
|
||||
paymentIntent PaymentIntent?
|
||||
ticket Ticket?
|
||||
foodOrders FoodOrder[]
|
||||
agentBooking AgentBooking?
|
||||
modifications BookingModification[]
|
||||
cancellation BookingCancellation?
|
||||
baggage BaggageBooking[]
|
||||
|
||||
@@index([passengerId, status])
|
||||
@@index([bookingType])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
|
||||
@@ -682,20 +682,6 @@ async function main() {
|
||||
|
||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||
['system users', seedSystemUsers],
|
||||
['stations', seedStations],
|
||||
['coach types & classes', seedCoachTypesAndClasses],
|
||||
['route', seedRoute],
|
||||
['coaches', seedCoaches],
|
||||
['trips', seedTrips],
|
||||
['fare rules', seedFareRules],
|
||||
['currency', seedCurrency],
|
||||
['payment methods', seedPaymentMethods],
|
||||
['notification templates', seedNotificationTemplates],
|
||||
['menu & food', seedMenuAndFood],
|
||||
['promotions', seedPromotions],
|
||||
['FAQ', seedFAQ],
|
||||
['fraud rules', seedFraudRules],
|
||||
['segment fares', seedSegmentFares],
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
|
||||
@@ -6,9 +6,11 @@ import { ConfigService } from '@nestjs/config';
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService) {
|
||||
const secret = config.get<string>('JWT_SECRET');
|
||||
if (!secret) throw new Error('JWT_SECRET environment variable is not set');
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: config.get('JWT_SECRET'),
|
||||
secretOrKey: secret,
|
||||
});
|
||||
}
|
||||
async validate(payload: any) {
|
||||
|
||||
@@ -144,9 +144,20 @@ export class BookingsController {
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Create booking (requires login)',
|
||||
description: `Creates a booking for logged-in users with saved passenger profiles.
|
||||
Use POST /bookings/guest for guest checkout without login.`
|
||||
summary: 'Create booking (one-way or round-trip)',
|
||||
description: `Creates a one-way or round-trip booking for logged-in users.
|
||||
|
||||
ONE_WAY booking:
|
||||
- scheduleId, holdId, originStationId, destinationStationId
|
||||
- passengers: array of PassengerInputDto with seatId
|
||||
- Single PNR, single payment
|
||||
|
||||
ROUND_TRIP booking:
|
||||
- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId
|
||||
- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
|
||||
- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId
|
||||
- Combined PNR, single payment for both legs
|
||||
- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction`
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Booking created with fare breakdown' })
|
||||
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' })
|
||||
|
||||
@@ -15,53 +15,130 @@ export class PassengerInputDto {
|
||||
}
|
||||
|
||||
export class RoundTripPassengerDto {
|
||||
@ApiProperty({ description: 'Outbound segment seat ID' }) @IsString() outboundSeatId: string;
|
||||
@ApiProperty({ description: 'Return segment seat ID' }) @IsString() returnSeatId: string;
|
||||
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
|
||||
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD)' }) @IsDateString() dateOfBirth: string;
|
||||
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() nationality?: string;
|
||||
@ApiProperty({
|
||||
description: 'Outbound journey seat ID',
|
||||
example: 'seat-uuid-outbound'
|
||||
})
|
||||
@IsString() outboundSeatId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Return journey seat ID',
|
||||
example: 'seat-uuid-return'
|
||||
})
|
||||
@IsString() returnSeatId: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Abebe Kebede',
|
||||
description: 'Full passenger name (will be verified via Verifayda for Ethiopian nationals)'
|
||||
})
|
||||
@IsString() passengerName: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '1990-05-15',
|
||||
description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first child FREE), Age ≥5 = ADULT (full fare for both legs)'
|
||||
})
|
||||
@IsDateString() dateOfBirth: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'NATIONAL_ID',
|
||||
enum: IdDocumentType,
|
||||
description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others'
|
||||
})
|
||||
@IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'ET123456789',
|
||||
description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)'
|
||||
})
|
||||
@IsOptional() @IsString() idDocumentNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'P1234567',
|
||||
description: 'Passport number for non-Ethiopian passengers (no verification)'
|
||||
})
|
||||
@IsOptional() @IsString() passportNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'Djibouti',
|
||||
description: 'Passport issuing country for non-Ethiopians'
|
||||
})
|
||||
@IsOptional() @IsString() passportCountry?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'Ethiopian',
|
||||
description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)'
|
||||
})
|
||||
@IsOptional() @IsString() nationality?: string;
|
||||
}
|
||||
|
||||
export class CreateBookingDto {
|
||||
@ApiProperty() @IsString() passengerId: string;
|
||||
@ApiProperty() @IsString() scheduleId: string;
|
||||
@ApiProperty() @IsString() holdId: string;
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg (must match the hold)' }) @IsString() originStationId: string;
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg (must match the hold)' }) @IsString() destinationStationId: string;
|
||||
@ApiProperty({ type: [PassengerInputDto], description: 'Array of passengers with age-based categorization. First child (<5 years) travels FREE.' }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
|
||||
@ApiProperty({ description: 'Passenger ID' })
|
||||
@IsString() passengerId: string;
|
||||
|
||||
@ApiProperty({ description: 'Outbound schedule ID' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@ApiProperty({ description: 'Outbound seat hold ID' })
|
||||
@IsString() holdId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
|
||||
@IsString() seatClassId: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
||||
@ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string;
|
||||
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
}
|
||||
|
||||
export class CreateRoundTripBookingDto {
|
||||
@ApiProperty({ description: 'Passenger ID' }) @IsString() passengerId: string;
|
||||
@ApiProperty({
|
||||
example: 'ONE_WAY',
|
||||
enum: ['ONE_WAY', 'ROUND_TRIP'],
|
||||
description: `Booking type:\n\n**ONE_WAY:**\n- Single journey from origin to destination\n- Uses: scheduleId, holdId, originStationId, destinationStationId, seatClassId\n- passengers: PassengerInputDto[] with seatId\n\n**ROUND_TRIP:**\n- Outbound + return journey with single PNR\n- Uses all outbound fields PLUS return fields\n- passengers: RoundTripPassengerDto[] with outboundSeatId and returnSeatId\n- Combined fare calculation with single payment`,
|
||||
default: 'ONE_WAY'
|
||||
})
|
||||
@IsOptional() @IsString() bookingType?: string;
|
||||
|
||||
@ApiProperty({ description: 'Outbound schedule ID' }) @IsString() outboundScheduleId: string;
|
||||
@ApiProperty({ description: 'Outbound origin station ID' }) @IsString() outboundOriginStationId: string;
|
||||
@ApiProperty({ description: 'Outbound destination station ID' }) @IsString() outboundDestinationStationId: string;
|
||||
@ApiProperty({ description: 'Outbound seat hold ID' }) @IsString() outboundHoldId: string;
|
||||
@ApiProperty({
|
||||
type: [PassengerInputDto],
|
||||
description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare`
|
||||
})
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
|
||||
passengers: PassengerInputDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
|
||||
@IsOptional() @IsString() promoCode?: string;
|
||||
|
||||
@ApiProperty({ description: 'Return schedule ID' }) @IsString() returnScheduleId: string;
|
||||
@ApiProperty({ description: 'Return origin station ID (usually same as outbound destination)' }) @IsString() returnOriginStationId: string;
|
||||
@ApiProperty({ description: 'Return destination station ID (usually same as outbound origin)' }) @IsString() returnDestinationStationId: string;
|
||||
@ApiProperty({ description: 'Return seat hold ID' }) @IsString() returnHoldId: string;
|
||||
@ApiPropertyOptional({ description: 'Loyalty points to redeem (applies to combined fare for round-trip)' })
|
||||
@IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
||||
|
||||
@ApiProperty({ type: [RoundTripPassengerDto], description: 'Array of passengers with seats for both outbound and return legs' })
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => RoundTripPassengerDto) passengers: RoundTripPassengerDto[];
|
||||
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' })
|
||||
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
|
||||
// Round-trip specific fields
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return schedule ID (required when bookingType=ROUND_TRIP)'
|
||||
})
|
||||
@IsOptional() @IsString() returnScheduleId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Seat class ID' }) @IsString() seatClassId: string;
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)'
|
||||
})
|
||||
@IsOptional() @IsString() returnOriginStationId?: string;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
||||
@ApiPropertyOptional({ example: 'DJF', enum: Currency }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)'
|
||||
})
|
||||
@IsOptional() @IsString() returnDestinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)'
|
||||
})
|
||||
@IsOptional() @IsString() returnHoldId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)'
|
||||
})
|
||||
@IsOptional() @IsString() returnSeatClassId?: string;
|
||||
}
|
||||
|
||||
export class ModifyBookingDto {
|
||||
|
||||
@@ -246,15 +246,19 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateBookingDto) {
|
||||
if (dto.bookingType === 'ROUND_TRIP') {
|
||||
return this.createRoundTripBooking(dto);
|
||||
}
|
||||
return this.createOneWayBooking(dto);
|
||||
}
|
||||
|
||||
private async createOneWayBooking(dto: CreateBookingDto) {
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
include: {
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
},
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
@@ -262,18 +266,182 @@ export class BookingsService {
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
||||
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||||
const passengersData = await this.processPassengers(dto.passengers as any[]);
|
||||
const { adultCount, childCount } = this.countPassengers(passengersData);
|
||||
const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = fareCalculation.totalMinor;
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
const seatIds = dto.passengers.map((p) => p.seatId);
|
||||
const passengersData = [];
|
||||
let adultCount = 0, childCount = 0;
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ONE_WAY',
|
||||
totalMinor: fareCalculation.totalMinor,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
seats: {
|
||||
create: passengersData.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? fareCalculation.baseFareMinor : (fareCalculation.paidChildrenCount > 0 ? fareCalculation.baseFareMinor : 0),
|
||||
displayCurrency
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
|
||||
});
|
||||
|
||||
for (const passenger of dto.passengers) {
|
||||
await this.seatsService.confirmSeats(passengersData.map(p => p.seatId));
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
return { ...booking, fareBreakdown: fareCalculation };
|
||||
}
|
||||
|
||||
private async createRoundTripBooking(dto: CreateBookingDto) {
|
||||
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
||||
throw new BadRequestException('Return trip details required for round-trip booking');
|
||||
}
|
||||
|
||||
const [outboundHold, returnHold] = await Promise.all([
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } })
|
||||
]);
|
||||
|
||||
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired');
|
||||
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired');
|
||||
|
||||
const [outboundSchedule, returnSchedule] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
|
||||
}),
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.returnScheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
|
||||
})
|
||||
]);
|
||||
|
||||
if (!outboundSchedule || !returnSchedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
|
||||
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
|
||||
|
||||
if (!outboundOriginStop || !outboundDestStop || !returnOriginStop || !returnDestStop) {
|
||||
throw new NotFoundException('Origin or destination stops not found');
|
||||
}
|
||||
|
||||
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
|
||||
const { adultCount, childCount } = this.countPassengers(passengersData);
|
||||
|
||||
const [outboundFare, returnFare] = await Promise.all([
|
||||
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
|
||||
this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount)
|
||||
]);
|
||||
|
||||
const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
|
||||
let discountMinor = 0;
|
||||
if (dto.promoCode) {
|
||||
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||
if (promo?.active && promo.validUntil > new Date()) {
|
||||
discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = totalMinor;
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP',
|
||||
totalMinor,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
returnScheduleId: dto.returnScheduleId,
|
||||
returnOriginStationId: dto.returnOriginStationId,
|
||||
returnDestinationStationId: dto.returnDestinationStationId,
|
||||
returnHoldId: dto.returnHoldId,
|
||||
returnSeatClassId: dto.returnSeatClassId,
|
||||
seats: {
|
||||
create: passengersData.map(p => ({
|
||||
seat: { connect: { id: p.outboundSeatId } },
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? (outboundFare.baseFareMinor + returnFare.baseFareMinor) : 0,
|
||||
displayCurrency
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
|
||||
});
|
||||
|
||||
const outboundSeatIds = passengersData.map(p => p.outboundSeatId);
|
||||
const returnSeatIds = passengersData.map(p => p.returnSeatId);
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(outboundSeatIds),
|
||||
this.seatsService.confirmSeats(returnSeatIds)
|
||||
]);
|
||||
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
...booking,
|
||||
fareBreakdown: {
|
||||
outboundFare: outboundFare.baseFareMinor,
|
||||
returnFare: returnFare.baseFareMinor,
|
||||
combinedBaseFareMinor,
|
||||
discountMinor,
|
||||
loyaltyRedemptionMinor: loyaltyMinor,
|
||||
taxesFeesMinor: taxesMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency,
|
||||
displayTotalMinor
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async processPassengers(passengers: any[]) {
|
||||
const processedPassengers = [];
|
||||
for (const passenger of passengers) {
|
||||
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||||
const age = calculateAge(dateOfBirth);
|
||||
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
|
||||
|
||||
let passengerName = passenger.passengerName;
|
||||
let verifaydaVerified = false;
|
||||
@@ -292,68 +460,93 @@ export class BookingsService {
|
||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||
}
|
||||
|
||||
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||
processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||
}
|
||||
return processedPassengers;
|
||||
}
|
||||
|
||||
const primaryNationality = passengersData[0]?.nationality;
|
||||
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality, originStop.sequence, destStop.sequence);
|
||||
private async processRoundTripPassengers(passengers: any[]) {
|
||||
const processedPassengers = [];
|
||||
for (const passenger of passengers) {
|
||||
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||||
const age = calculateAge(dateOfBirth);
|
||||
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||
|
||||
let passengerName = passenger.passengerName;
|
||||
let verifaydaVerified = false;
|
||||
let verifaydaData: Record<string, any> | undefined;
|
||||
let nationality = passenger.nationality;
|
||||
|
||||
if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
|
||||
passengerName = verification.passengerData?.fullName || passengerName;
|
||||
verifaydaVerified = true;
|
||||
verifaydaData = verification.passengerData?.profileData;
|
||||
nationality = nationality || 'Ethiopian';
|
||||
} else if (passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
|
||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||
}
|
||||
|
||||
processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||
}
|
||||
return processedPassengers;
|
||||
}
|
||||
|
||||
private countPassengers(passengersData: any[]) {
|
||||
let adultCount = 0, childCount = 0;
|
||||
for (const passenger of passengersData) {
|
||||
if (passenger.category === PassengerCategory.ADULT) adultCount++;
|
||||
else childCount++;
|
||||
}
|
||||
return { adultCount, childCount };
|
||||
}
|
||||
|
||||
private async calculateFare(
|
||||
scheduleId: string,
|
||||
seatClassId: string,
|
||||
originStop: any,
|
||||
destStop: any,
|
||||
nationality?: string,
|
||||
adultCount = 1,
|
||||
childCount = 0,
|
||||
promoCode?: string,
|
||||
loyaltyRedemptionPoints?: number
|
||||
) {
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence);
|
||||
|
||||
const adultFareMinor = baseFareMinor * adultCount;
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const childFareMinor = baseFareMinor * paidChildrenCount;
|
||||
const totalBaseFareMinor = adultFareMinor + childFareMinor;
|
||||
|
||||
let discountMinor = 0;
|
||||
if (dto.promoCode) {
|
||||
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||
if (promoCode) {
|
||||
const promo = await this.prisma.promotion.findUnique({ where: { code: promoCode } });
|
||||
if (promo?.active && promo.validUntil > new Date()) {
|
||||
discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = totalMinor;
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||||
bookingType: dto.bookingType ?? 'ONE_WAY',
|
||||
seats: {
|
||||
create: passengersData.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
idDocumentNumber: p.idDocumentType === IdDocumentType.NATIONAL_ID ? undefined : p.idDocumentNumber,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0),
|
||||
displayCurrency,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
|
||||
});
|
||||
|
||||
await this.seatsService.confirmSeats(seatIds);
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
return {
|
||||
...booking,
|
||||
fareBreakdown: { baseFareMinor, adultCount, adultFareMinor, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor },
|
||||
baseFareMinor,
|
||||
adultCount,
|
||||
adultFareMinor,
|
||||
childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
paidChildrenCount,
|
||||
childFareMinor,
|
||||
totalBaseFareMinor,
|
||||
discountMinor,
|
||||
loyaltyRedemptionMinor: loyaltyMinor,
|
||||
taxesFeesMinor: taxesMinor,
|
||||
totalMinor
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -134,9 +134,28 @@ export class FleetService {
|
||||
}
|
||||
|
||||
async deleteCoachType(id: string) {
|
||||
const coachType = await this.prisma.coachType.findUnique({ where: { id } });
|
||||
const coachType = await this.prisma.coachType.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
coaches: true,
|
||||
seatClasses: true,
|
||||
},
|
||||
});
|
||||
if (!coachType) throw new NotFoundException('Coach type not found');
|
||||
|
||||
// Check for related records
|
||||
if (coachType.coaches.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach type. ${coachType.coaches.length} coach(es) are still using this coach type. Please reassign or delete the coaches first.`
|
||||
);
|
||||
}
|
||||
|
||||
if (coachType.seatClasses.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach type. ${coachType.seatClasses.length} seat class(es) are still using this coach type. Please reassign or delete the seat classes first.`
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.coachType.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -183,9 +202,29 @@ export class FleetService {
|
||||
}
|
||||
|
||||
async deleteClass(id: string) {
|
||||
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
const seatClass = await this.prisma.seatClass.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
fareRules: true,
|
||||
routeFareRules: true,
|
||||
segmentFares: true,
|
||||
},
|
||||
});
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
|
||||
// Check for related records
|
||||
const relatedRecords = [
|
||||
...seatClass.fareRules,
|
||||
...seatClass.routeFareRules,
|
||||
...seatClass.segmentFares,
|
||||
];
|
||||
|
||||
if (relatedRecords.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete seat class. ${relatedRecords.length} fare rule(s) are still using this seat class. Please delete the fare rules first.`
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.seatClass.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -220,8 +259,21 @@ export class FleetService {
|
||||
}
|
||||
|
||||
async deleteTrain(id: string) {
|
||||
const train = await this.prisma.train.findUnique({ where: { id } });
|
||||
const train = await this.prisma.train.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
schedules: true,
|
||||
},
|
||||
});
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
|
||||
// Check for active schedules
|
||||
if (train.schedules.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.`
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.train.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -305,10 +357,53 @@ export class FleetService {
|
||||
}
|
||||
|
||||
async deleteCoach(id: string) {
|
||||
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
||||
const coach = await this.prisma.coach.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
assignments: true,
|
||||
seats: {
|
||||
include: {
|
||||
bookingSeats: true,
|
||||
blocks: true,
|
||||
ticketSeats: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
|
||||
// Delete related seats first
|
||||
// Check for active assignments
|
||||
if (coach.assignments.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. This coach is assigned to ${coach.assignments.length} schedule(s). Please remove the assignments first.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for booked seats
|
||||
const bookedSeats = coach.seats.filter(seat => seat.bookingSeats.length > 0);
|
||||
if (bookedSeats.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. ${bookedSeats.length} seat(s) have active bookings. Please wait for bookings to complete or cancel them first.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for blocked seats
|
||||
const blockedSeats = coach.seats.filter(seat => seat.blocks.length > 0);
|
||||
if (blockedSeats.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. ${blockedSeats.length} seat(s) are blocked. Please unblock them first.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for tickets
|
||||
const seatsWithTickets = coach.seats.filter(seat => seat.ticketSeats.length > 0);
|
||||
if (seatsWithTickets.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. ${seatsWithTickets.length} seat(s) have issued tickets. Please wait for travel completion.`
|
||||
);
|
||||
}
|
||||
|
||||
// Delete related seats first (now safe to do)
|
||||
await this.prisma.seat.deleteMany({ where: { coachId: id } });
|
||||
|
||||
return this.prisma.coach.delete({ where: { id } });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
||||
import { DynamicModule } from "@nestjs/common";
|
||||
import {
|
||||
PAYMENT_EVENTS_DLX,
|
||||
PAYMENT_EVENTS_EXCHANGE,
|
||||
@@ -20,21 +21,15 @@ import { TicketsModule } from "../tickets/tickets.module";
|
||||
|
||||
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SeatsModule,
|
||||
TicketsModule,
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
function rabbitMQImport(): DynamicModule[] {
|
||||
if (!process.env.PAYMENT_RABBITMQ_URL) return [];
|
||||
return [
|
||||
RabbitMQModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
uri: config.get<string>("rabbitmq.url") as string,
|
||||
exchanges: [
|
||||
{
|
||||
name: PAYMENT_EVENTS_EXCHANGE,
|
||||
type: "topic",
|
||||
options: { durable: true },
|
||||
},
|
||||
{ name: PAYMENT_EVENTS_EXCHANGE, type: "topic", options: { durable: true } },
|
||||
{ name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } },
|
||||
],
|
||||
queues: [
|
||||
@@ -49,6 +44,15 @@ const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
||||
connectionInitOptions: { wait: false },
|
||||
}),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SeatsModule,
|
||||
TicketsModule,
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
...rabbitMQImport(),
|
||||
],
|
||||
controllers: [PaymentsController, InternalPaymentsController],
|
||||
providers: [
|
||||
|
||||
@@ -545,8 +545,13 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.coaches && dto.coaches.length > 0) {
|
||||
await this.assignCoaches(id, dto.coaches);
|
||||
if (dto.coaches !== undefined) {
|
||||
if (dto.coaches.length > 0) {
|
||||
await this.assignCoaches(id, dto.coaches);
|
||||
} else {
|
||||
// Remove all coach assignments when empty array is sent
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||
}
|
||||
}
|
||||
|
||||
return this.getSchedule(id);
|
||||
|
||||
@@ -204,14 +204,11 @@ export default function SchedulesPage() {
|
||||
departureAt: editForm.departureAt,
|
||||
arrivalAt: editForm.arrivalAt,
|
||||
status: editForm.status,
|
||||
};
|
||||
|
||||
if (editForm.coachIds.length > 0) {
|
||||
payload.coaches = editForm.coachIds.map((coachId: string, idx: number) => ({
|
||||
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
|
||||
coachId,
|
||||
positionNumber: idx + 1,
|
||||
}));
|
||||
}
|
||||
})),
|
||||
};
|
||||
|
||||
await updateScheduleMutation.mutateAsync({
|
||||
id: editingSchedule.id,
|
||||
@@ -327,14 +324,20 @@ export default function SchedulesPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'originStation.name',
|
||||
label: 'From',
|
||||
render: (schedule: Schedule) => <span>{schedule.originStation?.name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'destinationStation.name',
|
||||
label: 'To',
|
||||
render: (schedule: Schedule) => <span>{schedule.destinationStation?.name}</span>,
|
||||
key: 'route',
|
||||
label: 'Route',
|
||||
sortable: true,
|
||||
render: (schedule: Schedule) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{schedule.originStation?.name || 'Unknown'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="text-sm font-medium">
|
||||
{schedule.destinationStation?.name || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'departureAt',
|
||||
|
||||
@@ -420,7 +420,12 @@ export default function SeatsPage() {
|
||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||
return seats.length > 0;
|
||||
})
|
||||
.sort((a: any, b: any) => (a.sequence || 0) - (b.sequence || 0));
|
||||
.sort((a: any, b: any) => {
|
||||
// Try multiple sequence field possibilities
|
||||
const seqA = a.positionNumber ?? a.sequence ?? a.coach?.sequence ?? 999;
|
||||
const seqB = b.positionNumber ?? b.sequence ?? b.coach?.sequence ?? 999;
|
||||
return seqA - seqB;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -460,8 +465,30 @@ export default function SeatsPage() {
|
||||
<p className="text-muted-foreground mt-3">Loading seats...</p>
|
||||
</div>
|
||||
) : coachesWithSeats.length === 0 ? (
|
||||
<div className="card text-center py-12 text-muted-foreground">
|
||||
<p>No coaches with seats found for this schedule</p>
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<label className="label">Select Schedule</label>
|
||||
<select
|
||||
value={selectedSchedule}
|
||||
onChange={(e) => setSelectedSchedule(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Select a schedule...</option>
|
||||
{schedules.map((schedule: any) => {
|
||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||
const routeName = schedule.route?.name || 'N/A';
|
||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||
return (
|
||||
<option key={schedule.id} value={schedule.id}>
|
||||
{trainNumber} - {routeName} - {date}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="card text-center py-12 text-muted-foreground">
|
||||
<p>No coaches with seats found for this schedule</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
@@ -526,7 +553,7 @@ export default function SeatsPage() {
|
||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||
const isExpanded = expandedCoaches.has(coach.id);
|
||||
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';
|
||||
const sequence = coachData?.sequence ?? coach?.sequence ?? index + 1;
|
||||
const sequence = coach.positionNumber ?? coach.sequence ?? coachData?.sequence ?? index + 1;
|
||||
|
||||
return (
|
||||
<div key={coach.id} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800/50 shadow-md hover:shadow-lg transition-shadow">
|
||||
|
||||
@@ -75,7 +75,6 @@ export default function StationsPage() {
|
||||
lat: parseFloat(formData.get('lat') as string) || null,
|
||||
lng: parseFloat(formData.get('lng') as string) || null,
|
||||
timezone: formData.get('timezone') as string,
|
||||
distance: parseFloat(formData.get('distance') as string) || 0,
|
||||
sequence,
|
||||
isOperational: formData.get('isOperational') === 'true',
|
||||
};
|
||||
@@ -136,13 +135,7 @@ export default function StationsPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'distance',
|
||||
label: 'Distance (km)',
|
||||
render: (station: any) => (
|
||||
<span className="font-mono text-sm">{station.distance ? `${station.distance}` : '0'}</span>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
key: 'isOperational',
|
||||
label: 'Status',
|
||||
@@ -346,18 +339,6 @@ export default function StationsPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Distance from Previous (km)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="distance"
|
||||
className="input"
|
||||
defaultValue={editingStation?.distance || 0}
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="e.g., 150.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Sequence Number *</label>
|
||||
<input
|
||||
|
||||
@@ -14,38 +14,40 @@ export default function ResultsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
|
||||
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
|
||||
const [outboundSelected, setOutboundSelected] = useState(false);
|
||||
const [classModal, setClassModal] = useState<Schedule | null>(null);
|
||||
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
|
||||
|
||||
const searchCriteria = useBookingStore((s) => s.searchCriteria);
|
||||
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
||||
|
||||
// Prefer URL params; fall back to persisted store values
|
||||
const searchData = {
|
||||
originStationId: searchParams.get('origin') || searchCriteria?.originStationId || '',
|
||||
destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '',
|
||||
date: searchParams.get('date') || searchCriteria?.departureDate || '',
|
||||
returnDate: searchParams.get('returnDate') || searchCriteria?.returnDate,
|
||||
journeyType: searchParams.get('tripType') === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||
adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1,
|
||||
childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0,
|
||||
nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN',
|
||||
promoCode: searchParams.get('promoCode') || searchCriteria?.promoCode || '',
|
||||
};
|
||||
|
||||
// Sync URL params back into store whenever they are present in the URL
|
||||
useEffect(() => {
|
||||
if (searchParams.get('origin')) {
|
||||
setSearchCriteria({
|
||||
tripType: (searchParams.get('tripType') || 'ONE_WAY') as 'ONE_WAY' | 'ROUND_TRIP',
|
||||
originStationId: searchParams.get('origin')!,
|
||||
destinationStationId: searchParams.get('destination')!,
|
||||
departureDate: searchParams.get('date')!,
|
||||
returnDate: searchParams.get('returnDate') || undefined,
|
||||
adultCount: parseInt(searchParams.get('adults') || '1'),
|
||||
childCount: parseInt(searchParams.get('children') || '0'),
|
||||
nationality: (searchParams.get('nationality') || 'ETHIOPIAN') as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER',
|
||||
promoCode: searchParams.get('promoCode') || '',
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [searchParams, setSearchCriteria]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchData.promoCode) {
|
||||
@@ -60,45 +62,91 @@ export default function ResultsPage() {
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Promo validation failed:', err);
|
||||
.catch(() => {
|
||||
// Promo validation failed - silently ignore
|
||||
});
|
||||
}
|
||||
}, [searchData.promoCode]);
|
||||
|
||||
const buildSearchUrl = () => {
|
||||
const params = new URLSearchParams({
|
||||
tripType: searchData.journeyType,
|
||||
origin: searchData.originStationId,
|
||||
destination: searchData.destinationStationId,
|
||||
date: searchData.date,
|
||||
adults: searchData.adultCount.toString(),
|
||||
children: searchData.childCount.toString(),
|
||||
nationality: searchData.nationality,
|
||||
...(searchData.returnDate && { returnDate: searchData.returnDate }),
|
||||
...(searchData.promoCode && { promoCode: searchData.promoCode }),
|
||||
});
|
||||
return `/booking/search?${params}`;
|
||||
};
|
||||
|
||||
const { data: results, isLoading, error } = useQuery<Schedule[]>({
|
||||
const { data: results, isLoading, error } = useQuery<any>({
|
||||
queryKey: ['search', searchData],
|
||||
queryFn: async (): Promise<Schedule[]> => {
|
||||
console.log('Searching with criteria:', searchData);
|
||||
const response = await apiClient.post('/search', searchData) as Schedule[];
|
||||
console.log('Search results:', response);
|
||||
console.log('Number of results:', response?.length || 0);
|
||||
if (response?.length > 0) {
|
||||
console.log('First schedule availabilityByClass:', response[0].availabilityByClass);
|
||||
queryFn: async (): Promise<any> => {
|
||||
const payload: any = {
|
||||
originStationId: searchData.originStationId,
|
||||
destinationStationId: searchData.destinationStationId,
|
||||
date: searchData.date,
|
||||
adultCount: searchData.adultCount,
|
||||
childCount: searchData.childCount,
|
||||
nationality: searchData.nationality,
|
||||
journeyType: searchData.journeyType,
|
||||
};
|
||||
|
||||
if (searchData.journeyType === 'ROUND_TRIP' && searchData.returnDate) {
|
||||
payload.returnDate = searchData.returnDate;
|
||||
}
|
||||
|
||||
console.log('🚂 Search Request:', JSON.stringify(payload, null, 2));
|
||||
|
||||
const response = await apiClient.post('/search', payload) as any;
|
||||
|
||||
console.log('✅ Search Response:', JSON.stringify(response, null, 2));
|
||||
|
||||
return response;
|
||||
},
|
||||
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
|
||||
});
|
||||
|
||||
const handleSelectClass = (scheduleId: string, seatClass: string) => {
|
||||
const isRoundTrip = searchData.journeyType === 'ROUND_TRIP';
|
||||
|
||||
// Handle both response formats:
|
||||
// 1. One-way: response is array of schedules
|
||||
// 2. Round-trip: response has journeyType, outbound, inbound properties
|
||||
let outboundSchedules: Schedule[] = [];
|
||||
let inboundSchedules: Schedule[] = [];
|
||||
|
||||
if (results) {
|
||||
if (isRoundTrip && results.journeyType === 'ROUND_TRIP') {
|
||||
// Round trip response format
|
||||
outboundSchedules = results.outbound || [];
|
||||
inboundSchedules = results.inbound || [];
|
||||
} else if (Array.isArray(results)) {
|
||||
// One-way response format (array of schedules)
|
||||
outboundSchedules = results;
|
||||
} else if (results.data && Array.isArray(results.data)) {
|
||||
// Fallback: wrapped in data property
|
||||
outboundSchedules = results.data;
|
||||
}
|
||||
}
|
||||
|
||||
// For one-way, check if outbound has results
|
||||
// For round-trip, check if BOTH outbound and inbound have results
|
||||
const hasResults = isRoundTrip
|
||||
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
|
||||
: outboundSchedules.length > 0;
|
||||
|
||||
const handleSelectClass = (scheduleId: string, seatClass: string, isOutbound: boolean = false) => {
|
||||
setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass }));
|
||||
if (isOutbound && isRoundTrip) {
|
||||
setOutboundSelected(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (schedule: Schedule) => {
|
||||
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
|
||||
const scheduleId = schedule.scheduleId || schedule.id || '';
|
||||
const selectedClass = selectedClasses[scheduleId];
|
||||
|
||||
@@ -120,7 +168,7 @@ export default function ResultsPage() {
|
||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||
const durationStr = `${hours}h ${minutes}m`;
|
||||
|
||||
setSelectedSchedule({
|
||||
const scheduleData = {
|
||||
id: scheduleId,
|
||||
trainNumber: schedule.trainNumber,
|
||||
origin: schedule.origin?.name || 'Origin',
|
||||
@@ -132,10 +180,113 @@ export default function ResultsPage() {
|
||||
baseFareChild: selectedClassFare.baseFareMinor,
|
||||
selectedSeatClass: selectedClass,
|
||||
selectedSeatClassName: selectedClass,
|
||||
});
|
||||
};
|
||||
|
||||
// For round trip, store outbound and wait for inbound selection
|
||||
if (isRoundTrip && isOutbound) {
|
||||
setOutboundSelected(true);
|
||||
setClassModal(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// For round trip inbound or one-way, proceed to next step
|
||||
setSelectedSchedule(scheduleData);
|
||||
router.push('/booking/auth-check');
|
||||
};
|
||||
|
||||
const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => {
|
||||
const scheduleId = schedule.scheduleId || schedule.id || '';
|
||||
const selectedClass = selectedClasses[scheduleId];
|
||||
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
|
||||
? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
|
||||
: null;
|
||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||
const durationStr = `${hours}h ${minutes}m`;
|
||||
const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null;
|
||||
const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null;
|
||||
const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString();
|
||||
|
||||
return (
|
||||
<div key={scheduleId} className="card">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Clock className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-lg text-gray-900 dark:text-gray-100">{schedule.trainNumber}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{schedule.trainName || 'Express Service'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{schedule.departureAt ? format(new Date(schedule.departureAt), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{schedule.departureAt ? format(new Date(schedule.departureAt), 'MMM d') : ''}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{durationStr}</span>
|
||||
</div>
|
||||
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||
</div>
|
||||
{schedule.stops && schedule.stops.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{schedule.stops.length - 2} stops</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1">
|
||||
<span>{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''}</span>
|
||||
{isNextDay && <span className="text-orange-500 font-medium">(+1)</span>}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
|
||||
<div className="text-center lg:text-right">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
|
||||
<div className="text-3xl font-bold text-primary">
|
||||
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
|
||||
{selectedClass && (
|
||||
<p className="text-xs text-primary font-semibold mb-2">
|
||||
{selectedClass.replace(/_/g, ' ')} selected
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setClassModal({ ...schedule, isOutbound } as any)}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{selectedClass ? 'Change class' : 'Select class'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
@@ -164,7 +315,7 @@ export default function ResultsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
if (!hasResults) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
@@ -192,19 +343,16 @@ export default function ResultsPage() {
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
|
||||
{/* Class selection modal */}
|
||||
{classModal && (() => {
|
||||
const scheduleId = classModal.scheduleId || classModal.id || '';
|
||||
const selectedClass = selectedClasses[scheduleId];
|
||||
const isOutbound = (classModal as any).isOutbound;
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[99] bg-black/50 backdrop-blur-sm" onClick={() => setClassModal(null)} />
|
||||
{/* Drawer */}
|
||||
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[640px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
|
||||
style={{ animation: 'drawer-slide-in 0.25s cubic-bezier(0.32,0.72,0,1)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Class</h2>
|
||||
@@ -222,7 +370,6 @@ export default function ResultsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Class grid */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
@@ -234,7 +381,7 @@ export default function ResultsPage() {
|
||||
return (
|
||||
<button
|
||||
key={fareClass.seatClassName}
|
||||
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName)}
|
||||
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName, isOutbound)}
|
||||
disabled={!isAvailable}
|
||||
className={`relative w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
isSelected
|
||||
@@ -272,14 +419,13 @@ export default function ResultsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => { if (selectedClass) { handleSelect(classModal); setClassModal(null); } }}
|
||||
onClick={() => { if (selectedClass) { handleSelect(classModal, isOutbound); } }}
|
||||
disabled={!selectedClass}
|
||||
className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-40 disabled:cursor-not-allowed shadow-lg"
|
||||
>
|
||||
<span>Continue</span>
|
||||
<span>{isRoundTrip && isOutbound ? 'Continue to Return' : 'Continue'}</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
{!selectedClass && (
|
||||
@@ -292,7 +438,6 @@ export default function ResultsPage() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Promo Notification */}
|
||||
{promoData && (
|
||||
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3">
|
||||
<Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||
@@ -329,101 +474,42 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{results.map((schedule) => {
|
||||
const scheduleId = schedule.scheduleId || schedule.id || '';
|
||||
const selectedClass = selectedClasses[scheduleId];
|
||||
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
|
||||
? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
|
||||
: null;
|
||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||
const durationStr = `${hours}h ${minutes}m`;
|
||||
const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null;
|
||||
const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null;
|
||||
const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString();
|
||||
|
||||
return (
|
||||
<div key={scheduleId} className="card">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
||||
{/* Train info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Clock className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-lg text-gray-900 dark:text-gray-100">{schedule.trainNumber}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{schedule.trainName || 'Express Service'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{schedule.departureAt ? format(new Date(schedule.departureAt), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{schedule.departureAt ? format(new Date(schedule.departureAt), 'MMM d') : ''}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{durationStr}</span>
|
||||
</div>
|
||||
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||
</div>
|
||||
{schedule.stops && schedule.stops.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{schedule.stops.length - 2} stops</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1">
|
||||
<span>{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''}</span>
|
||||
{isNextDay && <span className="text-orange-500 font-medium">(+1)</span>}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fare + action */}
|
||||
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
|
||||
<div className="text-center lg:text-right">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
|
||||
<div className="text-3xl font-bold text-primary">
|
||||
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
|
||||
{selectedClass && (
|
||||
<p className="text-xs text-primary font-semibold mb-2">
|
||||
{selectedClass.replace(/_/g, ' ')} selected
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setClassModal(schedule)}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{selectedClass ? 'Change class' : 'Select class'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
{outboundSchedules.length > 0 && (
|
||||
<div>
|
||||
{isRoundTrip && (
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ArrowRight className="w-5 h-5 text-primary" />
|
||||
Outbound Journey
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inboundSchedules.length > 0 && (!isRoundTrip || outboundSelected) && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ArrowRight className="w-5 h-5 text-primary rotate-180" />
|
||||
Return Journey
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{searchData.returnDate ? format(new Date(searchData.returnDate), 'EEEE, MMMM d, yyyy') : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,9 +17,11 @@ import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import ModernDatePicker from '@/components/ModernDatePicker';
|
||||
|
||||
const searchSchema = z.object({
|
||||
tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']),
|
||||
originStationId: z.string().min(1, 'Please select origin station'),
|
||||
destinationStationId: z.string().min(1, 'Please select destination station'),
|
||||
departureDate: z.string().min(1, 'Please select departure date'),
|
||||
returnDate: z.string().optional(),
|
||||
adultCount: z.number().min(1).max(9),
|
||||
childCount: z.number().min(0).max(9),
|
||||
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
|
||||
@@ -27,6 +29,22 @@ const searchSchema = z.object({
|
||||
}).refine((d) => d.originStationId !== d.destinationStationId, {
|
||||
message: 'Origin and destination must be different',
|
||||
path: ['destinationStationId'],
|
||||
}).refine((d) => {
|
||||
if (d.tripType === 'ROUND_TRIP' && !d.returnDate) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: 'Please select return date',
|
||||
path: ['returnDate'],
|
||||
}).refine((d) => {
|
||||
if (d.tripType === 'ROUND_TRIP' && d.returnDate && d.departureDate) {
|
||||
return d.returnDate >= d.departureDate;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: 'Return date must be after departure date',
|
||||
path: ['returnDate'],
|
||||
});
|
||||
|
||||
type SearchForm = z.infer<typeof searchSchema>;
|
||||
@@ -423,6 +441,7 @@ export default function SearchPage() {
|
||||
const { handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
|
||||
resolver: zodResolver(searchSchema as any),
|
||||
defaultValues: {
|
||||
tripType: 'ONE_WAY',
|
||||
adultCount: 1,
|
||||
childCount: 0,
|
||||
nationality: 'ETHIOPIAN',
|
||||
@@ -468,6 +487,8 @@ export default function SearchPage() {
|
||||
const adultCount = watch('adultCount');
|
||||
const childCount = watch('childCount');
|
||||
const departureDate = watch('departureDate');
|
||||
const returnDate = watch('returnDate');
|
||||
const tripType = watch('tripType');
|
||||
const totalPassengers = (adultCount || 1) + (childCount || 0);
|
||||
|
||||
const saveRecent = useCallback((id: string) => {
|
||||
@@ -510,12 +531,14 @@ export default function SearchPage() {
|
||||
if (data.originStationId) saveRecent(data.originStationId);
|
||||
if (data.destinationStationId) saveRecent(data.destinationStationId);
|
||||
const params = new URLSearchParams({
|
||||
tripType: data.tripType,
|
||||
origin: data.originStationId,
|
||||
destination: data.destinationStationId,
|
||||
date: data.departureDate,
|
||||
adults: data.adultCount.toString(),
|
||||
children: data.childCount.toString(),
|
||||
nationality: data.nationality,
|
||||
...(data.tripType === 'ROUND_TRIP' && data.returnDate && { returnDate: data.returnDate }),
|
||||
...(data.promoCode && { promoCode: data.promoCode }),
|
||||
});
|
||||
router.push(`/booking/results?${params}`);
|
||||
@@ -615,6 +638,34 @@ export default function SearchPage() {
|
||||
|
||||
<div className="p-4 md:p-5">
|
||||
|
||||
{/* Trip Type Tabs */}
|
||||
<div className="mb-4">
|
||||
<div className="inline-flex rounded-xl bg-gray-100 dark:bg-gray-800 p-1 w-full md:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('tripType', 'ONE_WAY')}
|
||||
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
|
||||
tripType === 'ONE_WAY'
|
||||
? 'bg-white dark:bg-gray-900 text-primary shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
One Way
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('tripType', 'ROUND_TRIP')}
|
||||
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
|
||||
tripType === 'ROUND_TRIP'
|
||||
? 'bg-white dark:bg-gray-900 text-primary shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Round Trip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: stacked */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<div className="space-y-1.5">
|
||||
@@ -660,6 +711,20 @@ export default function SearchPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{tripType === 'ROUND_TRIP' && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Return Date</label>
|
||||
<div className="relative z-20">
|
||||
<ModernDatePicker
|
||||
value={returnDate ? new Date(returnDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => setValue('returnDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
|
||||
minDate={departureDate ? new Date(departureDate + 'T00:00:00') : new Date()}
|
||||
placeholder="Select return date"
|
||||
/>
|
||||
</div>
|
||||
{errors.returnDate && <p className="text-xs text-red-500">{errors.returnDate.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
{/* Pax + Nationality combined trigger */}
|
||||
<button type="button" onClick={() => setPassengerModalOpen(true)}
|
||||
className="w-full flex items-center justify-between px-3.5 py-3 border-2 border-gray-200 rounded-xl bg-white">
|
||||
@@ -676,102 +741,230 @@ export default function SearchPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop: single row — From [swap] To | Date | Pax+Nat | Search */}
|
||||
<div className="hidden md:flex items-end gap-2">
|
||||
{/* From */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
|
||||
<StationDropdown stations={stations} value={originId} excludeId={destId} placeholder="Departure station"
|
||||
recentIds={recentStationIds} onSelect={(s) => { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} />
|
||||
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
|
||||
</div>
|
||||
{/* Swap */}
|
||||
<button type="button" onClick={handleSwap} disabled={!originId || !destId}
|
||||
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? 'rotate-180' : ''}`}>
|
||||
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
{/* To */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
|
||||
<StationDropdown stations={stations} value={destId} excludeId={originId} placeholder="Destination station"
|
||||
recentIds={recentStationIds} onSelect={(s) => { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} />
|
||||
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</p>}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
|
||||
{/* Date */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Date</label>
|
||||
<div className="relative z-30">
|
||||
<ModernDatePicker
|
||||
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
|
||||
minDate={new Date()} placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
|
||||
{/* Pax + Nationality combined — opens shared modal */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
|
||||
<button type="button" onClick={() => setPassengerModalOpen(true)}
|
||||
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 rounded-xl bg-white hover:border-gray-300 transition-all">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 truncate">
|
||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Search */}
|
||||
<button type="submit" disabled={isLoading}
|
||||
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50">
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Promo */}
|
||||
<div className="mt-3">
|
||||
{!promoVisible ? (
|
||||
<button type="button" onClick={() => setPromoVisible(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline">
|
||||
<Gift className="w-3.5 h-3.5" />
|
||||
Apply Promo Code
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
|
||||
<input type="text" value={promoCode}
|
||||
onChange={(e) => { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }}
|
||||
placeholder="Enter promo code"
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())}
|
||||
className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400"
|
||||
autoFocus />
|
||||
{/* Desktop: dynamic layout based on trip type */}
|
||||
<div className={`hidden md:block`}>
|
||||
{tripType === 'ONE_WAY' ? (
|
||||
// ONE WAY: Single row layout
|
||||
<div className="flex items-end gap-2">
|
||||
{/* From */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
|
||||
<StationDropdown stations={stations} value={originId} excludeId={destId} placeholder="Departure station"
|
||||
recentIds={recentStationIds} onSelect={(s) => { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} />
|
||||
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
|
||||
</div>
|
||||
{/* Swap */}
|
||||
<button type="button" onClick={handleSwap} disabled={!originId || !destId}
|
||||
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? 'rotate-180' : ''}`}>
|
||||
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
{/* To */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
|
||||
<StationDropdown stations={stations} value={destId} excludeId={originId} placeholder="Destination station"
|
||||
recentIds={recentStationIds} onSelect={(s) => { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} />
|
||||
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</p>}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
|
||||
{/* Date */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Date</label>
|
||||
<div className="relative z-30">
|
||||
<ModernDatePicker
|
||||
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
|
||||
minDate={new Date()} placeholder="Departure"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={handleValidatePromo} disabled={!promoCode || promoLoading}
|
||||
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 disabled:opacity-40 text-sm font-semibold">
|
||||
{promoLoading ? '...' : 'Apply'}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setPromoVisible(false); setPromoCode(''); setPromoValidation(null); }}
|
||||
className="p-2.5 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100">
|
||||
<X className="w-4 h-4" />
|
||||
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
|
||||
{/* Pax + Nationality */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
|
||||
<button type="button" onClick={() => setPassengerModalOpen(true)}
|
||||
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 rounded-xl bg-white hover:border-gray-300 transition-all">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 truncate">
|
||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
{promoValidation && (
|
||||
<div className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{promoValidation.valid && <Check className="w-3.5 h-3.5" />}
|
||||
{promoValidation.message}
|
||||
{/* Search */}
|
||||
<button type="submit" disabled={isLoading}
|
||||
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50">
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// ROUND TRIP: Two row layout
|
||||
<div className="space-y-3">
|
||||
{/* Row 1: From, Swap, To, Departure Date, Return Date */}
|
||||
<div className="flex items-end gap-2">
|
||||
{/* From */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
|
||||
<StationDropdown stations={stations} value={originId} excludeId={destId} placeholder="Departure station"
|
||||
recentIds={recentStationIds} onSelect={(s) => { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} />
|
||||
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
{/* Swap */}
|
||||
<button type="button" onClick={handleSwap} disabled={!originId || !destId}
|
||||
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? 'rotate-180' : ''}`}>
|
||||
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
{/* To */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
|
||||
<StationDropdown stations={stations} value={destId} excludeId={originId} placeholder="Destination station"
|
||||
recentIds={recentStationIds} onSelect={(s) => { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} />
|
||||
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</p>}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
|
||||
{/* Departure Date */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Departure</label>
|
||||
<div className="relative z-30">
|
||||
<ModernDatePicker
|
||||
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
|
||||
minDate={new Date()} placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
|
||||
</div>
|
||||
{/* Return Date */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Return</label>
|
||||
<div className="relative z-20">
|
||||
<ModernDatePicker
|
||||
value={returnDate ? new Date(returnDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => setValue('returnDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
|
||||
minDate={departureDate ? new Date(departureDate + 'T00:00:00') : new Date()}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
{errors.returnDate && <p className="text-xs text-red-500">{errors.returnDate.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Promo, Passengers, Search */}
|
||||
<div className="flex items-end gap-2">
|
||||
{/* Promo Code */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
{!promoVisible ? 'Promo Code (Optional)' : 'Promo Code'}
|
||||
</label>
|
||||
{!promoVisible ? (
|
||||
<button type="button" onClick={() => setPromoVisible(true)}
|
||||
className="w-full flex items-center gap-1.5 px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl hover:border-primary transition-all bg-white dark:bg-gray-800 text-left">
|
||||
<Gift className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm text-gray-400">Click to add promo code</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
|
||||
<input type="text" value={promoCode}
|
||||
onChange={(e) => { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }}
|
||||
placeholder="Enter promo code"
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())}
|
||||
className="w-full pl-9 pr-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400"
|
||||
autoFocus />
|
||||
</div>
|
||||
<button type="button" onClick={handleValidatePromo} disabled={!promoCode || promoLoading}
|
||||
className="px-4 py-3.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-sm font-semibold transition-colors">
|
||||
{promoLoading ? '...' : 'Apply'}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setPromoVisible(false); setPromoCode(''); setPromoValidation(null); }}
|
||||
className="p-3.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{promoValidation && (
|
||||
<div className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`}>
|
||||
{promoValidation.valid && <Check className="w-3.5 h-3.5" />}
|
||||
{promoValidation.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
|
||||
{/* Pax + Nationality */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
|
||||
<button type="button" onClick={() => setPassengerModalOpen(true)}
|
||||
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Search Button */}
|
||||
<div className="flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide opacity-0 pointer-events-none">Search</label>
|
||||
<button type="submit" disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50">
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Promo - Only visible in ONE WAY mode on desktop */}
|
||||
{tripType === 'ONE_WAY' && (
|
||||
<div className="mt-3">
|
||||
{!promoVisible ? (
|
||||
<button type="button" onClick={() => setPromoVisible(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline">
|
||||
<Gift className="w-3.5 h-3.5" />
|
||||
Apply Promo Code
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
|
||||
<input type="text" value={promoCode}
|
||||
onChange={(e) => { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }}
|
||||
placeholder="Enter promo code"
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())}
|
||||
className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400"
|
||||
autoFocus />
|
||||
</div>
|
||||
<button type="button" onClick={handleValidatePromo} disabled={!promoCode || promoLoading}
|
||||
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 disabled:opacity-40 text-sm font-semibold">
|
||||
{promoLoading ? '...' : 'Apply'}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setPromoVisible(false); setPromoCode(''); setPromoValidation(null); }}
|
||||
className="p-2.5 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{promoValidation && (
|
||||
<div className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{promoValidation.valid && <Check className="w-3.5 h-3.5" />}
|
||||
{promoValidation.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function SeatsPage() {
|
||||
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore();
|
||||
const [selectedSeats, setSelectedSeats] = useState<string[]>([]);
|
||||
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
|
||||
const [currentJourneyType, setCurrentJourneyType] = useState<'outbound' | 'inbound'>('outbound');
|
||||
const [modalState, setModalState] = useState({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
@@ -57,6 +58,8 @@ export default function SeatsPage() {
|
||||
type: 'info' as 'warning' | 'error' | 'success' | 'info',
|
||||
});
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
|
||||
const { data: seatMapData, isLoading, error } = useQuery({
|
||||
queryKey: ['seatmap', selectedSchedule?.id],
|
||||
queryFn: () => apiClient.get(`/seats/seatmap/${selectedSchedule?.id}`),
|
||||
@@ -70,10 +73,15 @@ export default function SeatsPage() {
|
||||
seatId: seatIds[i],
|
||||
}));
|
||||
|
||||
// For round trip inbound, swap origin and destination
|
||||
const isInbound = isRoundTrip && currentJourneyType === 'inbound';
|
||||
const originId = isInbound ? searchCriteria?.destinationStationId : searchCriteria?.originStationId;
|
||||
const destinationId = isInbound ? searchCriteria?.originStationId : searchCriteria?.destinationStationId;
|
||||
|
||||
return apiClient.post(`/seats/hold`, {
|
||||
scheduleId: selectedSchedule?.id,
|
||||
originStationId: searchCriteria?.originStationId,
|
||||
destinationStationId: searchCriteria?.destinationStationId,
|
||||
originStationId: originId,
|
||||
destinationStationId: destinationId,
|
||||
passengers: passengersForHold,
|
||||
});
|
||||
},
|
||||
@@ -157,17 +165,58 @@ export default function SeatsPage() {
|
||||
}, [passengers.length]);
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (isRoundTrip && currentJourneyType === 'outbound') {
|
||||
// Save outbound seats and show inbound
|
||||
if (selectedSeats.length > 0) {
|
||||
try {
|
||||
await holdMutation.mutateAsync(selectedSeats);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
|
||||
return {
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
} catch (error: any) {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title: 'Seat Hold Failed',
|
||||
message: error?.response?.data?.message || 'Failed to hold seats. Please try again.',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCurrentJourneyType('inbound');
|
||||
setSelectedSeats([]);
|
||||
setSelectedCoach(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Final continue (one-way or round-trip inbound)
|
||||
if (selectedSeats.length > 0) {
|
||||
await holdMutation.mutateAsync(selectedSeats);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
|
||||
return {
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
try {
|
||||
await holdMutation.mutateAsync(selectedSeats);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
|
||||
return {
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '',
|
||||
};
|
||||
});
|
||||
setPassengers(updatedPassengers);
|
||||
} catch (error: any) {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title: 'Seat Hold Failed',
|
||||
message: error?.response?.data?.message || 'Failed to hold seats. Please try again.',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
router.push('/booking/review');
|
||||
};
|
||||
@@ -449,7 +498,14 @@ export default function SeatsPage() {
|
||||
disabled={selectedSeats.length === 0 || holdMutation.isPending}
|
||||
className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg"
|
||||
>
|
||||
{holdMutation.isPending ? 'Holding seats...' : allSelected ? 'Continue' : 'Continue with partial selection'}
|
||||
{holdMutation.isPending
|
||||
? 'Holding seats...'
|
||||
: isRoundTrip && currentJourneyType === 'outbound'
|
||||
? 'Continue to Return Seats'
|
||||
: allSelected
|
||||
? 'Continue'
|
||||
: 'Continue with partial selection'
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAutoAssign}
|
||||
@@ -496,7 +552,12 @@ export default function SeatsPage() {
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
<h1 className="text-base font-bold text-gray-900 dark:text-white">Select Seats</h1>
|
||||
<h1 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
{isRoundTrip
|
||||
? (currentJourneyType === 'outbound' ? 'Select Outbound Seats' : 'Select Return Seats')
|
||||
: 'Select Seats'
|
||||
}
|
||||
</h1>
|
||||
<div className="text-sm font-semibold text-[rgb(20,113,76)]">
|
||||
{selectedSeats.length}/{passengers.length}
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,11 @@ import { useState } from 'react';
|
||||
import ModernDatePicker from '@/components/ModernDatePicker';
|
||||
|
||||
const searchSchema = z.object({
|
||||
tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']),
|
||||
originStationId: z.string().min(1),
|
||||
destinationStationId: z.string().min(1),
|
||||
departureDate: z.string().min(1),
|
||||
returnDate: z.string().optional(),
|
||||
adultCount: z.number().min(1).max(9),
|
||||
childCount: z.number().min(0).max(9),
|
||||
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
|
||||
@@ -44,6 +46,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
|
||||
resolver: zodResolver(searchSchema as any),
|
||||
defaultValues: {
|
||||
tripType: 'ONE_WAY',
|
||||
adultCount: 1,
|
||||
childCount: 0,
|
||||
nationality: 'ETHIOPIAN',
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface SearchCriteria {
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
departureDate: string;
|
||||
returnDate?: string;
|
||||
tripType: 'ONE_WAY' | 'ROUND_TRIP';
|
||||
adultCount: number;
|
||||
childCount: number;
|
||||
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';
|
||||
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
TURBO_FILTER: "@edr/freight-portal"
|
||||
APP_PATH: apps/edr-freight-web/portal
|
||||
# Browser-reachable URL; override for production deployments
|
||||
VITE_API_URL: ${FREIGHT_VITE_API_URL:-http://localhost:3001/api}
|
||||
VITE_API_URL: ${FREIGHT_VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
@@ -70,7 +70,7 @@ services:
|
||||
args:
|
||||
TURBO_FILTER: "@edr/freight-backoffice"
|
||||
APP_PATH: apps/edr-freight-web/backoffice
|
||||
VITE_API_URL: ${FREIGHT_VITE_API_URL:-http://localhost:3001/api}
|
||||
VITE_API_URL: ${FREIGHT_VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
ARG TURBO_FILTER=@edr/freight-portal
|
||||
ARG APP_PATH=apps/edr-freight-web/portal
|
||||
ARG VITE_API_URL=http://localhost:3001/api
|
||||
ARG VITE_API_URL=https://edrfreightapi.triaplc.com/api
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
|
||||
FROM node:24.15.0-alpine AS base
|
||||
|
||||
@@ -137,7 +137,7 @@ export interface ConfirmPaymentRequest {
|
||||
}
|
||||
|
||||
/** Response of `POST /payments/initiate` and shape of intent lookups. */
|
||||
export interface PaymentIntentSnapshot {
|
||||
export type PaymentIntentSnapshot ={
|
||||
intentId: string;
|
||||
service: PaymentService;
|
||||
referenceType: PaymentReferenceType;
|
||||
|
||||
@@ -2,15 +2,5 @@ export * from "./common/index";
|
||||
export * from "./freight/index";
|
||||
export * as Freight from "./freight/index";
|
||||
export * as Passenger from "./passenger/index";
|
||||
export type {
|
||||
PaymentEvent,
|
||||
PaymentEventType,
|
||||
PaymentFailedEvent,
|
||||
PaymentSucceededEvent,
|
||||
} from "./common/payments";
|
||||
export {
|
||||
type PaymentIntentSnapshot,
|
||||
type InitiatePaymentRequest,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
} from "./common/payments";
|
||||
export type { PaymentEvent, PaymentEventType, PaymentFailedEvent, PaymentSucceededEvent, PaymentIntentSnapshot, InitiatePaymentRequest } from "./common/payments";
|
||||
export { PaymentReferenceType, PaymentService } from "./common/payments";
|
||||
|
||||
Reference in New Issue
Block a user