This commit is contained in:
Roba Boru
2026-06-17 15:07:38 +03:00
111 changed files with 4428 additions and 2219 deletions

View File

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

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

3
.gitignore vendored
View File

@@ -28,3 +28,6 @@ coverage/
*~
\#*\#
.\#*
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat

View File

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

View File

@@ -48,6 +48,7 @@ import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from './modules/wagons/wagons.module';
@@ -121,6 +122,7 @@ import { OverviewModule } from './modules/overview/overview.module';
PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {
@@ -133,6 +135,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { }
async onApplicationBootstrap() {
@@ -144,5 +147,8 @@ export class AppModule implements OnApplicationBootstrap {
await this.demoBookingsSeeder.run();
await this.pricingDataSeeder.run();
await this.fileUploadSettingsSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
await this.demoFreightDataSeeder.run();
}
}

View File

@@ -21,3 +21,10 @@ export const TrainSchedulingView = () =>
export const TrainSchedulingManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view);
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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> {
const booking = await this.datasource
.getRepository(Booking)

View File

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

View File

@@ -1,7 +1,9 @@
import { Injectable } from '@nestjs/common';
import { Readable } from 'stream';
import { DataSource } from 'typeorm';
import { FilesService } from '../files/files.service';
import { FileRecord } from '../files/entities/file.entity';
import { MinioService } from '../minio/minio.service';
import { SignaturesRepository } from './signatures.repository';
import { SavedSignature } from './entities/saved-signature.entity';
@@ -19,6 +21,7 @@ export class SignaturesService {
private readonly signaturesRepository: SignaturesRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly dataSource: DataSource,
) {}
/** Saved signature for a user, with the image inlined as a data URL (or null). */
@@ -47,18 +50,32 @@ export class SignaturesService {
path: '',
};
const fileRecord = await this.filesService.upsertByCode({
// Capture the previously referenced file so we can remove it only AFTER the
// saved_signatures row is repointed — deleting it first would violate the
// FK constraint (saved_signatures.signature_file_id -> files.id).
const existing = await this.signaturesRepository.findByUserId(input.userId);
const previousFileId = existing?.signatureFileId ?? null;
const fileRecord = await this.filesService.upload({
resourceId: input.userId,
resource: 'saved_signatures',
code: 'signature',
file,
});
return this.signaturesRepository.upsert({
const saved = await this.signaturesRepository.upsert({
userId: input.userId,
signerDisplayName: input.signerDisplayName,
signatureFileId: fileRecord.id,
});
if (previousFileId && previousFileId !== fileRecord.id) {
await this.dataSource
.getRepository(FileRecord)
.delete({ id: previousFileId });
}
return saved;
}
private async inlineImageUrl(

View File

@@ -96,7 +96,8 @@ export class TrainSchedulingController {
}
@Get("bookable-schedules")
// @TrainSchedulingView()
// No staff guard: customers hit this while creating a booking to find OPEN
// same-route schedules. Do not attach train_scheduling permissions here.
@ApiOperation({
summary: "OPEN same-route schedules a new booking can target",
})

View File

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

View File

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

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" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff],
},
{
key: "edr_operations_officer",
name: { en: "EDR Operations Officer" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsOfficer],
},
{
key: "edr_director",
name: { en: "EDR Director" },

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-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
];
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
@@ -109,6 +112,11 @@ export const FREIGHT_PERMS = {
view: 'edr_freight_app:train_scheduling:view',
manage: 'edr_freight_app:train_scheduling:manage',
},
fleet: {
view: 'edr_freight_app:fleet:view',
manage: 'edr_freight_app:fleet:manage',
},
admin: 'edr_freight_app:admin',
ruleEngine: {
view: (slug: RuleEngineResourceSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
@@ -121,6 +129,9 @@ const allRuleEngineViewKeys = () =>
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
export const ROLE_PERMISSION_PRESETS = {
// Marketing / line staff: drives a booking from intake through line-staff
// approval and contract generation/signing — i.e. until the contract is ready
// and signed. No director/CEO approval, no scheduling, no operations.
lineStaff: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.staffAccept,
@@ -129,8 +140,17 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel,
...allRuleEngineViewKeys(),
],
// Operations Officer: train scheduling + wagon allocation + transit/complete
// + fleet management (wagons, trains, locomotives, routes, containers, cargo).
operationsOfficer: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.operations,
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.trainScheduling.manage,
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
...allRuleEngineViewKeys(),
],
director: [
@@ -147,8 +167,15 @@ export const ROLE_PERMISSION_PRESETS = {
...allRuleEngineViewKeys(),
],
finance: [FREIGHT_PERMS.bookings.view],
// Marketing handles intake through contract (same as line staff here).
marketing: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.staffAccept,
FREIGHT_PERMS.bookings.requestChanges,
FREIGHT_PERMS.bookings.reject,
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.bookings.generateContract,
FREIGHT_PERMS.bookings.signStaff,
],

View File

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

View File

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

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

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;
name: string;
mimeType?: string;
code?: string;
}
export interface BookingDetailView {

View File

@@ -17,3 +17,4 @@ export * from "./BookingRouteServiceCard";
export * from "./BookingMileServicesCard";
export * from "./BookingCargoCard";
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 { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
import { API_BASE_URL } from '@/constants/apiConfig';
interface Cargo {
id: string;
@@ -32,8 +33,6 @@ interface CargoFormDialogProps {
onSuccess?: () => void;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function CargoFormDialog({
open,
onOpenChange,

View File

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

View File

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

View File

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

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,
startTransit: FREIGHT_PERMS.bookings.operations,
complete: FREIGHT_PERMS.bookings.operations,
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
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",
cancel: "edr_freight_app:bookings:cancel",
},
trainScheduling: {
view: "edr_freight_app:train_scheduling:view",
manage: "edr_freight_app:train_scheduling:manage",
},
fleet: {
view: "edr_freight_app:fleet:view",
manage: "edr_freight_app:fleet:manage",
},
admin: "edr_freight_app:admin",
} as const;
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
@@ -70,6 +79,22 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view);
}
export function canViewScheduling(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
}
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.trainScheduling.manage);
}
export function canViewFleet(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.fleet.view);
}
export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.admin);
}
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
}

