This commit is contained in:
Stephanos A
2026-06-17 15:52:29 +03:00
117 changed files with 5948 additions and 2710 deletions

View File

@@ -10,10 +10,6 @@ on:
permissions: permissions:
contents: read contents: read
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: true
jobs: jobs:
detect-changes: detect-changes:
name: Detect changed services name: Detect changed services
@@ -73,8 +69,8 @@ jobs:
fi fi
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api") 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-web-portal/" && SERVICES+=("freight-portal")
echo "$CHANGED" | grep -q "^apps/edr-freight-backoffice/" && SERVICES+=("freight-backoffice") 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-api/" && SERVICES+=("passenger-api")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
@@ -149,12 +145,12 @@ jobs:
- name: Build ${{ matrix.service }} - name: Build ${{ matrix.service }}
run: | run: |
set -euo pipefail set -euo pipefail
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}"
- name: Deploy ${{ matrix.service }} - name: Deploy ${{ matrix.service }}
run: | run: |
set -euo pipefail 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 - name: Remove npm credentials from workspace
if: always() if: always()

View File

@@ -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]
# ...

View File

@@ -15,6 +15,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "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" "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
}, },
"dependencies": { "dependencies": {

View File

@@ -48,6 +48,7 @@ import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.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 //New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module"; import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from './modules/wagons/wagons.module'; import { WagonsModule } from './modules/wagons/wagons.module';
@@ -121,6 +122,7 @@ import { OverviewModule } from './modules/overview/overview.module';
PricingDataSeeder, PricingDataSeeder,
FileUploadSettingsSeeder, FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder, FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
], ],
}) })
export class AppModule implements OnApplicationBootstrap { export class AppModule implements OnApplicationBootstrap {
@@ -133,6 +135,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly pricingDataSeeder: PricingDataSeeder, private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { } ) { }
async onApplicationBootstrap() { async onApplicationBootstrap() {
@@ -144,5 +147,8 @@ export class AppModule implements OnApplicationBootstrap {
await this.demoBookingsSeeder.run(); await this.demoBookingsSeeder.run();
await this.pricingDataSeeder.run(); await this.pricingDataSeeder.run();
await this.fileUploadSettingsSeeder.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();
} }
} }

View File

@@ -21,3 +21,10 @@ export const TrainSchedulingView = () =>
export const TrainSchedulingManage = () => export const TrainSchedulingManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.manage); 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);

View File

