mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
12
.github/workflows/deploy.yml
vendored
12
.github/workflows/deploy.yml
vendored
@@ -10,10 +10,6 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-${{ github.ref_name }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
detect-changes:
|
detect-changes:
|
||||||
name: Detect changed services
|
name: Detect changed services
|
||||||
@@ -73,8 +69,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
|
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
|
||||||
echo "$CHANGED" | grep -q "^apps/edr-freight-portal/" && SERVICES+=("freight-portal")
|
echo "$CHANGED" | grep -q "^apps/edr-freight-web-portal/" && SERVICES+=("freight-portal")
|
||||||
echo "$CHANGED" | grep -q "^apps/edr-freight-backoffice/" && SERVICES+=("freight-backoffice")
|
echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice")
|
||||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
|
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
|
||||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
|
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
|
||||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
|
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
|
||||||
@@ -149,12 +145,12 @@ jobs:
|
|||||||
- name: Build ${{ matrix.service }}
|
- name: Build ${{ matrix.service }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
|
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}"
|
||||||
|
|
||||||
- name: Deploy ${{ matrix.service }}
|
- name: Deploy ${{ matrix.service }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}"
|
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
|
||||||
|
|
||||||
- name: Remove npm credentials from workspace
|
- name: Remove npm credentials from workspace
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
243
.github/workflows/polinrider-scan.yml
vendored
243
.github/workflows/polinrider-scan.yml
vendored
@@ -1,243 +0,0 @@
|
|||||||
name: PolinRider Malware Scan
|
|
||||||
|
|
||||||
# ── Triggers ──────────────────────────────────────────────────────────────────
|
|
||||||
# Runs on every push and every PR targeting main/master/develop.
|
|
||||||
# Also available as a manual trigger (workflow_dispatch) and on a nightly
|
|
||||||
# schedule so dormant infections in older branches are caught too.
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["**"]
|
|
||||||
pull_request:
|
|
||||||
branches: ["**"]
|
|
||||||
schedule:
|
|
||||||
# Nightly full-repo scan at 02:00 UTC
|
|
||||||
- cron: "0 2 * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# ── Permissions ───────────────────────────────────────────────────────────────
|
|
||||||
permissions:
|
|
||||||
contents: read # checkout
|
|
||||||
security-events: write # upload SARIF to GitHub Security tab
|
|
||||||
actions: read
|
|
||||||
checks: write # annotate PRs with scan findings
|
|
||||||
|
|
||||||
# ── Deployment gate ───────────────────────────────────────────────────────────
|
|
||||||
# All other jobs (build, test, deploy) should list this job under `needs:`.
|
|
||||||
# If this job fails (exit code 1 from the scanner), the whole workflow stops.
|
|
||||||
jobs:
|
|
||||||
polinrider-scan:
|
|
||||||
name: "PolinRider / Famous Chollima Scan"
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Prevent CI from being disabled by any workflow override
|
|
||||||
if: always()
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# ── 1. Checkout full history ─────────────────────────────────────────────
|
|
||||||
# Full depth so we can inspect recent commits for temp_auto_push.bat traces
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# ── 2. Detect suspicious force-push patterns in git history ──────────────
|
|
||||||
- name: Check git history for force-push and timestamp manipulation
|
|
||||||
id: git-check
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "=== Checking for suspicious git history patterns ==="
|
|
||||||
|
|
||||||
# Check for .gitignore entries hiding known malware artifacts
|
|
||||||
GITIGNORE_HITS=0
|
|
||||||
if [ -f .gitignore ]; then
|
|
||||||
for pattern in "branch_structure.json" "temp_auto_push.bat" "temp_interactive_push.bat"; do
|
|
||||||
if grep -qF "$pattern" .gitignore 2>/dev/null; then
|
|
||||||
echo "::warning file=.gitignore::SUSPICIOUS: .gitignore hides known PolinRider artifact: $pattern"
|
|
||||||
GITIGNORE_HITS=$((GITIGNORE_HITS + 1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if malware persistence artifacts exist anywhere in the tree
|
|
||||||
ARTIFACTS_FOUND=0
|
|
||||||
for artifact in "temp_auto_push.bat" "temp_interactive_push.bat" "branch_structure.json"; do
|
|
||||||
FOUND=$(find . -name "$artifact" -not -path "./.git/*" 2>/dev/null)
|
|
||||||
if [ -n "$FOUND" ]; then
|
|
||||||
echo "::error ::CRITICAL: PolinRider persistence artifact found: $artifact"
|
|
||||||
echo "$FOUND"
|
|
||||||
ARTIFACTS_FOUND=$((ARTIFACTS_FOUND + 1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Scan recent commit messages for --no-verify (used by temp_auto_push.bat)
|
|
||||||
NO_VERIFY_COMMITS=$(git log --oneline -50 --format="%H %s" 2>/dev/null | grep -i "no.verify\|force.*push\|amend" || true)
|
|
||||||
if [ -n "$NO_VERIFY_COMMITS" ]; then
|
|
||||||
echo "::warning ::Recent commits with suspicious metadata (--no-verify / force amend patterns):"
|
|
||||||
echo "$NO_VERIFY_COMMITS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for .woff2 files with unusually large sizes (>50KB is suspicious)
|
|
||||||
find . -name "*.woff2" -not -path "./.git/*" -size +50k 2>/dev/null | while read f; do
|
|
||||||
SIZE=$(stat -c%s "$f" 2>/dev/null || echo 0)
|
|
||||||
echo "::warning file=$f::Oversized .woff2 font file ($SIZE bytes) — may contain embedded payload"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "GITIGNORE_HITS=$GITIGNORE_HITS" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "ARTIFACTS_FOUND=$ARTIFACTS_FOUND" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
# ── 3. Run the JavaScript malware scanner ────────────────────────────────
|
|
||||||
- name: Run PolinRider malware scanner
|
|
||||||
id: scanner
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "=== Running PolinRider IOC scanner ==="
|
|
||||||
|
|
||||||
# The scanner is zero-dependency — just needs Node.js (always present on ubuntu-latest)
|
|
||||||
node .github/scripts/scan.js \
|
|
||||||
--json \
|
|
||||||
--output scan-report.json \
|
|
||||||
.
|
|
||||||
|
|
||||||
SCANNER_EXIT=$?
|
|
||||||
echo "SCANNER_EXIT=$SCANNER_EXIT" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
# Also emit a human-readable summary to the Actions log
|
|
||||||
node .github/scripts/scan.js . || true
|
|
||||||
|
|
||||||
exit $SCANNER_EXIT
|
|
||||||
|
|
||||||
# ── 4. Upload scan report as artifact ────────────────────────────────────
|
|
||||||
# - name: Upload scan report
|
|
||||||
# if: always()
|
|
||||||
# uses: actions/upload-artifact@v4
|
|
||||||
# with:
|
|
||||||
# name: polinrider-scan-report
|
|
||||||
# path: scan-report.json
|
|
||||||
# retention-days: 90
|
|
||||||
|
|
||||||
# # ── 5. Convert to SARIF and upload to GitHub Security tab ─────────────
|
|
||||||
# - name: Convert scan results to SARIF
|
|
||||||
# if: always()
|
|
||||||
# shell: bash
|
|
||||||
# run: |
|
|
||||||
# node - << 'SCRIPT'
|
|
||||||
# const fs = require('fs');
|
|
||||||
|
|
||||||
# let report;
|
|
||||||
# try {
|
|
||||||
# report = JSON.parse(fs.readFileSync('scan-report.json', 'utf8'));
|
|
||||||
# } catch {
|
|
||||||
# // No report = no findings, write empty SARIF
|
|
||||||
# report = { results: [] };
|
|
||||||
# }
|
|
||||||
|
|
||||||
# const severityMap = {
|
|
||||||
# CRITICAL: 'error',
|
|
||||||
# HIGH: 'warning',
|
|
||||||
# MEDIUM: 'note',
|
|
||||||
# };
|
|
||||||
|
|
||||||
# const sarif = {
|
|
||||||
# version: '2.1.0',
|
|
||||||
# $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json',
|
|
||||||
# runs: [{
|
|
||||||
# tool: {
|
|
||||||
# driver: {
|
|
||||||
# name: 'PolinRider Malware Scanner',
|
|
||||||
# version: '1.0.0',
|
|
||||||
# informationUri: 'https://github.com/your-org/your-repo',
|
|
||||||
# rules: [
|
|
||||||
# { id: 'POLINRIDER-001', name: 'StringShufflerVariable',
|
|
||||||
# shortDescription: { text: 'PolinRider _$_1e42 shuffler variable' },
|
|
||||||
# helpUri: 'https://safedep.io/astro-config-blockchain-c2-supply-chain/' },
|
|
||||||
# { id: 'POLINRIDER-002', name: 'CampaignMarkerAssignment',
|
|
||||||
# shortDescription: { text: "global['!'] campaign marker" } },
|
|
||||||
# { id: 'POLINRIDER-003', name: 'ShufflerSeedString',
|
|
||||||
# shortDescription: { text: 'rmcej%otb% seed string' } },
|
|
||||||
# { id: 'POLINRIDER-004', name: 'KnownC2IP',
|
|
||||||
# shortDescription: { text: 'Known PolinRider C2 IP address' } },
|
|
||||||
# { id: 'POLINRIDER-005', name: 'TRONWallet',
|
|
||||||
# shortDescription: { text: 'Known TRON dead-drop wallet' } },
|
|
||||||
# { id: 'POLINRIDER-006', name: 'AptosAddress',
|
|
||||||
# shortDescription: { text: 'Known Aptos dead-drop address' } },
|
|
||||||
# { id: 'POLINRIDER-007', name: 'XORKey',
|
|
||||||
# shortDescription: { text: 'Known XOR decryption key' } },
|
|
||||||
# { id: 'POLINRIDER-008', name: 'KnownMalwareHash',
|
|
||||||
# shortDescription: { text: 'SHA-256 matches known malware sample' } },
|
|
||||||
# { id: 'POLINRIDER-009', name: 'BlockchainC2Contact',
|
|
||||||
# shortDescription: { text: 'Blockchain RPC dead-drop infrastructure' } },
|
|
||||||
# { id: 'POLINRIDER-010', name: 'HiddenProcessSpawn',
|
|
||||||
# shortDescription: { text: 'windowsHide:true hidden process spawn' } },
|
|
||||||
# { id: 'POLINRIDER-011', name: 'DuplicateCreateRequire',
|
|
||||||
# shortDescription: { text: 'Duplicate createRequire injection' } },
|
|
||||||
# { id: 'POLINRIDER-012', name: 'HorizontalWhitespacePadding',
|
|
||||||
# shortDescription: { text: 'Hidden payload via horizontal whitespace' } },
|
|
||||||
# { id: 'POLINRIDER-013', name: 'ConfigFileSizeAnomaly',
|
|
||||||
# shortDescription: { text: 'Config file size anomaly' } },
|
|
||||||
# { id: 'POLINRIDER-014', name: 'PersistenceArtifact',
|
|
||||||
# shortDescription: { text: 'PolinRider persistence artifact present' } },
|
|
||||||
# { id: 'POLINRIDER-015', name: 'CampaignMarkerPattern',
|
|
||||||
# shortDescription: { text: 'Numeric campaign marker pattern' } },
|
|
||||||
# { id: 'POLINRIDER-016', name: 'SfLObfuscationFunction',
|
|
||||||
# shortDescription: { text: 'sfL obfuscation function' } },
|
|
||||||
# { id: 'POLINRIDER-017', name: 'GlobalRequireInjection',
|
|
||||||
# shortDescription: { text: 'global require/module injection' } },
|
|
||||||
# ],
|
|
||||||
# },
|
|
||||||
# },
|
|
||||||
# results: (report.results || []).flatMap(file =>
|
|
||||||
# (file.findings || []).map(finding => ({
|
|
||||||
# ruleId: finding.id,
|
|
||||||
# level: severityMap[finding.severity] || 'warning',
|
|
||||||
# message: { text: finding.description + ' — ' + finding.matches.join('; ') },
|
|
||||||
# locations: [{
|
|
||||||
# physicalLocation: {
|
|
||||||
# artifactLocation: { uri: file.filePath.replace(/^\.\//,''), uriBaseId: '%SRCROOT%' },
|
|
||||||
# region: { startLine: 1 },
|
|
||||||
# },
|
|
||||||
# }],
|
|
||||||
# }))
|
|
||||||
# ),
|
|
||||||
# }],
|
|
||||||
# };
|
|
||||||
|
|
||||||
# fs.writeFileSync('scan-results.sarif', JSON.stringify(sarif, null, 2));
|
|
||||||
# console.log('SARIF written.');
|
|
||||||
# SCRIPT
|
|
||||||
|
|
||||||
# - name: Upload SARIF to GitHub Security tab
|
|
||||||
# if: always()
|
|
||||||
# uses: github/codeql-action/upload-sarif@v3
|
|
||||||
# with:
|
|
||||||
# sarif_file: scan-results.sarif
|
|
||||||
# category: polinrider-malware-scan
|
|
||||||
|
|
||||||
# ── 6. Block deployment if infected ──────────────────────────────────────
|
|
||||||
- name: Enforce clean-scan gate
|
|
||||||
if: steps.scanner.outputs.SCANNER_EXIT == '1' || steps.git-check.outputs.ARTIFACTS_FOUND != '0'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo ""
|
|
||||||
echo "╔══════════════════════════════════════════════════════════════════╗"
|
|
||||||
echo "║ DEPLOYMENT BLOCKED — PolinRider malware signatures detected ║"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ This repository contains code signatures consistent with the ║"
|
|
||||||
echo "║ PolinRider supply-chain campaign (DPRK / Famous Chollima). ║"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ DO NOT run npm install, build, or deploy until remediated. ║"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ See scan-report.json artifact for full details. ║"
|
|
||||||
echo "╚══════════════════════════════════════════════════════════════════╝"
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
# ── Dependent jobs — add `needs: polinrider-scan` to block on clean scan ─────
|
|
||||||
# Example: your existing build/deploy jobs should look like this:
|
|
||||||
#
|
|
||||||
# build:
|
|
||||||
# needs: polinrider-scan
|
|
||||||
# runs-on: ubuntu-latest
|
|
||||||
# steps:
|
|
||||||
# ...
|
|
||||||
#
|
|
||||||
# deploy:
|
|
||||||
# needs: [polinrider-scan, build]
|
|
||||||
# ...
|
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
||||||
|
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
||||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
|
|||||||
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||||
|
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||||
@@ -121,6 +122,7 @@ import { OverviewModule } from './modules/overview/overview.module';
|
|||||||
PricingDataSeeder,
|
PricingDataSeeder,
|
||||||
FileUploadSettingsSeeder,
|
FileUploadSettingsSeeder,
|
||||||
FreightPermissionKeyMigrationSeeder,
|
FreightPermissionKeyMigrationSeeder,
|
||||||
|
DemoFreightDataSeeder,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
@@ -133,6 +135,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||||
|
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
@@ -144,5 +147,8 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
await this.demoBookingsSeeder.run();
|
await this.demoBookingsSeeder.run();
|
||||||
await this.pricingDataSeeder.run();
|
await this.pricingDataSeeder.run();
|
||||||
await this.fileUploadSettingsSeeder.run();
|
await this.fileUploadSettingsSeeder.run();
|
||||||
|
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||||
|
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||||
|
await this.demoFreightDataSeeder.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,3 +21,10 @@ export const TrainSchedulingView = () =>
|
|||||||
|
|
||||||
export const TrainSchedulingManage = () =>
|
export const TrainSchedulingManage = () =>
|
||||||
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
|
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
|
||||||
|
|
||||||
|
export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view);
|
||||||
|
|
||||||
|
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
|
||||||
|
|
||||||
|
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||||
|
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { FreightAdmin } from "../../common/booking-guards";
|
||||||
import { BackofficeService } from "./backoffice.service";
|
import { BackofficeService } from "./backoffice.service";
|
||||||
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
|
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
|
||||||
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
|
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
|
||||||
|
|
||||||
@ApiTags("backoffice")
|
@ApiTags("backoffice")
|
||||||
@Controller("backoffice")
|
@Controller("backoffice")
|
||||||
|
@FreightAdmin()
|
||||||
export class BackofficeController {
|
export class BackofficeController {
|
||||||
constructor(private readonly backofficeService: BackofficeService) {}
|
constructor(private readonly backofficeService: BackofficeService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
|
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { FreightAdmin } from "../../common/booking-guards";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
|
|
||||||
@ApiTags("billing")
|
@ApiTags("billing")
|
||||||
@Controller("billing")
|
@Controller("billing")
|
||||||
|
@FreightAdmin()
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(private readonly billingService: BillingService) {}
|
constructor(private readonly billingService: BillingService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||||
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
||||||
import { LoadCargoDto } from './dto/load-cargo.dto';
|
import { LoadCargoDto } from './dto/load-cargo.dto';
|
||||||
@@ -18,10 +19,12 @@ import { CargoesService } from './cargoes.service';
|
|||||||
|
|
||||||
@ApiTags('cargoes')
|
@ApiTags('cargoes')
|
||||||
@Controller('cargoes')
|
@Controller('cargoes')
|
||||||
|
@FleetView()
|
||||||
export class CargoesController {
|
export class CargoesController {
|
||||||
constructor(private readonly cargoesService: CargoesService) {}
|
constructor(private readonly cargoesService: CargoesService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Create a new cargo' })
|
@ApiOperation({ summary: 'Create a new cargo' })
|
||||||
create(@Body() dto: CreateCargoDto) {
|
create(@Body() dto: CreateCargoDto) {
|
||||||
return this.cargoesService.create(dto);
|
return this.cargoesService.create(dto);
|
||||||
@@ -40,30 +43,35 @@ export class CargoesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Update a cargo' })
|
@ApiOperation({ summary: 'Update a cargo' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
|
||||||
return this.cargoesService.update(id, dto);
|
return this.cargoesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Delete a cargo' })
|
@ApiOperation({ summary: 'Delete a cargo' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.cargoesService.remove(id);
|
return this.cargoesService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/load')
|
@Post(':id/load')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Load cargo into a container' })
|
@ApiOperation({ summary: 'Load cargo into a container' })
|
||||||
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
|
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
|
||||||
return this.cargoesService.loadCargo(id, dto);
|
return this.cargoesService.loadCargo(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/unload')
|
@Post(':id/unload')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Unload cargo from container' })
|
@ApiOperation({ summary: 'Unload cargo from container' })
|
||||||
unload(@Param('id', ParseUUIDPipe) id: string) {
|
unload(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.cargoesService.unloadCargo(id);
|
return this.cargoesService.unloadCargo(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/deliver')
|
@Post(':id/deliver')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Mark cargo as delivered' })
|
@ApiOperation({ summary: 'Mark cargo as delivered' })
|
||||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
|
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
|
||||||
return this.cargoesService.deliverCargo(id, dto);
|
return this.cargoesService.deliverCargo(id, dto);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe
|
|||||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||||
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import { FreightAdmin } from '../../common/booking-guards';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from './companies.service';
|
||||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||||
@@ -82,6 +83,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
|
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
|
||||||
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
|
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
|
||||||
const company = await this.companiesService.createCompany(dto);
|
const company = await this.companiesService.createCompany(dto);
|
||||||
@@ -119,6 +121,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: 'Update a company' })
|
@ApiOperation({ summary: 'Update a company' })
|
||||||
async update(
|
async update(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -129,6 +132,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: 'Soft-delete a company' })
|
@ApiOperation({ summary: 'Soft-delete a company' })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||||
@@ -147,6 +151,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':companyId/profiles')
|
@Post(':companyId/profiles')
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
||||||
async createProfile(
|
async createProfile(
|
||||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||||
@@ -175,6 +180,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('ff-clients')
|
@Post('ff-clients')
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: 'Link a forwarder to a client company' })
|
@ApiOperation({ summary: 'Link a forwarder to a client company' })
|
||||||
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
|
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
|
||||||
const client = await this.companiesService.createFFClient(dto);
|
const client = await this.companiesService.createFFClient(dto);
|
||||||
@@ -191,6 +197,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('ff-clients/:id')
|
@Delete('ff-clients/:id')
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
|
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||||
|
|||||||
@@ -9,16 +9,19 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { FleetManage, FleetView } from "../../common/booking-guards";
|
||||||
import { ConsignmentsService } from "./consignments.service";
|
import { ConsignmentsService } from "./consignments.service";
|
||||||
import { CreateConsignmentDto } from "./dto/create-consignment.dto";
|
import { CreateConsignmentDto } from "./dto/create-consignment.dto";
|
||||||
import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
|
import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
|
||||||
|
|
||||||
@ApiTags("consignments")
|
@ApiTags("consignments")
|
||||||
@Controller("consignments")
|
@Controller("consignments")
|
||||||
|
@FleetView()
|
||||||
export class ConsignmentsController {
|
export class ConsignmentsController {
|
||||||
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: "Create a new consignment" })
|
@ApiOperation({ summary: "Create a new consignment" })
|
||||||
create(@Body() dto: CreateConsignmentDto) {
|
create(@Body() dto: CreateConsignmentDto) {
|
||||||
return this.consignmentsService.create(dto);
|
return this.consignmentsService.create(dto);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||||
import { CreateContainerDto } from './dto/create-container.dto';
|
import { CreateContainerDto } from './dto/create-container.dto';
|
||||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||||
@@ -17,10 +18,12 @@ import { ContainersService } from './containers.service';
|
|||||||
|
|
||||||
@ApiTags('containers')
|
@ApiTags('containers')
|
||||||
@Controller('containers')
|
@Controller('containers')
|
||||||
|
@FleetView()
|
||||||
export class ContainersController {
|
export class ContainersController {
|
||||||
constructor(private readonly containersService: ContainersService) {}
|
constructor(private readonly containersService: ContainersService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Create a new container' })
|
@ApiOperation({ summary: 'Create a new container' })
|
||||||
create(@Body() dto: CreateContainerDto) {
|
create(@Body() dto: CreateContainerDto) {
|
||||||
return this.containersService.create(dto);
|
return this.containersService.create(dto);
|
||||||
@@ -39,24 +42,28 @@ export class ContainersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Update a container' })
|
@ApiOperation({ summary: 'Update a container' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
|
||||||
return this.containersService.update(id, dto);
|
return this.containersService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Delete a container' })
|
@ApiOperation({ summary: 'Delete a container' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.containersService.remove(id);
|
return this.containersService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/assign-wagon')
|
@Post(':id/assign-wagon')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Assign container to a wagon' })
|
@ApiOperation({ summary: 'Assign container to a wagon' })
|
||||||
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
|
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
|
||||||
return this.containersService.assignToWagon(id, dto);
|
return this.containersService.assignToWagon(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/unassign-wagon')
|
@Post(':id/unassign-wagon')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Unassign container from wagon' })
|
@ApiOperation({ summary: 'Unassign container from wagon' })
|
||||||
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
|
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.containersService.unassignFromWagon(id);
|
return this.containersService.unassignFromWagon(id);
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import {
|
|||||||
|
|
||||||
import { ApiOperation } from "@nestjs/swagger";
|
import { ApiOperation } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { FreightAdmin } from "../../common/booking-guards";
|
||||||
import { CustomersService } from "./customers.service";
|
import { CustomersService } from "./customers.service";
|
||||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||||
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||||
import { Customer } from "./entities/customer.entity";
|
import { Customer } from "./entities/customer.entity";
|
||||||
|
|
||||||
@Controller("customers")
|
@Controller("customers")
|
||||||
|
@FreightAdmin()
|
||||||
export class CustomersController {
|
export class CustomersController {
|
||||||
constructor(private readonly customersService: CustomersService) {}
|
constructor(private readonly customersService: CustomersService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { FreightAdmin } from "../../common/booking-guards";
|
||||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||||
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||||
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
||||||
@@ -24,6 +25,9 @@ import { DropdownSettingsService } from "./dropdown-settings.service";
|
|||||||
export class DropdownSettingsController {
|
export class DropdownSettingsController {
|
||||||
constructor(private readonly service: DropdownSettingsService) {}
|
constructor(private readonly service: DropdownSettingsService) {}
|
||||||
|
|
||||||
|
// Reads stay open: the customer portal fetches these to render dynamic
|
||||||
|
// dropdowns (by-code). Only writes are admin-guarded.
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: "List all dropdown settings" })
|
@ApiOperation({ summary: "List all dropdown settings" })
|
||||||
list() {
|
list() {
|
||||||
@@ -43,12 +47,14 @@ export class DropdownSettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Create a new dropdown setting" })
|
@ApiOperation({ summary: "Create a new dropdown setting" })
|
||||||
create(@Body() dto: CreateDropdownSettingDto) {
|
create(@Body() dto: CreateDropdownSettingDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Update a dropdown setting's metadata" })
|
@ApiOperation({ summary: "Update a dropdown setting's metadata" })
|
||||||
update(
|
update(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -58,6 +64,7 @@ export class DropdownSettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Soft-delete a dropdown setting" })
|
@ApiOperation({ summary: "Soft-delete a dropdown setting" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
@@ -67,6 +74,7 @@ export class DropdownSettingsController {
|
|||||||
/* ------------------------- option routes ------------------------- */
|
/* ------------------------- option routes ------------------------- */
|
||||||
|
|
||||||
@Put(":id/options")
|
@Put(":id/options")
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Replace the full option list for a setting" })
|
@ApiOperation({ summary: "Replace the full option list for a setting" })
|
||||||
replaceOptions(
|
replaceOptions(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -76,6 +84,7 @@ export class DropdownSettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(":id/options")
|
@Post(":id/options")
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Append a single option to a setting" })
|
@ApiOperation({ summary: "Append a single option to a setting" })
|
||||||
addOption(
|
addOption(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -85,6 +94,7 @@ export class DropdownSettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch("options/:optionId")
|
@Patch("options/:optionId")
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Update a single option" })
|
@ApiOperation({ summary: "Update a single option" })
|
||||||
updateOption(
|
updateOption(
|
||||||
@Param("optionId", ParseUUIDPipe) optionId: string,
|
@Param("optionId", ParseUUIDPipe) optionId: string,
|
||||||
@@ -94,6 +104,7 @@ export class DropdownSettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete("options/:optionId")
|
@Delete("options/:optionId")
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Soft-delete a single option" })
|
@ApiOperation({ summary: "Soft-delete a single option" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {
|
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { FreightAdmin } from "../../common/booking-guards";
|
||||||
import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto";
|
import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto";
|
||||||
import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto";
|
import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto";
|
||||||
import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto";
|
import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto";
|
||||||
@@ -24,6 +25,9 @@ import { FileUploadSettingsService } from "./file-upload-settings.service";
|
|||||||
export class FileUploadSettingsController {
|
export class FileUploadSettingsController {
|
||||||
constructor(private readonly service: FileUploadSettingsService) {}
|
constructor(private readonly service: FileUploadSettingsService) {}
|
||||||
|
|
||||||
|
// Reads stay open: the customer portal fetches these to render dynamic
|
||||||
|
// upload forms (by-code / by-entity). Only writes are admin-guarded.
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: "List all file upload settings" })
|
@ApiOperation({ summary: "List all file upload settings" })
|
||||||
list() {
|
list() {
|
||||||
@@ -49,12 +53,14 @@ export class FileUploadSettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Create a new file upload setting" })
|
@ApiOperation({ summary: "Create a new file upload setting" })
|
||||||
create(@Body() dto: CreateFileUploadSettingDto) {
|
create(@Body() dto: CreateFileUploadSettingDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Update a file upload setting's metadata" })
|
@ApiOperation({ summary: "Update a file upload setting's metadata" })
|
||||||
update(
|
update(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -64,6 +70,7 @@ export class FileUploadSettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Soft-delete a file upload setting" })
|
@ApiOperation({ summary: "Soft-delete a file upload setting" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
@@ -73,6 +80,7 @@ export class FileUploadSettingsController {
|
|||||||
/* ------------------------- field routes ------------------------- */
|
/* ------------------------- field routes ------------------------- */
|
||||||
|
|
||||||
@Put(":id/fields")
|
@Put(":id/fields")
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Replace the full field list for a setting" })
|
@ApiOperation({ summary: "Replace the full field list for a setting" })
|
||||||
replaceFields(
|
replaceFields(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -82,6 +90,7 @@ export class FileUploadSettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(":id/fields")
|
@Post(":id/fields")
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Append a single field to a setting" })
|
@ApiOperation({ summary: "Append a single field to a setting" })
|
||||||
addField(
|
addField(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -91,6 +100,7 @@ export class FileUploadSettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch("fields/:fieldId")
|
@Patch("fields/:fieldId")
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Update a single field" })
|
@ApiOperation({ summary: "Update a single field" })
|
||||||
updateField(
|
updateField(
|
||||||
@Param("fieldId", ParseUUIDPipe) fieldId: string,
|
@Param("fieldId", ParseUUIDPipe) fieldId: string,
|
||||||
@@ -100,6 +110,7 @@ export class FileUploadSettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete("fields/:fieldId")
|
@Delete("fields/:fieldId")
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Soft-delete a single field" })
|
@ApiOperation({ summary: "Soft-delete a single field" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) {
|
removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||||
@@ -9,6 +10,7 @@ import { LocomotivesService } from './locomotives.service';
|
|||||||
@ApiTags('locomotives')
|
@ApiTags('locomotives')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('locomotives')
|
@Controller('locomotives')
|
||||||
|
@FleetView()
|
||||||
export class LocomotivesController {
|
export class LocomotivesController {
|
||||||
constructor(private readonly locomotivesService: LocomotivesService) {}
|
constructor(private readonly locomotivesService: LocomotivesService) {}
|
||||||
|
|
||||||
@@ -25,18 +27,21 @@ export class LocomotivesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Create a locomotive' })
|
@ApiOperation({ summary: 'Create a locomotive' })
|
||||||
create(@Body() dto: CreateLocomotiveDto) {
|
create(@Body() dto: CreateLocomotiveDto) {
|
||||||
return this.locomotivesService.create(dto);
|
return this.locomotivesService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Update a locomotive' })
|
@ApiOperation({ summary: 'Update a locomotive' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
|
||||||
return this.locomotivesService.update(id, dto);
|
return this.locomotivesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/decommission')
|
@Post(':id/decommission')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Decommission a locomotive' })
|
@ApiOperation({ summary: 'Decommission a locomotive' })
|
||||||
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.locomotivesService.decommission(id);
|
return this.locomotivesService.decommission(id);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
export class PaymentClientService {
|
export class PaymentClientService {
|
||||||
private readonly logger = new Logger(PaymentClientService.name);
|
private readonly logger = new Logger(PaymentClientService.name);
|
||||||
private readonly baseUrl = (
|
private readonly baseUrl = (
|
||||||
process.env.PAYMENT_API_URL ?? "http://localhost:3003"
|
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
|
||||||
).replace(/\/$/, "");
|
).replace(/\/$/, "");
|
||||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
|
import { BookingView, FreightAdmin } from "../../common/booking-guards";
|
||||||
import { PaymentService } from "./payment.service";
|
import { PaymentService } from "./payment.service";
|
||||||
import {
|
import {
|
||||||
InitiatePaymentDto,
|
InitiatePaymentDto,
|
||||||
@@ -32,8 +33,16 @@ import {
|
|||||||
export class PaymentController {
|
export class PaymentController {
|
||||||
constructor(private readonly paymentService: PaymentService) { }
|
constructor(private readonly paymentService: PaymentService) { }
|
||||||
|
|
||||||
|
@Get("summary")
|
||||||
|
@BookingView()
|
||||||
|
@ApiOperation({ summary: "Payment count/amount summary for dashboard cards" })
|
||||||
|
getSummary() {
|
||||||
|
return this.paymentService.getSummary();
|
||||||
|
}
|
||||||
|
|
||||||
@Get("all")
|
@Get("all")
|
||||||
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
|
@BookingView()
|
||||||
|
@ApiOperation({ summary: "Get all payments with filters (view-only, any staff)" })
|
||||||
@ApiQuery({ name: "search", required: false })
|
@ApiQuery({ name: "search", required: false })
|
||||||
@ApiQuery({ name: "status", required: false })
|
@ApiQuery({ name: "status", required: false })
|
||||||
@ApiQuery({ name: "method", required: false })
|
@ApiQuery({ name: "method", required: false })
|
||||||
@@ -73,6 +82,7 @@ export class PaymentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("refund")
|
@Post("refund")
|
||||||
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: "Refund a paid booking (staff/admin only)" })
|
@ApiOperation({ summary: "Refund a paid booking (staff/admin only)" })
|
||||||
refund(@Body() dto: RefundDto) {
|
refund(@Body() dto: RefundDto) {
|
||||||
return this.paymentService.refund(dto);
|
return this.paymentService.refund(dto);
|
||||||
|
|||||||
@@ -105,6 +105,40 @@ export class PaymentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Aggregate counts across ALL payments for the dashboard summary cards. */
|
||||||
|
async getSummary() {
|
||||||
|
const rows = await this.paymentRepo
|
||||||
|
.createQueryBuilder("payment")
|
||||||
|
.select("payment.status", "status")
|
||||||
|
.addSelect("COUNT(*)::int", "count")
|
||||||
|
.groupBy("payment.status")
|
||||||
|
.getRawMany<{ status: string; count: number }>();
|
||||||
|
|
||||||
|
const byStatus: Record<string, number> = {};
|
||||||
|
let total = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
byStatus[row.status] = row.count;
|
||||||
|
total += row.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum of successfully collected amounts.
|
||||||
|
const paidAgg = await this.paymentRepo
|
||||||
|
.createQueryBuilder("payment")
|
||||||
|
.select("COALESCE(SUM(payment.amount), 0)", "sum")
|
||||||
|
.where("payment.status = :status", { status: "success" })
|
||||||
|
.getRawOne<{ sum: string }>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
success: byStatus["success"] ?? 0,
|
||||||
|
processing:
|
||||||
|
(byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0),
|
||||||
|
failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0),
|
||||||
|
refunded: byStatus["refunded"] ?? 0,
|
||||||
|
paidAmount: Number(paidAgg?.sum ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||||
const booking = await this.datasource
|
const booking = await this.datasource
|
||||||
.getRepository(Booking)
|
.getRepository(Booking)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||||
import { CreateRouteDto } from './dto/create-route.dto';
|
import { CreateRouteDto } from './dto/create-route.dto';
|
||||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||||
@@ -9,6 +10,7 @@ import { RoutesService } from './routes.service';
|
|||||||
@ApiTags('routes')
|
@ApiTags('routes')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('routes')
|
@Controller('routes')
|
||||||
|
@FleetView()
|
||||||
export class RoutesController {
|
export class RoutesController {
|
||||||
constructor(private readonly routesService: RoutesService) {}
|
constructor(private readonly routesService: RoutesService) {}
|
||||||
|
|
||||||
@@ -25,18 +27,21 @@ export class RoutesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Create route' })
|
@ApiOperation({ summary: 'Create route' })
|
||||||
create(@Body() dto: CreateRouteDto) {
|
create(@Body() dto: CreateRouteDto) {
|
||||||
return this.routesService.create(dto);
|
return this.routesService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Update route' })
|
@ApiOperation({ summary: 'Update route' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
|
||||||
return this.routesService.update(id, dto);
|
return this.routesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Deactivate route' })
|
@ApiOperation({ summary: 'Deactivate route' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.routesService.deactivate(id);
|
return this.routesService.deactivate(id);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
import { MinioService } from '../minio/minio.service';
|
import { MinioService } from '../minio/minio.service';
|
||||||
import { SignaturesRepository } from './signatures.repository';
|
import { SignaturesRepository } from './signatures.repository';
|
||||||
import { SavedSignature } from './entities/saved-signature.entity';
|
import { SavedSignature } from './entities/saved-signature.entity';
|
||||||
@@ -19,6 +21,7 @@ export class SignaturesService {
|
|||||||
private readonly signaturesRepository: SignaturesRepository,
|
private readonly signaturesRepository: SignaturesRepository,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Saved signature for a user, with the image inlined as a data URL (or null). */
|
/** Saved signature for a user, with the image inlined as a data URL (or null). */
|
||||||
@@ -47,18 +50,32 @@ export class SignaturesService {
|
|||||||
path: '',
|
path: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const fileRecord = await this.filesService.upsertByCode({
|
// Capture the previously referenced file so we can remove it only AFTER the
|
||||||
|
// saved_signatures row is repointed — deleting it first would violate the
|
||||||
|
// FK constraint (saved_signatures.signature_file_id -> files.id).
|
||||||
|
const existing = await this.signaturesRepository.findByUserId(input.userId);
|
||||||
|
const previousFileId = existing?.signatureFileId ?? null;
|
||||||
|
|
||||||
|
const fileRecord = await this.filesService.upload({
|
||||||
resourceId: input.userId,
|
resourceId: input.userId,
|
||||||
resource: 'saved_signatures',
|
resource: 'saved_signatures',
|
||||||
code: 'signature',
|
code: 'signature',
|
||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.signaturesRepository.upsert({
|
const saved = await this.signaturesRepository.upsert({
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
signerDisplayName: input.signerDisplayName,
|
signerDisplayName: input.signerDisplayName,
|
||||||
signatureFileId: fileRecord.id,
|
signatureFileId: fileRecord.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (previousFileId && previousFileId !== fileRecord.id) {
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(FileRecord)
|
||||||
|
.delete({ id: previousFileId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async inlineImageUrl(
|
private async inlineImageUrl(
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("bookable-schedules")
|
@Get("bookable-schedules")
|
||||||
// @TrainSchedulingView()
|
// No staff guard: customers hit this while creating a booking to find OPEN
|
||||||
|
// same-route schedules. Do not attach train_scheduling permissions here.
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "OPEN same-route schedules a new booking can target",
|
summary: "OPEN same-route schedules a new booking can target",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,16 +11,19 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { FleetManage, FleetView } from "../../common/booking-guards";
|
||||||
import { CreateTrainDto } from "./dto/create-train.dto";
|
import { CreateTrainDto } from "./dto/create-train.dto";
|
||||||
import { UpdateTrainDto } from "./dto/update-train.dto";
|
import { UpdateTrainDto } from "./dto/update-train.dto";
|
||||||
import { TrainsService } from "./trains.service";
|
import { TrainsService } from "./trains.service";
|
||||||
|
|
||||||
@ApiTags("trains")
|
@ApiTags("trains")
|
||||||
@Controller("trains")
|
@Controller("trains")
|
||||||
|
@FleetView()
|
||||||
export class TrainsController {
|
export class TrainsController {
|
||||||
constructor(private readonly trainsService: TrainsService) {}
|
constructor(private readonly trainsService: TrainsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: "Register a new train" })
|
@ApiOperation({ summary: "Register a new train" })
|
||||||
create(@Body() dto: CreateTrainDto) {
|
create(@Body() dto: CreateTrainDto) {
|
||||||
return this.trainsService.create(dto);
|
return this.trainsService.create(dto);
|
||||||
@@ -39,12 +42,14 @@ export class TrainsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: "Update a train" })
|
@ApiOperation({ summary: "Update a train" })
|
||||||
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) {
|
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) {
|
||||||
return this.trainsService.update(id, dto);
|
return this.trainsService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: "Delete a train" })
|
@ApiOperation({ summary: "Delete a train" })
|
||||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainsService.remove(id);
|
return this.trainsService.remove(id);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||||
@@ -19,10 +20,12 @@ import { WagonsService } from './wagons.service';
|
|||||||
|
|
||||||
@ApiTags('wagons')
|
@ApiTags('wagons')
|
||||||
@Controller('wagons')
|
@Controller('wagons')
|
||||||
|
@FleetView()
|
||||||
export class WagonsController {
|
export class WagonsController {
|
||||||
constructor(private readonly wagonsService: WagonsService) {}
|
constructor(private readonly wagonsService: WagonsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Create a new wagon' })
|
@ApiOperation({ summary: 'Create a new wagon' })
|
||||||
create(@Body() dto: CreateWagonDto) {
|
create(@Body() dto: CreateWagonDto) {
|
||||||
return this.wagonsService.create(dto);
|
return this.wagonsService.create(dto);
|
||||||
@@ -41,24 +44,28 @@ export class WagonsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Update a wagon' })
|
@ApiOperation({ summary: 'Update a wagon' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
|
||||||
return this.wagonsService.update(id, dto);
|
return this.wagonsService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Delete a wagon' })
|
@ApiOperation({ summary: 'Delete a wagon' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.wagonsService.remove(id);
|
return this.wagonsService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/assign-train')
|
@Post(':id/assign-train')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Assign wagon to a train' })
|
@ApiOperation({ summary: 'Assign wagon to a train' })
|
||||||
assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
|
assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
|
||||||
return this.wagonsService.assignToTrain(id, dto);
|
return this.wagonsService.assignToTrain(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/unassign-train')
|
@Post(':id/unassign-train')
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Unassign wagon from train' })
|
@ApiOperation({ summary: 'Unassign wagon from train' })
|
||||||
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
|
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.wagonsService.unassignFromTrain(id);
|
return this.wagonsService.unassignFromTrain(id);
|
||||||
@@ -67,10 +74,12 @@ export class WagonsController {
|
|||||||
|
|
||||||
// Separate controller for train‑specific reorder (registered in module)
|
// Separate controller for train‑specific reorder (registered in module)
|
||||||
@Controller('trains/:trainId/reorder-wagons')
|
@Controller('trains/:trainId/reorder-wagons')
|
||||||
|
@FleetView()
|
||||||
export class TrainWagonsReorderController {
|
export class TrainWagonsReorderController {
|
||||||
constructor(private readonly wagonsService: WagonsService) {}
|
constructor(private readonly wagonsService: WagonsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Reorder wagons of a train' })
|
@ApiOperation({ summary: 'Reorder wagons of a train' })
|
||||||
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
|
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
|
||||||
return this.wagonsService.reorderWagons(trainId, dto);
|
return this.wagonsService.reorderWagons(trainId, dto);
|
||||||
|
|||||||
28
apps/edr-freight-api/src/scripts/seed-freight-demo.ts
Normal file
28
apps/edr-freight-api/src/scripts/seed-freight-demo.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
config({ path: resolve(__dirname, '../../.env') });
|
||||||
|
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from '../app.module';
|
||||||
|
import { DemoFreightDataSeeder } from '../seed/demo-freight-data.seeder';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||||
|
logger: ['error', 'warn', 'log'],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seeder = app.get(DemoFreightDataSeeder);
|
||||||
|
await seeder.run();
|
||||||
|
console.log('Freight demo data seeded (wagons, approval rules, staff users).');
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Freight demo seed failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
180
apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts
Normal file
180
apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { WagonStatus } from '@edr/types';
|
||||||
|
import { hashPassword } from '@tria-plc/api-common/utils/argon';
|
||||||
|
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||||
|
import {
|
||||||
|
Employee,
|
||||||
|
Organization,
|
||||||
|
Role,
|
||||||
|
User,
|
||||||
|
UserCredential,
|
||||||
|
UserRole,
|
||||||
|
} from '@tria-plc/iamapi-common';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
import { Wagon } from '../modules/wagons/entities/wagon.entity';
|
||||||
|
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
|
||||||
|
import { ApprovalRule } from '../modules/rule-engine/entities/approval-rule.entity';
|
||||||
|
import { DEFAULT_APPROVAL_RULE_ROWS } from '../modules/rule-engine/approval-rules.defaults';
|
||||||
|
|
||||||
|
const EDR_ORG_KEY = 'edr_freight';
|
||||||
|
const MIN_WAGONS_PER_TYPE = 100;
|
||||||
|
|
||||||
|
/** The four demo staff users, each mapped to a seeded freight role. */
|
||||||
|
const DEMO_STAFF_USERS = [
|
||||||
|
{ email: 'marketing@edr.local', username: 'marketing', roleKey: 'edr_marketing' },
|
||||||
|
{ email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' },
|
||||||
|
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
||||||
|
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot demo data: at least 100 wagons per wagon type, the default approval
|
||||||
|
* chains, and four staff users with distinct permissions. Every block guards on
|
||||||
|
* an "is it already populated?" check, so this is safe to run on every boot and
|
||||||
|
* does nothing once the data exists.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DemoFreightDataSeeder {
|
||||||
|
private readonly logger = new Logger(DemoFreightDataSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.seedWagons(manager);
|
||||||
|
await this.seedApprovalRules(manager);
|
||||||
|
await this.seedStaffUsers(manager);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure every wagon type has at least MIN_WAGONS_PER_TYPE wagons. */
|
||||||
|
private async seedWagons(manager: EntityManager) {
|
||||||
|
const wagonTypeRepo = manager.getRepository(WagonType);
|
||||||
|
const wagonRepo = manager.getRepository(Wagon);
|
||||||
|
|
||||||
|
const wagonTypes = await wagonTypeRepo.find();
|
||||||
|
if (wagonTypes.length === 0) {
|
||||||
|
this.logger.warn('No wagon types found; skipping wagon seed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const type of wagonTypes) {
|
||||||
|
const existing = await wagonRepo.count({ where: { wagonTypeId: type.id } });
|
||||||
|
if (existing >= MIN_WAGONS_PER_TYPE) {
|
||||||
|
this.logger.log(
|
||||||
|
`Wagon type ${type.code} already has ${existing} wagons; skipping`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCreate = MIN_WAGONS_PER_TYPE - existing;
|
||||||
|
const tare = Number(type.tareWeightTons ?? 20);
|
||||||
|
const maxPayload = Number(type.capacityTons ?? 60);
|
||||||
|
const rows = Array.from({ length: toCreate }, (_, i) => {
|
||||||
|
const seq = existing + i + 1;
|
||||||
|
return wagonRepo.create({
|
||||||
|
wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`,
|
||||||
|
wagonTypeId: type.id,
|
||||||
|
tareWeight: tare,
|
||||||
|
maxPayloadWeight: maxPayload,
|
||||||
|
status: WagonStatus.Available,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await wagonRepo.save(rows);
|
||||||
|
this.logger.log(`Seeded ${toCreate} wagons for type ${type.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the default approval chains when the table is empty. */
|
||||||
|
private async seedApprovalRules(manager: EntityManager) {
|
||||||
|
const repo = manager.getRepository(ApprovalRule);
|
||||||
|
const count = await repo.count();
|
||||||
|
if (count > 0) {
|
||||||
|
this.logger.log(`Approval rules already populated (${count}); skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await repo.save(DEFAULT_APPROVAL_RULE_ROWS.map((row) => repo.create(row)));
|
||||||
|
this.logger.log(`Seeded ${DEFAULT_APPROVAL_RULE_ROWS.length} approval rules`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create the four demo staff users with their roles (idempotent per email). */
|
||||||
|
private async seedStaffUsers(manager: EntityManager) {
|
||||||
|
const organization = await manager.getRepository(Organization).findOne({
|
||||||
|
where: { key: EDR_ORG_KEY },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
|
if (!organization) {
|
||||||
|
this.logger.warn(`Missing organization ${EDR_ORG_KEY}; skipping staff users`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleRepo = manager.getRepository(Role);
|
||||||
|
const userRepo = manager.getRepository(User);
|
||||||
|
const credentialRepo = manager.getRepository(UserCredential);
|
||||||
|
const userRoleRepo = manager.getRepository(UserRole);
|
||||||
|
const employeeRepo = manager.getRepository(Employee);
|
||||||
|
|
||||||
|
const password = process.env.DEFAULT_PASSWORD?.trim() || '12345678';
|
||||||
|
const hashedPassword = await hashPassword(password);
|
||||||
|
|
||||||
|
for (const staff of DEMO_STAFF_USERS) {
|
||||||
|
const role = await roleRepo.findOne({
|
||||||
|
where: { key: staff.roleKey },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
|
if (!role) {
|
||||||
|
this.logger.warn(`Missing role ${staff.roleKey}; skipping ${staff.email}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await userRepo.findOne({
|
||||||
|
where: { email: staff.email },
|
||||||
|
select: { id: true, email: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
user = await userRepo.save(
|
||||||
|
userRepo.create({
|
||||||
|
email: staff.email,
|
||||||
|
username: staff.username,
|
||||||
|
name: { en: staff.username },
|
||||||
|
isActive: true,
|
||||||
|
hasSetPassword: true,
|
||||||
|
status: EUserStatus.ACCEPTED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(`Seeded staff user ${staff.email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCredential = await credentialRepo.exists({
|
||||||
|
where: { userId: user.id, isActive: true },
|
||||||
|
});
|
||||||
|
if (!hasCredential) {
|
||||||
|
await credentialRepo.insert({
|
||||||
|
userId: user.id,
|
||||||
|
password: hashedPassword,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await userRoleRepo.upsert(
|
||||||
|
{ userId: user.id, roleId: role.id, organizationId: organization.id },
|
||||||
|
{ conflictPaths: { userId: true, roleId: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasEmployee = await employeeRepo.exists({
|
||||||
|
where: { userId: user.id, organizationId: organization.id, isCurrent: true },
|
||||||
|
});
|
||||||
|
if (!hasEmployee) {
|
||||||
|
await employeeRepo.insert({
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: organization.id,
|
||||||
|
isCurrent: true,
|
||||||
|
name: { en: staff.username },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('Ensured demo staff users (marketing@, operations@, director@, ceo@)');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -212,6 +212,11 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
|||||||
name: { en: "EDR Line Staff" },
|
name: { en: "EDR Line Staff" },
|
||||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff],
|
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "edr_operations_officer",
|
||||||
|
name: { en: "EDR Operations Officer" },
|
||||||
|
permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsOfficer],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "edr_director",
|
key: "edr_director",
|
||||||
name: { en: "EDR Director" },
|
name: { en: "EDR Director" },
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'),
|
perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'),
|
||||||
perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
|
perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
|
||||||
perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
|
perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
|
||||||
|
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
|
||||||
|
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
|
||||||
|
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
|
||||||
];
|
];
|
||||||
|
|
||||||
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
||||||
@@ -109,6 +112,11 @@ export const FREIGHT_PERMS = {
|
|||||||
view: 'edr_freight_app:train_scheduling:view',
|
view: 'edr_freight_app:train_scheduling:view',
|
||||||
manage: 'edr_freight_app:train_scheduling:manage',
|
manage: 'edr_freight_app:train_scheduling:manage',
|
||||||
},
|
},
|
||||||
|
fleet: {
|
||||||
|
view: 'edr_freight_app:fleet:view',
|
||||||
|
manage: 'edr_freight_app:fleet:manage',
|
||||||
|
},
|
||||||
|
admin: 'edr_freight_app:admin',
|
||||||
ruleEngine: {
|
ruleEngine: {
|
||||||
view: (slug: RuleEngineResourceSlug) =>
|
view: (slug: RuleEngineResourceSlug) =>
|
||||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
|
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
|
||||||
@@ -121,6 +129,9 @@ const allRuleEngineViewKeys = () =>
|
|||||||
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
|
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
|
||||||
|
|
||||||
export const ROLE_PERMISSION_PRESETS = {
|
export const ROLE_PERMISSION_PRESETS = {
|
||||||
|
// Marketing / line staff: drives a booking from intake through line-staff
|
||||||
|
// approval and contract generation/signing — i.e. until the contract is ready
|
||||||
|
// and signed. No director/CEO approval, no scheduling, no operations.
|
||||||
lineStaff: [
|
lineStaff: [
|
||||||
FREIGHT_PERMS.bookings.view,
|
FREIGHT_PERMS.bookings.view,
|
||||||
FREIGHT_PERMS.bookings.staffAccept,
|
FREIGHT_PERMS.bookings.staffAccept,
|
||||||
@@ -129,8 +140,17 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||||
FREIGHT_PERMS.bookings.rejectApproval,
|
FREIGHT_PERMS.bookings.rejectApproval,
|
||||||
FREIGHT_PERMS.bookings.cancel,
|
FREIGHT_PERMS.bookings.cancel,
|
||||||
|
...allRuleEngineViewKeys(),
|
||||||
|
],
|
||||||
|
// Operations Officer: train scheduling + wagon allocation + transit/complete
|
||||||
|
// + fleet management (wagons, trains, locomotives, routes, containers, cargo).
|
||||||
|
operationsOfficer: [
|
||||||
|
FREIGHT_PERMS.bookings.view,
|
||||||
|
FREIGHT_PERMS.bookings.operations,
|
||||||
FREIGHT_PERMS.trainScheduling.view,
|
FREIGHT_PERMS.trainScheduling.view,
|
||||||
FREIGHT_PERMS.trainScheduling.manage,
|
FREIGHT_PERMS.trainScheduling.manage,
|
||||||
|
FREIGHT_PERMS.fleet.view,
|
||||||
|
FREIGHT_PERMS.fleet.manage,
|
||||||
...allRuleEngineViewKeys(),
|
...allRuleEngineViewKeys(),
|
||||||
],
|
],
|
||||||
director: [
|
director: [
|
||||||
@@ -147,8 +167,15 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
...allRuleEngineViewKeys(),
|
...allRuleEngineViewKeys(),
|
||||||
],
|
],
|
||||||
finance: [FREIGHT_PERMS.bookings.view],
|
finance: [FREIGHT_PERMS.bookings.view],
|
||||||
|
// Marketing handles intake through contract (same as line staff here).
|
||||||
marketing: [
|
marketing: [
|
||||||
FREIGHT_PERMS.bookings.view,
|
FREIGHT_PERMS.bookings.view,
|
||||||
|
FREIGHT_PERMS.bookings.staffAccept,
|
||||||
|
FREIGHT_PERMS.bookings.requestChanges,
|
||||||
|
FREIGHT_PERMS.bookings.reject,
|
||||||
|
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||||
|
FREIGHT_PERMS.bookings.rejectApproval,
|
||||||
|
FREIGHT_PERMS.bookings.cancel,
|
||||||
FREIGHT_PERMS.bookings.generateContract,
|
FREIGHT_PERMS.bookings.generateContract,
|
||||||
FREIGHT_PERMS.bookings.signStaff,
|
FREIGHT_PERMS.bookings.signStaff,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Package,
|
Package,
|
||||||
Users,
|
Users,
|
||||||
|
Wallet,
|
||||||
//TrainTrack,
|
//TrainTrack,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ import LoginPage from "./pages/auth/LoginPage";
|
|||||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||||
|
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage";
|
import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage";
|
||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||||
@@ -47,6 +49,8 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa
|
|||||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
|
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
|
||||||
|
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||||
|
|
||||||
@@ -70,6 +74,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
href: "/dashboard/booking-requests",
|
href: "/dashboard/booking-requests",
|
||||||
icon: <FileText />,
|
icon: <FileText />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Payments",
|
||||||
|
href: "/dashboard/payments",
|
||||||
|
icon: <Wallet />,
|
||||||
|
permission: FREIGHT_PERMS.bookings.view,
|
||||||
|
},
|
||||||
...demoItems,
|
...demoItems,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -80,11 +90,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Train Schedules",
|
label: "Train Schedules",
|
||||||
href: "/dashboard/operations/train-scheduling-v2",
|
href: "/dashboard/operations/train-scheduling-v2",
|
||||||
icon: <Train />,
|
icon: <Train />,
|
||||||
|
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Batch Board",
|
label: "Batch Board",
|
||||||
href: "/dashboard/operations/batch-board",
|
href: "/dashboard/operations/batch-board",
|
||||||
icon: <LayoutGrid />,
|
icon: <LayoutGrid />,
|
||||||
|
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -95,11 +107,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Routes",
|
label: "Routes",
|
||||||
href: "/dashboard/routes",
|
href: "/dashboard/routes",
|
||||||
icon: <Network />,
|
icon: <Network />,
|
||||||
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Locomotives",
|
label: "Locomotives",
|
||||||
href: "/dashboard/locomotives",
|
href: "/dashboard/locomotives",
|
||||||
icon: <Train />,
|
icon: <Train />,
|
||||||
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// label: "Trains",
|
// label: "Trains",
|
||||||
@@ -115,6 +129,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Wagons",
|
label: "Wagons",
|
||||||
href: "/dashboard/wagons",
|
href: "/dashboard/wagons",
|
||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// label: "Containers",
|
// label: "Containers",
|
||||||
@@ -135,6 +150,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "User management",
|
label: "User management",
|
||||||
href: "/dashboard/user-management",
|
href: "/dashboard/user-management",
|
||||||
icon: <Network />,
|
icon: <Network />,
|
||||||
|
permission: FREIGHT_PERMS.admin,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
label: "Users",
|
label: "Users",
|
||||||
@@ -162,11 +178,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "File settings",
|
label: "File settings",
|
||||||
href: "/dashboard/file-settings",
|
href: "/dashboard/file-settings",
|
||||||
icon: <Paperclip />,
|
icon: <Paperclip />,
|
||||||
|
permission: FREIGHT_PERMS.admin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Dropdown settings",
|
label: "Dropdown settings",
|
||||||
href: "/dashboard/dropdown-settings",
|
href: "/dashboard/dropdown-settings",
|
||||||
icon: <Settings />,
|
icon: <Settings />,
|
||||||
|
permission: FREIGHT_PERMS.admin,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -196,18 +214,25 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasPermission = (
|
/** Keep only items the user is permitted to see; drop now-empty sections. */
|
||||||
|
const filterSidebarByPermission = (
|
||||||
|
sections: SidebarSection[],
|
||||||
user: ReturnType<typeof useAuth>["user"],
|
user: ReturnType<typeof useAuth>["user"],
|
||||||
key: string,
|
): SidebarSection[] => {
|
||||||
) => {
|
const itemAllowed = (item: SidebarItem): boolean => {
|
||||||
if (!user) return false;
|
if (!item.permission) return true;
|
||||||
if (user.permissions?.some((p) => p.key === key)) return true;
|
const keys = Array.isArray(item.permission)
|
||||||
|
? item.permission
|
||||||
|
: [item.permission];
|
||||||
|
return keys.some((key) => hasFreightPermission(user, key));
|
||||||
|
};
|
||||||
|
|
||||||
return (user.employee ?? []).some((emp) =>
|
return sections
|
||||||
(emp.positions ?? []).some((pos) =>
|
.map((section) => ({
|
||||||
(pos.permissions ?? []).some((p) => p.key === key),
|
...section,
|
||||||
),
|
items: section.items.filter(itemAllowed),
|
||||||
);
|
}))
|
||||||
|
.filter((section) => section.items.length > 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DashboardShell = () => {
|
const DashboardShell = () => {
|
||||||
@@ -217,7 +242,10 @@ const DashboardShell = () => {
|
|||||||
|
|
||||||
const demoItems: SidebarItem[] = [];
|
const demoItems: SidebarItem[] = [];
|
||||||
|
|
||||||
const sidebarSections = buildSidebarSections(demoItems);
|
const sidebarSections = filterSidebarByPermission(
|
||||||
|
buildSidebarSections(demoItems),
|
||||||
|
user,
|
||||||
|
);
|
||||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -261,6 +289,14 @@ const App = () => {
|
|||||||
<Route path="profile" element={<MyProfilePage />} />
|
<Route path="profile" element={<MyProfilePage />} />
|
||||||
|
|
||||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||||
|
<Route
|
||||||
|
path="payments"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
|
||||||
|
<PaymentsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||||
<Route
|
<Route
|
||||||
@@ -271,30 +307,102 @@ const App = () => {
|
|||||||
path="operations/train-scheduling"
|
path="operations/train-scheduling"
|
||||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="operations/batch-board" element={<BatchBoardPage />} />
|
<Route
|
||||||
|
path="operations/batch-board"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<BatchBoardPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/batch-board/:scheduleId"
|
path="operations/batch-board/:scheduleId"
|
||||||
element={<BatchScheduleDetailPage />}
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<BatchScheduleDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/train-scheduling-v2"
|
path="operations/train-scheduling-v2"
|
||||||
element={<TrainScheduleV2ListPage />}
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<TrainScheduleV2ListPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/train-scheduling-v2/:scheduleId"
|
path="operations/train-scheduling-v2/:scheduleId"
|
||||||
element={<TrainScheduleV2DetailPage />}
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<TrainScheduleV2DetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/train-scheduling-v2/:scheduleId/track"
|
path="operations/train-scheduling-v2/:scheduleId/track"
|
||||||
element={<TrainScheduleTrackPage />}
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<TrainScheduleTrackPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="routes"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<RoutesPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="locomotives"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="trains"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="trains/:id"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<TrainDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="wagons"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="containers"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="cargoes"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="routes" element={<RoutesPage />} />
|
|
||||||
<Route path="locomotives" element={<FleetResourcePage />} />
|
|
||||||
<Route path="trains" element={<FleetResourcePage />} />
|
|
||||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
|
||||||
<Route path="wagons" element={<FleetResourcePage />} />
|
|
||||||
<Route path="containers" element={<FleetResourcePage />} />
|
|
||||||
<Route path="cargoes" element={<FleetResourcePage />} />
|
|
||||||
|
|
||||||
{/* iframe-based user management module */}
|
{/* iframe-based user management module */}
|
||||||
<Route path="um/*" element={<UserManagementHostPage />} />
|
<Route path="um/*" element={<UserManagementHostPage />} />
|
||||||
@@ -307,8 +415,22 @@ const App = () => {
|
|||||||
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
||||||
<Route path="user-management/roles" element={<RolesPage />} />
|
<Route path="user-management/roles" element={<RolesPage />} />
|
||||||
|
|
||||||
<Route path="file-settings" element={<FileUploadSettingsPage />} />
|
<Route
|
||||||
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
|
path="file-settings"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||||
|
<FileUploadSettingsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="dropdown-settings"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||||
|
<DropdownSettingsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="configuration"
|
path="configuration"
|
||||||
@@ -316,7 +438,11 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="configuration/train-scheduling-rules"
|
path="configuration/train-scheduling-rules"
|
||||||
element={<TrainSchedulingGlobalRulesPage />}
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<TrainSchedulingGlobalRulesPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
import {
|
import {
|
||||||
AUTH_TOKEN_COOKIE,
|
AUTH_TOKEN_COOKIE,
|
||||||
REFRESH_TOKEN_COOKIE,
|
REFRESH_TOKEN_COOKIE,
|
||||||
@@ -16,7 +17,7 @@ type RetriableRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: `${import.meta.env.VITE_BASE_API_URL}/api`,
|
baseURL: `${API_BASE_URL}/api`,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
|
interface RequirePermissionProps {
|
||||||
|
/** Permission key(s); access is granted if the user has ANY of them. */
|
||||||
|
permission: string | string[];
|
||||||
|
/** Where to send users who lack the permission. */
|
||||||
|
redirectTo?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page-level guard: renders children only when the current user holds one of
|
||||||
|
* the given permissions, otherwise redirects (default: overview).
|
||||||
|
*/
|
||||||
|
export function RequirePermission({
|
||||||
|
permission,
|
||||||
|
redirectTo = "/dashboard/overview",
|
||||||
|
children,
|
||||||
|
}: RequirePermissionProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const keys = Array.isArray(permission) ? permission : [permission];
|
||||||
|
const allowed = keys.some((key) => hasPermission(user, key));
|
||||||
|
|
||||||
|
if (!allowed) return <Navigate to={redirectTo} replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { BookingActionsMenu } from "./BookingActionsMenu";
|
|||||||
import { SectionCard } from "./detail/SectionCard";
|
import { SectionCard } from "./detail/SectionCard";
|
||||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { canManageScheduling } from "@/lib/permissions";
|
||||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||||
|
|
||||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||||
@@ -19,9 +21,11 @@ interface BookingActionsToolbarProps {
|
|||||||
|
|
||||||
/** Detail-page actions: primary toolbar + downloads. */
|
/** Detail-page actions: primary toolbar + downloads. */
|
||||||
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
const row = toBookingListRow(booking);
|
const row = toBookingListRow(booking);
|
||||||
const { status } = booking;
|
const { status } = booking;
|
||||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||||
|
const canAllocate = canManageScheduling(user);
|
||||||
|
|
||||||
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
||||||
const blob = await fn();
|
const blob = await fn();
|
||||||
@@ -127,7 +131,7 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canAllocateBooking(booking) ? (
|
{canAllocate && canAllocateBooking(booking) ? (
|
||||||
<AllocateBookingWizard
|
<AllocateBookingWizard
|
||||||
booking={booking}
|
booking={booking}
|
||||||
opened={allocateOpen}
|
opened={allocateOpen}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
FileCheck,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Phone,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Group, Stack, Text, Divider } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
|
||||||
|
interface InfoRowProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Icon size={15} color="var(--mantine-color-gray-5)" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
|
||||||
|
{value || "—"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingCompanyCardProps {
|
||||||
|
booking: BookingDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer (company) information for the booking. */
|
||||||
|
export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
|
||||||
|
const company = booking.company;
|
||||||
|
|
||||||
|
// Government bookings may not carry a company; show the institution instead.
|
||||||
|
if (!company && booking.isGovernment) {
|
||||||
|
return (
|
||||||
|
<SectionCard icon={Building2} title="Customer" accent="blue">
|
||||||
|
<InfoRow
|
||||||
|
icon={Building2}
|
||||||
|
label="Government"
|
||||||
|
value={booking.governmentInstitution}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
return (
|
||||||
|
<SectionCard icon={Building2} title="Customer" accent="blue">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No customer linked to this booking.
|
||||||
|
</Text>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyName = company.companyName ?? company.name ?? company.label;
|
||||||
|
|
||||||
|
const rows: InfoRowProps[] = [
|
||||||
|
{ icon: FileCheck, label: "TIN", value: company.tin },
|
||||||
|
{ icon: Mail, label: "Email", value: company.email },
|
||||||
|
{ icon: Phone, label: "Phone", value: company.phone },
|
||||||
|
{ icon: MapPin, label: "Address", value: company.address },
|
||||||
|
{ icon: User, label: "Contact person", value: company.contactPersonName },
|
||||||
|
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
|
||||||
|
].filter((r) => r.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
icon={Building2}
|
||||||
|
title="Customer"
|
||||||
|
subtitle={companyName}
|
||||||
|
accent="blue"
|
||||||
|
>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No additional company details available.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
rows.map((row, index) => (
|
||||||
|
<div key={row.label}>
|
||||||
|
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
|
||||||
|
<InfoRow {...row} />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -122,6 +122,7 @@ export interface BookingFileView {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
|
code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingDetailView {
|
export interface BookingDetailView {
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ export * from "./BookingRouteServiceCard";
|
|||||||
export * from "./BookingMileServicesCard";
|
export * from "./BookingMileServicesCard";
|
||||||
export * from "./BookingCargoCard";
|
export * from "./BookingCargoCard";
|
||||||
export * from "./BookingContractSummaryCard";
|
export * from "./BookingContractSummaryCard";
|
||||||
|
export * from "./BookingCompanyCard";
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { API_BASE_URL } from '@/constants/apiConfig';
|
||||||
|
|
||||||
interface Cargo {
|
interface Cargo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,8 +33,6 @@ interface CargoFormDialogProps {
|
|||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
|
||||||
|
|
||||||
export default function CargoFormDialog({
|
export default function CargoFormDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
|||||||
subtitle: "Manage your account and signature",
|
subtitle: "Manage your account and signature",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/payments",
|
||||||
|
meta: {
|
||||||
|
title: "Payments",
|
||||||
|
subtitle: "View booking payment transactions",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
prefix: "/dashboard/operations/train-scheduling-v2/",
|
prefix: "/dashboard/operations/train-scheduling-v2/",
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface SidebarItem {
|
|||||||
href?: string;
|
href?: string;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
children?: SidebarItem[];
|
children?: SidebarItem[];
|
||||||
|
/** Permission key(s) required to see this item; ANY grants access. */
|
||||||
|
permission?: string | string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarSection {
|
export interface SidebarSection {
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ export const URL_CONSTANTS = {
|
|||||||
VERIFY: "/api/otp/verify",
|
VERIFY: "/api/otp/verify",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
PAYMENTS: {
|
||||||
|
ALL: "/payments/all",
|
||||||
|
SUMMARY: "/payments/summary",
|
||||||
|
},
|
||||||
|
|
||||||
LOCOMOTIVES: {
|
LOCOMOTIVES: {
|
||||||
BASE: "/locomotives",
|
BASE: "/locomotives",
|
||||||
BY_ID: (id: string) => `/locomotives/${id}`,
|
BY_ID: (id: string) => `/locomotives/${id}`,
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||||
|
|
||||||
|
// export const API_BASE_URL = 'http://localhost:3001';
|
||||||
@@ -201,6 +201,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
|||||||
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
||||||
startTransit: FREIGHT_PERMS.bookings.operations,
|
startTransit: FREIGHT_PERMS.bookings.operations,
|
||||||
complete: FREIGHT_PERMS.bookings.operations,
|
complete: FREIGHT_PERMS.bookings.operations,
|
||||||
|
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
|
||||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
23
apps/edr-freight-web/backoffice/src/hooks/usePayments.ts
Normal file
23
apps/edr-freight-web/backoffice/src/hooks/usePayments.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import {
|
||||||
|
paymentsService,
|
||||||
|
type PaymentListFilter,
|
||||||
|
} from "@/services/payments.service";
|
||||||
|
|
||||||
|
export function usePaymentList(filter?: PaymentListFilter, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["payments", "list", filter ?? {}],
|
||||||
|
queryFn: () => paymentsService.list(filter),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePaymentSummary(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["payments", "summary"],
|
||||||
|
queryFn: () => paymentsService.getSummary(),
|
||||||
|
staleTime: 30_000,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -16,6 +16,15 @@ export const FREIGHT_PERMS = {
|
|||||||
operations: "edr_freight_app:bookings:operations",
|
operations: "edr_freight_app:bookings:operations",
|
||||||
cancel: "edr_freight_app:bookings:cancel",
|
cancel: "edr_freight_app:bookings:cancel",
|
||||||
},
|
},
|
||||||
|
trainScheduling: {
|
||||||
|
view: "edr_freight_app:train_scheduling:view",
|
||||||
|
manage: "edr_freight_app:train_scheduling:manage",
|
||||||
|
},
|
||||||
|
fleet: {
|
||||||
|
view: "edr_freight_app:fleet:view",
|
||||||
|
manage: "edr_freight_app:fleet:manage",
|
||||||
|
},
|
||||||
|
admin: "edr_freight_app:admin",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
||||||
@@ -70,6 +79,22 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
|
|||||||
return hasPermission(user, FREIGHT_PERMS.bookings.view);
|
return hasPermission(user, FREIGHT_PERMS.bookings.view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canViewScheduling(user: AuthUser | null | undefined): boolean {
|
||||||
|
return hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
|
||||||
|
return hasPermission(user, FREIGHT_PERMS.trainScheduling.manage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canViewFleet(user: AuthUser | null | undefined): boolean {
|
||||||
|
return hasPermission(user, FREIGHT_PERMS.fleet.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
|
||||||
|
return hasPermission(user, FREIGHT_PERMS.admin);
|
||||||
|
}
|
||||||
|
|
||||||
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
|
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
|
||||||
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
|
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,11 +50,8 @@ export default function BookingContractPage() {
|
|||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const signRole: "CUSTOMER" | "STAFF" | null = data?.canSignCustomer
|
// Backoffice only ever signs as STAFF — customers sign in the portal.
|
||||||
? "CUSTOMER"
|
const canSign = Boolean(data?.canSignStaff);
|
||||||
: data?.canSignStaff
|
|
||||||
? "STAFF"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const savedSignature = data?.savedSignature ?? null;
|
const savedSignature = data?.savedSignature ?? null;
|
||||||
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||||
@@ -106,12 +103,12 @@ export default function BookingContractPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmSign = () => {
|
const confirmSign = () => {
|
||||||
if (!signRole || !signerName.trim()) return;
|
if (!canSign || !signerName.trim()) return;
|
||||||
// Approve the saved signature, or submit the freshly drawn one.
|
// Approve the saved signature, or submit the freshly drawn one.
|
||||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
signMutation.mutate({
|
signMutation.mutate({
|
||||||
role: signRole,
|
role: "STAFF",
|
||||||
signatureImageBase64: image,
|
signatureImageBase64: image,
|
||||||
signerDisplayName: signerName.trim(),
|
signerDisplayName: signerName.trim(),
|
||||||
consentText: "I agree to the terms of this contract.",
|
consentText: "I agree to the terms of this contract.",
|
||||||
@@ -167,10 +164,10 @@ export default function BookingContractPage() {
|
|||||||
<Download className="size-4" />
|
<Download className="size-4" />
|
||||||
Download PDF
|
Download PDF
|
||||||
</Button>
|
</Button>
|
||||||
{signRole && (
|
{canSign && (
|
||||||
<Button size="sm" className="gap-2" onClick={openSign}>
|
<Button size="sm" className="gap-2" onClick={openSign}>
|
||||||
<FileSignature className="size-4" />
|
<FileSignature className="size-4" />
|
||||||
Sign as {signRole === "CUSTOMER" ? "Customer" : "Staff"}
|
{usingSaved ? "Approve & sign" : "Sign contract"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -194,9 +191,7 @@ export default function BookingContractPage() {
|
|||||||
<Dialog open={signOpen} onOpenChange={setSignOpen}>
|
<Dialog open={signOpen} onOpenChange={setSignOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>Staff signature</DialogTitle>
|
||||||
{signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{usingSaved
|
{usingSaved
|
||||||
? `Review your saved signature and approve it to execute the contract for ${data.reference}.`
|
? `Review your saved signature and approve it to execute the contract for ${data.reference}.`
|
||||||
|
|||||||
@@ -24,11 +24,25 @@ import {
|
|||||||
BookingRouteServiceCard,
|
BookingRouteServiceCard,
|
||||||
BookingMileServicesCard,
|
BookingMileServicesCard,
|
||||||
BookingCargoCard,
|
BookingCargoCard,
|
||||||
|
BookingCompanyCard,
|
||||||
BookingContractSummaryCard,
|
BookingContractSummaryCard,
|
||||||
|
BookingDocumentsCard,
|
||||||
|
type BookingFileView,
|
||||||
} from "@/components/bookings/detail";
|
} from "@/components/bookings/detail";
|
||||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
|
import { downloadBookingFile } from "@/services/files.service";
|
||||||
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
// Signature / generated-contract files are surfaced on the contract page, not
|
||||||
|
// in the booking's Documents list.
|
||||||
|
const SIGNATURE_FILE_CODES = new Set([
|
||||||
|
"signature",
|
||||||
|
"signature_customer",
|
||||||
|
"signature_staff",
|
||||||
|
"contract",
|
||||||
|
]);
|
||||||
|
|
||||||
export default function BookingRequestDetailPage() {
|
export default function BookingRequestDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -36,6 +50,14 @@ export default function BookingRequestDetailPage() {
|
|||||||
const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
|
const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
|
||||||
const mutations = useBookingMutations(id ?? "");
|
const mutations = useBookingMutations(id ?? "");
|
||||||
|
|
||||||
|
const handleDownloadFile = async (file: BookingFileView) => {
|
||||||
|
try {
|
||||||
|
await downloadBookingFile(file.id, file.name);
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not download file.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box style={detailStyles.page}>
|
<Box style={detailStyles.page}>
|
||||||
@@ -147,6 +169,12 @@ export default function BookingRequestDetailPage() {
|
|||||||
{booking.contractSummary && (
|
{booking.contractSummary && (
|
||||||
<BookingContractSummaryCard summary={booking.contractSummary} />
|
<BookingContractSummaryCard summary={booking.contractSummary} />
|
||||||
)}
|
)}
|
||||||
|
<BookingDocumentsCard
|
||||||
|
files={(booking.files ?? []).filter(
|
||||||
|
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
|
||||||
|
)}
|
||||||
|
onDownload={handleDownloadFile}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
@@ -154,6 +182,7 @@ export default function BookingRequestDetailPage() {
|
|||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
<Box style={{ position: "sticky", top: 24 }}>
|
<Box style={{ position: "sticky", top: 24 }}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
<BookingCompanyCard booking={booking} />
|
||||||
<BookingPricingSummary booking={booking} />
|
<BookingPricingSummary booking={booking} />
|
||||||
<BookingActionsToolbar booking={booking} mutations={mutations} />
|
<BookingActionsToolbar booking={booking} mutations={mutations} />
|
||||||
{showContractButton && (
|
{showContractButton && (
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
CircleDollarSign,
|
||||||
|
Loader2,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
XCircle,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
|
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
|
||||||
|
import type {
|
||||||
|
PaymentMethod,
|
||||||
|
PaymentRow,
|
||||||
|
} from "@/services/payments.service";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
type ColumnDef,
|
||||||
|
usePagination,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
const STATUS_TABS = [
|
||||||
|
{ key: "all", label: "All", statuses: undefined as string | undefined },
|
||||||
|
{ key: "success", label: "Success", statuses: "success" },
|
||||||
|
{ key: "processing", label: "Processing", statuses: "processing,action-required" },
|
||||||
|
{ key: "failed", label: "Failed", statuses: "failed,canceled" },
|
||||||
|
{ key: "refunded", label: "Refunded", statuses: "refunded" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
|
||||||
|
|
||||||
|
const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
|
||||||
|
{ value: "telebirr", label: "Telebirr" },
|
||||||
|
{ value: "waafi", label: "Waafi" },
|
||||||
|
{ value: "cbe-birr", label: "CBE Birr" },
|
||||||
|
{ value: "ebirr", label: "E-Birr" },
|
||||||
|
{ value: "card", label: "Card" },
|
||||||
|
{ value: "dmoney", label: "D-Money" },
|
||||||
|
{ value: "cac-bank", label: "CAC Bank" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
success: "green",
|
||||||
|
processing: "yellow",
|
||||||
|
"action-required": "yellow",
|
||||||
|
failed: "red",
|
||||||
|
canceled: "gray",
|
||||||
|
refunded: "indigo",
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
accent: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
p="md"
|
||||||
|
radius="lg"
|
||||||
|
style={{
|
||||||
|
flex: "1 1 180px",
|
||||||
|
minWidth: 160,
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap" align="center">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 11,
|
||||||
|
background: `var(--mantine-color-${accent}-1)`,
|
||||||
|
color: `var(--mantine-color-${accent}-7)`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={20} strokeWidth={2} />
|
||||||
|
</Box>
|
||||||
|
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" fw={600} c="dimmed" truncate>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(amount: number, currency: string): string {
|
||||||
|
return `${currency} ${Number(amount).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime())
|
||||||
|
? "—"
|
||||||
|
: d.toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableHeader = "text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
||||||
|
|
||||||
|
export default function PaymentsPage() {
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [statusTab, setStatusTab] = useState<StatusTabKey>("all");
|
||||||
|
const [method, setMethod] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const statuses = STATUS_TABS.find((t) => t.key === statusTab)?.statuses;
|
||||||
|
|
||||||
|
const filter = useMemo(
|
||||||
|
() => ({
|
||||||
|
search: query.trim() || undefined,
|
||||||
|
status: statuses,
|
||||||
|
method: method ?? undefined,
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
}),
|
||||||
|
[query, statuses, method, pagination.pageIndex, pagination.pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = usePaymentList(filter);
|
||||||
|
const { data: summary, isLoading: summaryLoading } = usePaymentSummary();
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
|
||||||
|
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
|
||||||
|
|
||||||
|
const columns: ColumnDef<PaymentRow>[] = [
|
||||||
|
{
|
||||||
|
id: "order",
|
||||||
|
header: () => <span className={tableHeader}>Order</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="min-w-0 py-1">
|
||||||
|
<p className="truncate font-medium text-foreground">
|
||||||
|
{row.original.merchantOrderId ?? row.original.id.slice(0, 8)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
|
Booking {row.original.bookingId?.slice(0, 8) ?? "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "amount",
|
||||||
|
header: () => <span className={tableHeader}>Amount</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
|
||||||
|
{formatAmount(row.original.amount, row.original.currency)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "method",
|
||||||
|
header: () => <span className={tableHeader}>Method</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="light" radius="sm">
|
||||||
|
{METHOD_OPTIONS.find((m) => m.value === row.original.method)?.label ??
|
||||||
|
row.original.method}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: () => <span className={tableHeader}>Status</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
color={STATUS_COLORS[row.original.status] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
radius="sm"
|
||||||
|
tt="capitalize"
|
||||||
|
>
|
||||||
|
{row.original.status.replace(/-/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "date",
|
||||||
|
header: () => <span className={tableHeader}>Date</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatDate(row.original.paidAt ?? row.original.createdAt)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
|
||||||
|
<Container size="xxl" py="xl">
|
||||||
|
<Breadcrumbs items={[{ label: "Operations" }, { label: "Payments" }]} />
|
||||||
|
|
||||||
|
<Stack gap="lg" mt="md">
|
||||||
|
<Group grow gap="md" align="stretch" wrap="wrap">
|
||||||
|
<StatCard
|
||||||
|
icon={CircleDollarSign}
|
||||||
|
label="Total collected"
|
||||||
|
value={
|
||||||
|
summaryLoading
|
||||||
|
? "—"
|
||||||
|
: `ETB ${Number(summary?.paidAmount ?? 0).toLocaleString()}`
|
||||||
|
}
|
||||||
|
accent="teal"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={CheckCircle2}
|
||||||
|
label="Successful"
|
||||||
|
value={val(summary?.success)}
|
||||||
|
accent="green"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Loader2}
|
||||||
|
label="Processing"
|
||||||
|
value={val(summary?.processing)}
|
||||||
|
accent="yellow"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={XCircle}
|
||||||
|
label="Failed"
|
||||||
|
value={val(summary?.failed)}
|
||||||
|
accent="red"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={RotateCcw}
|
||||||
|
label="Refunded"
|
||||||
|
value={val(summary?.refunded)}
|
||||||
|
accent="indigo"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={statusTab}
|
||||||
|
onChange={(value) => {
|
||||||
|
setStatusTab((value as StatusTabKey) ?? "all");
|
||||||
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.List>
|
||||||
|
{STATUS_TABS.map((t) => (
|
||||||
|
<Tabs.Tab key={t.key} value={t.key}>
|
||||||
|
{t.label}
|
||||||
|
</Tabs.Tab>
|
||||||
|
))}
|
||||||
|
</Tabs.List>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
p="md"
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{ background: "white", border: "1px solid var(--mantine-color-gray-2)" }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" gap="md" wrap="wrap">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search order, booking, or transaction…"
|
||||||
|
leftSection={<Search size={18} />}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
|
}}
|
||||||
|
rightSection={
|
||||||
|
query && (
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
variant="transparent"
|
||||||
|
onClick={() => setQuery("")}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ flex: 1, minWidth: "200px" }}
|
||||||
|
radius="lg"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="All methods"
|
||||||
|
clearable
|
||||||
|
data={METHOD_OPTIONS}
|
||||||
|
value={method}
|
||||||
|
onChange={(value) => {
|
||||||
|
setMethod(value);
|
||||||
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
|
}}
|
||||||
|
radius="lg"
|
||||||
|
style={{ minWidth: 180 }}
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount: total,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
|
}}
|
||||||
|
containerClassName={cn(
|
||||||
|
"border-0 shadow-none",
|
||||||
|
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
|
||||||
|
"[&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
|
||||||
|
)}
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { api as client } from "../auth/http";
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
|
||||||
|
const F = URL_CONSTANTS.FILES;
|
||||||
|
|
||||||
|
export const filesService = {
|
||||||
|
/** Stream a stored file by id (backend route: GET /files/:id). */
|
||||||
|
download: async (id: string): Promise<Blob> => {
|
||||||
|
const response = await client.get(F.BY_ID(id), { responseType: "blob" });
|
||||||
|
return response.data as Blob;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Download a file blob and trigger a browser save with the given name. */
|
||||||
|
export async function downloadBookingFile(
|
||||||
|
id: string,
|
||||||
|
filename: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const blob = await filesService.download(id);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { api as client } from "../auth/http";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
|
||||||
|
const P = URL_CONSTANTS.PAYMENTS;
|
||||||
|
|
||||||
|
export type PaymentStatus =
|
||||||
|
| "action-required"
|
||||||
|
| "processing"
|
||||||
|
| "success"
|
||||||
|
| "failed"
|
||||||
|
| "canceled"
|
||||||
|
| "refunded";
|
||||||
|
|
||||||
|
export type PaymentMethod =
|
||||||
|
| "telebirr"
|
||||||
|
| "cbe-birr"
|
||||||
|
| "ebirr"
|
||||||
|
| "waafi"
|
||||||
|
| "card"
|
||||||
|
| "dmoney"
|
||||||
|
| "cac-bank";
|
||||||
|
|
||||||
|
export interface PaymentRow {
|
||||||
|
id: string;
|
||||||
|
bookingId: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
method: PaymentMethod;
|
||||||
|
status: PaymentStatus;
|
||||||
|
merchantOrderId: string | null;
|
||||||
|
paidAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentListFilter {
|
||||||
|
search?: string;
|
||||||
|
status?: string;
|
||||||
|
method?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedPayments {
|
||||||
|
items: PaymentRow[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentSummary {
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
processing: number;
|
||||||
|
failed: number;
|
||||||
|
refunded: number;
|
||||||
|
paidAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paymentsService = {
|
||||||
|
list: async (filter?: PaymentListFilter): Promise<PaginatedPayments> => {
|
||||||
|
const params: Record<string, string | number | undefined> = {};
|
||||||
|
if (filter) {
|
||||||
|
if (filter.search) params.search = filter.search;
|
||||||
|
if (filter.status) params.status = filter.status;
|
||||||
|
if (filter.method) params.method = filter.method;
|
||||||
|
if (filter.page != null) params.page = filter.page;
|
||||||
|
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
||||||
|
}
|
||||||
|
const response = await client.get<PaginatedPayments>(P.ALL, { params });
|
||||||
|
const data = unwrap(response.data) as PaginatedPayments;
|
||||||
|
return {
|
||||||
|
items: data.items ?? [],
|
||||||
|
total: data.total ?? 0,
|
||||||
|
page: data.page ?? 1,
|
||||||
|
pageSize: data.pageSize ?? 10,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getSummary: async (): Promise<PaymentSummary> => {
|
||||||
|
const response = await client.get<PaymentSummary>(P.SUMMARY);
|
||||||
|
return unwrap(response.data) as PaymentSummary;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -30,6 +30,27 @@ export interface BookingNamedRef {
|
|||||||
companyName?: string;
|
companyName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Full company record the booking response joins in (subset used by the UI). */
|
||||||
|
export interface BookingCompany {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
tin?: string | null;
|
||||||
|
vatNumber?: string | null;
|
||||||
|
businessLicense?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
contactPersonName?: string | null;
|
||||||
|
contactPersonPhone?: string | null;
|
||||||
|
generalManagerName?: string | null;
|
||||||
|
generalManagerEmail?: string | null;
|
||||||
|
generalManagerPhone?: string | null;
|
||||||
|
website?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BookingContainerLine {
|
export interface BookingContainerLine {
|
||||||
id: string;
|
id: string;
|
||||||
containerTypeId: string;
|
containerTypeId: string;
|
||||||
@@ -81,6 +102,8 @@ export interface BookingFile {
|
|||||||
name: string;
|
name: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
|
url?: string;
|
||||||
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingDetail {
|
export interface BookingDetail {
|
||||||
@@ -121,7 +144,7 @@ export interface BookingDetail {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
// customer?: BookingNamedRef & { companyName?: string };
|
// customer?: BookingNamedRef & { companyName?: string };
|
||||||
company?: BookingNamedRef;
|
company?: BookingNamedRef & Partial<BookingCompany>;
|
||||||
originYard?: BookingNamedRef;
|
originYard?: BookingNamedRef;
|
||||||
destinationYard?: BookingNamedRef;
|
destinationYard?: BookingNamedRef;
|
||||||
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number };
|
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number };
|
||||||
|
|||||||
@@ -1,23 +1,36 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
/// <reference types="vitest/config" />
|
import { defineConfig } from "vitest/config";
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import type { ViteDevServer, PreviewServer } from "vite";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
function userManagementSpaFallback() {
|
function userManagementSpaFallback() {
|
||||||
const rewrite = (req) => {
|
const rewrite = (req: IncomingMessage) => {
|
||||||
const url = req.url || '';
|
const url = req.url ?? '';
|
||||||
if (!url.startsWith('/_um/') && url !== '/_um') return;
|
if (!url.startsWith('/_um/') && url !== '/_um') return;
|
||||||
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return;
|
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return;
|
||||||
req.url = '/_um/index.html';
|
req.url = '/_um/index.html';
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
name: 'user-management-spa-fallback',
|
name: 'user-management-spa-fallback',
|
||||||
configureServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); },
|
configureServer(s: ViteDevServer) {
|
||||||
configurePreviewServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); },
|
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
|
||||||
|
rewrite(req);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
},
|
||||||
|
configurePreviewServer(s: PreviewServer) {
|
||||||
|
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
|
||||||
|
rewrite(req);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +42,11 @@ export default defineConfig({
|
|||||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||||
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
||||||
},
|
},
|
||||||
|
// Force a single copy of these singletons so MantineProvider context is
|
||||||
|
// shared between the backoffice app and @edr/ui-common (which ships its
|
||||||
|
// own node_modules copy). Without this, two separate @mantine/core
|
||||||
|
// instances are bundled and the context lookup fails at runtime.
|
||||||
|
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5183,
|
port: 5183,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import useAuth from "./hooks/useAuth";
|
|||||||
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
||||||
import MyPortalPage from "./pages/MyPortalPage";
|
import MyPortalPage from "./pages/MyPortalPage";
|
||||||
import ProfilePage from "./pages/ProfilePage";
|
import ProfilePage from "./pages/ProfilePage";
|
||||||
|
import MySignaturePage from "./pages/MySignaturePage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import LoginPage from "./pages/accounts/LoginPage";
|
import LoginPage from "./pages/accounts/LoginPage";
|
||||||
import OnboardingPage from "./pages/accounts/OnboardingPage";
|
import OnboardingPage from "./pages/accounts/OnboardingPage";
|
||||||
@@ -201,6 +202,7 @@ const App = () => {
|
|||||||
<Route path="/tracking" element={<TrackingPage />} />
|
<Route path="/tracking" element={<TrackingPage />} />
|
||||||
<Route path="/billing" element={<BillingPage />} />
|
<Route path="/billing" element={<BillingPage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
<Route path="/signature" element={<MySignaturePage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useDisclosure } from "@mantine/hooks";
|
|||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
FileSignature,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -312,6 +313,12 @@ export function AppLayout({
|
|||||||
>
|
>
|
||||||
Profile
|
Profile
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<FileSignature size={15} />}
|
||||||
|
onClick={() => navigate("/signature")}
|
||||||
|
>
|
||||||
|
My signature
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Settings size={15} />}
|
leftSection={<Settings size={15} />}
|
||||||
onClick={() => navigate("/settings")}
|
onClick={() => navigate("/settings")}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { FileSignature, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
import {
|
||||||
|
useMySignature,
|
||||||
|
useSaveSignature,
|
||||||
|
} from "@/hooks/useSavedSignature";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the signed-in customer view and update the reusable signature stored on
|
||||||
|
* their profile. The same signature is offered for approval when signing a
|
||||||
|
* booking contract.
|
||||||
|
*/
|
||||||
|
export function MySignatureCard() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { data: saved, isPending } = useMySignature();
|
||||||
|
const saveMutation = useSaveSignature();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [signerName, setSignerName] = useState("");
|
||||||
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const defaultName = user?.name?.en || user?.username || user?.email || "";
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
setSignerName(saved?.signerDisplayName ?? defaultName);
|
||||||
|
setSignatureData(null);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!signatureData || !signerName.trim()) return;
|
||||||
|
saveMutation.mutate(
|
||||||
|
{
|
||||||
|
signerDisplayName: signerName.trim(),
|
||||||
|
signatureImageBase64: signatureData,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setOpen(false) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileSignature className="size-5 text-primary" />
|
||||||
|
My signature
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Reused to approve and sign booking contracts.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{isPending ? (
|
||||||
|
<div className="flex h-36 items-center justify-center">
|
||||||
|
<Loader2 className="size-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : saved?.signatureImageUrl ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
|
<img
|
||||||
|
src={saved.signatureImageUrl}
|
||||||
|
alt="My saved signature"
|
||||||
|
className="mx-auto h-36 w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Saved as {saved.signerDisplayName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You have not saved a signature yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={openDialog}>
|
||||||
|
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Save your signature</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Draw your signature below. It will be stored on your profile for
|
||||||
|
future contracts.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="profileSignerName">Full name</Label>
|
||||||
|
<Input
|
||||||
|
id="profileSignerName"
|
||||||
|
value={signerName}
|
||||||
|
onChange={(e) => setSignerName(e.target.value)}
|
||||||
|
placeholder="As shown on contracts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
saveMutation.isPending || !signatureData || !signerName.trim()
|
||||||
|
}
|
||||||
|
onClick={save}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Save signature"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
apps/edr-freight-web/portal/src/constants/apiConfig.ts
Normal file
3
apps/edr-freight-web/portal/src/constants/apiConfig.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||||
|
// export const API_BASE_URL = 'http://localhost:3001';
|
||||||
|
|
||||||
30
apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts
Normal file
30
apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import {
|
||||||
|
signaturesService,
|
||||||
|
type SaveSignaturePayload,
|
||||||
|
} from "@/services/signatures.service";
|
||||||
|
|
||||||
|
const SAVED_SIGNATURE_KEY = ["me", "signature"] as const;
|
||||||
|
|
||||||
|
export function useMySignature() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: SAVED_SIGNATURE_KEY,
|
||||||
|
queryFn: () => signaturesService.getMySignature(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveSignature() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: SaveSignaturePayload) =>
|
||||||
|
signaturesService.saveMySignature(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Signature saved");
|
||||||
|
void qc.invalidateQueries({ queryKey: SAVED_SIGNATURE_KEY });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to save signature"),
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
340
apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts
Normal file
340
apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts
Normal 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" },
|
||||||
|
};
|
||||||
74
apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts
Normal file
74
apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "./MyPortalPage";
|
||||||
19
apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx
Normal file
19
apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { MySignatureCard } from "@/components/profile/MySignatureCard";
|
||||||
|
|
||||||
|
export default function MySignaturePage() {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-8">
|
||||||
|
<div className="mx-auto flex max-w-md flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-black tracking-tight text-foreground">
|
||||||
|
My signature
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Saved and reused to approve and sign booking contracts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<MySignatureCard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,46 +1,51 @@
|
|||||||
import { useSearchParams } from "react-router-dom";
|
import { api } from "@/services/api";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
Container,
|
Container,
|
||||||
Group,
|
Group,
|
||||||
Stack,
|
|
||||||
Title,
|
|
||||||
Text,
|
|
||||||
Tabs,
|
|
||||||
Card,
|
|
||||||
TextInput,
|
|
||||||
Button,
|
|
||||||
Badge,
|
|
||||||
Alert,
|
|
||||||
Center,
|
|
||||||
Loader,
|
Loader,
|
||||||
Grid,
|
Tabs,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Building2,
|
|
||||||
Briefcase,
|
Briefcase,
|
||||||
CheckCircle2,
|
Building2,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
Save,
|
|
||||||
User,
|
User,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
XCircle,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { z } from "zod";
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
|
||||||
import PhoneInput from "@/components/auth/PhoneInput";
|
|
||||||
import TabCompanyProfile from "./settings/TabCompanyProfile";
|
import TabCompanyProfile from "./settings/TabCompanyProfile";
|
||||||
import TabContactPerson from "./settings/TabContactPerson";
|
import TabContactPerson from "./settings/TabContactPerson";
|
||||||
|
import TabDocuments from "./settings/TabDocuments";
|
||||||
import TabGeneralManager from "./settings/TabGeneralManager";
|
import TabGeneralManager from "./settings/TabGeneralManager";
|
||||||
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
|
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
|
||||||
import TabDocuments from "./settings/TabDocuments";
|
|
||||||
|
|
||||||
type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents";
|
type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents";
|
||||||
|
|
||||||
|
function tabIncomplete(tabId: SettingsTab, profile?: ProfileResponse | null): boolean {
|
||||||
|
if (!profile) return false;
|
||||||
|
switch (tabId) {
|
||||||
|
case "company":
|
||||||
|
return !profile.companyEmail || !profile.companyPhone || !profile.companyAddress || !profile.fanNumber;
|
||||||
|
case "contact":
|
||||||
|
return !profile.contactPersonName || !profile.contactPersonPhone;
|
||||||
|
case "gm":
|
||||||
|
return !profile.generalManagerName || !profile.generalManagerEmail || !profile.generalManagerPhone;
|
||||||
|
case "poa":
|
||||||
|
return false;
|
||||||
|
case "documents":
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
||||||
{ id: "company", label: "Company Profile", icon: <Building2 size={16} /> },
|
{ id: "company", label: "Company Profile", icon: <Building2 size={16} /> },
|
||||||
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
|
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
|
||||||
@@ -50,71 +55,68 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const tab = (searchParams.get("tab") as SettingsTab) || "company";
|
const tab = (searchParams.get("tab") as SettingsTab) || "company";
|
||||||
const setTab = (t: SettingsTab) => {
|
const setTab = useCallback(
|
||||||
setSearchParams(
|
(t: SettingsTab) => {
|
||||||
(prev) => {
|
setSearchParams(
|
||||||
const next = new URLSearchParams(prev);
|
(prev) => {
|
||||||
next.set("tab", t);
|
const next = new URLSearchParams(prev);
|
||||||
return next;
|
next.set("tab", t);
|
||||||
},
|
return next;
|
||||||
{ replace: true },
|
},
|
||||||
);
|
{ replace: true },
|
||||||
};
|
);
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
const profileQuery = useQuery(api.companies.getProfile.queryOptions());
|
const profileQuery = useQuery(
|
||||||
|
api.companies.getProfile.queryOptions({
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const profile = profileQuery.data;
|
const profile = profileQuery.data;
|
||||||
|
|
||||||
const createCompanyMutation = useMutation({
|
useEffect(() => {
|
||||||
mutationFn: (payload: CreateCompanyPayload) =>
|
if (profileQuery.dataUpdatedAt > 0) {
|
||||||
api.companies.create.call(payload),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: api.companies.getProfile.queryKey(),
|
queryKey: api.companies.getInfo.queryKey(),
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
});
|
}, [profileQuery.dataUpdatedAt, queryClient]);
|
||||||
|
|
||||||
const onboardingSchema = z.object({
|
const [isOnboarding, setIsOnboarding] = useState<boolean | null>(null);
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
|
||||||
companyEmail: z.string().email("Invalid email address"),
|
|
||||||
companyPhone: z.string().min(1, "Company phone is required"),
|
|
||||||
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
|
|
||||||
companyLocation: z.string().min(1, "Location is required"),
|
|
||||||
companyAddress: z.string().min(1, "Address is required"),
|
|
||||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
|
||||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
|
||||||
});
|
|
||||||
|
|
||||||
type OnboardingFormData = z.infer<typeof onboardingSchema>;
|
useEffect(() => {
|
||||||
|
if (profileQuery.isFetched && isOnboarding === null) {
|
||||||
|
setIsOnboarding(!profileQuery.data);
|
||||||
|
}
|
||||||
|
}, [profileQuery.isFetched, profileQuery.data, isOnboarding]);
|
||||||
|
|
||||||
const {
|
const handleOnboardingSuccess = useCallback(() => {
|
||||||
register,
|
setTab("contact");
|
||||||
handleSubmit,
|
}, [setTab]);
|
||||||
formState: { errors },
|
|
||||||
} = useForm<OnboardingFormData>({
|
|
||||||
resolver: zodResolver(onboardingSchema),
|
|
||||||
defaultValues: {
|
|
||||||
companyPhoneCountryCode: "+251",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmitOnboarding = (data: OnboardingFormData) => {
|
const handleContactContinue = useCallback(() => {
|
||||||
const payload: CreateCompanyPayload = {
|
setTab("gm");
|
||||||
companyType: "customer",
|
}, [setTab]);
|
||||||
companyName: data.companyName,
|
|
||||||
companyEmail: data.companyEmail,
|
const handleGMContinue = useCallback(() => {
|
||||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
setTab("poa");
|
||||||
companyLocation: data.companyLocation,
|
}, [setTab]);
|
||||||
companyAddress: data.companyAddress,
|
|
||||||
tin: data.tinNumber,
|
const handlePOAContinue = useCallback(() => {
|
||||||
fanNumber: data.fanNumber,
|
setTab("documents");
|
||||||
};
|
}, [setTab]);
|
||||||
createCompanyMutation.mutate(payload);
|
|
||||||
};
|
const handleDocumentsContinue = useCallback(() => {
|
||||||
|
navigate("/portal");
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
if (profileQuery.isPending) {
|
if (profileQuery.isPending) {
|
||||||
return (
|
return (
|
||||||
@@ -124,25 +126,54 @@ export default function SettingsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onboarding = isOnboarding === true;
|
||||||
|
|
||||||
|
const renderProfileContent = (children: React.ReactNode) => {
|
||||||
|
if (onboarding && tab !== "company" && !profile) {
|
||||||
|
return (
|
||||||
|
<Center h={200}>
|
||||||
|
<Loader color="edr-green" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!profile) {
|
||||||
|
return (
|
||||||
|
<Card padding="xl">
|
||||||
|
<Center>
|
||||||
|
<Alert
|
||||||
|
icon={<AlertCircle size={24} />}
|
||||||
|
color="gray"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
Please complete the company profile first.
|
||||||
|
</Alert>
|
||||||
|
</Center>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="xl">
|
<Container size="xl" px="lg">
|
||||||
<Group justify="space-between" mb="xl">
|
<Group justify="space-between" mb="xl">
|
||||||
<div>
|
<div>
|
||||||
<Title order={1} size="h2">
|
<Title order={1} size="h2">
|
||||||
Account Settings
|
{onboarding ? "Complete Your Profile" : "Account Settings"}
|
||||||
</Title>
|
</Title>
|
||||||
<Text c="edr-muted" size="sm" mt={4}>
|
<Text c="edr-muted" size="sm" mt={4}>
|
||||||
Manage your company profile, personnel, and documents
|
{onboarding
|
||||||
|
? "Set up your company profile, personnel, and documents to get started"
|
||||||
|
: "Manage your company profile, personnel, and documents"}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{profile && <Badge color="edr-green">Verified</Badge>}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
if (!profile && value !== "company") return;
|
// if (onboarding) return;
|
||||||
setTab(value as SettingsTab);
|
setTab(value as SettingsTab);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -152,7 +183,12 @@ export default function SettingsPage() {
|
|||||||
key={t.id}
|
key={t.id}
|
||||||
value={t.id}
|
value={t.id}
|
||||||
leftSection={t.icon}
|
leftSection={t.icon}
|
||||||
disabled={!profile && t.id !== "company"}
|
disabled={!onboarding && !profile && t.id !== "company"}
|
||||||
|
rightSection={
|
||||||
|
!onboarding && profile && tabIncomplete(t.id, profile) ? (
|
||||||
|
<AlertCircle size={14} color="red" />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -161,201 +197,52 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<Tabs.Panel value="company">
|
<Tabs.Panel value="company">
|
||||||
{!profile ? (
|
{!profile ? (
|
||||||
<Card padding="lg">
|
<TabCompanyProfile
|
||||||
<Stack gap="md">
|
mode="create"
|
||||||
<Group gap="sm">
|
onCreateSuccess={handleOnboardingSuccess}
|
||||||
<Building2 size={20} />
|
/>
|
||||||
<Title order={3}>Company Profile</Title>
|
|
||||||
</Group>
|
|
||||||
<Text c="edr-muted" size="sm">
|
|
||||||
Enter your company registration details to get started
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmitOnboarding)}>
|
|
||||||
<Stack gap="md" mt="lg">
|
|
||||||
<TextInput
|
|
||||||
label="Company Name"
|
|
||||||
placeholder="Global Logistics Ltd"
|
|
||||||
error={errors.companyName?.message}
|
|
||||||
{...register("companyName")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Grid>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<TextInput
|
|
||||||
label="Company Email"
|
|
||||||
type="email"
|
|
||||||
placeholder="ops@company.com"
|
|
||||||
error={errors.companyEmail?.message}
|
|
||||||
{...register("companyEmail")}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<PhoneInput
|
|
||||||
countryCode={{ ...register("companyPhoneCountryCode") }}
|
|
||||||
phone={{
|
|
||||||
...register("companyPhone"),
|
|
||||||
placeholder: "912345678",
|
|
||||||
}}
|
|
||||||
countryCodeError={errors.companyPhoneCountryCode}
|
|
||||||
phoneError={errors.companyPhone}
|
|
||||||
label="Company Phone"
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<TextInput
|
|
||||||
label="Location"
|
|
||||||
placeholder="Addis Ababa, Ethiopia"
|
|
||||||
error={errors.companyLocation?.message}
|
|
||||||
{...register("companyLocation")}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<TextInput
|
|
||||||
label="Address"
|
|
||||||
placeholder="Bole Subcity, Woreda 03"
|
|
||||||
error={errors.companyAddress?.message}
|
|
||||||
{...register("companyAddress")}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<TextInput
|
|
||||||
label="TIN Number (10 digits)"
|
|
||||||
placeholder="1234567890"
|
|
||||||
maxLength={10}
|
|
||||||
error={errors.tinNumber?.message}
|
|
||||||
{...register("tinNumber")}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<TextInput
|
|
||||||
label="FAN Number (16 digits)"
|
|
||||||
placeholder="1234567890123456"
|
|
||||||
maxLength={16}
|
|
||||||
error={errors.fanNumber?.message}
|
|
||||||
{...register("fanNumber")}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Group
|
|
||||||
justify="space-between"
|
|
||||||
mt="xl"
|
|
||||||
pt="md"
|
|
||||||
style={{
|
|
||||||
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group gap="xs">
|
|
||||||
{createCompanyMutation.isSuccess && (
|
|
||||||
<Group gap={6} c="green">
|
|
||||||
<CheckCircle2 size={16} />
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
Profile created successfully
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{createCompanyMutation.isError && (
|
|
||||||
<Group gap={6} c="red">
|
|
||||||
<XCircle size={16} />
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
Failed to create profile
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
leftSection={<Save size={16} />}
|
|
||||||
loading={createCompanyMutation.isPending}
|
|
||||||
>
|
|
||||||
Create Profile
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<TabCompanyProfile profile={profile} />
|
<TabCompanyProfile mode="edit" profile={profile} />
|
||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="contact">
|
<Tabs.Panel value="contact">
|
||||||
{profile ? (
|
{renderProfileContent(
|
||||||
<TabContactPerson profile={profile} />
|
<TabContactPerson
|
||||||
) : (
|
profile={profile!}
|
||||||
<Card padding="xl">
|
mode={onboarding ? "onboarding" : "edit"}
|
||||||
<Center>
|
onContinue={handleContactContinue}
|
||||||
<Alert
|
/>,
|
||||||
icon={<AlertCircle size={24} />}
|
|
||||||
color="gray"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
Please complete the company profile first.
|
|
||||||
</Alert>
|
|
||||||
</Center>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="gm">
|
<Tabs.Panel value="gm">
|
||||||
{profile ? (
|
{renderProfileContent(
|
||||||
<TabGeneralManager profile={profile} />
|
<TabGeneralManager
|
||||||
) : (
|
profile={profile!}
|
||||||
<Card padding="xl">
|
mode={onboarding ? "onboarding" : "edit"}
|
||||||
<Center>
|
onContinue={handleGMContinue}
|
||||||
<Alert
|
/>,
|
||||||
icon={<AlertCircle size={24} />}
|
|
||||||
color="gray"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
Please complete the company profile first.
|
|
||||||
</Alert>
|
|
||||||
</Center>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="poa">
|
<Tabs.Panel value="poa">
|
||||||
{profile ? (
|
{renderProfileContent(
|
||||||
<TabPowerOfAttorney profile={profile} />
|
<TabPowerOfAttorney
|
||||||
) : (
|
profile={profile!}
|
||||||
<Card padding="xl">
|
mode={onboarding ? "onboarding" : "edit"}
|
||||||
<Center>
|
onContinue={handlePOAContinue}
|
||||||
<Alert
|
/>,
|
||||||
icon={<AlertCircle size={24} />}
|
|
||||||
color="gray"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
Please complete the company profile first.
|
|
||||||
</Alert>
|
|
||||||
</Center>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="documents">
|
<Tabs.Panel value="documents">
|
||||||
{profile ? (
|
{renderProfileContent(
|
||||||
<TabDocuments profile={profile} />
|
<TabDocuments
|
||||||
) : (
|
profile={profile!}
|
||||||
<Card padding="xl">
|
mode={onboarding ? "onboarding" : "edit"}
|
||||||
<Center>
|
onContinue={handleDocumentsContinue}
|
||||||
<Alert
|
/>,
|
||||||
icon={<AlertCircle size={24} />}
|
|
||||||
color="gray"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
Please complete the company profile first.
|
|
||||||
</Alert>
|
|
||||||
</Center>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export default function BookingContractPage() {
|
|||||||
const [signOpen, setSignOpen] = useState(false);
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
// When a saved signature exists we offer it for approval first; the customer
|
||||||
|
// can switch to drawing a fresh one.
|
||||||
|
const [drawNew, setDrawNew] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ["booking-contract-view", id],
|
queryKey: ["booking-contract-view", id],
|
||||||
@@ -32,6 +35,31 @@ export default function BookingContractPage() {
|
|||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const savedSignature = data?.savedSignature ?? null;
|
||||||
|
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||||
|
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||||
|
|
||||||
|
const openSign = () => {
|
||||||
|
// Prefill from the saved signature so the customer only has to approve it.
|
||||||
|
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||||
|
setSignatureData(null);
|
||||||
|
setDrawNew(false);
|
||||||
|
setSignOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmSign = () => {
|
||||||
|
if (!signerName.trim()) return;
|
||||||
|
// Approve the saved signature, or submit the freshly drawn one.
|
||||||
|
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||||
|
if (!image) return;
|
||||||
|
signMutation.mutate({
|
||||||
|
role: "CUSTOMER",
|
||||||
|
signatureImageBase64: image,
|
||||||
|
signerDisplayName: signerName.trim(),
|
||||||
|
consentText: "I agree to the terms of this contract.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const signMutation = useMutation({
|
const signMutation = useMutation({
|
||||||
mutationFn: (payload: SignContractPayload) =>
|
mutationFn: (payload: SignContractPayload) =>
|
||||||
bookingsService.signContract(id!, payload),
|
bookingsService.signContract(id!, payload),
|
||||||
@@ -102,9 +130,9 @@ export default function BookingContractPage() {
|
|||||||
PDF
|
PDF
|
||||||
</Button>
|
</Button>
|
||||||
{data.canSignCustomer && (
|
{data.canSignCustomer && (
|
||||||
<Button size="sm" onClick={() => setSignOpen(true)}>
|
<Button size="sm" onClick={openSign}>
|
||||||
<FileSignature className="mr-2 size-4" />
|
<FileSignature className="mr-2 size-4" />
|
||||||
Sign contract
|
{usingSaved ? "Approve & sign" : "Sign contract"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -122,9 +150,13 @@ export default function BookingContractPage() {
|
|||||||
{signOpen && (
|
{signOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 print:hidden">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 print:hidden">
|
||||||
<div className="w-full max-w-md rounded-xl bg-background p-6 shadow-xl">
|
<div className="w-full max-w-md rounded-xl bg-background p-6 shadow-xl">
|
||||||
<h2 className="text-lg font-semibold">Sign contract</h2>
|
<h2 className="text-lg font-semibold">
|
||||||
|
{usingSaved ? "Approve signature" : "Sign contract"}
|
||||||
|
</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{data.reference} — your signature will be stored securely.
|
{usingSaved
|
||||||
|
? `${data.reference} — review your saved signature and approve it.`
|
||||||
|
: `${data.reference} — your signature will be stored securely.`}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
<label className="text-sm font-medium" htmlFor="portalSigner">
|
<label className="text-sm font-medium" htmlFor="portalSigner">
|
||||||
@@ -136,7 +168,29 @@ export default function BookingContractPage() {
|
|||||||
value={signerName}
|
value={signerName}
|
||||||
onChange={(e) => setSignerName(e.target.value)}
|
onChange={(e) => setSignerName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<ContractSignaturePad onChange={setSignatureData} />
|
{usingSaved ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
|
<img
|
||||||
|
src={savedSignatureImage ?? undefined}
|
||||||
|
alt="Saved signature"
|
||||||
|
className="mx-auto h-36 w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-primary underline"
|
||||||
|
onClick={() => {
|
||||||
|
setDrawNew(true);
|
||||||
|
setSignatureData(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Draw a new signature instead
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex justify-end gap-2">
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||||
@@ -145,19 +199,12 @@ export default function BookingContractPage() {
|
|||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
signMutation.isPending ||
|
signMutation.isPending ||
|
||||||
!signatureData ||
|
(!usingSaved && !signatureData) ||
|
||||||
!signerName.trim()
|
!signerName.trim()
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={confirmSign}
|
||||||
signMutation.mutate({
|
|
||||||
role: "CUSTOMER",
|
|
||||||
signatureImageBase64: signatureData!,
|
|
||||||
signerDisplayName: signerName.trim(),
|
|
||||||
consentText: "I agree to the terms of this contract.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Confirm signature
|
{usingSaved ? "Approve & sign" : "Confirm signature"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,17 +28,18 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||||
|
|
||||||
// Two-step flow: POST /payments/initiate to create the intent, then send the
|
// POST /payments/initiate creates the intent and returns the provider's
|
||||||
// browser to the public /payments/checkout page which redirects to the
|
// redirect URL (clientAction.url). Send the browser straight there; fall back
|
||||||
// selected provider to complete payment.
|
// to the public /payments/checkout page if no redirect URL came back.
|
||||||
const payMutation = useMutation({
|
const payMutation = useMutation({
|
||||||
mutationFn: (method: PaymentMethod) =>
|
mutationFn: (method: PaymentMethod) =>
|
||||||
api.payments.initiate.call({ bookingId: booking.id, method }),
|
api.payments.initiate.call({ bookingId: booking.id, method }),
|
||||||
onSuccess: (_data, method) => {
|
onSuccess: (data, method) => {
|
||||||
window.location.href = paymentsService.checkoutUrl({
|
const redirectUrl =
|
||||||
bookingId: booking.id,
|
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||||
method,
|
? data.clientAction.url
|
||||||
});
|
: paymentsService.checkoutUrl({ bookingId: booking.id, method });
|
||||||
|
window.location.href = redirectUrl;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||||
import {
|
import { Smartphone, type LucideIcon } from "lucide-react";
|
||||||
Banknote,
|
|
||||||
Building2,
|
|
||||||
CreditCard,
|
|
||||||
Smartphone,
|
|
||||||
Wallet,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { PaymentMethod } from "@/services/payments.service";
|
import type { PaymentMethod } from "@/services/payments.service";
|
||||||
@@ -18,6 +11,7 @@ interface ProviderOption {
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only Telebirr and Waafi are enabled for now.
|
||||||
const PROVIDERS: ProviderOption[] = [
|
const PROVIDERS: ProviderOption[] = [
|
||||||
{
|
{
|
||||||
method: "TELEBIRR",
|
method: "TELEBIRR",
|
||||||
@@ -25,42 +19,12 @@ const PROVIDERS: ProviderOption[] = [
|
|||||||
description: "Ethiopian mobile money",
|
description: "Ethiopian mobile money",
|
||||||
icon: Smartphone,
|
icon: Smartphone,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: "CBE_BIRR",
|
|
||||||
label: "CBE Birr",
|
|
||||||
description: "Commercial Bank of Ethiopia",
|
|
||||||
icon: Building2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: "EBIRR",
|
|
||||||
label: "E-Birr",
|
|
||||||
description: "Electronic payment gateway",
|
|
||||||
icon: Wallet,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
method: "WAAFI",
|
method: "WAAFI",
|
||||||
label: "WAAFI",
|
label: "Waafi",
|
||||||
description: "Djibouti mobile money",
|
description: "Djibouti mobile money",
|
||||||
icon: Smartphone,
|
icon: Smartphone,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: "CARD",
|
|
||||||
label: "Card",
|
|
||||||
description: "Visa / Mastercard",
|
|
||||||
icon: CreditCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: "DMONEY",
|
|
||||||
label: "D-Money",
|
|
||||||
description: "Djibouti D-money",
|
|
||||||
icon: Banknote,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: "CAC_BANK",
|
|
||||||
label: "CAC Bank",
|
|
||||||
description: "CAC Int Bank (OTP)",
|
|
||||||
icon: Building2,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function ProviderRow({
|
function ProviderRow({
|
||||||
@@ -141,7 +105,7 @@ export function PaymentMethodModal({
|
|||||||
processing?: boolean;
|
processing?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [method, setMethod] = useState<PaymentMethod | null>(null);
|
const [method, setMethod] = useState<PaymentMethod>(PROVIDERS[0].method);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -184,9 +148,9 @@ export function PaymentMethodModal({
|
|||||||
mt={6}
|
mt={6}
|
||||||
radius={10}
|
radius={10}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
disabled={!method || processing}
|
disabled={processing}
|
||||||
loading={processing}
|
loading={processing}
|
||||||
onClick={() => method && onConfirm(method)}
|
onClick={() => onConfirm(method)}
|
||||||
styles={{
|
styles={{
|
||||||
root: { height: 46 },
|
root: { height: 46 },
|
||||||
label: { fontSize: 14, fontWeight: 800 },
|
label: { fontSize: 14, fontWeight: 800 },
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function StatusHero({
|
|||||||
booking: Freight.IBooking;
|
booking: Freight.IBooking;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const status = booking.status as string;
|
const status = booking.status;
|
||||||
const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT;
|
const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT;
|
||||||
const negative = isNegative(status);
|
const negative = isNegative(status);
|
||||||
const draft = isDraftLike(status);
|
const draft = isDraftLike(status);
|
||||||
@@ -45,7 +45,12 @@ export function StatusHero({
|
|||||||
<Group gap={16} align="center" wrap="nowrap">
|
<Group gap={16} align="center" wrap="nowrap">
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center rounded-2xl shrink-0"
|
className="flex items-center justify-center rounded-2xl shrink-0"
|
||||||
style={{ width: 56, height: 56, backgroundColor: tileBg, color: tileFg }}
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
backgroundColor: tileBg,
|
||||||
|
color: tileFg,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<HeroIcon size={26} />
|
<HeroIcon size={26} />
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +82,7 @@ export function StatusHero({
|
|||||||
>
|
>
|
||||||
{chipLabel}
|
{chipLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz="13.5px" fw={700} c="#10202F">
|
<Text fz="sm" fw={700} c="#10202F">
|
||||||
{chipValue}
|
{chipValue}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -100,7 +105,6 @@ export function StatusHero({
|
|||||||
function ProgressTracker({
|
function ProgressTracker({
|
||||||
current,
|
current,
|
||||||
tone = "green",
|
tone = "green",
|
||||||
negative,
|
|
||||||
}: {
|
}: {
|
||||||
current: number;
|
current: number;
|
||||||
tone?: "green" | "ink";
|
tone?: "green" | "ink";
|
||||||
@@ -109,13 +113,17 @@ function ProgressTracker({
|
|||||||
const last = PROGRESS_STAGES.length - 1;
|
const last = PROGRESS_STAGES.length - 1;
|
||||||
const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371";
|
const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371";
|
||||||
const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4";
|
const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4";
|
||||||
const activeSub = tone === "ink" ? "#475569" : "#0A6F4D";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* Scrollable on mobile so 5 stages never overflow */
|
/* Scrollable on mobile so 5 stages never overflow */
|
||||||
<Box
|
<Box
|
||||||
className="overflow-x-auto"
|
className="overflow-x-auto pt-2"
|
||||||
style={{ scrollbarWidth: "none", WebkitOverflowScrolling: "touch" } as React.CSSProperties}
|
style={
|
||||||
|
{
|
||||||
|
scrollbarWidth: "none",
|
||||||
|
WebkitOverflowScrolling: "touch",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-start" style={{ minWidth: 440 }}>
|
<div className="flex items-start" style={{ minWidth: 440 }}>
|
||||||
{PROGRESS_STAGES.map((stage, idx) => {
|
{PROGRESS_STAGES.map((stage, idx) => {
|
||||||
@@ -131,14 +139,15 @@ function ProgressTracker({
|
|||||||
: state === "active"
|
: state === "active"
|
||||||
? activeFill
|
? activeFill
|
||||||
: "#0EA371";
|
: "#0EA371";
|
||||||
const circleBorder = state === "idle" ? "1px solid #E1E7EE" : undefined;
|
const circleBorder =
|
||||||
|
state === "idle" ? "1px solid #E1E7EE" : undefined;
|
||||||
const circleShadow =
|
const circleShadow =
|
||||||
state === "active" ? `0 0 0 4px ${activeRing}` : undefined;
|
state === "active" ? `0 0 0 4px ${activeRing}` : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={stage.label}
|
key={stage.label}
|
||||||
className="flex flex-1 flex-col items-center gap-[10px]"
|
className="flex flex-1 flex-col items-center"
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center">
|
<div className="flex w-full items-center">
|
||||||
{/* left connector */}
|
{/* left connector */}
|
||||||
@@ -147,15 +156,19 @@ function ProgressTracker({
|
|||||||
style={{
|
style={{
|
||||||
height: 3,
|
height: 3,
|
||||||
background:
|
background:
|
||||||
idx === 0 ? "transparent" : reachedLeft ? "#0EA371" : "#E1E7EE",
|
idx === 0
|
||||||
|
? "transparent"
|
||||||
|
: reachedLeft
|
||||||
|
? "#0EA371"
|
||||||
|
: "#E1E7EE",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* stage circle */}
|
{/* stage circle */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center rounded-full shrink-0"
|
className="flex items-center justify-center mb-2 rounded-full shrink-0"
|
||||||
style={{
|
style={{
|
||||||
width: 40,
|
width: 32,
|
||||||
height: 40,
|
height: 32,
|
||||||
backgroundColor: circleBg,
|
backgroundColor: circleBg,
|
||||||
border: circleBorder,
|
border: circleBorder,
|
||||||
boxShadow: circleShadow,
|
boxShadow: circleShadow,
|
||||||
@@ -173,32 +186,22 @@ function ProgressTracker({
|
|||||||
style={{
|
style={{
|
||||||
height: 3,
|
height: 3,
|
||||||
background:
|
background:
|
||||||
idx === last ? "transparent" : reachedRight ? "#0EA371" : "#E1E7EE",
|
idx === last
|
||||||
|
? "transparent"
|
||||||
|
: reachedRight
|
||||||
|
? "#0EA371"
|
||||||
|
: "#E1E7EE",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Text
|
<Text
|
||||||
fz="13.5px"
|
fz="14px"
|
||||||
fw={state === "active" ? 800 : 700}
|
fw={700}
|
||||||
ta="center"
|
ta="center"
|
||||||
c={state === "idle" ? "#9AA8B5" : "#10202F"}
|
c={state === "idle" ? "#9AA8B5" : "#10202F"}
|
||||||
>
|
>
|
||||||
{stage.label}
|
{stage.label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
|
||||||
fz="11.5px"
|
|
||||||
fw={state === "active" ? 700 : 500}
|
|
||||||
ta="center"
|
|
||||||
c={state === "active" ? activeSub : "#9AA8B5"}
|
|
||||||
>
|
|
||||||
{state === "done"
|
|
||||||
? "Completed"
|
|
||||||
: state === "active"
|
|
||||||
? negative
|
|
||||||
? "Stopped"
|
|
||||||
: "In progress"
|
|
||||||
: "Pending"}
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Box, Button, Group, Stack, Text } from "@mantine/core";
|
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||||
import { CheckCircle2, Clock, FileText } from "lucide-react";
|
import { CheckCircle2, Clock } from "lucide-react";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -173,16 +173,16 @@ export function PaymentCard({
|
|||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button
|
{/* <Button */}
|
||||||
fullWidth
|
{/* fullWidth */}
|
||||||
mt={16}
|
{/* mt={16} */}
|
||||||
variant="default"
|
{/* variant="default" */}
|
||||||
radius={10}
|
{/* radius={10} */}
|
||||||
leftSection={<FileText size={17} color="#475569" />}
|
{/* leftSection={<FileText size={17} color="#475569" />} */}
|
||||||
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }}
|
{/* styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }} */}
|
||||||
>
|
{/* > */}
|
||||||
Download invoice
|
{/* Download invoice */}
|
||||||
</Button>
|
{/* </Button> */}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Ship,
|
||||||
Train,
|
Train,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -15,32 +16,41 @@ export const PROGRESS_STAGES = [
|
|||||||
{
|
{
|
||||||
label: "Submitted",
|
label: "Submitted",
|
||||||
icon: ClipboardCheck,
|
icon: ClipboardCheck,
|
||||||
statuses: ["SUBMITTED", "PENDING_APPROVAL"],
|
statuses: ["SUBMITTED"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Approved",
|
label: "Approval",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE", "APPROVED"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Contract",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
statuses: ["CONTRACT_READY", "SIGNED_CUSTOMER"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Payment",
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
statuses: [
|
statuses: [
|
||||||
"APPROVED_PENDING_SIGNATURE",
|
|
||||||
"APPROVED",
|
|
||||||
"CONTRACT_READY",
|
|
||||||
"SIGNED_CUSTOMER",
|
|
||||||
"FULLY_EXECUTED",
|
"FULLY_EXECUTED",
|
||||||
|
"SELECTED_FOR_BATCH",
|
||||||
|
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Loading",
|
||||||
|
icon: Ship,
|
||||||
|
statuses: [
|
||||||
|
"PAID",
|
||||||
|
"PNR_GENERATED",
|
||||||
|
"PENDING_CONSOLIDATION",
|
||||||
|
"CONSOLIDATED",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "In Transit",
|
label: "In Transit",
|
||||||
icon: Train,
|
icon: Train,
|
||||||
statuses: [
|
statuses: ["EXPIRED", "IN_TRANSIT"],
|
||||||
"SELECTED_FOR_BATCH",
|
|
||||||
"EXPIRED",
|
|
||||||
"PNR_GENERATED",
|
|
||||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
|
||||||
"PAID",
|
|
||||||
"IN_TRANSIT",
|
|
||||||
"PENDING_CONSOLIDATION",
|
|
||||||
"CONSOLIDATED",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Complete",
|
label: "Complete",
|
||||||
@@ -72,7 +82,7 @@ export const STATUS_MAP: Record<
|
|||||||
PENDING_APPROVAL: {
|
PENDING_APPROVAL: {
|
||||||
title: "Pending approval",
|
title: "Pending approval",
|
||||||
description: "Your booking is moving through the approval process.",
|
description: "Your booking is moving through the approval process.",
|
||||||
stage: 1,
|
stage: 2,
|
||||||
},
|
},
|
||||||
APPROVED_PENDING_SIGNATURE: {
|
APPROVED_PENDING_SIGNATURE: {
|
||||||
title: "Approved — awaiting signature",
|
title: "Approved — awaiting signature",
|
||||||
@@ -88,71 +98,71 @@ export const STATUS_MAP: Record<
|
|||||||
title: "Contract ready to sign",
|
title: "Contract ready to sign",
|
||||||
description:
|
description:
|
||||||
"Your contract is ready. Review and apply your signature to proceed.",
|
"Your contract is ready. Review and apply your signature to proceed.",
|
||||||
stage: 2,
|
stage: 3,
|
||||||
},
|
},
|
||||||
SIGNED_CUSTOMER: {
|
SIGNED_CUSTOMER: {
|
||||||
title: "Signed — awaiting staff",
|
title: "Signed — awaiting staff",
|
||||||
description:
|
description:
|
||||||
"Your signature has been submitted. Awaiting the final staff signature.",
|
"Your signature has been submitted. Awaiting the final staff signature.",
|
||||||
stage: 2,
|
stage: 3,
|
||||||
},
|
},
|
||||||
FULLY_EXECUTED: {
|
FULLY_EXECUTED: {
|
||||||
title: "Contract fully executed",
|
title: "Contract fully executed",
|
||||||
description: "Signed by all parties. You can now proceed to payment.",
|
description: "Signed by all parties. You can now proceed to payment.",
|
||||||
stage: 2,
|
stage: 4,
|
||||||
},
|
},
|
||||||
SELECTED_FOR_BATCH: {
|
SELECTED_FOR_BATCH: {
|
||||||
title: "Selected for a train — payment due",
|
title: "Selected for a train — payment due",
|
||||||
description:
|
description:
|
||||||
"Your booking was selected for a scheduled train. Complete payment within the pay window to secure your slot.",
|
"Your booking was selected for a scheduled train. Complete payment within the pay window to secure your slot.",
|
||||||
stage: 3,
|
stage: 4,
|
||||||
},
|
},
|
||||||
EXPIRED: {
|
EXPIRED: {
|
||||||
title: "Pay window expired",
|
title: "Pay window expired",
|
||||||
description:
|
description:
|
||||||
"The payment window was missed. You can move this booking to another schedule or cancel it.",
|
"The payment window was missed. You can move this booking to another schedule or cancel it.",
|
||||||
stage: 3,
|
stage: 6,
|
||||||
},
|
},
|
||||||
PNR_GENERATED: {
|
PNR_GENERATED: {
|
||||||
title: "Payment reference generated",
|
title: "Payment reference generated",
|
||||||
description:
|
description:
|
||||||
"A payment reference number has been generated for this booking.",
|
"A payment reference number has been generated for this booking.",
|
||||||
stage: 3,
|
stage: 5,
|
||||||
},
|
},
|
||||||
PAYMENT_VERIFICATION_IN_PROGRESS: {
|
PAYMENT_VERIFICATION_IN_PROGRESS: {
|
||||||
title: "Verifying payment",
|
title: "Verifying payment",
|
||||||
description: "Your payment is being verified.",
|
description: "Your payment is being verified.",
|
||||||
stage: 3,
|
stage: 4,
|
||||||
},
|
},
|
||||||
PAID: {
|
PAID: {
|
||||||
title: "Payment confirmed",
|
title: "Payment confirmed",
|
||||||
description: "Payment has been confirmed for this booking.",
|
description: "Payment has been confirmed for this booking.",
|
||||||
stage: 3,
|
stage: 5,
|
||||||
},
|
},
|
||||||
IN_TRANSIT: {
|
IN_TRANSIT: {
|
||||||
title: "Cargo moving",
|
title: "Cargo moving",
|
||||||
description: "Your shipment is currently moving through the rail network.",
|
description: "Your shipment is currently moving through the rail network.",
|
||||||
stage: 3,
|
stage: 6,
|
||||||
},
|
},
|
||||||
PENDING_CONSOLIDATION: {
|
PENDING_CONSOLIDATION: {
|
||||||
title: "Pending consolidation",
|
title: "Pending consolidation",
|
||||||
description: "Awaiting a consolidation partner shipment.",
|
description: "Awaiting a consolidation partner shipment.",
|
||||||
stage: 3,
|
stage: 5,
|
||||||
},
|
},
|
||||||
CONSOLIDATED: {
|
CONSOLIDATED: {
|
||||||
title: "Consolidated",
|
title: "Consolidated",
|
||||||
description: "Cargo has been consolidated with a partner shipment.",
|
description: "Cargo has been consolidated with a partner shipment.",
|
||||||
stage: 3,
|
stage: 5,
|
||||||
},
|
},
|
||||||
COMPLETED: {
|
COMPLETED: {
|
||||||
title: "Service complete",
|
title: "Service complete",
|
||||||
description: "Cargo delivered and service successfully terminated.",
|
description: "Cargo delivered and service successfully terminated.",
|
||||||
stage: 4,
|
stage: 7,
|
||||||
},
|
},
|
||||||
DELIVERED: {
|
DELIVERED: {
|
||||||
title: "Service complete",
|
title: "Service complete",
|
||||||
description: "Cargo delivered and service successfully terminated.",
|
description: "Cargo delivered and service successfully terminated.",
|
||||||
stage: 4,
|
stage: 7,
|
||||||
},
|
},
|
||||||
REJECTED: {
|
REJECTED: {
|
||||||
title: "Booking rejected",
|
title: "Booking rejected",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useMemo, useRef, type ReactNode } from "react";
|
import { api } from "@/services/api";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import type { Freight } from "@edr/types";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -34,12 +34,17 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import { useMemo, useRef, type ReactNode } from "react";
|
||||||
import { api } from "@/services/api";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
CountChip,
|
||||||
|
DocRow,
|
||||||
|
IconSquare,
|
||||||
|
} from "./BookingDetailPage/components/Documents";
|
||||||
import {
|
import {
|
||||||
BookingFormInputValues,
|
|
||||||
BOOKING_DOCS_SETTING,
|
BOOKING_DOCS_SETTING,
|
||||||
|
BookingFormInputValues,
|
||||||
bookingFormSchema,
|
bookingFormSchema,
|
||||||
getRouteDirection,
|
getRouteDirection,
|
||||||
initialBookingFormValues,
|
initialBookingFormValues,
|
||||||
@@ -48,11 +53,6 @@ import {
|
|||||||
} from "./new-booking-form/schema";
|
} from "./new-booking-form/schema";
|
||||||
import { SelectField } from "./new-booking-form/shared";
|
import { SelectField } from "./new-booking-form/shared";
|
||||||
import { Step5CargoDetails } from "./new-booking-form/steps";
|
import { Step5CargoDetails } from "./new-booking-form/steps";
|
||||||
import {
|
|
||||||
CountChip,
|
|
||||||
DocRow,
|
|
||||||
IconSquare,
|
|
||||||
} from "./BookingDetailPage/components/Documents";
|
|
||||||
|
|
||||||
function yardNameFromBooking(
|
function yardNameFromBooking(
|
||||||
yard: { label?: string; code?: string; name?: string } | undefined | null,
|
yard: { label?: string; code?: string; name?: string } | undefined | null,
|
||||||
@@ -117,8 +117,6 @@ function mapBookingToFormValues(
|
|||||||
shippingLine: (booking as any).shippingLine?.name ?? "",
|
shippingLine: (booking as any).shippingLine?.name ?? "",
|
||||||
consolidationEnabled: booking.allowConsolidation ?? false,
|
consolidationEnabled: booking.allowConsolidation ?? false,
|
||||||
notes: "",
|
notes: "",
|
||||||
// Terms were accepted at creation; editing shouldn't re-gate on them.
|
|
||||||
termsAccepted: true,
|
|
||||||
containers: [],
|
containers: [],
|
||||||
} as BookingFormInputValues;
|
} as BookingFormInputValues;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
import type {
|
||||||
|
CreateBookingPayload,
|
||||||
|
GeneratePriceResponse,
|
||||||
|
} from "@/services/bookings.service";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Alert, Box, Button, Group, Text, Title } from "@mantine/core";
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Check,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Send,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -97,6 +117,58 @@ export default function NewBookingPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createAndPriceMutation = useMutation({
|
||||||
|
mutationFn: async (payload: CreateBookingPayload) => {
|
||||||
|
const booking = await api.bookings.create.call(payload);
|
||||||
|
|
||||||
|
const documents = form.getValues("documents") ?? {};
|
||||||
|
const hasDocs = Object.values(documents).some((value) =>
|
||||||
|
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
||||||
|
);
|
||||||
|
if (hasDocs) {
|
||||||
|
await api.bookings.uploadDocuments.call({
|
||||||
|
id: booking.id,
|
||||||
|
files: documents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricing = await api.bookings.generatePrice.call({ id: booking.id });
|
||||||
|
|
||||||
|
return { bookingId: booking.id, pricing };
|
||||||
|
},
|
||||||
|
onSuccess: ({ bookingId, pricing }) => {
|
||||||
|
setPriceBookingId(bookingId);
|
||||||
|
setPricingData(pricing);
|
||||||
|
setPricingPhase("ready");
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setPricingPhase("idle");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!priceBookingId) throw new Error("No booking to confirm");
|
||||||
|
await api.bookings.submit.call({ id: priceBookingId });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
|
navigate(`/bookings/${priceBookingId}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const abortMutation = useMutation({
|
||||||
|
mutationFn: async (reason: string) => {
|
||||||
|
if (!priceBookingId) throw new Error("No booking to abort");
|
||||||
|
await api.bookings.cancel.call({ id: priceBookingId, reason });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setCancelDialogOpen(false);
|
||||||
|
navigate("/bookings");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const form = useForm<BookingFormInputValues, any, BookingFormValues>({
|
const form = useForm<BookingFormInputValues, any, BookingFormValues>({
|
||||||
defaultValues: initialBookingFormValues,
|
defaultValues: initialBookingFormValues,
|
||||||
resolver: zodResolver(bookingFormSchema),
|
resolver: zodResolver(bookingFormSchema),
|
||||||
@@ -116,6 +188,25 @@ export default function NewBookingPage() {
|
|||||||
return route;
|
return route;
|
||||||
}, [originYard, destinationYard]);
|
}, [originYard, destinationYard]);
|
||||||
|
|
||||||
|
const docValues = form.watch("documents") ?? {};
|
||||||
|
const hasDocuments = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.values(docValues).some((value) =>
|
||||||
|
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
||||||
|
),
|
||||||
|
[docValues],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [pricingPhase, setPricingPhase] = useState<
|
||||||
|
"idle" | "generating" | "ready"
|
||||||
|
>("idle");
|
||||||
|
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
|
||||||
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||||
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
|
|
||||||
async function handleContinue() {
|
async function handleContinue() {
|
||||||
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
@@ -123,14 +214,14 @@ export default function NewBookingPage() {
|
|||||||
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
|
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit((data) => {
|
function buildApiPayload(data: BookingFormValues): CreateBookingPayload {
|
||||||
if (data.contractType === "renewal" && !data.previousContractRef) {
|
if (data.contractType === "renewal" && !data.previousContractRef) {
|
||||||
form.setError("previousContractRef", {
|
form.setError("previousContractRef", {
|
||||||
type: "manual",
|
type: "manual",
|
||||||
message: "Select a previous contract reference.",
|
message: "Select a previous contract reference.",
|
||||||
});
|
});
|
||||||
setStep(1);
|
setStep(1);
|
||||||
return;
|
throw new Error("Validation failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalWeight =
|
const totalWeight =
|
||||||
@@ -141,7 +232,6 @@ export default function NewBookingPage() {
|
|||||||
)
|
)
|
||||||
: Number(data.cargoWeight || 0);
|
: Number(data.cargoWeight || 0);
|
||||||
|
|
||||||
// ── Reference data lookups ──────────────────────────────────────────
|
|
||||||
const shippingLines = referenceData?.shipping_line ?? [];
|
const shippingLines = referenceData?.shipping_line ?? [];
|
||||||
const cargoTree = referenceData?.cargo_type ?? [];
|
const cargoTree = referenceData?.cargo_type ?? [];
|
||||||
const containerGroups = referenceData?.containers ?? [];
|
const containerGroups = referenceData?.containers ?? [];
|
||||||
@@ -174,8 +264,7 @@ export default function NewBookingPage() {
|
|||||||
(s) => s.id === data.serviceTypeId,
|
(s) => s.id === data.serviceTypeId,
|
||||||
)!;
|
)!;
|
||||||
|
|
||||||
// ── Build API payload ───────────────────────────────────────────────
|
return {
|
||||||
const apiPayload: CreateBookingPayload = {
|
|
||||||
scheduledDate: new Date().toISOString(),
|
scheduledDate: new Date().toISOString(),
|
||||||
contractType:
|
contractType:
|
||||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||||
@@ -222,8 +311,25 @@ export default function NewBookingPage() {
|
|||||||
: {}),
|
: {}),
|
||||||
...(cargoFreeText ? { cargoFreeText } : {}),
|
...(cargoFreeText ? { cargoFreeText } : {}),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
createMutation.mutate(apiPayload);
|
const handleDraftSubmit = form.handleSubmit((data) => {
|
||||||
|
try {
|
||||||
|
const apiPayload = buildApiPayload(data);
|
||||||
|
createMutation.mutate(apiPayload);
|
||||||
|
} catch {
|
||||||
|
// validation error already handled
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleGeneratePrice = form.handleSubmit((data) => {
|
||||||
|
try {
|
||||||
|
const apiPayload = buildApiPayload(data);
|
||||||
|
setPricingPhase("generating");
|
||||||
|
createAndPriceMutation.mutate(apiPayload);
|
||||||
|
} catch {
|
||||||
|
// validation error already handled
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -270,7 +376,7 @@ export default function NewBookingPage() {
|
|||||||
id="new-booking-form"
|
id="new-booking-form"
|
||||||
className="flex flex-col"
|
className="flex flex-col"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleDraftSubmit}
|
||||||
>
|
>
|
||||||
<Box flex={1} p="24px">
|
<Box flex={1} p="24px">
|
||||||
<Box mb="lg">
|
<Box mb="lg">
|
||||||
@@ -295,6 +401,24 @@ export default function NewBookingPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{createAndPriceMutation.isError && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
icon={<AlertCircle size={16} />}
|
||||||
|
radius="md"
|
||||||
|
mb="lg"
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
Failed to generate price estimate
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" mt={4} c="red.7">
|
||||||
|
{createAndPriceMutation.error instanceof Error
|
||||||
|
? createAndPriceMutation.error.message
|
||||||
|
: "An unexpected error occurred. Please try again."}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<Step1ContractType form={form} referenceData={referenceData} />
|
<Step1ContractType form={form} referenceData={referenceData} />
|
||||||
)}
|
)}
|
||||||
@@ -326,6 +450,17 @@ export default function NewBookingPage() {
|
|||||||
setStep={setStep}
|
setStep={setStep}
|
||||||
direction={direction!}
|
direction={direction!}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
|
pricingPhase={pricingPhase}
|
||||||
|
pricingData={pricingData}
|
||||||
|
onConfirm={() => confirmMutation.mutate()}
|
||||||
|
onContinueLater={
|
||||||
|
priceBookingId
|
||||||
|
? () => navigate(`/bookings/${priceBookingId}`)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onAbort={() => setCancelDialogOpen(true)}
|
||||||
|
confirmPending={confirmMutation.isPending}
|
||||||
|
abortPending={abortMutation.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -366,24 +501,98 @@ export default function NewBookingPage() {
|
|||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : pricingPhase === "idle" ? (
|
||||||
<Button
|
<Group>
|
||||||
type="submit"
|
<Button
|
||||||
form="new-booking-form"
|
type="submit"
|
||||||
color="edr-green"
|
form="new-booking-form"
|
||||||
radius="md"
|
variant={hasDocuments ? "outline" : "filled"}
|
||||||
loading={createMutation.isPending}
|
color="edr-green"
|
||||||
leftSection={
|
radius="md"
|
||||||
createMutation.isPending ? undefined : <Check size={16} />
|
loading={createMutation.isPending}
|
||||||
}
|
leftSection={
|
||||||
>
|
createMutation.isPending ? undefined : <Check size={16} />
|
||||||
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
|
}
|
||||||
|
>
|
||||||
|
{createMutation.isPending
|
||||||
|
? "Saving Draft..."
|
||||||
|
: "Save as Draft"}
|
||||||
|
</Button>
|
||||||
|
{hasDocuments && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
loading={createAndPriceMutation.isPending}
|
||||||
|
leftSection={
|
||||||
|
createAndPriceMutation.isPending ? undefined : (
|
||||||
|
<Send size={16} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => handleGeneratePrice()}
|
||||||
|
>
|
||||||
|
{createAndPriceMutation.isPending
|
||||||
|
? "Generating price…"
|
||||||
|
: "Submit"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
) : pricingPhase === "generating" ? (
|
||||||
|
<Button type="button" color="edr-green" radius="md" loading>
|
||||||
|
Generating price estimate…
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</form>
|
</form>
|
||||||
{/* <DevTool control={form.control} /> */}
|
|
||||||
|
<Modal
|
||||||
|
opened={cancelDialogOpen}
|
||||||
|
onClose={() => setCancelDialogOpen(false)}
|
||||||
|
title={<Text fw={700}>Abort booking</Text>}
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Are you sure you want to abort this booking? This action cannot be
|
||||||
|
undone.
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
label="Reason for cancellation (optional)"
|
||||||
|
placeholder="e.g. Change of plans…"
|
||||||
|
value={cancelReason}
|
||||||
|
onChange={(e) => setCancelReason(e.currentTarget.value)}
|
||||||
|
radius="md"
|
||||||
|
data-autofocus
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => setCancelDialogOpen(false)}
|
||||||
|
>
|
||||||
|
Keep editing
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
onClick={() =>
|
||||||
|
abortMutation.mutate(
|
||||||
|
cancelReason.trim() || "Aborted by customer",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={abortMutation.isPending}
|
||||||
|
loading={abortMutation.isPending}
|
||||||
|
leftSection={
|
||||||
|
!abortMutation.isPending ? <XCircle size={15} /> : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Yes, abort
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ export const bookingFormSchema = z
|
|||||||
consolidationEnabled: z.boolean(),
|
consolidationEnabled: z.boolean(),
|
||||||
documents: z.record(z.string(), z.any()).default({}),
|
documents: z.record(z.string(), z.any()).default({}),
|
||||||
notes: z.string(),
|
notes: z.string(),
|
||||||
termsAccepted: z.boolean(),
|
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) =>
|
(data) =>
|
||||||
@@ -165,23 +164,13 @@ export const bookingFormSchema = z
|
|||||||
(data) => !(data.cargoType === "container" && data.containers.length === 0),
|
(data) => !(data.cargoType === "container" && data.containers.length === 0),
|
||||||
{ message: "Add at least one container.", path: ["containers"] },
|
{ message: "Add at least one container.", path: ["containers"] },
|
||||||
)
|
)
|
||||||
.refine((data) => data.termsAccepted, {
|
|
||||||
message: "Accept the freight contract terms to submit.",
|
|
||||||
path: ["termsAccepted"],
|
|
||||||
})
|
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.cargoType === "bulk") {
|
if (data.cargoType === "bulk") {
|
||||||
if (!data.cargoTypePath[0]) {
|
if (!data.cargoTypePath[0]) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
path: ["cargoTypePath"],
|
path: ["cargoTypePath"],
|
||||||
message: "Select a freight type.",
|
message: "Select a Cargo type.",
|
||||||
});
|
|
||||||
} else if (!data.cargoTypePath[1]) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["cargoTypePath"],
|
|
||||||
message: "Select a commodity.",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,7 +226,6 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
|||||||
consolidationEnabled: false,
|
consolidationEnabled: false,
|
||||||
documents: {},
|
documents: {},
|
||||||
notes: "",
|
notes: "",
|
||||||
termsAccepted: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||||
@@ -265,7 +253,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
|||||||
],
|
],
|
||||||
5: ["scheduledDate", "trainScheduleId"],
|
5: ["scheduledDate", "trainScheduleId"],
|
||||||
6: ["documents"],
|
6: ["documents"],
|
||||||
7: ["notes", "termsAccepted"],
|
7: ["notes"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ContainerConfig {
|
export interface ContainerConfig {
|
||||||
@@ -288,10 +276,10 @@ export function getRouteDirection(
|
|||||||
return "DOMESTIC";
|
return "DOMESTIC";
|
||||||
}
|
}
|
||||||
if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
|
if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
|
||||||
return "IMPORT";
|
return "EXPORT";
|
||||||
}
|
}
|
||||||
if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
|
if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
|
||||||
return "EXPORT";
|
return "IMPORT";
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,38 +1,39 @@
|
|||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
Modal,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
useMantineTheme,
|
useMantineTheme
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { UseFormReturn } from "react-hook-form";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { BookingFormInputValues, BookingFormValues } from "./schema";
|
|
||||||
import {
|
import {
|
||||||
|
addMonths,
|
||||||
|
eachDayOfInterval,
|
||||||
|
endOfMonth,
|
||||||
|
endOfWeek,
|
||||||
|
format,
|
||||||
|
isSameMonth,
|
||||||
|
isToday,
|
||||||
|
startOfMonth,
|
||||||
|
startOfWeek,
|
||||||
|
} from "date-fns";
|
||||||
|
import {
|
||||||
|
Calendar as CalendarIcon,
|
||||||
|
Check,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Check,
|
|
||||||
Train,
|
|
||||||
Route,
|
|
||||||
Package,
|
Package,
|
||||||
Calendar as CalendarIcon,
|
Route,
|
||||||
|
Train,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
import { api } from "@/services/api";
|
import { BookingFormInputValues, BookingFormValues } from "./schema";
|
||||||
import {
|
|
||||||
format,
|
|
||||||
startOfMonth,
|
|
||||||
endOfMonth,
|
|
||||||
startOfWeek,
|
|
||||||
endOfWeek,
|
|
||||||
eachDayOfInterval,
|
|
||||||
isToday,
|
|
||||||
isSameMonth,
|
|
||||||
addMonths,
|
|
||||||
} from "date-fns";
|
|
||||||
|
|
||||||
interface StepSchedulingProps {
|
interface StepSchedulingProps {
|
||||||
form: UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
|
form: UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
|
||||||
@@ -52,6 +53,7 @@ interface DayData {
|
|||||||
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
const [currentDate, setCurrentDate] = useState(new Date());
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const [selectedDayForModal, setSelectedDayForModal] = useState<DayData | null>(null);
|
||||||
|
|
||||||
const selectedDate = form.watch("scheduledDate");
|
const selectedDate = form.watch("scheduledDate");
|
||||||
const selectedScheduleId = form.watch("trainScheduleId");
|
const selectedScheduleId = form.watch("trainScheduleId");
|
||||||
@@ -148,10 +150,35 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
|||||||
|
|
||||||
const weeksCount = Math.ceil(days.length / 7);
|
const weeksCount = Math.ceil(days.length / 7);
|
||||||
|
|
||||||
|
const handleDayClick = (day: DayData) => {
|
||||||
|
if (day.schedules.length > 1) {
|
||||||
|
setSelectedDayForModal(day);
|
||||||
|
} else if (day.schedules.length === 1) {
|
||||||
|
form.setValue("scheduledDate", day.dateString, {
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
form.setValue("trainScheduleId", day.schedules[0].id, {
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectScheduleFromModal = (scheduleId: string) => {
|
||||||
|
if (selectedDayForModal) {
|
||||||
|
form.setValue("scheduledDate", selectedDayForModal.dateString, {
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
form.setValue("trainScheduleId", scheduleId, {
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
setSelectedDayForModal(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap={24} align="flex-start" wrap="nowrap">
|
<Group gap={24} align="flex-start" wrap="wrap" style={{ width: "100%" }}>
|
||||||
{/* ── Calendar Card ───────────────────────────────────── */}
|
{/* ── Calendar Card ───────────────────────────────────── */}
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Box style={{ flex: 1, minWidth: 300 }}>
|
||||||
<Card p={0} style={{ overflow: "hidden" }}>
|
<Card p={0} style={{ overflow: "hidden" }}>
|
||||||
{/* Card header */}
|
{/* Card header */}
|
||||||
<Group
|
<Group
|
||||||
@@ -244,15 +271,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
|||||||
<DayCell
|
<DayCell
|
||||||
key={di}
|
key={di}
|
||||||
day={d}
|
day={d}
|
||||||
selectedScheduleId={selectedScheduleId}
|
onDayClick={handleDayClick}
|
||||||
onSelectSchedule={(scheduleId, dateString) => {
|
|
||||||
form.setValue("scheduledDate", dateString, {
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
form.setValue("trainScheduleId", scheduleId, {
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -263,7 +282,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ── Side Panel ──────────────────────────────────────── */}
|
{/* ── Side Panel ──────────────────────────────────────── */}
|
||||||
<Stack gap={20} w={340} style={{ flexShrink: 0 }}>
|
<Stack gap={20} style={{ width: 340, flexShrink: 0 }}>
|
||||||
{/* Booking summary card */}
|
{/* Booking summary card */}
|
||||||
<Card p={0} style={{ overflow: "hidden" }}>
|
<Card p={0} style={{ overflow: "hidden" }}>
|
||||||
<Group
|
<Group
|
||||||
@@ -361,17 +380,81 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{/* ── Schedule Selection Modal ──────────────────────────── */}
|
||||||
|
<Modal
|
||||||
|
opened={!!selectedDayForModal}
|
||||||
|
onClose={() => setSelectedDayForModal(null)}
|
||||||
|
title={selectedDayForModal ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEE, MMM d yyyy") : ""}
|
||||||
|
centered
|
||||||
|
size="sm"
|
||||||
|
styles={{
|
||||||
|
header: { borderBottom: `1px solid ${theme.colors["edr-border"][0]}` },
|
||||||
|
body: { padding: 24 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={12}>
|
||||||
|
<Text fz={13} c="edr-muted" fw={500}>
|
||||||
|
Choose a departure time
|
||||||
|
</Text>
|
||||||
|
{selectedDayForModal?.schedules.map((schedule) => (
|
||||||
|
<Button
|
||||||
|
key={schedule.id}
|
||||||
|
variant="outline"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => handleSelectScheduleFromModal(schedule.id)}
|
||||||
|
style={{ height: 64, justifyContent: "flex-start" }}
|
||||||
|
styles={{
|
||||||
|
inner: { justifyContent: "flex-start" },
|
||||||
|
root: {
|
||||||
|
borderColor: theme.colors["edr-border"][0],
|
||||||
|
transition: "all 150ms ease",
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: theme.colors["edr-green"][5],
|
||||||
|
backgroundColor: theme.colors["edr-soft"][0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={16} w="100%">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: theme.radius.md,
|
||||||
|
backgroundColor: theme.colors["edr-soft"][0],
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Train size={24} color={theme.colors["edr-green"][5]} />
|
||||||
|
</Box>
|
||||||
|
<Stack gap={3} style={{ flex: 1, alignItems: "flex-start" }}>
|
||||||
|
<Text fw={700} fz={18} c="edr-text.0">
|
||||||
|
{format(new Date(schedule.scheduleDate), "HH:mm")}
|
||||||
|
</Text>
|
||||||
|
{schedule.trainNumber && (
|
||||||
|
<Text fz={12} c="edr-muted">
|
||||||
|
Train {schedule.trainNumber}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DayCellProps {
|
interface DayCellProps {
|
||||||
day: DayData;
|
day: DayData;
|
||||||
selectedScheduleId: string;
|
onDayClick: (day: DayData) => void;
|
||||||
onSelectSchedule: (scheduleId: string, dateString: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps) {
|
function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
|
|
||||||
if (!d.isCurrentMonth) {
|
if (!d.isCurrentMonth) {
|
||||||
@@ -400,125 +483,113 @@ function DayCell({ day: d, selectedScheduleId, onSelectSchedule }: DayCellProps)
|
|||||||
: "transparent";
|
: "transparent";
|
||||||
|
|
||||||
const cellBorder = d.isSelectedDate
|
const cellBorder = d.isSelectedDate
|
||||||
? `1.5px solid ${theme.colors["edr-green"][5]}`
|
? `2px solid ${theme.colors["edr-green"][5]}`
|
||||||
: d.hasSchedule
|
: d.hasSchedule
|
||||||
? "1px solid #E7E8E5"
|
? `1px solid ${theme.colors["edr-border"][0]}`
|
||||||
: "none";
|
: "none";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
onClick={() => d.hasSchedule && onDayClick(d)}
|
||||||
style={{
|
style={{
|
||||||
height: 92,
|
height: 92,
|
||||||
borderRadius: theme.radius.md,
|
borderRadius: theme.radius.md,
|
||||||
backgroundColor: cellBg,
|
backgroundColor: cellBg,
|
||||||
border: cellBorder,
|
border: cellBorder,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
padding: "4px 6px 6px",
|
padding: "8px 10px 10px",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 4,
|
gap: 4,
|
||||||
|
cursor: d.hasSchedule ? "pointer" : "default",
|
||||||
|
transition: "all 150ms ease",
|
||||||
|
boxShadow: d.hasSchedule && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (d.hasSchedule && !d.isSelectedDate) {
|
||||||
|
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
|
||||||
|
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (d.hasSchedule && !d.isSelectedDate) {
|
||||||
|
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.05)";
|
||||||
|
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Day number + check icon */}
|
{/* Day number + check icon */}
|
||||||
<Group justify="space-between" align="center" style={{ flexShrink: 0 }}>
|
<Group justify="space-between" align="center" style={{ flexShrink: 0 }}>
|
||||||
<Text
|
<Stack gap={0}>
|
||||||
fz={14}
|
<Text
|
||||||
fw={d.hasSchedule ? 800 : 600}
|
fz={16}
|
||||||
c={
|
fw={800}
|
||||||
d.isToday && !d.isSelectedDate
|
c={
|
||||||
? "edr-green.6"
|
d.isToday && !d.isSelectedDate
|
||||||
: d.hasSchedule
|
? "edr-green.6"
|
||||||
? "edr-text.0"
|
: d.hasSchedule
|
||||||
: "edr-muted"
|
? "edr-text.0"
|
||||||
}
|
: "edr-muted"
|
||||||
>
|
}
|
||||||
{d.day}
|
>
|
||||||
</Text>
|
{d.day}
|
||||||
|
</Text>
|
||||||
|
{d.isToday && !d.isSelectedDate && (
|
||||||
|
<Text fz={9} fw={700} c="edr-green.6" style={{ letterSpacing: "0.04em" }}>
|
||||||
|
TODAY
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
{d.isSelectedDate && (
|
{d.isSelectedDate && (
|
||||||
<Check
|
<Box
|
||||||
size={15}
|
style={{
|
||||||
color={theme.colors["edr-green"][5]}
|
width: 24,
|
||||||
strokeWidth={2.5}
|
height: 24,
|
||||||
/>
|
borderRadius: "50%",
|
||||||
|
backgroundColor: theme.colors["edr-green"][5],
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
size={14}
|
||||||
|
color="white"
|
||||||
|
strokeWidth={3}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Departure chips */}
|
{/* Schedule times */}
|
||||||
{d.hasSchedule && (
|
{d.hasSchedule && (
|
||||||
<Stack gap={3} style={{ flex: 1, overflow: "hidden" }}>
|
<Stack gap={3} style={{ flex: 1, overflow: "hidden", minWidth: 0 }}>
|
||||||
{d.schedules.slice(0, 2).map((s) => {
|
{d.schedules.slice(0, 2).map((s) => (
|
||||||
const isChipSelected = s.id === selectedScheduleId;
|
<Group key={s.id} gap={6} align="center" style={{ minWidth: 0 }}>
|
||||||
const isFull = s.remainingWagons <= 0;
|
|
||||||
const canSelect = !isFull;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
<Box
|
||||||
key={s.id}
|
|
||||||
onClick={() =>
|
|
||||||
canSelect && onSelectSchedule(s.id, d.dateString)
|
|
||||||
}
|
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
width: 4,
|
||||||
alignItems: "center",
|
height: 4,
|
||||||
gap: 4,
|
borderRadius: "50%",
|
||||||
borderRadius: 7,
|
backgroundColor: theme.colors["edr-green"][5],
|
||||||
padding: "4px 6px",
|
flexShrink: 0,
|
||||||
cursor: canSelect ? "pointer" : "default",
|
|
||||||
backgroundColor: isChipSelected
|
|
||||||
? theme.colors["edr-green"][5]
|
|
||||||
: isFull
|
|
||||||
? theme.colors["edr-red-soft"][0]
|
|
||||||
: theme.colors["edr-soft"][0],
|
|
||||||
border: `1px solid ${
|
|
||||||
isChipSelected
|
|
||||||
? theme.colors["edr-green"][5]
|
|
||||||
: isFull
|
|
||||||
? "#EFCFCA"
|
|
||||||
: "#BFE3D4"
|
|
||||||
}`,
|
|
||||||
}}
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
fz={12}
|
||||||
|
fw={700}
|
||||||
|
c="edr-text.0"
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
>
|
>
|
||||||
<Box
|
{format(new Date(s.scheduleDate), "HH:mm")}
|
||||||
style={{
|
</Text>
|
||||||
width: 6,
|
</Group>
|
||||||
height: 6,
|
))}
|
||||||
borderRadius: "50%",
|
{d.schedules.length > 2 && (
|
||||||
backgroundColor: isChipSelected
|
<Text fz={11} fw={600} c="edr-green.7" style={{ paddingTop: 2 }}>
|
||||||
? "white"
|
+{d.schedules.length - 2} more
|
||||||
: isFull
|
</Text>
|
||||||
? theme.colors["edr-red"][0]
|
)}
|
||||||
: theme.colors["edr-green"][5],
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
fz={11}
|
|
||||||
fw={700}
|
|
||||||
style={{ flex: 1, minWidth: 0, overflow: "hidden" }}
|
|
||||||
truncate
|
|
||||||
c={
|
|
||||||
isChipSelected ? "white" : isFull ? "edr-red.0" : "edr-green.7"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isFull
|
|
||||||
? "Full"
|
|
||||||
: s.trainNumber
|
|
||||||
? s.trainNumber
|
|
||||||
: `${s.remainingWagons} wgn`}
|
|
||||||
</Text>
|
|
||||||
<ChevronRight
|
|
||||||
size={12}
|
|
||||||
color={
|
|
||||||
isChipSelected
|
|
||||||
? "white"
|
|
||||||
: isFull
|
|
||||||
? theme.colors["edr-muted"][0]
|
|
||||||
: theme.colors["edr-green"][7]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
|
||||||
|
import { Flame, MapPin, Snowflake } from "lucide-react";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
|
||||||
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
|
|
||||||
import type { Freight } from "@edr/types";
|
|
||||||
import {
|
import {
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
@@ -56,25 +56,24 @@ export function Step4Route({
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [yardOptions, destinationYard]);
|
}, [yardOptions, destinationYard]);
|
||||||
console.log({ yardOptions, originYard, destinationYard });
|
|
||||||
const destData = useMemo(() => {
|
const destData = useMemo(() => {
|
||||||
return yardOptions.filter((o) => o.value !== originYard);
|
return yardOptions.filter((o) => o.value !== originYard);
|
||||||
}, [yardOptions, originYard]);
|
}, [yardOptions, originYard]);
|
||||||
|
|
||||||
const direction = getRouteDirection(
|
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
||||||
referenceData?.yard.find((y) => y.id === originYard),
|
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
|
||||||
referenceData?.yard.find((y) => y.name === destinationYard),
|
const direction = getRouteDirection(origin, dest);
|
||||||
);
|
console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
|
||||||
|
|
||||||
const directionStyle: Record<string, string> = {
|
const directionStyle: Record<string, string> = {
|
||||||
export: "bg-sky-50 text-sky-800 border-sky-200",
|
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
|
||||||
import: "bg-amber-50 text-amber-800 border-amber-200",
|
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
|
||||||
domestic: "bg-gray-100 text-gray-600 border-gray-200",
|
DOMESTIC: "bg-gray-100 text-gray-600 border-gray-200",
|
||||||
};
|
};
|
||||||
const directionLabel: Record<string, string> = {
|
const directionLabel: Record<string, string> = {
|
||||||
export: "Export workflow (inside country to outside country)",
|
EXPORT: "Export workflow (inside country to outside country)",
|
||||||
import: "Import workflow (outside country to inside country)",
|
IMPORT: "Import workflow (outside country to inside country)",
|
||||||
domestic: "Domestic corridor",
|
DOMESTIC: "Domestic corridor",
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { Box, Card, Checkbox, SimpleGrid, Text, Textarea } from "@mantine/core";
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
BOOKING_DOCS_SETTING,
|
BOOKING_DOCS_SETTING,
|
||||||
@@ -8,6 +20,7 @@ import {
|
|||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { StepHeader } from "./shared";
|
import { StepHeader } from "./shared";
|
||||||
import type { Freight } from "@/types";
|
import type { Freight } from "@/types";
|
||||||
|
import type { GeneratePriceResponse } from "@/services/bookings.service";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<
|
type BookingForm = UseFormReturn<
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
@@ -20,19 +33,32 @@ export function Step8Review({
|
|||||||
setStep,
|
setStep,
|
||||||
direction,
|
direction,
|
||||||
referenceData,
|
referenceData,
|
||||||
|
pricingPhase = "idle",
|
||||||
|
pricingData,
|
||||||
|
onConfirm,
|
||||||
|
onContinueLater,
|
||||||
|
onAbort,
|
||||||
|
confirmPending = false,
|
||||||
|
abortPending = false,
|
||||||
}: {
|
}: {
|
||||||
form: BookingForm;
|
form: BookingForm;
|
||||||
setStep: (step: number) => void;
|
setStep: (step: number) => void;
|
||||||
direction: Freight.ScheduleTradeDirection;
|
direction: Freight.ScheduleTradeDirection;
|
||||||
referenceData?: Freight.BookingReferenceData;
|
referenceData?: Freight.BookingReferenceData;
|
||||||
|
pricingPhase?: "idle" | "generating" | "ready";
|
||||||
|
pricingData?: GeneratePriceResponse | null;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
onContinueLater?: () => void;
|
||||||
|
onAbort?: () => void;
|
||||||
|
confirmPending?: boolean;
|
||||||
|
abortPending?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const values = form.watch();
|
const values = form.watch();
|
||||||
const errors = form.formState.errors;
|
|
||||||
const serviceType = referenceData?.service.find(
|
const serviceType = referenceData?.service.find(
|
||||||
(s) => s.id === values.serviceTypeId,
|
(s) => s.id === values.serviceTypeId,
|
||||||
);
|
);
|
||||||
|
|
||||||
function Row({
|
function CompactRow({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
target,
|
target,
|
||||||
@@ -42,19 +68,19 @@ export function Step8Review({
|
|||||||
target: number;
|
target: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-4 py-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={500} mt={2} className="truncate">
|
<Text size="sm" fw={500} className="truncate">
|
||||||
{value || "—"}
|
{value || "—"}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setStep(target)}
|
onClick={() => setStep(target)}
|
||||||
className="shrink-0 text-xs font-medium text-emerald-600 hover:underline"
|
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -62,6 +88,28 @@ export function Step8Review({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CompactCard({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card radius="md" p="sm" withBorder className="border-gray-200 bg-white hover:shadow-sm transition-shadow">
|
||||||
|
<Group gap="xs" mb="xs" wrap="nowrap">
|
||||||
|
<Box c="edr-green">{Icon}</Box>
|
||||||
|
<Text size="xs" fw={700} tt="uppercase" c="dimmed" className="tracking-wider">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="xs">{children}</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const containerSummary =
|
const containerSummary =
|
||||||
values.cargoType === "container" && values.containers.length > 0
|
values.cargoType === "container" && values.containers.length > 0
|
||||||
? values.containers
|
? values.containers
|
||||||
@@ -95,146 +143,211 @@ export function Step8Review({
|
|||||||
return child ? `${group.name} — ${child.name}` : group.name;
|
return child ? `${group.name} — ${child.name}` : group.name;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function ReviewCard({
|
const originYardName = referenceData?.yard.find(
|
||||||
title,
|
(y) => y.id === values.originYard,
|
||||||
children,
|
)?.name ?? values.originYard;
|
||||||
}: {
|
|
||||||
title: string;
|
const destinationYardName = referenceData?.yard.find(
|
||||||
children: React.ReactNode;
|
(y) => y.id === values.destinationYard,
|
||||||
}) {
|
)?.name ?? values.destinationYard;
|
||||||
return (
|
|
||||||
<Card radius="lg" withBorder p={0} className="overflow-hidden">
|
|
||||||
<Box
|
|
||||||
px="md"
|
|
||||||
py="sm"
|
|
||||||
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
size="xs"
|
|
||||||
fw={600}
|
|
||||||
tt="uppercase"
|
|
||||||
c="dimmed"
|
|
||||||
className="tracking-wider"
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box px="md" py="xs" className="divide-y divide-gray-100">
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<Stack gap="md">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
title="Review & Submit"
|
title="Review & Submit"
|
||||||
description="Confirm your contract request before sending it for EDR staff review."
|
description="Confirm your contract request before sending it for EDR staff review."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
{/* Pricing Card - Prominent at top */}
|
||||||
<ReviewCard title="Contract & Service">
|
{pricingPhase === "generating" && (
|
||||||
<Row
|
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2">
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader size="sm" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Generating price estimate…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pricingPhase === "ready" && pricingData && (
|
||||||
|
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2 bg-gradient-to-br from-white to-emerald-50/30">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
|
||||||
|
💳 Price Breakdown
|
||||||
|
</Text>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{pricingData.lineItems.map((item) => (
|
||||||
|
<Group key={item.code} justify="space-between" py={2}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{item.amount.toLocaleString()} {item.currency}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<Divider my="xs" />
|
||||||
|
<Group justify="space-between" py={2}>
|
||||||
|
<Text fw={700} size="md">
|
||||||
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text fw={800} size="lg" c="edr-green">
|
||||||
|
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{pricingData.warnings.length > 0 && (
|
||||||
|
<Text size="xs" c="orange.7" mt="xs" p="xs" className="bg-orange-50 rounded">
|
||||||
|
⚠️ {pricingData.warnings.join(", ")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Group mt="md">
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Check size={16} />}
|
||||||
|
onClick={onConfirm}
|
||||||
|
loading={confirmPending}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{confirmPending ? "Confirming…" : "Confirm"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Send size={16} />}
|
||||||
|
onClick={onContinueLater}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Continue later
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
leftSection={!abortPending ? <XCircle size={16} /> : undefined}
|
||||||
|
onClick={onAbort}
|
||||||
|
loading={abortPending}
|
||||||
|
>
|
||||||
|
Abort
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review Details - Compact Cards Grid */}
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="sm" mt="md">
|
||||||
|
<CompactCard icon={<Package size={16} />} title="Contract & Service">
|
||||||
|
<CompactRow
|
||||||
label="Type"
|
label="Type"
|
||||||
value={values.contractType === "new" ? "New Contract" : "Renewal"}
|
value={values.contractType === "new" ? "New Contract" : "Renewal"}
|
||||||
target={1}
|
target={1}
|
||||||
/>
|
/>
|
||||||
<Row label="Service" value={serviceType?.name ?? ""} target={2} />
|
<CompactRow label="Service" value={serviceType?.name ?? ""} target={2} />
|
||||||
</ReviewCard>
|
</CompactCard>
|
||||||
|
|
||||||
<ReviewCard title="First & Last Mile">
|
<CompactCard icon={<Route size={16} />} title="Route">
|
||||||
<Row
|
<CompactRow
|
||||||
|
label="Origin → Destination"
|
||||||
|
value={`${originYardName} → ${destinationYardName}`}
|
||||||
|
target={3}
|
||||||
|
/>
|
||||||
|
<CompactRow
|
||||||
|
label="Workflow"
|
||||||
|
value={
|
||||||
|
direction ? direction.charAt(0).toUpperCase() + direction.slice(1) : ""
|
||||||
|
}
|
||||||
|
target={3}
|
||||||
|
/>
|
||||||
|
</CompactCard>
|
||||||
|
|
||||||
|
<CompactCard icon={<Truck size={16} />} title="Logistics">
|
||||||
|
<CompactRow
|
||||||
label="First Mile"
|
label="First Mile"
|
||||||
value={
|
value={
|
||||||
values.firstMile.enabled
|
values.firstMile.enabled ? values.firstMile.pickUpAddress : "Not requested"
|
||||||
? values.firstMile.pickUpAddress
|
|
||||||
: "Not requested"
|
|
||||||
}
|
}
|
||||||
target={2}
|
target={2}
|
||||||
/>
|
/>
|
||||||
<Row
|
<CompactRow
|
||||||
label="Last Mile"
|
label="Last Mile"
|
||||||
value={
|
value={
|
||||||
values.lastMile.enabled
|
values.lastMile.enabled ? values.lastMile.deliveryAddress : "Not requested"
|
||||||
? values.lastMile.deliveryAddress
|
|
||||||
: "Not requested"
|
|
||||||
}
|
}
|
||||||
target={2}
|
target={2}
|
||||||
/>
|
/>
|
||||||
<Row
|
<CompactRow
|
||||||
label="Equipment Return"
|
label="Equipment Return"
|
||||||
value={
|
value={
|
||||||
values.equipmentReturn === "with_return"
|
values.equipmentReturn === "with_return" ? "With Return" : "Without Return"
|
||||||
? "With Return"
|
|
||||||
: "Without Return"
|
|
||||||
}
|
}
|
||||||
target={2}
|
target={2}
|
||||||
/>
|
/>
|
||||||
<Row
|
<CompactRow
|
||||||
label="Customs Clearing"
|
label="Customs Clearing"
|
||||||
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
|
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
|
||||||
target={2}
|
target={2}
|
||||||
/>
|
/>
|
||||||
</ReviewCard>
|
</CompactCard>
|
||||||
|
|
||||||
<ReviewCard title="Route & Cargo">
|
<CompactCard icon={<Package size={16} />} title="Cargo Details">
|
||||||
<Row
|
<CompactRow
|
||||||
label="Route"
|
|
||||||
value={`${values.originYard} → ${values.destinationYard}`}
|
|
||||||
target={3}
|
|
||||||
/>
|
|
||||||
<Row
|
|
||||||
label="Workflow"
|
|
||||||
value={
|
|
||||||
direction
|
|
||||||
? direction.charAt(0).toUpperCase() + direction.slice(1)
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
target={3}
|
|
||||||
/>
|
|
||||||
<Row
|
|
||||||
label="Weight (VGM)"
|
label="Weight (VGM)"
|
||||||
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
|
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
|
||||||
target={4}
|
target={4}
|
||||||
/>
|
/>
|
||||||
<Row label="Cargo" value={cargoValue} target={4} />
|
<CompactRow label="Cargo Type" value={cargoValue} target={4} />
|
||||||
<Row
|
<CompactRow
|
||||||
label="Modifiers"
|
label="Modifiers"
|
||||||
value={
|
value={
|
||||||
[
|
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
|
||||||
values.isHazardous && "Hazardous",
|
|
||||||
values.isRefrigerated && "Refrigerated",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(", ") || "None"
|
.join(", ") || "None"
|
||||||
}
|
}
|
||||||
target={3}
|
target={3}
|
||||||
/>
|
/>
|
||||||
</ReviewCard>
|
</CompactCard>
|
||||||
|
|
||||||
<ReviewCard title="Container & Wagons">
|
<CompactCard icon={<Package size={16} />} title="Containers">
|
||||||
<Row label="Containers" value={containerSummary || "—"} target={4} />
|
<CompactRow
|
||||||
<Row
|
label="Count & Type"
|
||||||
label="Total VGM"
|
value={containerSummary || "—"}
|
||||||
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
|
|
||||||
target={4}
|
target={4}
|
||||||
/>
|
/>
|
||||||
</ReviewCard>
|
<CompactRow
|
||||||
|
label="Total VGM"
|
||||||
<ReviewCard title="Documents">
|
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
|
||||||
<Row
|
target={4}
|
||||||
label="Attached"
|
|
||||||
value={
|
|
||||||
docsAttached > 0
|
|
||||||
? `${docsAttached} of ${docsTotal} attached`
|
|
||||||
: "None — upload later from the booking page"
|
|
||||||
}
|
|
||||||
target={5}
|
|
||||||
/>
|
/>
|
||||||
</ReviewCard>
|
</CompactCard>
|
||||||
|
|
||||||
|
<CompactCard icon={<FileText size={16} />} title="Documents">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
|
||||||
|
Attached
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{docsAttached > 0
|
||||||
|
? `${docsAttached} of ${docsTotal}`
|
||||||
|
: "None"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep(5)}
|
||||||
|
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CompactCard>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
<Controller
|
<Controller
|
||||||
name="notes"
|
name="notes"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -243,35 +356,13 @@ export function Step8Review({
|
|||||||
{...field}
|
{...field}
|
||||||
id="notes"
|
id="notes"
|
||||||
label="Additional Notes"
|
label="Additional Notes"
|
||||||
placeholder="Any special instructions or notes for EDR operations..."
|
placeholder="Any special instructions or notes for EDR operations…"
|
||||||
rows={3}
|
rows={2}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
size="sm"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</Stack>
|
||||||
<Controller
|
|
||||||
name="termsAccepted"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Checkbox
|
|
||||||
label={
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
I confirm the information is accurate and agree to EDR's{" "}
|
|
||||||
<Text component="span" c="edr-green" fw={500}>
|
|
||||||
freight contract terms and conditions
|
|
||||||
</Text>
|
|
||||||
.
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
checked={field.value}
|
|
||||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
|
||||||
error={fieldState.error?.message ?? errors.termsAccepted?.message}
|
|
||||||
color="edr-green"
|
|
||||||
radius="sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import {
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import PhoneInput from "@/components/auth/PhoneInput";
|
import PhoneInput from "@/components/auth/PhoneInput";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
|
|
||||||
const schema = z.object({
|
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
companyEmail: z.string().email("Invalid email address"),
|
companyEmail: z.string().email("Invalid email address"),
|
||||||
companyPhone: z.string().min(1, "Company phone is required"),
|
companyPhone: z.string().min(1, "Company phone is required"),
|
||||||
@@ -29,29 +30,52 @@ const schema = z.object({
|
|||||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormData = z.infer<typeof schema>;
|
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
|
||||||
|
|
||||||
function splitPhone(fullPhone?: string | null) {
|
export function splitPhone(fullPhone?: string | null) {
|
||||||
if (!fullPhone) return { code: "+251", number: "" };
|
if (!fullPhone) return { code: "+251", number: "" };
|
||||||
const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
|
const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
|
||||||
if (match) return { code: match[1], number: match[2] };
|
if (match) return { code: match[1], number: match[2] };
|
||||||
return { code: "+251", number: fullPhone };
|
return { code: "+251", number: fullPhone };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TabCompanyProfile({ profile }: { profile: ProfileResponse }) {
|
interface TabCompanyProfileProps {
|
||||||
const queryClient = useQueryClient();
|
profile?: ProfileResponse;
|
||||||
|
mode?: "edit" | "create";
|
||||||
|
onCreateSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
const defaultValues = useMemo((): FormData => {
|
export default function TabCompanyProfile({
|
||||||
const phone = splitPhone(profile.companyPhone);
|
profile,
|
||||||
|
mode = "edit",
|
||||||
|
onCreateSuccess,
|
||||||
|
}: TabCompanyProfileProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isCreate = mode === "create";
|
||||||
|
|
||||||
|
const defaultValues = useMemo((): CompanyProfileFormData => {
|
||||||
|
if (profile) {
|
||||||
|
const phone = splitPhone(profile.companyPhone);
|
||||||
|
return {
|
||||||
|
companyName: profile.companyName,
|
||||||
|
companyEmail: profile.companyEmail ?? "",
|
||||||
|
companyPhone: phone.number,
|
||||||
|
companyPhoneCountryCode: phone.code,
|
||||||
|
companyLocation: profile.companyLocation,
|
||||||
|
companyAddress: profile.companyAddress ?? "",
|
||||||
|
tinNumber: profile.tinNumber,
|
||||||
|
fanNumber: profile.fanNumber ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
companyName: profile.companyName,
|
companyName: "",
|
||||||
companyEmail: profile.companyEmail ?? "",
|
companyEmail: "",
|
||||||
companyPhone: phone.number,
|
companyPhone: "",
|
||||||
companyPhoneCountryCode: phone.code,
|
companyPhoneCountryCode: "+251",
|
||||||
companyLocation: profile.companyLocation,
|
companyLocation: "",
|
||||||
companyAddress: profile.companyAddress ?? "",
|
companyAddress: "",
|
||||||
tinNumber: profile.tinNumber,
|
tinNumber: "",
|
||||||
fanNumber: profile.fanNumber ?? "",
|
fanNumber: "",
|
||||||
};
|
};
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
@@ -60,14 +84,15 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
formState: { errors, isDirty },
|
formState: { errors, isDirty },
|
||||||
} = useForm<FormData>({
|
} = useForm<CompanyProfileFormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(COMPANY_PROFILE_SCHEMA),
|
||||||
values: defaultValues,
|
values: defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: FormData) =>
|
mutationFn: async (data: CompanyProfileFormData) => {
|
||||||
api.companies.updateProfile.call({
|
const payload: CreateCompanyPayload = {
|
||||||
|
companyType: "customer",
|
||||||
companyName: data.companyName,
|
companyName: data.companyName,
|
||||||
companyEmail: data.companyEmail,
|
companyEmail: data.companyEmail,
|
||||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
||||||
@@ -75,13 +100,25 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
|
|||||||
companyAddress: data.companyAddress,
|
companyAddress: data.companyAddress,
|
||||||
tin: data.tinNumber,
|
tin: data.tinNumber,
|
||||||
fanNumber: data.fanNumber,
|
fanNumber: data.fanNumber,
|
||||||
}),
|
};
|
||||||
|
|
||||||
|
if (isCreate) {
|
||||||
|
return api.companies.create.call(payload);
|
||||||
|
} else {
|
||||||
|
return api.companies.updateProfile.call(payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
|
});
|
||||||
|
if (isCreate) {
|
||||||
|
onCreateSuccess?.();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (data: FormData) => mutation.mutate(data);
|
const onSubmit = (data: CompanyProfileFormData) => mutation.mutate(data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
@@ -90,7 +127,9 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
|
|||||||
<Title order={3}>Company Profile</Title>
|
<Title order={3}>Company Profile</Title>
|
||||||
</Group>
|
</Group>
|
||||||
<Text c="edr-muted" size="sm" mb="lg">
|
<Text c="edr-muted" size="sm" mb="lg">
|
||||||
Edit your company registration details
|
{isCreate
|
||||||
|
? "Enter your company registration details to get started"
|
||||||
|
: "Edit your company registration details"}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
@@ -115,7 +154,10 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
|
|||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
countryCode={{ ...register("companyPhoneCountryCode") }}
|
countryCode={{ ...register("companyPhoneCountryCode") }}
|
||||||
phone={{ ...register("companyPhone"), placeholder: "912345678" }}
|
phone={{
|
||||||
|
...register("companyPhone"),
|
||||||
|
placeholder: "912345678",
|
||||||
|
}}
|
||||||
countryCodeError={errors.companyPhoneCountryCode}
|
countryCodeError={errors.companyPhoneCountryCode}
|
||||||
phoneError={errors.companyPhone}
|
phoneError={errors.companyPhone}
|
||||||
label="Company Phone"
|
label="Company Phone"
|
||||||
@@ -171,34 +213,40 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
|
|||||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||||
>
|
>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{mutation.isSuccess && (
|
{mutation.isSuccess && !isCreate && (
|
||||||
<Group gap={6} c="green">
|
<Group gap={6} c="green">
|
||||||
<CheckCircle2 size={16} />
|
<CheckCircle2 size={16} />
|
||||||
<Text size="sm" fw={500}>Saved successfully</Text>
|
<Text size="sm" fw={500}>
|
||||||
|
Saved successfully
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
{mutation.isError && (
|
{mutation.isError && (
|
||||||
<Group gap={6} c="red">
|
<Group gap={6} c="red">
|
||||||
<XCircle size={16} />
|
<XCircle size={16} />
|
||||||
<Text size="sm" fw={500}>Save failed</Text>
|
<Text size="sm" fw={500}>
|
||||||
|
{isCreate ? "Failed to create profile" : "Save failed"}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
<Button
|
{!isCreate && (
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
disabled={mutation.isPending || !isDirty}
|
variant="outline"
|
||||||
onClick={() => reset()}
|
disabled={mutation.isPending || !isDirty}
|
||||||
>
|
onClick={() => reset()}
|
||||||
Reset
|
>
|
||||||
</Button>
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
leftSection={<Save size={16} />}
|
leftSection={<Save size={16} />}
|
||||||
loading={mutation.isPending}
|
loading={mutation.isPending}
|
||||||
>
|
>
|
||||||
Save Changes
|
{isCreate ? "Continue" : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -32,7 +32,13 @@ function splitPhone(fullPhone?: string | null) {
|
|||||||
return { code: "+251", number: fullPhone };
|
return { code: "+251", number: fullPhone };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TabContactPerson({ profile }: { profile: ProfileResponse }) {
|
interface TabContactPersonProps {
|
||||||
|
profile: ProfileResponse;
|
||||||
|
mode?: "edit" | "onboarding";
|
||||||
|
onContinue?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TabContactPerson({ profile, mode = "edit", onContinue }: TabContactPersonProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const defaultValues = useMemo((): FormData => {
|
const defaultValues = useMemo((): FormData => {
|
||||||
@@ -62,6 +68,7 @@ export default function TabContactPerson({ profile }: { profile: ProfileResponse
|
|||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
||||||
|
if (mode === "onboarding") onContinue?.();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,20 +123,22 @@ export default function TabContactPerson({ profile }: { profile: ProfileResponse
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
<Button
|
{mode === "edit" && (
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
disabled={mutation.isPending || !isDirty}
|
variant="outline"
|
||||||
onClick={() => reset()}
|
disabled={mutation.isPending || !isDirty}
|
||||||
>
|
onClick={() => reset()}
|
||||||
Reset
|
>
|
||||||
</Button>
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
leftSection={<Save size={16} />}
|
leftSection={<Save size={16} />}
|
||||||
loading={mutation.isPending}
|
loading={mutation.isPending}
|
||||||
>
|
>
|
||||||
Save Changes
|
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
ArrowRight,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -20,7 +21,13 @@ import { companiesService } from "@/services/companies.service";
|
|||||||
import { SmartFileInput } from "@edr/ui-common";
|
import { SmartFileInput } from "@edr/ui-common";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
|
|
||||||
export default function TabDocuments({ profile }: { profile: ProfileResponse }) {
|
interface TabDocumentsProps {
|
||||||
|
profile: ProfileResponse;
|
||||||
|
mode?: "edit" | "onboarding";
|
||||||
|
onContinue?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TabDocuments({ profile, mode = "edit", onContinue }: TabDocumentsProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
|
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
|
||||||
|
|
||||||
@@ -75,7 +82,9 @@ export default function TabDocuments({ profile }: { profile: ProfileResponse })
|
|||||||
{docUploadMutation.isSuccess && (
|
{docUploadMutation.isSuccess && (
|
||||||
<Group gap={6} c="green">
|
<Group gap={6} c="green">
|
||||||
<CheckCircle2 size={16} />
|
<CheckCircle2 size={16} />
|
||||||
<Text size="sm" fw={500}>Documents uploaded successfully</Text>
|
<Text size="sm" fw={500}>
|
||||||
|
{mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
{docUploadMutation.isError && (
|
{docUploadMutation.isError && (
|
||||||
@@ -85,14 +94,37 @@ export default function TabDocuments({ profile }: { profile: ProfileResponse })
|
|||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
{mode === "onboarding" ? (
|
||||||
type="button"
|
<Button
|
||||||
leftSection={<UploadCloud size={16} />}
|
type="button"
|
||||||
loading={docUploadMutation.isPending}
|
leftSection={<ArrowRight size={16} />}
|
||||||
onClick={() => docUploadMutation.mutate(documentFiles)}
|
loading={docUploadMutation.isPending}
|
||||||
>
|
onClick={() => {
|
||||||
Upload Documents
|
const hasFiles = Object.values(documentFiles).some((f) => f !== null);
|
||||||
</Button>
|
if (hasFiles) {
|
||||||
|
docUploadMutation.mutate(documentFiles, {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
||||||
|
onContinue?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onContinue?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
leftSection={<UploadCloud size={16} />}
|
||||||
|
loading={docUploadMutation.isPending}
|
||||||
|
onClick={() => docUploadMutation.mutate(documentFiles)}
|
||||||
|
>
|
||||||
|
Upload Documents
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -34,7 +34,13 @@ function splitPhone(fullPhone?: string | null) {
|
|||||||
return { code: "+251", number: fullPhone };
|
return { code: "+251", number: fullPhone };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TabGeneralManager({ profile }: { profile: ProfileResponse }) {
|
interface TabGeneralManagerProps {
|
||||||
|
profile: ProfileResponse;
|
||||||
|
mode?: "edit" | "onboarding";
|
||||||
|
onContinue?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const defaultValues = useMemo((): FormData => {
|
const defaultValues = useMemo((): FormData => {
|
||||||
@@ -66,6 +72,7 @@ export default function TabGeneralManager({ profile }: { profile: ProfileRespons
|
|||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
||||||
|
if (mode === "onboarding") onContinue?.();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,20 +140,22 @@ export default function TabGeneralManager({ profile }: { profile: ProfileRespons
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
<Button
|
{mode === "edit" && (
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
disabled={mutation.isPending || !isDirty}
|
variant="outline"
|
||||||
onClick={() => reset()}
|
disabled={mutation.isPending || !isDirty}
|
||||||
>
|
onClick={() => reset()}
|
||||||
Reset
|
>
|
||||||
</Button>
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
leftSection={<Save size={16} />}
|
leftSection={<Save size={16} />}
|
||||||
loading={mutation.isPending}
|
loading={mutation.isPending}
|
||||||
>
|
>
|
||||||
Save Changes
|
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -36,11 +36,17 @@ function splitPhone(fullPhone?: string | null) {
|
|||||||
return { code: "+251", number: fullPhone };
|
return { code: "+251", number: fullPhone };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TabPowerOfAttorneyProps {
|
||||||
|
profile: ProfileResponse;
|
||||||
|
mode?: "edit" | "onboarding";
|
||||||
|
onContinue?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export default function TabPowerOfAttorney({
|
export default function TabPowerOfAttorney({
|
||||||
profile,
|
profile,
|
||||||
}: {
|
mode = "edit",
|
||||||
profile: ProfileResponse;
|
onContinue,
|
||||||
}) {
|
}: TabPowerOfAttorneyProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const defaultValues = useMemo((): FormData => {
|
const defaultValues = useMemo((): FormData => {
|
||||||
@@ -49,7 +55,7 @@ export default function TabPowerOfAttorney({
|
|||||||
poaName: profile.poaName ?? "",
|
poaName: profile.poaName ?? "",
|
||||||
poaEmail: profile.poaEmail ?? "",
|
poaEmail: profile.poaEmail ?? "",
|
||||||
poaPhone: phone.number,
|
poaPhone: phone.number,
|
||||||
poaPhoneCountryCode: profile.poaPhone ? phone.code : "",
|
poaPhoneCountryCode: profile.poaPhone ? phone.code : "+251",
|
||||||
poaLocation: profile.poaLocation ?? "",
|
poaLocation: profile.poaLocation ?? "",
|
||||||
poaAddress: profile.poaAddress ?? "",
|
poaAddress: profile.poaAddress ?? "",
|
||||||
};
|
};
|
||||||
@@ -81,6 +87,7 @@ export default function TabPowerOfAttorney({
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: api.companies.getProfile.queryKey(),
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
});
|
});
|
||||||
|
if (mode === "onboarding") onContinue?.();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,11 +106,6 @@ export default function TabPowerOfAttorney({
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text c="edr-muted" size="sm">
|
|
||||||
Power of Attorney details are optional. Fill them in if you have an
|
|
||||||
authorized representative, or leave blank.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Full Name"
|
label="PoA Full Name"
|
||||||
placeholder="Authorized Representative Name"
|
placeholder="Authorized Representative Name"
|
||||||
@@ -177,20 +179,22 @@ export default function TabPowerOfAttorney({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
<Button
|
{mode === "edit" && (
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
disabled={mutation.isPending || !isDirty}
|
variant="outline"
|
||||||
onClick={() => reset()}
|
disabled={mutation.isPending || !isDirty}
|
||||||
>
|
onClick={() => reset()}
|
||||||
Reset
|
>
|
||||||
</Button>
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
leftSection={<Save size={16} />}
|
leftSection={<Save size={16} />}
|
||||||
loading={mutation.isPending}
|
loading={mutation.isPending}
|
||||||
>
|
>
|
||||||
Save Changes
|
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export interface ContractView {
|
|||||||
signedAt: string;
|
signedAt: string;
|
||||||
signatureImageUrl?: string | null;
|
signatureImageUrl?: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
/** Current viewer's reusable saved signature, if they have one. */
|
||||||
|
savedSignature?: {
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageUrl?: string | null;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PriceLineItem {
|
export interface PriceLineItem {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
import { client } from "../utils/api";
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
const P = URL_CONSTANTS.PAYMENTS;
|
const P = URL_CONSTANTS.PAYMENTS;
|
||||||
@@ -57,7 +58,7 @@ function buildCheckoutUrl(payload: {
|
|||||||
method: PaymentMethod;
|
method: PaymentMethod;
|
||||||
platform?: PaymentPlatform;
|
platform?: PaymentPlatform;
|
||||||
}): string {
|
}): string {
|
||||||
const base = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
const base = API_BASE_URL.replace(/\/$/, "");
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
bookingId: payload.bookingId,
|
bookingId: payload.bookingId,
|
||||||
method: payload.method,
|
method: payload.method,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
|
const SIGNATURE_URL = "/api/me/signature";
|
||||||
|
|
||||||
|
export interface SavedSignature {
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveSignaturePayload {
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const signaturesService = {
|
||||||
|
/** The current user's reusable saved signature, or null if none. */
|
||||||
|
getMySignature: async (): Promise<SavedSignature | null> => {
|
||||||
|
const { data } = await client.get(SIGNATURE_URL);
|
||||||
|
return (data.data ?? data) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveMySignature: async (
|
||||||
|
payload: SaveSignaturePayload,
|
||||||
|
): Promise<SavedSignature | null> => {
|
||||||
|
const { data } = await client.put(SIGNATURE_URL, payload);
|
||||||
|
return (data.data ?? data) ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
|
import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
|
||||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
|
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_URL,
|
baseURL: API_BASE_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
function getCookie(name: string): string | undefined {
|
function getCookie(name: string): string | undefined {
|
||||||
|
|||||||
@@ -1,21 +1,38 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vitest/config";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// Pin Mantine to this app's copy. pnpm can install a second @mantine/core under
|
||||||
|
// @edr/ui-common (linked to react@18) while the portal uses react@19 — dedupe
|
||||||
|
// alone does not merge those into one module in production builds.
|
||||||
|
const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core");
|
||||||
|
const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks");
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||||
|
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
||||||
|
"@mantine/core": mantineCore,
|
||||||
|
"@mantine/hooks": mantineHooks,
|
||||||
},
|
},
|
||||||
|
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,4 +37,3 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
};
|
|
||||||
BIN
apps/edr-passenger-web/portal/public/edr-banner.jpg
Normal file
BIN
apps/edr-passenger-web/portal/public/edr-banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
BIN
apps/edr-passenger-web/portal/public/edr-logo.png
Normal file
BIN
apps/edr-passenger-web/portal/public/edr-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user