View File

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

View File

@@ -24,11 +24,25 @@ import {
BookingRouteServiceCard,
BookingMileServicesCard,
BookingCargoCard,
BookingCompanyCard,
BookingContractSummaryCard,
BookingDocumentsCard,
type BookingFileView,
} from "@/components/bookings/detail";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { downloadBookingFile } from "@/services/files.service";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
import toast from "react-hot-toast";
// Signature / generated-contract files are surfaced on the contract page, not
// in the booking's Documents list.
const SIGNATURE_FILE_CODES = new Set([
"signature",
"signature_customer",
"signature_staff",
"contract",
]);
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -36,6 +50,14 @@ export default function BookingRequestDetailPage() {
const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
const mutations = useBookingMutations(id ?? "");
const handleDownloadFile = async (file: BookingFileView) => {
try {
await downloadBookingFile(file.id, file.name);
} catch {
toast.error("Could not download file.");
}
};
if (isLoading) {
return (
<Box style={detailStyles.page}>
@@ -147,6 +169,12 @@ export default function BookingRequestDetailPage() {
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
)}
onDownload={handleDownloadFile}
/>
</Stack>
</Grid.Col>
@@ -154,6 +182,7 @@ export default function BookingRequestDetailPage() {
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingPricingSummary booking={booking} />
<BookingActionsToolbar booking={booking} mutations={mutations} />
{showContractButton && (

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

View File

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

View File

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

View File

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

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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile";
import {
Alert,
Card,
Center,
Container,
Group,
Stack,
Title,
Text,
Tabs,
Card,
TextInput,
Button,
Badge,
Alert,
Center,
Loader,
Grid,
Tabs,
Text,
Title,
} from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
Building2,
Briefcase,
CheckCircle2,
Building2,
FileCheck,
Save,
User,
UserCheck,
XCircle,
} from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { api } from "@/services/api";
import type { CreateCompanyPayload } from "@/services/companies.service";
import PhoneInput from "@/components/auth/PhoneInput";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import TabCompanyProfile from "./settings/TabCompanyProfile";
import TabContactPerson from "./settings/TabContactPerson";
import TabDocuments from "./settings/TabDocuments";
import TabGeneralManager from "./settings/TabGeneralManager";
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
import TabDocuments from "./settings/TabDocuments";
type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents";
function tabIncomplete(tabId: SettingsTab, profile?: ProfileResponse | null): boolean {
if (!profile) return false;
switch (tabId) {
case "company":
return !profile.companyEmail || !profile.companyPhone || !profile.companyAddress || !profile.fanNumber;
case "contact":
return !profile.contactPersonName || !profile.contactPersonPhone;
case "gm":
return !profile.generalManagerName || !profile.generalManagerEmail || !profile.generalManagerPhone;
case "poa":
return false;
case "documents":
return false;
}
}
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "company", label: "Company Profile", icon: <Building2 size={16} /> },
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
@@ -50,10 +55,12 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
];
export default function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const tab = (searchParams.get("tab") as SettingsTab) || "company";
const setTab = (t: SettingsTab) => {
const setTab = useCallback(
(t: SettingsTab) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
@@ -62,59 +69,54 @@ export default function SettingsPage() {
},
{ replace: true },
);
};
},
[setSearchParams],
);
const profileQuery = useQuery(api.companies.getProfile.queryOptions());
const profileQuery = useQuery(
api.companies.getProfile.queryOptions({
retry: false,
refetchOnWindowFocus: false,
}),
);
const profile = profileQuery.data;
const createCompanyMutation = useMutation({
mutationFn: (payload: CreateCompanyPayload) =>
api.companies.create.call(payload),
onSuccess: () => {
useEffect(() => {
if (profileQuery.dataUpdatedAt > 0) {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
},
queryKey: api.companies.getInfo.queryKey(),
});
}
}, [profileQuery.dataUpdatedAt, queryClient]);
const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"),
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
});
const [isOnboarding, setIsOnboarding] = useState<boolean | null>(null);
type OnboardingFormData = z.infer<typeof onboardingSchema>;
useEffect(() => {
if (profileQuery.isFetched && isOnboarding === null) {
setIsOnboarding(!profileQuery.data);
}
}, [profileQuery.isFetched, profileQuery.data, isOnboarding]);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<OnboardingFormData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
companyPhoneCountryCode: "+251",
},
});
const handleOnboardingSuccess = useCallback(() => {
setTab("contact");
}, [setTab]);
const onSubmitOnboarding = (data: OnboardingFormData) => {
const payload: CreateCompanyPayload = {
companyType: "customer",
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: data.tinNumber,
fanNumber: data.fanNumber,
};
createCompanyMutation.mutate(payload);
};
const handleContactContinue = useCallback(() => {
setTab("gm");
}, [setTab]);
const handleGMContinue = useCallback(() => {
setTab("poa");
}, [setTab]);
const handlePOAContinue = useCallback(() => {
setTab("documents");
}, [setTab]);
const handleDocumentsContinue = useCallback(() => {
navigate("/portal");
}, [navigate]);
if (profileQuery.isPending) {
return (
@@ -124,25 +126,54 @@ export default function SettingsPage() {
);
}
const onboarding = isOnboarding === true;
const renderProfileContent = (children: React.ReactNode) => {
if (onboarding && tab !== "company" && !profile) {
return (
<Container size="xl" py="xl">
<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 (
<Container size="xl" px="lg">
<Group justify="space-between" mb="xl">
<div>
<Title order={1} size="h2">
Account Settings
{onboarding ? "Complete Your Profile" : "Account Settings"}
</Title>
<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>
</div>
{profile && <Badge color="edr-green">Verified</Badge>}
</Group>
<Tabs
value={tab}
onChange={(value) => {
if (!value) return;
if (!profile && value !== "company") return;
// if (onboarding) return;
setTab(value as SettingsTab);
}}
>
@@ -152,7 +183,12 @@ export default function SettingsPage() {
key={t.id}
value={t.id}
leftSection={t.icon}
disabled={!profile && t.id !== "company"}
disabled={!onboarding && !profile && t.id !== "company"}
rightSection={
!onboarding && profile && tabIncomplete(t.id, profile) ? (
<AlertCircle size={14} color="red" />
) : undefined
}
>
{t.label}
</Tabs.Tab>
@@ -161,201 +197,52 @@ export default function SettingsPage() {
<Tabs.Panel value="company">
{!profile ? (
<Card padding="lg">
<Stack gap="md">
<Group gap="sm">
<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")}
<TabCompanyProfile
mode="create"
onCreateSuccess={handleOnboardingSuccess}
/>
<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 value="contact">
{profile ? (
<TabContactPerson profile={profile} />
) : (
<Card padding="xl">
<Center>
<Alert
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
{renderProfileContent(
<TabContactPerson
profile={profile!}
mode={onboarding ? "onboarding" : "edit"}
onContinue={handleContactContinue}
/>,
)}
</Tabs.Panel>
<Tabs.Panel value="gm">
{profile ? (
<TabGeneralManager profile={profile} />
) : (
<Card padding="xl">
<Center>
<Alert
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
{renderProfileContent(
<TabGeneralManager
profile={profile!}
mode={onboarding ? "onboarding" : "edit"}
onContinue={handleGMContinue}
/>,
)}
</Tabs.Panel>
<Tabs.Panel value="poa">
{profile ? (
<TabPowerOfAttorney profile={profile} />
) : (
<Card padding="xl">
<Center>
<Alert
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
{renderProfileContent(
<TabPowerOfAttorney
profile={profile!}
mode={onboarding ? "onboarding" : "edit"}
onContinue={handlePOAContinue}
/>,
)}
</Tabs.Panel>
<Tabs.Panel value="documents">
{profile ? (
<TabDocuments profile={profile} />
) : (
<Card padding="xl">
<Center>
<Alert
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
{renderProfileContent(
<TabDocuments
profile={profile!}
mode={onboarding ? "onboarding" : "edit"}
onContinue={handleDocumentsContinue}
/>,
)}
</Tabs.Panel>
</Tabs>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,29 @@
import { api } from "@/services/api";
import type { CreateBookingPayload } from "@/services/bookings.service";
import type {
CreateBookingPayload,
GeneratePriceResponse,
} from "@/services/bookings.service";
import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Box, Button, Group, Text, Title } from "@mantine/core";
import {
Alert,
Box,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react";
import {
AlertCircle,
Check,
ChevronLeft,
ChevronRight,
Send,
XCircle,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
@@ -97,6 +117,58 @@ export default function NewBookingPage() {
},
});
const createAndPriceMutation = useMutation({
mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload);
const documents = form.getValues("documents") ?? {};
const hasDocs = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocs) {
await api.bookings.uploadDocuments.call({
id: booking.id,
files: documents,
});
}
const pricing = await api.bookings.generatePrice.call({ id: booking.id });
return { bookingId: booking.id, pricing };
},
onSuccess: ({ bookingId, pricing }) => {
setPriceBookingId(bookingId);
setPricingData(pricing);
setPricingPhase("ready");
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
onError: () => {
setPricingPhase("idle");
},
});
const confirmMutation = useMutation({
mutationFn: async () => {
if (!priceBookingId) throw new Error("No booking to confirm");
await api.bookings.submit.call({ id: priceBookingId });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${priceBookingId}`);
},
});
const abortMutation = useMutation({
mutationFn: async (reason: string) => {
if (!priceBookingId) throw new Error("No booking to abort");
await api.bookings.cancel.call({ id: priceBookingId, reason });
},
onSuccess: () => {
setCancelDialogOpen(false);
navigate("/bookings");
},
});
const form = useForm<BookingFormInputValues, any, BookingFormValues>({
defaultValues: initialBookingFormValues,
resolver: zodResolver(bookingFormSchema),
@@ -116,6 +188,25 @@ export default function NewBookingPage() {
return route;
}, [originYard, destinationYard]);
const docValues = form.watch("documents") ?? {};
const hasDocuments = useMemo(
() =>
Object.values(docValues).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
),
[docValues],
);
const [pricingPhase, setPricingPhase] = useState<
"idle" | "generating" | "ready"
>("idle");
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
null,
);
const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
async function handleContinue() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return;
@@ -123,14 +214,14 @@ export default function NewBookingPage() {
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
}
const handleSubmit = form.handleSubmit((data) => {
function buildApiPayload(data: BookingFormValues): CreateBookingPayload {
if (data.contractType === "renewal" && !data.previousContractRef) {
form.setError("previousContractRef", {
type: "manual",
message: "Select a previous contract reference.",
});
setStep(1);
return;
throw new Error("Validation failed");
}
const totalWeight =
@@ -141,7 +232,6 @@ export default function NewBookingPage() {
)
: Number(data.cargoWeight || 0);
// ── Reference data lookups ──────────────────────────────────────────
const shippingLines = referenceData?.shipping_line ?? [];
const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? [];
@@ -174,8 +264,7 @@ export default function NewBookingPage() {
(s) => s.id === data.serviceTypeId,
)!;
// ── Build API payload ───────────────────────────────────────────────
const apiPayload: CreateBookingPayload = {
return {
scheduledDate: new Date().toISOString(),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
@@ -222,8 +311,25 @@ export default function NewBookingPage() {
: {}),
...(cargoFreeText ? { cargoFreeText } : {}),
};
}
const handleDraftSubmit = form.handleSubmit((data) => {
try {
const apiPayload = buildApiPayload(data);
createMutation.mutate(apiPayload);
} catch {
// validation error already handled
}
});
const handleGeneratePrice = form.handleSubmit((data) => {
try {
const apiPayload = buildApiPayload(data);
setPricingPhase("generating");
createAndPriceMutation.mutate(apiPayload);
} catch {
// validation error already handled
}
});
return (
@@ -270,7 +376,7 @@ export default function NewBookingPage() {
id="new-booking-form"
className="flex flex-col"
style={{ flex: 1 }}
onSubmit={handleSubmit}
onSubmit={handleDraftSubmit}
>
<Box flex={1} p="24px">
<Box mb="lg">
@@ -295,6 +401,24 @@ export default function NewBookingPage() {
</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 && (
<Step1ContractType form={form} referenceData={referenceData} />
)}
@@ -326,6 +450,17 @@ export default function NewBookingPage() {
setStep={setStep}
direction={direction!}
referenceData={referenceData}
pricingPhase={pricingPhase}
pricingData={pricingData}
onConfirm={() => confirmMutation.mutate()}
onContinueLater={
priceBookingId
? () => navigate(`/bookings/${priceBookingId}`)
: undefined
}
onAbort={() => setCancelDialogOpen(true)}
confirmPending={confirmMutation.isPending}
abortPending={abortMutation.isPending}
/>
)}
</Box>
@@ -366,10 +501,12 @@ export default function NewBookingPage() {
>
Continue
</Button>
) : (
) : pricingPhase === "idle" ? (
<Group>
<Button
type="submit"
form="new-booking-form"
variant={hasDocuments ? "outline" : "filled"}
color="edr-green"
radius="md"
loading={createMutation.isPending}
@@ -377,13 +514,85 @@ export default function NewBookingPage() {
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>
) : null}
</Group>
</Box>
</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>
);
}

View File

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

View File

@@ -1,38 +1,39 @@
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import {
Box,
Button,
Card,
Group,
Modal,
Stack,
Text,
useMantineTheme,
useMantineTheme
} from "@mantine/core";
import { UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, BookingFormValues } from "./schema";
import { useQuery } from "@tanstack/react-query";
import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameMonth,
isToday,
startOfMonth,
startOfWeek,
} from "date-fns";
import {
Calendar as CalendarIcon,
Check,
ChevronLeft,
ChevronRight,
Check,
Train,
Route,
Package,
Calendar as CalendarIcon,
Route,
Train,
} from "lucide-react";
import type { Freight } from "@edr/types";
import React, { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isToday,
isSameMonth,
addMonths,
} from "date-fns";
import { UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, BookingFormValues } from "./schema";
interface StepSchedulingProps {
form: UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
@@ -52,6 +53,7 @@ interface DayData {
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const theme = useMantineTheme();
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDayForModal, setSelectedDayForModal] = useState<DayData | null>(null);
const selectedDate = form.watch("scheduledDate");
const selectedScheduleId = form.watch("trainScheduleId");
@@ -148,10 +150,35 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const weeksCount = Math.ceil(days.length / 7);
const handleDayClick = (day: DayData) => {
if (day.schedules.length > 1) {
setSelectedDayForModal(day);
} else if (day.schedules.length === 1) {
form.setValue("scheduledDate", day.dateString, {
shouldValidate: true,
});
form.setValue("trainScheduleId", day.schedules[0].id, {
shouldValidate: true,
});
}
};
const handleSelectScheduleFromModal = (scheduleId: string) => {
if (selectedDayForModal) {
form.setValue("scheduledDate", selectedDayForModal.dateString, {
shouldValidate: true,
});
form.setValue("trainScheduleId", scheduleId, {
shouldValidate: true,
});
setSelectedDayForModal(null);
}
};
return (
<Group gap={24} align="flex-start" wrap="nowrap">
<Group gap={24} align="flex-start" wrap="wrap" style={{ width: "100%" }}>
{/* ── Calendar Card ───────────────────────────────────── */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Box style={{ flex: 1, minWidth: 300 }}>
<Card p={0} style={{ overflow: "hidden" }}>
{/* Card header */}
<Group
@@ -244,15 +271,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
<DayCell
key={di}
day={d}
selectedScheduleId={selectedScheduleId}
onSelectSchedule={(scheduleId, dateString) => {
form.setValue("scheduledDate", dateString, {
shouldValidate: true,
});
form.setValue("trainScheduleId", scheduleId, {
shouldValidate: true,
});
}}
onDayClick={handleDayClick}
/>
))}
</Box>
@@ -263,7 +282,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
</Box>
{/* ── Side Panel ──────────────────────────────────────── */}
<Stack gap={20} w={340} style={{ flexShrink: 0 }}>
<Stack gap={20} style={{ width: 340, flexShrink: 0 }}>
{/* Booking summary card */}
<Card p={0} style={{ overflow: "hidden" }}>
<Group
@@ -361,17 +380,81 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
</Stack>
</Box>
</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>
);
}
interface DayCellProps {
day: DayData;
selectedScheduleId: string;
onSelectSchedule: (scheduleId: string, dateString: string) => void;
onDayClick: (day: DayData) => void;
}
function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps) {
function DayCell({ day: d, onDayClick, }: DayCellProps) {
const theme = useMantineTheme();
if (!d.isCurrentMonth) {
@@ -400,30 +483,47 @@ function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps)
: "transparent";
const cellBorder = d.isSelectedDate
? `1.5px solid ${theme.colors["edr-green"][5]}`
? `2px solid ${theme.colors["edr-green"][5]}`
: d.hasSchedule
? "1px solid #E7E8E5"
? `1px solid ${theme.colors["edr-border"][0]}`
: "none";
return (
<Box
onClick={() => d.hasSchedule && onDayClick(d)}
style={{
height: 92,
borderRadius: theme.radius.md,
backgroundColor: cellBg,
border: cellBorder,
overflow: "hidden",
padding: "4px 6px 6px",
padding: "8px 10px 10px",
display: "flex",
flexDirection: "column",
gap: 4,
cursor: d.hasSchedule ? "pointer" : "default",
transition: "all 150ms ease",
boxShadow: d.hasSchedule && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
}}
onMouseEnter={(e) => {
if (d.hasSchedule && !d.isSelectedDate) {
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
}
}}
onMouseLeave={(e) => {
if (d.hasSchedule && !d.isSelectedDate) {
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.05)";
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
}
}}
>
{/* Day number + check icon */}
<Group justify="space-between" align="center" style={{ flexShrink: 0 }}>
<Stack gap={0}>
<Text
fz={14}
fw={d.hasSchedule ? 800 : 600}
fz={16}
fw={800}
c={
d.isToday && !d.isSelectedDate
? "edr-green.6"
@@ -434,91 +534,62 @@ function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps)
>
{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 && (
<Box
style={{
width: 24,
height: 24,
borderRadius: "50%",
backgroundColor: theme.colors["edr-green"][5],
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Check
size={15}
color={theme.colors["edr-green"][5]}
strokeWidth={2.5}
size={14}
color="white"
strokeWidth={3}
/>
</Box>
)}
</Group>
{/* Departure chips */}
{/* Schedule times */}
{d.hasSchedule && (
<Stack gap={3} style={{ flex: 1, overflow: "hidden" }}>
{d.schedules.slice(0, 2).map((s) => {
const isChipSelected = s.id === selectedScheduleId;
const isFull = s.remainingWagons <= 0;
const canSelect = !isFull;
return (
<Box
key={s.id}
onClick={() =>
canSelect && onSelectSchedule(s.id, d.dateString)
}
style={{
display: "flex",
alignItems: "center",
gap: 4,
borderRadius: 7,
padding: "4px 6px",
cursor: canSelect ? "pointer" : "default",
backgroundColor: isChipSelected
? theme.colors["edr-green"][5]
: isFull
? theme.colors["edr-red-soft"][0]
: theme.colors["edr-soft"][0],
border: `1px solid ${
isChipSelected
? theme.colors["edr-green"][5]
: isFull
? "#EFCFCA"
: "#BFE3D4"
}`,
}}
>
<Stack gap={3} style={{ flex: 1, overflow: "hidden", minWidth: 0 }}>
{d.schedules.slice(0, 2).map((s) => (
<Group key={s.id} gap={6} align="center" style={{ minWidth: 0 }}>
<Box
style={{
width: 6,
height: 6,
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: isChipSelected
? "white"
: isFull
? theme.colors["edr-red"][0]
: theme.colors["edr-green"][5],
backgroundColor: theme.colors["edr-green"][5],
flexShrink: 0,
}}
/>
<Text
fz={11}
fz={12}
fw={700}
style={{ flex: 1, minWidth: 0, overflow: "hidden" }}
truncate
c={
isChipSelected ? "white" : isFull ? "edr-red.0" : "edr-green.7"
}
c="edr-text.0"
style={{ flex: 1, minWidth: 0 }}
>
{isFull
? "Full"
: s.trainNumber
? s.trainNumber
: `${s.remainingWagons} wgn`}
{format(new Date(s.scheduleDate), "HH:mm")}
</Text>
<ChevronRight
size={12}
color={
isChipSelected
? "white"
: isFull
? theme.colors["edr-muted"][0]
: theme.colors["edr-green"][7]
}
/>
</Box>
);
})}
</Group>
))}
{d.schedules.length > 2 && (
<Text fz={11} fw={600} c="edr-green.7" style={{ paddingTop: 2 }}>
+{d.schedules.length - 2} more
</Text>
)}
</Stack>
)}
</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 { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
import type { Freight } from "@edr/types";
import {
BookingFormInputValues,
type BookingFormValues,
@@ -56,25 +56,24 @@ export function Step4Route({
return true;
});
}, [yardOptions, destinationYard]);
console.log({ yardOptions, originYard, destinationYard });
const destData = useMemo(() => {
return yardOptions.filter((o) => o.value !== originYard);
}, [yardOptions, originYard]);
const direction = getRouteDirection(
referenceData?.yard.find((y) => y.id === originYard),
referenceData?.yard.find((y) => y.name === destinationYard),
);
const origin = referenceData?.yard.find((y) => y.id === originYard);
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
const direction = getRouteDirection(origin, dest);
console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
const directionStyle: Record<string, string> = {
export: "bg-sky-50 text-sky-800 border-sky-200",
import: "bg-amber-50 text-amber-800 border-amber-200",
domestic: "bg-gray-100 text-gray-600 border-gray-200",
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
DOMESTIC: "bg-gray-100 text-gray-600 border-gray-200",
};
const directionLabel: Record<string, string> = {
export: "Export workflow (inside country to outside country)",
import: "Import workflow (outside country to inside country)",
domestic: "Domestic corridor",
EXPORT: "Export workflow (inside country to outside country)",
IMPORT: "Import workflow (outside country to inside country)",
DOMESTIC: "Domestic corridor",
};
useEffect(() => {

View File

@@ -1,5 +1,17 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Box, Card, Checkbox, SimpleGrid, Text, Textarea } from "@mantine/core";
import {
Box,
Button,
Card,
Divider,
Group,
Loader,
SimpleGrid,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react";
import {
BookingFormInputValues,
BOOKING_DOCS_SETTING,
@@ -8,6 +20,7 @@ import {
} from "./schema";
import { StepHeader } from "./shared";
import type { Freight } from "@/types";
import type { GeneratePriceResponse } from "@/services/bookings.service";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -20,19 +33,32 @@ export function Step8Review({
setStep,
direction,
referenceData,
pricingPhase = "idle",
pricingData,
onConfirm,
onContinueLater,
onAbort,
confirmPending = false,
abortPending = false,
}: {
form: BookingForm;
setStep: (step: number) => void;
direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData;
pricingPhase?: "idle" | "generating" | "ready";
pricingData?: GeneratePriceResponse | null;
onConfirm?: () => void;
onContinueLater?: () => void;
onAbort?: () => void;
confirmPending?: boolean;
abortPending?: boolean;
}) {
const values = form.watch();
const errors = form.formState.errors;
const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId,
);
function Row({
function CompactRow({
label,
value,
target,
@@ -42,19 +68,19 @@ export function Step8Review({
target: number;
}) {
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<Text size="xs" c="dimmed">
<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">
{label}
</Text>
<Text size="sm" fw={500} mt={2} className="truncate">
<Text size="sm" fw={500} className="truncate">
{value || "—"}
</Text>
</div>
<button
type="button"
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
</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 =
values.cargoType === "container" && values.containers.length > 0
? values.containers
@@ -95,146 +143,211 @@ export function Step8Review({
return child ? `${group.name}${child.name}` : group.name;
})();
function ReviewCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<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>
);
}
const originYardName = referenceData?.yard.find(
(y) => y.id === values.originYard,
)?.name ?? values.originYard;
const destinationYardName = referenceData?.yard.find(
(y) => y.id === values.destinationYard,
)?.name ?? values.destinationYard;
return (
<div className="space-y-6">
<Stack gap="md">
<StepHeader
title="Review & Submit"
description="Confirm your contract request before sending it for EDR staff review."
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<ReviewCard title="Contract & Service">
<Row
{/* Pricing Card - Prominent at top */}
{pricingPhase === "generating" && (
<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"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<Row label="Service" value={serviceType?.name ?? ""} target={2} />
</ReviewCard>
<CompactRow label="Service" value={serviceType?.name ?? ""} target={2} />
</CompactCard>
<ReviewCard title="First & Last Mile">
<Row
<CompactCard icon={<Route size={16} />} title="Route">
<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"
value={
values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
values.firstMile.enabled ? values.firstMile.pickUpAddress : "Not requested"
}
target={2}
/>
<Row
<CompactRow
label="Last Mile"
value={
values.lastMile.enabled
? values.lastMile.deliveryAddress
: "Not requested"
values.lastMile.enabled ? values.lastMile.deliveryAddress : "Not requested"
}
target={2}
/>
<Row
<CompactRow
label="Equipment Return"
value={
values.equipmentReturn === "with_return"
? "With Return"
: "Without Return"
values.equipmentReturn === "with_return" ? "With Return" : "Without Return"
}
target={2}
/>
<Row
<CompactRow
label="Customs Clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
target={2}
/>
</ReviewCard>
</CompactCard>
<ReviewCard title="Route & Cargo">
<Row
label="Route"
value={`${values.originYard}${values.destinationYard}`}
target={3}
/>
<Row
label="Workflow"
value={
direction
? direction.charAt(0).toUpperCase() + direction.slice(1)
: ""
}
target={3}
/>
<Row
<CompactCard icon={<Package size={16} />} title="Cargo Details">
<CompactRow
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4}
/>
<Row label="Cargo" value={cargoValue} target={4} />
<Row
<CompactRow label="Cargo Type" value={cargoValue} target={4} />
<CompactRow
label="Modifiers"
value={
[
values.isHazardous && "Hazardous",
values.isRefrigerated && "Refrigerated",
]
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
.filter(Boolean)
.join(", ") || "None"
}
target={3}
/>
</ReviewCard>
</CompactCard>
<ReviewCard title="Container & Wagons">
<Row label="Containers" value={containerSummary || "—"} target={4} />
<Row
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
<CompactCard icon={<Package size={16} />} title="Containers">
<CompactRow
label="Count & Type"
value={containerSummary || "—"}
target={4}
/>
</ReviewCard>
<ReviewCard title="Documents">
<Row
label="Attached"
value={
docsAttached > 0
? `${docsAttached} of ${docsTotal} attached`
: "None — upload later from the booking page"
}
target={5}
<CompactRow
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
target={4}
/>
</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>
{/* Notes */}
<Controller
name="notes"
control={form.control}
@@ -243,35 +356,13 @@ export function Step8Review({
{...field}
id="notes"
label="Additional Notes"
placeholder="Any special instructions or notes for EDR operations..."
rows={3}
placeholder="Any special instructions or notes for EDR operations"
rows={2}
radius="md"
size="sm"
/>
)}
/>
<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>
</Stack>
);
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
CheckCircle2,
FileCheck,
Loader2,
@@ -20,7 +21,13 @@ import { companiesService } from "@/services/companies.service";
import { SmartFileInput } from "@edr/ui-common";
import type { ProfileResponse } from "@/types/profile";
export default function TabDocuments({ profile }: { profile: ProfileResponse }) {
interface TabDocumentsProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
export default function TabDocuments({ profile, mode = "edit", onContinue }: TabDocumentsProps) {
const queryClient = useQueryClient();
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
@@ -75,7 +82,9 @@ export default function TabDocuments({ profile }: { profile: ProfileResponse })
{docUploadMutation.isSuccess && (
<Group gap={6} c="green">
<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>
)}
{docUploadMutation.isError && (
@@ -85,6 +94,28 @@ export default function TabDocuments({ profile }: { profile: ProfileResponse })
</Group>
)}
</Group>
{mode === "onboarding" ? (
<Button
type="button"
leftSection={<ArrowRight size={16} />}
loading={docUploadMutation.isPending}
onClick={() => {
const hasFiles = Object.values(documentFiles).some((f) => f !== null);
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} />}
@@ -93,6 +124,7 @@ export default function TabDocuments({ profile }: { profile: ProfileResponse })
>
Upload Documents
</Button>
)}
</Group>
)}
</Card>

View File

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

View File

@@ -36,11 +36,17 @@ function splitPhone(fullPhone?: string | null) {
return { code: "+251", number: fullPhone };
}
interface TabPowerOfAttorneyProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
export default function TabPowerOfAttorney({
profile,
}: {
profile: ProfileResponse;
}) {
mode = "edit",
onContinue,
}: TabPowerOfAttorneyProps) {
const queryClient = useQueryClient();
const defaultValues = useMemo((): FormData => {
@@ -49,7 +55,7 @@ export default function TabPowerOfAttorney({
poaName: profile.poaName ?? "",
poaEmail: profile.poaEmail ?? "",
poaPhone: phone.number,
poaPhoneCountryCode: profile.poaPhone ? phone.code : "",
poaPhoneCountryCode: profile.poaPhone ? phone.code : "+251",
poaLocation: profile.poaLocation ?? "",
poaAddress: profile.poaAddress ?? "",
};
@@ -81,6 +87,7 @@ export default function TabPowerOfAttorney({
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
if (mode === "onboarding") onContinue?.();
},
});
@@ -99,11 +106,6 @@ export default function TabPowerOfAttorney({
<form onSubmit={handleSubmit(onSubmit)}>
<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
label="PoA Full Name"
placeholder="Authorized Representative Name"
@@ -177,6 +179,7 @@ export default function TabPowerOfAttorney({
)}
</Group>
<Group gap="md">
{mode === "edit" && (
<Button
type="button"
variant="outline"
@@ -185,12 +188,13 @@ export default function TabPowerOfAttorney({
>
Reset
</Button>
)}
<Button
type="submit"
leftSection={<Save size={16} />}
loading={mutation.isPending}
>
Save Changes
{mode === "onboarding" ? "Continue" : "Save Changes"}
</Button>
</Group>
</Group>

View File

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

View File

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

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 axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { URL_CONSTANTS } from "@/constants/URLS";
import { API_BASE_URL } from "@/constants/apiConfig";
const client = axios.create({
baseURL: import.meta.env.VITE_API_URL,
baseURL: API_BASE_URL,
});
function getCookie(name: string): string | undefined {

View File

@@ -1,21 +1,38 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Pin Mantine to this app's copy. pnpm can install a second @mantine/core under
// @edr/ui-common (linked to react@18) while the portal uses react@19 — dedupe
// alone does not merge those into one module in production builds.
const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core");
const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks");
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
"@mantine/core": mantineCore,
"@mantine/hooks": mantineHooks,
},
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
},
optimizeDeps: {
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
},
server: {
port: 5173,
host: "0.0.0.0",
},
test: {
environment: "node",
},
});

View File

@@ -0,0 +1,276 @@
-- DropForeignKey
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey";
-- DropForeignKey
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey";
-- DropForeignKey
ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey";
-- DropForeignKey
ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey";
-- DropForeignKey
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey";
-- DropForeignKey
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey";
-- DropForeignKey
ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey";
-- DropForeignKey
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey";
-- DropForeignKey
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey";
-- DropForeignKey
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey";
-- DropForeignKey
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey";
-- DropForeignKey
ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey";
-- DropForeignKey
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey";
-- DropForeignKey
ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey";
-- DropForeignKey
ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey";
-- DropForeignKey
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey";
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "returnDestinationStationId" TEXT,
ADD COLUMN "returnHoldId" TEXT,
ADD COLUMN "returnOriginStationId" TEXT,
ADD COLUMN "returnScheduleId" TEXT,
ADD COLUMN "returnSeatClassId" TEXT;
-- AlterTable
ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
-- AlterTable
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT;
-- CreateIndex
CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType");
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -682,20 +682,6 @@ async function main() {
const steps: Array<[string, () => Promise<unknown>]> = [
['system users', seedSystemUsers],
['stations', seedStations],
['coach types & classes', seedCoachTypesAndClasses],
['route', seedRoute],
['coaches', seedCoaches],
['trips', seedTrips],
['fare rules', seedFareRules],
['currency', seedCurrency],
['payment methods', seedPaymentMethods],
['notification templates', seedNotificationTemplates],
['menu & food', seedMenuAndFood],
['promotions', seedPromotions],
['FAQ', seedFAQ],
['fraud rules', seedFraudRules],
['segment fares', seedSegmentFares],
];
let failed = 0;

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