@@ -10,12 +10,14 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { BackofficeService } from "./backoffice.service"; import { BackofficeService } from "./backoffice.service";
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto"; import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
@ApiTags("backoffice") @ApiTags("backoffice")
@Controller("backoffice") @Controller("backoffice")
@FreightAdmin()
export class BackofficeController { export class BackofficeController {
constructor(private readonly backofficeService: BackofficeService) {} constructor(private readonly backofficeService: BackofficeService) {}

View File

@@ -1,10 +1,12 @@
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common"; import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { BillingService } from "./billing.service"; import { BillingService } from "./billing.service";
@ApiTags("billing") @ApiTags("billing")
@Controller("billing") @Controller("billing")
@FreightAdmin()
export class BillingController { export class BillingController {
constructor(private readonly billingService: BillingService) {} constructor(private readonly billingService: BillingService) {}

View File

@@ -10,6 +10,7 @@ import {
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateCargoDto } from './dto/create-cargo.dto'; import { CreateCargoDto } from './dto/create-cargo.dto';
import { UpdateCargoDto } from './dto/update-cargo.dto'; import { UpdateCargoDto } from './dto/update-cargo.dto';
import { LoadCargoDto } from './dto/load-cargo.dto'; import { LoadCargoDto } from './dto/load-cargo.dto';
@@ -18,10 +19,12 @@ import { CargoesService } from './cargoes.service';
@ApiTags('cargoes') @ApiTags('cargoes')
@Controller('cargoes') @Controller('cargoes')
@FleetView()
export class CargoesController { export class CargoesController {
constructor(private readonly cargoesService: CargoesService) {} constructor(private readonly cargoesService: CargoesService) {}
@Post() @Post()
@FleetManage()
@ApiOperation({ summary: 'Create a new cargo' }) @ApiOperation({ summary: 'Create a new cargo' })
create(@Body() dto: CreateCargoDto) { create(@Body() dto: CreateCargoDto) {
return this.cargoesService.create(dto); return this.cargoesService.create(dto);
@@ -40,30 +43,35 @@ export class CargoesController {
} }
@Patch(':id') @Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a cargo' }) @ApiOperation({ summary: 'Update a cargo' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
return this.cargoesService.update(id, dto); return this.cargoesService.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Delete a cargo' }) @ApiOperation({ summary: 'Delete a cargo' })
remove(@Param('id', ParseUUIDPipe) id: string) { remove(@Param('id', ParseUUIDPipe) id: string) {
return this.cargoesService.remove(id); return this.cargoesService.remove(id);
} }
@Post(':id/load') @Post(':id/load')
@FleetManage()
@ApiOperation({ summary: 'Load cargo into a container' }) @ApiOperation({ summary: 'Load cargo into a container' })
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) { load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
return this.cargoesService.loadCargo(id, dto); return this.cargoesService.loadCargo(id, dto);
} }
@Post(':id/unload') @Post(':id/unload')
@FleetManage()
@ApiOperation({ summary: 'Unload cargo from container' }) @ApiOperation({ summary: 'Unload cargo from container' })
unload(@Param('id', ParseUUIDPipe) id: string) { unload(@Param('id', ParseUUIDPipe) id: string) {
return this.cargoesService.unloadCargo(id); return this.cargoesService.unloadCargo(id);
} }
@Post(':id/deliver') @Post(':id/deliver')
@FleetManage()
@ApiOperation({ summary: 'Mark cargo as delivered' }) @ApiOperation({ summary: 'Mark cargo as delivered' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) { deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
return this.cargoesService.deliverCargo(id, dto); return this.cargoesService.deliverCargo(id, dto);

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe
import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger'; import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common'; import { CurrentUser } from '@edr/api-common';
import { FreightAdmin } from '../../common/booking-guards';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { CompaniesService } from './companies.service'; import { CompaniesService } from './companies.service';
import { CreateCompanyDto } from './dto/create-company.dto'; import { CreateCompanyDto } from './dto/create-company.dto';
@@ -82,6 +83,7 @@ export class CompaniesController {
} }
@Post() @Post()
@FreightAdmin()
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' }) @ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> { async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
const company = await this.companiesService.createCompany(dto); const company = await this.companiesService.createCompany(dto);
@@ -119,6 +121,7 @@ export class CompaniesController {
} }
@Patch(':id') @Patch(':id')
@FreightAdmin()
@ApiOperation({ summary: 'Update a company' }) @ApiOperation({ summary: 'Update a company' })
async update( async update(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -129,6 +132,7 @@ export class CompaniesController {
} }
@Delete(':id') @Delete(':id')
@FreightAdmin()
@ApiOperation({ summary: 'Soft-delete a company' }) @ApiOperation({ summary: 'Soft-delete a company' })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> { async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
@@ -147,6 +151,7 @@ export class CompaniesController {
} }
@Post(':companyId/profiles') @Post(':companyId/profiles')
@FreightAdmin()
@ApiOperation({ summary: 'Add a profile (employee) to a company' }) @ApiOperation({ summary: 'Add a profile (employee) to a company' })
async createProfile( async createProfile(
@Param('companyId', ParseUUIDPipe) companyId: string, @Param('companyId', ParseUUIDPipe) companyId: string,
@@ -175,6 +180,7 @@ export class CompaniesController {
} }
@Post('ff-clients') @Post('ff-clients')
@FreightAdmin()
@ApiOperation({ summary: 'Link a forwarder to a client company' }) @ApiOperation({ summary: 'Link a forwarder to a client company' })
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> { async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
const client = await this.companiesService.createFFClient(dto); const client = await this.companiesService.createFFClient(dto);
@@ -191,6 +197,7 @@ export class CompaniesController {
} }
@Delete('ff-clients/:id') @Delete('ff-clients/:id')
@FreightAdmin()
@ApiOperation({ summary: 'Remove a forwarder-client relationship' }) @ApiOperation({ summary: 'Remove a forwarder-client relationship' })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> { async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {

View File

@@ -9,16 +9,19 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FleetManage, FleetView } from "../../common/booking-guards";
import { ConsignmentsService } from "./consignments.service"; import { ConsignmentsService } from "./consignments.service";
import { CreateConsignmentDto } from "./dto/create-consignment.dto"; import { CreateConsignmentDto } from "./dto/create-consignment.dto";
import { FilterConsignmentDto } from "./dto/filter-consignment.dto"; import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
@ApiTags("consignments") @ApiTags("consignments")
@Controller("consignments") @Controller("consignments")
@FleetView()
export class ConsignmentsController { export class ConsignmentsController {
constructor(private readonly consignmentsService: ConsignmentsService) {} constructor(private readonly consignmentsService: ConsignmentsService) {}
@Post() @Post()
@FleetManage()
@ApiOperation({ summary: "Create a new consignment" }) @ApiOperation({ summary: "Create a new consignment" })
create(@Body() dto: CreateConsignmentDto) { create(@Body() dto: CreateConsignmentDto) {
return this.consignmentsService.create(dto); return this.consignmentsService.create(dto);

View File

@@ -10,6 +10,7 @@ import {
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateContainerDto } from './dto/create-container.dto'; import { CreateContainerDto } from './dto/create-container.dto';
import { UpdateContainerDto } from './dto/update-container.dto'; import { UpdateContainerDto } from './dto/update-container.dto';
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
@@ -17,10 +18,12 @@ import { ContainersService } from './containers.service';
@ApiTags('containers') @ApiTags('containers')
@Controller('containers') @Controller('containers')
@FleetView()
export class ContainersController { export class ContainersController {
constructor(private readonly containersService: ContainersService) {} constructor(private readonly containersService: ContainersService) {}
@Post() @Post()
@FleetManage()
@ApiOperation({ summary: 'Create a new container' }) @ApiOperation({ summary: 'Create a new container' })
create(@Body() dto: CreateContainerDto) { create(@Body() dto: CreateContainerDto) {
return this.containersService.create(dto); return this.containersService.create(dto);
@@ -39,24 +42,28 @@ export class ContainersController {
} }
@Patch(':id') @Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a container' }) @ApiOperation({ summary: 'Update a container' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
return this.containersService.update(id, dto); return this.containersService.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Delete a container' }) @ApiOperation({ summary: 'Delete a container' })
remove(@Param('id', ParseUUIDPipe) id: string) { remove(@Param('id', ParseUUIDPipe) id: string) {
return this.containersService.remove(id); return this.containersService.remove(id);
} }
@Post(':id/assign-wagon') @Post(':id/assign-wagon')
@FleetManage()
@ApiOperation({ summary: 'Assign container to a wagon' }) @ApiOperation({ summary: 'Assign container to a wagon' })
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) { assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
return this.containersService.assignToWagon(id, dto); return this.containersService.assignToWagon(id, dto);
} }
@Post(':id/unassign-wagon') @Post(':id/unassign-wagon')
@FleetManage()
@ApiOperation({ summary: 'Unassign container from wagon' }) @ApiOperation({ summary: 'Unassign container from wagon' })
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) { unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
return this.containersService.unassignFromWagon(id); return this.containersService.unassignFromWagon(id);

View File

@@ -16,12 +16,14 @@ import {
import { ApiOperation } from "@nestjs/swagger"; import { ApiOperation } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { CustomersService } from "./customers.service"; import { CustomersService } from "./customers.service";
import { CreateCustomerDto } from "./dto/create-customer.dto"; import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto"; import { UpdateCustomerDto } from "./dto/update-customer.dto";
import { Customer } from "./entities/customer.entity"; import { Customer } from "./entities/customer.entity";
@Controller("customers") @Controller("customers")
@FreightAdmin()
export class CustomersController { export class CustomersController {
constructor(private readonly customersService: CustomersService) {} constructor(private readonly customersService: CustomersService) {}

View File

@@ -13,6 +13,7 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto"; import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto"; import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto"; import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
@@ -24,6 +25,9 @@ import { DropdownSettingsService } from "./dropdown-settings.service";
export class DropdownSettingsController { export class DropdownSettingsController {
constructor(private readonly service: DropdownSettingsService) {} 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() @Get()
@ApiOperation({ summary: "List all dropdown settings" }) @ApiOperation({ summary: "List all dropdown settings" })
list() { list() {
@@ -43,12 +47,14 @@ export class DropdownSettingsController {
} }
@Post() @Post()
@FreightAdmin()
@ApiOperation({ summary: "Create a new dropdown setting" }) @ApiOperation({ summary: "Create a new dropdown setting" })
create(@Body() dto: CreateDropdownSettingDto) { create(@Body() dto: CreateDropdownSettingDto) {
return this.service.create(dto); return this.service.create(dto);
} }
@Patch(":id") @Patch(":id")
@FreightAdmin()
@ApiOperation({ summary: "Update a dropdown setting's metadata" }) @ApiOperation({ summary: "Update a dropdown setting's metadata" })
update( update(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -58,6 +64,7 @@ export class DropdownSettingsController {
} }
@Delete(":id") @Delete(":id")
@FreightAdmin()
@ApiOperation({ summary: "Soft-delete a dropdown setting" }) @ApiOperation({ summary: "Soft-delete a dropdown setting" })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) { remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -67,6 +74,7 @@ export class DropdownSettingsController {
/* ------------------------- option routes ------------------------- */ /* ------------------------- option routes ------------------------- */
@Put(":id/options") @Put(":id/options")
@FreightAdmin()
@ApiOperation({ summary: "Replace the full option list for a setting" }) @ApiOperation({ summary: "Replace the full option list for a setting" })
replaceOptions( replaceOptions(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -76,6 +84,7 @@ export class DropdownSettingsController {
} }
@Post(":id/options") @Post(":id/options")
@FreightAdmin()
@ApiOperation({ summary: "Append a single option to a setting" }) @ApiOperation({ summary: "Append a single option to a setting" })
addOption( addOption(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -85,6 +94,7 @@ export class DropdownSettingsController {
} }
@Patch("options/:optionId") @Patch("options/:optionId")
@FreightAdmin()
@ApiOperation({ summary: "Update a single option" }) @ApiOperation({ summary: "Update a single option" })
updateOption( updateOption(
@Param("optionId", ParseUUIDPipe) optionId: string, @Param("optionId", ParseUUIDPipe) optionId: string,
@@ -94,6 +104,7 @@ export class DropdownSettingsController {
} }
@Delete("options/:optionId") @Delete("options/:optionId")
@FreightAdmin()
@ApiOperation({ summary: "Soft-delete a single option" }) @ApiOperation({ summary: "Soft-delete a single option" })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) { removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {

View File

@@ -13,6 +13,7 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto"; import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto";
import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto"; import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto";
import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto"; import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto";
@@ -24,6 +25,9 @@ import { FileUploadSettingsService } from "./file-upload-settings.service";
export class FileUploadSettingsController { export class FileUploadSettingsController {
constructor(private readonly service: FileUploadSettingsService) {} 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() @Get()
@ApiOperation({ summary: "List all file upload settings" }) @ApiOperation({ summary: "List all file upload settings" })
list() { list() {
@@ -49,12 +53,14 @@ export class FileUploadSettingsController {
} }
@Post() @Post()
@FreightAdmin()
@ApiOperation({ summary: "Create a new file upload setting" }) @ApiOperation({ summary: "Create a new file upload setting" })
create(@Body() dto: CreateFileUploadSettingDto) { create(@Body() dto: CreateFileUploadSettingDto) {
return this.service.create(dto); return this.service.create(dto);
} }
@Patch(":id") @Patch(":id")
@FreightAdmin()
@ApiOperation({ summary: "Update a file upload setting's metadata" }) @ApiOperation({ summary: "Update a file upload setting's metadata" })
update( update(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -64,6 +70,7 @@ export class FileUploadSettingsController {
} }
@Delete(":id") @Delete(":id")
@FreightAdmin()
@ApiOperation({ summary: "Soft-delete a file upload setting" }) @ApiOperation({ summary: "Soft-delete a file upload setting" })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) { remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -73,6 +80,7 @@ export class FileUploadSettingsController {
/* ------------------------- field routes ------------------------- */ /* ------------------------- field routes ------------------------- */
@Put(":id/fields") @Put(":id/fields")
@FreightAdmin()
@ApiOperation({ summary: "Replace the full field list for a setting" }) @ApiOperation({ summary: "Replace the full field list for a setting" })
replaceFields( replaceFields(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -82,6 +90,7 @@ export class FileUploadSettingsController {
} }
@Post(":id/fields") @Post(":id/fields")
@FreightAdmin()
@ApiOperation({ summary: "Append a single field to a setting" }) @ApiOperation({ summary: "Append a single field to a setting" })
addField( addField(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -91,6 +100,7 @@ export class FileUploadSettingsController {
} }
@Patch("fields/:fieldId") @Patch("fields/:fieldId")
@FreightAdmin()
@ApiOperation({ summary: "Update a single field" }) @ApiOperation({ summary: "Update a single field" })
updateField( updateField(
@Param("fieldId", ParseUUIDPipe) fieldId: string, @Param("fieldId", ParseUUIDPipe) fieldId: string,
@@ -100,6 +110,7 @@ export class FileUploadSettingsController {
} }
@Delete("fields/:fieldId") @Delete("fields/:fieldId")
@FreightAdmin()
@ApiOperation({ summary: "Soft-delete a single field" }) @ApiOperation({ summary: "Soft-delete a single field" })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) { removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) {

View File

@@ -1,6 +1,7 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
@@ -9,6 +10,7 @@ import { LocomotivesService } from './locomotives.service';
@ApiTags('locomotives') @ApiTags('locomotives')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('locomotives') @Controller('locomotives')
@FleetView()
export class LocomotivesController { export class LocomotivesController {
constructor(private readonly locomotivesService: LocomotivesService) {} constructor(private readonly locomotivesService: LocomotivesService) {}
@@ -25,18 +27,21 @@ export class LocomotivesController {
} }
@Post() @Post()
@FleetManage()
@ApiOperation({ summary: 'Create a locomotive' }) @ApiOperation({ summary: 'Create a locomotive' })
create(@Body() dto: CreateLocomotiveDto) { create(@Body() dto: CreateLocomotiveDto) {
return this.locomotivesService.create(dto); return this.locomotivesService.create(dto);
} }
@Patch(':id') @Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a locomotive' }) @ApiOperation({ summary: 'Update a locomotive' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
return this.locomotivesService.update(id, dto); return this.locomotivesService.update(id, dto);
} }
@Post(':id/decommission') @Post(':id/decommission')
@FleetManage()
@ApiOperation({ summary: 'Decommission a locomotive' }) @ApiOperation({ summary: 'Decommission a locomotive' })
decommission(@Param('id', ParseUUIDPipe) id: string) { decommission(@Param('id', ParseUUIDPipe) id: string) {
return this.locomotivesService.decommission(id); return this.locomotivesService.decommission(id);

View File

@@ -18,7 +18,7 @@ import {
export class PaymentClientService { export class PaymentClientService {
private readonly logger = new Logger(PaymentClientService.name); private readonly logger = new Logger(PaymentClientService.name);
private readonly baseUrl = ( private readonly baseUrl = (
process.env.PAYMENT_API_URL ?? "http://localhost:3003" process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
).replace(/\/$/, ""); ).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";

View File

@@ -17,6 +17,7 @@ import {
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { Response } from "express"; import { Response } from "express";
import { Public } from "@edr/api-common"; import { Public } from "@edr/api-common";
import { BookingView, FreightAdmin } from "../../common/booking-guards";
import { PaymentService } from "./payment.service"; import { PaymentService } from "./payment.service";
import { import {
InitiatePaymentDto, InitiatePaymentDto,
@@ -32,8 +33,16 @@ import {
export class PaymentController { export class PaymentController {
constructor(private readonly paymentService: PaymentService) { } constructor(private readonly paymentService: PaymentService) { }
@Get("summary")
@BookingView()
@ApiOperation({ summary: "Payment count/amount summary for dashboard cards" })
getSummary() {
return this.paymentService.getSummary();
}
@Get("all") @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: "search", required: false })
@ApiQuery({ name: "status", required: false }) @ApiQuery({ name: "status", required: false })
@ApiQuery({ name: "method", required: false }) @ApiQuery({ name: "method", required: false })
@@ -73,6 +82,7 @@ export class PaymentController {
} }
@Post("refund") @Post("refund")
@FreightAdmin()
@ApiOperation({ summary: "Refund a paid booking (staff/admin only)" }) @ApiOperation({ summary: "Refund a paid booking (staff/admin only)" })
refund(@Body() dto: RefundDto) { refund(@Body() dto: RefundDto) {
return this.paymentService.refund(dto); return this.paymentService.refund(dto);

View File

@@ -105,6 +105,40 @@ export class PaymentService {
}; };
} }
/** Aggregate counts across ALL payments for the dashboard summary cards. */
async getSummary() {
const rows = await this.paymentRepo
.createQueryBuilder("payment")
.select("payment.status", "status")
.addSelect("COUNT(*)::int", "count")
.groupBy("payment.status")
.getRawMany<{ status: string; count: number }>();
const byStatus: Record<string, number> = {};
let total = 0;
for (const row of rows) {
byStatus[row.status] = row.count;
total += row.count;
}
// Sum of successfully collected amounts.
const paidAgg = await this.paymentRepo
.createQueryBuilder("payment")
.select("COALESCE(SUM(payment.amount), 0)", "sum")
.where("payment.status = :status", { status: "success" })
.getRawOne<{ sum: string }>();
return {
total,
success: byStatus["success"] ?? 0,
processing:
(byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0),
failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0),
refunded: byStatus["refunded"] ?? 0,
paidAmount: Number(paidAgg?.sum ?? 0),
};
}
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> { async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
const booking = await this.datasource const booking = await this.datasource
.getRepository(Booking) .getRepository(Booking)

View File

@@ -1,6 +1,7 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateRouteDto } from './dto/create-route.dto'; import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto'; import { UpdateRouteDto } from './dto/update-route.dto';
@@ -9,6 +10,7 @@ import { RoutesService } from './routes.service';
@ApiTags('routes') @ApiTags('routes')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('routes') @Controller('routes')
@FleetView()
export class RoutesController { export class RoutesController {
constructor(private readonly routesService: RoutesService) {} constructor(private readonly routesService: RoutesService) {}
@@ -25,18 +27,21 @@ export class RoutesController {
} }
@Post() @Post()
@FleetManage()
@ApiOperation({ summary: 'Create route' }) @ApiOperation({ summary: 'Create route' })
create(@Body() dto: CreateRouteDto) { create(@Body() dto: CreateRouteDto) {
return this.routesService.create(dto); return this.routesService.create(dto);
} }
@Patch(':id') @Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update route' }) @ApiOperation({ summary: 'Update route' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
return this.routesService.update(id, dto); return this.routesService.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Deactivate route' }) @ApiOperation({ summary: 'Deactivate route' })
remove(@Param('id', ParseUUIDPipe) id: string) { remove(@Param('id', ParseUUIDPipe) id: string) {
return this.routesService.deactivate(id); return this.routesService.deactivate(id);

View File

@@ -1,7 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { DataSource } from 'typeorm';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { FileRecord } from '../files/entities/file.entity';
import { MinioService } from '../minio/minio.service'; import { MinioService } from '../minio/minio.service';
import { SignaturesRepository } from './signatures.repository'; import { SignaturesRepository } from './signatures.repository';
import { SavedSignature } from './entities/saved-signature.entity'; import { SavedSignature } from './entities/saved-signature.entity';
@@ -19,6 +21,7 @@ export class SignaturesService {
private readonly signaturesRepository: SignaturesRepository, private readonly signaturesRepository: SignaturesRepository,
private readonly filesService: FilesService, private readonly filesService: FilesService,
private readonly minioService: MinioService, private readonly minioService: MinioService,
private readonly dataSource: DataSource,
) {} ) {}
/** Saved signature for a user, with the image inlined as a data URL (or null). */ /** Saved signature for a user, with the image inlined as a data URL (or null). */
@@ -47,18 +50,32 @@ export class SignaturesService {
path: '', 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, resourceId: input.userId,
resource: 'saved_signatures', resource: 'saved_signatures',
code: 'signature', code: 'signature',
file, file,
}); });
return this.signaturesRepository.upsert({ const saved = await this.signaturesRepository.upsert({
userId: input.userId, userId: input.userId,
signerDisplayName: input.signerDisplayName, signerDisplayName: input.signerDisplayName,
signatureFileId: fileRecord.id, signatureFileId: fileRecord.id,
}); });
if (previousFileId && previousFileId !== fileRecord.id) {
await this.dataSource
.getRepository(FileRecord)
.delete({ id: previousFileId });
}
return saved;
} }
private async inlineImageUrl( private async inlineImageUrl(

View File

@@ -96,7 +96,8 @@ export class TrainSchedulingController {
} }
@Get("bookable-schedules") @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({ @ApiOperation({
summary: "OPEN same-route schedules a new booking can target", summary: "OPEN same-route schedules a new booking can target",
}) })

View File

@@ -11,16 +11,19 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FleetManage, FleetView } from "../../common/booking-guards";
import { CreateTrainDto } from "./dto/create-train.dto"; import { CreateTrainDto } from "./dto/create-train.dto";
import { UpdateTrainDto } from "./dto/update-train.dto"; import { UpdateTrainDto } from "./dto/update-train.dto";
import { TrainsService } from "./trains.service"; import { TrainsService } from "./trains.service";
@ApiTags("trains") @ApiTags("trains")
@Controller("trains") @Controller("trains")
@FleetView()
export class TrainsController { export class TrainsController {
constructor(private readonly trainsService: TrainsService) {} constructor(private readonly trainsService: TrainsService) {}
@Post() @Post()
@FleetManage()
@ApiOperation({ summary: "Register a new train" }) @ApiOperation({ summary: "Register a new train" })
create(@Body() dto: CreateTrainDto) { create(@Body() dto: CreateTrainDto) {
return this.trainsService.create(dto); return this.trainsService.create(dto);
@@ -39,12 +42,14 @@ export class TrainsController {
} }
@Patch(":id") @Patch(":id")
@FleetManage()
@ApiOperation({ summary: "Update a train" }) @ApiOperation({ summary: "Update a train" })
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) { update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) {
return this.trainsService.update(id, dto); return this.trainsService.update(id, dto);
} }
@Delete(":id") @Delete(":id")
@FleetManage()
@ApiOperation({ summary: "Delete a train" }) @ApiOperation({ summary: "Delete a train" })
remove(@Param("id", ParseUUIDPipe) id: string) { remove(@Param("id", ParseUUIDPipe) id: string) {
return this.trainsService.remove(id); return this.trainsService.remove(id);

View File

@@ -10,6 +10,7 @@ import {
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateWagonDto } from './dto/create-wagon.dto'; import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto';
@@ -19,10 +20,12 @@ import { WagonsService } from './wagons.service';
@ApiTags('wagons') @ApiTags('wagons')
@Controller('wagons') @Controller('wagons')
@FleetView()
export class WagonsController { export class WagonsController {
constructor(private readonly wagonsService: WagonsService) {} constructor(private readonly wagonsService: WagonsService) {}
@Post() @Post()
@FleetManage()
@ApiOperation({ summary: 'Create a new wagon' }) @ApiOperation({ summary: 'Create a new wagon' })
create(@Body() dto: CreateWagonDto) { create(@Body() dto: CreateWagonDto) {
return this.wagonsService.create(dto); return this.wagonsService.create(dto);
@@ -41,24 +44,28 @@ export class WagonsController {
} }
@Patch(':id') @Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a wagon' }) @ApiOperation({ summary: 'Update a wagon' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
return this.wagonsService.update(id, dto); return this.wagonsService.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Delete a wagon' }) @ApiOperation({ summary: 'Delete a wagon' })
remove(@Param('id', ParseUUIDPipe) id: string) { remove(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.remove(id); return this.wagonsService.remove(id);
} }
@Post(':id/assign-train') @Post(':id/assign-train')
@FleetManage()
@ApiOperation({ summary: 'Assign wagon to a train' }) @ApiOperation({ summary: 'Assign wagon to a train' })
assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) { assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
return this.wagonsService.assignToTrain(id, dto); return this.wagonsService.assignToTrain(id, dto);
} }
@Post(':id/unassign-train') @Post(':id/unassign-train')
@FleetManage()
@ApiOperation({ summary: 'Unassign wagon from train' }) @ApiOperation({ summary: 'Unassign wagon from train' })
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) { unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.unassignFromTrain(id); return this.wagonsService.unassignFromTrain(id);
@@ -67,10 +74,12 @@ export class WagonsController {
// Separate controller for trainspecific reorder (registered in module) // Separate controller for trainspecific reorder (registered in module)
@Controller('trains/:trainId/reorder-wagons') @Controller('trains/:trainId/reorder-wagons')
@FleetView()
export class TrainWagonsReorderController { export class TrainWagonsReorderController {
constructor(private readonly wagonsService: WagonsService) {} constructor(private readonly wagonsService: WagonsService) {}
@Post() @Post()
@FleetManage()
@ApiOperation({ summary: 'Reorder wagons of a train' }) @ApiOperation({ summary: 'Reorder wagons of a train' })
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) { reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
return this.wagonsService.reorderWagons(trainId, dto); return this.wagonsService.reorderWagons(trainId, dto);

View File

@@ -0,0 +1,28 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(__dirname, '../../.env') });
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { DemoFreightDataSeeder } from '../seed/demo-freight-data.seeder';
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'],
});
try {
const seeder = app.get(DemoFreightDataSeeder);
await seeder.run();
console.log('Freight demo data seeded (wagons, approval rules, staff users).');
} finally {
await app.close();
}
}
main().catch((err) => {
console.error('Freight demo seed failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,180 @@
import { Injectable, Logger } from '@nestjs/common';
import { WagonStatus } from '@edr/types';
import { hashPassword } from '@tria-plc/api-common/utils/argon';
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
import {
Employee,
Organization,
Role,
User,
UserCredential,
UserRole,
} from '@tria-plc/iamapi-common';
import { DataSource, EntityManager } from 'typeorm';
import { Wagon } from '../modules/wagons/entities/wagon.entity';
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
import { ApprovalRule } from '../modules/rule-engine/entities/approval-rule.entity';
import { DEFAULT_APPROVAL_RULE_ROWS } from '../modules/rule-engine/approval-rules.defaults';
const EDR_ORG_KEY = 'edr_freight';
const MIN_WAGONS_PER_TYPE = 100;
/** The four demo staff users, each mapped to a seeded freight role. */
const DEMO_STAFF_USERS = [
{ email: 'marketing@edr.local', username: 'marketing', roleKey: 'edr_marketing' },
{ email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' },
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
] as const;
/**
* One-shot demo data: at least 100 wagons per wagon type, the default approval
* chains, and four staff users with distinct permissions. Every block guards on
* an "is it already populated?" check, so this is safe to run on every boot and
* does nothing once the data exists.
*/
@Injectable()
export class DemoFreightDataSeeder {
private readonly logger = new Logger(DemoFreightDataSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
await this.dataSource.transaction(async (manager) => {
await this.seedWagons(manager);
await this.seedApprovalRules(manager);
await this.seedStaffUsers(manager);
});
}
/** Ensure every wagon type has at least MIN_WAGONS_PER_TYPE wagons. */
private async seedWagons(manager: EntityManager) {
const wagonTypeRepo = manager.getRepository(WagonType);
const wagonRepo = manager.getRepository(Wagon);
const wagonTypes = await wagonTypeRepo.find();
if (wagonTypes.length === 0) {
this.logger.warn('No wagon types found; skipping wagon seed');
return;
}
for (const type of wagonTypes) {
const existing = await wagonRepo.count({ where: { wagonTypeId: type.id } });
if (existing >= MIN_WAGONS_PER_TYPE) {
this.logger.log(
`Wagon type ${type.code} already has ${existing} wagons; skipping`,
);
continue;
}
const toCreate = MIN_WAGONS_PER_TYPE - existing;
const tare = Number(type.tareWeightTons ?? 20);
const maxPayload = Number(type.capacityTons ?? 60);
const rows = Array.from({ length: toCreate }, (_, i) => {
const seq = existing + i + 1;
return wagonRepo.create({
wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`,
wagonTypeId: type.id,
tareWeight: tare,
maxPayloadWeight: maxPayload,
status: WagonStatus.Available,
});
});
await wagonRepo.save(rows);
this.logger.log(`Seeded ${toCreate} wagons for type ${type.code}`);
}
}
/** Seed the default approval chains when the table is empty. */
private async seedApprovalRules(manager: EntityManager) {
const repo = manager.getRepository(ApprovalRule);
const count = await repo.count();
if (count > 0) {
this.logger.log(`Approval rules already populated (${count}); skipping`);
return;
}
await repo.save(DEFAULT_APPROVAL_RULE_ROWS.map((row) => repo.create(row)));
this.logger.log(`Seeded ${DEFAULT_APPROVAL_RULE_ROWS.length} approval rules`);
}
/** Create the four demo staff users with their roles (idempotent per email). */
private async seedStaffUsers(manager: EntityManager) {
const organization = await manager.getRepository(Organization).findOne({
where: { key: EDR_ORG_KEY },
select: { id: true, key: true },
});
if (!organization) {
this.logger.warn(`Missing organization ${EDR_ORG_KEY}; skipping staff users`);
return;
}
const roleRepo = manager.getRepository(Role);
const userRepo = manager.getRepository(User);
const credentialRepo = manager.getRepository(UserCredential);
const userRoleRepo = manager.getRepository(UserRole);
const employeeRepo = manager.getRepository(Employee);
const password = process.env.DEFAULT_PASSWORD?.trim() || '12345678';
const hashedPassword = await hashPassword(password);
for (const staff of DEMO_STAFF_USERS) {
const role = await roleRepo.findOne({
where: { key: staff.roleKey },
select: { id: true, key: true },
});
if (!role) {
this.logger.warn(`Missing role ${staff.roleKey}; skipping ${staff.email}`);
continue;
}
let user = await userRepo.findOne({
where: { email: staff.email },
select: { id: true, email: true },
});
if (!user) {
user = await userRepo.save(
userRepo.create({
email: staff.email,
username: staff.username,
name: { en: staff.username },
isActive: true,
hasSetPassword: true,
status: EUserStatus.ACCEPTED,
}),
);
this.logger.log(`Seeded staff user ${staff.email}`);
}
const hasCredential = await credentialRepo.exists({
where: { userId: user.id, isActive: true },
});
if (!hasCredential) {
await credentialRepo.insert({
userId: user.id,
password: hashedPassword,
isActive: true,
});
}
await userRoleRepo.upsert(
{ userId: user.id, roleId: role.id, organizationId: organization.id },
{ conflictPaths: { userId: true, roleId: true } },
);
const hasEmployee = await employeeRepo.exists({
where: { userId: user.id, organizationId: organization.id, isCurrent: true },
});
if (!hasEmployee) {
await employeeRepo.insert({
userId: user.id,
organizationId: organization.id,
isCurrent: true,
name: { en: staff.username },
});
}
}
this.logger.log('Ensured demo staff users (marketing@, operations@, director@, ceo@)');
}
}

View File

@@ -212,6 +212,11 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
name: { en: "EDR Line Staff" }, name: { en: "EDR Line Staff" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff], permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff],
}, },
{
key: "edr_operations_officer",
name: { en: "EDR Operations Officer" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsOfficer],
},
{ {
key: "edr_director", key: "edr_director",
name: { en: "EDR Director" }, name: { en: "EDR Director" },

View File

@@ -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-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-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-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
]; ];
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = { const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
@@ -109,6 +112,11 @@ export const FREIGHT_PERMS = {
view: 'edr_freight_app:train_scheduling:view', view: 'edr_freight_app:train_scheduling:view',
manage: 'edr_freight_app:train_scheduling:manage', 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: { ruleEngine: {
view: (slug: RuleEngineResourceSlug) => view: (slug: RuleEngineResourceSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, `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)); RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
export const ROLE_PERMISSION_PRESETS = { 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: [ lineStaff: [
FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.staffAccept, FREIGHT_PERMS.bookings.staffAccept,
@@ -129,8 +140,17 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel, 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.view,
FREIGHT_PERMS.trainScheduling.manage, FREIGHT_PERMS.trainScheduling.manage,
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
...allRuleEngineViewKeys(), ...allRuleEngineViewKeys(),
], ],
director: [ director: [
@@ -147,8 +167,15 @@ export const ROLE_PERMISSION_PRESETS = {
...allRuleEngineViewKeys(), ...allRuleEngineViewKeys(),
], ],
finance: [FREIGHT_PERMS.bookings.view], finance: [FREIGHT_PERMS.bookings.view],
// Marketing handles intake through contract (same as line staff here).
marketing: [ marketing: [
FREIGHT_PERMS.bookings.view, 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.generateContract,
FREIGHT_PERMS.bookings.signStaff, FREIGHT_PERMS.bookings.signStaff,
], ],

View File

@@ -13,6 +13,7 @@ import {
Container, Container,
Package, Package,
Users, Users,
Wallet,
//TrainTrack, //TrainTrack,
} from "lucide-react"; } from "lucide-react";
@@ -23,6 +24,7 @@ import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import PaymentsPage from "./pages/payments/PaymentsPage";
import NewBookingPage from "./pages/bookings/NewBookingPage"; import NewBookingPage from "./pages/bookings/NewBookingPage";
import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
@@ -47,6 +49,8 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; 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 TrainDetailPage from "./pages/trains/TrainDetailPage";
import RoutesPage from "./pages/fleet/RoutesPage"; import RoutesPage from "./pages/fleet/RoutesPage";
@@ -70,6 +74,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/booking-requests", href: "/dashboard/booking-requests",
icon: <FileText />, icon: <FileText />,
}, },
{
label: "Payments",
href: "/dashboard/payments",
icon: <Wallet />,
permission: FREIGHT_PERMS.bookings.view,
},
...demoItems, ...demoItems,
], ],
}, },
@@ -80,11 +90,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Train Schedules", label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2", href: "/dashboard/operations/train-scheduling-v2",
icon: <Train />, icon: <Train />,
permission: FREIGHT_PERMS.trainScheduling.view,
}, },
{ {
label: "Batch Board", label: "Batch Board",
href: "/dashboard/operations/batch-board", href: "/dashboard/operations/batch-board",
icon: <LayoutGrid />, icon: <LayoutGrid />,
permission: FREIGHT_PERMS.trainScheduling.view,
}, },
], ],
}, },
@@ -95,11 +107,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Routes", label: "Routes",
href: "/dashboard/routes", href: "/dashboard/routes",
icon: <Network />, icon: <Network />,
permission: FREIGHT_PERMS.fleet.view,
}, },
{ {
label: "Locomotives", label: "Locomotives",
href: "/dashboard/locomotives", href: "/dashboard/locomotives",
icon: <Train />, icon: <Train />,
permission: FREIGHT_PERMS.fleet.view,
}, },
// { // {
// label: "Trains", // label: "Trains",
@@ -115,6 +129,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Wagons", label: "Wagons",
href: "/dashboard/wagons", href: "/dashboard/wagons",
icon: <Truck />, icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
}, },
// { // {
// label: "Containers", // label: "Containers",
@@ -135,6 +150,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "User management", label: "User management",
href: "/dashboard/user-management", href: "/dashboard/user-management",
icon: <Network />, icon: <Network />,
permission: FREIGHT_PERMS.admin,
children: [ children: [
{ {
label: "Users", label: "Users",
@@ -162,11 +178,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "File settings", label: "File settings",
href: "/dashboard/file-settings", href: "/dashboard/file-settings",
icon: <Paperclip />, icon: <Paperclip />,
permission: FREIGHT_PERMS.admin,
}, },
{ {
label: "Dropdown settings", label: "Dropdown settings",
href: "/dashboard/dropdown-settings", href: "/dashboard/dropdown-settings",
icon: <Settings />, icon: <Settings />,
permission: FREIGHT_PERMS.admin,
}, },
], ],
}, },
@@ -196,18 +214,25 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
}, },
]; ];
const hasPermission = ( /** Keep only items the user is permitted to see; drop now-empty sections. */
const filterSidebarByPermission = (
sections: SidebarSection[],
user: ReturnType<typeof useAuth>["user"], user: ReturnType<typeof useAuth>["user"],
key: string, ): SidebarSection[] => {
) => { const itemAllowed = (item: SidebarItem): boolean => {
if (!user) return false; if (!item.permission) return true;
if (user.permissions?.some((p) => p.key === key)) return true; const keys = Array.isArray(item.permission)
? item.permission
: [item.permission];
return keys.some((key) => hasFreightPermission(user, key));
};
return (user.employee ?? []).some((emp) => return sections
(emp.positions ?? []).some((pos) => .map((section) => ({
(pos.permissions ?? []).some((p) => p.key === key), ...section,
), items: section.items.filter(itemAllowed),
); }))
.filter((section) => section.items.length > 0);
}; };
const DashboardShell = () => { const DashboardShell = () => {
@@ -217,7 +242,10 @@ const DashboardShell = () => {
const demoItems: SidebarItem[] = []; const demoItems: SidebarItem[] = [];
const sidebarSections = buildSidebarSections(demoItems); const sidebarSections = filterSidebarByPermission(
buildSidebarSections(demoItems),
user,
);
const displayName = user?.name?.en || user?.username || user?.email || "User"; const displayName = user?.name?.en || user?.username || user?.email || "User";
return ( return (
@@ -261,6 +289,14 @@ const App = () => {
<Route path="profile" element={<MyProfilePage />} /> <Route path="profile" element={<MyProfilePage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} /> <Route path="booking-requests" element={<BookingRequestsPage />} />
<Route
path="payments"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<PaymentsPage />
</RequirePermission>
}
/>
<Route path="booking-requests/new" element={<NewBookingPage />} /> <Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} /> <Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route <Route
@@ -271,30 +307,102 @@ const App = () => {
path="operations/train-scheduling" path="operations/train-scheduling"
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />} element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
/> />
<Route path="operations/batch-board" element={<BatchBoardPage />} /> <Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route <Route
path="operations/batch-board/:scheduleId" path="operations/batch-board/:scheduleId"
element={<BatchScheduleDetailPage />} element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/> />
<Route <Route
path="operations/train-scheduling-v2" path="operations/train-scheduling-v2"
element={<TrainScheduleV2ListPage />} element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/> />
<Route <Route
path="operations/train-scheduling-v2/:scheduleId" path="operations/train-scheduling-v2/:scheduleId"
element={<TrainScheduleV2DetailPage />} element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/> />
<Route <Route
path="operations/train-scheduling-v2/:scheduleId/track" path="operations/train-scheduling-v2/:scheduleId/track"
element={<TrainScheduleTrackPage />} element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RoutesPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/> />
<Route path="routes" element={<RoutesPage />} />
<Route path="locomotives" element={<FleetResourcePage />} />
<Route path="trains" element={<FleetResourcePage />} />
<Route path="trains/:id" element={<TrainDetailPage />} />
<Route path="wagons" element={<FleetResourcePage />} />
<Route path="containers" element={<FleetResourcePage />} />
<Route path="cargoes" element={<FleetResourcePage />} />
{/* iframe-based user management module */} {/* iframe-based user management module */}
<Route path="um/*" element={<UserManagementHostPage />} /> <Route path="um/*" element={<UserManagementHostPage />} />
@@ -307,8 +415,22 @@ const App = () => {
<Route path="user-management/permissions" element={<PermissionsPage />} /> <Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} /> <Route path="user-management/roles" element={<RolesPage />} />
<Route path="file-settings" element={<FileUploadSettingsPage />} /> <Route
<Route path="dropdown-settings" element={<DropdownSettingsPage />} /> path="file-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<FileUploadSettingsPage />
</RequirePermission>
}
/>
<Route
path="dropdown-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<DropdownSettingsPage />
</RequirePermission>
}
/>
<Route <Route
path="configuration" path="configuration"
@@ -316,7 +438,11 @@ const App = () => {
/> />
<Route <Route
path="configuration/train-scheduling-rules" path="configuration/train-scheduling-rules"
element={<TrainSchedulingGlobalRulesPage />} element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainSchedulingGlobalRulesPage />
</RequirePermission>
}
/> />
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} /> <Route path="configuration/:resource" element={<RuleEngineResourcePage />} />

View File

@@ -1,5 +1,6 @@
import axios from "axios"; import axios from "axios";
import { API_BASE_URL } from "@/constants/apiConfig";
import { import {
AUTH_TOKEN_COOKIE, AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE,
@@ -16,7 +17,7 @@ type RetriableRequest = {
}; };
const api = axios.create({ const api = axios.create({
baseURL: `${import.meta.env.VITE_BASE_API_URL}/api`, baseURL: `${API_BASE_URL}/api`,
withCredentials: true, withCredentials: true,
}); });

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { hasPermission } from "@/lib/permissions";
interface RequirePermissionProps {
/** Permission key(s); access is granted if the user has ANY of them. */
permission: string | string[];
/** Where to send users who lack the permission. */
redirectTo?: string;
children: ReactNode;
}
/**
* Page-level guard: renders children only when the current user holds one of
* the given permissions, otherwise redirects (default: overview).
*/
export function RequirePermission({
permission,
redirectTo = "/dashboard/overview",
children,
}: RequirePermissionProps) {
const { user } = useAuth();
const keys = Array.isArray(permission) ? permission : [permission];
const allowed = keys.some((key) => hasPermission(user, key));
if (!allowed) return <Navigate to={redirectTo} replace />;
return <>{children}</>;
}

View File

@@ -8,6 +8,8 @@ import { BookingActionsMenu } from "./BookingActionsMenu";
import { SectionCard } from "./detail/SectionCard"; import { SectionCard } from "./detail/SectionCard";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { canAllocateBooking } from "@/features/bookings/booking-actions.config"; 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"; import type { useBookingMutations } from "@/hooks/bookings/useBookings";
type Mutations = ReturnType<typeof useBookingMutations>; type Mutations = ReturnType<typeof useBookingMutations>;
@@ -19,9 +21,11 @@ interface BookingActionsToolbarProps {
/** Detail-page actions: primary toolbar + downloads. */ /** Detail-page actions: primary toolbar + downloads. */
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) { export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
const { user } = useAuth();
const row = toBookingListRow(booking); const row = toBookingListRow(booking);
const { status } = booking; const { status } = booking;
const [allocateOpen, setAllocateOpen] = useState(false); const [allocateOpen, setAllocateOpen] = useState(false);
const canAllocate = canManageScheduling(user);
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => { const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn(); const blob = await fn();
@@ -127,7 +131,7 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
</SectionCard> </SectionCard>
)} )}
{canAllocateBooking(booking) ? ( {canAllocate && canAllocateBooking(booking) ? (
<AllocateBookingWizard <AllocateBookingWizard
booking={booking} booking={booking}
opened={allocateOpen} opened={allocateOpen}

View File

@@ -0,0 +1,102 @@
import type { LucideIcon } from "lucide-react";
import {
Building2,
FileCheck,
Mail,
MapPin,
Phone,
User,
} from "lucide-react";
import { Group, Stack, Text, Divider } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
interface InfoRowProps {
icon: LucideIcon;
label: string;
value?: string | null;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{value || "—"}
</Text>
</Group>
);
}
export interface BookingCompanyCardProps {
booking: BookingDetail;
}
/** Customer (company) information for the booking. */
export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
const company = booking.company;
// Government bookings may not carry a company; show the institution instead.
if (!company && booking.isGovernment) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<InfoRow
icon={Building2}
label="Government"
value={booking.governmentInstitution}
/>
</SectionCard>
);
}
if (!company) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<Text size="sm" c="dimmed">
No customer linked to this booking.
</Text>
</SectionCard>
);
}
const companyName = company.companyName ?? company.name ?? company.label;
const rows: InfoRowProps[] = [
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact person", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
].filter((r) => r.value);
return (
<SectionCard
icon={Building2}
title="Customer"
subtitle={companyName}
accent="blue"
>
<Stack gap={0}>
{rows.length === 0 ? (
<Text size="sm" c="dimmed">
No additional company details available.
</Text>
) : (
rows.map((row, index) => (
<div key={row.label}>
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<InfoRow {...row} />
</div>
))
)}
</Stack>
</SectionCard>
);
}

View File

@@ -122,6 +122,7 @@ export interface BookingFileView {
id: string; id: string;
name: string; name: string;
mimeType?: string; mimeType?: string;
code?: string;
} }
export interface BookingDetailView { export interface BookingDetailView {

View File

@@ -17,3 +17,4 @@ export * from "./BookingRouteServiceCard";
export * from "./BookingMileServicesCard"; export * from "./BookingMileServicesCard";
export * from "./BookingCargoCard"; export * from "./BookingCargoCard";
export * from "./BookingContractSummaryCard"; export * from "./BookingContractSummaryCard";
export * from "./BookingCompanyCard";

View File

@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { API_BASE_URL } from '@/constants/apiConfig';
interface Cargo { interface Cargo {
id: string; id: string;
@@ -32,8 +33,6 @@ interface CargoFormDialogProps {
onSuccess?: () => void; onSuccess?: () => void;
} }
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function CargoFormDialog({ export default function CargoFormDialog({
open, open,
onOpenChange, onOpenChange,

View File

@@ -43,6 +43,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage your account and signature", subtitle: "Manage your account and signature",
}, },
}, },
{
prefix: "/dashboard/payments",
meta: {
title: "Payments",
subtitle: "View booking payment transactions",
},
},
{ {
prefix: "/dashboard/operations/train-scheduling-v2/", prefix: "/dashboard/operations/train-scheduling-v2/",
meta: { meta: {

View File

@@ -6,6 +6,8 @@ export interface SidebarItem {
href?: string; href?: string;
icon?: ReactNode; icon?: ReactNode;
children?: SidebarItem[]; children?: SidebarItem[];
/** Permission key(s) required to see this item; ANY grants access. */
permission?: string | string[];
} }
export interface SidebarSection { export interface SidebarSection {

View File

@@ -124,6 +124,11 @@ export const URL_CONSTANTS = {
VERIFY: "/api/otp/verify", VERIFY: "/api/otp/verify",
}, },
PAYMENTS: {
ALL: "/payments/all",
SUMMARY: "/payments/summary",
},
LOCOMOTIVES: { LOCOMOTIVES: {
BASE: "/locomotives", BASE: "/locomotives",
BY_ID: (id: string) => `/locomotives/${id}`, BY_ID: (id: string) => `/locomotives/${id}`,

View File

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

View File

@@ -201,6 +201,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
signContractStaff: FREIGHT_PERMS.bookings.signStaff, signContractStaff: FREIGHT_PERMS.bookings.signStaff,
startTransit: FREIGHT_PERMS.bookings.operations, startTransit: FREIGHT_PERMS.bookings.operations,
complete: FREIGHT_PERMS.bookings.operations, complete: FREIGHT_PERMS.bookings.operations,
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
cancel: FREIGHT_PERMS.bookings.cancel, cancel: FREIGHT_PERMS.bookings.cancel,
}; };

View File

@@ -0,0 +1,23 @@
import { useQuery } from "@tanstack/react-query";
import {
paymentsService,
type PaymentListFilter,
} from "@/services/payments.service";
export function usePaymentList(filter?: PaymentListFilter, enabled = true) {
return useQuery({
queryKey: ["payments", "list", filter ?? {}],
queryFn: () => paymentsService.list(filter),
enabled,
});
}
export function usePaymentSummary(enabled = true) {
return useQuery({
queryKey: ["payments", "summary"],
queryFn: () => paymentsService.getSummary(),
staleTime: 30_000,
enabled,
});
}

View File

@@ -16,6 +16,15 @@ export const FREIGHT_PERMS = {
operations: "edr_freight_app:bookings:operations", operations: "edr_freight_app:bookings:operations",
cancel: "edr_freight_app:bookings:cancel", 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; } as const;
const slugToResourceKey = (slug: RuleEngineResourceSlug): string => const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
@@ -70,6 +79,22 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view); 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 { export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`; return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
} }

View File

@@ -50,11 +50,8 @@ export default function BookingContractPage() {
enabled: Boolean(id), enabled: Boolean(id),
}); });
const signRole: "CUSTOMER" | "STAFF" | null = data?.canSignCustomer // Backoffice only ever signs as STAFF — customers sign in the portal.
? "CUSTOMER" const canSign = Boolean(data?.canSignStaff);
: data?.canSignStaff
? "STAFF"
: null;
const savedSignature = data?.savedSignature ?? null; const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null; const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
@@ -106,12 +103,12 @@ export default function BookingContractPage() {
}; };
const confirmSign = () => { const confirmSign = () => {
if (!signRole || !signerName.trim()) return; if (!canSign || !signerName.trim()) return;
// Approve the saved signature, or submit the freshly drawn one. // Approve the saved signature, or submit the freshly drawn one.
const image = usingSaved ? savedSignatureImage : signatureData; const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return; if (!image) return;
signMutation.mutate({ signMutation.mutate({
role: signRole, role: "STAFF",
signatureImageBase64: image, signatureImageBase64: image,
signerDisplayName: signerName.trim(), signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.", consentText: "I agree to the terms of this contract.",
@@ -167,10 +164,10 @@ export default function BookingContractPage() {
<Download className="size-4" /> <Download className="size-4" />
Download PDF Download PDF
</Button> </Button>
{signRole && ( {canSign && (
<Button size="sm" className="gap-2" onClick={openSign}> <Button size="sm" className="gap-2" onClick={openSign}>
<FileSignature className="size-4" /> <FileSignature className="size-4" />
Sign as {signRole === "CUSTOMER" ? "Customer" : "Staff"} {usingSaved ? "Approve & sign" : "Sign contract"}
</Button> </Button>
)} )}
</div> </div>
@@ -194,9 +191,7 @@ export default function BookingContractPage() {
<Dialog open={signOpen} onOpenChange={setSignOpen}> <Dialog open={signOpen} onOpenChange={setSignOpen}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>Staff signature</DialogTitle>
{signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"}
</DialogTitle>
<DialogDescription> <DialogDescription>
{usingSaved {usingSaved
? `Review your saved signature and approve it to execute the contract for ${data.reference}.` ? `Review your saved signature and approve it to execute the contract for ${data.reference}.`

View File

@@ -24,11 +24,25 @@ import {
BookingRouteServiceCard, BookingRouteServiceCard,
BookingMileServicesCard, BookingMileServicesCard,
BookingCargoCard, BookingCargoCard,
BookingCompanyCard,
BookingContractSummaryCard, BookingContractSummaryCard,
BookingDocumentsCard,
type BookingFileView,
} from "@/components/bookings/detail"; } from "@/components/bookings/detail";
import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { downloadBookingFile } from "@/services/files.service";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings"; 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() { export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -36,6 +50,14 @@ export default function BookingRequestDetailPage() {
const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id); const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
const mutations = useBookingMutations(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) { if (isLoading) {
return ( return (
<Box style={detailStyles.page}> <Box style={detailStyles.page}>
@@ -147,6 +169,12 @@ export default function BookingRequestDetailPage() {
{booking.contractSummary && ( {booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} /> <BookingContractSummaryCard summary={booking.contractSummary} />
)} )}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
)}
onDownload={handleDownloadFile}
/>
</Stack> </Stack>
</Grid.Col> </Grid.Col>
@@ -154,6 +182,7 @@ export default function BookingRequestDetailPage() {
<Grid.Col span={{ base: 12, lg: 4 }}> <Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}> <Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg"> <Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingPricingSummary booking={booking} /> <BookingPricingSummary booking={booking} />
<BookingActionsToolbar booking={booking} mutations={mutations} /> <BookingActionsToolbar booking={booking} mutations={mutations} />
{showContractButton && ( {showContractButton && (

View File

@@ -0,0 +1,367 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Box,
Card,
Container,
Group,
Paper,
Select,
Stack,
Tabs,
Text,
TextInput,
} from "@mantine/core";
import {
CheckCircle2,
CircleDollarSign,
Loader2,
RotateCcw,
Search,
X,
XCircle,
type LucideIcon,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
import type {
PaymentMethod,
PaymentRow,
} from "@/services/payments.service";
import { cn } from "@/lib/utils";
import {
Badge,
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
} from "@edr/ui-common";
const STATUS_TABS = [
{ key: "all", label: "All", statuses: undefined as string | undefined },
{ key: "success", label: "Success", statuses: "success" },
{ key: "processing", label: "Processing", statuses: "processing,action-required" },
{ key: "failed", label: "Failed", statuses: "failed,canceled" },
{ key: "refunded", label: "Refunded", statuses: "refunded" },
] as const;
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
{ value: "telebirr", label: "Telebirr" },
{ value: "waafi", label: "Waafi" },
{ value: "cbe-birr", label: "CBE Birr" },
{ value: "ebirr", label: "E-Birr" },
{ value: "card", label: "Card" },
{ value: "dmoney", label: "D-Money" },
{ value: "cac-bank", label: "CAC Bank" },
];
const STATUS_COLORS: Record<string, string> = {
success: "green",
processing: "yellow",
"action-required": "yellow",
failed: "red",
canceled: "gray",
refunded: "indigo",
};
function StatCard({
icon: Icon,
label,
value,
accent,
}: {
icon: LucideIcon;
label: string;
value: string | number;
accent: string;
}) {
return (
<Paper
p="md"
radius="lg"
style={{
flex: "1 1 180px",
minWidth: 160,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap="sm" wrap="nowrap" align="center">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 11,
background: `var(--mantine-color-${accent}-1)`,
color: `var(--mantine-color-${accent}-7)`,
flexShrink: 0,
}}
>
<Icon size={20} strokeWidth={2} />
</Box>
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
{value}
</Text>
<Text size="xs" fw={600} c="dimmed" truncate>
{label}
</Text>
</Stack>
</Group>
</Paper>
);
}
function formatAmount(amount: number, currency: string): string {
return `${currency} ${Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
})}`;
}
function formatDate(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
const tableHeader = "text-xs font-semibold uppercase tracking-wide text-muted-foreground";
export default function PaymentsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [statusTab, setStatusTab] = useState<StatusTabKey>("all");
const [method, setMethod] = useState<string | null>(null);
const statuses = STATUS_TABS.find((t) => t.key === statusTab)?.statuses;
const filter = useMemo(
() => ({
search: query.trim() || undefined,
status: statuses,
method: method ?? undefined,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
}),
[query, statuses, method, pagination.pageIndex, pagination.pageSize],
);
const { data, isLoading, isError } = usePaymentList(filter);
const { data: summary, isLoading: summaryLoading } = usePaymentSummary();
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
const columns: ColumnDef<PaymentRow>[] = [
{
id: "order",
header: () => <span className={tableHeader}>Order</span>,
cell: ({ row }) => (
<div className="min-w-0 py-1">
<p className="truncate font-medium text-foreground">
{row.original.merchantOrderId ?? row.original.id.slice(0, 8)}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
Booking {row.original.bookingId?.slice(0, 8) ?? "—"}
</p>
</div>
),
},
{
id: "amount",
header: () => <span className={tableHeader}>Amount</span>,
cell: ({ row }) => (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{formatAmount(row.original.amount, row.original.currency)}
</span>
),
},
{
id: "method",
header: () => <span className={tableHeader}>Method</span>,
cell: ({ row }) => (
<Badge variant="light" radius="sm">
{METHOD_OPTIONS.find((m) => m.value === row.original.method)?.label ??
row.original.method}
</Badge>
),
},
{
id: "status",
header: () => <span className={tableHeader}>Status</span>,
cell: ({ row }) => (
<Badge
color={STATUS_COLORS[row.original.status] ?? "gray"}
variant="light"
radius="sm"
tt="capitalize"
>
{row.original.status.replace(/-/g, " ")}
</Badge>
),
},
{
id: "date",
header: () => <span className={tableHeader}>Date</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatDate(row.original.paidAt ?? row.original.createdAt)}
</span>
),
},
];
return (
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
<Container size="xxl" py="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Payments" }]} />
<Stack gap="lg" mt="md">
<Group grow gap="md" align="stretch" wrap="wrap">
<StatCard
icon={CircleDollarSign}
label="Total collected"
value={
summaryLoading
? "—"
: `ETB ${Number(summary?.paidAmount ?? 0).toLocaleString()}`
}
accent="teal"
/>
<StatCard
icon={CheckCircle2}
label="Successful"
value={val(summary?.success)}
accent="green"
/>
<StatCard
icon={Loader2}
label="Processing"
value={val(summary?.processing)}
accent="yellow"
/>
<StatCard
icon={XCircle}
label="Failed"
value={val(summary?.failed)}
accent="red"
/>
<StatCard
icon={RotateCcw}
label="Refunded"
value={val(summary?.refunded)}
accent="indigo"
/>
</Group>
<Tabs
value={statusTab}
onChange={(value) => {
setStatusTab((value as StatusTabKey) ?? "all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
>
<Tabs.List>
{STATUS_TABS.map((t) => (
<Tabs.Tab key={t.key} value={t.key}>
{t.label}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
<Card
p="md"
radius="lg"
withBorder
style={{ background: "white", border: "1px solid var(--mantine-color-gray-2)" }}
>
<Stack gap="md">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search order, booking, or transaction…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
placeholder="All methods"
clearable
data={METHOD_OPTIONS}
value={method}
onChange={(value) => {
setMethod(value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
radius="lg"
style={{ minWidth: 180 }}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<div style={{ overflowX: "auto" }}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName={cn(
"border-0 shadow-none",
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
"[&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
)}
footer={DataTableFooter}
/>
</div>
</Stack>
</Card>
</Stack>
</Container>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { api as client } from "../auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
const F = URL_CONSTANTS.FILES;
export const filesService = {
/** Stream a stored file by id (backend route: GET /files/:id). */
download: async (id: string): Promise<Blob> => {
const response = await client.get(F.BY_ID(id), { responseType: "blob" });
return response.data as Blob;
},
};
/** Download a file blob and trigger a browser save with the given name. */
export async function downloadBookingFile(
id: string,
filename: string,
): Promise<void> {
const blob = await filesService.download(id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,84 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
const P = URL_CONSTANTS.PAYMENTS;
export type PaymentStatus =
| "action-required"
| "processing"
| "success"
| "failed"
| "canceled"
| "refunded";
export type PaymentMethod =
| "telebirr"
| "cbe-birr"
| "ebirr"
| "waafi"
| "card"
| "dmoney"
| "cac-bank";
export interface PaymentRow {
id: string;
bookingId: string;
amount: number;
currency: string;
method: PaymentMethod;
status: PaymentStatus;
merchantOrderId: string | null;
paidAt: string | null;
createdAt: string;
}
export interface PaymentListFilter {
search?: string;
status?: string;
method?: string;
page?: number;
pageSize?: number;
}
export interface PaginatedPayments {
items: PaymentRow[];
total: number;
page: number;
pageSize: number;
}
export interface PaymentSummary {
total: number;
success: number;
processing: number;
failed: number;
refunded: number;
paidAmount: number;
}
export const paymentsService = {
list: async (filter?: PaymentListFilter): Promise<PaginatedPayments> => {
const params: Record<string, string | number | undefined> = {};
if (filter) {
if (filter.search) params.search = filter.search;
if (filter.status) params.status = filter.status;
if (filter.method) params.method = filter.method;
if (filter.page != null) params.page = filter.page;
if (filter.pageSize != null) params.pageSize = filter.pageSize;
}
const response = await client.get<PaginatedPayments>(P.ALL, { params });
const data = unwrap(response.data) as PaginatedPayments;
return {
items: data.items ?? [],
total: data.total ?? 0,
page: data.page ?? 1,
pageSize: data.pageSize ?? 10,
};
},
getSummary: async (): Promise<PaymentSummary> => {
const response = await client.get<PaymentSummary>(P.SUMMARY);
return unwrap(response.data) as PaymentSummary;
},
};

View File

@@ -30,6 +30,27 @@ export interface BookingNamedRef {
companyName?: string; companyName?: string;
} }
/** Full company record the booking response joins in (subset used by the UI). */
export interface BookingCompany {
id: string;
name?: string;
type?: string;
status?: string;
tin?: string | null;
vatNumber?: string | null;
businessLicense?: string | null;
country?: string | null;
address?: string | null;
phone?: string | null;
email?: string | null;
contactPersonName?: string | null;
contactPersonPhone?: string | null;
generalManagerName?: string | null;
generalManagerEmail?: string | null;
generalManagerPhone?: string | null;
website?: string | null;
}
export interface BookingContainerLine { export interface BookingContainerLine {
id: string; id: string;
containerTypeId: string; containerTypeId: string;
@@ -81,6 +102,8 @@ export interface BookingFile {
name: string; name: string;
mimeType?: string; mimeType?: string;
code?: string; code?: string;
url?: string;
size?: number;
} }
export interface BookingDetail { export interface BookingDetail {
@@ -121,7 +144,7 @@ export interface BookingDetail {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
// customer?: BookingNamedRef & { companyName?: string }; // customer?: BookingNamedRef & { companyName?: string };
company?: BookingNamedRef; company?: BookingNamedRef & Partial<BookingCompany>;
originYard?: BookingNamedRef; originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef; destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number }; serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number };

View File

@@ -1,23 +1,36 @@
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { IncomingMessage, ServerResponse } from "node:http";
/// <reference types="vitest/config" /> import { defineConfig } from "vitest/config";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import type { ViteDevServer, PreviewServer } from "vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
function userManagementSpaFallback() { function userManagementSpaFallback() {
const rewrite = (req) => { const rewrite = (req: IncomingMessage) => {
const url = req.url || ''; const url = req.url ?? '';
if (!url.startsWith('/_um/') && url !== '/_um') return; if (!url.startsWith('/_um/') && url !== '/_um') return;
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return; if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return;
req.url = '/_um/index.html'; req.url = '/_um/index.html';
}; };
return { return {
name: 'user-management-spa-fallback', name: 'user-management-spa-fallback',
configureServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); }, configureServer(s: ViteDevServer) {
configurePreviewServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); }, s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
rewrite(req);
next();
});
},
configurePreviewServer(s: PreviewServer) {
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
rewrite(req);
next();
});
},
}; };
} }
@@ -29,6 +42,11 @@ export default defineConfig({
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS). // Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"), "@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
}, },
// Force a single copy of these singletons so MantineProvider context is
// shared between the backoffice app and @edr/ui-common (which ships its
// own node_modules copy). Without this, two separate @mantine/core
// instances are bundled and the context lookup fails at runtime.
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
}, },
server: { server: {
port: 5183, port: 5183,

View File

@@ -22,6 +22,7 @@ import useAuth from "./hooks/useAuth";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage"; import MyPortalPage from "./pages/MyPortalPage";
import ProfilePage from "./pages/ProfilePage"; import ProfilePage from "./pages/ProfilePage";
import MySignaturePage from "./pages/MySignaturePage";
import SettingsPage from "./pages/SettingsPage"; import SettingsPage from "./pages/SettingsPage";
import LoginPage from "./pages/accounts/LoginPage"; import LoginPage from "./pages/accounts/LoginPage";
import OnboardingPage from "./pages/accounts/OnboardingPage"; import OnboardingPage from "./pages/accounts/OnboardingPage";
@@ -201,6 +202,7 @@ const App = () => {
<Route path="/tracking" element={<TrackingPage />} /> <Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} /> <Route path="/billing" element={<BillingPage />} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/signature" element={<MySignaturePage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
</Route> </Route>
</Route> </Route>

View File

@@ -18,6 +18,7 @@ import { useDisclosure } from "@mantine/hooks";
import { import {
Bell, Bell,
ChevronDown, ChevronDown,
FileSignature,
LogOut, LogOut,
Menu as MenuIcon, Menu as MenuIcon,
Moon, Moon,
@@ -312,6 +313,12 @@ export function AppLayout({
> >
Profile Profile
</Menu.Item> </Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/signature")}
>
My signature
</Menu.Item>
<Menu.Item <Menu.Item
leftSection={<Settings size={15} />} leftSection={<Settings size={15} />}
onClick={() => navigate("/settings")} onClick={() => navigate("/settings")}

View File

@@ -0,0 +1,141 @@
import { useState } from "react";
import { FileSignature, Loader2 } from "lucide-react";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import useAuth from "@/hooks/useAuth";
import {
useMySignature,
useSaveSignature,
} from "@/hooks/useSavedSignature";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@edr/ui-common";
/**
* Lets the signed-in customer view and update the reusable signature stored on
* their profile. The same signature is offered for approval when signing a
* booking contract.
*/
export function MySignatureCard() {
const { user } = useAuth();
const { data: saved, isPending } = useMySignature();
const saveMutation = useSaveSignature();
const [open, setOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const defaultName = user?.name?.en || user?.username || user?.email || "";
const openDialog = () => {
setSignerName(saved?.signerDisplayName ?? defaultName);
setSignatureData(null);
setOpen(true);
};
const save = () => {
if (!signatureData || !signerName.trim()) return;
saveMutation.mutate(
{
signerDisplayName: signerName.trim(),
signatureImageBase64: signatureData,
},
{ onSuccess: () => setOpen(false) },
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileSignature className="size-5 text-primary" />
My signature
</CardTitle>
<CardDescription>
Reused to approve and sign booking contracts.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{isPending ? (
<div className="flex h-36 items-center justify-center">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : saved?.signatureImageUrl ? (
<div className="flex flex-col gap-2">
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.signatureImageUrl}
alt="My saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</CardContent>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save your signature</DialogTitle>
<DialogDescription>
Draw your signature below. It will be stored on your profile for
future contracts.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="profileSignerName">Full name</Label>
<Input
id="profileSignerName"
value={signerName}
onChange={(e) => setSignerName(e.target.value)}
placeholder="As shown on contracts"
/>
</div>
<ContractSignaturePad onChange={setSignatureData} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={
saveMutation.isPending || !signatureData || !signerName.trim()
}
onClick={save}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Save signature"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

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

View File

@@ -0,0 +1,30 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import {
signaturesService,
type SaveSignaturePayload,
} from "@/services/signatures.service";
const SAVED_SIGNATURE_KEY = ["me", "signature"] as const;
export function useMySignature() {
return useQuery({
queryKey: SAVED_SIGNATURE_KEY,
queryFn: () => signaturesService.getMySignature(),
staleTime: 60_000,
});
}
export function useSaveSignature() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: SaveSignaturePayload) =>
signaturesService.saveMySignature(payload),
onSuccess: () => {
toast.success("Signature saved");
void qc.invalidateQueries({ queryKey: SAVED_SIGNATURE_KEY });
},
onError: () => toast.error("Failed to save signature"),
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 (
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
<HelloSection greeting={greeting} companyName={companyName} />
<SetupPrompt show={!customer} />
<StatsSection
activeBookingsLength={activeBookings.length}
newActiveThisWeek={newActiveThisWeek}
bookingsLoading={bookingsQuery.isPending}
outstandingInvoicesLength={outstandingInvoices.length}
totalOutstanding={totalOutstanding}
deliveredCount={dashboard?.deliveredCount.toString()}
completionRate={dashboard?.completionRate?.toString()}
spendYtd={
dashboard
? formatCurrency(
dashboard.spendYtd,
dashboard.spendCurrency as Currency,
)
: undefined
}
spendYtdChangePct={dashboard?.spendYtdChangePct}
dashboardLoading={dashboardQuery.isPending}
/>
<Grid align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<ShipmentsSection
bookings={allBookings}
isLoading={bookingsQuery.isPending}
onBookingClick={handleBookingClick}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<InvoicesSection invoices={recentInvoices} />
</Grid.Col>
</Grid>
<Grid align="stretch">
<Grid.Col span={{ base: 12, md: 5 }}>
<FreightVolumeSection
totalTonnes={dashboard?.freightVolume.totalTonnes ?? 0}
totalValue={dashboard?.freightVolume.totalValue ?? 0}
currency={
(dashboard?.freightVolume.currency ?? "ETB") as Currency
}
ytdChangePct={dashboard?.freightVolume.ytdChangePct ?? 0}
volumePoints={volumePoints}
maxVolume={maxVolume}
isLoading={dashboardQuery.isPending}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 7 }}>
<RecentActivitySection
bookings={allBookings}
isLoading={bookingsQuery.isPending}
onBookingClick={handleBookingClick}
/>
</Grid.Col>
</Grid>
</Stack>
);
}

View File

@@ -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 (
<Group
gap={12}
align="center"
wrap="nowrap"
py={9}
className="cursor-pointer"
onClick={onClick}
>
<Box
w={36}
h={36}
bg={cfg.tile}
className="flex shrink-0 items-center justify-center rounded-[10px]"
>
<Icon size={17} color={cv(cfg.iconColor)} />
</Box>
<Box className="min-w-0 flex-1">
<Text fz={13} fw={600} c="edr-text" truncate>
Booking {booking.reference} {verb}
</Text>
<Text fz={11} c="edr-muted" truncate>
{booking.originYard?.label ?? booking.originYard?.code ?? "—"} {" "}
{booking.destinationYard?.label ??
booking.destinationYard?.code ??
"—"}
</Text>
</Box>
<Text fz={11} c="edr-muted" className="shrink-0">
{format(new Date(booking.createdAt), "MMM d")}
</Text>
</Group>
);
});

View File

@@ -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 (
<Box className={last ? undefined : "border-b border-edr-divider"}>
<Group
gap={16}
align="center"
wrap="nowrap"
py={14}
px={4}
className="cursor-pointer"
onClick={onClick}
>
<Box
w={46}
h={46}
bg={cfg.tile}
className="flex shrink-0 items-center justify-center rounded-xl"
>
<Icon size={22} color={cv(cfg.iconColor)} />
</Box>
<Box className="min-w-0 flex-1 lg:!flex-none lg:!w-[188px]">
<Text fz={15} fw={700} c="edr-text" truncate>
{booking.reference}
</Text>
<Text fz={12} c="edr-muted" truncate>
{commodity} · {origin} {dest}
</Text>
</Box>
<Box className="hidden min-w-0 flex-1 pr-2 lg:block">
<Text fz={12} fw={500} mb={8} c={cfg.iconColor} truncate>
{cfg.hint}
</Text>
<Stepper stage={cfg.stage} color={cfg.step} />
</Box>
<Stack gap={9} align="flex-end" className="shrink-0">
<Group
gap={6}
align="center"
px={11}
py={5}
bg={cfg.badgeBg}
className="rounded-full"
>
<Box w={6} h={6} bg={cfg.badgeDot} className="rounded-full" />
<Text fz={11} fw={700} c={cfg.badgeText}>
{cfg.badgeLabel}
</Text>
</Group>
<Group
gap={5}
align="center"
px={15}
py={8}
bg={ap.bg}
bd={ap.bd}
className="cursor-pointer rounded-[9px]"
>
<Text fz={13} fw={700} c={ap.c}>
{cfg.action.label}
</Text>
{AIcon && (
<AIcon
size={15}
color={ap.c === "white" ? "#fff" : cv("edr-text")}
/>
)}
</Group>
</Stack>
</Group>
</Box>
);
});

View File

@@ -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 (
<Box
p={padding}
className={`rounded-[20px] border border-edr-border bg-edr-card ${className}`}
>
{children}
</Box>
);
}

View File

@@ -0,0 +1,18 @@
import { Box, Text } from "@mantine/core";
interface EmptyStateProps {
message: string;
}
export function EmptyState({ message }: EmptyStateProps) {
return (
<Box
py="xl"
className="rounded-xl border border-dashed border-edr-border text-center"
>
<Text size="sm" c="edr-muted">
{message}
</Text>
</Box>
);
}

View File

@@ -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 (
<Card className="h-full" padding={24}>
<Text fz={17} fw={700} c="edr-text">
Freight Volume
</Text>
<Group gap={10} align="baseline" mt={4} mb={22}>
{isLoading ? (
<Skeleton height={32} width={180} radius="sm" />
) : (
<>
<Text fz={26} fw={800} c="edr-text">
{totalTonnes.toLocaleString()} t
</Text>
<Text fz={13} c="edr-muted">
{formatCurrency(totalValue, currency)}
</Text>
<Text fz={12} fw={700} c="edr-green.7">
{formatPct(ytdChangePct)} YTD
</Text>
</>
)}
</Group>
{isLoading ? (
<Skeleton height={110} radius="md" />
) : volumePoints.length === 0 ? (
<Box className="flex h-[110px] items-center">
<Text fz={13} c="edr-muted">
No freight volume yet.
</Text>
</Box>
) : (
<Group align="flex-end" gap={10} className="h-[110px]">
{volumePoints.map((point, i) => {
const isLast = i === volumePoints.length - 1;
return (
<Box
key={point.month}
className="flex flex-1 flex-col items-center gap-2"
>
<Box
bg={isLast ? "edr-green" : "edr-soft"}
bd={isLast ? undefined : "1px solid edr-border"}
h={Math.round((point.tonnes / maxVolume) * 86)}
className="w-full rounded-t-md"
/>
<Text fz={11} c="edr-muted">
{point.month}
</Text>
</Box>
);
})}
</Group>
)}
</Card>
);
});

View File

@@ -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 (
<Group justify="space-between" align="center" gap="md">
<Box>
<Text size="sm" c="edr-muted">
{greeting}
</Text>
<Text fz={26} fw={800} mt={2} c="edr-text" className="tracking-tight">
{companyName} 👋
</Text>
</Box>
<Link to="/bookings/new">
<Group
gap={14}
align="center"
wrap="nowrap"
bg="edr-green"
px={18}
py={14}
className="w-full md:w-60! rounded-2xl no-underline shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]"
>
<Truck size={22} color="#fff" />
<Box className="min-w-0 flex-1">
<Text fz={14} fw={700} c="white" lh={1.3}>
Book a shipment
</Text>
</Box>
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">
<ArrowRight size={18} color={cv("edr-green.7")} />
</Box>
</Group>
</Link>
</Group>
);
});

View File

@@ -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 (
<Card className="h-full" padding={24}>
<Group justify="space-between" align="center" mb={16}>
<Text fz={17} fw={700} c="edr-text">
Invoices
</Text>
<Link to="/billing">
<Group gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">
View all
</Text>
<ChevronRight size={15} color={cv("edr-green.7")} />
</Group>
</Link>
</Group>
<Box mb={16} p={16} bg="edr-amber-soft" className="rounded-[14px]">
<Text fz={12} fw={600} c="edr-amber-text">
Outstanding balance
</Text>
<Text fz={24} fw={800} mt={4} c="edr-text">
{formatCurrency(totalOutstanding || 377500, "ETB")}
</Text>
<Group
justify="space-between"
align="center"
mt={8}
wrap="nowrap"
>
<Text fz={12} c="edr-amber-text">
{outstandingInvoices.length || 2} invoices unpaid
</Text>
<Group
gap={5}
align="center"
px={14}
py={8}
bg="edr-accent"
className="cursor-pointer rounded-[9px]"
>
<Zap size={15} color="#fff" />
<Text fz={13} fw={700} c="white">
Pay all
</Text>
</Group>
</Group>
</Box>
{invoices.length === 0 ? (
<EmptyState message="No invoices yet." />
) : (
<Stack gap={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 (
<Box key={invoice.id}>
{i > 0 && <Box h={1} bg="edr-divider" />}
<Stack gap={8} py={10}>
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
>
<Box>
<Text fz={13} fw={700} c="edr-text">
{invoice.number}
</Text>
<Text fz={11} c="edr-muted">
{invoice.bookingReference}
</Text>
</Box>
<Text fz={14} fw={700} c="edr-text">
{formatCurrency(invoice.amount, invoice.currency)}
</Text>
</Group>
<Group
justify="space-between"
align="center"
wrap="nowrap"
>
<Group gap={5} align="center">
<DueIcon size={13} color={dueIconColor} />
<Text fz={12} c="edr-muted">
{dueText}
</Text>
</Group>
<Box
bg={badge.bg}
px={10}
py={4}
className="rounded-full"
>
<Text fz={11} fw={700} c={badge.text}>
{badge.label}
</Text>
</Box>
</Group>
</Stack>
</Box>
);
})}
</Stack>
)}
</Card>
);
});

View File

@@ -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 (
<Card className="h-full" padding={24}>
<Group justify="space-between" align="center" mb={16}>
<Text fz={17} fw={700} c="edr-text">
Recent Activity
</Text>
<Link to="/bookings">
<Group gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">
View all
</Text>
<ChevronRight size={15} color={cv("edr-green.7")} />
</Group>
</Link>
</Group>
{isLoading ? (
<Stack gap={10}>
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} height={44} radius="md" />
))}
</Stack>
) : bookings.length === 0 ? (
<EmptyState message="No recent activity." />
) : (
<Stack gap={2}>
{bookings.slice(0, 6).map((booking) => (
<ActivityRow
key={booking.id}
booking={booking}
onClick={() => onBookingClick(booking.id)}
/>
))}
</Stack>
)}
</Card>
);
});

View File

@@ -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 (
<Box className="rounded-2xl border border-edr-border bg-gradient-to-r from-edr-blue/5 to-edr-green/5 px-7 py-6">
<Group justify="space-between" align="center" wrap="nowrap">
<Box className="flex-1">
<Group gap={6} align="center" mb={6}>
{incomplete && <AlertTriangle size={16} color={cv("edr-orange")} />}
<Text fz={15} fw={700} c="edr-text">
{incomplete ? "Complete Your Profile" : "Setup your Company Profile"}
</Text>
</Group>
<Text fz={13} c="edr-muted" mb={12}>
{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."}
</Text>
<Link to="/settings" className="no-underline">
<Group gap={8} align="center" className="w-fit">
<Text fz={13} fw={600} c="edr-green.7">
{incomplete ? "Complete Profile" : "Complete Setup"}
</Text>
<ArrowRight size={16} color={cv("edr-green.7")} />
</Group>
</Link>
</Box>
<Box className="hidden shrink-0 sm:block">
<Truck size={48} color={cv("edr-blue")} opacity={0.3} />
</Box>
</Group>
</Box>
);
});

View File

@@ -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 (
<Card className="h-full" padding={28}>
<Group justify="space-between" align="center" mb={18} wrap="nowrap">
<Box>
<Text fz={19} fw={800} c="edr-text">
My Shipments
</Text>
<Text fz={13} c="edr-muted">
From draft to delivery every booking in one place
</Text>
</Box>
</Group>
{isLoading ? (
<Stack gap={6}>
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} height={64} radius="md" />
))}
</Stack>
) : bookings.length === 0 ? (
<EmptyState message="No bookings in this view." />
) : (
<Stack gap={0}>
{bookings.map((booking, i) => (
<BookingRow
key={booking.id}
booking={booking}
last={i === bookings.length - 1}
onClick={() => onBookingClick(booking.id)}
/>
))}
</Stack>
)}
</Card>
);
});

View File

@@ -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 (
<Box
px={4}
className={
divider ? "lg:border-l lg:border-edr-border lg:pl-7" : undefined
}
>
<Group gap={6} align="center" mb={7} wrap="nowrap">
<Icon size={15} color={cv("edr-muted")} className="shrink-0" />
<Text fz={12} fw={600} c="edr-muted" truncate>
{label}
</Text>
</Group>
<Group gap={8} align="flex-end" wrap="nowrap">
<Text fz={22} fw={800} lh={1} c="edr-text" truncate>
{value}
</Text>
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>
{delta}
</Text>
</Group>
</Box>
);
});

View File

@@ -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 (
<Card className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5">
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing={0} verticalSpacing={20}>
<StatKpi
icon={Truck}
label="Active Shipments"
value={bookingsLoading ? "—" : activeBookingsLength.toString()}
delta={bookingsLoading ? "" : `+${newActiveThisWeek} this week`}
deltaColor="edr-green.7"
/>
<StatKpi
icon={Clock3}
label="Awaiting Payment"
value={outstandingInvoicesLength.toString()}
delta={`${formatCurrency(totalOutstanding || 0, "ETB")} due`}
deltaColor="edr-amber-text"
divider
/>
<StatKpi
icon={CheckCircle2}
label="Delivered (YTD)"
value={deliveredCount ?? "—"}
delta={completionRate ? `${completionRate}% completed` : ""}
deltaColor="edr-muted"
divider
/>
<StatKpi
icon={Wallet}
label="Spend YTD"
value={spendYtd ?? "—"}
delta={spendYtdChangePct ? `${formatPct(spendYtdChangePct)} YoY` : ""}
deltaColor="edr-green.7"
divider
/>
</SimpleGrid>
</Card>
);
});

View File

@@ -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 (
<Group gap={0} align="center" wrap="nowrap" className="h-3.5 w-full">
{[0, 1, 2, 3, 4].map((i) => {
const done = i < stage;
const active = i === stage;
const size = active ? 12 : done ? 9 : 8;
return (
<Group
key={i}
gap={0}
align="center"
wrap="nowrap"
className={i < 4 ? "flex-1" : undefined}
>
<Box
w={size}
h={size}
bg={done || active ? color : "edr-step-idle"}
className="shrink-0 rounded-full"
/>
{i < 4 && (
<Box
h={3}
bg={i < stage ? color : "edr-conn-idle"}
className="flex-1 rounded-full"
/>
)}
</Group>
);
})}
</Group>
);
});

View File

@@ -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";

View File

@@ -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<string, StageConfig> = {
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" },
};

View File

@@ -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,
};
}

View File

@@ -0,0 +1 @@
export { default } from "./MyPortalPage";

View File

@@ -0,0 +1,19 @@
import { MySignatureCard } from "@/components/profile/MySignatureCard";
export default function MySignaturePage() {
return (
<div className="px-4 py-8">
<div className="mx-auto flex max-w-md flex-col gap-6">
<div>
<h1 className="text-2xl font-black tracking-tight text-foreground">
My signature
</h1>
<p className="text-sm text-muted-foreground">
Saved and reused to approve and sign booking contracts.
</p>
</div>
<MySignatureCard />
</div>
</div>
);
}

View File

@@ -1,46 +1,51 @@
import { useSearchParams } from "react-router-dom"; import { api } from "@/services/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { ProfileResponse } from "@/types/profile";
import { import {
Alert,
Card,
Center,
Container, Container,
Group, Group,
Stack,
Title,
Text,
Tabs,
Card,
TextInput,
Button,
Badge,
Alert,
Center,
Loader, Loader,
Grid, Tabs,
Text,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertCircle, AlertCircle,
Building2,
Briefcase, Briefcase,
CheckCircle2, Building2,
FileCheck, FileCheck,
Save,
User, User,
UserCheck, UserCheck,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { useForm } from "react-hook-form"; import { useCallback, useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { useNavigate, useSearchParams } from "react-router-dom";
import { z } from "zod";
import { api } from "@/services/api";
import type { CreateCompanyPayload } from "@/services/companies.service";
import PhoneInput from "@/components/auth/PhoneInput";
import TabCompanyProfile from "./settings/TabCompanyProfile"; import TabCompanyProfile from "./settings/TabCompanyProfile";
import TabContactPerson from "./settings/TabContactPerson"; import TabContactPerson from "./settings/TabContactPerson";
import TabDocuments from "./settings/TabDocuments";
import TabGeneralManager from "./settings/TabGeneralManager"; import TabGeneralManager from "./settings/TabGeneralManager";
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney"; import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
import TabDocuments from "./settings/TabDocuments";
type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents"; 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 }[] = [ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "company", label: "Company Profile", icon: <Building2 size={16} /> }, { id: "company", label: "Company Profile", icon: <Building2 size={16} /> },
{ id: "contact", label: "Contact Person", icon: <User size={16} /> }, { id: "contact", label: "Contact Person", icon: <User size={16} /> },
@@ -50,71 +55,68 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
]; ];
export default function SettingsPage() { export default function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const tab = (searchParams.get("tab") as SettingsTab) || "company"; const tab = (searchParams.get("tab") as SettingsTab) || "company";
const setTab = (t: SettingsTab) => { const setTab = useCallback(
setSearchParams( (t: SettingsTab) => {
(prev) => { setSearchParams(
const next = new URLSearchParams(prev); (prev) => {
next.set("tab", t); const next = new URLSearchParams(prev);
return next; next.set("tab", t);
}, return next;
{ replace: true }, },
); { 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 profile = profileQuery.data;
const createCompanyMutation = useMutation({ useEffect(() => {
mutationFn: (payload: CreateCompanyPayload) => if (profileQuery.dataUpdatedAt > 0) {
api.companies.create.call(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(), queryKey: api.companies.getInfo.queryKey(),
}); });
}, }
}); }, [profileQuery.dataUpdatedAt, queryClient]);
const onboardingSchema = z.object({ const [isOnboarding, setIsOnboarding] = useState<boolean | null>(null);
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"),
});
type OnboardingFormData = z.infer<typeof onboardingSchema>; useEffect(() => {
if (profileQuery.isFetched && isOnboarding === null) {
setIsOnboarding(!profileQuery.data);
}
}, [profileQuery.isFetched, profileQuery.data, isOnboarding]);
const { const handleOnboardingSuccess = useCallback(() => {
register, setTab("contact");
handleSubmit, }, [setTab]);
formState: { errors },
} = useForm<OnboardingFormData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
companyPhoneCountryCode: "+251",
},
});
const onSubmitOnboarding = (data: OnboardingFormData) => { const handleContactContinue = useCallback(() => {
const payload: CreateCompanyPayload = { setTab("gm");
companyType: "customer", }, [setTab]);
companyName: data.companyName,
companyEmail: data.companyEmail, const handleGMContinue = useCallback(() => {
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, setTab("poa");
companyLocation: data.companyLocation, }, [setTab]);
companyAddress: data.companyAddress,
tin: data.tinNumber, const handlePOAContinue = useCallback(() => {
fanNumber: data.fanNumber, setTab("documents");
}; }, [setTab]);
createCompanyMutation.mutate(payload);
}; const handleDocumentsContinue = useCallback(() => {
navigate("/portal");
}, [navigate]);
if (profileQuery.isPending) { if (profileQuery.isPending) {
return ( return (
@@ -124,25 +126,54 @@ export default function SettingsPage() {
); );
} }
const onboarding = isOnboarding === true;
const renderProfileContent = (children: React.ReactNode) => {
if (onboarding && tab !== "company" && !profile) {
return (
<Center h={200}>
<Loader color="edr-green" />
</Center>
);
}
if (!profile) {
return (
<Card padding="xl">
<Center>
<Alert
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
);
}
return children;
};
return ( return (
<Container size="xl" py="xl"> <Container size="xl" px="lg">
<Group justify="space-between" mb="xl"> <Group justify="space-between" mb="xl">
<div> <div>
<Title order={1} size="h2"> <Title order={1} size="h2">
Account Settings {onboarding ? "Complete Your Profile" : "Account Settings"}
</Title> </Title>
<Text c="edr-muted" size="sm" mt={4}> <Text c="edr-muted" size="sm" mt={4}>
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"}
</Text> </Text>
</div> </div>
{profile && <Badge color="edr-green">Verified</Badge>}
</Group> </Group>
<Tabs <Tabs
value={tab} value={tab}
onChange={(value) => { onChange={(value) => {
if (!value) return; if (!value) return;
if (!profile && value !== "company") return; // if (onboarding) return;
setTab(value as SettingsTab); setTab(value as SettingsTab);
}} }}
> >
@@ -152,7 +183,12 @@ export default function SettingsPage() {
key={t.id} key={t.id}
value={t.id} value={t.id}
leftSection={t.icon} leftSection={t.icon}
disabled={!profile && t.id !== "company"} disabled={!onboarding && !profile && t.id !== "company"}
rightSection={
!onboarding && profile && tabIncomplete(t.id, profile) ? (
<AlertCircle size={14} color="red" />
) : undefined
}
> >
{t.label} {t.label}
</Tabs.Tab> </Tabs.Tab>
@@ -161,201 +197,52 @@ export default function SettingsPage() {
<Tabs.Panel value="company"> <Tabs.Panel value="company">
{!profile ? ( {!profile ? (
<Card padding="lg"> <TabCompanyProfile
<Stack gap="md"> mode="create"
<Group gap="sm"> onCreateSuccess={handleOnboardingSuccess}
<Building2 size={20} /> />
<Title order={3}>Company Profile</Title>
</Group>
<Text c="edr-muted" size="sm">
Enter your company registration details to get started
</Text>
</Stack>
<form onSubmit={handleSubmit(onSubmitOnboarding)}>
<Stack gap="md" mt="lg">
<TextInput
label="Company Name"
placeholder="Global Logistics Ltd"
error={errors.companyName?.message}
{...register("companyName")}
/>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Company Email"
type="email"
placeholder="ops@company.com"
error={errors.companyEmail?.message}
{...register("companyEmail")}
/>
</Grid.Col>
<Grid.Col span={6}>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone"
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Address"
placeholder="Bole Subcity, Woreda 03"
error={errors.companyAddress?.message}
{...register("companyAddress")}
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={6}>
<TextInput
label="TIN Number (10 digits)"
placeholder="1234567890"
maxLength={10}
error={errors.tinNumber?.message}
{...register("tinNumber")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="FAN Number (16 digits)"
placeholder="1234567890123456"
maxLength={16}
error={errors.fanNumber?.message}
{...register("fanNumber")}
/>
</Grid.Col>
</Grid>
</Stack>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Group gap="xs">
{createCompanyMutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Profile created successfully
</Text>
</Group>
)}
{createCompanyMutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
Failed to create profile
</Text>
</Group>
)}
</Group>
<Button
type="submit"
leftSection={<Save size={16} />}
loading={createCompanyMutation.isPending}
>
Create Profile
</Button>
</Group>
</form>
</Card>
) : ( ) : (
<TabCompanyProfile profile={profile} /> <TabCompanyProfile mode="edit" profile={profile} />
)} )}
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="contact"> <Tabs.Panel value="contact">
{profile ? ( {renderProfileContent(
<TabContactPerson profile={profile} /> <TabContactPerson
) : ( profile={profile!}
<Card padding="xl"> mode={onboarding ? "onboarding" : "edit"}
<Center> onContinue={handleContactContinue}
<Alert />,
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
)} )}
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="gm"> <Tabs.Panel value="gm">
{profile ? ( {renderProfileContent(
<TabGeneralManager profile={profile} /> <TabGeneralManager
) : ( profile={profile!}
<Card padding="xl"> mode={onboarding ? "onboarding" : "edit"}
<Center> onContinue={handleGMContinue}
<Alert />,
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
)} )}
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="poa"> <Tabs.Panel value="poa">
{profile ? ( {renderProfileContent(
<TabPowerOfAttorney profile={profile} /> <TabPowerOfAttorney
) : ( profile={profile!}
<Card padding="xl"> mode={onboarding ? "onboarding" : "edit"}
<Center> onContinue={handlePOAContinue}
<Alert />,
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
)} )}
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="documents"> <Tabs.Panel value="documents">
{profile ? ( {renderProfileContent(
<TabDocuments profile={profile} /> <TabDocuments
) : ( profile={profile!}
<Card padding="xl"> mode={onboarding ? "onboarding" : "edit"}
<Center> onContinue={handleDocumentsContinue}
<Alert />,
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
)} )}
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>

View File

@@ -25,6 +25,9 @@ export default function BookingContractPage() {
const [signOpen, setSignOpen] = useState(false); const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState(""); const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null); const [signatureData, setSignatureData] = useState<string | null>(null);
// When a saved signature exists we offer it for approval first; the customer
// can switch to drawing a fresh one.
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["booking-contract-view", id], queryKey: ["booking-contract-view", id],
@@ -32,6 +35,31 @@ export default function BookingContractPage() {
enabled: Boolean(id), 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({ const signMutation = useMutation({
mutationFn: (payload: SignContractPayload) => mutationFn: (payload: SignContractPayload) =>
bookingsService.signContract(id!, payload), bookingsService.signContract(id!, payload),
@@ -102,9 +130,9 @@ export default function BookingContractPage() {
PDF PDF
</Button> </Button>
{data.canSignCustomer && ( {data.canSignCustomer && (
<Button size="sm" onClick={() => setSignOpen(true)}> <Button size="sm" onClick={openSign}>
<FileSignature className="mr-2 size-4" /> <FileSignature className="mr-2 size-4" />
Sign contract {usingSaved ? "Approve & sign" : "Sign contract"}
</Button> </Button>
)} )}
</div> </div>
@@ -122,9 +150,13 @@ export default function BookingContractPage() {
{signOpen && ( {signOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 print:hidden"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 print:hidden">
<div className="w-full max-w-md rounded-xl bg-background p-6 shadow-xl"> <div className="w-full max-w-md rounded-xl bg-background p-6 shadow-xl">
<h2 className="text-lg font-semibold">Sign contract</h2> <h2 className="text-lg font-semibold">
{usingSaved ? "Approve signature" : "Sign contract"}
</h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{data.reference} your signature will be stored securely. {usingSaved
? `${data.reference} — review your saved signature and approve it.`
: `${data.reference} — your signature will be stored securely.`}
</p> </p>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<label className="text-sm font-medium" htmlFor="portalSigner"> <label className="text-sm font-medium" htmlFor="portalSigner">
@@ -136,7 +168,29 @@ export default function BookingContractPage() {
value={signerName} value={signerName}
onChange={(e) => setSignerName(e.target.value)} onChange={(e) => setSignerName(e.target.value)}
/> />
<ContractSignaturePad onChange={setSignatureData} /> {usingSaved ? (
<div className="space-y-2">
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={savedSignatureImage ?? undefined}
alt="Saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<button
type="button"
className="text-xs text-primary underline"
onClick={() => {
setDrawNew(true);
setSignatureData(null);
}}
>
Draw a new signature instead
</button>
</div>
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
</div> </div>
<div className="mt-6 flex justify-end gap-2"> <div className="mt-6 flex justify-end gap-2">
<Button variant="outline" onClick={() => setSignOpen(false)}> <Button variant="outline" onClick={() => setSignOpen(false)}>
@@ -145,19 +199,12 @@ export default function BookingContractPage() {
<Button <Button
disabled={ disabled={
signMutation.isPending || signMutation.isPending ||
!signatureData || (!usingSaved && !signatureData) ||
!signerName.trim() !signerName.trim()
} }
onClick={() => onClick={confirmSign}
signMutation.mutate({
role: "CUSTOMER",
signatureImageBase64: signatureData!,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
})
}
> >
Confirm signature {usingSaved ? "Approve & sign" : "Confirm signature"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -28,17 +28,18 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
const status = booking.status as string; const status = booking.status as string;
const [payModalOpen, setPayModalOpen] = useState(false); const [payModalOpen, setPayModalOpen] = useState(false);
// Two-step flow: POST /payments/initiate to create the intent, then send the // POST /payments/initiate creates the intent and returns the provider's
// browser to the public /payments/checkout page which redirects to the // redirect URL (clientAction.url). Send the browser straight there; fall back
// selected provider to complete payment. // to the public /payments/checkout page if no redirect URL came back.
const payMutation = useMutation({ const payMutation = useMutation({
mutationFn: (method: PaymentMethod) => mutationFn: (method: PaymentMethod) =>
api.payments.initiate.call({ bookingId: booking.id, method }), api.payments.initiate.call({ bookingId: booking.id, method }),
onSuccess: (_data, method) => { onSuccess: (data, method) => {
window.location.href = paymentsService.checkoutUrl({ const redirectUrl =
bookingId: booking.id, data?.clientAction?.type === "REDIRECT" && data.clientAction.url
method, ? data.clientAction.url
}); : paymentsService.checkoutUrl({ bookingId: booking.id, method });
window.location.href = redirectUrl;
}, },
}); });

View File

@@ -1,12 +1,5 @@
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core"; import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
import { import { Smartphone, type LucideIcon } from "lucide-react";
Banknote,
Building2,
CreditCard,
Smartphone,
Wallet,
type LucideIcon,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import type { PaymentMethod } from "@/services/payments.service"; import type { PaymentMethod } from "@/services/payments.service";
@@ -18,6 +11,7 @@ interface ProviderOption {
icon: LucideIcon; icon: LucideIcon;
} }
// Only Telebirr and Waafi are enabled for now.
const PROVIDERS: ProviderOption[] = [ const PROVIDERS: ProviderOption[] = [
{ {
method: "TELEBIRR", method: "TELEBIRR",
@@ -25,42 +19,12 @@ const PROVIDERS: ProviderOption[] = [
description: "Ethiopian mobile money", description: "Ethiopian mobile money",
icon: Smartphone, 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", method: "WAAFI",
label: "WAAFI", label: "Waafi",
description: "Djibouti mobile money", description: "Djibouti mobile money",
icon: Smartphone, 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({ function ProviderRow({
@@ -141,7 +105,7 @@ export function PaymentMethodModal({
processing?: boolean; processing?: boolean;
error?: string | null; error?: string | null;
}) { }) {
const [method, setMethod] = useState<PaymentMethod | null>(null); const [method, setMethod] = useState<PaymentMethod>(PROVIDERS[0].method);
return ( return (
<Modal <Modal
@@ -184,9 +148,9 @@ export function PaymentMethodModal({
mt={6} mt={6}
radius={10} radius={10}
color="edr-green" color="edr-green"
disabled={!method || processing} disabled={processing}
loading={processing} loading={processing}
onClick={() => method && onConfirm(method)} onClick={() => onConfirm(method)}
styles={{ styles={{
root: { height: 46 }, root: { height: 46 },
label: { fontSize: 14, fontWeight: 800 }, label: { fontSize: 14, fontWeight: 800 },

View File

@@ -14,7 +14,7 @@ export function StatusHero({
booking: Freight.IBooking; booking: Freight.IBooking;
children?: React.ReactNode; children?: React.ReactNode;
}) { }) {
const status = booking.status as string; const status = booking.status;
const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT; const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT;
const negative = isNegative(status); const negative = isNegative(status);
const draft = isDraftLike(status); const draft = isDraftLike(status);
@@ -45,7 +45,12 @@ export function StatusHero({
<Group gap={16} align="center" wrap="nowrap"> <Group gap={16} align="center" wrap="nowrap">
<div <div
className="flex items-center justify-center rounded-2xl shrink-0" className="flex items-center justify-center rounded-2xl shrink-0"
style={{ width: 56, height: 56, backgroundColor: tileBg, color: tileFg }} style={{
width: 56,
height: 56,
backgroundColor: tileBg,
color: tileFg,
}}
> >
<HeroIcon size={26} /> <HeroIcon size={26} />
</div> </div>
@@ -77,7 +82,7 @@ export function StatusHero({
> >
{chipLabel} {chipLabel}
</Text> </Text>
<Text fz="13.5px" fw={700} c="#10202F"> <Text fz="sm" fw={700} c="#10202F">
{chipValue} {chipValue}
</Text> </Text>
</Box> </Box>
@@ -100,7 +105,6 @@ export function StatusHero({
function ProgressTracker({ function ProgressTracker({
current, current,
tone = "green", tone = "green",
negative,
}: { }: {
current: number; current: number;
tone?: "green" | "ink"; tone?: "green" | "ink";
@@ -109,13 +113,17 @@ function ProgressTracker({
const last = PROGRESS_STAGES.length - 1; const last = PROGRESS_STAGES.length - 1;
const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371"; const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371";
const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4"; const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4";
const activeSub = tone === "ink" ? "#475569" : "#0A6F4D";
return ( return (
/* Scrollable on mobile so 5 stages never overflow */ /* Scrollable on mobile so 5 stages never overflow */
<Box <Box
className="overflow-x-auto" className="overflow-x-auto pt-2"
style={{ scrollbarWidth: "none", WebkitOverflowScrolling: "touch" } as React.CSSProperties} style={
{
scrollbarWidth: "none",
WebkitOverflowScrolling: "touch",
} as React.CSSProperties
}
> >
<div className="flex items-start" style={{ minWidth: 440 }}> <div className="flex items-start" style={{ minWidth: 440 }}>
{PROGRESS_STAGES.map((stage, idx) => { {PROGRESS_STAGES.map((stage, idx) => {
@@ -131,14 +139,15 @@ function ProgressTracker({
: state === "active" : state === "active"
? activeFill ? activeFill
: "#0EA371"; : "#0EA371";
const circleBorder = state === "idle" ? "1px solid #E1E7EE" : undefined; const circleBorder =
state === "idle" ? "1px solid #E1E7EE" : undefined;
const circleShadow = const circleShadow =
state === "active" ? `0 0 0 4px ${activeRing}` : undefined; state === "active" ? `0 0 0 4px ${activeRing}` : undefined;
return ( return (
<div <div
key={stage.label} key={stage.label}
className="flex flex-1 flex-col items-center gap-[10px]" className="flex flex-1 flex-col items-center"
> >
<div className="flex w-full items-center"> <div className="flex w-full items-center">
{/* left connector */} {/* left connector */}
@@ -147,15 +156,19 @@ function ProgressTracker({
style={{ style={{
height: 3, height: 3,
background: background:
idx === 0 ? "transparent" : reachedLeft ? "#0EA371" : "#E1E7EE", idx === 0
? "transparent"
: reachedLeft
? "#0EA371"
: "#E1E7EE",
}} }}
/> />
{/* stage circle */} {/* stage circle */}
<div <div
className="flex items-center justify-center rounded-full shrink-0" className="flex items-center justify-center mb-2 rounded-full shrink-0"
style={{ style={{
width: 40, width: 32,
height: 40, height: 32,
backgroundColor: circleBg, backgroundColor: circleBg,
border: circleBorder, border: circleBorder,
boxShadow: circleShadow, boxShadow: circleShadow,
@@ -173,32 +186,22 @@ function ProgressTracker({
style={{ style={{
height: 3, height: 3,
background: background:
idx === last ? "transparent" : reachedRight ? "#0EA371" : "#E1E7EE", idx === last
? "transparent"
: reachedRight
? "#0EA371"
: "#E1E7EE",
}} }}
/> />
</div> </div>
<Text <Text
fz="13.5px" fz="14px"
fw={state === "active" ? 800 : 700} fw={700}
ta="center" ta="center"
c={state === "idle" ? "#9AA8B5" : "#10202F"} c={state === "idle" ? "#9AA8B5" : "#10202F"}
> >
{stage.label} {stage.label}
</Text> </Text>
<Text
fz="11.5px"
fw={state === "active" ? 700 : 500}
ta="center"
c={state === "active" ? activeSub : "#9AA8B5"}
>
{state === "done"
? "Completed"
: state === "active"
? negative
? "Stopped"
: "In progress"
: "Pending"}
</Text>
</div> </div>
); );
})} })}

View File

@@ -1,5 +1,5 @@
import { Box, Button, Group, Stack, Text } from "@mantine/core"; import { Box, Group, Stack, Text } from "@mantine/core";
import { CheckCircle2, Clock, FileText } from "lucide-react"; import { CheckCircle2, Clock } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -173,16 +173,16 @@ export function PaymentCard({
</Group> </Group>
</> </>
)} )}
<Button {/* <Button */}
fullWidth {/* fullWidth */}
mt={16} {/* mt={16} */}
variant="default" {/* variant="default" */}
radius={10} {/* radius={10} */}
leftSection={<FileText size={17} color="#475569" />} {/* leftSection={<FileText size={17} color="#475569" />} */}
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }} {/* styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }} */}
> {/* > */}
Download invoice {/* Download invoice */}
</Button> {/* </Button> */}
</SectionCard> </SectionCard>
); );
} }

View File

@@ -3,6 +3,7 @@ import {
FileText, FileText,
PackageCheck, PackageCheck,
ShieldCheck, ShieldCheck,
Ship,
Train, Train,
} from "lucide-react"; } from "lucide-react";
@@ -15,32 +16,41 @@ export const PROGRESS_STAGES = [
{ {
label: "Submitted", label: "Submitted",
icon: ClipboardCheck, 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, icon: ShieldCheck,
statuses: [ statuses: [
"APPROVED_PENDING_SIGNATURE",
"APPROVED",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED", "FULLY_EXECUTED",
"SELECTED_FOR_BATCH",
"PAYMENT_VERIFICATION_IN_PROGRESS",
],
},
{
label: "Loading",
icon: Ship,
statuses: [
"PAID",
"PNR_GENERATED",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
], ],
}, },
{ {
label: "In Transit", label: "In Transit",
icon: Train, icon: Train,
statuses: [ statuses: ["EXPIRED", "IN_TRANSIT"],
"SELECTED_FOR_BATCH",
"EXPIRED",
"PNR_GENERATED",
"PAYMENT_VERIFICATION_IN_PROGRESS",
"PAID",
"IN_TRANSIT",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
],
}, },
{ {
label: "Complete", label: "Complete",
@@ -72,7 +82,7 @@ export const STATUS_MAP: Record<
PENDING_APPROVAL: { PENDING_APPROVAL: {
title: "Pending approval", title: "Pending approval",
description: "Your booking is moving through the approval process.", description: "Your booking is moving through the approval process.",
stage: 1, stage: 2,
}, },
APPROVED_PENDING_SIGNATURE: { APPROVED_PENDING_SIGNATURE: {
title: "Approved — awaiting signature", title: "Approved — awaiting signature",
@@ -88,71 +98,71 @@ export const STATUS_MAP: Record<
title: "Contract ready to sign", title: "Contract ready to sign",
description: description:
"Your contract is ready. Review and apply your signature to proceed.", "Your contract is ready. Review and apply your signature to proceed.",
stage: 2, stage: 3,
}, },
SIGNED_CUSTOMER: { SIGNED_CUSTOMER: {
title: "Signed — awaiting staff", title: "Signed — awaiting staff",
description: description:
"Your signature has been submitted. Awaiting the final staff signature.", "Your signature has been submitted. Awaiting the final staff signature.",
stage: 2, stage: 3,
}, },
FULLY_EXECUTED: { FULLY_EXECUTED: {
title: "Contract fully executed", title: "Contract fully executed",
description: "Signed by all parties. You can now proceed to payment.", description: "Signed by all parties. You can now proceed to payment.",
stage: 2, stage: 4,
}, },
SELECTED_FOR_BATCH: { SELECTED_FOR_BATCH: {
title: "Selected for a train — payment due", title: "Selected for a train — payment due",
description: description:
"Your booking was selected for a scheduled train. Complete payment within the pay window to secure your slot.", "Your booking was selected for a scheduled train. Complete payment within the pay window to secure your slot.",
stage: 3, stage: 4,
}, },
EXPIRED: { EXPIRED: {
title: "Pay window expired", title: "Pay window expired",
description: description:
"The payment window was missed. You can move this booking to another schedule or cancel it.", "The payment window was missed. You can move this booking to another schedule or cancel it.",
stage: 3, stage: 6,
}, },
PNR_GENERATED: { PNR_GENERATED: {
title: "Payment reference generated", title: "Payment reference generated",
description: description:
"A payment reference number has been generated for this booking.", "A payment reference number has been generated for this booking.",
stage: 3, stage: 5,
}, },
PAYMENT_VERIFICATION_IN_PROGRESS: { PAYMENT_VERIFICATION_IN_PROGRESS: {
title: "Verifying payment", title: "Verifying payment",
description: "Your payment is being verified.", description: "Your payment is being verified.",
stage: 3, stage: 4,
}, },
PAID: { PAID: {
title: "Payment confirmed", title: "Payment confirmed",
description: "Payment has been confirmed for this booking.", description: "Payment has been confirmed for this booking.",
stage: 3, stage: 5,
}, },
IN_TRANSIT: { IN_TRANSIT: {
title: "Cargo moving", title: "Cargo moving",
description: "Your shipment is currently moving through the rail network.", description: "Your shipment is currently moving through the rail network.",
stage: 3, stage: 6,
}, },
PENDING_CONSOLIDATION: { PENDING_CONSOLIDATION: {
title: "Pending consolidation", title: "Pending consolidation",
description: "Awaiting a consolidation partner shipment.", description: "Awaiting a consolidation partner shipment.",
stage: 3, stage: 5,
}, },
CONSOLIDATED: { CONSOLIDATED: {
title: "Consolidated", title: "Consolidated",
description: "Cargo has been consolidated with a partner shipment.", description: "Cargo has been consolidated with a partner shipment.",
stage: 3, stage: 5,
}, },
COMPLETED: { COMPLETED: {
title: "Service complete", title: "Service complete",
description: "Cargo delivered and service successfully terminated.", description: "Cargo delivered and service successfully terminated.",
stage: 4, stage: 7,
}, },
DELIVERED: { DELIVERED: {
title: "Service complete", title: "Service complete",
description: "Cargo delivered and service successfully terminated.", description: "Cargo delivered and service successfully terminated.",
stage: 4, stage: 7,
}, },
REJECTED: { REJECTED: {
title: "Booking rejected", title: "Booking rejected",

View File

@@ -1,8 +1,7 @@
import { useMemo, useRef, type ReactNode } from "react"; import { api } from "@/services/api";
import { Controller, useForm } from "react-hook-form"; import type { CreateBookingPayload } from "@/services/bookings.service";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Freight } from "@edr/types";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router-dom";
import { import {
ActionIcon, ActionIcon,
Alert, Alert,
@@ -21,6 +20,7 @@ import {
TextInput, TextInput,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@@ -34,12 +34,17 @@ import {
Upload, Upload,
X, X,
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types"; import { useMemo, useRef, type ReactNode } from "react";
import { api } from "@/services/api"; import { Controller, useForm } from "react-hook-form";
import type { CreateBookingPayload } from "@/services/bookings.service"; import { useNavigate, useParams } from "react-router-dom";
import {
CountChip,
DocRow,
IconSquare,
} from "./BookingDetailPage/components/Documents";
import { import {
BookingFormInputValues,
BOOKING_DOCS_SETTING, BOOKING_DOCS_SETTING,
BookingFormInputValues,
bookingFormSchema, bookingFormSchema,
getRouteDirection, getRouteDirection,
initialBookingFormValues, initialBookingFormValues,
@@ -48,11 +53,6 @@ import {
} from "./new-booking-form/schema"; } from "./new-booking-form/schema";
import { SelectField } from "./new-booking-form/shared"; import { SelectField } from "./new-booking-form/shared";
import { Step5CargoDetails } from "./new-booking-form/steps"; import { Step5CargoDetails } from "./new-booking-form/steps";
import {
CountChip,
DocRow,
IconSquare,
} from "./BookingDetailPage/components/Documents";
function yardNameFromBooking( function yardNameFromBooking(
yard: { label?: string; code?: string; name?: string } | undefined | null, yard: { label?: string; code?: string; name?: string } | undefined | null,
@@ -117,8 +117,6 @@ function mapBookingToFormValues(
shippingLine: (booking as any).shippingLine?.name ?? "", shippingLine: (booking as any).shippingLine?.name ?? "",
consolidationEnabled: booking.allowConsolidation ?? false, consolidationEnabled: booking.allowConsolidation ?? false,
notes: "", notes: "",
// Terms were accepted at creation; editing shouldn't re-gate on them.
termsAccepted: true,
containers: [], containers: [],
} as BookingFormInputValues; } as BookingFormInputValues;

View File

@@ -1,9 +1,29 @@
import { api } from "@/services/api"; 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 { 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 { 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 { useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom"; 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<BookingFormInputValues, any, BookingFormValues>({ const form = useForm<BookingFormInputValues, any, BookingFormValues>({
defaultValues: initialBookingFormValues, defaultValues: initialBookingFormValues,
resolver: zodResolver(bookingFormSchema), resolver: zodResolver(bookingFormSchema),
@@ -116,6 +188,25 @@ export default function NewBookingPage() {
return route; return route;
}, [originYard, destinationYard]); }, [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<GeneratePriceResponse | null>(
null,
);
const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
async function handleContinue() { async function handleContinue() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true }); const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return; if (!valid) return;
@@ -123,14 +214,14 @@ export default function NewBookingPage() {
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1)); setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
} }
const handleSubmit = form.handleSubmit((data) => { function buildApiPayload(data: BookingFormValues): CreateBookingPayload {
if (data.contractType === "renewal" && !data.previousContractRef) { if (data.contractType === "renewal" && !data.previousContractRef) {
form.setError("previousContractRef", { form.setError("previousContractRef", {
type: "manual", type: "manual",
message: "Select a previous contract reference.", message: "Select a previous contract reference.",
}); });
setStep(1); setStep(1);
return; throw new Error("Validation failed");
} }
const totalWeight = const totalWeight =
@@ -141,7 +232,6 @@ export default function NewBookingPage() {
) )
: Number(data.cargoWeight || 0); : Number(data.cargoWeight || 0);
// ── Reference data lookups ──────────────────────────────────────────
const shippingLines = referenceData?.shipping_line ?? []; const shippingLines = referenceData?.shipping_line ?? [];
const cargoTree = referenceData?.cargo_type ?? []; const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? []; const containerGroups = referenceData?.containers ?? [];
@@ -174,8 +264,7 @@ export default function NewBookingPage() {
(s) => s.id === data.serviceTypeId, (s) => s.id === data.serviceTypeId,
)!; )!;
// ── Build API payload ─────────────────────────────────────────────── return {
const apiPayload: CreateBookingPayload = {
scheduledDate: new Date().toISOString(), scheduledDate: new Date().toISOString(),
contractType: contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"], data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
@@ -222,8 +311,25 @@ export default function NewBookingPage() {
: {}), : {}),
...(cargoFreeText ? { cargoFreeText } : {}), ...(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 ( return (
@@ -270,7 +376,7 @@ export default function NewBookingPage() {
id="new-booking-form" id="new-booking-form"
className="flex flex-col" className="flex flex-col"
style={{ flex: 1 }} style={{ flex: 1 }}
onSubmit={handleSubmit} onSubmit={handleDraftSubmit}
> >
<Box flex={1} p="24px"> <Box flex={1} p="24px">
<Box mb="lg"> <Box mb="lg">
@@ -295,6 +401,24 @@ export default function NewBookingPage() {
</Alert> </Alert>
)} )}
{createAndPriceMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="lg"
>
<Text size="sm" fw={600}>
Failed to generate price estimate
</Text>
<Text size="sm" mt={4} c="red.7">
{createAndPriceMutation.error instanceof Error
? createAndPriceMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
)}
{step === 1 && ( {step === 1 && (
<Step1ContractType form={form} referenceData={referenceData} /> <Step1ContractType form={form} referenceData={referenceData} />
)} )}
@@ -326,6 +450,17 @@ export default function NewBookingPage() {
setStep={setStep} setStep={setStep}
direction={direction!} direction={direction!}
referenceData={referenceData} referenceData={referenceData}
pricingPhase={pricingPhase}
pricingData={pricingData}
onConfirm={() => confirmMutation.mutate()}
onContinueLater={
priceBookingId
? () => navigate(`/bookings/${priceBookingId}`)
: undefined
}
onAbort={() => setCancelDialogOpen(true)}
confirmPending={confirmMutation.isPending}
abortPending={abortMutation.isPending}
/> />
)} )}
</Box> </Box>
@@ -366,24 +501,98 @@ export default function NewBookingPage() {
> >
Continue Continue
</Button> </Button>
) : ( ) : pricingPhase === "idle" ? (
<Button <Group>
type="submit" <Button
form="new-booking-form" type="submit"
color="edr-green" form="new-booking-form"
radius="md" variant={hasDocuments ? "outline" : "filled"}
loading={createMutation.isPending} color="edr-green"
leftSection={ radius="md"
createMutation.isPending ? undefined : <Check size={16} /> loading={createMutation.isPending}
} leftSection={
> createMutation.isPending ? undefined : <Check size={16} />
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"} }
>
{createMutation.isPending
? "Saving Draft..."
: "Save as Draft"}
</Button>
{hasDocuments && (
<Button
type="button"
color="edr-green"
radius="md"
loading={createAndPriceMutation.isPending}
leftSection={
createAndPriceMutation.isPending ? undefined : (
<Send size={16} />
)
}
onClick={() => handleGeneratePrice()}
>
{createAndPriceMutation.isPending
? "Generating price…"
: "Submit"}
</Button>
)}
</Group>
) : pricingPhase === "generating" ? (
<Button type="button" color="edr-green" radius="md" loading>
Generating price estimate
</Button> </Button>
)} ) : null}
</Group> </Group>
</Box> </Box>
</form> </form>
{/* <DevTool control={form.control} /> */}
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
title={<Text fw={700}>Abort booking</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Are you sure you want to abort this booking? This action cannot be
undone.
</Text>
<TextInput
label="Reason for cancellation (optional)"
placeholder="e.g. Change of plans…"
value={cancelReason}
onChange={(e) => setCancelReason(e.currentTarget.value)}
radius="md"
data-autofocus
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setCancelDialogOpen(false)}
>
Keep editing
</Button>
<Button
color="red"
radius="md"
onClick={() =>
abortMutation.mutate(
cancelReason.trim() || "Aborted by customer",
)
}
disabled={abortMutation.isPending}
loading={abortMutation.isPending}
leftSection={
!abortMutation.isPending ? <XCircle size={15} /> : undefined
}
>
Yes, abort
</Button>
</Group>
</Stack>
</Modal>
</Box> </Box>
); );
} }

View File

@@ -121,7 +121,6 @@ export const bookingFormSchema = z
consolidationEnabled: z.boolean(), consolidationEnabled: z.boolean(),
documents: z.record(z.string(), z.any()).default({}), documents: z.record(z.string(), z.any()).default({}),
notes: z.string(), notes: z.string(),
termsAccepted: z.boolean(),
}) })
.refine( .refine(
(data) => (data) =>
@@ -165,23 +164,13 @@ export const bookingFormSchema = z
(data) => !(data.cargoType === "container" && data.containers.length === 0), (data) => !(data.cargoType === "container" && data.containers.length === 0),
{ message: "Add at least one container.", path: ["containers"] }, { 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) => { .superRefine((data, ctx) => {
if (data.cargoType === "bulk") { if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) { if (!data.cargoTypePath[0]) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
path: ["cargoTypePath"], path: ["cargoTypePath"],
message: "Select a freight type.", message: "Select a Cargo type.",
});
} else if (!data.cargoTypePath[1]) {
ctx.addIssue({
code: "custom",
path: ["cargoTypePath"],
message: "Select a commodity.",
}); });
} }
} }
@@ -237,7 +226,6 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
consolidationEnabled: false, consolidationEnabled: false,
documents: {}, documents: {},
notes: "", notes: "",
termsAccepted: false,
}; };
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = { export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
@@ -265,7 +253,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
], ],
5: ["scheduledDate", "trainScheduleId"], 5: ["scheduledDate", "trainScheduleId"],
6: ["documents"], 6: ["documents"],
7: ["notes", "termsAccepted"], 7: ["notes"],
}; };
export interface ContainerConfig { export interface ContainerConfig {
@@ -288,10 +276,10 @@ export function getRouteDirection(
return "DOMESTIC"; return "DOMESTIC";
} }
if (origin.country === "Ethiopia" && dest.country === "Djibouti") { if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
return "IMPORT"; return "EXPORT";
} }
if (origin.country === "Djibouti" && dest.country === "Ethiopia") { if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
return "EXPORT"; return "IMPORT";
} }
return null; return null;

View File

@@ -1,38 +1,39 @@
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { import {
Box, Box,
Button, Button,
Card, Card,
Group, Group,
Modal,
Stack, Stack,
Text, Text,
useMantineTheme, useMantineTheme
} from "@mantine/core"; } from "@mantine/core";
import { UseFormReturn } from "react-hook-form"; import { useQuery } from "@tanstack/react-query";
import { BookingFormInputValues, BookingFormValues } from "./schema";
import { import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameMonth,
isToday,
startOfMonth,
startOfWeek,
} from "date-fns";
import {
Calendar as CalendarIcon,
Check,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Check,
Train,
Route,
Package, Package,
Calendar as CalendarIcon, Route,
Train,
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { UseFormReturn } from "react-hook-form";
import { api } from "@/services/api"; import { BookingFormInputValues, BookingFormValues } from "./schema";
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isToday,
isSameMonth,
addMonths,
} from "date-fns";
interface StepSchedulingProps { interface StepSchedulingProps {
form: UseFormReturn<BookingFormInputValues, any, BookingFormValues>; form: UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
@@ -52,6 +53,7 @@ interface DayData {
export function StepScheduling({ form, referenceData }: StepSchedulingProps) { export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const theme = useMantineTheme(); const theme = useMantineTheme();
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDayForModal, setSelectedDayForModal] = useState<DayData | null>(null);
const selectedDate = form.watch("scheduledDate"); const selectedDate = form.watch("scheduledDate");
const selectedScheduleId = form.watch("trainScheduleId"); const selectedScheduleId = form.watch("trainScheduleId");
@@ -148,10 +150,35 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const weeksCount = Math.ceil(days.length / 7); 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 ( return (
<Group gap={24} align="flex-start" wrap="nowrap"> <Group gap={24} align="flex-start" wrap="wrap" style={{ width: "100%" }}>
{/* ── Calendar Card ───────────────────────────────────── */} {/* ── Calendar Card ───────────────────────────────────── */}
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 300 }}>
<Card p={0} style={{ overflow: "hidden" }}> <Card p={0} style={{ overflow: "hidden" }}>
{/* Card header */} {/* Card header */}
<Group <Group
@@ -244,15 +271,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
<DayCell <DayCell
key={di} key={di}
day={d} day={d}
selectedScheduleId={selectedScheduleId} onDayClick={handleDayClick}
onSelectSchedule={(scheduleId, dateString) => {
form.setValue("scheduledDate", dateString, {
shouldValidate: true,
});
form.setValue("trainScheduleId", scheduleId, {
shouldValidate: true,
});
}}
/> />
))} ))}
</Box> </Box>
@@ -263,7 +282,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
</Box> </Box>
{/* ── Side Panel ──────────────────────────────────────── */} {/* ── Side Panel ──────────────────────────────────────── */}
<Stack gap={20} w={340} style={{ flexShrink: 0 }}> <Stack gap={20} style={{ width: 340, flexShrink: 0 }}>
{/* Booking summary card */} {/* Booking summary card */}
<Card p={0} style={{ overflow: "hidden" }}> <Card p={0} style={{ overflow: "hidden" }}>
<Group <Group
@@ -361,17 +380,81 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
</Stack> </Stack>
</Box> </Box>
</Stack> </Stack>
{/* ── Schedule Selection Modal ──────────────────────────── */}
<Modal
opened={!!selectedDayForModal}
onClose={() => 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 },
}}
>
<Stack gap={12}>
<Text fz={13} c="edr-muted" fw={500}>
Choose a departure time
</Text>
{selectedDayForModal?.schedules.map((schedule) => (
<Button
key={schedule.id}
variant="outline"
fullWidth
onClick={() => handleSelectScheduleFromModal(schedule.id)}
style={{ height: 64, justifyContent: "flex-start" }}
styles={{
inner: { justifyContent: "flex-start" },
root: {
borderColor: theme.colors["edr-border"][0],
transition: "all 150ms ease",
"&:hover": {
borderColor: theme.colors["edr-green"][5],
backgroundColor: theme.colors["edr-soft"][0],
},
},
}}
>
<Group gap={16} w="100%">
<Box
style={{
width: 48,
height: 48,
borderRadius: theme.radius.md,
backgroundColor: theme.colors["edr-soft"][0],
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Train size={24} color={theme.colors["edr-green"][5]} />
</Box>
<Stack gap={3} style={{ flex: 1, alignItems: "flex-start" }}>
<Text fw={700} fz={18} c="edr-text.0">
{format(new Date(schedule.scheduleDate), "HH:mm")}
</Text>
{schedule.trainNumber && (
<Text fz={12} c="edr-muted">
Train {schedule.trainNumber}
</Text>
)}
</Stack>
</Group>
</Button>
))}
</Stack>
</Modal>
</Group> </Group>
); );
} }
interface DayCellProps { interface DayCellProps {
day: DayData; day: DayData;
selectedScheduleId: string; onDayClick: (day: DayData) => void;
onSelectSchedule: (scheduleId: string, dateString: string) => void;
} }
function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps) { function DayCell({ day: d, onDayClick, }: DayCellProps) {
const theme = useMantineTheme(); const theme = useMantineTheme();
if (!d.isCurrentMonth) { if (!d.isCurrentMonth) {
@@ -400,125 +483,113 @@ function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps)
: "transparent"; : "transparent";
const cellBorder = d.isSelectedDate const cellBorder = d.isSelectedDate
? `1.5px solid ${theme.colors["edr-green"][5]}` ? `2px solid ${theme.colors["edr-green"][5]}`
: d.hasSchedule : d.hasSchedule
? "1px solid #E7E8E5" ? `1px solid ${theme.colors["edr-border"][0]}`
: "none"; : "none";
return ( return (
<Box <Box
onClick={() => d.hasSchedule && onDayClick(d)}
style={{ style={{
height: 92, height: 92,
borderRadius: theme.radius.md, borderRadius: theme.radius.md,
backgroundColor: cellBg, backgroundColor: cellBg,
border: cellBorder, border: cellBorder,
overflow: "hidden", overflow: "hidden",
padding: "4px 6px 6px", padding: "8px 10px 10px",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 4, 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 */} {/* Day number + check icon */}
<Group justify="space-between" align="center" style={{ flexShrink: 0 }}> <Group justify="space-between" align="center" style={{ flexShrink: 0 }}>
<Text <Stack gap={0}>
fz={14} <Text
fw={d.hasSchedule ? 800 : 600} fz={16}
c={ fw={800}
d.isToday && !d.isSelectedDate c={
? "edr-green.6" d.isToday && !d.isSelectedDate
: d.hasSchedule ? "edr-green.6"
? "edr-text.0" : d.hasSchedule
: "edr-muted" ? "edr-text.0"
} : "edr-muted"
> }
{d.day} >
</Text> {d.day}
</Text>
{d.isToday && !d.isSelectedDate && (
<Text fz={9} fw={700} c="edr-green.6" style={{ letterSpacing: "0.04em" }}>
TODAY
</Text>
)}
</Stack>
{d.isSelectedDate && ( {d.isSelectedDate && (
<Check <Box
size={15} style={{
color={theme.colors["edr-green"][5]} width: 24,
strokeWidth={2.5} height: 24,
/> borderRadius: "50%",
backgroundColor: theme.colors["edr-green"][5],
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Check
size={14}
color="white"
strokeWidth={3}
/>
</Box>
)} )}
</Group> </Group>
{/* Departure chips */} {/* Schedule times */}
{d.hasSchedule && ( {d.hasSchedule && (
<Stack gap={3} style={{ flex: 1, overflow: "hidden" }}> <Stack gap={3} style={{ flex: 1, overflow: "hidden", minWidth: 0 }}>
{d.schedules.slice(0, 2).map((s) => { {d.schedules.slice(0, 2).map((s) => (
const isChipSelected = s.id === selectedScheduleId; <Group key={s.id} gap={6} align="center" style={{ minWidth: 0 }}>
const isFull = s.remainingWagons <= 0;
const canSelect = !isFull;
return (
<Box <Box
key={s.id}
onClick={() =>
canSelect && onSelectSchedule(s.id, d.dateString)
}
style={{ style={{
display: "flex", width: 4,
alignItems: "center", height: 4,
gap: 4, borderRadius: "50%",
borderRadius: 7, backgroundColor: theme.colors["edr-green"][5],
padding: "4px 6px", flexShrink: 0,
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"
}`,
}} }}
/>
<Text
fz={12}
fw={700}
c="edr-text.0"
style={{ flex: 1, minWidth: 0 }}
> >
<Box {format(new Date(s.scheduleDate), "HH:mm")}
style={{ </Text>
width: 6, </Group>
height: 6, ))}
borderRadius: "50%", {d.schedules.length > 2 && (
backgroundColor: isChipSelected <Text fz={11} fw={600} c="edr-green.7" style={{ paddingTop: 2 }}>
? "white" +{d.schedules.length - 2} more
: isFull </Text>
? theme.colors["edr-red"][0] )}
: theme.colors["edr-green"][5],
flexShrink: 0,
}}
/>
<Text
fz={11}
fw={700}
style={{ flex: 1, minWidth: 0, overflow: "hidden" }}
truncate
c={
isChipSelected ? "white" : isFull ? "edr-red.0" : "edr-green.7"
}
>
{isFull
? "Full"
: s.trainNumber
? s.trainNumber
: `${s.remainingWagons} wgn`}
</Text>
<ChevronRight
size={12}
color={
isChipSelected
? "white"
: isFull
? theme.colors["edr-muted"][0]
: theme.colors["edr-green"][7]
}
/>
</Box>
);
})}
</Stack> </Stack>
)} )}
</Box> </Box>

View File

@@ -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 { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form"; 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 { import {
BookingFormInputValues, BookingFormInputValues,
type BookingFormValues, type BookingFormValues,
@@ -56,25 +56,24 @@ export function Step4Route({
return true; return true;
}); });
}, [yardOptions, destinationYard]); }, [yardOptions, destinationYard]);
console.log({ yardOptions, originYard, destinationYard });
const destData = useMemo(() => { const destData = useMemo(() => {
return yardOptions.filter((o) => o.value !== originYard); return yardOptions.filter((o) => o.value !== originYard);
}, [yardOptions, originYard]); }, [yardOptions, originYard]);
const direction = getRouteDirection( const origin = referenceData?.yard.find((y) => y.id === originYard);
referenceData?.yard.find((y) => y.id === originYard), const dest = referenceData?.yard.find((y) => y.id === destinationYard);
referenceData?.yard.find((y) => y.name === destinationYard), const direction = getRouteDirection(origin, dest);
); console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
const directionStyle: Record<string, string> = { const directionStyle: Record<string, string> = {
export: "bg-sky-50 text-sky-800 border-sky-200", EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
import: "bg-amber-50 text-amber-800 border-amber-200", IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
domestic: "bg-gray-100 text-gray-600 border-gray-200", DOMESTIC: "bg-gray-100 text-gray-600 border-gray-200",
}; };
const directionLabel: Record<string, string> = { const directionLabel: Record<string, string> = {
export: "Export workflow (inside country to outside country)", EXPORT: "Export workflow (inside country to outside country)",
import: "Import workflow (outside country to inside country)", IMPORT: "Import workflow (outside country to inside country)",
domestic: "Domestic corridor", DOMESTIC: "Domestic corridor",
}; };
useEffect(() => { useEffect(() => {

View File

@@ -1,5 +1,17 @@
import { Controller, type UseFormReturn } from "react-hook-form"; 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 { import {
BookingFormInputValues, BookingFormInputValues,
BOOKING_DOCS_SETTING, BOOKING_DOCS_SETTING,
@@ -8,6 +20,7 @@ import {
} from "./schema"; } from "./schema";
import { StepHeader } from "./shared"; import { StepHeader } from "./shared";
import type { Freight } from "@/types"; import type { Freight } from "@/types";
import type { GeneratePriceResponse } from "@/services/bookings.service";
type BookingForm = UseFormReturn< type BookingForm = UseFormReturn<
BookingFormInputValues, BookingFormInputValues,
@@ -20,19 +33,32 @@ export function Step8Review({
setStep, setStep,
direction, direction,
referenceData, referenceData,
pricingPhase = "idle",
pricingData,
onConfirm,
onContinueLater,
onAbort,
confirmPending = false,
abortPending = false,
}: { }: {
form: BookingForm; form: BookingForm;
setStep: (step: number) => void; setStep: (step: number) => void;
direction: Freight.ScheduleTradeDirection; direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData; 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 values = form.watch();
const errors = form.formState.errors;
const serviceType = referenceData?.service.find( const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId, (s) => s.id === values.serviceTypeId,
); );
function Row({ function CompactRow({
label, label,
value, value,
target, target,
@@ -42,19 +68,19 @@ export function Step8Review({
target: number; target: number;
}) { }) {
return ( return (
<div className="flex items-start justify-between gap-4 py-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0 flex-1">
<Text size="xs" c="dimmed"> <Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
{label} {label}
</Text> </Text>
<Text size="sm" fw={500} mt={2} className="truncate"> <Text size="sm" fw={500} className="truncate">
{value || "—"} {value || "—"}
</Text> </Text>
</div> </div>
<button <button
type="button" type="button"
onClick={() => setStep(target)} onClick={() => setStep(target)}
className="shrink-0 text-xs font-medium text-emerald-600 hover:underline" className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
> >
Edit Edit
</button> </button>
@@ -62,6 +88,28 @@ export function Step8Review({
); );
} }
function CompactCard({
icon: Icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<Card radius="md" p="sm" withBorder className="border-gray-200 bg-white hover:shadow-sm transition-shadow">
<Group gap="xs" mb="xs" wrap="nowrap">
<Box c="edr-green">{Icon}</Box>
<Text size="xs" fw={700} tt="uppercase" c="dimmed" className="tracking-wider">
{title}
</Text>
</Group>
<Stack gap="xs">{children}</Stack>
</Card>
);
}
const containerSummary = const containerSummary =
values.cargoType === "container" && values.containers.length > 0 values.cargoType === "container" && values.containers.length > 0
? values.containers ? values.containers
@@ -95,146 +143,211 @@ export function Step8Review({
return child ? `${group.name}${child.name}` : group.name; return child ? `${group.name}${child.name}` : group.name;
})(); })();
function ReviewCard({ const originYardName = referenceData?.yard.find(
title, (y) => y.id === values.originYard,
children, )?.name ?? values.originYard;
}: {
title: string; const destinationYardName = referenceData?.yard.find(
children: React.ReactNode; (y) => y.id === values.destinationYard,
}) { )?.name ?? values.destinationYard;
return (
<Card radius="lg" withBorder p={0} className="overflow-hidden">
<Box
px="md"
py="sm"
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
>
<Text
size="xs"
fw={600}
tt="uppercase"
c="dimmed"
className="tracking-wider"
>
{title}
</Text>
</Box>
<Box px="md" py="xs" className="divide-y divide-gray-100">
{children}
</Box>
</Card>
);
}
return ( return (
<div className="space-y-6"> <Stack gap="md">
<StepHeader <StepHeader
title="Review & Submit" title="Review & Submit"
description="Confirm your contract request before sending it for EDR staff review." description="Confirm your contract request before sending it for EDR staff review."
/> />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md"> {/* Pricing Card - Prominent at top */}
<ReviewCard title="Contract & Service"> {pricingPhase === "generating" && (
<Row <Card radius="lg" withBorder p="lg" className="border-edr-green border-2">
<Group justify="center" py="lg">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Generating price estimate
</Text>
</Group>
</Card>
)}
{pricingPhase === "ready" && pricingData && (
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2 bg-gradient-to-br from-white to-emerald-50/30">
<Stack gap="sm">
<Text size="sm" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
💳 Price Breakdown
</Text>
<Stack gap="xs">
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between" py={2}>
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
<Divider my="xs" />
<Group justify="space-between" py={2}>
<Text fw={700} size="md">
Total
</Text>
<Text fw={800} size="lg" c="edr-green">
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
</Text>
</Group>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" mt="xs" p="xs" className="bg-orange-50 rounded">
{pricingData.warnings.join(", ")}
</Text>
)}
<Group mt="md">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
onClick={onConfirm}
loading={confirmPending}
className="flex-1"
>
{confirmPending ? "Confirming…" : "Confirm"}
</Button>
<Button
variant="outline"
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={onContinueLater}
className="flex-1"
>
Continue later
</Button>
<Button
variant="outline"
color="red"
radius="md"
leftSection={!abortPending ? <XCircle size={16} /> : undefined}
onClick={onAbort}
loading={abortPending}
>
Abort
</Button>
</Group>
</Stack>
</Card>
)}
{/* Review Details - Compact Cards Grid */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="sm" mt="md">
<CompactCard icon={<Package size={16} />} title="Contract & Service">
<CompactRow
label="Type" label="Type"
value={values.contractType === "new" ? "New Contract" : "Renewal"} value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1} target={1}
/> />
<Row label="Service" value={serviceType?.name ?? ""} target={2} /> <CompactRow label="Service" value={serviceType?.name ?? ""} target={2} />
</ReviewCard> </CompactCard>
<ReviewCard title="First & Last Mile"> <CompactCard icon={<Route size={16} />} title="Route">
<Row <CompactRow
label="Origin → Destination"
value={`${originYardName}${destinationYardName}`}
target={3}
/>
<CompactRow
label="Workflow"
value={
direction ? direction.charAt(0).toUpperCase() + direction.slice(1) : ""
}
target={3}
/>
</CompactCard>
<CompactCard icon={<Truck size={16} />} title="Logistics">
<CompactRow
label="First Mile" label="First Mile"
value={ value={
values.firstMile.enabled values.firstMile.enabled ? values.firstMile.pickUpAddress : "Not requested"
? values.firstMile.pickUpAddress
: "Not requested"
} }
target={2} target={2}
/> />
<Row <CompactRow
label="Last Mile" label="Last Mile"
value={ value={
values.lastMile.enabled values.lastMile.enabled ? values.lastMile.deliveryAddress : "Not requested"
? values.lastMile.deliveryAddress
: "Not requested"
} }
target={2} target={2}
/> />
<Row <CompactRow
label="Equipment Return" label="Equipment Return"
value={ value={
values.equipmentReturn === "with_return" values.equipmentReturn === "with_return" ? "With Return" : "Without Return"
? "With Return"
: "Without Return"
} }
target={2} target={2}
/> />
<Row <CompactRow
label="Customs Clearing" label="Customs Clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"} value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
target={2} target={2}
/> />
</ReviewCard> </CompactCard>
<ReviewCard title="Route & Cargo"> <CompactCard icon={<Package size={16} />} title="Cargo Details">
<Row <CompactRow
label="Route"
value={`${values.originYard}${values.destinationYard}`}
target={3}
/>
<Row
label="Workflow"
value={
direction
? direction.charAt(0).toUpperCase() + direction.slice(1)
: ""
}
target={3}
/>
<Row
label="Weight (VGM)" label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""} value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4} target={4}
/> />
<Row label="Cargo" value={cargoValue} target={4} /> <CompactRow label="Cargo Type" value={cargoValue} target={4} />
<Row <CompactRow
label="Modifiers" label="Modifiers"
value={ value={
[ [values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
values.isHazardous && "Hazardous",
values.isRefrigerated && "Refrigerated",
]
.filter(Boolean) .filter(Boolean)
.join(", ") || "None" .join(", ") || "None"
} }
target={3} target={3}
/> />
</ReviewCard> </CompactCard>
<ReviewCard title="Container & Wagons"> <CompactCard icon={<Package size={16} />} title="Containers">
<Row label="Containers" value={containerSummary || "—"} target={4} /> <CompactRow
<Row label="Count & Type"
label="Total VGM" value={containerSummary || "—"}
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
target={4} target={4}
/> />
</ReviewCard> <CompactRow
label="Total VGM"
<ReviewCard title="Documents"> value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
<Row target={4}
label="Attached"
value={
docsAttached > 0
? `${docsAttached} of ${docsTotal} attached`
: "None — upload later from the booking page"
}
target={5}
/> />
</ReviewCard> </CompactCard>
<CompactCard icon={<FileText size={16} />} title="Documents">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
Attached
</Text>
<Text size="sm" fw={500}>
{docsAttached > 0
? `${docsAttached} of ${docsTotal}`
: "None"}
</Text>
</div>
<button
type="button"
onClick={() => setStep(5)}
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
>
Edit
</button>
</div>
</CompactCard>
</SimpleGrid> </SimpleGrid>
{/* Notes */}
<Controller <Controller
name="notes" name="notes"
control={form.control} control={form.control}
@@ -243,35 +356,13 @@ export function Step8Review({
{...field} {...field}
id="notes" id="notes"
label="Additional Notes" label="Additional Notes"
placeholder="Any special instructions or notes for EDR operations..." placeholder="Any special instructions or notes for EDR operations"
rows={3} rows={2}
radius="md" radius="md"
size="sm"
/> />
)} )}
/> />
</Stack>
<Controller
name="termsAccepted"
control={form.control}
render={({ field, fieldState }) => (
<Checkbox
label={
<Text size="sm" c="dimmed">
I confirm the information is accurate and agree to EDR's{" "}
<Text component="span" c="edr-green" fw={500}>
freight contract terms and conditions
</Text>
.
</Text>
}
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
error={fieldState.error?.message ?? errors.termsAccepted?.message}
color="edr-green"
radius="sm"
/>
)}
/>
</div>
); );
} }

View File

@@ -17,8 +17,9 @@ import {
import { api } from "@/services/api"; import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput"; import PhoneInput from "@/components/auth/PhoneInput";
import type { ProfileResponse } from "@/types/profile"; 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"), companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"), companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"), 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"), fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
}); });
type FormData = z.infer<typeof schema>; export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
function splitPhone(fullPhone?: string | null) { export function splitPhone(fullPhone?: string | null) {
if (!fullPhone) return { code: "+251", number: "" }; if (!fullPhone) return { code: "+251", number: "" };
const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
if (match) return { code: match[1], number: match[2] }; if (match) return { code: match[1], number: match[2] };
return { code: "+251", number: fullPhone }; return { code: "+251", number: fullPhone };
} }
export default function TabCompanyProfile({ profile }: { profile: ProfileResponse }) { interface TabCompanyProfileProps {
const queryClient = useQueryClient(); profile?: ProfileResponse;
mode?: "edit" | "create";
onCreateSuccess?: () => void;
}
const defaultValues = useMemo((): FormData => { export default function TabCompanyProfile({
const phone = splitPhone(profile.companyPhone); 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 { return {
companyName: profile.companyName, companyName: "",
companyEmail: profile.companyEmail ?? "", companyEmail: "",
companyPhone: phone.number, companyPhone: "",
companyPhoneCountryCode: phone.code, companyPhoneCountryCode: "+251",
companyLocation: profile.companyLocation, companyLocation: "",
companyAddress: profile.companyAddress ?? "", companyAddress: "",
tinNumber: profile.tinNumber, tinNumber: "",
fanNumber: profile.fanNumber ?? "", fanNumber: "",
}; };
}, [profile]); }, [profile]);
@@ -60,14 +84,15 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
handleSubmit, handleSubmit,
reset, reset,
formState: { errors, isDirty }, formState: { errors, isDirty },
} = useForm<FormData>({ } = useForm<CompanyProfileFormData>({
resolver: zodResolver(schema), resolver: zodResolver(COMPANY_PROFILE_SCHEMA),
values: defaultValues, values: defaultValues,
}); });
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data: FormData) => mutationFn: async (data: CompanyProfileFormData) => {
api.companies.updateProfile.call({ const payload: CreateCompanyPayload = {
companyType: "customer",
companyName: data.companyName, companyName: data.companyName,
companyEmail: data.companyEmail, companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
@@ -75,13 +100,25 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
companyAddress: data.companyAddress, companyAddress: data.companyAddress,
tin: data.tinNumber, tin: data.tinNumber,
fanNumber: data.fanNumber, fanNumber: data.fanNumber,
}), };
if (isCreate) {
return api.companies.create.call(payload);
} else {
return api.companies.updateProfile.call(payload);
}
},
onSuccess: () => { 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 ( return (
<Card padding="lg"> <Card padding="lg">
@@ -90,7 +127,9 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
<Title order={3}>Company Profile</Title> <Title order={3}>Company Profile</Title>
</Group> </Group>
<Text c="edr-muted" size="sm" mb="lg"> <Text c="edr-muted" size="sm" mb="lg">
Edit your company registration details {isCreate
? "Enter your company registration details to get started"
: "Edit your company registration details"}
</Text> </Text>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
@@ -115,7 +154,10 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
<Grid.Col span={6}> <Grid.Col span={6}>
<PhoneInput <PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }} countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{ ...register("companyPhone"), placeholder: "912345678" }} phone={{
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode} countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone} phoneError={errors.companyPhone}
label="Company Phone" label="Company Phone"
@@ -171,34 +213,40 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }} style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
> >
<Group gap="xs"> <Group gap="xs">
{mutation.isSuccess && ( {mutation.isSuccess && !isCreate && (
<Group gap={6} c="green"> <Group gap={6} c="green">
<CheckCircle2 size={16} /> <CheckCircle2 size={16} />
<Text size="sm" fw={500}>Saved successfully</Text> <Text size="sm" fw={500}>
Saved successfully
</Text>
</Group> </Group>
)} )}
{mutation.isError && ( {mutation.isError && (
<Group gap={6} c="red"> <Group gap={6} c="red">
<XCircle size={16} /> <XCircle size={16} />
<Text size="sm" fw={500}>Save failed</Text> <Text size="sm" fw={500}>
{isCreate ? "Failed to create profile" : "Save failed"}
</Text>
</Group> </Group>
)} )}
</Group> </Group>
<Group gap="md"> <Group gap="md">
<Button {!isCreate && (
type="button" <Button
variant="outline" type="button"
disabled={mutation.isPending || !isDirty} variant="outline"
onClick={() => reset()} disabled={mutation.isPending || !isDirty}
> onClick={() => reset()}
Reset >
</Button> Reset
</Button>
)}
<Button <Button
type="submit" type="submit"
leftSection={<Save size={16} />} leftSection={<Save size={16} />}
loading={mutation.isPending} loading={mutation.isPending}
> >
Save Changes {isCreate ? "Continue" : "Save Changes"}
</Button> </Button>
</Group> </Group>
</Group> </Group>

