diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a819f7939..a8f03027a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,10 +10,6 @@ on: permissions: contents: read -concurrency: - group: deploy-${{ github.ref_name }} - cancel-in-progress: true - jobs: detect-changes: name: Detect changed services @@ -73,8 +69,8 @@ jobs: fi echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api") - echo "$CHANGED" | grep -q "^apps/edr-freight-portal/" && SERVICES+=("freight-portal") - echo "$CHANGED" | grep -q "^apps/edr-freight-backoffice/" && SERVICES+=("freight-backoffice") + echo "$CHANGED" | grep -q "^apps/edr-freight-web-portal/" && SERVICES+=("freight-portal") + echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice") echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") @@ -149,12 +145,12 @@ jobs: - name: Build ${{ matrix.service }} run: | set -euo pipefail - docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" + docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}" - name: Deploy ${{ matrix.service }} run: | set -euo pipefail - docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" + docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate - name: Remove npm credentials from workspace if: always() diff --git a/.github/workflows/polinrider-scan.yml b/.github/workflows/polinrider-scan.yml deleted file mode 100644 index 594f181d1..000000000 --- a/.github/workflows/polinrider-scan.yml +++ /dev/null @@ -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] - # ... diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 32237b507..3ddef331e 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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": { diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 30b25e013..4d855e055 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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(); } } diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 393a97f9f..8d55f1dc4 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts index 395ff0386..b3305ba68 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 0091b5341..5a801cf73 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts index b0babb06f..7f3f06ec2 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 5b38c4d65..81fba19fb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 { 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 { @@ -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 { 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 { diff --git a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts index 46ef22bf8..b107e8935 100644 --- a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts +++ b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts index efffb075e..1a0cdb14f 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/customers/customers.controller.ts b/apps/edr-freight-api/src/modules/customers/customers.controller.ts index 404e4d27b..7451bd6b6 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.controller.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts index e8c3fbba0..7a63964d8 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts index ecdecffc3..661339902 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts index f7ccdde1d..c907af717 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index f8dc45a0a..9c92a036d 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -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 ?? ""; diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 736f5d274..14308883d 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 3f90628f0..b24febf91 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -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 = {}; + 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 { const booking = await this.datasource .getRepository(Booking) diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts index 4af088727..8c25d67b3 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts index 6ff10f7d5..7137ab6a5 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 3ef7693ab..2a4c3a357 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -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", }) diff --git a/apps/edr-freight-api/src/modules/trains/trains.controller.ts b/apps/edr-freight-api/src/modules/trains/trains.controller.ts index c58fc086e..0217bc161 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index c70208052..ec98a4a4b 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/scripts/seed-freight-demo.ts b/apps/edr-freight-api/src/scripts/seed-freight-demo.ts new file mode 100644 index 000000000..8f4e8d0b2 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-freight-demo.ts @@ -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); +}); diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts new file mode 100644 index 000000000..f4931f77b --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts @@ -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@)'); + } +} diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index c2705673f..a88ee8f89 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -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" }, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 0ee62fa7c..ed0a494ab 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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 = { @@ -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, ], diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index cfe22845d..a384d5956 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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: , }, + { + label: "Payments", + href: "/dashboard/payments", + icon: , + 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: , + permission: FREIGHT_PERMS.trainScheduling.view, }, { label: "Batch Board", href: "/dashboard/operations/batch-board", icon: , + permission: FREIGHT_PERMS.trainScheduling.view, }, ], }, @@ -95,11 +107,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Routes", href: "/dashboard/routes", icon: , + permission: FREIGHT_PERMS.fleet.view, }, { label: "Locomotives", href: "/dashboard/locomotives", icon: , + permission: FREIGHT_PERMS.fleet.view, }, // { // label: "Trains", @@ -115,6 +129,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Wagons", href: "/dashboard/wagons", icon: , + permission: FREIGHT_PERMS.fleet.view, }, // { // label: "Containers", @@ -135,6 +150,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "User management", href: "/dashboard/user-management", icon: , + permission: FREIGHT_PERMS.admin, children: [ { label: "Users", @@ -162,11 +178,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "File settings", href: "/dashboard/file-settings", icon: , + permission: FREIGHT_PERMS.admin, }, { label: "Dropdown settings", href: "/dashboard/dropdown-settings", icon: , + 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["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 = () => { } /> } /> + + + + } + /> } /> } /> { path="operations/train-scheduling" element={} /> - } /> + + + + } + /> } + element={ + + + + } /> } + element={ + + + + } /> } + element={ + + + + } /> } + element={ + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> {/* iframe-based user management module */} } /> @@ -307,8 +415,22 @@ const App = () => { } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> { /> } + element={ + + + + } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index 115a0d9d1..5aa78e2d3 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -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, }); diff --git a/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx b/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx new file mode 100644 index 000000000..4e35fc712 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx @@ -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 ; + return <>{children}; +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx index 0c20184cf..4589ad7fb 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -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; @@ -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, filename: string) => { const blob = await fn(); @@ -127,7 +131,7 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool )} - {canAllocateBooking(booking) ? ( + {canAllocate && canAllocateBooking(booking) ? ( + + + + {label} + + + + {value || "—"} + + + ); +} + +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 ( + + + + ); + } + + if (!company) { + return ( + + + No customer linked to this booking. + + + ); + } + + 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 ( + + + {rows.length === 0 ? ( + + No additional company details available. + + ) : ( + rows.map((row, index) => ( +
+ {index > 0 && } + +
+ )) + )} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts index c53cbccbd..80fc527cb 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts @@ -122,6 +122,7 @@ export interface BookingFileView { id: string; name: string; mimeType?: string; + code?: string; } export interface BookingDetailView { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index 36d782730..001bc6976 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -17,3 +17,4 @@ export * from "./BookingRouteServiceCard"; export * from "./BookingMileServicesCard"; export * from "./BookingCargoCard"; export * from "./BookingContractSummaryCard"; +export * from "./BookingCompanyCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx index 6d1cfb545..625e32a3a 100644 --- a/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index 3c421090a..879292143 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -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: { diff --git a/apps/edr-freight-web/backoffice/src/components/layout/types.ts b/apps/edr-freight-web/backoffice/src/components/layout/types.ts index ba37f33e5..051f28839 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/types.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/types.ts @@ -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 { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 99f817b74..c87cf300c 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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}`, diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts new file mode 100644 index 000000000..030b051a1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -0,0 +1,3 @@ +export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; + +// export const API_BASE_URL = 'http://localhost:3001'; diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts index 5aeeabbd7..cdb0d3d83 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts @@ -201,6 +201,7 @@ const ACTION_PERMISSION: Partial> = { 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, }; diff --git a/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts b/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts new file mode 100644 index 000000000..e9a09760b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts @@ -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, + }); +} diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index cadaaea03..7f3b2ac90 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -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`; } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx index 4689d28fa..6ea4c40e9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx @@ -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 PDF - {signRole && ( + {canSign && ( )} @@ -194,9 +191,7 @@ export default function BookingContractPage() { - - {signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"} - + Staff signature {usingSaved ? `Review your saved signature and approve it to execute the contract for ${data.reference}.` diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index b534b44cc..73de7ee8f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -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 ( @@ -147,6 +169,12 @@ export default function BookingRequestDetailPage() { {booking.contractSummary && ( )} + !SIGNATURE_FILE_CODES.has(f.code ?? ""), + )} + onDownload={handleDownloadFile} + /> @@ -154,6 +182,7 @@ export default function BookingRequestDetailPage() { + {showContractButton && ( diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx new file mode 100644 index 000000000..07e212b76 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -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 = { + 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 ( + + + + + + + + {value} + + + {label} + + + + + ); +} + +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("all"); + const [method, setMethod] = useState(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[] = [ + { + id: "order", + header: () => Order, + cell: ({ row }) => ( +
+

+ {row.original.merchantOrderId ?? row.original.id.slice(0, 8)} +

+

+ Booking {row.original.bookingId?.slice(0, 8) ?? "—"} +

+
+ ), + }, + { + id: "amount", + header: () => Amount, + cell: ({ row }) => ( + + {formatAmount(row.original.amount, row.original.currency)} + + ), + }, + { + id: "method", + header: () => Method, + cell: ({ row }) => ( + + {METHOD_OPTIONS.find((m) => m.value === row.original.method)?.label ?? + row.original.method} + + ), + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => ( + + {row.original.status.replace(/-/g, " ")} + + ), + }, + { + id: "date", + header: () => Date, + cell: ({ row }) => ( + + {formatDate(row.original.paidAt ?? row.original.createdAt)} + + ), + }, + ]; + + return ( +
+ + + + + + + + + + + + + { + setStatusTab((value as StatusTabKey) ?? "all"); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + > + + {STATUS_TABS.map((t) => ( + + {t.label} + + ))} + + + + + + + } + value={query} + onChange={(e) => { + setQuery(e.target.value); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + rightSection={ + query && ( + setQuery("")} + > + + + ) + } + style={{ flex: 1, minWidth: "200px" }} + radius="lg" + /> + setSignerName(e.target.value)} + placeholder="As shown on contracts" + /> +
+ + + + + + +
+
+ + ); +} diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts new file mode 100644 index 000000000..dce8aad63 --- /dev/null +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -0,0 +1,3 @@ +export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'http://localhost:3001'; + diff --git a/apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts b/apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts new file mode 100644 index 000000000..b8c9480a7 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts @@ -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"), + }); +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx deleted file mode 100644 index a6e34b7ab..000000000 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx +++ /dev/null @@ -1,1046 +0,0 @@ -import { - Box, - Grid, - Group, - SimpleGrid, - Skeleton, - Stack, - Text, -} from "@mantine/core"; -import { useQuery } from "@tanstack/react-query"; -import { format } from "date-fns"; -import { - ArrowRight, - CheckCircle2, - ChevronRight, - Clock3, - FileCheck2, - FilePen, - MapPin, - Truck, - Wallet, - Zap, - type LucideIcon, -} from "lucide-react"; -import { useMemo } from "react"; -import { Link, useNavigate } from "react-router-dom"; - -import useAuth from "@/hooks/useAuth"; -import { getMyInvoices } from "@/lib/currentCustomer"; -import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; -import { api } from "@/services/api"; - -/** Resolve a Mantine color token ("edr-slate" or "edr-green.7") to its CSS var, - * for the few places that need a raw color string (lucide icons). */ -const cv = (token: string) => { - const [name, shade] = token.split("."); - return `var(--mantine-color-${name}-${shade ?? "6"})`; -}; - -/** Format a signed percentage for KPI deltas, e.g. 16 → "+16%", -4 → "-4%". */ -const formatPct = (n: number) => `${n >= 0 ? "+" : ""}${n}%`; - -const ACTIVE_STATUSES = [ - "DRAFT", - "SUBMITTED", - "PENDING_APPROVAL", - "IN_TRANSIT", -]; - -interface StageConfig { - stage: number; - icon: LucideIcon; - iconColor: string; // Mantine color token - tile: string; // Mantine bg token - hint: string; - step: string; // stepper color token - badgeLabel: string; - badgeBg: string; - badgeText: string; - badgeDot: string; - action: { - label: string; - kind: "dark" | "amber" | "outline"; - icon?: LucideIcon; - }; -} - -const STATUS_CONFIG: Record = { - DRAFT: { - stage: 0, - icon: FilePen, - iconColor: "edr-slate", - tile: "edr-slate-soft", - hint: "Draft saved · not yet submitted", - step: "edr-step", - badgeLabel: "Draft", - badgeBg: "edr-slate-soft", - badgeText: "edr-slate", - badgeDot: "edr-step", - action: { label: "Continue", kind: "dark" }, - }, - SUBMITTED: { - stage: 1, - icon: FileCheck2, - iconColor: "edr-blue", - tile: "edr-blue-soft", - hint: "Quote being prepared by EDR", - step: "edr-blue-dot", - badgeLabel: "Reviewing", - badgeBg: "edr-blue-soft", - badgeText: "edr-blue", - badgeDot: "edr-blue-dot", - action: { label: "View", kind: "outline" }, - }, - CHANGES_REQUESTED: { - stage: 1, - icon: FilePen, - iconColor: "edr-amber-text", - tile: "edr-amber-soft", - hint: "Changes requested · please update", - step: "edr-accent", - badgeLabel: "Revise", - badgeBg: "edr-amber-soft", - badgeText: "edr-amber-text", - badgeDot: "edr-accent", - action: { label: "Update", kind: "dark" }, - }, - PENDING_APPROVAL: { - stage: 2, - icon: FileCheck2, - iconColor: "edr-blue", - tile: "edr-blue-soft", - hint: "Pending internal approval", - step: "edr-blue-dot", - badgeLabel: "Pending", - badgeBg: "edr-blue-soft", - badgeText: "edr-blue", - badgeDot: "edr-blue-dot", - action: { label: "View", kind: "outline" }, - }, - APPROVED_PENDING_SIGNATURE: { - stage: 2, - icon: FileCheck2, - iconColor: "edr-blue", - tile: "edr-blue-soft", - hint: "Approved · awaiting signature", - step: "edr-blue-dot", - badgeLabel: "For Signature", - badgeBg: "edr-blue-soft", - badgeText: "edr-blue", - badgeDot: "edr-blue-dot", - action: { label: "Review", kind: "outline" }, - }, - APPROVED: { - stage: 2, - icon: CheckCircle2, - iconColor: "edr-green.7", - tile: "edr-soft", - hint: "Quote approved · ready to sign", - step: "edr-green.5", - badgeLabel: "Approved", - badgeBg: "edr-soft", - badgeText: "edr-green.7", - badgeDot: "edr-green.5", - action: { label: "View", kind: "outline" }, - }, - CONTRACT_READY: { - stage: 2, - icon: CheckCircle2, - iconColor: "edr-green.7", - tile: "edr-soft", - hint: "Contract ready · awaiting signature", - step: "edr-green.5", - badgeLabel: "Contract Ready", - badgeBg: "edr-soft", - badgeText: "edr-green.7", - badgeDot: "edr-green.5", - action: { label: "Review", kind: "outline" }, - }, - SIGNED_CUSTOMER: { - stage: 3, - icon: CheckCircle2, - iconColor: "edr-green.7", - tile: "edr-soft", - hint: "Signed by customer · internal processing", - step: "edr-green.5", - badgeLabel: "Signed", - badgeBg: "edr-soft", - badgeText: "edr-green.7", - badgeDot: "edr-green.5", - action: { label: "View", kind: "outline" }, - }, - FULLY_EXECUTED: { - stage: 3, - icon: CheckCircle2, - iconColor: "edr-green.7", - tile: "edr-soft", - hint: "Fully executed · generating PNR", - step: "edr-green.5", - badgeLabel: "Executed", - badgeBg: "edr-soft", - badgeText: "edr-green.7", - badgeDot: "edr-green.5", - action: { label: "View", kind: "outline" }, - }, - PNR_GENERATED: { - stage: 3, - icon: FileCheck2, - iconColor: "edr-blue", - tile: "edr-blue-soft", - hint: "PNR generated · awaiting payment verification", - step: "edr-blue-dot", - badgeLabel: "PNR Ready", - badgeBg: "edr-blue-soft", - badgeText: "edr-blue", - badgeDot: "edr-blue-dot", - action: { label: "View", kind: "outline" }, - }, - PAYMENT_VERIFICATION_IN_PROGRESS: { - stage: 2, - icon: Clock3, - iconColor: "edr-amber-text", - tile: "edr-amber-soft", - hint: "Verifying payment · please wait", - step: "edr-accent", - badgeLabel: "Verifying", - badgeBg: "edr-amber-soft", - badgeText: "edr-amber-text", - badgeDot: "edr-accent", - action: { label: "View", kind: "outline" }, - }, - SELECTED_FOR_BATCH: { - stage: 2, - icon: Wallet, - iconColor: "edr-amber-text", - tile: "edr-amber-soft", - hint: "Selected for batch · payment due within 1 hour", - step: "edr-accent", - badgeLabel: "Pay Now", - badgeBg: "edr-amber-soft", - badgeText: "edr-amber-text", - badgeDot: "edr-accent", - action: { label: "Pay now", kind: "amber", icon: ArrowRight }, - }, - EXPIRED: { - stage: 1, - icon: Clock3, - iconColor: "edr-red", - tile: "edr-red-soft", - hint: "Payment window expired · contact support", - step: "edr-red", - badgeLabel: "Expired", - badgeBg: "edr-red-soft", - badgeText: "edr-red", - badgeDot: "edr-red", - action: { label: "Contact", kind: "outline" }, - }, - PAID: { - stage: 3, - icon: CheckCircle2, - iconColor: "edr-green.7", - tile: "edr-soft", - hint: "Payment received · awaiting dispatch", - step: "edr-green.5", - badgeLabel: "Paid", - badgeBg: "edr-soft", - badgeText: "edr-green.7", - badgeDot: "edr-green.5", - action: { label: "View", kind: "outline" }, - }, - IN_TRANSIT: { - stage: 3, - icon: Truck, - iconColor: "edr-green.7", - tile: "edr-soft", - hint: "In transit · on schedule", - step: "edr-green.5", - badgeLabel: "In Transit", - badgeBg: "edr-soft", - badgeText: "edr-green.7", - badgeDot: "edr-green.5", - action: { label: "Track", kind: "outline", icon: MapPin }, - }, - COMPLETED: { - stage: 4, - icon: CheckCircle2, - iconColor: "edr-slate", - tile: "edr-slate-soft2", - hint: "Completed · awaiting delivery", - step: "edr-green.5", - badgeLabel: "Completed", - badgeBg: "edr-slate-soft2", - badgeText: "edr-slate", - badgeDot: "edr-step", - action: { label: "View", kind: "outline" }, - }, - DELIVERED: { - stage: 4, - icon: CheckCircle2, - iconColor: "edr-slate", - tile: "edr-slate-soft2", - hint: "Delivered · POD ready", - step: "edr-green.5", - badgeLabel: "Delivered", - badgeBg: "edr-slate-soft2", - badgeText: "edr-slate", - badgeDot: "edr-step", - action: { label: "View POD", kind: "outline" }, - }, - CANCELLED: { - stage: 0, - icon: FilePen, - iconColor: "edr-red", - tile: "edr-red-soft", - hint: "Cancelled", - step: "edr-red", - badgeLabel: "Cancelled", - badgeBg: "edr-red-soft", - badgeText: "edr-red", - badgeDot: "edr-red", - action: { label: "View", kind: "outline" }, - }, - REJECTED: { - stage: 0, - icon: FilePen, - iconColor: "edr-red", - tile: "edr-red-soft", - hint: "Rejected · contact support", - step: "edr-red", - badgeLabel: "Rejected", - badgeBg: "edr-red-soft", - badgeText: "edr-red", - badgeDot: "edr-red", - action: { label: "Contact", kind: "outline" }, - }, - PENDING_CONSOLIDATION: { - stage: 3, - icon: Clock3, - iconColor: "edr-blue", - tile: "edr-blue-soft", - hint: "Awaiting consolidation", - step: "edr-blue-dot", - badgeLabel: "Consolidating", - badgeBg: "edr-blue-soft", - badgeText: "edr-blue", - badgeDot: "edr-blue-dot", - action: { label: "View", kind: "outline" }, - }, - CONSOLIDATED: { - stage: 3, - icon: CheckCircle2, - iconColor: "edr-green.7", - tile: "edr-soft", - hint: "Consolidated · ready for dispatch", - step: "edr-green.5", - badgeLabel: "Consolidated", - badgeBg: "edr-soft", - badgeText: "edr-green.7", - badgeDot: "edr-green.5", - action: { label: "View", kind: "outline" }, - }, -}; - -const ACTION_PROPS: Record = { - dark: { bg: "edr-ink", c: "white" }, - amber: { bg: "edr-accent", c: "white" }, - outline: { bg: "edr-card", c: "edr-text", bd: "1px solid edr-border" }, -}; - -const INVOICE_BADGE: Record< - InvoiceStatus, - { label: string; bg: string; text: string } -> = { - Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" }, - Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" }, - Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" }, - Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" }, - Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" }, -}; - -export default function MyPortalPage() { - const { user, customer } = useAuth(); - const myInvoices = useMemo(() => getMyInvoices(), []); - const navigate = useNavigate(); - - const bookingsQuery = useQuery( - api.bookings.list.queryOptions({ - input: { sortBy: "createdAt", sortOrder: "DESC" }, - }), - ); - - const dashboardQuery = useQuery(api.companies.getDashboard.queryOptions()); - const dashboard = dashboardQuery.data; - - const allBookings = bookingsQuery.data?.items ?? []; - const activeBookings = allBookings.filter((b) => - ACTIVE_STATUSES.includes(b.status), - ); - const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; - const newActiveThisWeek = activeBookings.filter( - (b) => new Date(b.createdAt).getTime() >= weekAgo, - ).length; - - const visibleBookings = allBookings; - const outstandingInvoices = myInvoices.filter( - (inv) => inv.status === "Sent" || inv.status === "Overdue", - ); - const totalOutstanding = outstandingInvoices.reduce( - (sum, inv) => sum + inv.amount, - 0, - ); - const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—"; - const companyName = (customer as any)?.companyName ?? displayName; - - const hour = new Date().getHours(); - const greeting = - hour < 12 - ? "Good morning," - : hour < 18 - ? "Good afternoon," - : "Good evening,"; - const recentInvoices = myInvoices.slice(0, 3); - - const volumePoints = dashboard?.freightVolume.monthly ?? []; - const maxVolume = Math.max(1, ...volumePoints.map((p) => p.tonnes)); - - return ( - - {/* ── Hello Row ─────────────────────────────────────────────────────── */} - - - - {greeting} - - - {companyName} 👋 - - - - {/* Book a shipment CTA */} - - - - - - - Book a shipment - - - - - - - - - - {!customer && ( - - - - - Setup your Company Profile - - - Complete your company information to unlock all features and - start booking shipments. - - - - - Complete Setup - - - - - - - - - - - )} - - {/* ── Stats Strip ───────────────────────────────────────────────────── */} - - - - - - - - - - {/* ── Mid Row: Shipments + Invoices ─────────────────────────────────── */} - - - - - - - My Shipments - - - From draft to delivery — every booking in one place - - - - - {bookingsQuery.isPending ? ( - - {[1, 2, 3, 4].map((i) => ( - - ))} - - ) : visibleBookings.length === 0 ? ( - - ) : ( - - {visibleBookings.map((booking, i) => ( - navigate(`/bookings/${booking.id}`)} - /> - ))} - - )} - - - - {/* Invoices */} - - - - - Invoices - - - - - View all - - - - - - - {/* Outstanding card */} - - - Outstanding balance - - - {formatCurrency(totalOutstanding || 377500, "ETB")} - - - - {outstandingInvoices.length || 2} invoices unpaid - - - - - Pay all - - - - - - {/* Invoice list */} - {recentInvoices.length === 0 ? ( - - ) : ( - - {recentInvoices.map((invoice, i) => { - const badge = INVOICE_BADGE[invoice.status]; - const dueText = - invoice.status === "Paid" - ? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}` - : invoice.status === "Overdue" - ? "Overdue 3 days" - : `Due ${invoice.dueDate}`; - const DueIcon = - invoice.status === "Paid" ? CheckCircle2 : Clock3; - const dueIconColor = - invoice.status === "Paid" - ? cv("edr-green.5") - : cv("edr-muted"); - return ( - - {i > 0 && } - - - - - {invoice.number} - - - {invoice.bookingReference} - - - - {formatCurrency(invoice.amount, invoice.currency)} - - - - - - - {dueText} - - - - - {badge.label} - - - - - - ); - })} - - )} - - - - - {/* ── Bottom Row: Freight Volume + Recent Activity ──────────────────── */} - - - - - Freight Volume - - - {dashboardQuery.isPending ? ( - - ) : ( - <> - - {(dashboard?.freightVolume.totalTonnes ?? 0).toLocaleString()}{" "} - t - - - {formatCurrency( - dashboard?.freightVolume.totalValue ?? 0, - (dashboard?.freightVolume.currency ?? "ETB") as Currency, - )} - - - {formatPct(dashboard?.freightVolume.ytdChangePct ?? 0)} YTD - - - )} - - {dashboardQuery.isPending ? ( - - ) : volumePoints.length === 0 ? ( - - - No freight volume yet. - - - ) : ( - - {volumePoints.map((point, i) => { - const isLast = i === volumePoints.length - 1; - return ( - - - - {point.month} - - - ); - })} - - )} - - - - - - - - Recent Activity - - - - - View all - - - - - - - {bookingsQuery.isPending ? ( - - {[1, 2, 3, 4, 5].map((i) => ( - - ))} - - ) : allBookings.length === 0 ? ( - - ) : ( - - {allBookings.slice(0, 6).map((booking) => ( - navigate(`/bookings/${booking.id}`)} - /> - ))} - - )} - - - - - ); -} - -// ── Sub-components ───────────────────────────────────────────────────────────── - -function Card({ - children, - className = "", - padding = 24, -}: { - children: React.ReactNode; - className?: string; - padding?: number; -}) { - return ( - - {children} - - ); -} - -function StatKpi({ - icon: Icon, - label, - value, - delta, - deltaColor, - divider, -}: { - icon: LucideIcon; - label: string; - value: string; - delta: string; - deltaColor: string; - divider?: boolean; -}) { - return ( - - - - - {label} - - - - - {value} - - - {delta} - - - - ); -} - -function Stepper({ stage, color }: { stage: number; color: string }) { - return ( - - {[0, 1, 2, 3, 4].map((i) => { - const done = i < stage; - const active = i === stage; - const size = active ? 12 : done ? 9 : 8; - return ( - - - {i < 4 && ( - - )} - - ); - })} - - ); -} - -function BookingRow({ - booking, - last, - onClick, -}: { - booking: any; - last: boolean; - onClick: () => void; -}) { - const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT; - const Icon = cfg.icon; - const AIcon = cfg.action.icon; - const ap = ACTION_PROPS[cfg.action.kind]; - const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—"; - const dest = - booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"; - const commodity = - (typeof booking.cargoType === "string" - ? booking.cargoType - : booking.cargoType?.name) ?? - booking.commodity ?? - "Freight"; - - return ( - - - - - - - - - {booking.reference} - - - {commodity} · {origin} → {dest} - - - - - - {cfg.hint} - - - - - - - - - {cfg.badgeLabel} - - - - - {cfg.action.label} - - {AIcon && ( - - )} - - - - - ); -} - -function ActivityRow({ - booking, - onClick, -}: { - booking: any; - onClick: () => void; -}) { - const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT; - const Icon = cfg.icon; - const verb = - booking.status === "IN_TRANSIT" - ? "departed" - : booking.status === "COMPLETED" - ? "delivered" - : booking.status === "PENDING_APPROVAL" - ? "quote ready" - : booking.status === "SUBMITTED" - ? "submitted for review" - : "created"; - return ( - - - - - - - Booking {booking.reference} {verb} - - - {booking.originYard?.label ?? booking.originYard?.code ?? "—"} →{" "} - {booking.destinationYard?.label ?? - booking.destinationYard?.code ?? - "—"} - - - - {format(new Date(booking.createdAt), "MMM d")} - - - ); -} - -function EmptyState({ message }: { message: string }) { - return ( - - - {message} - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx new file mode 100644 index 000000000..0a4dcb6ce --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx @@ -0,0 +1,104 @@ +import type { Currency } from "@/pages/billing/invoices.mock"; +import { formatCurrency } from "@/pages/billing/invoices.mock"; +import { Grid, Stack } from "@mantine/core"; +import { useNavigate } from "react-router-dom"; +import { + FreightVolumeSection, + HelloSection, + InvoicesSection, + RecentActivitySection, + SetupPrompt, + ShipmentsSection, + StatsSection, +} from "./components"; +import { useMyPortalData } from "./hooks"; + +export default function MyPortalPage() { + const navigate = useNavigate(); + const { + customer, + bookingsQuery, + dashboardQuery, + allBookings, + activeBookings, + newActiveThisWeek, + outstandingInvoices, + totalOutstanding, + companyName, + greeting, + recentInvoices, + dashboard, + volumePoints, + maxVolume, + } = useMyPortalData(); + + const handleBookingClick = (id: string) => { + navigate(`/bookings/${id}`); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx new file mode 100644 index 000000000..a65aa009e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx @@ -0,0 +1,61 @@ +import { Box, Group, Text } from "@mantine/core"; +import { format } from "date-fns"; +import { memo } from "react"; +import { STATUS_CONFIG, cv } from "../constants"; + +interface ActivityRowProps { + booking: any; + onClick: () => void; +} + +export const ActivityRow = memo(function ActivityRow({ + booking, + onClick, +}: ActivityRowProps) { + const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT; + const Icon = cfg.icon; + const verb = + booking.status === "IN_TRANSIT" + ? "departed" + : booking.status === "COMPLETED" + ? "delivered" + : booking.status === "PENDING_APPROVAL" + ? "quote ready" + : booking.status === "SUBMITTED" + ? "submitted for review" + : "created"; + + return ( + + + + + + + Booking {booking.reference} {verb} + + + {booking.originYard?.label ?? booking.originYard?.code ?? "—"} →{" "} + {booking.destinationYard?.label ?? + booking.destinationYard?.code ?? + "—"} + + + + {format(new Date(booking.createdAt), "MMM d")} + + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx new file mode 100644 index 000000000..27b923467 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx @@ -0,0 +1,104 @@ +import { Box, Group, Stack, Text } from "@mantine/core"; +import { memo } from "react"; +import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants"; +import { Stepper } from "./Stepper"; + +interface BookingRowProps { + booking: any; + last: boolean; + onClick: () => void; +} + +export const BookingRow = memo(function BookingRow({ + booking, + last, + onClick, +}: BookingRowProps) { + const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT; + const Icon = cfg.icon; + const AIcon = cfg.action.icon; + const ap = ACTION_PROPS[cfg.action.kind]; + const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—"; + const dest = + booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"; + const commodity = + (typeof booking.cargoType === "string" + ? booking.cargoType + : booking.cargoType?.name) ?? + booking.commodity ?? + "Freight"; + + return ( + + + + + + + + + {booking.reference} + + + {commodity} · {origin} → {dest} + + + + + + {cfg.hint} + + + + + + + + + {cfg.badgeLabel} + + + + + {cfg.action.label} + + {AIcon && ( + + )} + + + + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/Card.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/Card.tsx new file mode 100644 index 000000000..2f24d0a59 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/Card.tsx @@ -0,0 +1,18 @@ +import { Box } from "@mantine/core"; + +interface CardProps { + children: React.ReactNode; + className?: string; + padding?: number; +} + +export function Card({ children, className = "", padding = 24 }: CardProps) { + return ( + + {children} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/EmptyState.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/EmptyState.tsx new file mode 100644 index 000000000..66f3f16a3 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/EmptyState.tsx @@ -0,0 +1,18 @@ +import { Box, Text } from "@mantine/core"; + +interface EmptyStateProps { + message: string; +} + +export function EmptyState({ message }: EmptyStateProps) { + return ( + + + {message} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx new file mode 100644 index 000000000..27b74cb6f --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx @@ -0,0 +1,82 @@ +import { Box, Group, Skeleton, Text } from "@mantine/core"; +import { memo } from "react"; +import type { Currency } from "@/pages/billing/invoices.mock"; +import { formatCurrency } from "@/pages/billing/invoices.mock"; +import { formatPct } from "../constants"; +import { Card } from "./Card"; + +interface FreightVolumeSectionProps { + totalTonnes: number; + totalValue: number; + currency: Currency; + ytdChangePct: number; + volumePoints: Array<{ month: string; tonnes: number }>; + maxVolume: number; + isLoading: boolean; +} + +export const FreightVolumeSection = memo(function FreightVolumeSection({ + totalTonnes, + totalValue, + currency, + ytdChangePct, + volumePoints, + maxVolume, + isLoading, +}: FreightVolumeSectionProps) { + return ( + + + Freight Volume + + + {isLoading ? ( + + ) : ( + <> + + {totalTonnes.toLocaleString()} t + + + {formatCurrency(totalValue, currency)} + + + {formatPct(ytdChangePct)} YTD + + + )} + + {isLoading ? ( + + ) : volumePoints.length === 0 ? ( + + + No freight volume yet. + + + ) : ( + + {volumePoints.map((point, i) => { + const isLast = i === volumePoints.length - 1; + return ( + + + + {point.month} + + + ); + })} + + )} + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx new file mode 100644 index 000000000..9f1d726b0 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx @@ -0,0 +1,51 @@ +import { Box, Group, Text } from "@mantine/core"; +import { ArrowRight, Truck } from "lucide-react"; +import { memo } from "react"; +import { Link } from "react-router-dom"; +import { cv } from "../constants"; + +interface HelloSectionProps { + greeting: string; + companyName: string; +} + +export const HelloSection = memo(function HelloSection({ + greeting, + companyName, +}: HelloSectionProps) { + return ( + + + + {greeting} + + + {companyName} 👋 + + + + + + + + + + Book a shipment + + + + + + + + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx new file mode 100644 index 000000000..4e2c30343 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx @@ -0,0 +1,154 @@ +import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock"; +import { formatCurrency } from "@/pages/billing/invoices.mock"; +import { Box, Group, Stack, Text } from "@mantine/core"; +import { format } from "date-fns"; +import { CheckCircle2, ChevronRight, Clock3, Zap } from "lucide-react"; +import { memo } from "react"; +import { Link } from "react-router-dom"; +import { cv, INVOICE_BADGE } from "../constants"; +import { Card } from "./Card"; +import { EmptyState } from "./EmptyState"; + +interface InvoicesSectionProps { + invoices: Array<{ + id: number; + number: string; + bookingReference: string; + amount: number; + currency: Currency; + status: InvoiceStatus; + dueDate: string; + paidDate: string | null; + }>; +} + +export const InvoicesSection = memo(function InvoicesSection({ + invoices, +}: InvoicesSectionProps) { + const outstandingInvoices = invoices.filter( + (inv) => inv.status === "Sent" || inv.status === "Overdue", + ); + const totalOutstanding = outstandingInvoices.reduce( + (sum, inv) => sum + inv.amount, + 0, + ); + + return ( + + + + Invoices + + + + + View all + + + + + + + + + Outstanding balance + + + {formatCurrency(totalOutstanding || 377500, "ETB")} + + + + {outstandingInvoices.length || 2} invoices unpaid + + + + + Pay all + + + + + + {invoices.length === 0 ? ( + + ) : ( + + {invoices.map((invoice, i) => { + const badge = INVOICE_BADGE[invoice.status]; + const dueText = + invoice.status === "Paid" + ? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}` + : invoice.status === "Overdue" + ? "Overdue 3 days" + : `Due ${invoice.dueDate}`; + const DueIcon = + invoice.status === "Paid" ? CheckCircle2 : Clock3; + const dueIconColor = + invoice.status === "Paid" + ? cv("edr-green.5") + : cv("edr-muted"); + + return ( + + {i > 0 && } + + + + + {invoice.number} + + + {invoice.bookingReference} + + + + {formatCurrency(invoice.amount, invoice.currency)} + + + + + + + {dueText} + + + + + {badge.label} + + + + + + ); + })} + + )} + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/RecentActivitySection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/RecentActivitySection.tsx new file mode 100644 index 000000000..44a8e7fa6 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/RecentActivitySection.tsx @@ -0,0 +1,58 @@ +import { ChevronRight } from "lucide-react"; +import { Group, Skeleton, Stack, Text } from "@mantine/core"; +import { memo } from "react"; +import { Link } from "react-router-dom"; +import { cv } from "../constants"; +import { ActivityRow } from "./ActivityRow"; +import { Card } from "./Card"; +import { EmptyState } from "./EmptyState"; + +interface RecentActivitySectionProps { + bookings: any[]; + isLoading: boolean; + onBookingClick: (id: string) => void; +} + +export const RecentActivitySection = memo(function RecentActivitySection({ + bookings, + isLoading, + onBookingClick, +}: RecentActivitySectionProps) { + return ( + + + + Recent Activity + + + + + View all + + + + + + + {isLoading ? ( + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + ) : bookings.length === 0 ? ( + + ) : ( + + {bookings.slice(0, 6).map((booking) => ( + onBookingClick(booking.id)} + /> + ))} + + )} + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/SetupPrompt.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/SetupPrompt.tsx new file mode 100644 index 000000000..adb3c65ef --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/SetupPrompt.tsx @@ -0,0 +1,70 @@ +import { Box, Group, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, Truck, AlertTriangle } from "lucide-react"; +import { memo } from "react"; +import { Link } from "react-router-dom"; +import { api } from "@/services/api"; +import type { ProfileResponse } from "@/types/profile"; +import { cv } from "../constants"; + +const REQUIRED_FIELDS: (keyof ProfileResponse)[] = [ + "companyEmail", + "companyPhone", + "companyAddress", + "fanNumber", + "contactPersonName", + "contactPersonPhone", + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", +]; + +function isProfileIncomplete(profile?: ProfileResponse | null): boolean { + if (!profile) return true; + return REQUIRED_FIELDS.some((field) => !profile[field]); +} + +interface SetupPromptProps { + show: boolean; +} + +export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) { + const profileQuery = useQuery( + api.companies.getProfile.queryOptions({ retry: false }), + ); + + const incomplete = !profileQuery.isPending && isProfileIncomplete(profileQuery.data); + + if (!show && !incomplete) return null; + + return ( + + + + + {incomplete && } + + {incomplete ? "Complete Your Profile" : "Setup your Company Profile"} + + + + {incomplete + ? "Your company profile is incomplete. Fill in the missing details to unlock all features." + : "Complete your company information to unlock all features and start booking shipments."} + + + + + {incomplete ? "Complete Profile" : "Complete Setup"} + + + + + + + + + + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ShipmentsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ShipmentsSection.tsx new file mode 100644 index 000000000..d4237914b --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ShipmentsSection.tsx @@ -0,0 +1,53 @@ +import { Box, Group, Skeleton, Stack, Text } from "@mantine/core"; +import { memo } from "react"; +import { BookingRow } from "./BookingRow"; +import { Card } from "./Card"; +import { EmptyState } from "./EmptyState"; + +interface ShipmentsSectionProps { + bookings: any[]; + isLoading: boolean; + onBookingClick: (id: string) => void; +} + +export const ShipmentsSection = memo(function ShipmentsSection({ + bookings, + isLoading, + onBookingClick, +}: ShipmentsSectionProps) { + return ( + + + + + My Shipments + + + From draft to delivery — every booking in one place + + + + + {isLoading ? ( + + {[1, 2, 3, 4].map((i) => ( + + ))} + + ) : bookings.length === 0 ? ( + + ) : ( + + {bookings.map((booking, i) => ( + onBookingClick(booking.id)} + /> + ))} + + )} + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx new file mode 100644 index 000000000..413caafc4 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx @@ -0,0 +1,46 @@ +import { Box, Group, Text } from "@mantine/core"; +import { memo } from "react"; +import type { LucideIcon } from "lucide-react"; +import { cv } from "../constants"; + +interface StatKpiProps { + icon: LucideIcon; + label: string; + value: string; + delta: string; + deltaColor: string; + divider?: boolean; +} + +export const StatKpi = memo(function StatKpi({ + icon: Icon, + label, + value, + delta, + deltaColor, + divider, +}: StatKpiProps) { + return ( + + + + + {label} + + + + + {value} + + + {delta} + + + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx new file mode 100644 index 000000000..8a0545fb7 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx @@ -0,0 +1,70 @@ +import { SimpleGrid } from "@mantine/core"; +import { CheckCircle2, Clock3, Truck, Wallet } from "lucide-react"; +import { memo } from "react"; +import { formatCurrency } from "@/pages/billing/invoices.mock"; +import { formatPct } from "../constants"; +import { Card } from "./Card"; +import { StatKpi } from "./StatKpi"; + +interface StatsSectionProps { + activeBookingsLength: number; + newActiveThisWeek: number; + bookingsLoading: boolean; + outstandingInvoicesLength: number; + totalOutstanding: number; + deliveredCount: string | undefined; + completionRate: string | undefined; + spendYtd: string | undefined; + spendYtdChangePct: number | undefined; + dashboardLoading: boolean; +} + +export const StatsSection = memo(function StatsSection({ + activeBookingsLength, + newActiveThisWeek, + bookingsLoading, + outstandingInvoicesLength, + totalOutstanding, + deliveredCount, + completionRate, + spendYtd, + spendYtdChangePct, +}: StatsSectionProps) { + return ( + + + + + + + + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/Stepper.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/Stepper.tsx new file mode 100644 index 000000000..820ba6034 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/Stepper.tsx @@ -0,0 +1,42 @@ +import { Box, Group } from "@mantine/core"; +import { memo } from "react"; + +interface StepperProps { + stage: number; + color: string; +} + +export const Stepper = memo(function Stepper({ stage, color }: StepperProps) { + return ( + + {[0, 1, 2, 3, 4].map((i) => { + const done = i < stage; + const active = i === stage; + const size = active ? 12 : done ? 9 : 8; + return ( + + + {i < 4 && ( + + )} + + ); + })} + + ); +}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts new file mode 100644 index 000000000..de3ddb448 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts @@ -0,0 +1,13 @@ +export { ActivityRow } from "./ActivityRow"; +export { BookingRow } from "./BookingRow"; +export { Card } from "./Card"; +export { EmptyState } from "./EmptyState"; +export { FreightVolumeSection } from "./FreightVolumeSection"; +export { HelloSection } from "./HelloSection"; +export { InvoicesSection } from "./InvoicesSection"; +export { RecentActivitySection } from "./RecentActivitySection"; +export { SetupPrompt } from "./SetupPrompt"; +export { ShipmentsSection } from "./ShipmentsSection"; +export { StatKpi } from "./StatKpi"; +export { StatsSection } from "./StatsSection"; +export { Stepper } from "./Stepper"; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts new file mode 100644 index 000000000..d1231406c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -0,0 +1,340 @@ +import { + ArrowRight, + CheckCircle2, + Clock3, + FileCheck2, + FilePen, + MapPin, + Truck, + Wallet, + type LucideIcon, +} from "lucide-react"; +import type { InvoiceStatus } from "@/pages/billing/invoices.mock"; + +export const cv = (token: string) => { + const [name, shade] = token.split("."); + return `var(--mantine-color-${name}-${shade ?? "6"})`; +}; + +export const formatPct = (n: number) => `${n >= 0 ? "+" : ""}${n}%`; + +export const ACTIVE_STATUSES = [ + "DRAFT", + "SUBMITTED", + "PENDING_APPROVAL", + "IN_TRANSIT", +]; + +export interface StageConfig { + stage: number; + icon: LucideIcon; + iconColor: string; + tile: string; + hint: string; + step: string; + badgeLabel: string; + badgeBg: string; + badgeText: string; + badgeDot: string; + action: { + label: string; + kind: "dark" | "amber" | "outline"; + icon?: LucideIcon; + }; +} + +export const STATUS_CONFIG: Record = { + DRAFT: { + stage: 0, + icon: FilePen, + iconColor: "edr-slate", + tile: "edr-slate-soft", + hint: "Draft saved · not yet submitted", + step: "edr-step", + badgeLabel: "Draft", + badgeBg: "edr-slate-soft", + badgeText: "edr-slate", + badgeDot: "edr-step", + action: { label: "Continue", kind: "dark" }, + }, + SUBMITTED: { + stage: 1, + icon: FileCheck2, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Quote being prepared by EDR", + step: "edr-blue-dot", + badgeLabel: "Reviewing", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + CHANGES_REQUESTED: { + stage: 1, + icon: FilePen, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Changes requested · please update", + step: "edr-accent", + badgeLabel: "Revise", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Update", kind: "dark" }, + }, + PENDING_APPROVAL: { + stage: 2, + icon: FileCheck2, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Pending internal approval", + step: "edr-blue-dot", + badgeLabel: "Pending", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + APPROVED_PENDING_SIGNATURE: { + stage: 2, + icon: FileCheck2, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Approved · awaiting signature", + step: "edr-blue-dot", + badgeLabel: "For Signature", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "Review", kind: "outline" }, + }, + APPROVED: { + stage: 2, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Quote approved · ready to sign", + step: "edr-green.5", + badgeLabel: "Approved", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + CONTRACT_READY: { + stage: 2, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Contract ready · awaiting signature", + step: "edr-green.5", + badgeLabel: "Contract Ready", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "Review", kind: "outline" }, + }, + SIGNED_CUSTOMER: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Signed by customer · internal processing", + step: "edr-green.5", + badgeLabel: "Signed", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + FULLY_EXECUTED: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Fully executed · generating PNR", + step: "edr-green.5", + badgeLabel: "Executed", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + PNR_GENERATED: { + stage: 3, + icon: FileCheck2, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "PNR generated · awaiting payment verification", + step: "edr-blue-dot", + badgeLabel: "PNR Ready", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + PAYMENT_VERIFICATION_IN_PROGRESS: { + stage: 2, + icon: Clock3, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Verifying payment · please wait", + step: "edr-accent", + badgeLabel: "Verifying", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "View", kind: "outline" }, + }, + SELECTED_FOR_BATCH: { + stage: 2, + icon: Wallet, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Selected for batch · payment due within 1 hour", + step: "edr-accent", + badgeLabel: "Pay Now", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Pay now", kind: "amber", icon: ArrowRight }, + }, + EXPIRED: { + stage: 1, + icon: Clock3, + iconColor: "edr-red", + tile: "edr-red-soft", + hint: "Payment window expired · contact support", + step: "edr-red", + badgeLabel: "Expired", + badgeBg: "edr-red-soft", + badgeText: "edr-red", + badgeDot: "edr-red", + action: { label: "Contact", kind: "outline" }, + }, + PAID: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Payment received · awaiting dispatch", + step: "edr-green.5", + badgeLabel: "Paid", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + IN_TRANSIT: { + stage: 3, + icon: Truck, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "In transit · on schedule", + step: "edr-green.5", + badgeLabel: "In Transit", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "Track", kind: "outline", icon: MapPin }, + }, + COMPLETED: { + stage: 4, + icon: CheckCircle2, + iconColor: "edr-slate", + tile: "edr-slate-soft2", + hint: "Completed · awaiting delivery", + step: "edr-green.5", + badgeLabel: "Completed", + badgeBg: "edr-slate-soft2", + badgeText: "edr-slate", + badgeDot: "edr-step", + action: { label: "View", kind: "outline" }, + }, + DELIVERED: { + stage: 4, + icon: CheckCircle2, + iconColor: "edr-slate", + tile: "edr-slate-soft2", + hint: "Delivered · POD ready", + step: "edr-green.5", + badgeLabel: "Delivered", + badgeBg: "edr-slate-soft2", + badgeText: "edr-slate", + badgeDot: "edr-step", + action: { label: "View POD", kind: "outline" }, + }, + CANCELLED: { + stage: 0, + icon: FilePen, + iconColor: "edr-red", + tile: "edr-red-soft", + hint: "Cancelled", + step: "edr-red", + badgeLabel: "Cancelled", + badgeBg: "edr-red-soft", + badgeText: "edr-red", + badgeDot: "edr-red", + action: { label: "View", kind: "outline" }, + }, + REJECTED: { + stage: 0, + icon: FilePen, + iconColor: "edr-red", + tile: "edr-red-soft", + hint: "Rejected · contact support", + step: "edr-red", + badgeLabel: "Rejected", + badgeBg: "edr-red-soft", + badgeText: "edr-red", + badgeDot: "edr-red", + action: { label: "Contact", kind: "outline" }, + }, + PENDING_CONSOLIDATION: { + stage: 3, + icon: Clock3, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Awaiting consolidation", + step: "edr-blue-dot", + badgeLabel: "Consolidating", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + CONSOLIDATED: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Consolidated · ready for dispatch", + step: "edr-green.5", + badgeLabel: "Consolidated", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, +}; + +export const ACTION_PROPS: Record< + string, + { bg: string; c: string; bd?: string } +> = { + dark: { bg: "edr-ink", c: "white" }, + amber: { bg: "edr-accent", c: "white" }, + outline: { bg: "edr-card", c: "edr-text", bd: "1px solid edr-border" }, +}; + +export const INVOICE_BADGE: Record< + InvoiceStatus, + { label: string; bg: string; text: string } +> = { + Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" }, + Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" }, + Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" }, + Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" }, + Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" }, +}; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts new file mode 100644 index 000000000..fe2701840 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts @@ -0,0 +1,74 @@ +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import useAuth from "@/hooks/useAuth"; +import { getMyInvoices } from "@/lib/currentCustomer"; +import { api } from "@/services/api"; +import { ACTIVE_STATUSES } from "./constants"; + +export function useMyPortalData() { + const { user, customer } = useAuth(); + const myInvoices = useMemo(() => getMyInvoices(), []); + + const bookingsQuery = useQuery( + api.bookings.list.queryOptions({ + input: { sortBy: "createdAt", sortOrder: "DESC" }, + }), + ); + + const dashboardQuery = useQuery(api.companies.getDashboard.queryOptions()); + + const allBookings = bookingsQuery.data?.items ?? []; + const activeBookings = allBookings.filter((b) => + ACTIVE_STATUSES.includes(b.status), + ); + + const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const newActiveThisWeek = activeBookings.filter( + (b) => new Date(b.createdAt).getTime() >= weekAgo, + ).length; + + const outstandingInvoices = myInvoices.filter( + (inv) => inv.status === "Sent" || inv.status === "Overdue", + ); + + const totalOutstanding = outstandingInvoices.reduce( + (sum, inv) => sum + inv.amount, + 0, + ); + + const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—"; + const companyName = (customer as any)?.companyName ?? displayName; + + const hour = new Date().getHours(); + const greeting = + hour < 12 + ? "Good morning," + : hour < 18 + ? "Good afternoon," + : "Good evening,"; + + const recentInvoices = myInvoices.slice(0, 3); + + const dashboard = dashboardQuery.data; + const volumePoints = dashboard?.freightVolume.monthly ?? []; + const maxVolume = Math.max(1, ...volumePoints.map((p) => p.tonnes)); + + return { + user, + customer, + bookingsQuery, + dashboardQuery, + allBookings, + activeBookings, + newActiveThisWeek, + outstandingInvoices, + totalOutstanding, + companyName, + greeting, + recentInvoices, + dashboard, + volumePoints, + maxVolume, + myInvoices, + }; +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/index.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/index.ts new file mode 100644 index 000000000..9b18dc3af --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/index.ts @@ -0,0 +1 @@ +export { default } from "./MyPortalPage"; diff --git a/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx b/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx new file mode 100644 index 000000000..4c30c878a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx @@ -0,0 +1,19 @@ +import { MySignatureCard } from "@/components/profile/MySignatureCard"; + +export default function MySignaturePage() { + return ( +
+
+
+

+ My signature +

+

+ Saved and reused to approve and sign booking contracts. +

+
+ +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 50d55f478..6f0b17022 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -1,46 +1,51 @@ -import { useSearchParams } from "react-router-dom"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/services/api"; +import type { ProfileResponse } from "@/types/profile"; import { + Alert, + Card, + Center, Container, Group, - Stack, - Title, - Text, - Tabs, - Card, - TextInput, - Button, - Badge, - Alert, - Center, Loader, - Grid, + Tabs, + Text, + Title, } from "@mantine/core"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, - Building2, Briefcase, - CheckCircle2, + Building2, FileCheck, - Save, User, UserCheck, - XCircle, } from "lucide-react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { api } from "@/services/api"; -import type { CreateCompanyPayload } from "@/services/companies.service"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { useCallback, useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import TabCompanyProfile from "./settings/TabCompanyProfile"; import TabContactPerson from "./settings/TabContactPerson"; +import TabDocuments from "./settings/TabDocuments"; import TabGeneralManager from "./settings/TabGeneralManager"; import TabPowerOfAttorney from "./settings/TabPowerOfAttorney"; -import TabDocuments from "./settings/TabDocuments"; type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents"; +function tabIncomplete(tabId: SettingsTab, profile?: ProfileResponse | null): boolean { + if (!profile) return false; + switch (tabId) { + case "company": + return !profile.companyEmail || !profile.companyPhone || !profile.companyAddress || !profile.fanNumber; + case "contact": + return !profile.contactPersonName || !profile.contactPersonPhone; + case "gm": + return !profile.generalManagerName || !profile.generalManagerEmail || !profile.generalManagerPhone; + case "poa": + return false; + case "documents": + return false; + } +} + const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ { id: "company", label: "Company Profile", icon: }, { id: "contact", label: "Contact Person", icon: }, @@ -50,71 +55,68 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ ]; export default function SettingsPage() { + const navigate = useNavigate(); const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); const tab = (searchParams.get("tab") as SettingsTab) || "company"; - const setTab = (t: SettingsTab) => { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - next.set("tab", t); - return next; - }, - { replace: true }, - ); - }; + const setTab = useCallback( + (t: SettingsTab) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set("tab", t); + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); - const profileQuery = useQuery(api.companies.getProfile.queryOptions()); + const profileQuery = useQuery( + api.companies.getProfile.queryOptions({ + retry: false, + refetchOnWindowFocus: false, + }), + ); const profile = profileQuery.data; - const createCompanyMutation = useMutation({ - mutationFn: (payload: CreateCompanyPayload) => - api.companies.create.call(payload), - onSuccess: () => { + useEffect(() => { + if (profileQuery.dataUpdatedAt > 0) { queryClient.invalidateQueries({ - queryKey: api.companies.getProfile.queryKey(), + queryKey: api.companies.getInfo.queryKey(), }); - }, - }); + } + }, [profileQuery.dataUpdatedAt, queryClient]); - const onboardingSchema = z.object({ - companyName: z.string().min(1, "Company name is required"), - companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1, "Country code is required"), - companyLocation: z.string().min(1, "Location is required"), - companyAddress: z.string().min(1, "Address is required"), - tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), - fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), - }); + const [isOnboarding, setIsOnboarding] = useState(null); - type OnboardingFormData = z.infer; + useEffect(() => { + if (profileQuery.isFetched && isOnboarding === null) { + setIsOnboarding(!profileQuery.data); + } + }, [profileQuery.isFetched, profileQuery.data, isOnboarding]); - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(onboardingSchema), - defaultValues: { - companyPhoneCountryCode: "+251", - }, - }); + const handleOnboardingSuccess = useCallback(() => { + setTab("contact"); + }, [setTab]); - const onSubmitOnboarding = (data: OnboardingFormData) => { - const payload: CreateCompanyPayload = { - companyType: "customer", - companyName: data.companyName, - companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, - companyLocation: data.companyLocation, - companyAddress: data.companyAddress, - tin: data.tinNumber, - fanNumber: data.fanNumber, - }; - createCompanyMutation.mutate(payload); - }; + const handleContactContinue = useCallback(() => { + setTab("gm"); + }, [setTab]); + + const handleGMContinue = useCallback(() => { + setTab("poa"); + }, [setTab]); + + const handlePOAContinue = useCallback(() => { + setTab("documents"); + }, [setTab]); + + const handleDocumentsContinue = useCallback(() => { + navigate("/portal"); + }, [navigate]); if (profileQuery.isPending) { return ( @@ -124,25 +126,54 @@ export default function SettingsPage() { ); } + const onboarding = isOnboarding === true; + + const renderProfileContent = (children: React.ReactNode) => { + if (onboarding && tab !== "company" && !profile) { + return ( +
+ +
+ ); + } + if (!profile) { + return ( + +
+ } + color="gray" + variant="light" + > + Please complete the company profile first. + +
+
+ ); + } + return children; + }; + return ( - +
- Account Settings + {onboarding ? "Complete Your Profile" : "Account Settings"} - Manage your company profile, personnel, and documents + {onboarding + ? "Set up your company profile, personnel, and documents to get started" + : "Manage your company profile, personnel, and documents"}
- {profile && Verified}
{ if (!value) return; - if (!profile && value !== "company") return; + // if (onboarding) return; setTab(value as SettingsTab); }} > @@ -152,7 +183,12 @@ export default function SettingsPage() { key={t.id} value={t.id} leftSection={t.icon} - disabled={!profile && t.id !== "company"} + disabled={!onboarding && !profile && t.id !== "company"} + rightSection={ + !onboarding && profile && tabIncomplete(t.id, profile) ? ( + + ) : undefined + } > {t.label} @@ -161,201 +197,52 @@ export default function SettingsPage() { {!profile ? ( - - - - - Company Profile - - - Enter your company registration details to get started - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {createCompanyMutation.isSuccess && ( - - - - Profile created successfully - - - )} - {createCompanyMutation.isError && ( - - - - Failed to create profile - - - )} - - - -
-
+ ) : ( - + )}
- {profile ? ( - - ) : ( - -
- } - color="gray" - variant="light" - > - Please complete the company profile first. - -
-
+ {renderProfileContent( + , )}
- {profile ? ( - - ) : ( - -
- } - color="gray" - variant="light" - > - Please complete the company profile first. - -
-
+ {renderProfileContent( + , )}
- {profile ? ( - - ) : ( - -
- } - color="gray" - variant="light" - > - Please complete the company profile first. - -
-
+ {renderProfileContent( + , )}
- {profile ? ( - - ) : ( - -
- } - color="gray" - variant="light" - > - Please complete the company profile first. - -
-
+ {renderProfileContent( + , )}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx index dd1916093..062a8a766 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx @@ -25,6 +25,9 @@ export default function BookingContractPage() { const [signOpen, setSignOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(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 {data.canSignCustomer && ( - )} @@ -122,9 +150,13 @@ export default function BookingContractPage() { {signOpen && (
-

Sign contract

+

+ {usingSaved ? "Approve signature" : "Sign contract"} +

- {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.`}

diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 622227674..5998ea2fa 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -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; }, }); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx index 18ab3a36c..8e734229b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx @@ -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(null); + const [method, setMethod] = useState(PROVIDERS[0].method); return ( method && onConfirm(method)} + onClick={() => onConfirm(method)} styles={{ root: { height: 46 }, label: { fontSize: 14, fontWeight: 800 }, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index 95e4cdf78..31bbca748 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -14,7 +14,7 @@ export function StatusHero({ booking: Freight.IBooking; children?: React.ReactNode; }) { - const status = booking.status as string; + const status = booking.status; const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT; const negative = isNegative(status); const draft = isDraftLike(status); @@ -45,7 +45,12 @@ export function StatusHero({
@@ -77,7 +82,7 @@ export function StatusHero({ > {chipLabel} - + {chipValue} @@ -100,7 +105,6 @@ export function StatusHero({ function ProgressTracker({ current, tone = "green", - negative, }: { current: number; tone?: "green" | "ink"; @@ -109,13 +113,17 @@ function ProgressTracker({ const last = PROGRESS_STAGES.length - 1; const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371"; const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4"; - const activeSub = tone === "ink" ? "#475569" : "#0A6F4D"; return ( /* Scrollable on mobile so 5 stages never overflow */
{PROGRESS_STAGES.map((stage, idx) => { @@ -131,14 +139,15 @@ function ProgressTracker({ : state === "active" ? activeFill : "#0EA371"; - const circleBorder = state === "idle" ? "1px solid #E1E7EE" : undefined; + const circleBorder = + state === "idle" ? "1px solid #E1E7EE" : undefined; const circleShadow = state === "active" ? `0 0 0 4px ${activeRing}` : undefined; return (
{/* left connector */} @@ -147,15 +156,19 @@ function ProgressTracker({ style={{ height: 3, background: - idx === 0 ? "transparent" : reachedLeft ? "#0EA371" : "#E1E7EE", + idx === 0 + ? "transparent" + : reachedLeft + ? "#0EA371" + : "#E1E7EE", }} /> {/* stage circle */}
{stage.label} - - {state === "done" - ? "Completed" - : state === "active" - ? negative - ? "Stopped" - : "In progress" - : "Pending"} -
); })} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx index ec6c10f02..1fec98a35 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx @@ -1,5 +1,5 @@ -import { Box, Button, Group, Stack, Text } from "@mantine/core"; -import { CheckCircle2, Clock, FileText } from "lucide-react"; +import { Box, Group, Stack, Text } from "@mantine/core"; +import { CheckCircle2, Clock } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -173,16 +173,16 @@ export function PaymentCard({ )} - + {/* */} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index c6e19fb9b..6ff19a633 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -3,6 +3,7 @@ import { FileText, PackageCheck, ShieldCheck, + Ship, Train, } from "lucide-react"; @@ -15,32 +16,41 @@ export const PROGRESS_STAGES = [ { label: "Submitted", icon: ClipboardCheck, - statuses: ["SUBMITTED", "PENDING_APPROVAL"], + statuses: ["SUBMITTED"], }, { - label: "Approved", + label: "Approval", + icon: ShieldCheck, + statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE", "APPROVED"], + }, + { + label: "Contract", + icon: ShieldCheck, + statuses: ["CONTRACT_READY", "SIGNED_CUSTOMER"], + }, + { + label: "Payment", icon: ShieldCheck, statuses: [ - "APPROVED_PENDING_SIGNATURE", - "APPROVED", - "CONTRACT_READY", - "SIGNED_CUSTOMER", "FULLY_EXECUTED", + "SELECTED_FOR_BATCH", + "PAYMENT_VERIFICATION_IN_PROGRESS", + ], + }, + { + label: "Loading", + icon: Ship, + statuses: [ + "PAID", + "PNR_GENERATED", + "PENDING_CONSOLIDATION", + "CONSOLIDATED", ], }, { label: "In Transit", icon: Train, - statuses: [ - "SELECTED_FOR_BATCH", - "EXPIRED", - "PNR_GENERATED", - "PAYMENT_VERIFICATION_IN_PROGRESS", - "PAID", - "IN_TRANSIT", - "PENDING_CONSOLIDATION", - "CONSOLIDATED", - ], + statuses: ["EXPIRED", "IN_TRANSIT"], }, { label: "Complete", @@ -72,7 +82,7 @@ export const STATUS_MAP: Record< PENDING_APPROVAL: { title: "Pending approval", description: "Your booking is moving through the approval process.", - stage: 1, + stage: 2, }, APPROVED_PENDING_SIGNATURE: { title: "Approved — awaiting signature", @@ -88,71 +98,71 @@ export const STATUS_MAP: Record< title: "Contract ready to sign", description: "Your contract is ready. Review and apply your signature to proceed.", - stage: 2, + stage: 3, }, SIGNED_CUSTOMER: { title: "Signed — awaiting staff", description: "Your signature has been submitted. Awaiting the final staff signature.", - stage: 2, + stage: 3, }, FULLY_EXECUTED: { title: "Contract fully executed", description: "Signed by all parties. You can now proceed to payment.", - stage: 2, + stage: 4, }, SELECTED_FOR_BATCH: { title: "Selected for a train — payment due", description: "Your booking was selected for a scheduled train. Complete payment within the pay window to secure your slot.", - stage: 3, + stage: 4, }, EXPIRED: { title: "Pay window expired", description: "The payment window was missed. You can move this booking to another schedule or cancel it.", - stage: 3, + stage: 6, }, PNR_GENERATED: { title: "Payment reference generated", description: "A payment reference number has been generated for this booking.", - stage: 3, + stage: 5, }, PAYMENT_VERIFICATION_IN_PROGRESS: { title: "Verifying payment", description: "Your payment is being verified.", - stage: 3, + stage: 4, }, PAID: { title: "Payment confirmed", description: "Payment has been confirmed for this booking.", - stage: 3, + stage: 5, }, IN_TRANSIT: { title: "Cargo moving", description: "Your shipment is currently moving through the rail network.", - stage: 3, + stage: 6, }, PENDING_CONSOLIDATION: { title: "Pending consolidation", description: "Awaiting a consolidation partner shipment.", - stage: 3, + stage: 5, }, CONSOLIDATED: { title: "Consolidated", description: "Cargo has been consolidated with a partner shipment.", - stage: 3, + stage: 5, }, COMPLETED: { title: "Service complete", description: "Cargo delivered and service successfully terminated.", - stage: 4, + stage: 7, }, DELIVERED: { title: "Service complete", description: "Cargo delivered and service successfully terminated.", - stage: 4, + stage: 7, }, REJECTED: { title: "Booking rejected", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx index edd83df15..b81cc455a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -1,8 +1,7 @@ -import { useMemo, useRef, type ReactNode } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/services/api"; +import type { CreateBookingPayload } from "@/services/bookings.service"; +import type { Freight } from "@edr/types"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useNavigate, useParams } from "react-router-dom"; import { ActionIcon, Alert, @@ -21,6 +20,7 @@ import { TextInput, Title, } from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, AlertTriangle, @@ -34,12 +34,17 @@ import { Upload, X, } from "lucide-react"; -import type { Freight } from "@edr/types"; -import { api } from "@/services/api"; -import type { CreateBookingPayload } from "@/services/bookings.service"; +import { useMemo, useRef, type ReactNode } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useNavigate, useParams } from "react-router-dom"; +import { + CountChip, + DocRow, + IconSquare, +} from "./BookingDetailPage/components/Documents"; import { - BookingFormInputValues, BOOKING_DOCS_SETTING, + BookingFormInputValues, bookingFormSchema, getRouteDirection, initialBookingFormValues, @@ -48,11 +53,6 @@ import { } from "./new-booking-form/schema"; import { SelectField } from "./new-booking-form/shared"; import { Step5CargoDetails } from "./new-booking-form/steps"; -import { - CountChip, - DocRow, - IconSquare, -} from "./BookingDetailPage/components/Documents"; function yardNameFromBooking( yard: { label?: string; code?: string; name?: string } | undefined | null, @@ -117,8 +117,6 @@ function mapBookingToFormValues( shippingLine: (booking as any).shippingLine?.name ?? "", consolidationEnabled: booking.allowConsolidation ?? false, notes: "", - // Terms were accepted at creation; editing shouldn't re-gate on them. - termsAccepted: true, containers: [], } as BookingFormInputValues; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index e992bdd20..a7b3cab11 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,9 +1,29 @@ import { api } from "@/services/api"; -import type { CreateBookingPayload } from "@/services/bookings.service"; +import type { + CreateBookingPayload, + GeneratePriceResponse, +} from "@/services/bookings.service"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Alert, Box, Button, Group, Text, Title } from "@mantine/core"; +import { + Alert, + Box, + Button, + Group, + Modal, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react"; +import { + AlertCircle, + Check, + ChevronLeft, + ChevronRight, + Send, + XCircle, +} from "lucide-react"; import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; @@ -97,6 +117,58 @@ export default function NewBookingPage() { }, }); + const createAndPriceMutation = useMutation({ + mutationFn: async (payload: CreateBookingPayload) => { + const booking = await api.bookings.create.call(payload); + + const documents = form.getValues("documents") ?? {}; + const hasDocs = Object.values(documents).some((value) => + Array.isArray(value) ? value.length > 0 : Boolean(value), + ); + if (hasDocs) { + await api.bookings.uploadDocuments.call({ + id: booking.id, + files: documents, + }); + } + + const pricing = await api.bookings.generatePrice.call({ id: booking.id }); + + return { bookingId: booking.id, pricing }; + }, + onSuccess: ({ bookingId, pricing }) => { + setPriceBookingId(bookingId); + setPricingData(pricing); + setPricingPhase("ready"); + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + }, + onError: () => { + setPricingPhase("idle"); + }, + }); + + const confirmMutation = useMutation({ + mutationFn: async () => { + if (!priceBookingId) throw new Error("No booking to confirm"); + await api.bookings.submit.call({ id: priceBookingId }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + navigate(`/bookings/${priceBookingId}`); + }, + }); + + const abortMutation = useMutation({ + mutationFn: async (reason: string) => { + if (!priceBookingId) throw new Error("No booking to abort"); + await api.bookings.cancel.call({ id: priceBookingId, reason }); + }, + onSuccess: () => { + setCancelDialogOpen(false); + navigate("/bookings"); + }, + }); + const form = useForm({ defaultValues: initialBookingFormValues, resolver: zodResolver(bookingFormSchema), @@ -116,6 +188,25 @@ export default function NewBookingPage() { return route; }, [originYard, destinationYard]); + const docValues = form.watch("documents") ?? {}; + const hasDocuments = useMemo( + () => + Object.values(docValues).some((value) => + Array.isArray(value) ? value.length > 0 : Boolean(value), + ), + [docValues], + ); + + const [pricingPhase, setPricingPhase] = useState< + "idle" | "generating" | "ready" + >("idle"); + const [pricingData, setPricingData] = useState( + null, + ); + const [priceBookingId, setPriceBookingId] = useState(null); + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); + const [cancelReason, setCancelReason] = useState(""); + async function handleContinue() { const valid = await form.trigger(stepFields[step], { shouldFocus: true }); if (!valid) return; @@ -123,14 +214,14 @@ export default function NewBookingPage() { setStep((currentStep) => Math.min(STEPS.length, currentStep + 1)); } - const handleSubmit = form.handleSubmit((data) => { + function buildApiPayload(data: BookingFormValues): CreateBookingPayload { if (data.contractType === "renewal" && !data.previousContractRef) { form.setError("previousContractRef", { type: "manual", message: "Select a previous contract reference.", }); setStep(1); - return; + throw new Error("Validation failed"); } const totalWeight = @@ -141,7 +232,6 @@ export default function NewBookingPage() { ) : Number(data.cargoWeight || 0); - // ── Reference data lookups ────────────────────────────────────────── const shippingLines = referenceData?.shipping_line ?? []; const cargoTree = referenceData?.cargo_type ?? []; const containerGroups = referenceData?.containers ?? []; @@ -174,8 +264,7 @@ export default function NewBookingPage() { (s) => s.id === data.serviceTypeId, )!; - // ── Build API payload ─────────────────────────────────────────────── - const apiPayload: CreateBookingPayload = { + return { scheduledDate: new Date().toISOString(), contractType: data.contractType.toUpperCase() as CreateBookingPayload["contractType"], @@ -222,8 +311,25 @@ export default function NewBookingPage() { : {}), ...(cargoFreeText ? { cargoFreeText } : {}), }; + } - createMutation.mutate(apiPayload); + const handleDraftSubmit = form.handleSubmit((data) => { + try { + const apiPayload = buildApiPayload(data); + createMutation.mutate(apiPayload); + } catch { + // validation error already handled + } + }); + + const handleGeneratePrice = form.handleSubmit((data) => { + try { + const apiPayload = buildApiPayload(data); + setPricingPhase("generating"); + createAndPriceMutation.mutate(apiPayload); + } catch { + // validation error already handled + } }); return ( @@ -270,7 +376,7 @@ export default function NewBookingPage() { id="new-booking-form" className="flex flex-col" style={{ flex: 1 }} - onSubmit={handleSubmit} + onSubmit={handleDraftSubmit} > @@ -295,6 +401,24 @@ export default function NewBookingPage() { )} + {createAndPriceMutation.isError && ( + } + radius="md" + mb="lg" + > + + Failed to generate price estimate + + + {createAndPriceMutation.error instanceof Error + ? createAndPriceMutation.error.message + : "An unexpected error occurred. Please try again."} + + + )} + {step === 1 && ( )} @@ -326,6 +450,17 @@ export default function NewBookingPage() { setStep={setStep} direction={direction!} referenceData={referenceData} + pricingPhase={pricingPhase} + pricingData={pricingData} + onConfirm={() => confirmMutation.mutate()} + onContinueLater={ + priceBookingId + ? () => navigate(`/bookings/${priceBookingId}`) + : undefined + } + onAbort={() => setCancelDialogOpen(true)} + confirmPending={confirmMutation.isPending} + abortPending={abortMutation.isPending} /> )} @@ -366,24 +501,98 @@ export default function NewBookingPage() { > Continue - ) : ( - + {hasDocuments && ( + + )} + + ) : pricingPhase === "generating" ? ( + - )} + ) : null} - {/* */} + + setCancelDialogOpen(false)} + title={Abort booking} + radius="lg" + centered + > + + + Are you sure you want to abort this booking? This action cannot be + undone. + + setCancelReason(e.currentTarget.value)} + radius="md" + data-autofocus + /> + + + + + + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 3d1a7a0bf..dbf943068 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -121,7 +121,6 @@ export const bookingFormSchema = z consolidationEnabled: z.boolean(), documents: z.record(z.string(), z.any()).default({}), notes: z.string(), - termsAccepted: z.boolean(), }) .refine( (data) => @@ -165,23 +164,13 @@ export const bookingFormSchema = z (data) => !(data.cargoType === "container" && data.containers.length === 0), { message: "Add at least one container.", path: ["containers"] }, ) - .refine((data) => data.termsAccepted, { - message: "Accept the freight contract terms to submit.", - path: ["termsAccepted"], - }) .superRefine((data, ctx) => { if (data.cargoType === "bulk") { if (!data.cargoTypePath[0]) { ctx.addIssue({ code: "custom", path: ["cargoTypePath"], - message: "Select a freight type.", - }); - } else if (!data.cargoTypePath[1]) { - ctx.addIssue({ - code: "custom", - path: ["cargoTypePath"], - message: "Select a commodity.", + message: "Select a Cargo type.", }); } } @@ -237,7 +226,6 @@ export const initialBookingFormValues: DeepPartial = { consolidationEnabled: false, documents: {}, notes: "", - termsAccepted: false, }; export const stepFields: Record>> = { @@ -265,7 +253,7 @@ export const stepFields: Record>> = { ], 5: ["scheduledDate", "trainScheduleId"], 6: ["documents"], - 7: ["notes", "termsAccepted"], + 7: ["notes"], }; export interface ContainerConfig { @@ -288,10 +276,10 @@ export function getRouteDirection( return "DOMESTIC"; } if (origin.country === "Ethiopia" && dest.country === "Djibouti") { - return "IMPORT"; + return "EXPORT"; } if (origin.country === "Djibouti" && dest.country === "Ethiopia") { - return "EXPORT"; + return "IMPORT"; } return null; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx index 181e611be..3a989e60c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx @@ -1,38 +1,39 @@ +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; import { Box, Button, Card, Group, + Modal, Stack, Text, - useMantineTheme, + useMantineTheme } from "@mantine/core"; -import { UseFormReturn } from "react-hook-form"; -import { BookingFormInputValues, BookingFormValues } from "./schema"; +import { useQuery } from "@tanstack/react-query"; import { + addMonths, + eachDayOfInterval, + endOfMonth, + endOfWeek, + format, + isSameMonth, + isToday, + startOfMonth, + startOfWeek, +} from "date-fns"; +import { + Calendar as CalendarIcon, + Check, ChevronLeft, ChevronRight, - Check, - Train, - Route, Package, - Calendar as CalendarIcon, + Route, + Train, } from "lucide-react"; -import type { Freight } from "@edr/types"; import React, { useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { api } from "@/services/api"; -import { - format, - startOfMonth, - endOfMonth, - startOfWeek, - endOfWeek, - eachDayOfInterval, - isToday, - isSameMonth, - addMonths, -} from "date-fns"; +import { UseFormReturn } from "react-hook-form"; +import { BookingFormInputValues, BookingFormValues } from "./schema"; interface StepSchedulingProps { form: UseFormReturn; @@ -52,6 +53,7 @@ interface DayData { export function StepScheduling({ form, referenceData }: StepSchedulingProps) { const theme = useMantineTheme(); const [currentDate, setCurrentDate] = useState(new Date()); + const [selectedDayForModal, setSelectedDayForModal] = useState(null); const selectedDate = form.watch("scheduledDate"); const selectedScheduleId = form.watch("trainScheduleId"); @@ -148,10 +150,35 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) { const weeksCount = Math.ceil(days.length / 7); + const handleDayClick = (day: DayData) => { + if (day.schedules.length > 1) { + setSelectedDayForModal(day); + } else if (day.schedules.length === 1) { + form.setValue("scheduledDate", day.dateString, { + shouldValidate: true, + }); + form.setValue("trainScheduleId", day.schedules[0].id, { + shouldValidate: true, + }); + } + }; + + const handleSelectScheduleFromModal = (scheduleId: string) => { + if (selectedDayForModal) { + form.setValue("scheduledDate", selectedDayForModal.dateString, { + shouldValidate: true, + }); + form.setValue("trainScheduleId", scheduleId, { + shouldValidate: true, + }); + setSelectedDayForModal(null); + } + }; + return ( - + {/* ── Calendar Card ───────────────────────────────────── */} - + {/* Card header */} { - form.setValue("scheduledDate", dateString, { - shouldValidate: true, - }); - form.setValue("trainScheduleId", scheduleId, { - shouldValidate: true, - }); - }} + onDayClick={handleDayClick} /> ))} @@ -263,7 +282,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) { {/* ── Side Panel ──────────────────────────────────────── */} - + {/* Booking summary card */} + + {/* ── Schedule Selection Modal ──────────────────────────── */} + setSelectedDayForModal(null)} + title={selectedDayForModal ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEE, MMM d yyyy") : ""} + centered + size="sm" + styles={{ + header: { borderBottom: `1px solid ${theme.colors["edr-border"][0]}` }, + body: { padding: 24 }, + }} + > + + + Choose a departure time + + {selectedDayForModal?.schedules.map((schedule) => ( + + ))} + + ); } interface DayCellProps { day: DayData; - selectedScheduleId: string; - onSelectSchedule: (scheduleId: string, dateString: string) => void; + onDayClick: (day: DayData) => void; } -function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps) { +function DayCell({ day: d, onDayClick, }: DayCellProps) { const theme = useMantineTheme(); if (!d.isCurrentMonth) { @@ -400,125 +483,113 @@ function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps) : "transparent"; const cellBorder = d.isSelectedDate - ? `1.5px solid ${theme.colors["edr-green"][5]}` + ? `2px solid ${theme.colors["edr-green"][5]}` : d.hasSchedule - ? "1px solid #E7E8E5" + ? `1px solid ${theme.colors["edr-border"][0]}` : "none"; return ( d.hasSchedule && onDayClick(d)} style={{ height: 92, borderRadius: theme.radius.md, backgroundColor: cellBg, border: cellBorder, overflow: "hidden", - padding: "4px 6px 6px", + padding: "8px 10px 10px", display: "flex", flexDirection: "column", gap: 4, + cursor: d.hasSchedule ? "pointer" : "default", + transition: "all 150ms ease", + boxShadow: d.hasSchedule && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none", + }} + onMouseEnter={(e) => { + if (d.hasSchedule && !d.isSelectedDate) { + e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)"; + e.currentTarget.style.borderColor = theme.colors["edr-border"][0]; + } + }} + onMouseLeave={(e) => { + if (d.hasSchedule && !d.isSelectedDate) { + e.currentTarget.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.05)"; + e.currentTarget.style.borderColor = theme.colors["edr-border"][0]; + } }} > {/* Day number + check icon */} - - {d.day} - + + + {d.day} + + {d.isToday && !d.isSelectedDate && ( + + TODAY + + )} + {d.isSelectedDate && ( - + + + )} - {/* Departure chips */} + {/* Schedule times */} {d.hasSchedule && ( - - {d.schedules.slice(0, 2).map((s) => { - const isChipSelected = s.id === selectedScheduleId; - const isFull = s.remainingWagons <= 0; - const canSelect = !isFull; - - return ( + + {d.schedules.slice(0, 2).map((s) => ( + - canSelect && onSelectSchedule(s.id, d.dateString) - } style={{ - display: "flex", - alignItems: "center", - gap: 4, - borderRadius: 7, - padding: "4px 6px", - cursor: canSelect ? "pointer" : "default", - backgroundColor: isChipSelected - ? theme.colors["edr-green"][5] - : isFull - ? theme.colors["edr-red-soft"][0] - : theme.colors["edr-soft"][0], - border: `1px solid ${ - isChipSelected - ? theme.colors["edr-green"][5] - : isFull - ? "#EFCFCA" - : "#BFE3D4" - }`, + width: 4, + height: 4, + borderRadius: "50%", + backgroundColor: theme.colors["edr-green"][5], + flexShrink: 0, }} + /> + - - - {isFull - ? "Full" - : s.trainNumber - ? s.trainNumber - : `${s.remainingWagons} wgn`} - - - - ); - })} + {format(new Date(s.scheduleDate), "HH:mm")} + + + ))} + {d.schedules.length > 2 && ( + + +{d.schedules.length - 2} more + + )} )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index e8d0310c4..e9d4a387e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -1,8 +1,8 @@ +import type { Freight } from "@edr/types"; +import { Divider, Skeleton, Stack, Switch } from "@mantine/core"; +import { Flame, MapPin, Snowflake } from "lucide-react"; import { useEffect, useMemo } from "react"; import { Controller, type UseFormReturn } from "react-hook-form"; -import { Flame, MapPin, Snowflake } from "lucide-react"; -import { Divider, Skeleton, Stack, Switch } from "@mantine/core"; -import type { Freight } from "@edr/types"; import { BookingFormInputValues, type BookingFormValues, @@ -56,25 +56,24 @@ export function Step4Route({ return true; }); }, [yardOptions, destinationYard]); - console.log({ yardOptions, originYard, destinationYard }); const destData = useMemo(() => { return yardOptions.filter((o) => o.value !== originYard); }, [yardOptions, originYard]); - const direction = getRouteDirection( - referenceData?.yard.find((y) => y.id === originYard), - referenceData?.yard.find((y) => y.name === destinationYard), - ); + const origin = referenceData?.yard.find((y) => y.id === originYard); + const dest = referenceData?.yard.find((y) => y.id === destinationYard); + const direction = getRouteDirection(origin, dest); + console.log({ yardOptions, originYard, destinationYard, direction, origin, dest }); const directionStyle: Record = { - export: "bg-sky-50 text-sky-800 border-sky-200", - import: "bg-amber-50 text-amber-800 border-amber-200", - domestic: "bg-gray-100 text-gray-600 border-gray-200", + EXPORT: "bg-sky-50 text-sky-800 border-sky-200", + IMPORT: "bg-amber-50 text-amber-800 border-amber-200", + DOMESTIC: "bg-gray-100 text-gray-600 border-gray-200", }; const directionLabel: Record = { - export: "Export workflow (inside country to outside country)", - import: "Import workflow (outside country to inside country)", - domestic: "Domestic corridor", + EXPORT: "Export workflow (inside country to outside country)", + IMPORT: "Import workflow (outside country to inside country)", + DOMESTIC: "Domestic corridor", }; useEffect(() => { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index 7d14dd28e..278698f83 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -1,5 +1,17 @@ import { Controller, type UseFormReturn } from "react-hook-form"; -import { Box, Card, Checkbox, SimpleGrid, Text, Textarea } from "@mantine/core"; +import { + Box, + Button, + Card, + Divider, + Group, + Loader, + SimpleGrid, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react"; import { BookingFormInputValues, BOOKING_DOCS_SETTING, @@ -8,6 +20,7 @@ import { } from "./schema"; import { StepHeader } from "./shared"; import type { Freight } from "@/types"; +import type { GeneratePriceResponse } from "@/services/bookings.service"; type BookingForm = UseFormReturn< BookingFormInputValues, @@ -20,19 +33,32 @@ export function Step8Review({ setStep, direction, referenceData, + pricingPhase = "idle", + pricingData, + onConfirm, + onContinueLater, + onAbort, + confirmPending = false, + abortPending = false, }: { form: BookingForm; setStep: (step: number) => void; direction: Freight.ScheduleTradeDirection; referenceData?: Freight.BookingReferenceData; + pricingPhase?: "idle" | "generating" | "ready"; + pricingData?: GeneratePriceResponse | null; + onConfirm?: () => void; + onContinueLater?: () => void; + onAbort?: () => void; + confirmPending?: boolean; + abortPending?: boolean; }) { const values = form.watch(); - const errors = form.formState.errors; const serviceType = referenceData?.service.find( (s) => s.id === values.serviceTypeId, ); - function Row({ + function CompactRow({ label, value, target, @@ -42,19 +68,19 @@ export function Step8Review({ target: number; }) { return ( -
-
- +
+
+ {label} - + {value || "—"}
@@ -62,6 +88,28 @@ export function Step8Review({ ); } + function CompactCard({ + icon: Icon, + title, + children, + }: { + icon: React.ReactNode; + title: string; + children: React.ReactNode; + }) { + return ( + + + {Icon} + + {title} + + + {children} + + ); + } + const containerSummary = values.cargoType === "container" && values.containers.length > 0 ? values.containers @@ -95,146 +143,211 @@ export function Step8Review({ return child ? `${group.name} — ${child.name}` : group.name; })(); - function ReviewCard({ - title, - children, - }: { - title: string; - children: React.ReactNode; - }) { - return ( - - - - {title} - - - - {children} - - - ); - } + const originYardName = referenceData?.yard.find( + (y) => y.id === values.originYard, + )?.name ?? values.originYard; + + const destinationYardName = referenceData?.yard.find( + (y) => y.id === values.destinationYard, + )?.name ?? values.destinationYard; return ( -
+ - - - + + + + Generating price estimate… + + + + )} + + {pricingPhase === "ready" && pricingData && ( + + + + 💳 Price Breakdown + + + {pricingData.lineItems.map((item) => ( + + + {item.description} + + + {item.amount.toLocaleString()} {item.currency} + + + ))} + + + + + Total + + + {pricingData.totalAmount.toLocaleString()} {pricingData.currency} + + + {pricingData.warnings.length > 0 && ( + + ⚠️ {pricingData.warnings.join(", ")} + + )} + + + + + + + + )} + + {/* Review Details - Compact Cards Grid */} + + } title="Contract & Service"> + - - + + - - } title="Route"> + + + + + } title="Logistics"> + - - - - + - - - - } title="Cargo Details"> + - - + - + - - - 0 ? `${totalVgm.toFixed(1)} tons` : ""} + } title="Containers"> + - - - - 0 - ? `${docsAttached} of ${docsTotal} attached` - : "None — upload later from the booking page" - } - target={5} + 0 ? `${totalVgm.toFixed(1)} tons` : "—"} + target={4} /> - + + + } title="Documents"> +
+
+ + Attached + + + {docsAttached > 0 + ? `${docsAttached} of ${docsTotal}` + : "None"} + +
+ +
+
+ {/* Notes */} )} /> - - ( - - I confirm the information is accurate and agree to EDR's{" "} - - freight contract terms and conditions - - . - - } - checked={field.value} - onChange={(e) => field.onChange(e.currentTarget.checked)} - error={fieldState.error?.message ?? errors.termsAccepted?.message} - color="edr-green" - radius="sm" - /> - )} - /> -
+ ); } diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index 0ba3d99a2..f2ad48f2c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -17,8 +17,9 @@ import { import { api } from "@/services/api"; import PhoneInput from "@/components/auth/PhoneInput"; import type { ProfileResponse } from "@/types/profile"; +import type { CreateCompanyPayload } from "@/services/companies.service"; -const schema = z.object({ +export const COMPANY_PROFILE_SCHEMA = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), companyPhone: z.string().min(1, "Company phone is required"), @@ -29,29 +30,52 @@ const schema = z.object({ fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), }); -type FormData = z.infer; +export type CompanyProfileFormData = z.infer; -function splitPhone(fullPhone?: string | null) { +export function splitPhone(fullPhone?: string | null) { if (!fullPhone) return { code: "+251", number: "" }; const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); if (match) return { code: match[1], number: match[2] }; return { code: "+251", number: fullPhone }; } -export default function TabCompanyProfile({ profile }: { profile: ProfileResponse }) { - const queryClient = useQueryClient(); +interface TabCompanyProfileProps { + profile?: ProfileResponse; + mode?: "edit" | "create"; + onCreateSuccess?: () => void; +} - const defaultValues = useMemo((): FormData => { - const phone = splitPhone(profile.companyPhone); +export default function TabCompanyProfile({ + profile, + mode = "edit", + onCreateSuccess, +}: TabCompanyProfileProps) { + const queryClient = useQueryClient(); + const isCreate = mode === "create"; + + const defaultValues = useMemo((): CompanyProfileFormData => { + if (profile) { + const phone = splitPhone(profile.companyPhone); + return { + companyName: profile.companyName, + companyEmail: profile.companyEmail ?? "", + companyPhone: phone.number, + companyPhoneCountryCode: phone.code, + companyLocation: profile.companyLocation, + companyAddress: profile.companyAddress ?? "", + tinNumber: profile.tinNumber, + fanNumber: profile.fanNumber ?? "", + }; + } return { - companyName: profile.companyName, - companyEmail: profile.companyEmail ?? "", - companyPhone: phone.number, - companyPhoneCountryCode: phone.code, - companyLocation: profile.companyLocation, - companyAddress: profile.companyAddress ?? "", - tinNumber: profile.tinNumber, - fanNumber: profile.fanNumber ?? "", + companyName: "", + companyEmail: "", + companyPhone: "", + companyPhoneCountryCode: "+251", + companyLocation: "", + companyAddress: "", + tinNumber: "", + fanNumber: "", }; }, [profile]); @@ -60,14 +84,15 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons handleSubmit, reset, formState: { errors, isDirty }, - } = useForm({ - resolver: zodResolver(schema), + } = useForm({ + resolver: zodResolver(COMPANY_PROFILE_SCHEMA), values: defaultValues, }); const mutation = useMutation({ - mutationFn: (data: FormData) => - api.companies.updateProfile.call({ + mutationFn: async (data: CompanyProfileFormData) => { + const payload: CreateCompanyPayload = { + companyType: "customer", companyName: data.companyName, companyEmail: data.companyEmail, companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, @@ -75,13 +100,25 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons companyAddress: data.companyAddress, tin: data.tinNumber, fanNumber: data.fanNumber, - }), + }; + + if (isCreate) { + return api.companies.create.call(payload); + } else { + return api.companies.updateProfile.call(payload); + } + }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + if (isCreate) { + onCreateSuccess?.(); + } }, }); - const onSubmit = (data: FormData) => mutation.mutate(data); + const onSubmit = (data: CompanyProfileFormData) => mutation.mutate(data); return ( @@ -90,7 +127,9 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons Company Profile - Edit your company registration details + {isCreate + ? "Enter your company registration details to get started" + : "Edit your company registration details"}
@@ -115,7 +154,10 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons - {mutation.isSuccess && ( + {mutation.isSuccess && !isCreate && ( - Saved successfully + + Saved successfully + )} {mutation.isError && ( - Save failed + + {isCreate ? "Failed to create profile" : "Save failed"} + )} - + {!isCreate && ( + + )} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx index 837307a87..7a2d9b931 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx @@ -32,7 +32,13 @@ function splitPhone(fullPhone?: string | null) { return { code: "+251", number: fullPhone }; } -export default function TabContactPerson({ profile }: { profile: ProfileResponse }) { +interface TabContactPersonProps { + profile: ProfileResponse; + mode?: "edit" | "onboarding"; + onContinue?: () => void; +} + +export default function TabContactPerson({ profile, mode = "edit", onContinue }: TabContactPersonProps) { const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { @@ -62,6 +68,7 @@ export default function TabContactPerson({ profile }: { profile: ProfileResponse }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); + if (mode === "onboarding") onContinue?.(); }, }); @@ -116,20 +123,22 @@ export default function TabContactPerson({ profile }: { profile: ProfileResponse )} - + {mode === "edit" && ( + + )} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx index e9b458060..4425c513e 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + ArrowRight, CheckCircle2, FileCheck, Loader2, @@ -20,7 +21,13 @@ import { companiesService } from "@/services/companies.service"; import { SmartFileInput } from "@edr/ui-common"; import type { ProfileResponse } from "@/types/profile"; -export default function TabDocuments({ profile }: { profile: ProfileResponse }) { +interface TabDocumentsProps { + profile: ProfileResponse; + mode?: "edit" | "onboarding"; + onContinue?: () => void; +} + +export default function TabDocuments({ profile, mode = "edit", onContinue }: TabDocumentsProps) { const queryClient = useQueryClient(); const [documentFiles, setDocumentFiles] = useState>({}); @@ -75,7 +82,9 @@ export default function TabDocuments({ profile }: { profile: ProfileResponse }) {docUploadMutation.isSuccess && ( - Documents uploaded successfully + + {mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"} + )} {docUploadMutation.isError && ( @@ -85,14 +94,37 @@ export default function TabDocuments({ profile }: { profile: ProfileResponse }) )} - + {mode === "onboarding" ? ( + + ) : ( + + )} )} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx index 84f365d5d..80fb5f721 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx @@ -34,7 +34,13 @@ function splitPhone(fullPhone?: string | null) { return { code: "+251", number: fullPhone }; } -export default function TabGeneralManager({ profile }: { profile: ProfileResponse }) { +interface TabGeneralManagerProps { + profile: ProfileResponse; + mode?: "edit" | "onboarding"; + onContinue?: () => void; +} + +export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) { const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { @@ -66,6 +72,7 @@ export default function TabGeneralManager({ profile }: { profile: ProfileRespons }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); + if (mode === "onboarding") onContinue?.(); }, }); @@ -133,20 +140,22 @@ export default function TabGeneralManager({ profile }: { profile: ProfileRespons )} - + {mode === "edit" && ( + + )} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx index 07f3b7720..f5f9a8210 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx @@ -36,11 +36,17 @@ function splitPhone(fullPhone?: string | null) { return { code: "+251", number: fullPhone }; } +interface TabPowerOfAttorneyProps { + profile: ProfileResponse; + mode?: "edit" | "onboarding"; + onContinue?: () => void; +} + export default function TabPowerOfAttorney({ profile, -}: { - profile: ProfileResponse; -}) { + mode = "edit", + onContinue, +}: TabPowerOfAttorneyProps) { const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { @@ -49,7 +55,7 @@ export default function TabPowerOfAttorney({ poaName: profile.poaName ?? "", poaEmail: profile.poaEmail ?? "", poaPhone: phone.number, - poaPhoneCountryCode: profile.poaPhone ? phone.code : "", + poaPhoneCountryCode: profile.poaPhone ? phone.code : "+251", poaLocation: profile.poaLocation ?? "", poaAddress: profile.poaAddress ?? "", }; @@ -81,6 +87,7 @@ export default function TabPowerOfAttorney({ queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey(), }); + if (mode === "onboarding") onContinue?.(); }, }); @@ -99,11 +106,6 @@ export default function TabPowerOfAttorney({ - - Power of Attorney details are optional. Fill them in if you have an - authorized representative, or leave blank. - - - + {mode === "edit" && ( + + )} diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index ac6411505..9933fa227 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -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 { diff --git a/apps/edr-freight-web/portal/src/services/payments.service.ts b/apps/edr-freight-web/portal/src/services/payments.service.ts index 3c345f648..119aea4ed 100644 --- a/apps/edr-freight-web/portal/src/services/payments.service.ts +++ b/apps/edr-freight-web/portal/src/services/payments.service.ts @@ -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, diff --git a/apps/edr-freight-web/portal/src/services/signatures.service.ts b/apps/edr-freight-web/portal/src/services/signatures.service.ts new file mode 100644 index 000000000..05ae61ea1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/signatures.service.ts @@ -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 => { + const { data } = await client.get(SIGNATURE_URL); + return (data.data ?? data) ?? null; + }, + + saveMySignature: async ( + payload: SaveSignaturePayload, + ): Promise => { + const { data } = await client.put(SIGNATURE_URL, payload); + return (data.data ?? data) ?? null; + }, +}; diff --git a/apps/edr-freight-web/portal/src/utils/api.ts b/apps/edr-freight-web/portal/src/utils/api.ts index 446eec591..289709906 100644 --- a/apps/edr-freight-web/portal/src/utils/api.ts +++ b/apps/edr-freight-web/portal/src/utils/api.ts @@ -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 { diff --git a/apps/edr-freight-web/portal/vite.config.ts b/apps/edr-freight-web/portal/vite.config.ts index 8d01990aa..99e2a41d5 100644 --- a/apps/edr-freight-web/portal/vite.config.ts +++ b/apps/edr-freight-web/portal/vite.config.ts @@ -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", + }, }); diff --git a/apps/edr-passenger-web/backoffice/tailwind.config.js b/apps/edr-passenger-web/backoffice/tailwind.config.js index 459b34e1b..e4b1c586b 100644 --- a/apps/edr-passenger-web/backoffice/tailwind.config.js +++ b/apps/edr-passenger-web/backoffice/tailwind.config.js @@ -37,4 +37,3 @@ module.exports = { }, }, plugins: [], -}; \ No newline at end of file diff --git a/apps/edr-passenger-web/portal/public/edr-banner.jpg b/apps/edr-passenger-web/portal/public/edr-banner.jpg new file mode 100644 index 000000000..81b8ddca3 Binary files /dev/null and b/apps/edr-passenger-web/portal/public/edr-banner.jpg differ diff --git a/apps/edr-passenger-web/portal/public/edr-logo.png b/apps/edr-passenger-web/portal/public/edr-logo.png new file mode 100644 index 000000000..3966c9e80 Binary files /dev/null and b/apps/edr-passenger-web/portal/public/edr-logo.png differ diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 2efe9b5d4..389123510 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -7,6 +7,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { useState, useEffect } from "react"; import { PaymentMethod } from "@/types"; +import { format } from "date-fns"; import { CreditCard, Smartphone, @@ -23,12 +24,14 @@ const getIconForMethod = (methodId: string) => { export default function PaymentPage() { const router = useRouter(); - const { bookingId, pnr, selectedSchedule, passengers } = useBookingStore(); + const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore(); const { selectedCurrency, setPaymentIntent, updateStatus } = usePaymentStore(); const [selectedMethod, setSelectedMethod] = useState(null); const [isProcessing, setIsProcessing] = useState(false); + const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; + const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery({ queryKey: ['paymentMethods'], queryFn: async () => { @@ -38,10 +41,21 @@ export default function PaymentPage() { }); // Calculate total amount - const baseFare = passengers.reduce( + const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce( + (sum) => sum + (outboundSchedule.baseFareAdult || 0), + 0, + ) : 0; + + const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce( + (sum) => sum + (inboundSchedule.baseFareAdult || 0), + 0, + ) : 0; + + const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce( (sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0, ); + const totalAmount = baseFare; const paymentMutation = useMutation({ @@ -207,47 +221,231 @@ export default function PaymentPage() { {/* Order Summary */}
-

+

Order summary

-
-
- Route - - {selectedSchedule?.origin} → {selectedSchedule?.destination} - -
-
- Train - - {selectedSchedule?.trainNumber} - -
- {selectedSchedule?.selectedSeatClassName && ( -
+
+ {isRoundTrip ? ( + <> + {/* Outbound Journey */} +
+
+
+ Outbound Journey + + {outboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+
+ + {/* Origin */} +
+
+
+ {outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {outboundSchedule?.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {outboundSchedule?.duration} +
+
+ + + + Train {outboundSchedule?.trainNumber} +
+
+
+ + {/* Destination */} +
+
+
+ {outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {outboundSchedule?.destination} +
+
+
+ +
+
+ Outbound fare + ETB {(outboundBaseFare / 100).toFixed(2)} +
+
+
+ + {/* Return Journey */} +
+
+
+ Return Journey + + {inboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+
+ + {/* Origin */} +
+
+
+ {inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {inboundSchedule?.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {inboundSchedule?.duration} +
+
+ + + + Train {inboundSchedule?.trainNumber} +
+
+
+ + {/* Destination */} +
+
+
+ {inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {inboundSchedule?.destination} +
+
+
+ +
+
+ Return fare + ETB {(inboundBaseFare / 100).toFixed(2)} +
+
+
+ + ) : ( + <> + {/* One-Way Journey */} +
+
+
+ Your Journey + + {selectedSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+
+ + {/* Origin */} +
+
+
+ {selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {selectedSchedule?.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {selectedSchedule?.duration} +
+
+ + + + Train {selectedSchedule?.trainNumber} +
+
+
+ + {/* Destination */} +
+
+
+ {selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {selectedSchedule?.destination} +
+
+
+
+ + )} + + {/* Passengers and Total */} +
+
- Class + Passengers - {selectedSchedule.selectedSeatClassName.replace(/_/g, " ")} + {passengers.length} passenger{passengers.length !== 1 ? "s" : ""}
- )} -
- - Passengers - - - {passengers.length} passenger - {passengers.length !== 1 ? "s" : ""} - -
-
-
- +
+ Total amount - + ETB {(totalAmount / 100).toFixed(2)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx index 0943db60e..9b190fbf2 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/telebirr/success/page.tsx @@ -16,7 +16,7 @@ function TelebirrSuccessContent() { const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing'); // Telebirr callback query params - const merchantOrderId = searchParams.get('merchantOrderId') || ''; + const orderid = searchParams.get('orderid') || ''; const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || ''; const bookingIdQp = searchParams.get('bookingId') || bookingId || ''; @@ -25,7 +25,7 @@ function TelebirrSuccessContent() { try { if (bookingIdQp) { await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, { - paymentReference: merchantOrderId || trxRef, + paymentReference: orderid || trxRef, paymentMethod: 'TELEBIRR', }); } @@ -59,7 +59,7 @@ function TelebirrSuccessContent() {

Payment Successful!

Your Telebirr payment was received.

- {merchantOrderId &&

Order ID: {merchantOrderId}

} + {orderid &&

Order ID: {orderid}

} {trxRef &&

Transaction Ref: {trxRef}

}

Redirecting to your booking confirmation…

diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 79b8419c5..2e8d9e950 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -5,16 +5,16 @@ import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useBookingStore } from '@/lib/booking-store'; import { Schedule } from '@/types'; -import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, X, MapPin, Gift, Train } from 'lucide-react'; +import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train } from 'lucide-react'; import { format } from 'date-fns'; import { useState, useEffect } from 'react'; export default function ResultsPage() { const router = useRouter(); const searchParams = useSearchParams(); - const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule); + const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore(); const [selectedClasses, setSelectedClasses] = useState>({}); - const [outboundSelected, setOutboundSelected] = useState(false); + const [outboundScheduleData, setOutboundScheduleData] = useState(null); const [classModal, setClassModal] = useState(null); const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null); @@ -114,18 +114,21 @@ export default function ResultsPage() { const isRoundTrip = searchData.journeyType === 'ROUND_TRIP'; // Handle both response formats: - // 1. One-way: response is array of schedules + // 1. One-way: response can be array of schedules OR object with journeyType and outbound // 2. Round-trip: response has journeyType, outbound, inbound properties let outboundSchedules: Schedule[] = []; let inboundSchedules: Schedule[] = []; if (results) { - if (isRoundTrip && results.journeyType === 'ROUND_TRIP') { + if (results.journeyType === 'ROUND_TRIP') { // Round trip response format outboundSchedules = results.outbound || []; inboundSchedules = results.inbound || []; + } else if (results.journeyType === 'ONE_WAY' && results.outbound) { + // One-way response format with outbound array + outboundSchedules = results.outbound || []; } else if (Array.isArray(results)) { - // One-way response format (array of schedules) + // One-way response format (direct array of schedules) outboundSchedules = results; } else if (results.data && Array.isArray(results.data)) { // Fallback: wrapped in data property @@ -139,11 +142,8 @@ export default function ResultsPage() { ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; - const handleSelectClass = (scheduleId: string, seatClass: string, isOutbound: boolean = false) => { + const handleSelectClass = (scheduleId: string, seatClass: string) => { setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass })); - if (isOutbound && isRoundTrip) { - setOutboundSelected(true); - } }; const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { @@ -184,13 +184,28 @@ export default function ResultsPage() { // For round trip, store outbound and wait for inbound selection if (isRoundTrip && isOutbound) { - setOutboundSelected(true); + setOutboundScheduleData(scheduleData); + setOutboundSchedule(scheduleData); setClassModal(null); + // Scroll to inbound section + setTimeout(() => { + const inboundSection = document.getElementById('inbound-section'); + if (inboundSection) { + inboundSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, 100); return; } - // For round trip inbound or one-way, proceed to next step - setSelectedSchedule(scheduleData); + // For round trip inbound, proceed with both schedules + if (isRoundTrip && !isOutbound) { + setInboundSchedule(scheduleData); + setSelectedSchedule(outboundScheduleData); // Set primary as outbound + } else { + // For one-way + setSelectedSchedule(scheduleData); + } + router.push('/booking/auth-check'); }; @@ -289,10 +304,138 @@ export default function ResultsPage() { if (isLoading) { return ( -
-
- -

Searching for trains...

+
+
+
+ {/* Progress Header */} +
+
+
+
+
+
+
+

+ Searching for trains... +

+

+ Finding the best options for your journey +

+
+
+ {/* Progress bar */} +
+
+
+
+
+ + {/* Skeleton Cards */} +
+ {[1, 2, 3].map((i) => ( +
+
+
+ {/* Train info skeleton */} +
+
+
+
+
+
+
+ + {/* Time and route skeleton */} +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ ))} +
+
); @@ -381,7 +524,7 @@ export default function ResultsPage() { return ( {!selectedClass && ( @@ -494,8 +637,8 @@ export default function ResultsPage() {
)} - {inboundSchedules.length > 0 && (!isRoundTrip || outboundSelected) && ( -
+ {isRoundTrip && inboundSchedules.length > 0 && outboundScheduleData && ( +

diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 6f9c2a265..d3e75d7d9 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -51,11 +51,13 @@ function getPassengerIdFromToken(token: string): string | null { export default function ReviewPage() { const router = useRouter(); - const { selectedSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId } = useBookingStore(); + const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId, searchCriteria } = useBookingStore(); const { user, isAuthenticated } = useAuthStore(); const [timeLeft, setTimeLeft] = useState(''); const [seatDetails, setSeatDetails] = useState>({}); + const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; + useEffect(() => { if (!seatHold?.expiresAt) return; @@ -79,22 +81,57 @@ export default function ReviewPage() { useEffect(() => { const fetchSeatDetails = async () => { - if (!selectedSchedule?.id) return; - try { - const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`); - const coaches = seatMapData?.coaches || []; - const allSeats = coaches.flatMap((coach: any) => coach.seats || []); - const details: Record = {}; - passengers.forEach(p => { - if (p.seatId) { - const seat = allSeats.find((s: any) => s.id === p.seatId); - if (seat) { - details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A'; + + // Fetch outbound seat details + if (isRoundTrip && outboundSchedule?.id) { + const outboundSeatMap: any = await apiClient.get(`/seats/seatmap/${outboundSchedule.id}`); + const outboundCoaches = outboundSeatMap?.coaches || []; + const outboundSeats = outboundCoaches.flatMap((coach: any) => coach.seats || []); + + passengers.forEach(p => { + if ((p as any).outboundSeatId) { + const seat = outboundSeats.find((s: any) => s.id === (p as any).outboundSeatId); + if (seat) { + details[`outbound-${(p as any).outboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A'; + } } - } - }); + }); + } + + // Fetch inbound seat details + if (isRoundTrip && inboundSchedule?.id) { + const inboundSeatMap: any = await apiClient.get(`/seats/seatmap/${inboundSchedule.id}`); + const inboundCoaches = inboundSeatMap?.coaches || []; + const inboundSeats = inboundCoaches.flatMap((coach: any) => coach.seats || []); + + passengers.forEach(p => { + if ((p as any).inboundSeatId) { + const seat = inboundSeats.find((s: any) => s.id === (p as any).inboundSeatId); + if (seat) { + details[`inbound-${(p as any).inboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A'; + } + } + }); + } + + // Fetch one-way seat details + if (!isRoundTrip && selectedSchedule?.id) { + const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`); + const coaches = seatMapData?.coaches || []; + const allSeats = coaches.flatMap((coach: any) => coach.seats || []); + + passengers.forEach(p => { + if (p.seatId) { + const seat = allSeats.find((s: any) => s.id === p.seatId); + if (seat) { + details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A'; + } + } + }); + } + setSeatDetails(details); } catch (error) { console.error('Failed to fetch seat details:', error); @@ -102,7 +139,7 @@ export default function ReviewPage() { }; fetchSeatDetails(); - }, [selectedSchedule?.id, passengers]); + }, [selectedSchedule?.id, outboundSchedule?.id, inboundSchedule?.id, passengers, isRoundTrip]); const createBookingMutation = useMutation({ mutationFn: (data: any) => { @@ -155,6 +192,8 @@ export default function ReviewPage() { console.log('Search criteria:', searchCriteria); console.log('Seat hold:', seatHold); console.log('Selected schedule:', selectedSchedule); + console.log('Outbound schedule:', outboundSchedule); + console.log('Inbound schedule:', inboundSchedule); console.log('Passengers:', passengers); if (!seatHold?.holdId) { @@ -171,12 +210,15 @@ export default function ReviewPage() { return; } + // Get seat class ID let seatClassId = 'default-seat-class-id'; + let returnSeatClassId = 'default-seat-class-id'; try { const seatClasses: any = await apiClient.get('/seat-classes'); console.log('Seat classes:', seatClasses); if (seatClasses && seatClasses.length > 0) { seatClassId = seatClasses[0].id; + returnSeatClassId = seatClasses[0].id; } } catch (err) { console.error('Failed to fetch seat classes:', err); @@ -229,18 +271,20 @@ export default function ReviewPage() { throw new Error('Passenger ID not found in authentication token. Please log in again.'); } + // Build booking request for authenticated users bookingData = { - scheduleId: selectedSchedule?.id || '', + passengerId: passengerId, + scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, holdId: seatHold.holdId, originStationId: searchCriteria.originStationId, destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, + bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', displayCurrency: 'ETB', - passengerId: passengerId, passengers: passengers.map((p) => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { - seatId: p.seatId || '', + seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -251,19 +295,34 @@ export default function ReviewPage() { }; }), }; + + // Add round trip specific fields + if (isRoundTrip && inboundSchedule) { + bookingData.returnScheduleId = inboundSchedule.id; + bookingData.returnOriginStationId = searchCriteria.destinationStationId; + bookingData.returnDestinationStationId = searchCriteria.originStationId; + bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed + bookingData.returnSeatClassId = returnSeatClassId; + } + + // Add promo code if exists + if (searchCriteria.promoCode) { + bookingData.promoCode = searchCriteria.promoCode; + } } else { // For guests: send full passenger details array bookingData = { - scheduleId: selectedSchedule?.id || '', + scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, holdId: seatHold.holdId, originStationId: searchCriteria.originStationId, destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, + bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', displayCurrency: 'ETB', passengers: passengers.map(p => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { - seatId: p.seatId || '', + seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -279,6 +338,20 @@ export default function ReviewPage() { savePassengerDetails: true, deviceId: typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || `device-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`) : undefined, }; + + // Add round trip specific fields + if (isRoundTrip && inboundSchedule) { + bookingData.returnScheduleId = inboundSchedule.id; + bookingData.returnOriginStationId = searchCriteria.destinationStationId; + bookingData.returnDestinationStationId = searchCriteria.originStationId; + bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed + bookingData.returnSeatClassId = returnSeatClassId; + } + + // Add promo code if exists + if (searchCriteria.promoCode) { + bookingData.promoCode = searchCriteria.promoCode; + } } if (typeof window !== 'undefined' && !isAuthenticated && bookingData.deviceId && !localStorage.getItem('deviceId')) { @@ -294,26 +367,49 @@ export default function ReviewPage() { }; useEffect(() => { - if (!selectedSchedule || !passengers.length) { - if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) { - console.log('Redirecting to search - missing data'); - router.push('/booking/search'); + if (isRoundTrip) { + if (!outboundSchedule || !inboundSchedule || !passengers.length) { + if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) { + console.log('Redirecting to search - missing round trip data'); + router.push('/booking/search'); + } + } + } else { + if (!selectedSchedule || !passengers.length) { + if (!createBookingMutation.isPending && !createBookingMutation.isSuccess) { + console.log('Redirecting to search - missing data'); + router.push('/booking/search'); + } } } - }, [selectedSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]); + }, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]); - if (!selectedSchedule || !passengers.length) { + if (isRoundTrip && (!outboundSchedule || !inboundSchedule || !passengers.length)) { return null; } - console.log('Selected schedule:', selectedSchedule); - console.log('Base fare adult:', selectedSchedule.baseFareAdult); + if (!isRoundTrip && (!selectedSchedule || !passengers.length)) { + return null; + } + + const displaySchedule = isRoundTrip ? outboundSchedule : selectedSchedule; + + console.log('Selected schedule:', displaySchedule); + console.log('Base fare adult:', displaySchedule?.baseFareAdult); console.log('Passengers:', passengers); - const baseFare = passengers.reduce((sum, p, i) => { - const farePerPassenger = selectedSchedule.baseFareAdult || - (selectedSchedule as any).fareAdult || - (selectedSchedule as any).price || + const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => { + return sum + (outboundSchedule.baseFareAdult || 0); + }, 0) : 0; + + const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => { + return sum + (inboundSchedule.baseFareAdult || 0); + }, 0) : 0; + + const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => { + const farePerPassenger = selectedSchedule?.baseFareAdult || + (selectedSchedule as any)?.fareAdult || + (selectedSchedule as any)?.price || 0; console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`); @@ -340,51 +436,259 @@ export default function ReviewPage() { )}
-
-

Trip details

-
-
- Train - {selectedSchedule.trainNumber} -
-
- Route - {selectedSchedule.origin} → {selectedSchedule.destination} -
-
- Departure - - {selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'PPp') : 'N/A'} + {/* Outbound Trip Details */} + {isRoundTrip && outboundSchedule && ( +
+
+
+

Outbound Journey

+ + {outboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'}
-
- Arrival - - {selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'PPp') : 'N/A'} - -
-
- Duration - {selectedSchedule.duration} + + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {outboundSchedule.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {outboundSchedule.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {outboundSchedule.duration} +
+
+ + + + Train {outboundSchedule.trainNumber} +
+
+
+ + {/* Destination */} +
+
+ {outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {outboundSchedule.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {outboundSchedule.destination} +
+
+
-
+ )} + + {/* Inbound Trip Details */} + {isRoundTrip && inboundSchedule && ( +
+
+
+

Return Journey

+ + {inboundSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {inboundSchedule.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {inboundSchedule.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {inboundSchedule.duration} +
+
+ + + + Train {inboundSchedule.trainNumber} +
+
+
+ + {/* Destination */} +
+
+ {inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {inboundSchedule.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {inboundSchedule.destination} +
+
+
+
+
+ )} + + {/* One-Way Trip Details */} + {!isRoundTrip && selectedSchedule && ( +
+
+
+

Trip Details

+ + {selectedSchedule.selectedSeatClass?.replace(/_/g, ' ') || 'Standard'} + +
+ + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'} +
+
+ {selectedSchedule.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'} +
+
+ {selectedSchedule.origin} +
+
+ + {/* Journey Info */} +
+
+
+ + + + {selectedSchedule.duration} +
+
+ + + + Train {selectedSchedule.trainNumber} +
+
+
+ + {/* Destination */} +
+
+ {selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'} +
+
+ {selectedSchedule.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'} +
+
+ {selectedSchedule.destination} +
+
+
+
+
+ )}

Passengers

{passengers.map((p, i) => ( -
-
-

{p.name}

-

- {p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality} -

-
-
-

Seat

-

{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}

+
+
+
+

{p.name}

+

+ {p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality} +

+
+ {isRoundTrip ? ( +
+
+

Outbound Seat

+

+ {(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : 'Auto-assign'} +

+
+
+

Return Seat

+

+ {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : 'Auto-assign'} +

+
+
+ ) : ( +
+

Seat

+

{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}

+
+ )}
))}
@@ -393,10 +697,23 @@ export default function ReviewPage() {

Fare breakdown

-
- Base fare - ETB {(baseFare / 100).toFixed(2)} -
+ {isRoundTrip ? ( + <> +
+ Outbound fare + ETB {(outboundBaseFare / 100).toFixed(2)} +
+
+ Return fare + ETB {(inboundBaseFare / 100).toFixed(2)} +
+ + ) : ( +
+ Base fare + ETB {(baseFare / 100).toFixed(2)} +
+ )}
Total ETB {(total / 100).toFixed(2)} diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 8295d3a09..a587bc87c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -1,58 +1,78 @@ -'use client'; +"use client"; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useQuery } from '@tanstack/react-query'; -import { useAuthStore } from '@/lib/auth-store'; -import { apiClient } from '@/lib/api-client'; -import { useBookingStore } from '@/lib/booking-store'; -import { Station } from '@/types'; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { useAuthStore } from "@/lib/auth-store"; +import { apiClient } from "@/lib/api-client"; +import { useBookingStore } from "@/lib/booking-store"; +import { Station } from "@/types"; import { - MapPin, ArrowRight, ArrowLeftRight, Plus, Minus, Search, - Users, ChevronDown, Gift, Check, X, ChevronLeft, Clock, Zap, -} from 'lucide-react'; -import { useEffect, useRef, useState, useCallback } from 'react'; -import ModernDatePicker from '@/components/ModernDatePicker'; + MapPin, + ArrowRight, + ArrowLeftRight, + Plus, + Minus, + Search, + Users, + ChevronDown, + Gift, + Check, + X, + ChevronLeft, + Clock, + Zap, +} from "lucide-react"; +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']), - promoCode: z.string().optional(), -}).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'], -}); +const searchSchema = z + .object({ + tripType: z.enum(["ONE_WAY", "ROUND_TRIP"]), + originStationId: z.string().min(1, "Please select your departure station"), + destinationStationId: z + .string() + .min(1, "Please select your destination station"), + departureDate: z.string().min(1, "Please select your 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"]), + promoCode: z.string().optional(), + }) + .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; const POPULAR_ROUTES = [ - { from: 'Sebeta', to: 'Nagad', duration: '12h', icon: '🌆' }, - { from: 'Sebeta', to: 'Diredawa', duration: '8h', icon: '🏔️' }, - { from: 'Diredawa', to: 'Nagad', duration: '4h', icon: '🌊' }, + { from: "Sebeta", to: "Nagad", duration: "12h", icon: "🌆" }, + { from: "Sebeta", to: "Diredawa", duration: "8h", icon: "🏔️" }, + { from: "Diredawa", to: "Nagad", duration: "4h", icon: "🌊" }, ]; // ─── Station Modal ──────────────────────────────────────────────────────────── @@ -71,7 +91,7 @@ function StationModal({ onClose: () => void; recentIds: string[]; }) { - const [query, setQuery] = useState(''); + const [query, setQuery] = useState(""); const inputRef = useRef(null); useEffect(() => { @@ -83,7 +103,7 @@ function StationModal({ (s) => s.id !== excludeId && (s.name.toLowerCase().includes(query.toLowerCase()) || - s.code?.toLowerCase().includes(query.toLowerCase())) + s.code?.toLowerCase().includes(query.toLowerCase())), ) : stations.filter((s) => s.id !== excludeId); @@ -102,7 +122,9 @@ function StationModal({ > -

{title}

+

+ {title} +

{/* Search input */} @@ -119,7 +141,7 @@ function StationModal({ {query && (
-

{s.name}

+

+ {s.name} +

{s.code &&

{s.code}

}
@@ -153,7 +179,7 @@ function StationModal({ )}

- {query ? 'Results' : 'All Stations'} + {query ? "Results" : "All Stations"}

{filtered.length === 0 ? (
@@ -172,8 +198,14 @@ function StationModal({
-

{s.name}

- {s.code &&

{s.code} • {s.country}

} +

+ {s.name} +

+ {s.code && ( +

+ {s.code} • {s.country} +

+ )}
)) @@ -202,14 +234,28 @@ function PassengerModal({ onClose: () => void; }) { const rows = [ - { label: 'Adults', sub: '≥ 5 years', val: adultCount, min: 1, max: 9, onChange: onChangeAdult }, - { label: 'Children', sub: '< 5 years • First child free', val: childCount, min: 0, max: 9, onChange: onChangeChild }, + { + label: "Adults", + sub: "≥ 5 years", + val: adultCount, + min: 1, + max: 9, + onChange: onChangeAdult, + }, + { + label: "Children", + sub: "< 5 years • First child free", + val: childCount, + min: 0, + max: 9, + onChange: onChangeChild, + }, ]; const natOptions = [ - { value: 'ETHIOPIAN', label: '🇪🇹 Ethiopian' }, - { value: 'DJIBOUTIAN', label: '🇩🇯 Djiboutian' }, - { value: 'OTHER', label: '🌍 Other' }, + { value: "ETHIOPIAN", label: "🇪🇹 Ethiopian" }, + { value: "DJIBOUTIAN", label: "🇩🇯 Djiboutian" }, + { value: "OTHER", label: "🌍 Other" }, ]; return ( @@ -217,7 +263,7 @@ function PassengerModal({
@@ -225,29 +271,49 @@ function PassengerModal({
-

Passengers & Nationality

+

+ Passengers & Nationality +

-
{rows.map(({ label, sub, val, min, max, onChange }, i) => (
- {i > 0 &&
} + {i > 0 && ( +
+ )}
-

{label}

+

+ {label} +

{sub}

- - {val} -
@@ -255,15 +321,21 @@ function PassengerModal({
))}
-

Nationality

+

+ Nationality +

{natOptions.map((opt) => ( - ))} @@ -271,9 +343,13 @@ function PassengerModal({
-
@@ -302,22 +378,23 @@ function StationDropdown({ recentIds: string[]; onOpen?: () => void; }) { - const [query, setQuery] = useState(''); + const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const ref = useRef(null); const inputRef = useRef(null); const selectedStation = stations.find((s) => s.id === value); useEffect(() => { - if (selectedStation && !open) setQuery(''); + if (selectedStation && !open) setQuery(""); }, [selectedStation, open]); useEffect(() => { const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + if (ref.current && !ref.current.contains(e.target as Node)) + setOpen(false); }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); }, []); const filtered = query.trim() @@ -325,32 +402,46 @@ function StationDropdown({ (s) => s.id !== excludeId && (s.name.toLowerCase().includes(query.toLowerCase()) || - s.code?.toLowerCase().includes(query.toLowerCase())) + s.code?.toLowerCase().includes(query.toLowerCase())), ) : stations.filter((s) => s.id !== excludeId).slice(0, 8); - const displayValue = open ? query : (selectedStation?.name ?? ''); + const displayValue = open ? query : (selectedStation?.name ?? ""); return (
{ setQuery(e.target.value); setOpen(true); }} - onFocus={() => { setQuery(''); setOpen(true); onOpen?.(); }} + onChange={(e) => { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => { + setQuery(""); + setOpen(true); + onOpen?.(); + }} placeholder={placeholder} className="w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm text-gray-900 dark:text-white placeholder-gray-400" /> {value && ( ))}
)} {filtered.length === 0 ? ( -

No stations found

+

+ No stations found +

) : ( filtered.map((s) => ( )) @@ -413,13 +524,23 @@ export default function SearchPage() { const [passengerModalOpen, setPassengerModalOpen] = useState(false); const [promoVisible, setPromoVisible] = useState(false); - const [promoCode, setPromoCode] = useState(''); - const [promoValidation, setPromoValidation] = useState<{ valid: boolean; message: string } | null>(null); + const [promoCode, setPromoCode] = useState(""); + const [promoValidation, setPromoValidation] = useState<{ + valid: boolean; + message: string; + } | null>(null); const [promoLoading, setPromoLoading] = useState(false); const [swapping, setSwapping] = useState(false); - const [stationModal, setStationModal] = useState<'origin' | 'destination' | null>(null); + const [stationModal, setStationModal] = useState< + "origin" | "destination" | null + >(null); + const [hasInteracted, setHasInteracted] = useState(false); const [recentStationIds, setRecentStationIds] = useState(() => { - try { return JSON.parse(localStorage.getItem('edr_recent_stations') || '[]'); } catch { return []; } + try { + return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]"); + } catch { + return []; + } }); const passengerRef = useRef(null); const widgetRef = useRef(null); @@ -429,72 +550,100 @@ export default function SearchPage() { if (!el) return; const headerHeight = 64; const marginTop = 24; - const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - marginTop; - window.scrollTo({ top, behavior: 'smooth' }); + const top = + el.getBoundingClientRect().top + + window.scrollY - + headerHeight - + marginTop; + window.scrollTo({ top, behavior: "smooth" }); }; - const { data: stations = [], isLoading, error } = useQuery({ - queryKey: ['stations'], - queryFn: async () => await apiClient.get('/stations') as Station[], + const { + data: stations = [], + isLoading, + error, + } = useQuery({ + queryKey: ["stations"], + queryFn: async () => (await apiClient.get("/stations")) as Station[], }); - const { handleSubmit, watch, setValue, formState: { errors } } = useForm({ + const { + handleSubmit, + watch, + setValue, + trigger, + clearErrors, + formState: { errors }, + } = useForm({ resolver: zodResolver(searchSchema as any), + mode: "onSubmit", + reValidateMode: "onSubmit", defaultValues: { - tripType: 'ONE_WAY', + tripType: "ONE_WAY", adultCount: 1, childCount: 0, - nationality: 'ETHIOPIAN', - departureDate: new Date().toISOString().split('T')[0], - promoCode: '', + nationality: "ETHIOPIAN", + departureDate: new Date().toISOString().split("T")[0], + promoCode: "", }, }); useEffect(() => { if (isAuthenticated && user?.nationality) { const n = user.nationality.toUpperCase().trim(); - setValue('nationality', n.includes('DJIBOUTIAN') ? 'DJIBOUTIAN' : n.includes('ETHIOPIAN') ? 'ETHIOPIAN' : 'OTHER'); + setValue( + "nationality", + n.includes("DJIBOUTIAN") + ? "DJIBOUTIAN" + : n.includes("ETHIOPIAN") + ? "ETHIOPIAN" + : "OTHER", + ); } }, [isAuthenticated, user?.nationality, setValue]); useEffect(() => { - const o = searchParams.get('origin'); - const d = searchParams.get('destination'); - const date = searchParams.get('date'); - const adults = searchParams.get('adults'); - const children = searchParams.get('children'); - const nat = searchParams.get('nationality'); - if (o) setValue('originStationId', o); - if (d) setValue('destinationStationId', d); - if (date) setValue('departureDate', date); - if (adults) setValue('adultCount', parseInt(adults)); - if (children) setValue('childCount', parseInt(children)); - if (nat) setValue('nationality', nat as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'); + const o = searchParams.get("origin"); + const d = searchParams.get("destination"); + const date = searchParams.get("date"); + const adults = searchParams.get("adults"); + const children = searchParams.get("children"); + const nat = searchParams.get("nationality"); + if (o) setValue("originStationId", o); + if (d) setValue("destinationStationId", d); + if (date) setValue("departureDate", date); + if (adults) setValue("adultCount", parseInt(adults)); + if (children) setValue("childCount", parseInt(children)); + if (nat) + setValue("nationality", nat as "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER"); }, [searchParams, setValue]); useEffect(() => { const handler = (e: MouseEvent) => { - if (passengerRef.current && !passengerRef.current.contains(e.target as Node)) { + if ( + passengerRef.current && + !passengerRef.current.contains(e.target as Node) + ) { setPassengerModalOpen(false); } }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); }, []); - const originId = watch('originStationId'); - const destId = watch('destinationStationId'); - const adultCount = watch('adultCount'); - const childCount = watch('childCount'); - const departureDate = watch('departureDate'); - const returnDate = watch('returnDate'); - const tripType = watch('tripType'); + const originId = watch("originStationId"); + const destId = watch("destinationStationId"); + 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) => { setRecentStationIds((prev) => { const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5); - localStorage.setItem('edr_recent_stations', JSON.stringify(next)); + localStorage.setItem("edr_recent_stations", JSON.stringify(next)); return next; }); }, []); @@ -503,8 +652,8 @@ export default function SearchPage() { if (!originId || !destId) return; setSwapping(true); setTimeout(() => { - setValue('originStationId', destId); - setValue('destinationStationId', originId); + setValue("originStationId", destId); + setValue("destinationStationId", originId); setSwapping(false); }, 300); }; @@ -513,20 +662,31 @@ export default function SearchPage() { if (!promoCode.trim()) return setPromoValidation(null); setPromoLoading(true); try { - const res = await apiClient.post('/promos/validate', { code: promoCode }) as any; + const res = (await apiClient.post("/promos/validate", { + code: promoCode, + })) as any; const valid = res.applicable || res.valid; - setPromoValidation({ valid, message: res.message || (valid ? 'Promo applied!' : 'Invalid promo code') }); - if (valid) setValue('promoCode', promoCode); - else setPromoCode(''); + setPromoValidation({ + valid, + message: + res.message || (valid ? "Promo applied!" : "Invalid promo code"), + }); + if (valid) setValue("promoCode", promoCode); + else setPromoCode(""); } catch (err: any) { - setPromoValidation({ valid: false, message: err?.response?.data?.message || 'Promo code is invalid or expired' }); - setPromoCode(''); + setPromoValidation({ + valid: false, + message: + err?.response?.data?.message || "Promo code is invalid or expired", + }); + setPromoCode(""); } finally { setPromoLoading(false); } }; const onSubmit = (data: SearchForm) => { + setHasInteracted(true); setSearchCriteria(data); if (data.originStationId) saveRecent(data.originStationId); if (data.destinationStationId) saveRecent(data.destinationStationId); @@ -538,7 +698,8 @@ export default function SearchPage() { adults: data.adultCount.toString(), children: data.childCount.toString(), nationality: data.nationality, - ...(data.tripType === 'ROUND_TRIP' && data.returnDate && { returnDate: data.returnDate }), + ...(data.tripType === "ROUND_TRIP" && + data.returnDate && { returnDate: data.returnDate }), ...(data.promoCode && { promoCode: data.promoCode }), }); router.push(`/booking/results?${params}`); @@ -549,12 +710,16 @@ export default function SearchPage() { const destStation = getStationById(destId); const handlePopularRoute = (fromName: string, toName: string) => { - const origin = stations.find((s) => s.name.toLowerCase().includes(fromName.toLowerCase())); - const dest = stations.find((s) => s.name.toLowerCase().includes(toName.toLowerCase())); + const origin = stations.find((s) => + s.name.toLowerCase().includes(fromName.toLowerCase()), + ); + const dest = stations.find((s) => + s.name.toLowerCase().includes(toName.toLowerCase()), + ); if (origin && dest) { - setValue('originStationId', origin.id); - setValue('destinationStationId', dest.id); - window.scrollTo({ top: 0, behavior: 'smooth' }); + setValue("originStationId", origin.id); + setValue("destinationStationId", dest.id); + window.scrollTo({ top: 0, behavior: "smooth" }); } }; @@ -565,36 +730,45 @@ export default function SearchPage() { setValue('adultCount', n)} - onChangeChild={(n) => setValue('childCount', n)} - onChangeNationality={(v) => setValue('nationality', v as any)} + nationality={watch("nationality")} + onChangeAdult={(n) => setValue("adultCount", n)} + onChangeChild={(n) => setValue("childCount", n)} + onChangeNationality={(v) => setValue("nationality", v as any)} onClose={() => setPassengerModalOpen(false)} /> )} {/* Station modals (mobile) */} - {stationModal === 'origin' && ( + {stationModal === "origin" && ( { - if (s.id) { setValue('originStationId', s.id); saveRecent(s.id); } + if (s.id) { + setValue("originStationId", s.id); + saveRecent(s.id); + clearErrors("originStationId"); + clearErrors("destinationStationId"); + } setStationModal(null); }} onClose={() => setStationModal(null)} /> )} - {stationModal === 'destination' && ( + {stationModal === "destination" && ( { - if (s.id) { setValue('destinationStationId', s.id); saveRecent(s.id); } + if (s.id) { + setValue("destinationStationId", s.id); + saveRecent(s.id); + clearErrors("destinationStationId"); + } setStationModal(null); }} onClose={() => setStationModal(null)} @@ -604,61 +778,76 @@ export default function SearchPage() { {/* ── 90vh hero with banner image ── */}
- {/* Background image */} -
+ {/* Background image with zoom - fully isolated */} +
+
+
{/* Gradient overlay */}
{/* Hero headline — top area */}
-

- Where are you
headed today? +

+ Where are you +
headed today?

-

Book your train journey across East Africa

+

+ Book your train journey across East Africa +

{/* ── Widget — absolutely positioned at bottom with margin ── */} -
+
- {error && (
⚠️ - Unable to load stations. Please check your connection. + + Unable to load stations. Please check your connection. +
)}
- {/* Trip Type Tabs */}
- -
-
- +
setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} - minDate={new Date()} placeholder="Select date" + 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")}`, + ); + trigger("departureDate"); + }} + minDate={new Date()} + placeholder="Select date" />
- {tripType === 'ROUND_TRIP' && ( + {tripType === "ROUND_TRIP" && (
- +
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()} + 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")}`, + ); + trigger("returnDate"); + }} + minDate={ + departureDate + ? new Date(departureDate + "T00:00:00") + : new Date() + } placeholder="Select return date" />
- {errors.returnDate &&

{errors.returnDate.message}

} + {errors.returnDate && ( +

+ {errors.returnDate.message} +

+ )}
)} {/* Pax + Nationality combined trigger */} - - @@ -743,59 +1024,132 @@ export default function SearchPage() { {/* Desktop: dynamic layout based on trip type */}
- {tripType === 'ONE_WAY' ? ( + {tripType === "ONE_WAY" ? ( // ONE WAY: Single row layout
{/* From */}
- - { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} /> - {errors.originStationId &&

{errors.originStationId.message}

} + + { + setHasInteracted(true); + setValue("originStationId", s.id); + if (s.id) saveRecent(s.id); + clearErrors("originStationId"); + clearErrors("destinationStationId"); + }} + error={hasInteracted ? errors.originStationId?.message : undefined} + onOpen={scrollWidgetIntoView} + /> + {hasInteracted && errors.originStationId && ( +

+ {errors.originStationId.message} +

+ )}
{/* Swap */} - {/* To */}
- - { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} /> - {errors.destinationStationId &&

{errors.destinationStationId.message}

} + + { + setHasInteracted(true); + setValue("destinationStationId", s.id); + if (s.id) saveRecent(s.id); + clearErrors("destinationStationId"); + }} + error={hasInteracted ? errors.destinationStationId?.message : undefined} + onOpen={scrollWidgetIntoView} + /> + {hasInteracted && errors.destinationStationId && ( +

+ {errors.destinationStationId.message} +

+ )}
{/* Divider */}
{/* Date */}
- +
setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} - minDate={new Date()} placeholder="Departure" + 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")}`, + ); + trigger("departureDate"); + }} + minDate={new Date()} + placeholder="Departure" />
- {errors.departureDate &&

{errors.departureDate.message}

} + {errors.departureDate && ( +

+ {errors.departureDate.message} +

+ )}
{/* Divider */}
{/* Pax + Nationality */}
- -
{/* Search */} - @@ -807,49 +1161,130 @@ export default function SearchPage() {
{/* From */}
- - { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} /> - {errors.originStationId &&

{errors.originStationId.message}

} + + { + setHasInteracted(true); + setValue("originStationId", s.id); + if (s.id) saveRecent(s.id); + clearErrors("originStationId"); + clearErrors("destinationStationId"); + }} + error={hasInteracted ? errors.originStationId?.message : undefined} + onOpen={scrollWidgetIntoView} + /> + {hasInteracted && errors.originStationId && ( +

+ {errors.originStationId.message} +

+ )}
{/* Swap */} - {/* To */}
- - { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} /> - {errors.destinationStationId &&

{errors.destinationStationId.message}

} + + { + setHasInteracted(true); + setValue("destinationStationId", s.id); + if (s.id) saveRecent(s.id); + clearErrors("destinationStationId"); + }} + error={hasInteracted ? errors.destinationStationId?.message : undefined} + onOpen={scrollWidgetIntoView} + /> + {hasInteracted && errors.destinationStationId && ( +

+ {errors.destinationStationId.message} +

+ )}
{/* Divider */}
{/* Departure Date */}
- +
setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} - minDate={new Date()} placeholder="Select date" - /> -
- {errors.departureDate &&

{errors.departureDate.message}

} -
- {/* Return 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()} + 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")}`, + ); + trigger("departureDate"); + trigger("returnDate"); + }} + minDate={new Date()} placeholder="Select date" />
- {errors.returnDate &&

{errors.returnDate.message}

} + {errors.departureDate && ( +

+ {errors.departureDate.message} +

+ )} +
+ {/* Return Date */} +
+ +
+ { + setValue( + "returnDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); + trigger("returnDate"); + }} + minDate={ + departureDate + ? new Date(departureDate + "T00:00:00") + : new Date() + } + placeholder="Select date" + /> +
+ {errors.returnDate && ( +

+ {errors.returnDate.message} +

+ )}
@@ -858,38 +1293,73 @@ export default function SearchPage() { {/* Promo Code */}
{!promoVisible ? ( - ) : (
- { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }} + { + setPromoCode( + e.target.value.toUpperCase(), + ); + if (promoValidation) + setPromoValidation(null); + }} placeholder="Enter promo code" - onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())} + 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 /> + autoFocus + />
- -
{promoValidation && ( -
- {promoValidation.valid && } +
+ {promoValidation.valid && ( + + )} {promoValidation.message}
)} @@ -900,21 +1370,36 @@ export default function SearchPage() {
{/* Pax + Nationality */}
- -
{/* Search Button */}
- - @@ -925,11 +1410,14 @@ export default function SearchPage() {
{/* Promo - Only visible in ONE WAY mode on desktop */} - {tripType === 'ONE_WAY' && ( + {tripType === "ONE_WAY" && (
{!promoVisible ? ( - @@ -938,25 +1426,49 @@ export default function SearchPage() {
- { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }} + { + setPromoCode(e.target.value.toUpperCase()); + if (promoValidation) setPromoValidation(null); + }} placeholder="Enter promo code" - onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())} + 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 /> + autoFocus + />
- -
{promoValidation && ( -
- {promoValidation.valid && } +
+ {promoValidation.valid && ( + + )} {promoValidation.message}
)} @@ -964,7 +1476,6 @@ export default function SearchPage() { )}
)} -
@@ -978,12 +1489,18 @@ export default function SearchPage() {
-

Popular Routes

+

+ Popular Routes +

{POPULAR_ROUTES.map((route, idx) => ( -
@@ -157,7 +165,7 @@ export default function AppHeader() { )} - + data.originStationId !== data.destinationStationId, { +}).refine((data) => { + if (!data.originStationId || !data.destinationStationId) return true; + return data.originStationId !== data.destinationStationId; +}, { message: 'Origin and destination must be different', path: ['destinationStationId'], }); @@ -43,10 +46,15 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) queryFn: async () => await apiClient.get('/stations') as Station[], }); - const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({ - resolver: zodResolver(searchSchema as any), + const { register, handleSubmit, watch, setValue, clearErrors, formState: { errors } } = useForm({ + // @ts-ignore - ZodEffects type compatibility issue + resolver: zodResolver(searchSchema), + mode: 'onSubmit', + reValidateMode: 'onChange', defaultValues: { tripType: 'ONE_WAY', + originStationId: '', + destinationStationId: '', adultCount: 1, childCount: 0, nationality: 'ETHIOPIAN', @@ -89,8 +97,14 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
{errors.originStationId && ( -

{errors.originStationId.message}

+

{errors.originStationId.message}

)}
@@ -110,8 +124,14 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
{errors.destinationStationId && ( -

{errors.destinationStationId.message}

+

{errors.destinationStationId.message}

)}
@@ -135,12 +155,13 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); setValue('departureDate', `${year}-${month}-${day}`); + clearErrors('departureDate'); }} minDate={new Date()} placeholder="Select date" /> {errors.departureDate && ( -

{errors.departureDate.message}

+

{errors.departureDate.message}

)}
@@ -267,6 +288,17 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) {/* Row 3: Search Button */}
+ {/* Debug info - remove after testing */} + {Object.keys(errors).length > 0 && ( +
+

Validation Errors:

+
    + {Object.entries(errors).map(([key, value]) => ( +
  • {key}: {value?.message}
  • + ))} +
+
+ )}