View File

@@ -32,7 +32,13 @@ function splitPhone(fullPhone?: string | null) {
return { code: "+251", number: fullPhone }; 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 queryClient = useQueryClient();
const defaultValues = useMemo((): FormData => { const defaultValues = useMemo((): FormData => {
@@ -62,6 +68,7 @@ export default function TabContactPerson({ profile }: { profile: ProfileResponse
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
if (mode === "onboarding") onContinue?.();
}, },
}); });
@@ -116,20 +123,22 @@ export default function TabContactPerson({ profile }: { profile: ProfileResponse
)} )}
</Group> </Group>
<Group gap="md"> <Group gap="md">
<Button {mode === "edit" && (
type="button" <Button
variant="outline" type="button"
disabled={mutation.isPending || !isDirty} variant="outline"
onClick={() => reset()} disabled={mutation.isPending || !isDirty}
> onClick={() => reset()}
Reset >
</Button> Reset
</Button>
)}
<Button <Button
type="submit" type="submit"
leftSection={<Save size={16} />} leftSection={<Save size={16} />}
loading={mutation.isPending} loading={mutation.isPending}
> >
Save Changes {mode === "onboarding" ? "Continue" : "Save Changes"}
</Button> </Button>
</Group> </Group>
</Group> </Group>

View File

@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowRight,
CheckCircle2, CheckCircle2,
FileCheck, FileCheck,
Loader2, Loader2,
@@ -20,7 +21,13 @@ import { companiesService } from "@/services/companies.service";
import { SmartFileInput } from "@edr/ui-common"; import { SmartFileInput } from "@edr/ui-common";
import type { ProfileResponse } from "@/types/profile"; 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 queryClient = useQueryClient();
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({}); const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
@@ -75,7 +82,9 @@ export default function TabDocuments({ profile }: { profile: ProfileResponse })
{docUploadMutation.isSuccess && ( {docUploadMutation.isSuccess && (
<Group gap={6} c="green"> <Group gap={6} c="green">
<CheckCircle2 size={16} /> <CheckCircle2 size={16} />
<Text size="sm" fw={500}>Documents uploaded successfully</Text> <Text size="sm" fw={500}>
{mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"}
</Text>
</Group> </Group>
)} )}
{docUploadMutation.isError && ( {docUploadMutation.isError && (
@@ -85,14 +94,37 @@ export default function TabDocuments({ profile }: { profile: ProfileResponse })
</Group> </Group>
)} )}
</Group> </Group>
<Button {mode === "onboarding" ? (
type="button" <Button
leftSection={<UploadCloud size={16} />} type="button"
loading={docUploadMutation.isPending} leftSection={<ArrowRight size={16} />}
onClick={() => docUploadMutation.mutate(documentFiles)} loading={docUploadMutation.isPending}
> onClick={() => {
Upload Documents const hasFiles = Object.values(documentFiles).some((f) => f !== null);
</Button> if (hasFiles) {
docUploadMutation.mutate(documentFiles, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
onContinue?.();
},
});
} else {
onContinue?.();
}
}}
>
Continue
</Button>
) : (
<Button
type="button"
leftSection={<UploadCloud size={16} />}
loading={docUploadMutation.isPending}
onClick={() => docUploadMutation.mutate(documentFiles)}
>
Upload Documents
</Button>
)}
</Group> </Group>
)} )}
</Card> </Card>

View File

@@ -34,7 +34,13 @@ function splitPhone(fullPhone?: string | null) {
return { code: "+251", number: fullPhone }; 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 queryClient = useQueryClient();
const defaultValues = useMemo((): FormData => { const defaultValues = useMemo((): FormData => {
@@ -66,6 +72,7 @@ export default function TabGeneralManager({ profile }: { profile: ProfileRespons
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
if (mode === "onboarding") onContinue?.();
}, },
}); });
@@ -133,20 +140,22 @@ export default function TabGeneralManager({ profile }: { profile: ProfileRespons
)} )}
</Group> </Group>
<Group gap="md"> <Group gap="md">
<Button {mode === "edit" && (
type="button" <Button
variant="outline" type="button"
disabled={mutation.isPending || !isDirty} variant="outline"
onClick={() => reset()} disabled={mutation.isPending || !isDirty}
> onClick={() => reset()}
Reset >
</Button> Reset
</Button>
)}
<Button <Button
type="submit" type="submit"
leftSection={<Save size={16} />} leftSection={<Save size={16} />}
loading={mutation.isPending} loading={mutation.isPending}
> >
Save Changes {mode === "onboarding" ? "Continue" : "Save Changes"}
</Button> </Button>
</Group> </Group>
</Group> </Group>

View File

@@ -36,11 +36,17 @@ function splitPhone(fullPhone?: string | null) {
return { code: "+251", number: fullPhone }; return { code: "+251", number: fullPhone };
} }
interface TabPowerOfAttorneyProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
export default function TabPowerOfAttorney({ export default function TabPowerOfAttorney({
profile, profile,
}: { mode = "edit",
profile: ProfileResponse; onContinue,
}) { }: TabPowerOfAttorneyProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const defaultValues = useMemo((): FormData => { const defaultValues = useMemo((): FormData => {
@@ -49,7 +55,7 @@ export default function TabPowerOfAttorney({
poaName: profile.poaName ?? "", poaName: profile.poaName ?? "",
poaEmail: profile.poaEmail ?? "", poaEmail: profile.poaEmail ?? "",
poaPhone: phone.number, poaPhone: phone.number,
poaPhoneCountryCode: profile.poaPhone ? phone.code : "", poaPhoneCountryCode: profile.poaPhone ? phone.code : "+251",
poaLocation: profile.poaLocation ?? "", poaLocation: profile.poaLocation ?? "",
poaAddress: profile.poaAddress ?? "", poaAddress: profile.poaAddress ?? "",
}; };
@@ -81,6 +87,7 @@ export default function TabPowerOfAttorney({
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(), queryKey: api.companies.getProfile.queryKey(),
}); });
if (mode === "onboarding") onContinue?.();
}, },
}); });
@@ -99,11 +106,6 @@ export default function TabPowerOfAttorney({
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md"> <Stack gap="md">
<Text c="edr-muted" size="sm">
Power of Attorney details are optional. Fill them in if you have an
authorized representative, or leave blank.
</Text>
<TextInput <TextInput
label="PoA Full Name" label="PoA Full Name"
placeholder="Authorized Representative Name" placeholder="Authorized Representative Name"
@@ -177,20 +179,22 @@ export default function TabPowerOfAttorney({
)} )}
</Group> </Group>
<Group gap="md"> <Group gap="md">
<Button {mode === "edit" && (
type="button" <Button
variant="outline" type="button"
disabled={mutation.isPending || !isDirty} variant="outline"
onClick={() => reset()} disabled={mutation.isPending || !isDirty}
> onClick={() => reset()}
Reset >
</Button> Reset
</Button>
)}
<Button <Button
type="submit" type="submit"
leftSection={<Save size={16} />} leftSection={<Save size={16} />}
loading={mutation.isPending} loading={mutation.isPending}
> >
Save Changes {mode === "onboarding" ? "Continue" : "Save Changes"}
</Button> </Button>
</Group> </Group>
</Group> </Group>

View File

@@ -23,6 +23,11 @@ export interface ContractView {
signedAt: string; signedAt: string;
signatureImageUrl?: string | null; signatureImageUrl?: string | null;
}>; }>;
/** Current viewer's reusable saved signature, if they have one. */
savedSignature?: {
signerDisplayName: string;
signatureImageUrl?: string | null;
} | null;
} }
export interface PriceLineItem { export interface PriceLineItem {

View File

@@ -1,4 +1,5 @@
import { URL_CONSTANTS } from "@/constants/URLS"; import { URL_CONSTANTS } from "@/constants/URLS";
import { API_BASE_URL } from "@/constants/apiConfig";
import { client } from "../utils/api"; import { client } from "../utils/api";
const P = URL_CONSTANTS.PAYMENTS; const P = URL_CONSTANTS.PAYMENTS;
@@ -57,7 +58,7 @@ function buildCheckoutUrl(payload: {
method: PaymentMethod; method: PaymentMethod;
platform?: PaymentPlatform; platform?: PaymentPlatform;
}): string { }): string {
const base = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, ""); const base = API_BASE_URL.replace(/\/$/, "");
const params = new URLSearchParams({ const params = new URLSearchParams({
bookingId: payload.bookingId, bookingId: payload.bookingId,
method: payload.method, method: payload.method,

View File

@@ -0,0 +1,28 @@
import { client } from "../utils/api";
const SIGNATURE_URL = "/api/me/signature";
export interface SavedSignature {
signerDisplayName: string;
signatureImageUrl?: string | null;
}
export interface SaveSignaturePayload {
signerDisplayName: string;
signatureImageBase64: string;
}
export const signaturesService = {
/** The current user's reusable saved signature, or null if none. */
getMySignature: async (): Promise<SavedSignature | null> => {
const { data } = await client.get(SIGNATURE_URL);
return (data.data ?? data) ?? null;
},
saveMySignature: async (
payload: SaveSignaturePayload,
): Promise<SavedSignature | null> => {
const { data } = await client.put(SIGNATURE_URL, payload);
return (data.data ?? data) ?? null;
},
};

View File

@@ -1,9 +1,10 @@
import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query"; import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { URL_CONSTANTS } from "@/constants/URLS"; import { URL_CONSTANTS } from "@/constants/URLS";
import { API_BASE_URL } from "@/constants/apiConfig";
const client = axios.create({ const client = axios.create({
baseURL: import.meta.env.VITE_API_URL, baseURL: API_BASE_URL,
}); });
function getCookie(name: string): string | undefined { function getCookie(name: string): string | undefined {

View File

@@ -1,21 +1,38 @@
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { defineConfig } from "vite"; import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); 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({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": 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: { server: {
port: 5173, port: 5173,
host: "0.0.0.0", host: "0.0.0.0",
}, },
test: {
environment: "node",
},
}); });

View File

@@ -37,4 +37,3 @@ module.exports = {
}, },
}, },
plugins: [], plugins: [],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Some files were not shown because too many files have changed in this diff Show More