From d52a5715e08d07451de72ccbad04ab663cc8efc4 Mon Sep 17 00:00:00 2001
From: hagiye
Date: Tue, 2 Jun 2026 10:47:36 +0300
Subject: [PATCH 01/25] Rate Matrix Rules user story
---
apps/edr-freight-web/backoffice/package.json | 1 +
.../backoffice/src/auth/types.ts | 7 +
.../backoffice/src/auth/useAuth.ts | 2 +
.../baselineRatematrix/RateMatrixForm.tsx | 330 ++++++++++++++++++
.../components/ruleEngine/rateMatrixRules.ts | 173 +++++++++
.../src/constants/TANSTACK_QUEY_KEY.ts | 20 +-
.../backoffice/src/constants/URLS.ts | 14 +
.../src/constants/rateMatrixConstants.ts | 60 ++++
.../admin/rateMatrix/RateMatrixApproval.tsx | 123 +++++++
.../rateMatrix/RateMatrixRegistration.tsx | 25 ++
apps/edr-freight-web/backoffice/tsconfig.json | 14 +-
pnpm-lock.yaml | 3 +
12 files changed, 769 insertions(+), 3 deletions(-)
create mode 100644 apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/ruleEngine/rateMatrixRules.ts
create mode 100644 apps/edr-freight-web/backoffice/src/constants/rateMatrixConstants.ts
create mode 100644 apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixApproval.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixRegistration.tsx
diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json
index f39ee4541..7499e38a2 100644
--- a/apps/edr-freight-web/backoffice/package.json
+++ b/apps/edr-freight-web/backoffice/package.json
@@ -27,6 +27,7 @@
"react-hot-toast": "^2.6.0",
"react-router-dom": "^6.27.0",
"recharts": "^3.8.1",
+ "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.0"
},
diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts
index 2d4ecd536..5efcdd828 100644
--- a/apps/edr-freight-web/backoffice/src/auth/types.ts
+++ b/apps/edr-freight-web/backoffice/src/auth/types.ts
@@ -52,3 +52,10 @@ export interface AuthTokens {
export interface LoginResponse extends Partial {
mfaRequired?: boolean;
}
+
+// Additional types for Matrix form test
+export interface User {
+ id: string;
+ name: string;
+ role: "ADMIN" | "MANAGER" | "CHIEF_EXECUTIVE";
+}
diff --git a/apps/edr-freight-web/backoffice/src/auth/useAuth.ts b/apps/edr-freight-web/backoffice/src/auth/useAuth.ts
index e454bc509..74a29cf7e 100644
--- a/apps/edr-freight-web/backoffice/src/auth/useAuth.ts
+++ b/apps/edr-freight-web/backoffice/src/auth/useAuth.ts
@@ -11,3 +11,5 @@ export const useAuth = () => {
return context;
};
+
+
diff --git a/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx b/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx
new file mode 100644
index 000000000..f18389f9e
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx
@@ -0,0 +1,330 @@
+// components/baselineRatematrix/RateMatrixForm.tsx
+import React, { useState, useCallback } from 'react';
+// import { useForm } from 'react-hook-form';
+// import { zodResolver } from '@hookform/resolvers/zod';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner';
+import { z } from 'zod';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { Loader2, Save, Send, Shield, AlertTriangle } from 'lucide-react';
+import { RateTypeSection } from './RateTypeSection';
+import { ConfirmationDialog } from './ConfirmationDialog';
+import { ValidationSummary } from './ValidationSummary';
+import { LoadingScreen } from '@/ui/LoadingScreen';
+import { useRateMatrixAuth } from '@/auth/hooks/useAuth';
+import { useReferenceData } from '@/hooks/useReferenceData';
+import { queryKeys } from '@/constants/queryKeys';
+import { API_URLS } from '@/constants/apiUrls';
+import {
+ RATE_TYPES,
+ RATE_TYPE_LABELS,
+ REQUIRED_RATE_TYPES
+} from '@/constants/rateMatrixConstants';
+import { rateMatrixRulesEngine } from '../../ruleEngine/rateMatrixRules';
+import type { RateEntry } from './types';
+
+const formSchema = z.object({
+ matrixName: z.string().min(1, 'Matrix name is required').max(200),
+ effectiveDate: z.string().min(1, 'Effective date is required'),
+ expiryDate: z.string().optional(),
+ currency: z.string().min(1, 'Currency is required'),
+});
+
+type FormData = z.infer;
+
+const createInitialSections = (): RateEntry[] => {
+ return REQUIRED_RATE_TYPES.map(rateType => ({
+ rateType,
+ entries: [{
+ validFrom: '',
+ validTo: '',
+ }],
+ }));
+};
+
+export function RateMatrixForm() {
+ const [rateSections, setRateSections] = useState(createInitialSections());
+ const [showConfirmation, setShowConfirmation] = useState(false);
+ const [savedMatrixId, setSavedMatrixId] = useState(null);
+ const [validationErrors, setValidationErrors] = useState([]);
+
+ const { isDirector } = useRateMatrixAuth();
+ const { data: referenceData, isLoading: isLoadingReference } = useReferenceData();
+ const queryClient = useQueryClient();
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ matrixName: '',
+ effectiveDate: '',
+ expiryDate: '',
+ currency: 'USD',
+ },
+ });
+
+ // Save draft mutation
+ const saveDraftMutation = useMutation({
+ mutationFn: async (data: FormData & { rateSections: RateEntry[] }) => {
+ const response = await fetch(API_URLS.RATE_MATRIX.DRAFT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ });
+ if (!response.ok) throw new Error('Failed to save draft');
+ return response.json();
+ },
+ onSuccess: (data) => {
+ setSavedMatrixId(data.id);
+ queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all });
+ toast.success('Draft saved successfully');
+ },
+ onError: (error) => {
+ toast.error('Failed to save draft');
+ },
+ });
+
+ // Submit for approval mutation
+ const submitMutation = useMutation({
+ mutationFn: async (matrixId: string) => {
+ const response = await fetch(API_URLS.RATE_MATRIX.SUBMIT(matrixId), {
+ method: 'POST',
+ });
+ if (!response.ok) throw new Error('Failed to submit');
+ return response.json();
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all });
+ toast.success('Rate matrix submitted for executive approval and locked!');
+ setShowConfirmation(false);
+ },
+ onError: (error) => {
+ toast.error('Failed to submit for approval');
+ setShowConfirmation(false);
+ },
+ });
+
+ const handleValidate = useCallback(() => {
+ const validation = rateMatrixRulesEngine.validate(rateSections);
+ setValidationErrors([...validation.errors, ...validation.warnings]);
+
+ if (validation.isValid) {
+ toast.success('All validations passed!');
+ }
+ }, [rateSections]);
+
+ const handleSaveDraft = async () => {
+ const formData = form.getValues();
+ await saveDraftMutation.mutateAsync({
+ ...formData,
+ rateSections,
+ });
+ };
+
+ const handleSubmitClick = async () => {
+ const isFormValid = await form.trigger();
+ if (!isFormValid) return;
+
+ const validation = rateMatrixRulesEngine.validate(rateSections);
+ setValidationErrors([...validation.errors, ...validation.warnings]);
+
+ if (!validation.isValid) {
+ toast.error('Please fix validation errors before submitting');
+ return;
+ }
+
+ setShowConfirmation(true);
+ };
+
+ const handleConfirmSubmit = async () => {
+ const formData = form.getValues();
+
+ try {
+ let matrixId = savedMatrixId;
+
+ if (!matrixId) {
+ const draftResult = await saveDraftMutation.mutateAsync({
+ ...formData,
+ rateSections,
+ });
+ matrixId = draftResult.id;
+ }
+
+ await submitMutation.mutateAsync(matrixId!);
+ } catch (error) {
+ // Error handling done in mutations
+ }
+ };
+
+ if (isLoadingReference) {
+ return ;
+ }
+
+ if (!isDirector) {
+ return (
+
+
+
+ Access Denied
+
+ Only Directors can access the rate matrix registration.
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ Baseline Rate Matrix Registration
+
+
+ Submit a comprehensive rate matrix for executive approval
+
+
+
+ {/* Director Warning */}
+
+
+ Director Notice
+
+ Once submitted, this matrix will be locked pending Chief Executive approval.
+ No edits can be made by any user until authorization is granted.
+
+
+
+
+
+ {/* Confirmation Dialog */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/rateMatrixRules.ts b/apps/edr-freight-web/backoffice/src/components/ruleEngine/rateMatrixRules.ts
new file mode 100644
index 000000000..f0080c5a3
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/rateMatrixRules.ts
@@ -0,0 +1,173 @@
+// ruleEngine/rateMatrixRules.ts
+import { RATE_TYPES, REQUIRED_RATE_TYPES, MATRIX_STATUS } from '@/constants/rateMatrixConstants';
+
+interface RateEntry {
+ rateType: string;
+ entries: Array>;
+}
+
+interface ValidationRule {
+ id: string;
+ description: string;
+ severity: 'error' | 'warning';
+ validate: (data: any) => boolean;
+ message: string;
+}
+
+export class RateMatrixRulesEngine {
+ private rules: ValidationRule[] = [];
+
+ constructor() {
+ this.initializeRules();
+ }
+
+ private initializeRules() {
+ // Rule 1: All rate types must be present
+ this.rules.push({
+ id: 'ALL_TYPES_REQUIRED',
+ description: 'Verify all 13 rate types are included',
+ severity: 'error',
+ validate: (rateSections: RateEntry[]) => {
+ const submittedTypes = rateSections.map(s => s.rateType);
+ return REQUIRED_RATE_TYPES.every(type => submittedTypes.includes(type));
+ },
+ message: 'All 13 rate types must be included in the submission',
+ });
+
+ // Rule 2: Each rate type must have at least one entry
+ this.rules.push({
+ id: 'MINIMUM_ENTRIES',
+ description: 'Each rate type requires at least one rate entry',
+ severity: 'error',
+ validate: (rateSections: RateEntry[]) => {
+ return rateSections.every(section => section.entries.length > 0);
+ },
+ message: 'Each rate type must have at least one rate entry',
+ });
+
+ // Rule 3: Dates must be valid
+ this.rules.push({
+ id: 'VALID_DATES',
+ description: 'Rate entries must have valid date ranges',
+ severity: 'error',
+ validate: (rateSections: RateEntry[]) => {
+ return rateSections.every(section =>
+ section.entries.every(entry => {
+ if (!entry.validFrom) return false;
+ if (entry.validTo && new Date(entry.validTo) <= new Date(entry.validFrom)) {
+ return false;
+ }
+ return true;
+ })
+ );
+ },
+ message: 'All rate entries must have valid dates (Valid To must be after Valid From)',
+ });
+
+ // Rule 4: Rates must be non-negative
+ this.rules.push({
+ id: 'NON_NEGATIVE_RATES',
+ description: 'All rate values must be non-negative',
+ severity: 'error',
+ validate: (rateSections: RateEntry[]) => {
+ const numericFields = ['baseRate', 'ratePerMetricTon', 'ratePerKm',
+ 'ratePerTrip', 'ratePerDay', 'ratePerUnit'];
+
+ return rateSections.every(section =>
+ section.entries.every(entry => {
+ return numericFields.every(field => {
+ const value = entry[field];
+ return value === undefined || value === '' || Number(value) >= 0;
+ });
+ })
+ );
+ },
+ message: 'Rate values cannot be negative',
+ });
+
+ // Rule 5: Business rule - Demurrage free days should be reasonable
+ this.rules.push({
+ id: 'DEMURRAGE_FREE_DAYS',
+ description: 'Demurrage free days should be between 0 and 30',
+ severity: 'warning',
+ validate: (rateSections: RateEntry[]) => {
+ const demurrageSection = rateSections.find(
+ s => s.rateType === RATE_TYPES.DEMURRAGE
+ );
+ if (!demurrageSection) return true;
+
+ return demurrageSection.entries.every(entry => {
+ const freeDays = Number(entry.freeDays);
+ return !freeDays || (freeDays >= 0 && freeDays <= 30);
+ });
+ },
+ message: 'Demurrage free days typically range from 0 to 30 days',
+ });
+
+ // Rule 6: Cancellation fee percentage should be 0-100
+ this.rules.push({
+ id: 'CANCELLATION_FEE_RANGE',
+ description: 'Cancellation fee percentage must be between 0 and 100',
+ severity: 'error',
+ validate: (rateSections: RateEntry[]) => {
+ const cancellationSection = rateSections.find(
+ s => s.rateType === RATE_TYPES.CANCELLATION_FEE
+ );
+ if (!cancellationSection) return true;
+
+ return cancellationSection.entries.every(entry => {
+ const percentage = Number(entry.cancellationFeePercentage);
+ return !percentage || (percentage >= 0 && percentage <= 100);
+ });
+ },
+ message: 'Cancellation fee percentage must be between 0 and 100',
+ });
+ }
+
+ validate(data: RateEntry[]) {
+ const errors: Array<{ ruleId: string; message: string; severity: string }> = [];
+ const warnings: Array<{ ruleId: string; message: string; severity: string }> = [];
+
+ this.rules.forEach(rule => {
+ if (!rule.validate(data)) {
+ const issue = {
+ ruleId: rule.id,
+ message: rule.message,
+ severity: rule.severity,
+ };
+
+ if (rule.severity === 'error') {
+ errors.push(issue);
+ } else {
+ warnings.push(issue);
+ }
+ }
+ });
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ warnings,
+ };
+ }
+
+ // Check if matrix can transition to a new status
+ canTransition(fromStatus: string, toStatus: string, userRole: string): boolean {
+ const transitions: Record> = {
+ [MATRIX_STATUS.DRAFT]: [
+ { to: MATRIX_STATUS.PENDING_APPROVAL, allowedRoles: ['Director'] },
+ ],
+ [MATRIX_STATUS.PENDING_APPROVAL]: [
+ { to: MATRIX_STATUS.ACTIVE, allowedRoles: ['Chief Executive'] },
+ { to: MATRIX_STATUS.REJECTED, allowedRoles: ['Chief Executive'] },
+ ],
+ };
+
+ const allowedTransitions = transitions[fromStatus] || [];
+ const transition = allowedTransitions.find(t => t.to === toStatus);
+
+ return transition ? transition.allowedRoles.includes(userRole) : false;
+ }
+}
+
+export const rateMatrixRulesEngine = new RateMatrixRulesEngine();
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts b/apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts
index 42bfa33ff..34b1ed929 100644
--- a/apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts
@@ -16,5 +16,21 @@ export const QUERY_KEYS = {
ROOT: "customers",
LIST: "list",
BY_ID: "by-id"
- }
-}
\ No newline at end of file
+ },
+ // constants/queryKeys.ts - Add these keys
+
+ rateMatrix: {
+ all: ['rate-matrices'] as const,
+ lists: () => [...QUERY_KEYS.rateMatrix.all, 'list'] as const,
+ list: (filters: Record) => [...QUERY_KEYS.rateMatrix.lists(), filters] as const,
+ details: () => [...QUERY_KEYS.rateMatrix.all, 'detail'] as const,
+ detail: (id: string) => [...QUERY_KEYS.rateMatrix.details(), id] as const,
+ },
+ referenceData: {
+ all: ['reference-data'] as const,
+ ports: () => [...QUERY_KEYS.referenceData.all, 'ports'] as const,
+ cities: () => [...QUERY_KEYS.referenceData.all, 'cities'] as const,
+ containerTypes: () => [...QUERY_KEYS.referenceData.all, 'container-types'] as const,
+ currencies: () => [...QUERY_KEYS.referenceData.all, 'currencies'] as const,
+ },
+};
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 739faf950..380ec7992 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -121,4 +121,18 @@ export const URL_CONSTANTS = {
WEIGHT_LIMIT_RULE_BY_ID: (id: string | number) =>
`/weight-limit-rules/${id}`,
},
+ RATE_MATRIX: {
+ BASE: '/api/rate-matrices',
+ DRAFT: '/api/rate-matrices/draft',
+ SUBMIT: (id: string) => `/api/rate-matrices/${id}/submit`,
+ AUTHORIZE: (id: string) => `/api/rate-matrices/${id}/authorize`,
+ LIST: '/api/rate-matrices',
+ DETAIL: (id: string) => `/api/rate-matrices/${id}`,
+ },
+ REFERENCE: {
+ PORTS: '/api/reference/ports',
+ CITIES: '/api/reference/cities',
+ CONTAINER_TYPES: '/api/reference/container-types',
+ CURRENCIES: '/api/reference/currencies',
+ },
};
diff --git a/apps/edr-freight-web/backoffice/src/constants/rateMatrixConstants.ts b/apps/edr-freight-web/backoffice/src/constants/rateMatrixConstants.ts
new file mode 100644
index 000000000..ff8d3bb80
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/constants/rateMatrixConstants.ts
@@ -0,0 +1,60 @@
+// constants/rateMatrixConstants.ts
+export const RATE_TYPES = {
+ CONTAINER_IMPORT: 'container_import',
+ CONTAINER_EXPORT: 'container_export',
+ BULK_IMPORT: 'bulk_import',
+ BULK_EXPORT: 'bulk_export',
+ INTER_CITY_BULK: 'inter_city_bulk',
+ INTER_CITY_CONTAINER: 'inter_city_container',
+ FIRST_MILE: 'first_mile',
+ LAST_MILE: 'last_mile',
+ DEMURRAGE: 'demurrage',
+ LASHING: 'lashing',
+ DOUBLE_HANDLING: 'double_handling',
+ CONTAINER_WITH_RETURN: 'container_with_return',
+ CANCELLATION_FEE: 'cancellation_fee',
+} as const;
+
+export const RATE_TYPE_LABELS = {
+ [RATE_TYPES.CONTAINER_IMPORT]: 'Container Import Rates',
+ [RATE_TYPES.CONTAINER_EXPORT]: 'Container Export Rates',
+ [RATE_TYPES.BULK_IMPORT]: 'Bulk Import Rates',
+ [RATE_TYPES.BULK_EXPORT]: 'Bulk Export Rates',
+ [RATE_TYPES.INTER_CITY_BULK]: 'Inter City Bulk Rates',
+ [RATE_TYPES.INTER_CITY_CONTAINER]: 'Inter City Container Rates',
+ [RATE_TYPES.FIRST_MILE]: 'First Mile Cost Rates',
+ [RATE_TYPES.LAST_MILE]: 'Last Mile Cost Rates',
+ [RATE_TYPES.DEMURRAGE]: 'Demurrage Cost Rates',
+ [RATE_TYPES.LASHING]: 'Lashing Cost Rates',
+ [RATE_TYPES.DOUBLE_HANDLING]: 'Double Handling Cost Rates',
+ [RATE_TYPES.CONTAINER_WITH_RETURN]: 'Container With Return Cost Rates',
+ [RATE_TYPES.CANCELLATION_FEE]: 'Cancellation Fee Cost Rates',
+} as const;
+
+export const REQUIRED_RATE_TYPES = Object.values(RATE_TYPES);
+
+export const RATE_FIELDS_CONFIG = {
+ [RATE_TYPES.CONTAINER_IMPORT]: [
+ { name: 'portOfLoading', label: 'Port of Loading', type: 'text', required: true },
+ { name: 'portOfDischarge', label: 'Port of Discharge', type: 'text', required: true },
+ { name: 'containerType', label: 'Container Type', type: 'select', required: true },
+ { name: 'baseRate', label: 'Base Rate', type: 'number', required: true, min: 0, step: '0.01' },
+ { name: 'baf', label: 'Bunker Adjustment Factor', type: 'number', required: false, min: 0 },
+ { name: 'caf', label: 'Currency Adjustment Factor', type: 'number', required: false, min: 0 },
+ ],
+ [RATE_TYPES.DEMURRAGE]: [
+ { name: 'containerType', label: 'Container Type', type: 'select', required: true },
+ { name: 'freeDays', label: 'Free Days', type: 'number', required: true, min: 0 },
+ { name: 'ratePerDay', label: 'Rate per Day', type: 'number', required: true, min: 0 },
+ { name: 'maximumDays', label: 'Maximum Days', type: 'number', required: false, min: 1 },
+ ],
+ // ... define for all 13 rate types
+} as const;
+
+export const MATRIX_STATUS = {
+ DRAFT: 'draft',
+ PENDING_APPROVAL: 'pending_approval',
+ ACTIVE: 'active',
+ REJECTED: 'rejected',
+ EXPIRED: 'expired',
+} as const;
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixApproval.tsx b/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixApproval.tsx
new file mode 100644
index 000000000..b44b5f3d4
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixApproval.tsx
@@ -0,0 +1,123 @@
+// pages/admin/rateMatrix/RateMatrixApproval.tsx
+import React from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { LoadingScreen } from '@/ui/LoadingScreen';
+import { useRateMatrixAuth } from '@/auth/hooks/useAuth';
+import { queryKeys } from '../../../constants/QUERY_KEYS';
+//import { MATRIX_STATUS } from '@/constants/rateMatrixConstants';
+import { toast } from 'sonner';
+
+const API_URLS = {
+ RATE_MATRIX: {
+ LIST: '/api/rate-matrices',
+ AUTHORIZE: (id: string) => `/api/rate-matrices/${id}/authorize`,
+ },
+};
+import { Navigate } from 'react-router-dom';
+
+export default function RateMatrixApprovalPage() {
+ const { isChiefExecutive } = useRateMatrixAuth();
+ const queryClient = useQueryClient();
+ const pendingMatricesQueryKey = [...queryKeys.rateMatrix.all, 'pending-approval'];
+
+ const { data: pendingMatrices, isLoading } = useQuery({
+ queryKey: pendingMatricesQueryKey,
+ queryFn: async () => {
+ const response = await fetch(`${API_URLS.RATE_MATRIX.LIST}?status=pending_approval`);
+ return response.json();
+ },
+ });
+
+ const authorizeMutation = useMutation({
+ mutationFn: async ({ matrixId, signature }: { matrixId: string; signature: string }) => {
+ const response = await fetch(API_URLS.RATE_MATRIX.AUTHORIZE(matrixId), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ digitalSignature: signature }),
+ });
+ if (!response.ok) throw new Error('Authorization failed');
+ return response.json();
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: pendingMatricesQueryKey });
+ toast.success('Rate matrix authorized successfully!');
+ },
+ onError: () => {
+ toast.error('Failed to authorize rate matrix');
+ },
+ });
+
+ if (!isChiefExecutive) {
+ return ;
+ }
+
+ if (isLoading) return ;
+
+ return (
+
+
Pending Rate Matrix Approvals
+
+
+ {pendingMatrices?.map((matrix: any) => (
+
+
+
+ {matrix.matrixName}
+ {matrix.status}
+
+
+
+
+
+
+
Effective Date
+
{matrix.effectiveDate}
+
+
+
Submitted By
+
{matrix.createdBy}
+
+
+
+
+
Rate Types Included:
+
+ {matrix.rateEntries?.map((entry: any) => (
+
+ {entry.rateType}
+
+ ))}
+
+
+
+
+ {
+ // Implement digital signature collection
+ const signature = prompt('Enter digital signature:');
+ if (signature) {
+ authorizeMutation.mutate({
+ matrixId: matrix.id,
+ signature
+ });
+ }
+ }}
+ disabled={authorizeMutation.isPending}
+ >
+ Authorize & Release
+
+
+ Request Changes
+
+
+
+
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixRegistration.tsx b/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixRegistration.tsx
new file mode 100644
index 000000000..fc6855dc3
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixRegistration.tsx
@@ -0,0 +1,25 @@
+// pages/admin/rateMatrix/RateMatrixRegistration.tsx
+import React from 'react';
+import { RateMatrixForm } from '@/components/baselineRatematrix/RateMatrixForm';
+import { useRateMatrixAuth } from '../../../auth/hooks/useAuth';
+import { Navigate } from 'react-router-dom';
+// Local lightweight fallback for LoadingScreen to avoid import errors
+const LoadingScreen: React.FC<{ message?: string }> = ({ message = 'Loading...' }) => (
+
+);
+
+export default function RateMatrixRegistrationPage() {
+ const { isDirector, isLoading } = useRateMatrixAuth();
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!isDirector) {
+ return ;
+ }
+
+ return ;
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/tsconfig.json b/apps/edr-freight-web/backoffice/tsconfig.json
index 1ffef600d..04f44ca2b 100644
--- a/apps/edr-freight-web/backoffice/tsconfig.json
+++ b/apps/edr-freight-web/backoffice/tsconfig.json
@@ -2,6 +2,18 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
- { "path": "./tsconfig.node.json" }
+ { "path": "./tsconfig.node.json" },
+ {
+ "compilerOptions": {
+ "baseUrl": "src",
+ "paths": {
+ "@/*": ["*"],
+
+ "@constants/*": ["constants/*"],
+ "@components/*": ["components/*"]
+ }
+ }
+}
+
]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b9b737e64..db3a0db68 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -210,6 +210,9 @@ importers:
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6)(redux@5.0.1)
+ sonner:
+ specifier: ^2.0.7
+ version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
From 4ed0e22f5561ae0bd924171f988ef4998ea4bf67 Mon Sep 17 00:00:00 2001
From: marshal
Date: Wed, 3 Jun 2026 15:45:38 +0300
Subject: [PATCH 02/25] refactor(bookings): replace RFQ/quotation flow with
submit, staff review, approval routing, and payment stubs
---
.../1749200000000-BookingFlowRefactor.ts | 50 +++
.../bookings/booking-contract.service.ts | 106 +++++
.../bookings/booking-payment.service.ts | 130 ++++++
.../bookings/booking-pricing.service.ts | 248 +++++++++++
.../modules/bookings/booking-status.util.ts | 10 +
.../bookings/booking-transition.service.ts | 291 +++++++++++++
.../modules/bookings/bookings.controller.ts | 394 ++++++++++++------
.../src/modules/bookings/bookings.module.ts | 13 +-
.../modules/bookings/bookings.repository.ts | 115 ++++-
.../src/modules/bookings/bookings.service.ts | 223 ++--------
.../dto/generate-price-response.dto.ts | 32 ++
.../bookings/dto/request-changes.dto.ts | 74 ++++
.../modules/bookings/dto/update-status.dto.ts | 41 --
.../entities/booking-review-note.entity.ts | 26 ++
.../bookings/entities/booking.entity.ts | 45 +-
.../bookings/payments-webhook.controller.ts | 17 +
16 files changed, 1448 insertions(+), 367 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-status.util.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
delete mode 100644 apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts
diff --git a/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts b/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts
new file mode 100644
index 000000000..162672727
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts
@@ -0,0 +1,50 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class BookingFlowRefactor1749200000000 implements MigrationInterface {
+ name = 'BookingFlowRefactor1749200000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.booking_review_note (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
+ author_id UUID,
+ note TEXT NOT NULL,
+ type VARCHAR(30) NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ deleted_at TIMESTAMPTZ
+ );
+ CREATE INDEX IF NOT EXISTS idx_booking_review_note_booking_id
+ ON freight.booking_review_note(booking_id);
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ADD COLUMN IF NOT EXISTS marketing_approved_by_id UUID,
+ ADD COLUMN IF NOT EXISTS marketing_approved_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS contract_summary TEXT,
+ ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ;
+ `);
+
+ await queryRunner.query(`
+ UPDATE freight.bookings SET status = 'SUBMITTED'
+ WHERE status IN ('RFQ_SUBMITTED', 'QUOTATION_SENT', 'QUOTATION_APPROVED');
+ UPDATE freight.bookings SET status = 'REJECTED'
+ WHERE status = 'QUOTATION_REJECTED';
+ UPDATE freight.bookings SET status = 'CANCELLED'
+ WHERE status = 'CANCELLED';
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ DROP COLUMN IF EXISTS locked_at,
+ DROP COLUMN IF EXISTS contract_summary,
+ DROP COLUMN IF EXISTS marketing_approved_at,
+ DROP COLUMN IF EXISTS marketing_approved_by_id;
+ `);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_review_note;`);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts
new file mode 100644
index 000000000..65c317eaf
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts
@@ -0,0 +1,106 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { Readable } from 'stream';
+
+import { FilesService } from '../files/files.service';
+import { BookingsRepository } from './bookings.repository';
+import { Booking } from './entities/booking.entity';
+import { assertBookingStatus } from './booking-status.util';
+
+@Injectable()
+export class BookingContractService {
+ constructor(
+ private readonly bookingsRepository: BookingsRepository,
+ private readonly filesService: FilesService,
+ ) {}
+
+ buildContractSummary(booking: Booking): string {
+ const direction =
+ booking.tradeDirection === 'IMPORT'
+ ? 'Import'
+ : booking.tradeDirection === 'EXPORT'
+ ? 'Export'
+ : booking.tradeDirection;
+
+ const cargo = booking.cargoType;
+ const isBulk = cargo?.requiresDirectorApproval;
+
+ let cargoLabel: string;
+ if (isBulk) {
+ cargoLabel = `Bulk (${booking.cargoFreeText || cargo?.cargoTypeName || 'Commodity'})`;
+ } else {
+ const lines =
+ booking.bookingContainers?.map((bc) => {
+ const label = bc.containerType?.label ?? bc.containerType?.code ?? 'Container';
+ return `${bc.quantity}× ${label}`;
+ }) ?? [];
+ cargoLabel =
+ lines.length > 0
+ ? `Container (${lines.join(', ')})`
+ : `Container (${cargo?.cargoTypeName ?? 'Standard'})`;
+ }
+
+ return `Operation: ${direction} | Cargo Type: ${cargoLabel}`;
+ }
+
+ async getSummary(bookingId: string): Promise<{ summary: string }> {
+ const booking = await this.requireBooking(bookingId);
+ const summary = booking.contractSummary ?? this.buildContractSummary(booking);
+ return { summary };
+ }
+
+ async generateContract(bookingId: string): Promise {
+ const booking = await this.requireBooking(bookingId);
+ assertBookingStatus(booking, ['APPROVED']);
+
+ const summary = this.buildContractSummary(booking);
+ const body = [
+ 'FREIGHT CONTRACT (STUB)',
+ `Reference: ${booking.reference}`,
+ summary,
+ `Total: ${booking.totalAmount} ${booking.paymentCurrency}`,
+ `Trade: ${booking.tradeDirection}`,
+ ].join('\n');
+
+ const buffer = Buffer.from(body, 'utf-8');
+ const file: Express.Multer.File = {
+ fieldname: 'contract',
+ originalname: `contract-${booking.reference}.txt`,
+ encoding: '7bit',
+ mimetype: 'text/plain',
+ size: buffer.length,
+ buffer,
+ stream: Readable.from(buffer),
+ destination: '',
+ filename: '',
+ path: '',
+ };
+
+ await this.filesService.upload({
+ resourceId: bookingId,
+ resource: 'bookings',
+ code: 'contract',
+ file,
+ });
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'CONTRACT_READY',
+ contractSummary: summary,
+ } as never);
+ return updated!;
+ }
+
+ async streamContract(bookingId: string) {
+ const record = await this.filesService.findByCode(
+ bookingId,
+ 'bookings',
+ 'contract',
+ );
+ return this.filesService.streamById(record.id);
+ }
+
+ private async requireBooking(id: string): Promise {
+ const booking = await this.bookingsRepository.findByIdWithFiles(id);
+ if (!booking) throw new NotFoundException(`Booking ${id} not found`);
+ return booking;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts
new file mode 100644
index 000000000..80476ead2
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts
@@ -0,0 +1,130 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { FilesService } from '../files/files.service';
+import { BookingsRepository } from './bookings.repository';
+import { Booking } from './entities/booking.entity';
+import { assertBookingStatus } from './booking-status.util';
+
+const PROOF_MAX_BYTES = 5 * 1024 * 1024;
+const PROOF_MIMES = ['application/pdf', 'image/jpeg', 'image/png'];
+
+@Injectable()
+export class BookingPaymentService {
+ constructor(
+ private readonly bookingsRepository: BookingsRepository,
+ private readonly filesService: FilesService,
+ ) {}
+
+ async generatePnr(bookingId: string): Promise {
+ const booking = await this.requireBooking(bookingId);
+ assertBookingStatus(booking, ['FULLY_EXECUTED']);
+
+ if (booking.paymentCurrency !== 'ETB') {
+ throw new BadRequestException('PNR generation is only for ETB payers');
+ }
+
+ const year = new Date().getFullYear();
+ const pnrCode = `PNR-${year}-${Math.random().toString(36).slice(2, 10).toUpperCase()}`;
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'PNR_GENERATED',
+ pnrCode,
+ paymentStatus: 'PNR_GENERATED',
+ } as never);
+ return updated!;
+ }
+
+ async submitPaymentProof(
+ bookingId: string,
+ file: Express.Multer.File,
+ ): Promise {
+ const booking = await this.requireBooking(bookingId);
+ assertBookingStatus(booking, ['FULLY_EXECUTED']);
+
+ if (booking.paymentCurrency !== 'USD') {
+ throw new BadRequestException('Payment proof upload is only for USD payers');
+ }
+
+ this.validateProofFile(file);
+
+ await this.filesService.upload({
+ resourceId: bookingId,
+ resource: 'bookings',
+ code: 'payment_proof',
+ file,
+ });
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'PAYMENT_VERIFICATION_IN_PROGRESS',
+ paymentStatus: 'VERIFICATION_IN_PROGRESS',
+ } as never);
+ return updated!;
+ }
+
+ async verifyPayment(bookingId: string): Promise {
+ const booking = await this.requireBooking(bookingId);
+ assertBookingStatus(booking, ['PAYMENT_VERIFICATION_IN_PROGRESS']);
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'PAID',
+ paymentStatus: 'PAID',
+ } as never);
+ return updated!;
+ }
+
+ async handleBankCallback(pnrCode: string): Promise {
+ const booking = await this.bookingsRepository.findByPnrCode(pnrCode);
+ if (!booking) {
+ throw new NotFoundException(`No booking found for PNR ${pnrCode}`);
+ }
+
+ if (booking.status !== 'PNR_GENERATED') {
+ throw new BadRequestException(
+ `Booking ${booking.reference} is not awaiting bank payment (status: ${booking.status})`,
+ );
+ }
+
+ const updated = await this.bookingsRepository.update(booking.id, {
+ status: 'PAID',
+ paymentStatus: 'PAID',
+ } as never);
+ return updated!;
+ }
+
+ async getPaymentRequestLetter(
+ bookingId: string,
+ ): Promise<{ buffer: Buffer; filename: string }> {
+ const booking = await this.requireBooking(bookingId);
+ const body = [
+ 'PAYMENT REQUEST LETTER (STUB)',
+ `Reference: ${booking.reference}`,
+ `Amount: ${booking.totalAmount} ${booking.paymentCurrency}`,
+ 'Pay at your bank and upload stamped proof.',
+ ].join('\n');
+ return {
+ buffer: Buffer.from(body, 'utf-8'),
+ filename: `payment-request-${booking.reference}.txt`,
+ };
+ }
+
+ private validateProofFile(file: Express.Multer.File): void {
+ if (!file?.buffer?.length) {
+ throw new BadRequestException('Payment proof file is required');
+ }
+ if (file.size > PROOF_MAX_BYTES) {
+ throw new BadRequestException('Payment proof must be 5MB or less');
+ }
+ if (!PROOF_MIMES.includes(file.mimetype)) {
+ throw new BadRequestException('Payment proof must be PDF, JPG, or PNG');
+ }
+ }
+
+ private async requireBooking(id: string): Promise {
+ const booking = await this.bookingsRepository.findById(id);
+ if (!booking) throw new NotFoundException(`Booking ${id} not found`);
+ return booking;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
new file mode 100644
index 000000000..621fb58e9
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
@@ -0,0 +1,248 @@
+import { Inject, Injectable, NotFoundException } from '@nestjs/common';
+
+import { ContainerTypesService } from '../rule-engine/services/container-types.service';
+import {
+ IRatesRepository,
+ RATES_REPOSITORY,
+} from '../rule-engine/interfaces/rates.repository.interface';
+import {
+ IServiceTypesRepository,
+ SERVICE_TYPES_REPOSITORY,
+} from '../rule-engine/interfaces/service-types.repository.interface';
+import { Rate } from '../rule-engine/entities/rate.entity';
+import {
+ AppliedCargoModifier,
+ BookingEvaluationInput,
+ RuleEngineService,
+} from '../rule-engine/rule-engine.service';
+import { BookingsRepository } from './bookings.repository';
+import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
+import { Booking } from './entities/booking.entity';
+import { assertBookingStatus } from './booking-status.util';
+
+@Injectable()
+export class BookingPricingService {
+ constructor(
+ private readonly bookingsRepository: BookingsRepository,
+ private readonly ruleEngineService: RuleEngineService,
+ private readonly containerTypesService: ContainerTypesService,
+ @Inject(RATES_REPOSITORY)
+ private readonly ratesRepo: IRatesRepository,
+ @Inject(SERVICE_TYPES_REPOSITORY)
+ private readonly serviceTypesRepo: IServiceTypesRepository,
+ ) {}
+
+ async generatePrice(bookingId: string): Promise {
+ const booking = await this.requireBooking(bookingId);
+ assertBookingStatus(booking, ['DRAFT']);
+
+ const evalInput = await this.buildEvalInputForBooking(booking);
+ const ruleResult = await this.ruleEngineService.evaluate(evalInput);
+ this.ruleEngineService.assertNoHardBlocks(ruleResult);
+
+ const lineItems: PriceLineItemDto[] = [];
+ let total = 0;
+
+ const baseLines = await this.computeBaseRailLines(booking, evalInput);
+ for (const line of baseLines) {
+ lineItems.push(line);
+ total += line.amount;
+ }
+
+ for (const mod of ruleResult.appliedModifiers) {
+ const item: PriceLineItemDto = {
+ code: mod.surchargeTypeCode,
+ description: `Surcharge: ${mod.surchargeTypeCode}`,
+ amount: mod.calculatedAmount,
+ currency: mod.currency,
+ };
+ lineItems.push(item);
+ total += mod.calculatedAmount;
+ }
+
+ await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total);
+
+ await this.bookingsRepository.update(bookingId, {
+ totalAmount: total,
+ priorityScore: ruleResult.priorityScore,
+ } as never);
+
+ return {
+ bookingId,
+ totalAmount: total,
+ currency: booking.paymentCurrency,
+ lineItems,
+ warnings: ruleResult.warnings,
+ };
+ }
+
+ async buildEvalInputForBooking(booking: Booking): Promise {
+ const containers = await Promise.all(
+ (booking.bookingContainers ?? []).map(async (bc) => {
+ const ct = await this.containerTypesService.findById(bc.containerTypeId);
+ const vgm = Number(bc.vgmPerUnitTons);
+ const qty = bc.quantity;
+ return {
+ containerTypeId: bc.containerTypeId,
+ quantity: qty,
+ vgmPerUnitTons: vgm,
+ totalVgmTons: qty * vgm,
+ isReefer: ct.isReefer,
+ };
+ }),
+ );
+ return {
+ cargoTypeId: booking.cargoTypeId,
+ serviceTypeId: booking.serviceTypeId,
+ paymentCurrency: booking.paymentCurrency,
+ tradeDirection: booking.tradeDirection,
+ isHazardous: booking.isHazardous,
+ allowConsolidation: booking.allowConsolidation,
+ shippingLineId: booking.shippingLineId,
+ containers,
+ };
+ }
+
+ private async requireBooking(id: string): Promise {
+ const booking = await this.bookingsRepository.findByIdWithFiles(id);
+ if (!booking) throw new NotFoundException(`Booking ${id} not found`);
+ return booking;
+ }
+
+ /** Recompute priority on submit (USD + service tier). */
+ async computeSubmitPriorityScore(booking: Booking): Promise {
+ const evalInput = await this.buildEvalInputForBooking(booking);
+ const ruleResult = await this.ruleEngineService.evaluate(evalInput);
+ let score = ruleResult.priorityScore;
+
+ const serviceType = await this.serviceTypesRepo.findById(booking.serviceTypeId);
+ if (booking.paymentCurrency === 'USD' && serviceType) {
+ const code = (serviceType.code ?? '').toUpperCase();
+ const hasForwarding =
+ serviceType.includesFirstMile ||
+ serviceType.includesLastMile ||
+ code.includes('FORWARD') ||
+ code.includes('Y');
+ const railOnly = code.includes('RAIL') && !hasForwarding;
+
+ if (hasForwarding) score += 1000;
+ else if (railOnly || code.includes('X')) score += 500;
+ }
+
+ return score;
+ }
+
+ private async computeBaseRailLines(
+ booking: Booking,
+ evalInput: BookingEvaluationInput,
+ ): Promise {
+ const liveRates = await this.ratesRepo.findLiveRates();
+ const currency = booking.paymentCurrency;
+ const isBulk = booking.cargoType?.requiresDirectorApproval ?? false;
+
+ const rateType =
+ booking.tradeDirection === 'IMPORT'
+ ? isBulk
+ ? 'BULK_IMPORT'
+ : 'CONTAINER_IMPORT'
+ : booking.tradeDirection === 'EXPORT'
+ ? isBulk
+ ? 'BULK_EXPORT'
+ : 'CONTAINER_EXPORT'
+ : 'INTERCITY_CONTAINER';
+
+ const lines: PriceLineItemDto[] = [];
+ const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
+
+ for (const container of evalInput.containers) {
+ const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
+ if (!rate) continue;
+
+ const amount = this.amountForRate(rate, container.quantity, wagonCount);
+ lines.push({
+ code: rateType,
+ description: `Base rail (${rateType})`,
+ amount,
+ currency: rate.currency,
+ });
+ }
+
+ if (lines.length === 0) {
+ const fallback = liveRates.find(
+ (r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
+ );
+ if (fallback) {
+ const amount = this.amountForRate(fallback, 1, wagonCount);
+ lines.push({
+ code: rateType,
+ description: `Base rail (${rateType})`,
+ amount,
+ currency: fallback.currency,
+ });
+ }
+ }
+
+ return lines;
+ }
+
+ private pickRate(
+ rates: Rate[],
+ rateType: string,
+ containerTypeId: string,
+ currency: string,
+ ): Rate | undefined {
+ return (
+ rates.find(
+ (r) =>
+ r.rateType === rateType &&
+ r.currency === currency &&
+ r.containerTypeId === containerTypeId,
+ ) ??
+ rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
+ );
+ }
+
+ private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
+ const value = Number(rate.rateValue);
+ switch (rate.rateUnit) {
+ case 'PER_CONTAINER':
+ return value * quantity;
+ case 'PER_WAGON':
+ return value * wagonCount;
+ case 'PER_TON':
+ return value * quantity;
+ case 'FLAT':
+ return value;
+ default:
+ return value * quantity;
+ }
+ }
+
+ private async persistPriceRun(
+ bookingId: string,
+ modifiers: AppliedCargoModifier[],
+ _total: number,
+ ): Promise {
+ await this.bookingsRepository.clearPricingArtifacts(bookingId);
+ const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId);
+
+ const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
+ const rows = modifiers
+ .map((m) => {
+ const snapshotId = snapshotByRateId.get(m.rateId);
+ if (!snapshotId) return null;
+ return {
+ bookingId,
+ surchargeTypeId: m.surchargeTypeId,
+ triggerValue: m.triggerValue,
+ calculatedAmount: m.calculatedAmount,
+ rateSnapshotId: snapshotId,
+ };
+ })
+ .filter((r): r is NonNullable => r !== null);
+
+ if (rows.length > 0) {
+ await this.bookingsRepository.createCargoModifiers(rows);
+ }
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts
new file mode 100644
index 000000000..fe9152149
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts
@@ -0,0 +1,10 @@
+import { ConflictException } from '@nestjs/common';
+import { Booking } from './entities/booking.entity';
+
+export function assertBookingStatus(booking: Booking, allowed: string[]): void {
+ if (!allowed.includes(booking.status)) {
+ throw new ConflictException(
+ `Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
new file mode 100644
index 000000000..d40d6590b
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
@@ -0,0 +1,291 @@
+import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
+
+import { RuleEngineService } from '../rule-engine/rule-engine.service';
+import { BookingContractService } from './booking-contract.service';
+import { BookingPricingService } from './booking-pricing.service';
+import { BookingsRepository } from './bookings.repository';
+import { assertBookingStatus } from './booking-status.util';
+import { Booking } from './entities/booking.entity';
+import { BookingsService } from './bookings.service';
+
+@Injectable()
+export class BookingTransitionService {
+ constructor(
+ private readonly bookingsRepository: BookingsRepository,
+ private readonly ruleEngineService: RuleEngineService,
+ private readonly pricingService: BookingPricingService,
+ private readonly contractService: BookingContractService,
+ @Inject(forwardRef(() => BookingsService))
+ private readonly bookingsService: BookingsService,
+ ) {}
+
+ async submit(bookingId: string): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
+
+ if (Number(booking.totalAmount) <= 0) {
+ throw new BadRequestException(
+ 'Generate a price before submitting (POST /bookings/:id/generate-price)',
+ );
+ }
+
+ const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
+ await this.ruleEngineService.snapshotLiveRates(bookingId);
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'SUBMITTED',
+ priorityScore,
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async requestChanges(
+ bookingId: string,
+ note: string,
+ actorId?: string,
+ ): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['SUBMITTED']);
+
+ await this.bookingsRepository.createReviewNote(
+ bookingId,
+ note,
+ 'CHANGES_REQUESTED',
+ actorId,
+ );
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'CHANGES_REQUESTED',
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async acceptIntake(bookingId: string, actorId?: string): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['SUBMITTED']);
+
+ await this.ruleEngineService.instantiateApprovalSteps(
+ bookingId,
+ booking.cargoTypeId,
+ );
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'PENDING_APPROVAL',
+ approvedByStaffId: actorId ?? booking.approvedByStaffId,
+ approvedByStaffAt: actorId ? new Date() : booking.approvedByStaffAt,
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async staffReject(
+ bookingId: string,
+ reason: string,
+ actorId?: string,
+ ): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
+
+ await this.bookingsRepository.createReviewNote(
+ bookingId,
+ reason,
+ 'REJECTION',
+ actorId,
+ );
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'REJECTED',
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async approveStep(
+ bookingId: string,
+ stepId: string,
+ actorId: string,
+ requiredRole: string,
+ ): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, [
+ 'PENDING_APPROVAL',
+ 'APPROVED_PENDING_SIGNATURE',
+ ]);
+
+ const step = await this.bookingsRepository.findApprovalStepById(
+ bookingId,
+ stepId,
+ );
+ if (!step || step.status !== 'PENDING') {
+ throw new BadRequestException('Approval step not found or already actioned');
+ }
+
+ const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
+ if (!next || next.id !== step.id) {
+ throw new BadRequestException(
+ 'Approval steps must be completed in order',
+ );
+ }
+
+ if (step.requiredRole !== requiredRole) {
+ throw new BadRequestException(
+ `Step requires role ${step.requiredRole}, not ${requiredRole}`,
+ );
+ }
+
+ const blocksRole = step.approvalRule?.blocksRole;
+ if (blocksRole && blocksRole === requiredRole) {
+ throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
+ }
+
+ await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
+
+ const updates: Record = {};
+ const now = new Date();
+
+ if (requiredRole === 'LINE_STAFF') {
+ updates.status = 'APPROVED_PENDING_SIGNATURE';
+ updates.approvedByStaffId = actorId;
+ updates.approvedByStaffAt = now;
+ } else if (requiredRole === 'DIRECTOR') {
+ updates.signedByDirectorId = actorId;
+ updates.signedByDirectorAt = now;
+ } else if (requiredRole === 'CEO') {
+ updates.signedByCeoId = actorId;
+ updates.signedByCeoAt = now;
+ }
+
+ const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId);
+ if (allDone) {
+ updates.status = 'APPROVED';
+ }
+
+ if (Object.keys(updates).length > 0) {
+ await this.bookingsRepository.update(bookingId, updates as never);
+ }
+
+ return this.bookingsService.findById(bookingId);
+ }
+
+ async rejectStep(
+ bookingId: string,
+ stepId: string,
+ actorId: string,
+ reason: string,
+ ): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
+
+ const step = await this.bookingsRepository.findApprovalStepById(
+ bookingId,
+ stepId,
+ );
+ if (!step) throw new BadRequestException('Approval step not found');
+
+ await this.bookingsRepository.completeApprovalStep(
+ step.id,
+ actorId,
+ 'REJECTED',
+ reason,
+ );
+
+ await this.bookingsRepository.createReviewNote(
+ bookingId,
+ reason,
+ 'REJECTION',
+ actorId,
+ );
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'REJECTED',
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async customerSign(bookingId: string): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['CONTRACT_READY']);
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'SIGNED_CUSTOMER',
+ customerSignedAt: new Date(),
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async marketingApprove(
+ bookingId: string,
+ actorId?: string,
+ ): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'FULLY_EXECUTED',
+ fullyExecutedAt: new Date(),
+ marketingApprovedById: actorId ?? null,
+ marketingApprovedAt: new Date(),
+ lockedAt: new Date(),
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async startTransit(bookingId: string): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['PAID']);
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'IN_TRANSIT',
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async complete(bookingId: string): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['IN_TRANSIT']);
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'COMPLETED',
+ endDate: new Date(),
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async cancel(bookingId: string, reason: string): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, [
+ 'DRAFT',
+ 'SUBMITTED',
+ 'CHANGES_REQUESTED',
+ 'PENDING_APPROVAL',
+ 'CONTRACT_READY',
+ ]);
+
+ await this.bookingsRepository.createReviewNote(
+ bookingId,
+ reason,
+ 'REJECTION',
+ );
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'CANCELLED',
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async enrichBookingResponse(booking: Booking): Promise {
+ const note = await this.bookingsRepository.findLatestReviewNote(
+ booking.id,
+ 'CHANGES_REQUESTED',
+ );
+ const summary =
+ booking.contractSummary ??
+ this.contractService.buildContractSummary(booking);
+ return {
+ ...booking,
+ latestChangeRequestNote: note?.note ?? null,
+ contractSummary: summary,
+ };
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
index 41a1a09c4..c8572dd76 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
@@ -3,6 +3,7 @@ import {
Controller,
Delete,
Get,
+ Header,
HttpCode,
Param,
ParseUUIDPipe,
@@ -10,10 +11,12 @@ import {
Post,
Query,
Request,
+ Res,
+ StreamableFile,
UploadedFiles,
UseInterceptors,
-} from "@nestjs/common";
-import { AnyFilesInterceptor } from "@nestjs/platform-express";
+} from '@nestjs/common';
+import { AnyFilesInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
ApiBody,
@@ -21,181 +24,332 @@ import {
ApiOkResponse,
ApiOperation,
ApiTags,
-} from "@nestjs/swagger";
+} from '@nestjs/swagger';
+import type { Response } from 'express';
-import { BookingReferenceDataService } from "./booking-reference-data.service";
-import { BookingsService } from "./bookings.service";
-import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
-import { CreateBookingDto } from "./dto/create-booking.dto";
-import { FilterBookingDto } from "./dto/filter-booking.dto";
-import { UpdateBookingDto } from "./dto/update-booking.dto";
-import { UpdateStatusDto } from "./dto/update-status.dto";
+import { BookingContractService } from './booking-contract.service';
+import { BookingPaymentService } from './booking-payment.service';
+import { BookingPricingService } from './booking-pricing.service';
+import { BookingTransitionService } from './booking-transition.service';
+import { BookingReferenceDataService } from './booking-reference-data.service';
+import { BookingsService } from './bookings.service';
+import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
+import { CreateBookingDto } from './dto/create-booking.dto';
+import { FilterBookingDto } from './dto/filter-booking.dto';
+import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
+import {
+ ApproveStepDto,
+ CancelBookingDto,
+ RejectStepDto,
+ MarketingApproveDto,
+ RequestChangesDto,
+ StaffAcceptDto,
+ StaffRejectDto,
+} from './dto/request-changes.dto';
+import { UpdateBookingDto } from './dto/update-booking.dto';
-@ApiTags("bookings")
-@Controller("bookings")
+@ApiTags('bookings')
+@Controller('bookings')
@ApiBearerAuth()
export class BookingsController {
constructor(
private readonly bookingsService: BookingsService,
private readonly bookingReferenceDataService: BookingReferenceDataService,
+ private readonly pricingService: BookingPricingService,
+ private readonly transitionService: BookingTransitionService,
+ private readonly contractService: BookingContractService,
+ private readonly paymentService: BookingPaymentService,
) {}
- // ── 1. Create booking (multipart/form-data) ──────────────────────────
@Post()
@UseInterceptors(AnyFilesInterceptor())
- @ApiConsumes("multipart/form-data")
- @ApiOperation({
- summary: "Create a new freight booking",
- description:
- "Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " +
- "Auto-enables consolidation when container quantity does not fill a whole wagon; attempts partner match or PENDING_CONSOLIDATION.",
- })
- @ApiBody({
- description:
- "Booking form data. Attach files with any field name (e.g. passport, tin_certificate). " +
- "Each uploaded file is saved as a row in the files table (resource=bookings).",
- type: CreateBookingDto,
- })
+ @ApiConsumes('multipart/form-data')
+ @ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
+ @ApiBody({ type: CreateBookingDto })
create(
@Body() dto: CreateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
- @Request() req: any,
+ @Request() req: { user?: { id?: string; sub?: string } },
) {
- console.log(
- "[BookingsController] Files received:",
- files?.length,
- files?.map((f) => ({
- fieldname: f.fieldname,
- originalname: f.originalname,
- size: f.size,
- mimetype: f.mimetype,
- })),
- );
- const userId: string | undefined = req.user?.id ?? req.user?.sub;
+ const userId = req.user?.id ?? req.user?.sub;
return this.bookingsService.create(dto, files ?? [], userId);
}
- // ── 2. Update draft booking (multipart/form-data) ─────────────────────
- @Patch(":id")
+ @Patch(':id')
@UseInterceptors(AnyFilesInterceptor())
- @ApiConsumes("multipart/form-data")
+ @ApiConsumes('multipart/form-data')
@ApiOperation({
- summary: "Update a draft booking",
- description:
- "Only DRAFT bookings can be updated. New files are merged into existing documents.",
+ summary: 'Update booking',
+ description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
})
@ApiBody({ type: UpdateBookingDto })
update(
- @Param("id", ParseUUIDPipe) id: string,
+ @Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.bookingsService.update(id, dto, files ?? []);
}
- // ── 3. List bookings (paginated + filtered) ───────────────────────────
@Get()
- @ApiOperation({
- summary: "List freight bookings (paginated)",
- description:
- "Filter by status, customerId, contractType, serviceTypeId, cargoTypeId, tradeDirection, " +
- "paymentCurrency, allowConsolidation, consolidationPaired. " +
- "Sort by createdAt or priorityScore.",
- })
+ @ApiOperation({ summary: 'List freight bookings (paginated)' })
findAll(@Query() filter: FilterBookingDto) {
return this.bookingsService.findAll(filter);
}
- // ── Booking form catalog (must be before :id) ─────────────────────────
- @Get("reference-data")
+ @Get('queues/:queue')
@ApiOperation({
- summary: "Booking form catalog",
- description:
- "Returns yards, container types (grouped by size), service types, shipping lines, " +
- "and hierarchical cargo types for the booking UI in a single payload.",
+ summary: 'List bookings for a dashboard queue',
+ description: 'Queues: intake, approval, signatures, marketing, finance',
})
+ findQueue(
+ @Param('queue') queue: string,
+ @Query() filter: FilterBookingDto,
+ @Query('excludeBulk') excludeBulk?: string,
+ ) {
+ return this.bookingsService.findQueue(queue, filter, {
+ excludeBulk: excludeBulk === 'true',
+ });
+ }
+
+ @Get('reference-data')
+ @ApiOperation({ summary: 'Booking form catalog' })
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise {
return this.bookingReferenceDataService.getReferenceData();
}
- // ── 5. Lookup by reference (must be before :id to avoid conflict) ─────
- @Get("by-reference/:reference")
- @ApiOperation({
- summary: "Get a freight booking by reference number",
- description: "Lookup booking by its human-readable reference string.",
- })
- findByReference(@Param("reference") reference: string) {
- return this.bookingsService.findByReference(reference);
+ @Get('by-reference/:reference')
+ @ApiOperation({ summary: 'Get booking by reference' })
+ async findByReference(@Param('reference') reference: string) {
+ const booking = await this.bookingsService.findByReference(reference);
+ return this.transitionService.enrichBookingResponse(booking);
}
- // ── 4. Get single booking by ID ───────────────────────────────────────
- @Get(":id")
- @ApiOperation({ summary: "Get a freight booking by ID" })
- findOne(@Param("id", ParseUUIDPipe) id: string) {
- return this.bookingsService.findById(id);
+ @Get(':id')
+ @ApiOperation({ summary: 'Get booking by ID' })
+ async findOne(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.bookingsService.findById(id);
+ return this.transitionService.enrichBookingResponse(booking);
}
- // ── 6. Soft-delete (DRAFT only) ───────────────────────────────────────
- @Delete(":id")
+ @Delete(':id')
@HttpCode(204)
- @ApiOperation({
- summary: "Soft-delete a freight booking",
- description: "Only DRAFT bookings can be deleted.",
- })
- remove(@Param("id", ParseUUIDPipe) id: string) {
+ @ApiOperation({ summary: 'Soft-delete DRAFT booking' })
+ remove(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.remove(id);
}
- // ── 7. Unified status transition ──────────────────────────────────────
- @Patch(":id/status")
- @ApiOperation({
- summary: "Transition booking status",
- description:
- "Unified endpoint for all status transitions. Actions: " +
- "SUBMIT, APPROVE_STAFF, APPROVE_DIRECTOR, APPROVE_CEO, REJECT, CANCEL, ACTIVATE, EXPIRE. " +
- "Approval routing: Standard → LINE_STAFF → DIRECTOR → SIGNED. " +
- "Bulk/high-volume → DIRECTOR → CEO → SIGNED.",
- })
- updateStatus(
- @Param("id", ParseUUIDPipe) id: string,
- @Body() dto: UpdateStatusDto,
- ) {
- return this.bookingsService.updateStatus(id, dto);
+ @Post(':id/generate-price')
+ @ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
+ @ApiOkResponse({ type: GeneratePriceResponseDto })
+ generatePrice(@Param('id', ParseUUIDPipe) id: string) {
+ return this.pricingService.generatePrice(id);
}
- // ── 8. Request or auto-pair consolidation ─────────────────────────────
- @Post(":id/consolidation")
- @ApiOperation({
- summary: "Request freight consolidation",
- description:
- "Searches for a partner whose container quantity complements yours to fill whole wagon(s) " +
- "(same route, same container type). Pairs on match or sets PENDING_CONSOLIDATION with a status message.",
- })
- requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
+ @Post(':id/submit')
+ @ApiOperation({ summary: 'Customer submit booking' })
+ async submit(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.transitionService.submit(id);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/staff/request-changes')
+ @ApiOperation({ summary: 'Staff return booking for customer updates' })
+ async requestChanges(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: RequestChangesDto,
+ ) {
+ const booking = await this.transitionService.requestChanges(
+ id,
+ dto.note,
+ dto.actorId,
+ );
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/staff/accept')
+ @ApiOperation({ summary: 'Staff accept intake → start approval chain' })
+ async acceptIntake(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: StaffAcceptDto,
+ ) {
+ const booking = await this.transitionService.acceptIntake(id, dto.actorId);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/staff/reject')
+ @ApiOperation({ summary: 'Staff final reject' })
+ async staffReject(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: StaffRejectDto,
+ ) {
+ const booking = await this.transitionService.staffReject(
+ id,
+ dto.reason,
+ dto.actorId,
+ );
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/approval-steps/:stepId/approve')
+ @ApiOperation({ summary: 'Approve one approval step in sequence' })
+ async approveStep(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Param('stepId', ParseUUIDPipe) stepId: string,
+ @Body() dto: ApproveStepDto,
+ ) {
+ const booking = await this.transitionService.approveStep(
+ id,
+ stepId,
+ dto.actorId,
+ dto.requiredRole,
+ );
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/approval-steps/:stepId/reject')
+ @ApiOperation({ summary: 'Reject at approval step' })
+ async rejectStep(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Param('stepId', ParseUUIDPipe) stepId: string,
+ @Body() dto: RejectStepDto,
+ ) {
+ const booking = await this.transitionService.rejectStep(
+ id,
+ stepId,
+ dto.actorId,
+ dto.reason,
+ );
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/contract/generate')
+ @ApiOperation({ summary: 'Generate contract document' })
+ async generateContract(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.contractService.generateContract(id);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Get(':id/contract')
+ @ApiOperation({ summary: 'Download contract file' })
+ async downloadContract(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Res({ passthrough: true }) res: Response,
+ ) {
+ const { stream, record } = await this.contractService.streamContract(id);
+ res.set({
+ 'Content-Type': record.mimeType ?? 'application/octet-stream',
+ 'Content-Disposition': `attachment; filename="${record.name}"`,
+ });
+ return new StreamableFile(stream);
+ }
+
+ @Get(':id/summary')
+ @ApiOperation({ summary: 'Contract summary string for dashboard' })
+ getSummary(@Param('id', ParseUUIDPipe) id: string) {
+ return this.contractService.getSummary(id);
+ }
+
+ @Post(':id/customer/sign')
+ @ApiOperation({ summary: 'Customer digital signature' })
+ async customerSign(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.transitionService.customerSign(id);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/marketing/approve')
+ @ApiOperation({ summary: 'Marketing verify and fully execute' })
+ async marketingApprove(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: MarketingApproveDto,
+ ) {
+ const booking = await this.transitionService.marketingApprove(
+ id,
+ dto.actorId,
+ );
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/payment/pnr')
+ @ApiOperation({ summary: 'Generate PNR code (ETB)' })
+ async generatePnr(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.paymentService.generatePnr(id);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/payment/proof')
+ @UseInterceptors(AnyFilesInterceptor())
+ @ApiConsumes('multipart/form-data')
+ @ApiOperation({ summary: 'Upload USD payment proof' })
+ async submitPaymentProof(
+ @Param('id', ParseUUIDPipe) id: string,
+ @UploadedFiles() files: Express.Multer.File[],
+ ) {
+ const file = files?.[0];
+ const booking = await this.paymentService.submitPaymentProof(id, file);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Get(':id/payment/request-letter')
+ @ApiOperation({ summary: 'Download payment request letter (USD stub)' })
+ @Header('Content-Type', 'text/plain')
+ async paymentRequestLetter(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Res({ passthrough: true }) res: Response,
+ ) {
+ const { buffer, filename } =
+ await this.paymentService.getPaymentRequestLetter(id);
+ res.set('Content-Disposition', `attachment; filename="${filename}"`);
+ return new StreamableFile(buffer);
+ }
+
+ @Post(':id/payment/verify')
+ @ApiOperation({ summary: 'Finance verify USD payment' })
+ async verifyPayment(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.paymentService.verifyPayment(id);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/operations/start-transit')
+ @ApiOperation({ summary: 'Mark in transit' })
+ async startTransit(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.transitionService.startTransit(id);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/operations/complete')
+ @ApiOperation({ summary: 'Mark completed' })
+ async complete(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.transitionService.complete(id);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/cancel')
+ @ApiOperation({ summary: 'Cancel booking' })
+ async cancel(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: CancelBookingDto,
+ ) {
+ const booking = await this.transitionService.cancel(id, dto.reason);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/consolidation')
+ @ApiOperation({ summary: 'Request freight consolidation' })
+ requestConsolidation(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id);
}
- // ── 9. Remove consolidation pairing ───────────────────────────────────
- @Delete(":id/consolidation")
- @ApiOperation({
- summary: "Remove consolidation pairing",
- description:
- "Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.",
- })
- removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
+ @Delete(':id/consolidation')
+ @ApiOperation({ summary: 'Remove consolidation pairing' })
+ removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id);
}
- // ── 10. Get consolidation details ─────────────────────────────────────
- @Get(":id/consolidation")
- @ApiOperation({
- summary: "Get consolidation details",
- description:
- "Returns partner booking details and split billing information.",
- })
- getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
+ @Get(':id/consolidation')
+ @ApiOperation({ summary: 'Get consolidation details' })
+ getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id);
}
-
}
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
index 9785ec270..f3b3a9360 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
@@ -5,15 +5,21 @@ import { CustomersModule } from '../customers/customers.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
+import { BookingContractService } from './booking-contract.service';
+import { BookingPaymentService } from './booking-payment.service';
+import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
+import { BookingTransitionService } from './booking-transition.service';
import { BookingsController } from './bookings.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { BookingsService } from './bookings.service';
+import { PaymentsWebhookController } from './payments-webhook.controller';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
+import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
@Module({
@@ -24,18 +30,23 @@ import { Booking } from './entities/booking.entity';
BookingCargoModifier,
BookingApprovalStep,
BookingRateSnapshot,
+ BookingReviewNote,
]),
FilesModule,
MinioModule,
CustomersModule,
RuleEngineModule,
],
- controllers: [BookingsController],
+ controllers: [BookingsController, PaymentsWebhookController],
providers: [
BookingsService,
BookingsRepository,
ConsolidationService,
BookingReferenceDataService,
+ BookingPricingService,
+ BookingTransitionService,
+ BookingContractService,
+ BookingPaymentService,
],
exports: [BookingsService],
})
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
index f329c942f..3a6155f3b 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
@@ -1,13 +1,14 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
-import { DataSource, Repository } from 'typeorm';
+import { DataSource, FindOptionsWhere, Repository } from 'typeorm';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
+import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
@@ -66,6 +67,7 @@ export class BookingsRepository extends BaseRepository {
.leftJoinAndSelect('booking.approvalSteps', 'steps')
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
+ .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
.where('booking.id = :id', { id })
.leftJoinAndMapMany(
'booking.files',
@@ -216,15 +218,35 @@ export class BookingsRepository extends BaseRepository {
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
}
- /** Get pending approval step for a role. */
+ /** Lowest-order pending approval step (sequential enforcement). */
+ async findNextPendingApprovalStep(
+ bookingId: string,
+ ): Promise {
+ return this.dataSource.getRepository(BookingApprovalStep).findOne({
+ where: { bookingId, status: 'PENDING' },
+ order: { stepOrder: 'ASC' },
+ relations: ['approvalRule'],
+ });
+ }
+
+ async findApprovalStepById(
+ bookingId: string,
+ stepId: string,
+ ): Promise {
+ return this.dataSource.getRepository(BookingApprovalStep).findOne({
+ where: { bookingId, id: stepId },
+ relations: ['approvalRule'],
+ });
+ }
+
+ /** Get pending approval step for a role (must match next in sequence). */
async findPendingApprovalStep(
bookingId: string,
requiredRole: string,
): Promise {
- return this.dataSource.getRepository(BookingApprovalStep).findOne({
- where: { bookingId, requiredRole, status: 'PENDING' },
- order: { stepOrder: 'ASC' },
- });
+ const next = await this.findNextPendingApprovalStep(bookingId);
+ if (!next || next.requiredRole !== requiredRole) return null;
+ return next;
}
/** Mark an approval step complete. */
@@ -277,4 +299,85 @@ export class BookingsRepository extends BaseRepository {
where: { bookingId, rateId },
});
}
+
+ async createReviewNote(
+ bookingId: string,
+ note: string,
+ type: ReviewNoteType,
+ authorId?: string,
+ ): Promise {
+ const repo = this.dataSource.getRepository(BookingReviewNote);
+ return repo.save(
+ repo.create({ bookingId, note, type, authorId: authorId ?? null }),
+ );
+ }
+
+ async findLatestReviewNote(
+ bookingId: string,
+ type?: ReviewNoteType,
+ ): Promise {
+ const repo = this.dataSource.getRepository(BookingReviewNote);
+ return repo.findOne({
+ where: type ? { bookingId, type } : { bookingId },
+ order: { createdAt: 'DESC' },
+ });
+ }
+
+ async clearPricingArtifacts(bookingId: string): Promise {
+ await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId });
+ await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
+ }
+
+ async findByPnrCode(pnrCode: string): Promise {
+ return this.repository.findOne({ where: { pnrCode } });
+ }
+
+ /** Queue listing with optional bulk exclusion for LINE_STAFF. */
+ async findQueue(options: {
+ status: string | string[];
+ page?: number;
+ pageSize?: number;
+ excludeBulk?: boolean;
+ sortBy?: string;
+ sortOrder?: 'ASC' | 'DESC';
+ }): Promise<{ items: Booking[]; total: number }> {
+ const page = options.page ?? 1;
+ const pageSize = options.pageSize ?? 20;
+ const statuses = Array.isArray(options.status) ? options.status : [options.status];
+
+ const qb = this.repository
+ .createQueryBuilder('booking')
+ .leftJoinAndSelect('booking.customer', 'customer')
+ .leftJoinAndSelect('booking.cargoType', 'cargo')
+ .leftJoinAndSelect('booking.serviceType', 'serviceType')
+ .where('booking.status IN (:...statuses)', { statuses });
+
+ if (options.excludeBulk) {
+ qb.andWhere('cargo.requires_director_approval = false');
+ }
+
+ const sortField =
+ options.sortBy === 'priorityScore' ? 'booking.priority_score' : 'booking.created_at';
+ qb.orderBy(sortField, options.sortOrder ?? 'DESC');
+
+ const [items, total] = await qb
+ .skip((page - 1) * pageSize)
+ .take(pageSize)
+ .getManyAndCount();
+
+ return { items, total };
+ }
+
+ async findAndCountFiltered(where: FindOptionsWhere, options: {
+ skip: number;
+ take: number;
+ order: Record;
+ }): Promise<[Booking[], number]> {
+ return this.repository.findAndCount({
+ where,
+ skip: options.skip,
+ take: options.take,
+ order: options.order,
+ });
+ }
}
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index 3aaa12a70..1039e60c1 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -19,7 +19,7 @@ import { ConsolidationService } from './consolidation.service';
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
-import { UpdateStatusDto } from './dto/update-status.dto';
+import { CUSTOMER_EDITABLE_STATUSES } from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
@@ -242,8 +242,10 @@ export class BookingsService {
files: Express.Multer.File[],
): Promise<{ booking: Booking; warnings: string[] }> {
const existing = await this.findById(id);
- if (existing.status !== 'DRAFT') {
- throw new BadRequestException('Only DRAFT bookings can be updated');
+ if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
+ throw new BadRequestException(
+ 'Only DRAFT or CHANGES_REQUESTED bookings can be updated',
+ );
}
const warnings: string[] = [];
@@ -390,201 +392,32 @@ export class BookingsService {
await this.bookingsRepository.softDelete(id);
}
- /** Unified status transition handler. */
- async updateStatus(id: string, dto: UpdateStatusDto): Promise {
- const booking = await this.findById(id);
- const { action, actorId, reason, requiredRole } = dto;
+ async findQueue(
+ queue: string,
+ filter: FilterBookingDto,
+ options?: { excludeBulk?: boolean },
+ ): Promise<{ items: Booking[]; total: number }> {
+ const statusMap: Record = {
+ intake: 'SUBMITTED',
+ approval: 'PENDING_APPROVAL',
+ signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
+ marketing: 'SIGNED_CUSTOMER',
+ finance: 'PAYMENT_VERIFICATION_IN_PROGRESS',
+ };
- switch (action) {
- case 'SUBMIT':
- return this.handleSubmit(booking);
- case 'SEND_QUOTATION':
- return this.handleSendQuotation(booking);
- case 'APPROVE_QUOTATION':
- return this.handleApproveQuotation(booking);
- case 'REJECT_QUOTATION':
- return this.handleRejectQuotation(booking, reason);
- case 'APPROVE_STEP':
- return this.handleApproveStep(booking, actorId, requiredRole);
- case 'APPROVE':
- return this.handleFullyApproved(booking);
- case 'CUSTOMER_SIGN':
- return this.handleCustomerSign(booking);
- case 'MARK_FULLY_EXECUTED':
- return this.handleFullyExecuted(booking);
- case 'MARK_PAID':
- return this.handleMarkPaid(booking);
- case 'START_TRANSIT':
- return this.handleStartTransit(booking);
- case 'COMPLETE':
- return this.handleComplete(booking);
- case 'REJECT':
- return this.handleReject(booking, actorId, reason);
- case 'CANCEL':
- return this.handleCancel(booking, reason);
- default:
- throw new BadRequestException(`Unknown action: ${action}`);
- }
- }
-
- /** SUBMIT: DRAFT → RFQ_SUBMITTED → PENDING_APPROVAL with approval steps and rate snapshots. */
- private async handleSubmit(booking: Booking): Promise {
- this.assertStatus(booking, ['DRAFT']);
-
- await this.bookingsRepository.update(booking.id, { status: 'RFQ_SUBMITTED' } as never);
- await this.ruleEngineService.snapshotLiveRates(booking.id);
- await this.ruleEngineService.instantiateApprovalSteps(booking.id, booking.cargoTypeId);
-
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'PENDING_APPROVAL',
- } as never);
- return updated!;
- }
-
- private async handleSendQuotation(booking: Booking): Promise {
- this.assertStatus(booking, ['RFQ_SUBMITTED']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'QUOTATION_SENT',
- } as never);
- return updated!;
- }
-
- private async handleApproveQuotation(booking: Booking): Promise {
- this.assertStatus(booking, ['QUOTATION_SENT']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'QUOTATION_APPROVED',
- } as never);
- return updated!;
- }
-
- private async handleRejectQuotation(booking: Booking, reason?: string): Promise {
- this.assertStatus(booking, ['QUOTATION_SENT']);
- if (!reason) throw new BadRequestException('reason is required for REJECT_QUOTATION');
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'QUOTATION_REJECTED',
- } as never);
- return updated!;
- }
-
- private async handleApproveStep(
- booking: Booking,
- actorId?: string,
- requiredRole?: string,
- ): Promise {
- this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
- if (!actorId || !requiredRole) {
- throw new BadRequestException('actorId and requiredRole are required for APPROVE_STEP');
+ const status = statusMap[queue];
+ if (!status) {
+ throw new BadRequestException(`Unknown queue: ${queue}`);
}
- const step = await this.bookingsRepository.findPendingApprovalStep(
- booking.id,
- requiredRole,
- );
- if (!step) {
- throw new BadRequestException(`No pending approval step for role ${requiredRole}`);
- }
-
- await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
-
- const allDone = await this.bookingsRepository.allApprovalStepsComplete(booking.id);
- if (allDone) {
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'APPROVED',
- } as never);
- return updated!;
- }
-
- return this.findById(booking.id);
- }
-
- private async handleFullyApproved(booking: Booking): Promise {
- this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'APPROVED',
- } as never);
- return updated!;
- }
-
- private async handleCustomerSign(booking: Booking): Promise {
- this.assertStatus(booking, ['APPROVED']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'SIGNED_CUSTOMER',
- customerSignedAt: new Date(),
- } as never);
- return updated!;
- }
-
- private async handleFullyExecuted(booking: Booking): Promise {
- this.assertStatus(booking, ['SIGNED_CUSTOMER']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'FULLY_EXECUTED',
- fullyExecutedAt: new Date(),
- } as never);
- return updated!;
- }
-
- private async handleMarkPaid(booking: Booking): Promise {
- this.assertStatus(booking, ['FULLY_EXECUTED', 'APPROVED', 'SIGNED_CUSTOMER']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'PAID',
- paymentStatus: 'PAID',
- } as never);
- return updated!;
- }
-
- private async handleStartTransit(booking: Booking): Promise {
- this.assertStatus(booking, ['PAID']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'IN_TRANSIT',
- } as never);
- return updated!;
- }
-
- private async handleComplete(booking: Booking): Promise {
- this.assertStatus(booking, ['IN_TRANSIT']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'COMPLETED',
- endDate: new Date(),
- } as never);
- return updated!;
- }
-
- private async handleReject(
- booking: Booking,
- actorId?: string,
- reason?: string,
- ): Promise {
- this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
- if (!actorId || !reason) {
- throw new BadRequestException('actorId and reason are required for REJECT');
- }
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'CANCELLED',
- } as never);
- return updated!;
- }
-
- private async handleCancel(booking: Booking, reason?: string): Promise {
- this.assertStatus(booking, [
- 'DRAFT',
- 'RFQ_SUBMITTED',
- 'QUOTATION_SENT',
- 'QUOTATION_APPROVED',
- 'PENDING_APPROVAL',
- ]);
- if (!reason) throw new BadRequestException('reason is required for CANCEL');
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'CANCELLED',
- } as never);
- return updated!;
- }
-
- private assertStatus(booking: Booking, allowed: string[]): void {
- if (!allowed.includes(booking.status)) {
- throw new ConflictException(
- `Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
- );
- }
+ return this.bookingsRepository.findQueue({
+ status,
+ page: filter.page,
+ pageSize: filter.pageSize,
+ excludeBulk: options?.excludeBulk ?? queue === 'approval',
+ sortBy: filter.sortBy,
+ sortOrder: filter.sortOrder,
+ });
}
async requestConsolidation(id: string): Promise<{
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts
new file mode 100644
index 000000000..3474bec74
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts
@@ -0,0 +1,32 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+export class PriceLineItemDto {
+ @ApiProperty()
+ code!: string;
+
+ @ApiProperty()
+ description!: string;
+
+ @ApiProperty()
+ amount!: number;
+
+ @ApiProperty()
+ currency!: string;
+}
+
+export class GeneratePriceResponseDto {
+ @ApiProperty()
+ bookingId!: string;
+
+ @ApiProperty()
+ totalAmount!: number;
+
+ @ApiProperty()
+ currency!: string;
+
+ @ApiProperty({ type: [PriceLineItemDto] })
+ lineItems!: PriceLineItemDto[];
+
+ @ApiProperty({ type: [String] })
+ warnings!: string[];
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
new file mode 100644
index 000000000..8e77b51fc
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
@@ -0,0 +1,74 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
+
+export class RequestChangesDto {
+ @ApiProperty({ description: 'Staff note explaining what the customer must fix' })
+ @IsString()
+ @MinLength(1)
+ note!: string;
+
+ @ApiPropertyOptional({ format: 'uuid' })
+ @IsOptional()
+ @IsUUID()
+ actorId?: string;
+}
+
+export class StaffAcceptDto {
+ @ApiPropertyOptional({ format: 'uuid' })
+ @IsOptional()
+ @IsUUID()
+ actorId?: string;
+}
+
+export class MarketingApproveDto {
+ @ApiPropertyOptional({ format: 'uuid' })
+ @IsOptional()
+ @IsUUID()
+ actorId?: string;
+}
+
+export class StaffRejectDto {
+ @ApiProperty()
+ @IsString()
+ @MinLength(1)
+ reason!: string;
+
+ @ApiPropertyOptional({ format: 'uuid' })
+ @IsOptional()
+ @IsUUID()
+ actorId?: string;
+}
+
+export class ApproveStepDto {
+ @ApiProperty({ format: 'uuid' })
+ @IsUUID()
+ actorId!: string;
+
+ @ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
+ @IsString()
+ requiredRole!: string;
+}
+
+export class RejectStepDto {
+ @ApiProperty({ format: 'uuid' })
+ @IsUUID()
+ actorId!: string;
+
+ @ApiProperty()
+ @IsString()
+ @MinLength(1)
+ reason!: string;
+}
+
+export class CancelBookingDto {
+ @ApiProperty()
+ @IsString()
+ @MinLength(1)
+ reason!: string;
+}
+
+export class BankCallbackDto {
+ @ApiProperty()
+ @IsString()
+ pnrCode!: string;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts
deleted file mode 100644
index a2d6c192d..000000000
--- a/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
-import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
-
-const STATUS_ACTIONS = [
- 'SUBMIT',
- 'SEND_QUOTATION',
- 'APPROVE_QUOTATION',
- 'REJECT_QUOTATION',
- 'APPROVE_STEP',
- 'APPROVE',
- 'CUSTOMER_SIGN',
- 'MARK_FULLY_EXECUTED',
- 'MARK_PAID',
- 'START_TRANSIT',
- 'COMPLETE',
- 'REJECT',
- 'CANCEL',
-] as const;
-
-export { STATUS_ACTIONS };
-
-export class UpdateStatusDto {
- @ApiProperty({ enum: STATUS_ACTIONS })
- @IsIn([...STATUS_ACTIONS])
- action!: string;
-
- @ApiPropertyOptional({ format: 'uuid', description: 'Staff/director/CEO actor' })
- @IsOptional()
- @IsUUID()
- actorId?: string;
-
- @ApiPropertyOptional({ description: 'Required role for APPROVE_STEP (LINE_STAFF, DIRECTOR, CEO)' })
- @IsOptional()
- @IsString()
- requiredRole?: string;
-
- @ApiPropertyOptional({ description: 'Required for REJECT, REJECT_QUOTATION, CANCEL' })
- @IsOptional()
- @IsString()
- reason?: string;
-}
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts
new file mode 100644
index 000000000..af39a469c
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts
@@ -0,0 +1,26 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
+import { Booking } from './booking.entity';
+
+export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const;
+export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
+
+@Entity({ schema: 'freight', name: 'booking_review_note' })
+@Index(['bookingId'])
+export class BookingReviewNote extends BaseEntity {
+ @Column({ name: 'booking_id', type: 'uuid' })
+ bookingId!: string;
+
+ @ManyToOne(() => Booking, (b) => b.reviewNotes, { onDelete: 'CASCADE' })
+ @JoinColumn({ name: 'booking_id' })
+ booking?: Booking;
+
+ @Column({ name: 'author_id', type: 'uuid', nullable: true })
+ authorId?: string | null;
+
+ @Column({ name: 'note', type: 'text' })
+ note!: string;
+
+ @Column({ name: 'type', type: 'varchar', length: 30 })
+ type!: ReviewNoteType;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
index 7875babbf..6a4140006 100644
--- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
@@ -11,25 +11,47 @@ import { BookingApprovalStep } from './booking-approval-step.entity';
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
import { BookingContainer } from './booking-container.entity';
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
+import { BookingReviewNote } from './booking-review-note.entity';
export const BOOKING_STATUSES = [
'DRAFT',
- 'RFQ_SUBMITTED',
- 'QUOTATION_SENT',
- 'QUOTATION_APPROVED',
- 'QUOTATION_REJECTED',
+ 'SUBMITTED',
+ 'CHANGES_REQUESTED',
'PENDING_APPROVAL',
+ 'APPROVED_PENDING_SIGNATURE',
'APPROVED',
+ 'CONTRACT_READY',
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
+ 'PNR_GENERATED',
+ 'PAYMENT_VERIFICATION_IN_PROGRESS',
'PAID',
'IN_TRANSIT',
'COMPLETED',
+ 'REJECTED',
'CANCELLED',
'PENDING_CONSOLIDATION',
'CONSOLIDATED',
] as const;
+export type BookingStatus = (typeof BOOKING_STATUSES)[number];
+
+export const PAYMENT_STATUSES = [
+ 'PENDING',
+ 'PNR_GENERATED',
+ 'VERIFICATION_IN_PROGRESS',
+ 'PAID',
+ 'FAILED',
+] as const;
+
+export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
+
+/** Statuses where the customer may edit booking fields. */
+export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
+ 'DRAFT',
+ 'CHANGES_REQUESTED',
+];
+
@Entity({ schema: 'freight', name: 'bookings' })
export class Booking extends BaseEntity {
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
@@ -169,6 +191,18 @@ export class Booking extends BaseEntity {
@Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true })
fullyExecutedAt?: Date | null;
+ @Column({ name: 'marketing_approved_by_id', type: 'uuid', nullable: true })
+ marketingApprovedById?: string | null;
+
+ @Column({ name: 'marketing_approved_at', type: 'timestamptz', nullable: true })
+ marketingApprovedAt?: Date | null;
+
+ @Column({ name: 'contract_summary', type: 'text', nullable: true })
+ contractSummary?: string | null;
+
+ @Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
+ lockedAt?: Date | null;
+
@Column({ name: 'priority_score', type: 'int', default: 0 })
priorityScore!: number;
@@ -194,6 +228,9 @@ export class Booking extends BaseEntity {
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
rateSnapshots?: BookingRateSnapshot[];
+ @OneToMany(() => BookingReviewNote, (n) => n.booking)
+ reviewNotes?: BookingReviewNote[];
+
@OneToMany(() => FileRecord, (file) => file.resourceId, {
createForeignKeyConstraints: false,
})
diff --git a/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts b/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts
new file mode 100644
index 000000000..d0f60f8df
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts
@@ -0,0 +1,17 @@
+import { Body, Controller, Post } from '@nestjs/common';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
+
+import { BookingPaymentService } from './booking-payment.service';
+import { BankCallbackDto } from './dto/request-changes.dto';
+
+@ApiTags('payments')
+@Controller('webhooks/payments')
+export class PaymentsWebhookController {
+ constructor(private readonly paymentService: BookingPaymentService) {}
+
+ @Post('bank')
+ @ApiOperation({ summary: 'Bank payment callback (stub)' })
+ bankCallback(@Body() dto: BankCallbackDto) {
+ return this.paymentService.handleBankCallback(dto.pnrCode);
+ }
+}
From 125ee18308506561b1c12291386cc575a03a2f24 Mon Sep 17 00:00:00 2001
From: marshal
Date: Thu, 4 Jun 2026 15:16:27 +0300
Subject: [PATCH 03/25] implement booking flow
---
apps/edr-freight-api/nest-cli.json | 5 +-
apps/edr-freight-api/package.json | 2 +
.../src/common/resolve-auth-user-id.ts | 12 +
.../src/contracts/contract-pdf.service.ts | 57 +
.../contract-pricing-schedule.builder.ts | 70 ++
.../contracts/contract-renderer.service.ts | 51 +
.../contracts/contract-template.registry.ts | 98 ++
.../contracts/contract-template.resolver.ts | 43 +
.../contracts/contract-view-model.builder.ts | 118 +++
.../templates/IMP_CON_ETB_TRANSPORT_ONLY.hbs | 68 ++
.../templates/_partials/article5_pricing.hbs | 47 +
.../templates/_partials/signatures_block.hbs | 30 +
.../contracts/templates/_partials/styles.hbs | 22 +
.../src/contracts/templates/generic.hbs | 42 +
.../1749300000000-AddBookingFreightType.ts | 71 ++
.../1749400000000-AddContractSignatures.ts | 45 +
.../bookings/booking-contract.service.ts | 210 +++-
.../modules/bookings/booking-freight.util.ts | 43 +
.../bookings/booking-pricing.service.ts | 99 +-
.../bookings/booking-transition.service.ts | 25 +-
.../modules/bookings/bookings.controller.ts | 120 ++-
.../src/modules/bookings/bookings.module.ts | 12 +
.../modules/bookings/bookings.repository.ts | 41 +-
.../src/modules/bookings/bookings.service.ts | 147 ++-
.../modules/bookings/dto/contract-view.dto.ts | 50 +
.../bookings/dto/create-booking.dto.ts | 31 +-
.../bookings/dto/filter-booking.dto.ts | 12 +-
.../bookings/dto/request-changes.dto.ts | 36 +-
.../modules/bookings/dto/sign-contract.dto.ts | 23 +
.../bookings/dto/update-booking.dto.ts | 11 +-
.../validators/booking-freight.validator.ts | 60 ++
.../booking-contract-signature.entity.ts | 44 +
.../bookings/entities/booking.entity.ts | 19 +-
.../src/modules/files/files.repository.ts | 8 +
.../src/modules/files/files.service.ts | 7 +
.../controllers/rates.controller.ts | 26 +-
.../rule-engine/dto/create-rate.dto.ts | 10 -
.../rule-engine/rule-engine.service.ts | 42 +-
.../rule-engine/services/rates.service.ts | 11 +-
.../services/surcharge-types.service.ts | 1 +
apps/edr-freight-web/backoffice/src/App.tsx | 32 +-
.../components/bookings/ApprovalStepsCard.tsx | 116 +++
.../bookings/BookingActionsMenu.tsx | 233 +++++
.../bookings/BookingActionsToolbar.tsx | 168 +++
.../bookings/BookingConfirmDialog.tsx | 134 +++
.../bookings/BookingPricingSummary.tsx | 86 ++
.../bookings/BookingPriorityBadge.tsx | 21 +
.../components/bookings/BookingStatGrid.tsx | 56 +
.../bookings/BookingStatusBadge.tsx | 21 +
.../components/bookings/BookingStatusTabs.tsx | 91 ++
.../components/bookings/BookingTableEmpty.tsx | 44 +
.../bookings/BookingWorkflowStepper.tsx | 124 +++
.../bookings/ContractSignaturePad.tsx | 107 ++
.../components/bookings/booking-ui.styles.ts | 32 +
.../bookings/useBookingActionDialog.ts | 132 +++
.../ruleEngine/ruleEngineFormat.tsx | 40 +
.../backoffice/src/constants/QUERY_KEYS.ts | 50 +
.../src/constants/TANSTACK_QUEY_KEY.ts | 25 -
.../backoffice/src/constants/URLS.ts | 30 +-
.../bookings/booking-actions.config.ts | 268 +++++
.../bookings/booking-status.config.ts | 260 +++++
.../features/bookings/mapBookingListRow.ts | 34 +
.../src/hooks/bookings/useBookings.ts | 178 ++++
.../src/hooks/rule-engine/useRuleEngine.ts | 152 +--
.../backoffice/src/hooks/useBookings.ts | 53 +-
.../backoffice/src/lib/queryClient.ts | 12 +
apps/edr-freight-web/backoffice/src/main.tsx | 5 +-
.../pages/bookings/BookingContractPage.tsx | 218 ++++
.../bookings/BookingRequestDetailPage.tsx | 985 +++++++-----------
.../pages/bookings/BookingRequestsPage.tsx | 624 +++++------
.../pages/bookings/booking-requests.mock.ts | 182 +---
.../src/pages/bookings/bookings.mock.ts | 9 +
.../ruleEngine/RuleEngineResourcePage.tsx | 131 +--
.../src/pages/ruleEngine/config/resources.ts | 46 +-
.../backoffice/src/services/api.ts | 135 ++-
.../src/services/bookings.service.ts | 179 +++-
.../services/ruleEngine/ruleEngine.service.ts | 8 +-
.../backoffice/src/types/booking.ts | 126 +++
.../backoffice/src/types/rule-engine/index.ts | 4 -
.../backoffice/src/utils/endpoint.ts | 12 +-
.../backoffice/src/utils/queryInvalidation.ts | 56 +
apps/edr-freight-web/portal/src/App.tsx | 2 +
.../bookings/ContractSignaturePad.tsx | 105 ++
.../portal/src/constants/URLS.ts | 4 +
.../pages/bookings/BookingContractPage.tsx | 165 +++
.../src/pages/bookings/BookingDetailPage.tsx | 21 +
.../portal/src/services/bookings.service.ts | 48 +
packages/types/src/freight/index.ts | 12 +-
pnpm-lock.yaml | 469 +++++++++
89 files changed, 6190 insertions(+), 1724 deletions(-)
create mode 100644 apps/edr-freight-api/src/common/resolve-auth-user-id.ts
create mode 100644 apps/edr-freight-api/src/contracts/contract-pdf.service.ts
create mode 100644 apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts
create mode 100644 apps/edr-freight-api/src/contracts/contract-renderer.service.ts
create mode 100644 apps/edr-freight-api/src/contracts/contract-template.registry.ts
create mode 100644 apps/edr-freight-api/src/contracts/contract-template.resolver.ts
create mode 100644 apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
create mode 100644 apps/edr-freight-api/src/contracts/templates/IMP_CON_ETB_TRANSPORT_ONLY.hbs
create mode 100644 apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs
create mode 100644 apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs
create mode 100644 apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs
create mode 100644 apps/edr-freight-api/src/contracts/templates/generic.hbs
create mode 100644 apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts
create mode 100644 apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts
create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts
create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingPriorityBadge.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingStatGrid.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingTableEmpty.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingWorkflowStepper.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/ContractSignaturePad.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts
create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts
create mode 100644 apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
delete mode 100644 apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts
create mode 100644 apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts
create mode 100644 apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts
create mode 100644 apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts
create mode 100644 apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts
create mode 100644 apps/edr-freight-web/backoffice/src/lib/queryClient.ts
create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/bookings.mock.ts
create mode 100644 apps/edr-freight-web/backoffice/src/types/booking.ts
create mode 100644 apps/edr-freight-web/backoffice/src/utils/queryInvalidation.ts
create mode 100644 apps/edr-freight-web/portal/src/components/bookings/ContractSignaturePad.tsx
create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx
diff --git a/apps/edr-freight-api/nest-cli.json b/apps/edr-freight-api/nest-cli.json
index 6c524a8a1..f4a3b488d 100644
--- a/apps/edr-freight-api/nest-cli.json
+++ b/apps/edr-freight-api/nest-cli.json
@@ -4,7 +4,10 @@
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
- "assets": [{ "include": "migrations/**/*", "outDir": "dist" }],
+ "assets": [
+ { "include": "migrations/**/*", "outDir": "dist" },
+ { "include": "contracts/templates/**/*", "watchAssets": true }
+ ],
"watchAssets": true
}
}
diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index 5114e20da..7e85ffe21 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -31,7 +31,9 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^17.4.2",
+ "handlebars": "^4.7.9",
"minio": "7.1.3",
+ "puppeteer": "^24.2.0",
"pg": "^8.13.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
diff --git a/apps/edr-freight-api/src/common/resolve-auth-user-id.ts b/apps/edr-freight-api/src/common/resolve-auth-user-id.ts
new file mode 100644
index 000000000..cab29b671
--- /dev/null
+++ b/apps/edr-freight-api/src/common/resolve-auth-user-id.ts
@@ -0,0 +1,12 @@
+import { UnauthorizedException } from '@nestjs/common';
+
+export type AuthUserPayload = { id?: string; sub?: string } | null | undefined;
+
+/** Resolve IAM user id from JWT payload attached by JwtGuard. */
+export function resolveAuthUserId(user: AuthUserPayload): string {
+ const id = user?.id ?? user?.sub;
+ if (!id) {
+ throw new UnauthorizedException('Authentication required');
+ }
+ return id;
+}
diff --git a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts
new file mode 100644
index 000000000..399559fb2
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts
@@ -0,0 +1,57 @@
+import { Injectable, Logger } from '@nestjs/common';
+
+@Injectable()
+export class ContractPdfService {
+ private readonly logger = new Logger(ContractPdfService.name);
+
+ async htmlToPdfBuffer(html: string): Promise {
+ try {
+ const puppeteer = await import('puppeteer');
+ const browser = await puppeteer.default.launch({
+ headless: true,
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
+ });
+ try {
+ const page = await browser.newPage();
+ await page.setContent(html, { waitUntil: 'load' });
+ const pdf = await page.pdf({
+ format: 'A4',
+ printBackground: true,
+ margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
+ });
+ return Buffer.from(pdf);
+ } finally {
+ await browser.close();
+ }
+ } catch (err) {
+ this.logger.warn(
+ `Puppeteer PDF failed, falling back to minimal PDF stub: ${err}`,
+ );
+ return this.fallbackPdfBuffer(html);
+ }
+ }
+
+ /** Minimal valid PDF when Chromium is unavailable. */
+ private fallbackPdfBuffer(html: string): Buffer {
+ const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').slice(0, 2000);
+ const escaped = text.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
+ const stream = `BT /F1 10 Tf 50 750 Td (${escaped}) Tj ET`;
+ const len = stream.length;
+ const pdf = `%PDF-1.4
+1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj
+2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj
+3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>endobj
+4 0 obj<< /Length ${len} >>stream
+${stream}
+endstream endobj
+5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj
+xref
+0 6
+0000000000 65535 f
+trailer<< /Size 6 /Root 1 0 R >>
+startxref
+0
+%%EOF`;
+ return Buffer.from(pdf, 'utf-8');
+ }
+}
diff --git a/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts
new file mode 100644
index 000000000..f9dc0b7aa
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts
@@ -0,0 +1,70 @@
+import { Injectable } from '@nestjs/common';
+
+import { BookingPricingService } from '../modules/bookings/booking-pricing.service';
+import { Booking } from '../modules/bookings/entities/booking.entity';
+import { PriceLineItemDto } from '../modules/bookings/dto/generate-price-response.dto';
+
+export interface PricingScheduleRow {
+ label: string;
+ description: string;
+ amount: number;
+ currency: string;
+}
+
+export interface PricingSchedule {
+ lineItems: PricingScheduleRow[];
+ surcharges: PricingScheduleRow[];
+ totalAmount: number;
+ currency: string;
+ equipmentReturn?: string;
+ originLabel: string;
+ destinationLabel: string;
+ containerLines: Array<{
+ label: string;
+ quantity: number;
+ vgmPerUnitTons: number;
+ }>;
+}
+
+@Injectable()
+export class ContractPricingScheduleBuilder {
+ constructor(private readonly pricingService: BookingPricingService) {}
+
+ async build(booking: Booking): Promise {
+ const { lineItems, totalAmount, currency } =
+ await this.pricingService.computeContractLineItems(booking);
+
+ const isSurcharge = (l: PriceLineItemDto) =>
+ l.code.includes('SURCHARGE') || l.description.toLowerCase().includes('surcharge');
+
+ const baseLines = lineItems.filter((l) => !isSurcharge(l));
+ const surchargeLines = lineItems.filter(isSurcharge);
+
+ return {
+ lineItems: baseLines.map((l) => ({
+ label: l.code,
+ description: l.description,
+ amount: l.amount,
+ currency: l.currency,
+ })),
+ surcharges: surchargeLines.map((l) => ({
+ label: l.code,
+ description: l.description,
+ amount: l.amount,
+ currency: l.currency,
+ })),
+ totalAmount,
+ currency,
+ equipmentReturn: booking.equipmentReturn ?? undefined,
+ originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—',
+ destinationLabel:
+ booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
+ containerLines: (booking.bookingContainers ?? []).map((c) => ({
+ label:
+ c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId,
+ quantity: c.quantity,
+ vgmPerUnitTons: Number(c.vgmPerUnitTons),
+ })),
+ };
+ }
+}
diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts
new file mode 100644
index 000000000..dd539df25
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts
@@ -0,0 +1,51 @@
+import { Injectable, OnModuleInit } from '@nestjs/common';
+import * as fs from 'fs';
+import * as path from 'path';
+import Handlebars from 'handlebars';
+
+import { ContractViewModel } from './contract-view-model.builder';
+
+@Injectable()
+export class ContractRendererService implements OnModuleInit {
+ private readonly templatesDir = path.join(__dirname, 'templates');
+ private readonly compiled = new Map();
+
+ onModuleInit(): void {
+ Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b);
+
+ const partialsDir = path.join(this.templatesDir, '_partials');
+ if (fs.existsSync(partialsDir)) {
+ for (const file of fs.readdirSync(partialsDir)) {
+ if (!file.endsWith('.hbs')) continue;
+ const name = file.replace(/\.hbs$/, '');
+ const content = fs.readFileSync(path.join(partialsDir, file), 'utf-8');
+ Handlebars.registerPartial(name, content);
+ }
+ }
+ }
+
+ render(view: ContractViewModel): string {
+ const fileName =
+ view.template.templateFile ?? 'generic.hbs';
+ const template = this.getCompiled(fileName);
+ return template({
+ ...view,
+ paymentArticle: view.pricing.currency === 'ETB' ? 'ETB' : 'USD',
+ });
+ }
+
+ private getCompiled(fileName: string): Handlebars.TemplateDelegate {
+ const cached = this.compiled.get(fileName);
+ if (cached) return cached;
+
+ const filePath = path.join(this.templatesDir, fileName);
+ const fallbackPath = path.join(this.templatesDir, 'generic.hbs');
+ const source = fs.existsSync(filePath)
+ ? fs.readFileSync(filePath, 'utf-8')
+ : fs.readFileSync(fallbackPath, 'utf-8');
+
+ const compiled = Handlebars.compile(source);
+ this.compiled.set(fileName, compiled);
+ return compiled;
+ }
+}
diff --git a/apps/edr-freight-api/src/contracts/contract-template.registry.ts b/apps/edr-freight-api/src/contracts/contract-template.registry.ts
new file mode 100644
index 000000000..aacf6a0a3
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-template.registry.ts
@@ -0,0 +1,98 @@
+export interface ContractTemplateMeta {
+ key: string;
+ title: string;
+ directionLabel: string;
+ freightLabel: string;
+ currency: string;
+ serviceScope: 'TRANSPORT_ONLY' | 'FORWARDING';
+ /** Optional dedicated .hbs file; otherwise uses generic.hbs */
+ templateFile?: string;
+ whereas: string;
+ article1Objective: string;
+}
+
+const DIRECTION_LABELS: Record = {
+ IMP: 'Import',
+ EXP: 'Export',
+ DOM: 'Domestic',
+};
+
+const FREIGHT_LABELS: Record = {
+ CON: 'Container',
+ BULK: 'Bulk',
+};
+
+function buildMeta(
+ dir: string,
+ freight: string,
+ currency: string,
+ service: 'TRANSPORT_ONLY' | 'FORWARDING',
+ templateFile?: string,
+): ContractTemplateMeta {
+ const key = `${dir}_${freight}_${currency}_${service}`;
+ const dirLabel = DIRECTION_LABELS[dir] ?? dir;
+ const freightLabel = FREIGHT_LABELS[freight] ?? freight;
+ const serviceLabel =
+ service === 'FORWARDING' ? 'Rail and Forwarding' : 'Transport Only';
+
+ const corridor =
+ dir === 'IMP'
+ ? 'from SGTD railway freight station at Djibouti to Ethiopian dry ports and return of empty containers as applicable'
+ : dir === 'EXP'
+ ? 'from Ethiopian dry ports to SGTD and related export corridors'
+ : 'between designated Ethiopian rail terminals';
+
+ return {
+ key,
+ title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`,
+ directionLabel: dirLabel,
+ freightLabel,
+ currency,
+ serviceScope: service,
+ templateFile,
+ whereas: `The Client has requested transportation of ${freightLabel.toLowerCase()} cargo ${corridor} using the Addis Ababa–Djibouti Railway line. The Service Provider has agreed to provide services per this contract.`,
+ article1Objective: `To provide railway transportation services for ${freightLabel.toLowerCase()} cargo on the agreed corridor (${serviceLabel}).`,
+ };
+}
+
+const DIRECTIONS = ['IMP', 'EXP', 'DOM'] as const;
+const FREIGHTS = ['CON', 'BULK'] as const;
+const CURRENCIES = ['ETB', 'USD'] as const;
+const SERVICES = ['TRANSPORT_ONLY', 'FORWARDING'] as const;
+
+/** Full template matrix (24 keys). */
+export const CONTRACT_TEMPLATE_REGISTRY: Record =
+ {};
+
+for (const dir of DIRECTIONS) {
+ for (const freight of FREIGHTS) {
+ for (const currency of CURRENCIES) {
+ for (const service of SERVICES) {
+ const dedicated =
+ dir === 'IMP' &&
+ freight === 'CON' &&
+ currency === 'ETB' &&
+ service === 'TRANSPORT_ONLY'
+ ? 'IMP_CON_ETB_TRANSPORT_ONLY.hbs'
+ : undefined;
+ const meta = buildMeta(dir, freight, currency, service, dedicated);
+ CONTRACT_TEMPLATE_REGISTRY[meta.key] = meta;
+ }
+ }
+ }
+}
+
+export function getTemplateMeta(key: string): ContractTemplateMeta {
+ return (
+ CONTRACT_TEMPLATE_REGISTRY[key] ?? {
+ key,
+ title: 'Freight Contract Agreement',
+ directionLabel: 'Freight',
+ freightLabel: 'Cargo',
+ currency: 'USD',
+ serviceScope: 'TRANSPORT_ONLY',
+ whereas: 'The parties agree to railway freight services as described in the schedule below.',
+ article1Objective: 'To provide railway transportation services per the agreed schedule.',
+ }
+ );
+}
diff --git a/apps/edr-freight-api/src/contracts/contract-template.resolver.ts b/apps/edr-freight-api/src/contracts/contract-template.resolver.ts
new file mode 100644
index 000000000..daa48a4e7
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-template.resolver.ts
@@ -0,0 +1,43 @@
+import { Injectable } from '@nestjs/common';
+
+import { Booking } from '../modules/bookings/entities/booking.entity';
+import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
+import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
+
+@Injectable()
+export class ContractTemplateResolver {
+ resolve(booking: Booking): string {
+ const dir =
+ booking.tradeDirection === 'IMPORT'
+ ? 'IMP'
+ : booking.tradeDirection === 'EXPORT'
+ ? 'EXP'
+ : 'DOM';
+
+ let freight = booking.freightType === 'BULK' ? 'BULK' : 'CON';
+ const cargoCode = (booking.cargoType as CargoType | undefined)?.code ?? '';
+ if (cargoCode.startsWith('BREAK_BULK')) {
+ freight = 'BULK';
+ }
+
+ const currency = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
+ const service = this.resolveServiceScope(booking.serviceType);
+
+ return `${dir}_${freight}_${currency}_${service}`;
+ }
+
+ private resolveServiceScope(
+ serviceType?: ServiceType | null,
+ ): 'TRANSPORT_ONLY' | 'FORWARDING' {
+ if (!serviceType) return 'TRANSPORT_ONLY';
+ const code = (serviceType.code ?? '').toUpperCase();
+ if (
+ serviceType.includesFirstMile ||
+ serviceType.includesLastMile ||
+ code.includes('FORWARD')
+ ) {
+ return 'FORWARDING';
+ }
+ return 'TRANSPORT_ONLY';
+ }
+}
diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
new file mode 100644
index 000000000..36c0328c8
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
@@ -0,0 +1,118 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+
+import { BookingsRepository } from '../modules/bookings/bookings.repository';
+import { Booking } from '../modules/bookings/entities/booking.entity';
+import {
+ BookingContractSignature,
+ ContractSignerRole,
+} from '../modules/bookings/entities/booking-contract-signature.entity';
+import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
+import { ContractTemplateResolver } from './contract-template.resolver';
+import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
+
+export interface ContractSignatureView {
+ role: ContractSignerRole;
+ signerDisplayName: string;
+ signedAt: string;
+ signatureImageUrl?: string | null;
+}
+
+export interface ContractViewModel {
+ bookingId: string;
+ reference: string;
+ status: string;
+ templateKey: string;
+ template: ContractTemplateMeta;
+ contractDate: string;
+ contractYear: number;
+ client: {
+ companyName: string;
+ companyAddress: string;
+ companyLocation: string;
+ phone: string;
+ email: string;
+ tinNumber: string;
+ };
+ pricing: PricingSchedule;
+ signatures: ContractSignatureView[];
+ canSignCustomer: boolean;
+ canSignStaff: boolean;
+ hasContractDocument: boolean;
+ hasCustomerSignature: boolean;
+ hasStaffSignature: boolean;
+}
+
+@Injectable()
+export class ContractViewModelBuilder {
+ constructor(
+ private readonly bookingsRepository: BookingsRepository,
+ private readonly templateResolver: ContractTemplateResolver,
+ private readonly pricingBuilder: ContractPricingScheduleBuilder,
+ ) {}
+
+ async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
+ const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
+ if (!booking) {
+ throw new NotFoundException(`Booking ${bookingId} not found`);
+ }
+
+ const templateKey =
+ booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
+ const template = getTemplateMeta(templateKey);
+ const pricing = await this.pricingBuilder.build(booking);
+ const signatures = await this.loadSignatures(bookingId);
+
+ const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
+ const hasStaff = signatures.some((s) => s.role === 'STAFF');
+ const hasContractFile = Boolean(
+ booking.files?.some((f) => f.code === 'contract'),
+ );
+
+ const view: ContractViewModel = {
+ bookingId: booking.id,
+ reference: booking.reference,
+ status: booking.status,
+ templateKey,
+ template,
+ contractDate: new Date().toLocaleDateString('en-GB', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ }),
+ contractYear: new Date().getFullYear(),
+ client: {
+ companyName: booking.customer?.companyName ?? 'Client',
+ companyAddress: booking.customer?.companyAddress ?? '—',
+ companyLocation: booking.customer?.companyLocation ?? '—',
+ phone: booking.customer?.companyPhone ?? booking.customer?.phone ?? '—',
+ email: booking.customer?.companyEmail ?? booking.customer?.email ?? '—',
+ tinNumber: booking.customer?.tinNumber ?? '—',
+ },
+ pricing,
+ signatures,
+ canSignCustomer:
+ booking.status === 'CONTRACT_READY' && !hasCustomer,
+ canSignStaff:
+ booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
+ hasContractDocument: hasContractFile,
+ hasCustomerSignature: hasCustomer,
+ hasStaffSignature: hasStaff,
+ };
+
+ return { booking, view };
+ }
+
+ private async loadSignatures(bookingId: string): Promise {
+ const rows = await this.bookingsRepository.findContractSignatures(bookingId);
+ return rows.map((s) => this.toSignatureView(s));
+ }
+
+ toSignatureView(row: BookingContractSignature): ContractSignatureView {
+ return {
+ role: row.signerRole,
+ signerDisplayName: row.signerDisplayName,
+ signedAt: row.signedAt.toISOString(),
+ signatureImageUrl: row.signatureFile?.url ?? null,
+ };
+ }
+}
diff --git a/apps/edr-freight-api/src/contracts/templates/IMP_CON_ETB_TRANSPORT_ONLY.hbs b/apps/edr-freight-api/src/contracts/templates/IMP_CON_ETB_TRANSPORT_ONLY.hbs
new file mode 100644
index 000000000..90b6dba93
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/IMP_CON_ETB_TRANSPORT_ONLY.hbs
@@ -0,0 +1,68 @@
+
+
+
+
+ Import Container Transport — {{reference}}
+ {{> styles}}
+
+
+
+
Contract Agreement
+
Import Container Transport Service by Railway
+
Contract Ref No: {{reference}}
+
Year: {{contractYear}}
+
+
+ This Contract Agreement is made on {{contractDate}} .
+ Between Ethio-Djibouti Standard Gauge Railway Share Company (EDR), Addis Ababa (“Service Provider”), and {{client.companyName}} at {{client.companyAddress}}, {{client.companyLocation}} (“Client”). Phone {{client.phone}} / {{client.email}}. TIN {{client.tinNumber}}.
+
+ Whereas
+ {{template.whereas}}
+ Now therefore, the parties agree as follows:
+
+
+
Article 1: Objective and Scope of Services
+
Objective: To provide railway transportation for 40ft and/or 20ft full containers from SGTD to Dire Dawa, Modjo dry port and/or Galaan Multipurpose port (GMP), and empty container return from those terminals to SGTD.
+
Scope: (1) Railway transport service; (2) Cargo handling at Galaan Multipurpose port (GMP) where applicable.
+
+
+
+
Article 2: Obligations of the Client (summary)
+
+ Provide shipment instructions to EDR for container movements on the agreed corridor.
+ Meet minimum container supply per terminal (Modjo, Dire Dawa, GMP) as per EDR operational rules.
+ Submit required documents to Djibouti Nagad station at least 24 hours before loading.
+ Pay 100% transportation fees in advance per train set in {{paymentArticle}}.
+ Notify EDR 48 hours in advance for hazardous or valuable cargo.
+
+
+
+
+
Article 3: Obligations of the Service Provider (summary)
+
+ Assign voyage per operational schedule and notify train schedule 48 hours in advance.
+ Provide safe transportation and deliver within agreed timelines when documents are complete.
+ Return empty containers from Dire Dawa, Modjo and GMP to SGTD within seven (7) calendar days of receipt.
+ Maintain cargo liability insurance per wagon.
+
+
+
+ {{> article5_pricing}}
+
+
+
Article 4: Force Majeure
+
Neither party is liable for delays due to force majeure interpreted under the Ethiopian Civil Code.
+
+
+
+
Article 6: Contract Documents
+
+ Amendments (if any)
+ This Contract Agreement
+ Final Minutes of Negotiation (if any)
+
+
+
+ {{> signatures_block}}
+
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs
new file mode 100644
index 000000000..d56c8b8a6
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs
@@ -0,0 +1,47 @@
+Article 5: Contract Price and Terms of Payment
+
+
Contract Price
+
Corridor: {{pricing.originLabel}} → {{pricing.destinationLabel}}
+ {{#if pricing.equipmentReturn}}
+
Equipment return: {{pricing.equipmentReturn}}
+ {{/if}}
+ {{#if pricing.containerLines.length}}
+
+
+ Container type Quantity VGM / unit (t)
+
+
+ {{#each pricing.containerLines}}
+ {{label}} {{quantity}} {{vgmPerUnitTons}}
+ {{/each}}
+
+
+ {{/if}}
+
+
+ Item Description Amount
+
+
+ {{#each pricing.lineItems}}
+
+ {{label}}
+ {{description}}
+ {{currency}} {{amount}}
+
+ {{/each}}
+ {{#each pricing.surcharges}}
+
+ {{label}}
+ {{description}}
+ {{currency}} {{amount}}
+
+ {{/each}}
+
+ Total contract value
+ {{pricing.currency}} {{pricing.totalAmount}}
+
+
+
+
Terms of payment
+
All payments shall be made in accordance with EDR policy in {{paymentArticle}} , unless otherwise agreed in writing.
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs
new file mode 100644
index 000000000..ed5b7048d
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs
@@ -0,0 +1,30 @@
+
+
+
Service Provider (EDR)
+ {{#if hasStaffSignature}}
+ {{#each signatures}}
+ {{#if (eq role "STAFF")}}
+ {{#if signatureImageUrl}}
{{/if}}
+
{{signerDisplayName}}
+
Signed: {{signedAt}}
+ {{/if}}
+ {{/each}}
+ {{else}}
+
Authorized representative (pending)
+ {{/if}}
+
+
+
Client — {{client.companyName}}
+ {{#if hasCustomerSignature}}
+ {{#each signatures}}
+ {{#if (eq role "CUSTOMER")}}
+ {{#if signatureImageUrl}}
{{/if}}
+
{{signerDisplayName}}
+
Signed: {{signedAt}}
+ {{/if}}
+ {{/each}}
+ {{else}}
+
Client representative (pending)
+ {{/if}}
+
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs
new file mode 100644
index 000000000..601768185
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs
@@ -0,0 +1,22 @@
+
diff --git a/apps/edr-freight-api/src/contracts/templates/generic.hbs b/apps/edr-freight-api/src/contracts/templates/generic.hbs
new file mode 100644
index 000000000..23a35cb88
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/generic.hbs
@@ -0,0 +1,42 @@
+
+
+
+
+ {{template.title}} — {{reference}}
+ {{> styles}}
+
+
+
+
Contract Agreement
+
{{template.title}}
+
Contract Ref No: {{reference}}
+
Year: {{contractYear}}
+
+
+ This Contract Agreement is made on {{contractDate}} .
+ Between Ethio-Djibouti Standard Gauge Railway Share Company (EDR) (“Service Provider”) and {{client.companyName}} (“Client”) at {{client.companyAddress}}, {{client.companyLocation}}. Phone: {{client.phone}}. Email: {{client.email}}. TIN: {{client.tinNumber}}.
+
+ Whereas
+ {{template.whereas}}
+ Now therefore, the parties agree as follows:
+
+
+
Article 1: Objective and Scope
+
{{template.article1Objective}}
+
+
+ {{> article5_pricing}}
+
+
+
Article 4: Force Majeure
+
Neither party shall be liable for delays caused by force majeure beyond reasonable control, interpreted per the Ethiopian Civil Code.
+
+
+
+
Article 6: Contract Documents
+
This agreement, amendments (if any), and negotiated minutes constitute the contract.
+
+
+ {{> signatures_block}}
+
+
diff --git a/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts b/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts
new file mode 100644
index 000000000..795d93fc3
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts
@@ -0,0 +1,71 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddBookingFreightType1749300000000 implements MigrationInterface {
+ name = 'AddBookingFreightType1749300000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20);
+ `);
+
+ await queryRunner.query(`
+ UPDATE freight.bookings b
+ SET freight_type = 'CONTAINER'
+ WHERE EXISTS (
+ SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = b.id
+ );
+ `);
+
+ await queryRunner.query(`
+ UPDATE freight.bookings b
+ SET freight_type = 'BULK'
+ WHERE freight_type IS NULL
+ AND b.cargo_type_id IS NOT NULL
+ AND EXISTS (
+ SELECT 1 FROM freight.cargo_types ct
+ WHERE ct.id = b.cargo_type_id AND ct.requires_director_approval = true
+ );
+ `);
+
+ await queryRunner.query(`
+ UPDATE freight.bookings
+ SET freight_type = 'CONTAINER'
+ WHERE freight_type IS NULL;
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ALTER COLUMN cargo_type_id DROP NOT NULL;
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ALTER COLUMN freight_type SET NOT NULL;
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ADD CONSTRAINT chk_bookings_freight_type
+ CHECK (freight_type IN ('CONTAINER', 'BULK'));
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS chk_bookings_freight_type;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings DROP COLUMN IF EXISTS freight_type;
+ `);
+ await queryRunner.query(`
+ UPDATE freight.bookings SET cargo_type_id = (
+ SELECT id FROM freight.cargo_types LIMIT 1
+ ) WHERE cargo_type_id IS NULL;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ALTER COLUMN cargo_type_id SET NOT NULL;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts b/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts
new file mode 100644
index 000000000..8126b91ca
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts
@@ -0,0 +1,45 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddContractSignatures1749400000000 implements MigrationInterface {
+ name = 'AddContractSignatures1749400000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ADD COLUMN IF NOT EXISTS contract_template_key VARCHAR(80),
+ ADD COLUMN IF NOT EXISTS contract_generated_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS pricing_breakdown JSONB;
+ `);
+
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.booking_contract_signatures (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
+ signer_role VARCHAR(20) NOT NULL,
+ signer_user_id UUID,
+ signer_display_name VARCHAR(200) NOT NULL,
+ signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ signature_file_id UUID REFERENCES freight.files(id) ON DELETE SET NULL,
+ consent_text TEXT,
+ ip_address VARCHAR(64),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ deleted_at TIMESTAMPTZ,
+ CONSTRAINT uq_booking_contract_signatures_role
+ UNIQUE (booking_id, signer_role)
+ );
+ CREATE INDEX IF NOT EXISTS idx_booking_contract_signatures_booking_id
+ ON freight.booking_contract_signatures(booking_id);
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_contract_signatures;`);
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ DROP COLUMN IF EXISTS pricing_breakdown,
+ DROP COLUMN IF EXISTS contract_generated_at,
+ DROP COLUMN IF EXISTS contract_template_key;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts
index 65c317eaf..4eadff0fc 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts
@@ -1,16 +1,34 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
import { Readable } from 'stream';
+import { ContractPdfService } from '../../contracts/contract-pdf.service';
+import { ContractRendererService } from '../../contracts/contract-renderer.service';
+import { getTemplateMeta } from '../../contracts/contract-template.registry';
+import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
+import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
+import { MinioService } from '../minio/minio.service';
import { FilesService } from '../files/files.service';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
+import { ContractViewDto } from './dto/contract-view.dto';
+import { SignContractDto } from './dto/sign-contract.dto';
+import { ContractSignerRole } from './entities/booking-contract-signature.entity';
@Injectable()
export class BookingContractService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
+ private readonly minioService: MinioService,
+ private readonly templateResolver: ContractTemplateResolver,
+ private readonly viewModelBuilder: ContractViewModelBuilder,
+ private readonly renderer: ContractRendererService,
+ private readonly pdfService: ContractPdfService,
) {}
buildContractSummary(booking: Booking): string {
@@ -22,7 +40,7 @@ export class BookingContractService {
: booking.tradeDirection;
const cargo = booking.cargoType;
- const isBulk = cargo?.requiresDirectorApproval;
+ const isBulk = booking.freightType === 'BULK';
let cargoLabel: string;
if (isBulk) {
@@ -36,7 +54,7 @@ export class BookingContractService {
cargoLabel =
lines.length > 0
? `Container (${lines.join(', ')})`
- : `Container (${cargo?.cargoTypeName ?? 'Standard'})`;
+ : 'Container (Standard)';
}
return `Operation: ${direction} | Cargo Type: ${cargoLabel}`;
@@ -48,25 +66,117 @@ export class BookingContractService {
return { summary };
}
+ async getContractView(bookingId: string): Promise {
+ const { view } = await this.viewModelBuilder.build(bookingId);
+ await this.enrichSignatureUrls(view.signatures);
+ const html = this.renderer.render(view);
+ return {
+ bookingId: view.bookingId,
+ reference: view.reference,
+ status: view.status,
+ templateKey: view.templateKey,
+ title: view.template.title,
+ html,
+ canSignCustomer: view.canSignCustomer,
+ canSignStaff: view.canSignStaff,
+ hasContractDocument: view.hasContractDocument,
+ signatures: view.signatures,
+ pricingSchedule: view.pricing as unknown as Record,
+ };
+ }
+
async generateContract(bookingId: string): Promise {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['APPROVED']);
- const summary = this.buildContractSummary(booking);
- const body = [
- 'FREIGHT CONTRACT (STUB)',
- `Reference: ${booking.reference}`,
- summary,
- `Total: ${booking.totalAmount} ${booking.paymentCurrency}`,
- `Trade: ${booking.tradeDirection}`,
- ].join('\n');
+ const templateKey = this.templateResolver.resolve(booking);
+ const { view } = await this.viewModelBuilder.build(bookingId);
+ view.templateKey = templateKey;
+ view.template = getTemplateMeta(templateKey);
+
+ const html = this.renderer.render(view);
+ const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
+ const summary = this.buildContractSummary(booking);
- const buffer = Buffer.from(body, 'utf-8');
const file: Express.Multer.File = {
fieldname: 'contract',
- originalname: `contract-${booking.reference}.txt`,
+ originalname: `contract-${booking.reference}.pdf`,
encoding: '7bit',
- mimetype: 'text/plain',
+ mimetype: 'application/pdf',
+ size: pdfBuffer.length,
+ buffer: pdfBuffer,
+ stream: Readable.from(pdfBuffer),
+ destination: '',
+ filename: '',
+ path: '',
+ };
+
+ await this.filesService.upsertByCode({
+ resourceId: bookingId,
+ resource: 'bookings',
+ code: 'contract',
+ file,
+ });
+
+ const now = new Date();
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'CONTRACT_READY',
+ contractSummary: summary,
+ contractTemplateKey: templateKey,
+ contractGeneratedAt: now,
+ } as never);
+ return updated!;
+ }
+
+ async streamContract(bookingId: string) {
+ try {
+ const record = await this.filesService.findByCode(
+ bookingId,
+ 'bookings',
+ 'contract',
+ );
+ return this.filesService.streamById(record.id);
+ } catch {
+ throw new NotFoundException(
+ 'Contract document not found. Generate the contract first.',
+ );
+ }
+ }
+
+ async signContract(
+ bookingId: string,
+ dto: SignContractDto,
+ options: { signerUserId?: string; ipAddress?: string },
+ ): Promise {
+ const booking = await this.requireBooking(bookingId);
+ const role = dto.role as ContractSignerRole;
+
+ if (role === 'CUSTOMER') {
+ assertBookingStatus(booking, ['CONTRACT_READY']);
+ const existing = await this.bookingsRepository.findContractSignature(
+ bookingId,
+ 'CUSTOMER',
+ );
+ if (existing) {
+ throw new BadRequestException('Customer has already signed this contract');
+ }
+ } else {
+ assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
+ const existing = await this.bookingsRepository.findContractSignature(
+ bookingId,
+ 'STAFF',
+ );
+ if (existing) {
+ throw new BadRequestException('Staff has already signed this contract');
+ }
+ }
+
+ const buffer = this.decodeSignatureImage(dto.signatureImageBase64);
+ const sigFile: Express.Multer.File = {
+ fieldname: `signature_${role.toLowerCase()}`,
+ originalname: `signature-${role.toLowerCase()}-${booking.reference}.png`,
+ encoding: '7bit',
+ mimetype: 'image/png',
size: buffer.length,
buffer,
stream: Readable.from(buffer),
@@ -75,27 +185,71 @@ export class BookingContractService {
path: '',
};
- await this.filesService.upload({
+ const fileRecord = await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
- code: 'contract',
- file,
+ code: role === 'CUSTOMER' ? 'signature_customer' : 'signature_staff',
+ file: sigFile,
});
- const updated = await this.bookingsRepository.update(bookingId, {
- status: 'CONTRACT_READY',
- contractSummary: summary,
- } as never);
+ const now = new Date();
+ await this.bookingsRepository.saveContractSignature({
+ bookingId,
+ signerRole: role,
+ signerUserId: options.signerUserId ?? null,
+ signerDisplayName: dto.signerDisplayName,
+ signedAt: now,
+ signatureFileId: fileRecord.id,
+ consentText: dto.consentText ?? null,
+ ipAddress: options.ipAddress ?? null,
+ });
+
+ const updates: Record = {};
+
+ if (role === 'CUSTOMER') {
+ updates.status = 'SIGNED_CUSTOMER';
+ updates.customerSignedAt = now;
+ } else {
+ updates.status = 'FULLY_EXECUTED';
+ updates.fullyExecutedAt = now;
+ updates.marketingApprovedAt = now;
+ updates.marketingApprovedById = options.signerUserId ?? null;
+ updates.lockedAt = now;
+ }
+
+ const updated = await this.bookingsRepository.update(bookingId, updates as never);
return updated!;
}
- async streamContract(bookingId: string) {
- const record = await this.filesService.findByCode(
- bookingId,
- 'bookings',
- 'contract',
- );
- return this.filesService.streamById(record.id);
+ async getSignatures(bookingId: string) {
+ const rows = await this.bookingsRepository.findContractSignatures(bookingId);
+ const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
+ await this.enrichSignatureUrls(views);
+ return { signatures: views };
+ }
+
+ private async enrichSignatureUrls(
+ signatures: Array<{ signatureImageUrl?: string | null }>,
+ ): Promise {
+ for (const sig of signatures) {
+ if (!sig.signatureImageUrl) continue;
+ try {
+ const objectName = this.extractObjectName(sig.signatureImageUrl);
+ sig.signatureImageUrl = await this.minioService.getSignedUrl(objectName, 3600);
+ } catch {
+ /* keep original url */
+ }
+ }
+ }
+
+ private extractObjectName(url: string): string {
+ const parts = url.split('/');
+ return parts.slice(4).join('/');
+ }
+
+ private decodeSignatureImage(base64: string): Buffer {
+ const raw = base64.includes(',') ? base64.split(',')[1]! : base64;
+ return Buffer.from(raw, 'base64');
}
private async requireBooking(id: string): Promise {
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts
new file mode 100644
index 000000000..cf4561c7b
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts
@@ -0,0 +1,43 @@
+import { BadRequestException } from '@nestjs/common';
+
+import { FREIGHT_TYPES, FreightType } from './entities/booking.entity';
+import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator';
+
+/** Normalize and validate booking freight shape (used on create and after update merge). */
+export function assertFreightShape(input: BookingFreightShapeInput): void {
+ if (!input.freightType || !FREIGHT_TYPES.includes(input.freightType as FreightType)) {
+ throw new BadRequestException(
+ `freightType is required and must be one of: ${FREIGHT_TYPES.join(', ')}`,
+ );
+ }
+
+ const containers = input.containers ?? [];
+ const hasContainers = containers.length > 0;
+ const hasCargoType = Boolean(input.cargoTypeId);
+
+ if (input.freightType === 'BULK') {
+ if (hasContainers) {
+ throw new BadRequestException(
+ 'BULK freight cannot include container lines; use cargoTypeId only',
+ );
+ }
+ if (!hasCargoType) {
+ throw new BadRequestException('cargoTypeId is required for BULK freight');
+ }
+ return;
+ }
+
+ if (hasCargoType) {
+ throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
+ }
+ if (!hasContainers) {
+ throw new BadRequestException(
+ 'CONTAINER freight requires at least one container line with containerTypeId',
+ );
+ }
+ for (const line of containers) {
+ if (!line.containerTypeId) {
+ throw new BadRequestException('Each container line must include containerTypeId');
+ }
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
index 621fb58e9..4e47456e0 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
@@ -1,14 +1,8 @@
-import { Inject, Injectable, NotFoundException } from '@nestjs/common';
+import { Injectable, NotFoundException } from '@nestjs/common';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
-import {
- IRatesRepository,
- RATES_REPOSITORY,
-} from '../rule-engine/interfaces/rates.repository.interface';
-import {
- IServiceTypesRepository,
- SERVICE_TYPES_REPOSITORY,
-} from '../rule-engine/interfaces/service-types.repository.interface';
+import { RatesService } from '../rule-engine/services/rates.service';
+import { ServiceTypesService } from '../rule-engine/services/service-types.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import {
AppliedCargoModifier,
@@ -26,10 +20,8 @@ export class BookingPricingService {
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
- @Inject(RATES_REPOSITORY)
- private readonly ratesRepo: IRatesRepository,
- @Inject(SERVICE_TYPES_REPOSITORY)
- private readonly serviceTypesRepo: IServiceTypesRepository,
+ private readonly ratesService: RatesService,
+ private readonly serviceTypesService: ServiceTypesService,
) {}
async generatePrice(bookingId: string): Promise {
@@ -37,6 +29,7 @@ export class BookingPricingService {
assertBookingStatus(booking, ['DRAFT']);
const evalInput = await this.buildEvalInputForBooking(booking);
+ console.log('evalInput----', evalInput);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
@@ -65,6 +58,12 @@ export class BookingPricingService {
await this.bookingsRepository.update(bookingId, {
totalAmount: total,
priorityScore: ruleResult.priorityScore,
+ pricingBreakdown: {
+ lineItems,
+ totalAmount: total,
+ currency: booking.paymentCurrency,
+ generatedAt: new Date().toISOString(),
+ },
} as never);
return {
@@ -92,7 +91,8 @@ export class BookingPricingService {
}),
);
return {
- cargoTypeId: booking.cargoTypeId,
+ freightType: booking.freightType as 'CONTAINER' | 'BULK',
+ cargoTypeId: booking.cargoTypeId ?? null,
serviceTypeId: booking.serviceTypeId,
paymentCurrency: booking.paymentCurrency,
tradeDirection: booking.tradeDirection,
@@ -109,13 +109,71 @@ export class BookingPricingService {
return booking;
}
+ /** Line items for contract schedule (uses stored breakdown or recomputes). */
+ async computeContractLineItems(booking: Booking): Promise<{
+ lineItems: PriceLineItemDto[];
+ totalAmount: number;
+ currency: string;
+ }> {
+ const stored = booking.pricingBreakdown as {
+ lineItems?: PriceLineItemDto[];
+ totalAmount?: number;
+ currency?: string;
+ } | null;
+
+ if (stored?.lineItems?.length) {
+ return {
+ lineItems: stored.lineItems,
+ totalAmount: Number(stored.totalAmount ?? booking.totalAmount),
+ currency: stored.currency ?? booking.paymentCurrency,
+ };
+ }
+
+ const evalInput = await this.buildEvalInputForBooking(booking);
+ const ruleResult = await this.ruleEngineService.evaluate(evalInput);
+ const lineItems: PriceLineItemDto[] = [];
+ let total = 0;
+
+ const baseLines = await this.computeBaseRailLines(booking, evalInput);
+ for (const line of baseLines) {
+ lineItems.push(line);
+ total += line.amount;
+ }
+
+ for (const mod of ruleResult.appliedModifiers) {
+ lineItems.push({
+ code: mod.surchargeTypeCode,
+ description: `Surcharge: ${mod.surchargeTypeCode}`,
+ amount: mod.calculatedAmount,
+ currency: mod.currency,
+ });
+ total += mod.calculatedAmount;
+ }
+
+ if (lineItems.length === 0) {
+ total = Number(booking.totalAmount);
+ lineItems.push({
+ code: 'TOTAL',
+ description: 'Contract total',
+ amount: total,
+ currency: booking.paymentCurrency,
+ });
+ }
+
+ return {
+ lineItems,
+ totalAmount: total || Number(booking.totalAmount),
+ currency: booking.paymentCurrency,
+ };
+ }
+
/** Recompute priority on submit (USD + service tier). */
async computeSubmitPriorityScore(booking: Booking): Promise {
const evalInput = await this.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
let score = ruleResult.priorityScore;
- const serviceType = await this.serviceTypesRepo.findById(booking.serviceTypeId);
+ const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId);
if (booking.paymentCurrency === 'USD' && serviceType) {
const code = (serviceType.code ?? '').toUpperCase();
const hasForwarding =
@@ -136,10 +194,10 @@ export class BookingPricingService {
booking: Booking,
evalInput: BookingEvaluationInput,
): Promise {
- const liveRates = await this.ratesRepo.findLiveRates();
+ const liveRates = await this.ratesService.findLiveRates();
const currency = booking.paymentCurrency;
- const isBulk = booking.cargoType?.requiresDirectorApproval ?? false;
-
+ const isBulk = booking.freightType === 'BULK';
+console.log('liveRates----', liveRates);
const rateType =
booking.tradeDirection === 'IMPORT'
? isBulk
@@ -151,11 +209,16 @@ export class BookingPricingService {
: 'CONTAINER_EXPORT'
: 'INTERCITY_CONTAINER';
+
+ console.log('rateType----', rateType);
+
const lines: PriceLineItemDto[] = [];
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
for (const container of evalInput.containers) {
+ console.log('container----', container);
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
+ console.log('rate----', rate);
if (!rate) continue;
const amount = this.amountForRate(rate, container.quantity, wagonCount);
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
index d40d6590b..ad76fa7a3 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
@@ -42,7 +42,7 @@ export class BookingTransitionService {
async requestChanges(
bookingId: string,
note: string,
- actorId?: string,
+ actorId: string,
): Promise {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
@@ -60,19 +60,19 @@ export class BookingTransitionService {
return this.bookingsService.findById(updated!.id);
}
- async acceptIntake(bookingId: string, actorId?: string): Promise {
+ async acceptIntake(bookingId: string, actorId: string): Promise {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
- await this.ruleEngineService.instantiateApprovalSteps(
- bookingId,
- booking.cargoTypeId,
- );
+ await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
+ freightType: booking.freightType as 'CONTAINER' | 'BULK',
+ cargoTypeId: booking.cargoTypeId,
+ });
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PENDING_APPROVAL',
- approvedByStaffId: actorId ?? booking.approvedByStaffId,
- approvedByStaffAt: actorId ? new Date() : booking.approvedByStaffAt,
+ approvedByStaffId: actorId,
+ approvedByStaffAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -80,7 +80,7 @@ export class BookingTransitionService {
async staffReject(
bookingId: string,
reason: string,
- actorId?: string,
+ actorId: string,
): Promise {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
@@ -211,17 +211,14 @@ export class BookingTransitionService {
return this.bookingsService.findById(updated!.id);
}
- async marketingApprove(
- bookingId: string,
- actorId?: string,
- ): Promise {
+ async marketingApprove(bookingId: string, actorId: string): Promise {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'FULLY_EXECUTED',
fullyExecutedAt: new Date(),
- marketingApprovedById: actorId ?? null,
+ marketingApprovedById: actorId,
marketingApprovedAt: new Date(),
lockedAt: new Date(),
} as never);
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
index c8572dd76..03c5cfde4 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
@@ -14,8 +14,11 @@ import {
Res,
StreamableFile,
UploadedFiles,
+ UseGuards,
UseInterceptors,
} from '@nestjs/common';
+import { CurrentUser } from '@edr/api-common';
+import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
@@ -41,12 +44,16 @@ import {
ApproveStepDto,
CancelBookingDto,
RejectStepDto,
- MarketingApproveDto,
RequestChangesDto,
- StaffAcceptDto,
StaffRejectDto,
} from './dto/request-changes.dto';
+import { ContractViewDto } from './dto/contract-view.dto';
+import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
+import {
+ type AuthUserPayload,
+ resolveAuthUserId,
+} from '../../common/resolve-auth-user-id';
@ApiTags('bookings')
@Controller('bookings')
@@ -155,96 +162,146 @@ export class BookingsController {
}
@Post(':id/staff/request-changes')
+ @UseGuards(JwtGuard)
@ApiOperation({ summary: 'Staff return booking for customer updates' })
async requestChanges(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestChangesDto,
+ @CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.requestChanges(
id,
dto.note,
- dto.actorId,
+ resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/accept')
+ @UseGuards(JwtGuard)
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
async acceptIntake(
@Param('id', ParseUUIDPipe) id: string,
- @Body() dto: StaffAcceptDto,
+ @CurrentUser() user: AuthUserPayload,
) {
- const booking = await this.transitionService.acceptIntake(id, dto.actorId);
+ const booking = await this.transitionService.acceptIntake(
+ id,
+ resolveAuthUserId(user),
+ );
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/reject')
+ @UseGuards(JwtGuard)
@ApiOperation({ summary: 'Staff final reject' })
async staffReject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: StaffRejectDto,
+ @CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.staffReject(
id,
dto.reason,
- dto.actorId,
+ resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/approve')
+ @UseGuards(JwtGuard)
@ApiOperation({ summary: 'Approve one approval step in sequence' })
async approveStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto,
+ @CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.approveStep(
id,
stepId,
- dto.actorId,
+ resolveAuthUserId(user),
dto.requiredRole,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/reject')
+ @UseGuards(JwtGuard)
@ApiOperation({ summary: 'Reject at approval step' })
async rejectStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: RejectStepDto,
+ @CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.rejectStep(
id,
stepId,
- dto.actorId,
+ resolveAuthUserId(user),
dto.reason,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/contract/generate')
- @ApiOperation({ summary: 'Generate contract document' })
+ @UseGuards(JwtGuard)
+ @ApiOperation({ summary: 'Generate contract PDF from template' })
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.contractService.generateContract(id);
return this.transitionService.enrichBookingResponse(booking);
}
- @Get(':id/contract')
- @ApiOperation({ summary: 'Download contract file' })
- async downloadContract(
+ @Get(':id/contract/view')
+ @ApiOkResponse({ type: ContractViewDto })
+ @ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
+ getContractView(@Param('id', ParseUUIDPipe) id: string) {
+ return this.contractService.getContractView(id);
+ }
+
+ @Get(':id/contract/document')
+ @ApiOperation({ summary: 'Download contract PDF' })
+ async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@Res({ passthrough: true }) res: Response,
) {
const { stream, record } = await this.contractService.streamContract(id);
res.set({
- 'Content-Type': record.mimeType ?? 'application/octet-stream',
+ 'Content-Type': record.mimeType ?? 'application/pdf',
'Content-Disposition': `attachment; filename="${record.name}"`,
});
return new StreamableFile(stream);
}
+ @Get(':id/contract')
+ @ApiOperation({ summary: 'Download contract file (alias)' })
+ async downloadContract(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Res({ passthrough: true }) res: Response,
+ ) {
+ return this.downloadContractDocument(id, res);
+ }
+
+ @Post(':id/contract/sign')
+ @ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
+ async signContract(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: SignContractDto,
+ @Request() req: { user?: { id?: string; sub?: string }; ip?: string },
+ ) {
+ const userId = req.user?.id ?? req.user?.sub;
+ const booking = await this.contractService.signContract(id, dto, {
+ signerUserId: userId,
+ ipAddress: req.ip,
+ });
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Get(':id/contract/signatures')
+ @ApiOperation({ summary: 'List contract signatures' })
+ getContractSignatures(@Param('id', ParseUUIDPipe) id: string) {
+ return this.contractService.getSignatures(id);
+ }
+
@Get(':id/summary')
@ApiOperation({ summary: 'Contract summary string for dashboard' })
getSummary(@Param('id', ParseUUIDPipe) id: string) {
@@ -252,22 +309,41 @@ export class BookingsController {
}
@Post(':id/customer/sign')
- @ApiOperation({ summary: 'Customer digital signature' })
- async customerSign(@Param('id', ParseUUIDPipe) id: string) {
- const booking = await this.transitionService.customerSign(id);
+ @ApiOperation({
+ summary: 'Customer digital signature (deprecated — use POST contract/sign)',
+ })
+ async customerSign(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: SignContractDto,
+ @Request() req: { user?: { id?: string; sub?: string }; ip?: string },
+ ) {
+ const payload: SignContractDto = { ...dto, role: 'CUSTOMER' };
+ const booking = await this.contractService.signContract(id, payload, {
+ signerUserId: req.user?.id ?? req.user?.sub,
+ ipAddress: req.ip,
+ });
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/marketing/approve')
- @ApiOperation({ summary: 'Marketing verify and fully execute' })
+ @UseGuards(JwtGuard)
+ @ApiOperation({
+ summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
+ })
async marketingApprove(
@Param('id', ParseUUIDPipe) id: string,
- @Body() dto: MarketingApproveDto,
+ @Body() dto: SignContractDto,
+ @CurrentUser() user: AuthUserPayload,
+ @Request() req: { ip?: string },
) {
- const booking = await this.transitionService.marketingApprove(
- id,
- dto.actorId,
- );
+ const payload: SignContractDto = {
+ ...dto,
+ role: 'STAFF',
+ };
+ const booking = await this.contractService.signContract(id, payload, {
+ signerUserId: resolveAuthUserId(user),
+ ipAddress: req.ip,
+ });
return this.transitionService.enrichBookingResponse(booking);
}
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
index f3b3a9360..1f96176ba 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
@@ -19,8 +19,14 @@ import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
+import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
+import { ContractPdfService } from '../../contracts/contract-pdf.service';
+import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
+import { ContractRendererService } from '../../contracts/contract-renderer.service';
+import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
+import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
@Module({
imports: [
@@ -31,6 +37,7 @@ import { Booking } from './entities/booking.entity';
BookingApprovalStep,
BookingRateSnapshot,
BookingReviewNote,
+ BookingContractSignature,
]),
FilesModule,
MinioModule,
@@ -47,6 +54,11 @@ import { Booking } from './entities/booking.entity';
BookingTransitionService,
BookingContractService,
BookingPaymentService,
+ ContractTemplateResolver,
+ ContractViewModelBuilder,
+ ContractPricingScheduleBuilder,
+ ContractRendererService,
+ ContractPdfService,
],
exports: [BookingsService],
})
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
index 3a6155f3b..04c8a2fb7 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
@@ -10,6 +10,10 @@ import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
+import {
+ BookingContractSignature,
+ ContractSignerRole,
+} from './entities/booking-contract-signature.entity';
import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
@@ -353,7 +357,7 @@ export class BookingsRepository extends BaseRepository {
.where('booking.status IN (:...statuses)', { statuses });
if (options.excludeBulk) {
- qb.andWhere('cargo.requires_director_approval = false');
+ qb.andWhere("booking.freight_type = 'CONTAINER'");
}
const sortField =
@@ -380,4 +384,39 @@ export class BookingsRepository extends BaseRepository {
order: options.order,
});
}
+
+ findContractSignatures(bookingId: string): Promise {
+ return this.dataSource.getRepository(BookingContractSignature).find({
+ where: { bookingId },
+ relations: ['signatureFile'],
+ order: { signedAt: 'ASC' },
+ });
+ }
+
+ findContractSignature(
+ bookingId: string,
+ role: ContractSignerRole,
+ ): Promise {
+ return this.dataSource.getRepository(BookingContractSignature).findOne({
+ where: { bookingId, signerRole: role },
+ relations: ['signatureFile'],
+ });
+ }
+
+ async saveContractSignature(
+ data: Partial,
+ ): Promise {
+ const repo = this.dataSource.getRepository(BookingContractSignature);
+ const existing = await repo.findOne({
+ where: {
+ bookingId: data.bookingId!,
+ signerRole: data.signerRole!,
+ },
+ });
+ if (existing) {
+ Object.assign(existing, data);
+ return repo.save(existing);
+ }
+ return repo.save(repo.create(data));
+ }
}
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index 1039e60c1..f4351a1e6 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -16,10 +16,11 @@ import {
} from '../rule-engine/rule-engine.service';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
+import { assertFreightShape } from './booking-freight.util';
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
-import { CUSTOMER_EDITABLE_STATUSES } from './entities/booking.entity';
+import { CUSTOMER_EDITABLE_STATUSES, FreightType } from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
@@ -42,22 +43,23 @@ export class BookingsService {
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
}
- /** Build evaluation input from DTO containers. */
- private async buildEvalInput(
- dto: Pick<
- CreateBookingDto,
- | 'cargoTypeId'
- | 'serviceTypeId'
- | 'paymentCurrency'
- | 'tradeDirection'
- | 'isHazardous'
- | 'allowConsolidation'
- | 'shippingLineId'
- | 'containers'
- >,
- ): Promise {
+ /** Build evaluation input from booking freight shape. */
+ private async buildEvalInput(dto: {
+ freightType: FreightType;
+ cargoTypeId?: string | null;
+ serviceTypeId: string;
+ paymentCurrency: string;
+ tradeDirection: string;
+ isHazardous?: boolean;
+ allowConsolidation?: boolean;
+ shippingLineId?: string | null;
+ containers: CreateBookingContainerDto[];
+ }): Promise {
+ const containerLines =
+ dto.freightType === 'CONTAINER' ? dto.containers : [];
+
const containers = await Promise.all(
- dto.containers.map(async (c) => {
+ containerLines.map(async (c) => {
const ct = await this.containerTypesService.findById(c.containerTypeId);
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
return {
@@ -69,13 +71,16 @@ export class BookingsService {
};
}),
);
+
return {
- cargoTypeId: dto.cargoTypeId,
+ freightType: dto.freightType,
+ cargoTypeId: dto.cargoTypeId ?? null,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
- allowConsolidation: dto.allowConsolidation,
+ allowConsolidation:
+ dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
shippingLineId: dto.shippingLineId,
containers,
};
@@ -161,12 +166,29 @@ export class BookingsService {
}
const reference = dto.reference || (await this.generateReference());
- const allowConsolidation = await this.resolveConsolidation(
- dto.containers,
- dto.allowConsolidation,
- );
+ const containers = dto.containers ?? [];
+ assertFreightShape({
+ freightType: dto.freightType,
+ cargoTypeId: dto.cargoTypeId,
+ containers,
+ });
- const evalInput = await this.buildEvalInput({ ...dto, allowConsolidation });
+ const allowConsolidation =
+ dto.freightType === 'CONTAINER'
+ ? await this.resolveConsolidation(containers, dto.allowConsolidation)
+ : false;
+
+ const evalInput = await this.buildEvalInput({
+ freightType: dto.freightType as FreightType,
+ cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
+ serviceTypeId: dto.serviceTypeId,
+ paymentCurrency: dto.paymentCurrency,
+ tradeDirection: dto.tradeDirection,
+ isHazardous: dto.isHazardous,
+ allowConsolidation,
+ shippingLineId: dto.shippingLineId,
+ containers,
+ });
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
@@ -185,7 +207,8 @@ export class BookingsService {
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
tradeDirection: dto.tradeDirection,
- cargoTypeId: dto.cargoTypeId,
+ freightType: dto.freightType,
+ cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
cargoFreeText: dto.cargoFreeText,
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
@@ -203,18 +226,19 @@ export class BookingsService {
paymentStatus: 'PENDING',
});
- await this.bookingsRepository.createContainers(
- booking.id,
- dto.containers.map((c, i) => ({
- containerTypeId: c.containerTypeId,
- quantity: c.quantity,
- vgmPerUnitTons: c.vgmPerUnitTons,
- weightResult: ruleResult.containerWeightResults[i],
- })),
- );
-
- const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
- warnings.push(`Estimated wagons required: ${wagonCount}`);
+ if (dto.freightType === 'CONTAINER') {
+ await this.bookingsRepository.createContainers(
+ booking.id,
+ containers.map((c, i) => ({
+ containerTypeId: c.containerTypeId,
+ quantity: c.quantity,
+ vgmPerUnitTons: c.vgmPerUnitTons,
+ weightResult: ruleResult.containerWeightResults[i],
+ })),
+ );
+ const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
+ warnings.push(`Estimated wagons required: ${wagonCount}`);
+ }
if (files.length > 0) {
try {
@@ -249,19 +273,44 @@ export class BookingsService {
}
const warnings: string[] = [];
- const containers = dto.containers ?? existing.bookingContainers?.map((bc) => ({
- containerTypeId: bc.containerTypeId,
- quantity: bc.quantity,
- vgmPerUnitTons: Number(bc.vgmPerUnitTons),
- })) ?? [];
+ const freightType = (dto.freightType ?? existing.freightType) as FreightType;
+ let containers =
+ dto.containers ??
+ existing.bookingContainers?.map((bc) => ({
+ containerTypeId: bc.containerTypeId,
+ quantity: bc.quantity,
+ vgmPerUnitTons: Number(bc.vgmPerUnitTons),
+ })) ??
+ [];
- const allowConsolidation = await this.resolveConsolidation(
- containers,
- dto.allowConsolidation ?? existing.allowConsolidation,
- );
+ let cargoTypeId =
+ dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
+
+ if (freightType === 'BULK') {
+ containers = [];
+ if (dto.containers !== undefined) {
+ await this.bookingsRepository.deleteContainers(id);
+ }
+ } else {
+ cargoTypeId = null;
+ if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== null) {
+ throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
+ }
+ }
+
+ assertFreightShape({ freightType, cargoTypeId, containers });
+
+ const allowConsolidation =
+ freightType === 'CONTAINER'
+ ? await this.resolveConsolidation(
+ containers,
+ dto.allowConsolidation ?? existing.allowConsolidation,
+ )
+ : false;
const evalInput = await this.buildEvalInput({
- cargoTypeId: dto.cargoTypeId ?? existing.cargoTypeId,
+ freightType,
+ cargoTypeId,
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
@@ -277,6 +326,8 @@ export class BookingsService {
const updates: Record = {
...dto,
+ freightType,
+ cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
allowConsolidation,
priorityScore: ruleResult.priorityScore,
};
@@ -287,7 +338,7 @@ export class BookingsService {
await this.bookingsRepository.update(id, updates);
- if (dto.containers) {
+ if (freightType === 'CONTAINER' && dto.containers) {
await this.bookingsRepository.deleteContainers(id);
await this.bookingsRepository.createContainers(
id,
@@ -328,6 +379,7 @@ export class BookingsService {
if (filter.contractType) where.contractType = filter.contractType;
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
+ if (filter.freightType) where.freightType = filter.freightType;
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
if (filter.allowConsolidation !== undefined) {
@@ -347,6 +399,7 @@ export class BookingsService {
skip: (page - 1) * pageSize,
take: pageSize,
order: { [sortField]: sortDir },
+ relations: ['customer', 'originYard', 'destinationYard', 'serviceType'],
});
return { items, total };
}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts
new file mode 100644
index 000000000..4af9e535d
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts
@@ -0,0 +1,50 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+
+export class ContractSignatureDto {
+ @ApiProperty({ enum: ['CUSTOMER', 'STAFF'] })
+ role!: string;
+
+ @ApiProperty()
+ signerDisplayName!: string;
+
+ @ApiProperty()
+ signedAt!: string;
+
+ @ApiPropertyOptional()
+ signatureImageUrl?: string | null;
+}
+
+export class ContractViewDto {
+ @ApiProperty()
+ bookingId!: string;
+
+ @ApiProperty()
+ reference!: string;
+
+ @ApiProperty()
+ status!: string;
+
+ @ApiProperty()
+ templateKey!: string;
+
+ @ApiProperty()
+ title!: string;
+
+ @ApiProperty({ description: 'Full HTML document for in-browser display' })
+ html!: string;
+
+ @ApiProperty()
+ canSignCustomer!: boolean;
+
+ @ApiProperty()
+ canSignStaff!: boolean;
+
+ @ApiProperty()
+ hasContractDocument!: boolean;
+
+ @ApiProperty({ type: [ContractSignatureDto] })
+ signatures!: ContractSignatureDto[];
+
+ @ApiPropertyOptional()
+ pricingSchedule?: Record;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
index 0d61752f4..30cfd9fab 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
+ ArrayMinSize,
IsArray,
IsBoolean,
IsDateString,
@@ -11,9 +12,12 @@ import {
IsString,
IsUUID,
Min,
+ Validate,
+ ValidateIf,
ValidateNested,
} from 'class-validator';
-import { BOOKING_STATUSES } from '../entities/booking.entity';
+import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity';
+import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const;
@@ -24,6 +28,7 @@ export {
BOOKING_STATUSES,
CONTRACT_TYPES,
EQUIPMENT_RETURNS,
+ FREIGHT_TYPES,
TRADE_DIRECTIONS,
PAYMENT_CURRENCIES,
};
@@ -47,6 +52,9 @@ export class CreateBookingContainerDto {
}
export class CreateBookingDto {
+ /** Class-level freight shape check (not a request field). */
+ @Validate(BookingFreightShapeConstraint)
+ freightShapeValidation?: boolean;
@ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' })
@IsOptional()
@IsString()
@@ -107,9 +115,17 @@ export class CreateBookingDto {
@IsIn([...TRADE_DIRECTIONS])
tradeDirection!: string;
- @ApiProperty({ format: 'uuid', description: 'FK to cargo_types.id' })
+ @ApiProperty({ enum: FREIGHT_TYPES, description: 'CONTAINER or BULK (mutually exclusive cargo shape)' })
+ @IsIn([...FREIGHT_TYPES])
+ freightType!: string;
+
+ @ApiPropertyOptional({
+ format: 'uuid',
+ description: 'Required for BULK; must be omitted for CONTAINER',
+ })
+ @ValidateIf((o) => o.freightType === 'BULK')
@IsUUID()
- cargoTypeId!: string;
+ cargoTypeId?: string;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@@ -157,11 +173,16 @@ export class CreateBookingDto {
@IsString()
financialTerms?: string;
- @ApiProperty({ type: [CreateBookingContainerDto] })
+ @ApiPropertyOptional({
+ type: [CreateBookingContainerDto],
+ description: 'Required for CONTAINER (min 1 line); must be empty for BULK',
+ })
+ @ValidateIf((o) => o.freightType === 'CONTAINER')
@IsArray()
+ @ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateBookingContainerDto)
- containers!: CreateBookingContainerDto[];
+ containers?: CreateBookingContainerDto[];
@ApiPropertyOptional({ default: false })
@IsOptional()
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts
index 912064905..cdf4a6b18 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts
@@ -1,7 +1,12 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
-import { BOOKING_STATUSES, PAYMENT_CURRENCIES, TRADE_DIRECTIONS } from './create-booking.dto';
+import {
+ BOOKING_STATUSES,
+ FREIGHT_TYPES,
+ PAYMENT_CURRENCIES,
+ TRADE_DIRECTIONS,
+} from './create-booking.dto';
export class FilterBookingDto {
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
@@ -28,6 +33,11 @@ export class FilterBookingDto {
@IsUUID()
cargoTypeId?: string;
+ @ApiPropertyOptional({ enum: FREIGHT_TYPES })
+ @IsOptional()
+ @IsIn([...FREIGHT_TYPES])
+ freightType?: string;
+
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
index 8e77b51fc..6f716388c 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
@@ -1,30 +1,11 @@
-import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
-import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString, MinLength } from 'class-validator';
export class RequestChangesDto {
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
@IsString()
@MinLength(1)
note!: string;
-
- @ApiPropertyOptional({ format: 'uuid' })
- @IsOptional()
- @IsUUID()
- actorId?: string;
-}
-
-export class StaffAcceptDto {
- @ApiPropertyOptional({ format: 'uuid' })
- @IsOptional()
- @IsUUID()
- actorId?: string;
-}
-
-export class MarketingApproveDto {
- @ApiPropertyOptional({ format: 'uuid' })
- @IsOptional()
- @IsUUID()
- actorId?: string;
}
export class StaffRejectDto {
@@ -32,28 +13,15 @@ export class StaffRejectDto {
@IsString()
@MinLength(1)
reason!: string;
-
- @ApiPropertyOptional({ format: 'uuid' })
- @IsOptional()
- @IsUUID()
- actorId?: string;
}
export class ApproveStepDto {
- @ApiProperty({ format: 'uuid' })
- @IsUUID()
- actorId!: string;
-
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
@IsString()
requiredRole!: string;
}
export class RejectStepDto {
- @ApiProperty({ format: 'uuid' })
- @IsUUID()
- actorId!: string;
-
@ApiProperty()
@IsString()
@MinLength(1)
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts
new file mode 100644
index 000000000..0b176ebd5
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts
@@ -0,0 +1,23 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
+
+export class SignContractDto {
+ @ApiProperty({ enum: ['CUSTOMER', 'STAFF'] })
+ @IsIn(['CUSTOMER', 'STAFF'])
+ role!: 'CUSTOMER' | 'STAFF';
+
+ @ApiProperty({ description: 'PNG signature image as base64 (with or without data URL prefix)' })
+ @IsString()
+ @MinLength(20)
+ signatureImageBase64!: string;
+
+ @ApiProperty()
+ @IsString()
+ @MinLength(1)
+ signerDisplayName!: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ consentText?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts
index 2b97debc6..328e71180 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts
@@ -1,5 +1,10 @@
-import { PartialType } from "@nestjs/mapped-types";
+import { PartialType } from '@nestjs/mapped-types';
+import { Validate } from 'class-validator';
-import { CreateBookingDto } from "./create-booking.dto";
+import { CreateBookingDto } from './create-booking.dto';
+import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
-export class UpdateBookingDto extends PartialType(CreateBookingDto) {}
+export class UpdateBookingDto extends PartialType(CreateBookingDto) {
+ @Validate(BookingFreightShapeConstraint)
+ freightShapeValidation?: boolean;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts
new file mode 100644
index 000000000..1365158b1
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts
@@ -0,0 +1,60 @@
+import {
+ ValidationArguments,
+ ValidatorConstraint,
+ ValidatorConstraintInterface,
+} from 'class-validator';
+
+import { FREIGHT_TYPES, FreightType } from '../../entities/booking.entity';
+
+export interface BookingFreightShapeInput {
+ freightType?: string;
+ cargoTypeId?: string | null;
+ containers?: Array<{ containerTypeId?: string }> | null;
+}
+
+@ValidatorConstraint({ name: 'BookingFreightShape', async: false })
+export class BookingFreightShapeConstraint implements ValidatorConstraintInterface {
+ validate(_value: unknown, args: ValidationArguments): boolean {
+ const dto = args.object as BookingFreightShapeInput;
+ if (!dto.freightType || !FREIGHT_TYPES.includes(dto.freightType as FreightType)) {
+ return true;
+ }
+
+ const containers = dto.containers ?? [];
+ const hasContainers = containers.length > 0;
+ const hasCargoType =
+ dto.cargoTypeId !== undefined &&
+ dto.cargoTypeId !== null &&
+ String(dto.cargoTypeId).trim() !== '';
+
+ if (dto.freightType === 'BULK') {
+ if (hasContainers) return false;
+ if (!hasCargoType) return false;
+ return true;
+ }
+
+ if (dto.freightType === 'CONTAINER') {
+ if (hasCargoType) return false;
+ if (!hasContainers) return false;
+ return containers.every(
+ (c) =>
+ c.containerTypeId !== undefined &&
+ c.containerTypeId !== null &&
+ String(c.containerTypeId).trim() !== '',
+ );
+ }
+
+ return true;
+ }
+
+ defaultMessage(args: ValidationArguments): string {
+ const dto = args.object as BookingFreightShapeInput;
+ if (dto.freightType === 'BULK') {
+ return 'BULK freight requires cargoTypeId and must not include container lines';
+ }
+ if (dto.freightType === 'CONTAINER') {
+ return 'CONTAINER freight requires at least one container line with containerTypeId and must not include cargoTypeId';
+ }
+ return 'Invalid freight type shape';
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts
new file mode 100644
index 000000000..6370c97c2
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts
@@ -0,0 +1,44 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
+import { FileRecord } from '../../files/entities/file.entity';
+import { Booking } from './booking.entity';
+
+export const CONTRACT_SIGNER_ROLES = ['CUSTOMER', 'STAFF'] as const;
+export type ContractSignerRole = (typeof CONTRACT_SIGNER_ROLES)[number];
+
+@Entity({ schema: 'freight', name: 'booking_contract_signatures' })
+@Unique(['bookingId', 'signerRole'])
+@Index(['bookingId'])
+export class BookingContractSignature extends BaseEntity {
+ @Column({ name: 'booking_id', type: 'uuid' })
+ bookingId!: string;
+
+ @ManyToOne(() => Booking, { onDelete: 'CASCADE' })
+ @JoinColumn({ name: 'booking_id' })
+ booking?: Booking;
+
+ @Column({ name: 'signer_role', type: 'varchar', length: 20 })
+ signerRole!: ContractSignerRole;
+
+ @Column({ name: 'signer_user_id', type: 'uuid', nullable: true })
+ signerUserId?: string | null;
+
+ @Column({ name: 'signer_display_name', type: 'varchar', length: 200 })
+ signerDisplayName!: string;
+
+ @Column({ name: 'signed_at', type: 'timestamptz' })
+ signedAt!: Date;
+
+ @Column({ name: 'signature_file_id', type: 'uuid', nullable: true })
+ signatureFileId?: string | null;
+
+ @ManyToOne(() => FileRecord, { nullable: true })
+ @JoinColumn({ name: 'signature_file_id' })
+ signatureFile?: FileRecord | null;
+
+ @Column({ name: 'consent_text', type: 'text', nullable: true })
+ consentText?: string | null;
+
+ @Column({ name: 'ip_address', type: 'varchar', length: 64, nullable: true })
+ ipAddress?: string | null;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
index 6a4140006..eaec84e82 100644
--- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
@@ -46,6 +46,9 @@ export const PAYMENT_STATUSES = [
export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
+export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
+export type FreightType = (typeof FREIGHT_TYPES)[number];
+
/** Statuses where the customer may edit booking fields. */
export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
'DRAFT',
@@ -126,8 +129,11 @@ export class Booking extends BaseEntity {
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
tradeDirection!: string;
- @Column({ name: 'cargo_type_id', type: 'uuid' })
- cargoTypeId!: string;
+ @Column({ name: 'freight_type', type: 'varchar', length: 20 })
+ freightType!: string;
+
+ @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
+ cargoTypeId?: string | null;
@ManyToOne(() => CargoType)
@JoinColumn({ name: 'cargo_type_id' })
@@ -200,6 +206,15 @@ export class Booking extends BaseEntity {
@Column({ name: 'contract_summary', type: 'text', nullable: true })
contractSummary?: string | null;
+ @Column({ name: 'contract_template_key', type: 'varchar', length: 80, nullable: true })
+ contractTemplateKey?: string | null;
+
+ @Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
+ contractGeneratedAt?: Date | null;
+
+ @Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
+ pricingBreakdown?: Record | null;
+
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
lockedAt?: Date | null;
diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts
index 2ec2b94a2..ea4bfd19e 100644
--- a/apps/edr-freight-api/src/modules/files/files.repository.ts
+++ b/apps/edr-freight-api/src/modules/files/files.repository.ts
@@ -25,4 +25,12 @@ export class FilesRepository extends BaseRepository {
): Promise {
return this.repository.findOne({ where: { resourceId, resource, code } });
}
+
+ async deleteByCode(
+ resourceId: string,
+ resource: string,
+ code: string,
+ ): Promise {
+ await this.repository.delete({ resourceId, resource, code });
+ }
}
diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts
index e08fce72b..1f0076986 100644
--- a/apps/edr-freight-api/src/modules/files/files.service.ts
+++ b/apps/edr-freight-api/src/modules/files/files.service.ts
@@ -35,6 +35,13 @@ export class FilesService {
});
}
+ /** Replace existing file row for the same resource + code (e.g. contract PDF). */
+ async upsertByCode(input: CreateFileInput): Promise {
+ const { resourceId, resource, code } = input;
+ await this.filesRepository.deleteByCode(resourceId, resource, code);
+ return this.upload(input);
+ }
+
async uploadMany(
resourceId: string,
resource: string,
diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts
index 02408e53d..0ff7ef543 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts
@@ -1,9 +1,15 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
- Param, ParseUUIDPipe, Patch, Post, Query,
+ Param, ParseUUIDPipe, Patch, Post, Query, UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
-import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto';
+import { CurrentUser } from '@edr/api-common';
+import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
+import { CreateRateDto } from '../dto/create-rate.dto';
+import {
+ type AuthUserPayload,
+ resolveAuthUserId,
+} from '../../../common/resolve-auth-user-id';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { RatesService } from '../services/rates.service';
@@ -38,9 +44,13 @@ export class RatesController {
}
@Post()
+ @UseGuards(JwtGuard)
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
- create(@Body() dto: CreateRateDto) {
- return this.service.create(dto);
+ create(
+ @Body() dto: CreateRateDto,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ return this.service.create(dto, resolveAuthUserId(user));
}
@Patch(':id')
@@ -56,9 +66,13 @@ export class RatesController {
}
@Post(':id/approve')
+ @UseGuards(JwtGuard)
@ApiOperation({ summary: 'CEO approves a rate' })
- approve(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveRateDto) {
- return this.service.approve(id, dto);
+ approve(
+ @Param('id', ParseUUIDPipe) id: string,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ return this.service.approve(id, resolveAuthUserId(user));
}
@Delete(':id')
diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts
index fe9084395..2f73780d9 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts
@@ -35,10 +35,6 @@ export class CreateRateDto {
@IsIn([...RATE_UNITS])
rateUnit!: string;
- @ApiProperty({ description: 'ID of the staff member (Director) proposing this rate' })
- @IsUUID()
- proposedByStaffId!: string;
-
@ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' })
@IsDateString()
effectiveFrom!: string;
@@ -49,12 +45,6 @@ export class CreateRateDto {
effectiveTo?: string;
}
-export class ApproveRateDto {
- @ApiProperty({ description: 'ID of the CEO approving this rate' })
- @IsUUID()
- approvedByCeoId!: string;
-}
-
export class SubmitRateForApprovalDto {
@ApiPropertyOptional({ description: 'Optional note for the approval request', maxLength: 500 })
@IsOptional()
diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts
index 5fc27ecbd..452bee90b 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts
@@ -47,7 +47,8 @@ export interface BookingContainerEvalInput {
}
export interface BookingEvaluationInput {
- cargoTypeId: string;
+ cargoTypeId?: string | null;
+ freightType?: 'CONTAINER' | 'BULK';
serviceTypeId: string;
paymentCurrency: string;
tradeDirection: string;
@@ -115,13 +116,19 @@ export class RuleEngineService {
let priorityScore = 0;
let requiresDirectorApproval = false;
- const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
- if (!cargoType) {
- hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
- } else if (cargoType.requiresDirectorApproval) {
+ if (input.freightType === 'BULK') {
requiresDirectorApproval = true;
}
+ if (input.cargoTypeId) {
+ const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
+ if (!cargoType) {
+ hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
+ } else if (cargoType.requiresDirectorApproval) {
+ requiresDirectorApproval = true;
+ }
+ }
+
for (const container of input.containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId,
@@ -232,16 +239,29 @@ export class RuleEngineService {
}
/**
- * Instantiate booking_approval_step rows from approval_rules for a cargo type.
+ * Instantiate booking_approval_step rows from approval_rules by freight type.
*/
- async instantiateApprovalSteps(bookingId: string, cargoTypeId: string): Promise {
- const cargoType = await this.cargoTypesRepo.findById(cargoTypeId);
- if (!cargoType) {
- throw new BadRequestException(`Cargo type ${cargoTypeId} not found`);
+ async instantiateApprovalSteps(
+ bookingId: string,
+ options: {
+ freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
+ },
+ ): Promise {
+ let requiresDirectorApproval = options.freightType === 'BULK';
+
+ if (options.cargoTypeId) {
+ const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
+ if (!cargoType) {
+ throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
+ }
+ if (cargoType.requiresDirectorApproval) {
+ requiresDirectorApproval = true;
+ }
}
const chain = await this.approvalRulesRepo.findChainForCargo(
- cargoType.requiresDirectorApproval,
+ requiresDirectorApproval,
);
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts
index 656802e3f..0202ef44a 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts
@@ -1,5 +1,5 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
-import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto';
+import { CreateRateDto } from '../dto/create-rate.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity';
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
@@ -46,7 +46,7 @@ export class RatesService {
}
/** Create a rate in DRAFT status. */
- async create(dto: CreateRateDto): Promise {
+ async create(dto: CreateRateDto, proposedByStaffId: string): Promise {
return this.repository.create({
rateType: dto.rateType as Rate['rateType'],
containerTypeId: dto.containerTypeId,
@@ -55,7 +55,7 @@ export class RatesService {
rateValue: dto.rateValue,
rateUnit: dto.rateUnit as Rate['rateUnit'],
status: 'DRAFT',
- proposedByStaffId: dto.proposedByStaffId,
+ proposedByStaffId,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined,
});
@@ -74,7 +74,6 @@ export class RatesService {
if (dto.currency) updates.currency = dto.currency;
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
- if (dto.proposedByStaffId) updates.proposedByStaffId = dto.proposedByStaffId;
if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom);
if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo);
const updated = await this.repository.update(id, updates);
@@ -93,14 +92,14 @@ export class RatesService {
}
/** CEO approves a rate — moves to LIVE. */
- async approve(id: string, dto: ApproveRateDto): Promise {
+ async approve(id: string, approverUserId: string): Promise {
const rate = await this.findById(id);
if (rate.status !== 'PENDING_APPROVAL') {
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
}
const updated = await this.repository.update(id, {
status: 'LIVE',
- approvedByCeoId: dto.approvedByCeoId,
+ approvedByCeoId: approverUserId,
approvedAt: new Date(),
});
return updated!;
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts
index 98e5b1642..387e26ba9 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts
@@ -28,6 +28,7 @@ export class SurchargeTypesService {
const [data, total] = await this.repository.findAndCount({
where,
+ relations: { rate: true },
order: { label: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index 7cc6a9935..884fde00b 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -1,5 +1,4 @@
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
Boxes,
FileText,
@@ -14,6 +13,7 @@ import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@
import LoadingScreen from "./components/LoadingScreen";
import { useAuth } from "./auth/useAuth";
import LoginPage from "./pages/auth/LoginPage";
+import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
@@ -31,16 +31,6 @@ import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirec
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: 1,
- refetchOnWindowFocus: false,
- staleTime: 5 * 60 * 1000,
- },
- },
-});
-
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
@@ -188,18 +178,15 @@ const App = () => {
if (!user) {
return (
-
-
- } />
- } />
-
-
+
+ } />
+ } />
+
);
}
return (
-
-
+
} />
} />
@@ -208,6 +195,10 @@ const App = () => {
} />
} />
+ }
+ />
} />
} />
@@ -251,8 +242,7 @@ const App = () => {
} />
-
-
+
);
};
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx
new file mode 100644
index 000000000..64cca7db3
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx
@@ -0,0 +1,116 @@
+import { useMemo } from "react";
+import { ShieldCheck } from "lucide-react";
+
+import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
+import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
+import { Badge } from "@edr/ui-common";
+import { cn } from "@/lib/utils";
+
+interface ApprovalStepsCardProps {
+ booking: BookingDetail;
+}
+
+/** Read-only approval chain visualization; actions live in BookingActionsToolbar. */
+export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
+ const steps = useMemo(
+ () =>
+ [...(booking.approvalSteps ?? [])].sort(
+ (a, b) => a.stepOrder - b.stepOrder,
+ ),
+ [booking.approvalSteps],
+ );
+
+ const nextPending = getNextPendingApprovalStep(steps);
+
+ return (
+
+
+
+
+
+
+
+ Approval chain
+
+
+ Next:{" "}
+ {nextPending
+ ? `${nextPending.requiredRole} · step ${nextPending.stepOrder}`
+ : steps.length
+ ? "All steps complete"
+ : "Accept submission to begin"}
+
+
+
+
+
+ {steps.length === 0 ? (
+
+ Use Accept for approval {" "}
+ in staff actions to instantiate steps.
+
+ ) : (
+
+ {steps.map((step) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function StepRow({
+ step,
+ isNext,
+}: {
+ step: BookingApprovalStep;
+ isNext: boolean;
+}) {
+ const statusStyles =
+ step.status === "APPROVED"
+ ? "bg-emerald-500/15 text-emerald-800 dark:text-emerald-300"
+ : step.status === "REJECTED"
+ ? "bg-red-500/15 text-red-800 dark:text-red-300"
+ : isNext
+ ? "bg-amber-500/15 text-amber-800 dark:text-amber-300"
+ : "bg-muted text-muted-foreground";
+
+ return (
+
+
+
+ {step.stepOrder}
+
+
+
+ {step.requiredRole}
+
+ {step.remarks && (
+
+ {step.remarks}
+
+ )}
+
+
+
+ {step.status}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx
new file mode 100644
index 000000000..29b4e9b19
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx
@@ -0,0 +1,233 @@
+import { useNavigate } from "react-router-dom";
+import {
+ ChevronRight,
+ ExternalLink,
+ Loader2,
+ MoreHorizontal,
+ Upload,
+} from "lucide-react";
+
+import { BookingConfirmDialog } from "./BookingConfirmDialog";
+import { useBookingActionDialog } from "./useBookingActionDialog";
+import {
+ listRowHasActions,
+ type BookingActionContext,
+} from "@/features/bookings/booking-actions.config";
+import type { BookingListRow } from "@/types/booking";
+import { cn } from "@/lib/utils";
+import {
+ Button,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@edr/ui-common";
+
+interface BookingActionsMenuProps {
+ row: BookingListRow;
+ /** Compact table cell vs. larger detail toolbar */
+ variant?: "table" | "toolbar";
+ className?: string;
+}
+
+export function BookingActionsMenu({
+ row,
+ variant = "table",
+ className,
+}: BookingActionsMenuProps) {
+ const navigate = useNavigate();
+ const context: BookingActionContext = {
+ status: row.status,
+ paymentCurrency: row.paymentCurrency,
+ reference: row.reference,
+ };
+
+ const flow = useBookingActionDialog(row.id, context);
+ const { actions, pendingAction, mutations } = flow;
+
+ const goToContract = () =>
+ navigate(`/dashboard/booking-requests/${row.id}/contract`);
+
+ const showUsdPaymentHint =
+ row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
+ const hasMenu = listRowHasActions(row) || showUsdPaymentHint;
+
+ const primary = actions.find((a) => a.primary) ?? actions[0];
+
+ if (!hasMenu && variant === "table") {
+ return (
+ navigate(`/dashboard/booking-requests/${row.id}`)}
+ aria-label="View booking"
+ >
+
+
+ );
+ }
+
+ return (
+ <>
+ e.stopPropagation()}
+ onKeyDown={(e) => e.stopPropagation()}
+ >
+ {variant === "table" && primary && (
+
+ primary.id === "viewContract"
+ ? goToContract()
+ : flow.openAction(primary)
+ }
+ >
+
+ {primary.shortLabel}
+
+ )}
+
+ {variant === "toolbar" && actions.length > 0 ? (
+
+ {actions.map((action) => {
+ const Icon = action.icon;
+ return (
+
+ action.id === "viewContract"
+ ? goToContract()
+ : flow.openAction(action)
+ }
+ >
+
+ {action.label}
+
+ );
+ })}
+
+ ) : (
+
+
+
+ {mutations.isPending ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {row.reference}
+
+
+ {actions.map((action) => {
+ const Icon = action.icon;
+ return (
+
+ action.id === "viewContract"
+ ? goToContract()
+ : flow.openAction(action)
+ }
+ >
+
+ {action.label}
+
+ );
+ })}
+ {showUsdPaymentHint && (
+
+ navigate(`/dashboard/booking-requests/${row.id}`)
+ }
+ >
+
+ Upload payment proof…
+
+ )}
+ {(actions.length > 0 || showUsdPaymentHint) && (
+
+ )}
+
+ navigate(`/dashboard/booking-requests/${row.id}`)
+ }
+ >
+
+ Open full details
+
+
+
+ )}
+
+
+
+
+ Loading approval steps…
+
+ ) : pendingAction?.id === "approve" &&
+ !flow.mergedContext.approvalSteps?.length ? (
+
+ No pending approval step found. Accept the submission on the detail
+ page first.
+
+ ) : null
+ }
+ />
+ >
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx
new file mode 100644
index 000000000..8c0b123d5
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx
@@ -0,0 +1,168 @@
+import { useRef } from "react";
+import { Download, Upload, Zap } from "lucide-react";
+
+import type { BookingDetail } from "@/types/booking";
+import { BookingActionsMenu } from "./BookingActionsMenu";
+import { bookingSurface } from "./booking-ui.styles";
+import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
+import type { useBookingMutations } from "@/hooks/bookings/useBookings";
+import { Button } from "@edr/ui-common";
+
+type Mutations = ReturnType;
+
+interface BookingActionsToolbarProps {
+ booking: BookingDetail;
+ mutations: Mutations;
+}
+
+/** Detail-page actions: primary toolbar + payment uploads + downloads. */
+export function BookingActionsToolbar({
+ booking,
+ mutations,
+}: BookingActionsToolbarProps) {
+ const fileRef = useRef(null);
+ const row = toBookingListRow(booking);
+ const { status, paymentCurrency } = booking;
+ const pending = mutations.isPending;
+
+ const downloadBlob = async (fn: () => Promise, filename: string) => {
+ const blob = await fn();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ if (
+ status === "REJECTED" ||
+ status === "CANCELLED" ||
+ status === "COMPLETED"
+ ) {
+ return null;
+ }
+
+ if (status === "CHANGES_REQUESTED") {
+ return (
+
+ {booking.latestChangeRequestNote && (
+
+ {booking.latestChangeRequestNote}
+
+ )}
+
+ );
+ }
+
+ if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {status === "FULLY_EXECUTED" && paymentCurrency === "USD" && (
+
+ {
+ const file = e.target.files?.[0];
+ if (file) mutations.submitPaymentProof.mutate(file);
+ }}
+ />
+
+ fileRef.current?.click()}
+ >
+
+ Upload payment proof
+
+
+ downloadBlob(
+ () => mutations.downloadPaymentLetter(),
+ `payment-letter-${booking.reference}.txt`,
+ )
+ }
+ >
+
+ Request letter
+
+
+
+ )}
+
+ {status === "CONTRACT_READY" && (
+
+
+ downloadBlob(
+ () => mutations.downloadContract(),
+ `contract-${booking.reference}.txt`,
+ )
+ }
+ >
+
+ Download contract
+
+
+ )}
+
+ );
+}
+
+function PanelShell({
+ title,
+ description,
+ children,
+ muted,
+}: {
+ title: string;
+ description: string;
+ children: React.ReactNode;
+ muted?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+
{title}
+
{description}
+
+
+
{children}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx
new file mode 100644
index 000000000..183995a65
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx
@@ -0,0 +1,134 @@
+import { Loader2 } from "lucide-react";
+
+import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
+import { cn } from "@/lib/utils";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Textarea,
+} from "@edr/ui-common";
+
+interface BookingConfirmDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ action: BookingActionDef | null;
+ reference?: string;
+ inputValue: string;
+ onInputChange: (value: string) => void;
+ onConfirm: () => void;
+ isPending: boolean;
+ confirmDisabled?: boolean;
+ extra?: React.ReactNode;
+}
+
+export function BookingConfirmDialog({
+ open,
+ onOpenChange,
+ action,
+ reference,
+ inputValue,
+ onInputChange,
+ onConfirm,
+ isPending,
+ confirmDisabled = false,
+ extra,
+}: BookingConfirmDialogProps) {
+ if (!action || !action.confirmTitle) return null;
+
+ const Icon = action.icon;
+ const needsInput = Boolean(action.input);
+ const inputMissing = needsInput && !inputValue.trim();
+ const isDestructive = action.variant === "destructive";
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {action.confirmTitle}
+
+ {reference && (
+
+ {reference}
+
+ )}
+
+
+
+ {action.confirmDescription}
+
+
+
+
+
+ {needsInput && (
+
+
+ {action.inputLabel}
+ *
+
+
+ )}
+ {extra}
+
+
+
+ onOpenChange(false)}
+ >
+ Cancel
+
+
+ {isPending ? (
+
+ ) : (
+
+ )}
+ {action.shortLabel}
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx
new file mode 100644
index 000000000..b6af2b0b4
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx
@@ -0,0 +1,86 @@
+import { Banknote, Receipt } from "lucide-react";
+
+import type { BookingDetail } from "@/types/booking";
+import { Separator } from "@edr/ui-common";
+import { bookingSurface } from "./booking-ui.styles";
+
+export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
+ const amount = Number(booking.totalAmount);
+ const modifiers = booking.cargoModifiers ?? [];
+
+ return (
+
+
+
+
+
+
+
+ Pricing & payment
+
+
Commercial terms
+
+
+
+
+
+ Total amount
+
+
+ {booking.paymentCurrency}{" "}
+ {amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
+
+
+
|
+ {booking.pnrCode &&
|
}
+ {modifiers.length > 0 && (
+ <>
+
+
+
+ Surcharges applied
+
+
+ {modifiers.map((m) => (
+
+ Modifier
+
+ {Number(m.calculatedAmount).toLocaleString()}
+
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
+
+function Row({
+ label,
+ value,
+ mono,
+}: {
+ label: string;
+ value: string;
+ mono?: boolean;
+}) {
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPriorityBadge.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPriorityBadge.tsx
new file mode 100644
index 000000000..9202977c6
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPriorityBadge.tsx
@@ -0,0 +1,21 @@
+export function BookingPriorityBadge({ score }: { score: number }) {
+ if (score >= 1000) {
+ return (
+
+ Urgent
+
+ );
+ }
+ if (score >= 500) {
+ return (
+
+ High
+
+ );
+ }
+ return (
+
+ Normal
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatGrid.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatGrid.tsx
new file mode 100644
index 000000000..cc02413bd
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatGrid.tsx
@@ -0,0 +1,56 @@
+import type { LucideIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export interface StatItem {
+ label: string;
+ value: number | string;
+ hint?: string;
+ icon: LucideIcon;
+ accent?: "default" | "amber" | "emerald" | "rose";
+}
+
+const accentStyles = {
+ default: "bg-primary/10 text-primary",
+ amber: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
+ emerald: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
+ rose: "bg-rose-500/10 text-rose-700 dark:text-rose-400",
+};
+
+export function BookingStatGrid({ items }: { items: StatItem[] }) {
+ return (
+
+ {items.map((item) => {
+ const Icon = item.icon;
+ const accent = item.accent ?? "default";
+ return (
+
+
+
+
+ {item.label}
+
+
+ {item.value}
+
+ {item.hint && (
+
{item.hint}
+ )}
+
+
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx
new file mode 100644
index 000000000..25c3363c3
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx
@@ -0,0 +1,21 @@
+import { Badge } from "@edr/ui-common";
+import { cn } from "@/lib/utils";
+import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
+
+export function BookingStatusBadge({ status }: { status: string }) {
+ const style = BOOKING_STATUS_STYLES[status] ?? {
+ label: status,
+ color: "bg-muted text-muted-foreground border-border",
+ };
+ return (
+
+ {style.label}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx
new file mode 100644
index 000000000..86c7739fc
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx
@@ -0,0 +1,91 @@
+import {
+ ClipboardCheck,
+ FileSignature,
+ FileText,
+ Inbox,
+ LayoutGrid,
+ ShieldCheck,
+} from "lucide-react";
+
+import {
+ BOOKING_LIST_TABS,
+ type BookingStatusTabKey,
+} from "@/features/bookings/booking-status.config";
+import { cn } from "@/lib/utils";
+
+const TAB_ICONS: Record = {
+ all: ,
+ SUBMITTED: ,
+ PENDING_APPROVAL: ,
+ APPROVED_PENDING_SIGNATURE: ,
+ SIGNED_CUSTOMER: ,
+ PAYMENT_VERIFICATION_IN_PROGRESS: ,
+};
+
+interface BookingStatusTabsProps {
+ active: BookingStatusTabKey;
+ onChange: (tab: BookingStatusTabKey) => void;
+ counts?: Partial>;
+}
+
+export function BookingStatusTabs({
+ active,
+ onChange,
+ counts,
+}: BookingStatusTabsProps) {
+ return (
+
+
+ {BOOKING_LIST_TABS.map((tab) => {
+ const isActive = active === tab.key;
+ const count = counts?.[tab.key];
+ return (
+ onChange(tab.key)}
+ className={cn(
+ "flex min-w-[7.5rem] shrink-0 flex-col items-start gap-0.5 rounded-lg px-3.5 py-2.5 text-left transition-all duration-200",
+ isActive
+ ? "bg-background text-foreground shadow-sm ring-1 ring-border/80"
+ : "text-muted-foreground hover:bg-background/60 hover:text-foreground",
+ )}
+ >
+
+
+ {TAB_ICONS[tab.key]}
+ {tab.label}
+
+ {count !== undefined && count > 0 && (
+
+ {count}
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
+
+export type { BookingStatusTabKey };
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingTableEmpty.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingTableEmpty.tsx
new file mode 100644
index 000000000..84de06dcb
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingTableEmpty.tsx
@@ -0,0 +1,44 @@
+import { Package, Search } from "lucide-react";
+import { Button } from "@edr/ui-common";
+import { bookingSurface } from "./booking-ui.styles";
+
+interface BookingTableEmptyProps {
+ isError?: boolean;
+ hasSearch?: boolean;
+ onRetry?: () => void;
+}
+
+export function BookingTableEmpty({
+ isError,
+ hasSearch,
+ onRetry,
+}: BookingTableEmptyProps) {
+ return (
+
+
+
+
+ {isError
+ ? "Could not load bookings"
+ : hasSearch
+ ? "No matches on this page"
+ : "No bookings with this status"}
+
+
+ {isError
+ ? "Check your connection and try again."
+ : hasSearch
+ ? "Try a different reference or customer name."
+ : "New customer submissions will appear when status is Submitted."}
+
+
+ {isError && onRetry && (
+
+ Retry
+
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingWorkflowStepper.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingWorkflowStepper.tsx
new file mode 100644
index 000000000..fa68c1d67
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingWorkflowStepper.tsx
@@ -0,0 +1,124 @@
+import {
+ Check,
+ CheckCircle2,
+ FileSignature,
+ FileText,
+ Train,
+ Wallet,
+} from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import {
+ getWorkflowStageIndex,
+ WORKFLOW_STAGES,
+} from "@/features/bookings/booking-status.config";
+import { bookingSurface } from "./booking-ui.styles";
+
+const STAGE_ICONS = [FileText, FileSignature, FileSignature, Wallet, Train, Check];
+
+interface BookingWorkflowStepperProps {
+ status: string;
+ title: string;
+ description: string;
+ titleColor: string;
+}
+
+export function BookingWorkflowStepper({
+ status,
+ title,
+ description,
+ titleColor,
+}: BookingWorkflowStepperProps) {
+ const currentStage = getWorkflowStageIndex(status);
+ const isTerminal = currentStage < 0;
+
+ return (
+
+
+
+
+
+
+
+ Workflow progress
+
+
+ Customer submission through completion
+
+
+
+
+
+
+
= 0
+ ? `calc(${(currentStage / (WORKFLOW_STAGES.length - 1)) * 100}% - 2rem)`
+ : "0%",
+ }}
+ />
+
+ {WORKFLOW_STAGES.map((stage, idx) => {
+ const Icon = STAGE_ICONS[idx] ?? FileText;
+ const isCompleted = !isTerminal && idx < currentStage;
+ const isActive = !isTerminal && idx === currentStage;
+ return (
+
+
+ {isCompleted ? (
+
+ ) : (
+
+ )}
+
+
+ {stage.label}
+
+
+ );
+ })}
+
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ContractSignaturePad.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ContractSignaturePad.tsx
new file mode 100644
index 000000000..03dda6e32
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/ContractSignaturePad.tsx
@@ -0,0 +1,107 @@
+import { useEffect, useRef, useState } from "react";
+import { Eraser } from "lucide-react";
+
+import { Button } from "@edr/ui-common";
+import { cn } from "@/lib/utils";
+
+interface ContractSignaturePadProps {
+ onChange: (dataUrl: string | null) => void;
+ className?: string;
+}
+
+export function ContractSignaturePad({
+ onChange,
+ className,
+}: ContractSignaturePadProps) {
+ const canvasRef = useRef
(null);
+ const drawing = useRef(false);
+ const [empty, setEmpty] = useState(true);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ const dpr = window.devicePixelRatio || 1;
+ const w = canvas.offsetWidth;
+ const h = canvas.offsetHeight;
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ ctx.scale(dpr, dpr);
+ ctx.strokeStyle = "#111";
+ ctx.lineWidth = 2;
+ ctx.lineCap = "round";
+ }, []);
+
+ const getPos = (e: React.MouseEvent | React.TouchEvent) => {
+ const canvas = canvasRef.current!;
+ const rect = canvas.getBoundingClientRect();
+ if ("touches" in e) {
+ const t = e.touches[0];
+ return { x: t.clientX - rect.left, y: t.clientY - rect.top };
+ }
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
+ };
+
+ const start = (e: React.MouseEvent | React.TouchEvent) => {
+ drawing.current = true;
+ const ctx = canvasRef.current?.getContext("2d");
+ const { x, y } = getPos(e);
+ ctx?.beginPath();
+ ctx?.moveTo(x, y);
+ };
+
+ const move = (e: React.MouseEvent | React.TouchEvent) => {
+ if (!drawing.current) return;
+ const ctx = canvasRef.current?.getContext("2d");
+ const { x, y } = getPos(e);
+ ctx?.lineTo(x, y);
+ ctx?.stroke();
+ setEmpty(false);
+ onChange(canvasRef.current?.toDataURL("image/png") ?? null);
+ };
+
+ const end = () => {
+ drawing.current = false;
+ };
+
+ const clear = () => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ setEmpty(true);
+ onChange(null);
+ };
+
+ return (
+
+
+
+
+
+
+ Draw your signature above
+
+
+
+ Clear
+
+
+ {empty && (
+
Signature is required before confirming.
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts b/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts
new file mode 100644
index 000000000..a86a5afd4
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts
@@ -0,0 +1,32 @@
+/** Shared surfaces for booking list & detail — aligned with rule-engine polish. */
+
+export const bookingSurface = {
+ page: "min-h-screen bg-gradient-to-b from-muted/40 via-background to-background",
+ pageInner: "mx-auto max-w-[1600px] space-y-6 p-6 lg:p-8",
+ hero:
+ "relative overflow-hidden rounded-2xl border border-border/80 bg-card shadow-sm",
+ heroGlow:
+ "pointer-events-none absolute -right-20 -top-20 size-64 rounded-full bg-primary/10 blur-3xl",
+ panel:
+ "overflow-hidden rounded-xl border border-border bg-card shadow-sm",
+ panelToolbar:
+ "flex flex-wrap items-center justify-between gap-3 border-b border-border bg-muted/25 px-4 py-3.5 sm:px-5",
+ tableWrap: "px-0",
+ sectionCard:
+ "overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md",
+ sectionHeader:
+ "flex items-center gap-3 border-b border-border/60 bg-muted/20 px-5 py-4",
+ sectionBody: "px-5 py-5",
+ detailHero:
+ "relative overflow-hidden rounded-2xl border border-border bg-gradient-to-br from-card via-card to-primary/[0.04] shadow-sm",
+ stickySidebar: "lg:sticky lg:top-6 lg:self-start",
+ metricTile:
+ "rounded-lg border border-border/70 bg-background/80 px-4 py-3 shadow-xs",
+ emptyState:
+ "flex flex-col items-center justify-center gap-3 px-6 py-16 text-center",
+} as const;
+
+export const bookingInput = {
+ search:
+ "h-10 w-full rounded-lg border border-input bg-background pl-10 text-sm shadow-xs transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25 sm:max-w-xs",
+} as const;
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts
new file mode 100644
index 000000000..4b3570506
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts
@@ -0,0 +1,132 @@
+import { useCallback, useState } from "react";
+
+import {
+ getBookingActions,
+ getNextPendingApprovalStep,
+ type BookingActionContext,
+ type BookingActionDef,
+} from "@/features/bookings/booking-actions.config";
+import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
+
+export function useBookingActionDialog(
+ bookingId: string,
+ context: BookingActionContext,
+) {
+ const [pendingAction, setPendingAction] = useState(null);
+ const [inputValue, setInputValue] = useState("");
+ const [dialogOpen, setDialogOpen] = useState(false);
+
+ const needsApprovalSteps =
+ pendingAction?.id === "approve" || pendingAction?.id === "rejectApproval";
+
+ const { data: detail, isLoading: detailLoading } = useBookingDetail(
+ needsApprovalSteps ? bookingId : undefined,
+ );
+
+ const mergedContext: BookingActionContext = {
+ ...context,
+ approvalSteps: detail?.approvalSteps ?? context.approvalSteps,
+ reference: detail?.reference ?? context.reference,
+ };
+
+ const mutations = useBookingMutations(bookingId);
+ const actions = getBookingActions(mergedContext);
+
+ const openAction = useCallback((action: BookingActionDef) => {
+ setPendingAction(action);
+ setInputValue("");
+ setDialogOpen(true);
+ }, []);
+
+ const closeDialog = useCallback(() => {
+ setDialogOpen(false);
+ setPendingAction(null);
+ setInputValue("");
+ }, []);
+
+ const runAction = useCallback(() => {
+ if (!pendingAction) return;
+
+ const onSuccess = () => closeDialog();
+
+ switch (pendingAction.id) {
+ case "accept":
+ mutations.staffAccept.mutate(undefined, { onSuccess });
+ break;
+ case "requestChanges":
+ mutations.requestChanges.mutate(inputValue.trim(), { onSuccess });
+ break;
+ case "reject":
+ mutations.staffReject.mutate(inputValue.trim(), { onSuccess });
+ break;
+ case "approve": {
+ const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
+ if (!step) return;
+ mutations.approveStep.mutate(
+ { stepId: step.id, requiredRole: step.requiredRole },
+ { onSuccess },
+ );
+ break;
+ }
+ case "rejectApproval": {
+ const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
+ if (!step) return;
+ mutations.rejectStep.mutate(
+ { stepId: step.id, reason: inputValue.trim() },
+ { onSuccess },
+ );
+ break;
+ }
+ case "generateContract":
+ mutations.generateContract.mutate(undefined, { onSuccess });
+ break;
+ case "viewContract":
+ break;
+ case "generatePnr":
+ mutations.generatePnr.mutate(undefined, { onSuccess });
+ break;
+ case "verifyPayment":
+ mutations.verifyPayment.mutate(undefined, { onSuccess });
+ break;
+ case "startTransit":
+ mutations.startTransit.mutate(undefined, { onSuccess });
+ break;
+ case "complete":
+ mutations.complete.mutate(undefined, { onSuccess });
+ break;
+ default:
+ break;
+ }
+ }, [
+ pendingAction,
+ inputValue,
+ mergedContext.approvalSteps,
+ mutations,
+ closeDialog,
+ ]);
+
+ const confirmDisabled =
+ mutations.isPending ||
+ (needsApprovalSteps && detailLoading) ||
+ (pendingAction?.id === "approve" &&
+ !getNextPendingApprovalStep(mergedContext.approvalSteps));
+
+ return {
+ actions,
+ pendingAction,
+ inputValue,
+ setInputValue,
+ dialogOpen,
+ setDialogOpen: (open: boolean) => {
+ if (!open) closeDialog();
+ else setDialogOpen(true);
+ },
+ openAction,
+ closeDialog,
+ runAction,
+ mutations,
+ confirmDisabled,
+ detailLoading,
+ mergedContext,
+ };
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx
index 079bb2727..966ba8ba1 100644
--- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx
@@ -61,5 +61,45 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString();
}
+ if (format === "entityLabel" && value && typeof value === "object") {
+ const entity = value as { label?: string; code?: string; cargoTypeName?: string };
+ const label =
+ entity.label?.trim() ||
+ entity.cargoTypeName?.trim() ||
+ entity.code?.trim();
+ return label ? (
+ {label}
+ ) : (
+ —
+ );
+ }
+
+ if (format === "rateLabel") {
+ if (!value || typeof value !== "object") {
+ return value ? (
+ {String(value)}
+ ) : (
+ —
+ );
+ }
+ const rate = value as {
+ rateType?: string;
+ currency?: string;
+ rateValue?: number;
+ rateUnit?: string;
+ };
+ const parts = [
+ rate.rateType?.replace(/_/g, " "),
+ rate.currency,
+ rate.rateValue != null ? String(rate.rateValue) : "",
+ rate.rateUnit?.replace(/_/g, " "),
+ ].filter(Boolean);
+ return parts.length > 0 ? (
+ {parts.join(" · ")}
+ ) : (
+ —
+ );
+ }
+
return String(value);
};
diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
new file mode 100644
index 000000000..02fe21ffc
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
@@ -0,0 +1,50 @@
+import type { BookingListFilter } from "@/services/bookings.service";
+import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
+import type { RuleEngineResourceSlug } from "@/types/rule-engine";
+
+export const QUERY_KEYS = {
+ USERS: {
+ ROOT: ["users"] as const,
+ ADD: ["users", "add"] as const,
+ },
+
+ FILES: {
+ ROOT: ["file-upload-settings"] as const,
+ list: () => ["file-upload-settings", "list"] as const,
+ byId: (id: string) => ["file-upload-settings", "detail", id] as const,
+ byCode: (code: string) => ["file-upload-settings", "by-code", code] as const,
+ },
+
+ DROPDOWN_SETTINGS: {
+ ROOT: ["dropdown-settings"] as const,
+ list: () => ["dropdown-settings", "list"] as const,
+ byId: (id: string) => ["dropdown-settings", "detail", id] as const,
+ byCode: (code: string) => ["dropdown-settings", "by-code", code] as const,
+ },
+
+ CUSTOMERS: {
+ ROOT: ["customers"] as const,
+ list: () => ["customers", "list"] as const,
+ byId: (id: string) => ["customers", "detail", id] as const,
+ },
+
+ BOOKINGS: {
+ ROOT: ["bookings"] as const,
+ list: (filter?: BookingListFilter) =>
+ ["bookings", "list", filter ?? {}] as const,
+ byId: (id: string) => ["bookings", "detail", id] as const,
+ },
+
+ RULE_ENGINE: {
+ ROOT: ["rule-engine"] as const,
+ list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
+ ["rule-engine", "list", resource, params ?? {}] as const,
+ detail: (resource: RuleEngineResourceSlug | string, id: string) =>
+ ["rule-engine", "detail", resource, id] as const,
+ chain: ["rule-engine", "approval-rules", "chain"] as const,
+ selectOptions: (
+ resource: RuleEngineResourceSlug | string,
+ params?: Record,
+ ) => ["rule-engine", "select-options", resource, params ?? {}] as const,
+ },
+} as const;
diff --git a/apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts b/apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts
deleted file mode 100644
index fca149d58..000000000
--- a/apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-export const QUERY_KEYS = {
- USERS: "users",
- ADD_USER: "add_user",
- CUSTOMER: "Customers",
- FILES: {
- FILE_UPLOAD_SETTINGS: "file-upload-settings",
- BY_CODE: "by-code"
- },
- DROPDOWN_SETTINGS: {
- ROOT: "dropdown-settings",
- LIST: "list",
- BY_ID: "by-id",
- BY_CODE: "by-code"
- },
- CUSTOMERS: {
- ROOT: "customers",
- LIST: "list",
- BY_ID: "by-id"
- },
- RULE_ENGINE: {
- ROOT: "rule-engine",
- list: (resource: string) => ["rule-engine", resource, "list"] as const,
- chain: ["rule-engine", "approval-rules", "chain"] as const,
- },
-}
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index f5c75395e..174416857 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -77,9 +77,33 @@ export const URL_CONSTANTS = {
BOOKINGS: {
BASE: "/bookings",
- BY_ID: (id: string | number) => `/bookings/${id}`,
- CANCEL: (id: string | number) => `/bookings/${id}/cancel`,
- CONFIRM: (id: string | number) => `/bookings/${id}/confirm`,
+ BY_ID: (id: string) => `/bookings/${id}`,
+ QUEUE: (queue: string) => `/bookings/queues/${queue}`,
+ STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
+ STAFF_REQUEST_CHANGES: (id: string) =>
+ `/bookings/${id}/staff/request-changes`,
+ STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,
+ APPROVE_STEP: (id: string, stepId: string) =>
+ `/bookings/${id}/approval-steps/${stepId}/approve`,
+ REJECT_STEP: (id: string, stepId: string) =>
+ `/bookings/${id}/approval-steps/${stepId}/reject`,
+ CONTRACT_GENERATE: (id: string) => `/bookings/${id}/contract/generate`,
+ CONTRACT_VIEW: (id: string) => `/bookings/${id}/contract/view`,
+ CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
+ CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`,
+ CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
+ SUMMARY: (id: string) => `/bookings/${id}/summary`,
+ CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
+ MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
+ PAYMENT_PNR: (id: string) => `/bookings/${id}/payment/pnr`,
+ PAYMENT_PROOF: (id: string) => `/bookings/${id}/payment/proof`,
+ PAYMENT_VERIFY: (id: string) => `/bookings/${id}/payment/verify`,
+ PAYMENT_REQUEST_LETTER: (id: string) =>
+ `/bookings/${id}/payment/request-letter`,
+ START_TRANSIT: (id: string) => `/bookings/${id}/operations/start-transit`,
+ COMPLETE: (id: string) => `/bookings/${id}/operations/complete`,
+ CANCEL: (id: string) => `/bookings/${id}/cancel`,
+ CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
},
OTP: {
diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts
new file mode 100644
index 000000000..d1bd6f867
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts
@@ -0,0 +1,268 @@
+import type { LucideIcon } from "lucide-react";
+import {
+ Ban,
+ Check,
+ FileSignature,
+ FileText,
+ MessageSquareWarning,
+ Play,
+ ShieldCheck,
+ Truck,
+ Wallet,
+ XCircle,
+} from "lucide-react";
+
+import type {
+ BookingApprovalStep,
+ BookingDetail,
+ BookingStatus,
+} from "@/types/booking";
+
+export type BookingActionId =
+ | "accept"
+ | "requestChanges"
+ | "reject"
+ | "approve"
+ | "rejectApproval"
+ | "generateContract"
+ | "viewContract"
+ | "generatePnr"
+ | "verifyPayment"
+ | "startTransit"
+ | "complete";
+
+export type BookingActionInputKind = "note" | "reason";
+
+export interface BookingActionDef {
+ id: BookingActionId;
+ label: string;
+ shortLabel: string;
+ description: string;
+ confirmTitle: string;
+ confirmDescription: string;
+ variant: "default" | "destructive" | "outline";
+ icon: LucideIcon;
+ input?: BookingActionInputKind;
+ inputLabel?: string;
+ inputPlaceholder?: string;
+ primary?: boolean;
+}
+
+export type BookingActionContext = Pick<
+ BookingDetail,
+ "status" | "paymentCurrency" | "approvalSteps" | "reference"
+>;
+
+export function getNextPendingApprovalStep(
+ steps?: BookingApprovalStep[] | null,
+): BookingApprovalStep | undefined {
+ if (!steps?.length) return undefined;
+ return [...steps]
+ .sort((a, b) => a.stepOrder - b.stepOrder)
+ .find((s) => s.status === "PENDING");
+}
+
+function approvalActions(
+ steps?: BookingApprovalStep[] | null,
+): BookingActionDef[] {
+ const next = getNextPendingApprovalStep(steps);
+ if (!next) return [];
+ return [
+ {
+ id: "approve",
+ label: `Approve (${next.requiredRole})`,
+ shortLabel: "Approve",
+ description: `Complete step ${next.stepOrder} as ${next.requiredRole}`,
+ confirmTitle: `Approve as ${next.requiredRole}?`,
+ confirmDescription:
+ "This records your approval and advances the booking to the next step in the chain.",
+ variant: "default",
+ icon: Check,
+ primary: true,
+ },
+ {
+ id: "rejectApproval",
+ label: "Reject approval",
+ shortLabel: "Reject",
+ description: "Reject at the current approval step",
+ confirmTitle: "Reject at approval step?",
+ confirmDescription:
+ "The booking will be marked rejected. This action cannot be undone from the UI.",
+ variant: "destructive",
+ icon: XCircle,
+ input: "reason",
+ inputLabel: "Rejection reason",
+ inputPlaceholder: "Explain why this booking is rejected…",
+ },
+ ];
+}
+
+const SUBMITTED_ACTIONS: BookingActionDef[] = [
+ {
+ id: "accept",
+ label: "Accept for approval",
+ shortLabel: "Accept",
+ description: "Start the formal approval chain",
+ confirmTitle: "Accept submission?",
+ confirmDescription:
+ "The booking moves to pending approval and approval steps are created from the rule engine.",
+ variant: "default",
+ icon: ShieldCheck,
+ primary: true,
+ },
+ {
+ id: "requestChanges",
+ label: "Request changes",
+ shortLabel: "Changes",
+ description: "Ask the customer to update and resubmit",
+ confirmTitle: "Request changes from customer?",
+ confirmDescription:
+ "The customer will see your note and can edit the booking before resubmitting.",
+ variant: "outline",
+ icon: MessageSquareWarning,
+ input: "note",
+ inputLabel: "Message to customer",
+ inputPlaceholder: "Describe what needs to be corrected or added…",
+ },
+ {
+ id: "reject",
+ label: "Reject booking",
+ shortLabel: "Reject",
+ description: "Reject this submission",
+ confirmTitle: "Reject booking?",
+ confirmDescription:
+ "The booking will be marked rejected and removed from active queues.",
+ variant: "destructive",
+ icon: Ban,
+ input: "reason",
+ inputLabel: "Rejection reason",
+ inputPlaceholder: "Reason for rejection…",
+ },
+];
+
+/** Actions available for the current booking status (detail or list). */
+export function getBookingActions(ctx: BookingActionContext): BookingActionDef[] {
+ const { status, paymentCurrency, approvalSteps } = ctx;
+
+ switch (status) {
+ case "SUBMITTED":
+ return SUBMITTED_ACTIONS;
+ case "PENDING_APPROVAL":
+ case "APPROVED_PENDING_SIGNATURE":
+ return approvalActions(approvalSteps);
+ case "APPROVED":
+ return [
+ {
+ id: "generateContract",
+ label: "Generate contract",
+ shortLabel: "Contract",
+ description: "Create contract document",
+ confirmTitle: "Generate contract?",
+ confirmDescription:
+ "A contract will be generated and the booking moves to contract ready.",
+ variant: "default",
+ icon: FileText,
+ primary: true,
+ },
+ ];
+ case "CONTRACT_READY":
+ case "SIGNED_CUSTOMER":
+ case "FULLY_EXECUTED":
+ return [
+ {
+ id: "viewContract",
+ label:
+ status === "SIGNED_CUSTOMER"
+ ? "View & sign contract (staff)"
+ : status === "CONTRACT_READY"
+ ? "View contract"
+ : "View executed contract",
+ shortLabel: "Contract",
+ description: "Open contract document and signatures",
+ confirmTitle: "",
+ confirmDescription: "",
+ variant: "default",
+ icon: FileSignature,
+ primary: true,
+ },
+ ];
+ case "FULLY_EXECUTED":
+ if (paymentCurrency === "ETB") {
+ return [
+ {
+ id: "generatePnr",
+ label: "Generate PNR",
+ shortLabel: "PNR",
+ description: "Issue PNR for ETB bank payment",
+ confirmTitle: "Generate PNR?",
+ confirmDescription:
+ "A payment reference number will be issued for the customer.",
+ variant: "default",
+ icon: Wallet,
+ primary: true,
+ },
+ ];
+ }
+ return [];
+ case "PAYMENT_VERIFICATION_IN_PROGRESS":
+ return [
+ {
+ id: "verifyPayment",
+ label: "Verify payment",
+ shortLabel: "Verify",
+ description: "Confirm USD payment proof",
+ confirmTitle: "Verify payment?",
+ confirmDescription:
+ "Finance confirms the uploaded proof and marks the booking as paid.",
+ variant: "default",
+ icon: Check,
+ primary: true,
+ },
+ ];
+ case "PAID":
+ case "PNR_GENERATED":
+ return [
+ {
+ id: "startTransit",
+ label: "Start transit",
+ shortLabel: "Transit",
+ description: "Begin rail movement",
+ confirmTitle: "Start transit?",
+ confirmDescription: "The booking will move to in transit status.",
+ variant: "default",
+ icon: Truck,
+ primary: true,
+ },
+ ];
+ case "IN_TRANSIT":
+ return [
+ {
+ id: "complete",
+ label: "Complete booking",
+ shortLabel: "Complete",
+ description: "Mark journey finished",
+ confirmTitle: "Complete booking?",
+ confirmDescription:
+ "Marks the booking as completed. No further staff transitions apply.",
+ variant: "default",
+ icon: Play,
+ primary: true,
+ },
+ ];
+ default:
+ return [];
+ }
+}
+
+export function listRowHasActions(row: {
+ status: BookingStatus;
+ paymentCurrency: string;
+}): boolean {
+ const actions = getBookingActions({
+ status: row.status,
+ paymentCurrency: row.paymentCurrency,
+ reference: "",
+ });
+ if (actions.length > 0) return true;
+ return row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
+}
diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts
new file mode 100644
index 000000000..71892a5dc
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts
@@ -0,0 +1,260 @@
+import type { BookingStatus } from "@/types/booking";
+
+export interface StatusStyle {
+ label: string;
+ color: string;
+}
+
+export const BOOKING_STATUS_STYLES: Record = {
+ DRAFT: {
+ label: "Draft",
+ color: "bg-slate-100 text-slate-700 border-slate-300",
+ },
+ SUBMITTED: {
+ label: "Submitted",
+ color: "bg-amber-50 text-amber-700 border-amber-200",
+ },
+ CHANGES_REQUESTED: {
+ label: "Changes Requested",
+ color: "bg-orange-50 text-orange-700 border-orange-200",
+ },
+ PENDING_APPROVAL: {
+ label: "Pending Approval",
+ color: "bg-amber-50 text-amber-700 border-amber-200",
+ },
+ APPROVED_PENDING_SIGNATURE: {
+ label: "Pending Signature",
+ color: "bg-sky-50 text-sky-700 border-sky-200",
+ },
+ APPROVED: {
+ label: "Approved",
+ color: "bg-emerald-50 text-emerald-700 border-emerald-200",
+ },
+ CONTRACT_READY: {
+ label: "Contract Ready",
+ color: "bg-indigo-50 text-indigo-700 border-indigo-200",
+ },
+ SIGNED_CUSTOMER: {
+ label: "Customer Signed",
+ color: "bg-sky-50 text-sky-700 border-sky-200",
+ },
+ FULLY_EXECUTED: {
+ label: "Fully Executed",
+ color: "bg-indigo-50 text-indigo-700 border-indigo-200",
+ },
+ PNR_GENERATED: {
+ label: "PNR Generated",
+ color: "bg-violet-50 text-violet-700 border-violet-200",
+ },
+ PAYMENT_VERIFICATION_IN_PROGRESS: {
+ label: "Payment Verification",
+ color: "bg-amber-50 text-amber-800 border-amber-200",
+ },
+ PAID: {
+ label: "Paid",
+ color: "bg-emerald-50 text-emerald-700 border-emerald-200",
+ },
+ IN_TRANSIT: {
+ label: "In Transit",
+ color: "bg-sky-50 text-sky-700 border-sky-200",
+ },
+ COMPLETED: {
+ label: "Completed",
+ color: "bg-indigo-50 text-indigo-700 border-indigo-200",
+ },
+ REJECTED: {
+ label: "Rejected",
+ color: "bg-red-50 text-red-700 border-red-200",
+ },
+ CANCELLED: {
+ label: "Cancelled",
+ color: "bg-red-50 text-red-700 border-red-200",
+ },
+ PENDING_CONSOLIDATION: {
+ label: "Pending Consolidation",
+ color: "bg-amber-50 text-amber-700 border-amber-200",
+ },
+ CONSOLIDATED: {
+ label: "Consolidated",
+ color: "bg-indigo-50 text-indigo-700 border-indigo-200",
+ },
+};
+
+export interface StatusMeta {
+ title: string;
+ description: string;
+ color: string;
+ stage: number;
+}
+
+export const BOOKING_STATUS_META: Record = {
+ DRAFT: {
+ title: "Draft",
+ description: "Booking is being prepared by the customer.",
+ color: "text-slate-500",
+ stage: 0,
+ },
+ SUBMITTED: {
+ title: "Submitted",
+ description: "Awaiting staff review.",
+ color: "text-amber-600",
+ stage: 0,
+ },
+ CHANGES_REQUESTED: {
+ title: "Changes Requested",
+ description: "Returned to customer for updates.",
+ color: "text-orange-600",
+ stage: 0,
+ },
+ PENDING_APPROVAL: {
+ title: "Pending Approval",
+ description: "Moving through internal approval chain.",
+ color: "text-amber-600",
+ stage: 1,
+ },
+ APPROVED_PENDING_SIGNATURE: {
+ title: "Pending Signature",
+ description: "Awaiting director or CEO signature steps.",
+ color: "text-sky-600",
+ stage: 1,
+ },
+ APPROVED: {
+ title: "Approved",
+ description: "Ready to generate contract.",
+ color: "text-emerald-600",
+ stage: 2,
+ },
+ CONTRACT_READY: {
+ title: "Contract Ready",
+ description: "Contract generated; awaiting customer signature.",
+ color: "text-indigo-600",
+ stage: 2,
+ },
+ SIGNED_CUSTOMER: {
+ title: "Customer Signed",
+ description: "Awaiting contract execution.",
+ color: "text-sky-600",
+ stage: 2,
+ },
+ FULLY_EXECUTED: {
+ title: "Fully Executed",
+ description: "Contract locked; proceed to payment.",
+ color: "text-indigo-600",
+ stage: 3,
+ },
+ PNR_GENERATED: {
+ title: "PNR Generated",
+ description: "ETB payment reference issued.",
+ color: "text-violet-600",
+ stage: 3,
+ },
+ PAYMENT_VERIFICATION_IN_PROGRESS: {
+ title: "Payment Verification",
+ description: "USD payment proof under review.",
+ color: "text-amber-700",
+ stage: 3,
+ },
+ PAID: {
+ title: "Paid",
+ description: "Payment confirmed; ready for operations.",
+ color: "text-emerald-600",
+ stage: 4,
+ },
+ IN_TRANSIT: {
+ title: "In Transit",
+ description: "Shipment is on the railway network.",
+ color: "text-sky-600",
+ stage: 4,
+ },
+ COMPLETED: {
+ title: "Completed",
+ description: "Booking fulfilled.",
+ color: "text-indigo-600",
+ stage: 5,
+ },
+ REJECTED: {
+ title: "Rejected",
+ description: "Booking was rejected.",
+ color: "text-red-600",
+ stage: -1,
+ },
+ CANCELLED: {
+ title: "Cancelled",
+ description: "Booking was cancelled.",
+ color: "text-red-600",
+ stage: -1,
+ },
+ PENDING_CONSOLIDATION: {
+ title: "Pending Consolidation",
+ description: "Waiting for consolidation partner.",
+ color: "text-amber-600",
+ stage: 4,
+ },
+ CONSOLIDATED: {
+ title: "Consolidated",
+ description: "Paired with another booking.",
+ color: "text-indigo-600",
+ stage: 4,
+ },
+};
+
+export const BOOKING_LIST_TABS = [
+ { key: "all", label: "All bookings", status: null },
+ { key: "SUBMITTED", label: "Submitted", status: "SUBMITTED" },
+ { key: "PENDING_APPROVAL", label: "Pending Approval", status: "PENDING_APPROVAL" },
+ {
+ key: "APPROVED_PENDING_SIGNATURE",
+ label: "Pending Signature",
+ status: "APPROVED_PENDING_SIGNATURE",
+ },
+ { key: "SIGNED_CUSTOMER", label: "Customer Signed", status: "SIGNED_CUSTOMER" },
+ {
+ key: "PAYMENT_VERIFICATION_IN_PROGRESS",
+ label: "Payment Verification",
+ status: "PAYMENT_VERIFICATION_IN_PROGRESS",
+ },
+] as const;
+
+export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
+
+export const WORKFLOW_STAGES = [
+ { label: "Submission", statuses: ["DRAFT", "SUBMITTED", "CHANGES_REQUESTED"] },
+ {
+ label: "Approval",
+ statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"],
+ },
+ {
+ label: "Contract",
+ statuses: ["APPROVED", "CONTRACT_READY", "SIGNED_CUSTOMER", "FULLY_EXECUTED"],
+ },
+ {
+ label: "Payment",
+ statuses: [
+ "PNR_GENERATED",
+ "PAYMENT_VERIFICATION_IN_PROGRESS",
+ "PAID",
+ ],
+ },
+ {
+ label: "Operations",
+ statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
+ },
+ { label: "Done", statuses: ["COMPLETED"] },
+] as const;
+
+export function getStatusMeta(status: BookingStatus | string): StatusMeta {
+ return (
+ BOOKING_STATUS_META[status] ?? {
+ title: status,
+ description: "",
+ color: "text-muted-foreground",
+ stage: 0,
+ }
+ );
+}
+
+export function getWorkflowStageIndex(status: BookingStatus | string): number {
+ const meta = getStatusMeta(status);
+ if (meta.stage < 0) return -1;
+ return meta.stage;
+}
diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts
new file mode 100644
index 000000000..eaf7421f4
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts
@@ -0,0 +1,34 @@
+import type { BookingDetail, BookingListRow } from "@/types/booking";
+
+function labelFromRef(
+ ref?: { name?: string; label?: string; code?: string; companyName?: string },
+ fallback = "—",
+): string {
+ if (!ref) return fallback;
+ return (
+ ref.companyName ??
+ ref.label ??
+ ref.name ??
+ ref.code ??
+ fallback
+ );
+}
+
+export function toBookingListRow(booking: BookingDetail): BookingListRow {
+ return {
+ id: booking.id,
+ reference: booking.reference,
+ customerLabel: labelFromRef(booking.customer, booking.customerId),
+ status: booking.status,
+ scheduledDate: booking.scheduledDate,
+ totalAmount: Number(booking.totalAmount),
+ paymentCurrency: booking.paymentCurrency,
+ paymentStatus: booking.paymentStatus,
+ tradeDirection: booking.tradeDirection,
+ freightType: booking.freightType,
+ originLabel: labelFromRef(booking.originYard),
+ destinationLabel: labelFromRef(booking.destinationYard),
+ priorityScore: booking.priorityScore ?? 0,
+ createdAt: booking.createdAt,
+ };
+}
diff --git a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts
new file mode 100644
index 000000000..241fe5e43
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts
@@ -0,0 +1,178 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import toast from "react-hot-toast";
+
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
+import { api } from "@/services/api";
+import {
+ bookingsService,
+ type BookingListFilter,
+} from "@/services/bookings.service";
+import { invalidateBookingDetail } from "@/utils/queryInvalidation";
+
+export function useBookingList(filter?: BookingListFilter, enabled = true) {
+ return useQuery({
+ queryKey: QUERY_KEYS.BOOKINGS.list(filter),
+ queryFn: () => bookingsService.list(filter),
+ enabled,
+ });
+}
+
+export function useBookingDetail(id: string | undefined) {
+ return useQuery({
+ queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? ""),
+ queryFn: () => bookingsService.getById(id!),
+ enabled: Boolean(id),
+ });
+}
+
+export function useBookingMutations(bookingId: string) {
+ const qc = useQueryClient();
+ const onSuccess = (data: { id: string }, message: string) => {
+ toast.success(message);
+ void invalidateBookingDetail(qc, data.id);
+ };
+
+ const staffAccept = useMutation({
+ mutationFn: () => api.bookings.staffAccept.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Booking accepted for approval"),
+ onError: () => toast.error("Failed to accept booking"),
+ });
+
+ const requestChanges = useMutation({
+ mutationFn: (note: string) =>
+ api.bookings.requestChanges.call({ id: bookingId, note }),
+ onSuccess: (data) => onSuccess(data, "Changes requested from customer"),
+ onError: () => toast.error("Failed to request changes"),
+ });
+
+ const staffReject = useMutation({
+ mutationFn: (reason: string) =>
+ api.bookings.staffReject.call({ id: bookingId, reason }),
+ onSuccess: (data) => onSuccess(data, "Booking rejected"),
+ onError: () => toast.error("Failed to reject booking"),
+ });
+
+ const approveStep = useMutation({
+ mutationFn: ({
+ stepId,
+ requiredRole,
+ }: {
+ stepId: string;
+ requiredRole: string;
+ }) =>
+ api.bookings.approveStep.call({
+ id: bookingId,
+ stepId,
+ requiredRole,
+ }),
+ onSuccess: (data) => onSuccess(data, "Approval step completed"),
+ onError: () => toast.error("Failed to approve step"),
+ });
+
+ const rejectStep = useMutation({
+ mutationFn: ({
+ stepId,
+ reason,
+ }: {
+ stepId: string;
+ reason: string;
+ }) =>
+ api.bookings.rejectStep.call({
+ id: bookingId,
+ stepId,
+ reason,
+ }),
+ onSuccess: (data) => onSuccess(data, "Booking rejected at approval step"),
+ onError: () => toast.error("Failed to reject step"),
+ });
+
+ const generateContract = useMutation({
+ mutationFn: () => api.bookings.generateContract.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Contract generated"),
+ onError: () => toast.error("Failed to generate contract"),
+ });
+
+ const signContract = useMutation({
+ mutationFn: (payload: {
+ role: "CUSTOMER" | "STAFF";
+ signatureImageBase64: string;
+ signerDisplayName: string;
+ consentText?: string;
+ }) => bookingsService.signContract(bookingId, payload),
+ onSuccess: (data) => onSuccess(data, "Contract signed"),
+ onError: () => toast.error("Failed to sign contract"),
+ });
+
+ const generatePnr = useMutation({
+ mutationFn: () => api.bookings.generatePnr.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "PNR generated"),
+ onError: () => toast.error("Failed to generate PNR"),
+ });
+
+ const submitPaymentProof = useMutation({
+ mutationFn: (file: File) =>
+ bookingsService.submitPaymentProof(bookingId, file),
+ onSuccess: (data) => onSuccess(data, "Payment proof uploaded"),
+ onError: () => toast.error("Failed to upload payment proof"),
+ });
+
+ const verifyPayment = useMutation({
+ mutationFn: () => api.bookings.verifyPayment.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Payment verified"),
+ onError: () => toast.error("Failed to verify payment"),
+ });
+
+ const startTransit = useMutation({
+ mutationFn: () => api.bookings.startTransit.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Marked in transit"),
+ onError: () => toast.error("Failed to start transit"),
+ });
+
+ const complete = useMutation({
+ mutationFn: () => api.bookings.complete.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Booking completed"),
+ onError: () => toast.error("Failed to complete booking"),
+ });
+
+ const cancel = useMutation({
+ mutationFn: (reason: string) =>
+ api.bookings.cancel.call({ id: bookingId, reason }),
+ onSuccess: (data) => onSuccess(data, "Booking cancelled"),
+ onError: () => toast.error("Failed to cancel booking"),
+ });
+
+ const isPending =
+ staffAccept.isPending ||
+ requestChanges.isPending ||
+ staffReject.isPending ||
+ approveStep.isPending ||
+ rejectStep.isPending ||
+ generateContract.isPending ||
+ signContract.isPending ||
+ generatePnr.isPending ||
+ submitPaymentProof.isPending ||
+ verifyPayment.isPending ||
+ startTransit.isPending ||
+ complete.isPending ||
+ cancel.isPending;
+
+ return {
+ staffAccept,
+ requestChanges,
+ staffReject,
+ approveStep,
+ rejectStep,
+ generateContract,
+ signContract,
+ generatePnr,
+ submitPaymentProof,
+ verifyPayment,
+ startTransit,
+ complete,
+ cancel,
+ isPending,
+ downloadContract: () => bookingsService.downloadContract(bookingId),
+ downloadPaymentLetter: () =>
+ bookingsService.downloadPaymentRequestLetter(bookingId),
+ };
+}
diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
index 8430c8f1c..c98738102 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
@@ -1,41 +1,38 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { api } from "@/services/api";
-import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
+import { ruleEngineService, type RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
import type {
- ApproveRatePayload,
RuleEngineRecord,
RuleEngineResourceSlug,
} from "@/types/rule-engine";
+import {
+ invalidateRuleEngineList,
+ patchRuleEngineListRecord,
+} from "@/utils/queryInvalidation";
const CARGO_TYPE_PARENT_PAGE_SIZE = 500;
const CONTAINER_TYPE_OPTIONS_PAGE_SIZE = 500;
-const listKey = (resource: RuleEngineResourceSlug) =>
- ["rule-engine", resource] as const;
-
export const useRuleEngineList = (
resource: RuleEngineResourceSlug,
params: RuleEngineListParams,
) =>
- useQuery(
- api.ruleEngine.list.queryOptions({
- input: { resource, params },
- }),
- );
+ useQuery({
+ queryKey: QUERY_KEYS.RULE_ENGINE.list(resource, params),
+ queryFn: () => ruleEngineService.list(resource, params),
+ });
export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
useQuery({
- queryKey: api.ruleEngine.list.queryKey({
- resource: "cargo-types",
- params: { page: 1, pageSize: CARGO_TYPE_PARENT_PAGE_SIZE },
- }),
+ queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types"),
queryFn: () =>
- api.ruleEngine.list.call({
- resource: "cargo-types",
- params: { page: 1, pageSize: CARGO_TYPE_PARENT_PAGE_SIZE },
+ ruleEngineService.list("cargo-types", {
+ page: 1,
+ pageSize: CARGO_TYPE_PARENT_PAGE_SIZE,
}),
enabled,
select: (result) => {
@@ -55,54 +52,90 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
},
});
-export const useContainerTypeOptions = (enabled = true) =>
+export function buildContainerTypeSelectOptions(
+ rows: RuleEngineRecord[],
+ includeNone: boolean,
+): { label: string; value: string }[] {
+ const options = rows
+ .filter((row) => row.id)
+ .map((row) => {
+ const label = String(row.label ?? "").trim();
+ const code = String(row.code ?? "").trim();
+ const size = row.sizeFt ? `${String(row.sizeFt)}ft` : "";
+ const parts = [label || code || String(row.id), size].filter(Boolean);
+ return {
+ label: parts.join(" - "),
+ value: String(row.id),
+ };
+ });
+
+ if (!includeNone) return options;
+ return [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...options];
+}
+
+export const useContainerTypeOptions = (
+ includeNone = true,
+ enabled = true,
+) =>
useQuery({
- queryKey: [
- ...QUERY_KEYS.RULE_ENGINE.list("container-types"),
- "select-options",
- ],
+ queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("container-types", {
+ includeNone,
+ }),
queryFn: () =>
ruleEngineService.list("container-types", {
page: 1,
pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
}),
enabled,
- select: (result) => {
- const noneOption = { label: "None", value: RULE_ENGINE_SELECT_NONE };
- const options = (result.data ?? []).map((row) => {
- const label = String(row.label ?? "").trim();
- const code = String(row.code ?? "").trim();
- const size = row.sizeFt ? `${String(row.sizeFt)}ft` : "";
- const parts = [label || code || String(row.id), size].filter(Boolean);
+ select: (result) =>
+ buildContainerTypeSelectOptions(result.data ?? [], includeNone),
+ });
- return {
- label: parts.join(" - "),
- value: String(row.id),
- };
- });
+const LIVE_RATE_PAGE_SIZE = 500;
- return [noneOption, ...options];
- },
+export const useLiveRateOptions = (enabled = true) =>
+ useQuery({
+ queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("rates", { status: "LIVE" }),
+ queryFn: () =>
+ ruleEngineService.list("rates", {
+ page: 1,
+ pageSize: LIVE_RATE_PAGE_SIZE,
+ status: "LIVE",
+ }),
+ enabled,
+ select: (result) =>
+ (result.data ?? [])
+ .filter((row) => row.id)
+ .map((row) => {
+ const rateType = String(row.rateType ?? "").replace(/_/g, " ");
+ const currency = String(row.currency ?? "");
+ const value = row.rateValue != null ? String(row.rateValue) : "";
+ const unit = row.rateUnit ? String(row.rateUnit).replace(/_/g, " ") : "";
+ const parts = [rateType, currency, value, unit].filter(Boolean);
+ return {
+ label: parts.join(" · "),
+ value: String(row.id),
+ };
+ }),
});
export const useApprovalChain = (enabled: boolean) =>
- useQuery(
- api.ruleEngine.getApprovalChain.queryOptions({
- enabled,
- }),
- );
+ useQuery({
+ queryKey: QUERY_KEYS.RULE_ENGINE.chain,
+ queryFn: () => ruleEngineService.getApprovalChain(),
+ enabled,
+ });
export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
const qc = useQueryClient();
- const invalidate = () =>
- qc.invalidateQueries({ queryKey: listKey(resource) });
const create = useMutation({
mutationFn: (payload: Record) =>
api.ruleEngine.create.call({ resource, payload }),
- onSuccess: () => {
+ onSuccess: async (created) => {
toast.success("Created successfully");
- invalidate();
+ patchRuleEngineListRecord(qc, resource, created);
+ await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to create record"),
});
@@ -115,9 +148,10 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
id: string;
payload: Record;
}) => api.ruleEngine.update.call({ resource, id, payload }),
- onSuccess: () => {
+ onSuccess: async (updated) => {
toast.success("Updated successfully");
- invalidate();
+ patchRuleEngineListRecord(qc, resource, updated);
+ await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to update record"),
});
@@ -125,9 +159,9 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
const remove = useMutation({
mutationFn: (id: string) =>
api.ruleEngine.remove.call({ resource, id }),
- onSuccess: () => {
+ onSuccess: async () => {
toast.success("Deleted successfully");
- invalidate();
+ await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to delete record"),
});
@@ -137,29 +171,23 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
export const useRateWorkflow = () => {
const qc = useQueryClient();
- const invalidate = () =>
- qc.invalidateQueries({ queryKey: listKey("rates") });
const submit = useMutation({
mutationFn: (id: string) => api.ruleEngine.submitRate.call({ id }),
- onSuccess: () => {
+ onSuccess: async (updated) => {
toast.success("Rate submitted for approval");
- invalidate();
+ patchRuleEngineListRecord(qc, "rates", updated);
+ await invalidateRuleEngineList(qc, "rates");
},
onError: () => toast.error("Failed to submit rate"),
});
const approve = useMutation({
- mutationFn: ({
- id,
- payload,
- }: {
- id: string;
- payload: ApproveRatePayload;
- }) => api.ruleEngine.approveRate.call({ id, payload }),
- onSuccess: () => {
+ mutationFn: (id: string) => api.ruleEngine.approveRate.call({ id }),
+ onSuccess: async (updated) => {
toast.success("Rate approved");
- invalidate();
+ patchRuleEngineListRecord(qc, "rates", updated);
+ await invalidateRuleEngineList(qc, "rates");
},
onError: () => toast.error("Failed to approve rate"),
});
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts
index 5ee2e5749..81e7bbc83 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts
@@ -1,48 +1,5 @@
-import { useMutation, useQueryClient } from "@tanstack/react-query";
-
-import { api } from "@/services/api";
-import type { BookingListFilter } from "@/services/bookings.service";
-
-export const useBookingList = (filter?: BookingListFilter) => {
- const input = { filter };
- return {
- queryKey: api.bookings.list.queryKey(input),
- queryFn: () => api.bookings.list.call(input),
- };
-};
-
-export const useBooking = (id: string) => ({
- queryKey: api.bookings.getById.queryKey({ id }),
- queryFn: () => api.bookings.getById.call({ id }),
- enabled: Boolean(id),
-});
-
-export const useUpdateBookingStatus = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: ({
- id,
- action,
- reason,
- }: {
- id: string;
- action: string;
- reason?: string;
- }) => api.bookings.updateStatus.call({ id, action, reason }),
- onSuccess: (_data, { id }) => {
- qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
- qc.invalidateQueries({
- queryKey: api.bookings.getById.queryKey({ id }),
- });
- },
- });
-};
-
-export const useDeleteBooking = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: (id: string) => api.bookings.remove.call({ id }),
- onSuccess: () =>
- qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
- });
-};
+export {
+ useBookingList,
+ useBookingDetail,
+ useBookingMutations,
+} from "./bookings/useBookings";
diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
new file mode 100644
index 000000000..9ac8402d8
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
@@ -0,0 +1,12 @@
+import { QueryClient } from "@tanstack/react-query";
+
+/** Single app-wide React Query client (do not nest additional providers). */
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: 1,
+ refetchOnWindowFocus: false,
+ staleTime: 30_000,
+ },
+ },
+});
diff --git a/apps/edr-freight-web/backoffice/src/main.tsx b/apps/edr-freight-web/backoffice/src/main.tsx
index f2418ba0b..7ab0fcbf8 100644
--- a/apps/edr-freight-web/backoffice/src/main.tsx
+++ b/apps/edr-freight-web/backoffice/src/main.tsx
@@ -9,7 +9,8 @@ import { Toaster } from "react-hot-toast";
import App from "./App";
import { AuthProvider } from "./auth/AuthProvider";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { queryClient } from "./lib/queryClient";
const THEME_STORAGE_KEY = "edr-theme";
@@ -38,8 +39,6 @@ if (!rootElement) {
throw new Error("Root element not found");
}
-const queryClient = new QueryClient();
-
createRoot(rootElement).render(
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx
new file mode 100644
index 000000000..32a7d9750
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx
@@ -0,0 +1,218 @@
+import { useCallback, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ ArrowLeft,
+ Download,
+ FileSignature,
+ Loader2,
+ Printer,
+} from "lucide-react";
+import toast from "react-hot-toast";
+
+import Breadcrumbs from "@/components/ui/Breadcrumbs";
+import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
+import { bookingSurface } from "@/components/bookings/booking-ui.styles";
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
+import { invalidateBookingDetail } from "@/utils/queryInvalidation";
+import {
+ bookingsService,
+ type ContractView,
+ type SignContractPayload,
+} from "@/services/bookings.service";
+import { cn } from "@/lib/utils";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Input,
+ Label,
+} from "@edr/ui-common";
+
+export default function BookingContractPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const qc = useQueryClient();
+ const [signOpen, setSignOpen] = useState(false);
+ const [signerName, setSignerName] = useState("");
+ const [signatureData, setSignatureData] = useState(null);
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"],
+ queryFn: () => bookingsService.getContractView(id!),
+ enabled: Boolean(id),
+ });
+
+ const signRole: "CUSTOMER" | "STAFF" | null = data?.canSignCustomer
+ ? "CUSTOMER"
+ : data?.canSignStaff
+ ? "STAFF"
+ : null;
+
+ const signMutation = useMutation({
+ mutationFn: (payload: SignContractPayload) =>
+ bookingsService.signContract(id!, payload),
+ onSuccess: async () => {
+ toast.success("Signature recorded");
+ setSignOpen(false);
+ await invalidateBookingDetail(qc, id!);
+ qc.invalidateQueries({
+ queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"],
+ });
+ },
+ onError: () => toast.error("Failed to sign contract"),
+ });
+
+ const downloadPdf = useCallback(async () => {
+ if (!id) return;
+ try {
+ const blob = await bookingsService.downloadContractDocument(id);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `contract-${data?.reference ?? id}.pdf`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch {
+ toast.error("Contract PDF not available. Ask staff to generate it first.");
+ }
+ }, [id, data?.reference]);
+
+ const handlePrint = () => window.print();
+
+ const openSign = () => {
+ setSignerName("");
+ setSignatureData(null);
+ setSignOpen(true);
+ };
+
+ const confirmSign = () => {
+ if (!signRole || !signatureData || !signerName.trim()) return;
+ signMutation.mutate({
+ role: signRole,
+ signatureImageBase64: signatureData,
+ signerDisplayName: signerName.trim(),
+ consentText: "I agree to the terms of this contract.",
+ });
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+
Could not load contract.
+
navigate(-1)}>
+ Go back
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
navigate(-1)}>
+
+ Back
+
+
+
+
+ Print
+
+
+
+ Download PDF
+
+ {signRole && (
+
+
+ Sign as {signRole === "CUSTOMER" ? "Customer" : "Staff"}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"}
+
+
+ Sign to execute the contract for {data.reference}.
+
+
+
+
+ Full name
+ setSignerName(e.target.value)}
+ placeholder="As shown on the contract"
+ />
+
+
+
+
+ setSignOpen(false)}>
+ Cancel
+
+
+ {signMutation.isPending ? (
+
+ ) : (
+ "Confirm signature"
+ )}
+
+
+
+
+
+
+ );
+}
+
+/** Render server HTML body content inside our layout wrapper. */
+function extractBodyHtml(fullHtml: string): string {
+ const match = fullHtml.match(/]*>([\s\S]*)<\/body>/i);
+ return match ? match[1] : fullHtml;
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
index 3bb75b6bf..e88e26e1d 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
@@ -1,642 +1,232 @@
import { useNavigate, useParams } from "react-router-dom";
-import { useQuery } from "@tanstack/react-query";
import {
- AlertCircle,
- AlertTriangle,
Anchor,
ArrowLeft,
ArrowRight,
+ Building2,
Calendar,
- Check,
- CheckCircle2,
Clock,
- FileSignature,
- FileText,
- History,
- Info,
+ Loader2,
MapPin,
Package,
- ShieldCheck,
- Ship,
- StickyNote,
+ FileSignature,
+ RefreshCw,
Train,
Truck,
Weight,
- X,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
+import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
+import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
+import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
+import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
+import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
+import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
+import { bookingSurface } from "@/components/bookings/booking-ui.styles";
+import { getStatusMeta } from "@/features/bookings/booking-status.config";
+import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
+import {
+ useBookingDetail,
+ useBookingMutations,
+} from "@/hooks/bookings/useBookings";
+import type { BookingDetail } from "@/types/booking";
import { cn } from "@/lib/utils";
-import { api } from "@/services/api";
-import { useUpdateBookingStatus } from "@/hooks/useBookings";
-import { mapBookingToRequest, BOOKING_STATUSES } from "./booking-requests.mock";
import {
Badge,
Button,
- Card,
- CardHeader,
- CardTitle,
- CardDescription,
- CardContent,
Separator,
} from "@edr/ui-common";
-const STATUS_STYLES: Record = {
- DRAFT: {
- label: "Draft",
- color: "bg-slate-100 text-slate-700 border-slate-300",
- },
- RFQ_SUBMITTED: {
- label: "RFQ Submitted",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- QUOTATION_SENT: {
- label: "Quotation Sent",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- QUOTATION_APPROVED: {
- label: "Quotation Approved",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- QUOTATION_REJECTED: {
- label: "Quotation Rejected",
- color: "bg-red-50 text-red-700 border-red-200",
- },
- PENDING_APPROVAL: {
- label: "Pending Approval",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- APPROVED: {
- label: "Approved",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- SIGNED_CUSTOMER: {
- label: "Customer Signed",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- FULLY_EXECUTED: {
- label: "Fully Executed",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
- PAID: {
- label: "Paid",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- IN_TRANSIT: {
- label: "In Transit",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- COMPLETED: {
- label: "Completed",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
- CANCELLED: {
- label: "Cancelled",
- color: "bg-red-50 text-red-700 border-red-200",
- },
- PENDING_CONSOLIDATION: {
- label: "Pending Consolidation",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- CONSOLIDATED: {
- label: "Consolidated",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
-};
-
-const PROGRESS_STAGES = [
- { label: "Request", icon: FileText, statuses: ["DRAFT", "RFQ_SUBMITTED"] },
- {
- label: "Quotation",
- icon: ShieldCheck,
- statuses: ["QUOTATION_SENT", "QUOTATION_APPROVED", "QUOTATION_REJECTED"],
- },
- {
- label: "Approval",
- icon: FileSignature,
- statuses: ["PENDING_APPROVAL", "APPROVED"],
- },
- {
- label: "Execution",
- icon: CheckCircle2,
- statuses: ["SIGNED_CUSTOMER", "FULLY_EXECUTED", "PAID"],
- },
- {
- label: "In Transit",
- icon: Train,
- statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
- },
- { label: "Complete", icon: Check, statuses: ["COMPLETED"] },
-];
-
-const STATUS_CONFIG: Record<
- string,
- { title: string; description: string; color: string; stage: number }
-> = {
- DRAFT: {
- title: "Draft",
- description: "Booking is being prepared.",
- color: "text-slate-500",
- stage: 0,
- },
- RFQ_SUBMITTED: {
- title: "RFQ Submitted",
- description: "Customer has submitted a request for quotation.",
- color: "text-amber-600",
- stage: 0,
- },
- QUOTATION_SENT: {
- title: "Quotation Sent",
- description: "A formal quotation has been sent to the customer.",
- color: "text-sky-600",
- stage: 1,
- },
- QUOTATION_APPROVED: {
- title: "Quotation Approved",
- description: "Customer approved the quotation.",
- color: "text-emerald-600",
- stage: 1,
- },
- QUOTATION_REJECTED: {
- title: "Quotation Rejected",
- description: "Customer rejected the quotation.",
- color: "text-red-600",
- stage: 1,
- },
- PENDING_APPROVAL: {
- title: "Pending Approval",
- description: "Booking requires your approval to proceed.",
- color: "text-amber-600",
- stage: 2,
- },
- APPROVED: {
- title: "Approved",
- description: "Booking has been approved by all parties.",
- color: "text-emerald-600",
- stage: 2,
- },
- SIGNED_CUSTOMER: {
- title: "Customer Signed",
- description: "Customer has signed the contract.",
- color: "text-sky-600",
- stage: 3,
- },
- FULLY_EXECUTED: {
- title: "Fully Executed",
- description: "All parties have signed.",
- color: "text-indigo-600",
- stage: 3,
- },
- PAID: {
- title: "Paid",
- description: "Payment received.",
- color: "text-emerald-600",
- stage: 3,
- },
- IN_TRANSIT: {
- title: "In Transit",
- description: "Cargo is moving through the rail network.",
- color: "text-sky-600",
- stage: 4,
- },
- PENDING_CONSOLIDATION: {
- title: "Pending Consolidation",
- description: "Cargo awaiting consolidation.",
- color: "text-amber-500",
- stage: 4,
- },
- CONSOLIDATED: {
- title: "Consolidated",
- description: "Cargo merged into larger shipment.",
- color: "text-indigo-500",
- stage: 4,
- },
- COMPLETED: {
- title: "Completed",
- description: "Service completed successfully.",
- color: "text-emerald-600",
- stage: 5,
- },
- CANCELLED: {
- title: "Cancelled",
- description: "Booking terminated.",
- color: "text-red-600",
- stage: -1,
- },
-};
-
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
+ const { data: booking, isLoading, isError, refetch, isFetching } =
+ useBookingDetail(id);
+ const mutations = useBookingMutations(id ?? "");
- const { data: bookingData } = useQuery(
- api.bookings.getById.queryOptions({
- input: { id: id ?? "" },
- enabled: Boolean(id),
- }),
- );
- const updateStatus = useUpdateBookingStatus();
-
- const booking = bookingData ? mapBookingToRequest(bookingData) : undefined;
-
- if (!booking) {
+ if (isLoading) {
return (
-
-
-
-
- Booking not found
-
- navigate("/dashboard/booking-requests")}
- >
-
- Back to Booking Requests
-
-
+
+
+
+
+ Loading booking…
+
+
);
}
- const statusConfig = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
- const currentStage = statusConfig.stage;
-
- const canApprove = ["PENDING_APPROVAL", "RFQ_SUBMITTED"].includes(
- booking.status,
- );
- const canReject = !["COMPLETED", "CANCELLED", "QUOTATION_REJECTED"].includes(
- booking.status,
- );
-
- const isPending = updateStatus.isPending;
-
- function handleApprove() {
- if (!booking) return;
- const action =
- booking.status === "RFQ_SUBMITTED"
- ? "SEND_QUOTATION"
- : "APPROVE";
- updateStatus.mutate({ id: booking.id, action });
+ if (isError || !booking) {
+ return (
+
+
+
+
+
+ Booking not found
+
+
+ This request may have been removed or the link is invalid.
+
+
navigate("/dashboard/booking-requests")}
+ >
+
+ Back to booking requests
+
+
+
+
+ );
}
- function handleReject() {
- if (!booking) return;
- updateStatus.mutate({ id: booking.id, action: "CANCEL", reason: "Cancelled by backoffice" });
- }
+ const row = toBookingListRow(booking);
+ const statusMeta = getStatusMeta(booking.status);
+ const amount = Number(booking.totalAmount);
return (
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
{booking.reference}
-
-
+
+
-
-
{booking.customer}
-
-
-
- Requested {booking.scheduledDate}
+
+
+
+ {row.customerLabel}
-
-
-
- {new Date(booking.createdAt).toLocaleDateString()}
+
+
+ Scheduled {booking.scheduledDate}
+
+
+
+ Created{" "}
+ {new Date(booking.createdAt).toLocaleDateString(undefined, {
+ dateStyle: "medium",
+ })}
-
-
-
- {canReject && (
-
-
- Reject
-
- )}
- {canApprove && (
-
-
- {booking.status === "RFQ_SUBMITTED"
- ? "Send Quotation"
- : "Approve"}
-
- )}
+
+
+
+ Total value
+
+
+ {booking.paymentCurrency}{" "}
+ {amount.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ })}
+
+
+ {booking.paymentStatus}
+
+
+
refetch()}
+ >
+
+ Refresh
+
+
+
-
-
-
-
- Status Lifecycle
-
-
- Track the booking from request to completion
-
-
-
-
-
-
= 0
- ? `${(currentStage / (PROGRESS_STAGES.length - 1)) * 100}%`
- : "0%",
- }}
- />
-
- {PROGRESS_STAGES.map((stage, idx) => {
- const isCompleted = idx < currentStage;
- const isActive = idx === currentStage;
- return (
-
-
- {isCompleted ? (
-
- ) : (
-
- )}
-
-
- {stage.label}
-
-
- );
- })}
-
+
-
-
- {booking.status === "CANCELLED" ? (
-
- ) : (
-
- )}
-
-
-
- {statusConfig.title}
-
-
- {statusConfig.description}
-
-
-
-
-
-
-
-
-
-
-
-
- Route & Service
-
-
-
-
-
}
- />
-
-
-
- {booking.serviceType.replace(/_/g, " ")}
-
-
-
}
- />
-
-
-
- }
- label="Trade Direction"
- value={booking.tradeDirection}
- />
- }
- label="Return"
- value={
- booking.serviceType === "RAIL_AND_FORWARDING"
- ? "With Return"
- : "Without Return"
- }
- />
- {booking.shippingLine && (
- }
- label="Shipping Line"
- value={booking.shippingLine}
- />
- )}
-
-
-
-
- {(booking.firstMilePickupAddress ||
- booking.lastMileDeliveryAddress) && (
-
-
-
-
- Mile Services
-
-
-
- {booking.firstMilePickupAddress && (
-
-
- First Mile
-
-
-
- )}
- {booking.lastMileDeliveryAddress && (
-
-
- Last Mile
-
-
-
- )}
-
-
- )}
-
-
-
-
-
- Cargo Specifications
-
-
-
-
- }
- label="Type"
- value={booking.cargoType}
- />
- }
- label="Total Weight"
- value={`${booking.cargoTotalWeightVgm} Tons`}
- />
- {booking.shippingLine && (
- }
- label="Shipping Line"
- value={booking.shippingLine}
- />
- )}
-
-
-
-
- Hazardous: {booking.isHazardous ? "Yes" : "No"}
-
- {booking.pnrCode && (
-
- PNR: {booking.pnrCode}
-
- )}
-
-
-
+
+
+
+
+
+ {booking.contractSummary && (
+
}
+ title="Contract summary"
+ subtitle="Generated terms"
+ >
+
+ {booking.contractSummary}
+
+
+ )}
-
-
-
-
-
- Contract Info
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {canApprove && (
-
-
-
-
- Approval Required
-
-
- This booking is waiting for your review.
-
-
-
-
-
- {booking.status === "RFQ_SUBMITTED"
- ? "Send Quotation"
- : "Approve Booking"}
-
+
+
+
+ {["CONTRACT_READY", "SIGNED_CUSTOMER", "FULLY_EXECUTED"].includes(
+ booking.status,
+ ) && (
+
+
+ navigate(`/dashboard/booking-requests/${booking.id}/contract`)
+ }
>
-
- Reject
+
+ View & sign contract
-
-
+
+
+ )}
+ {(booking.status === "PENDING_APPROVAL" ||
+ booking.status === "APPROVED_PENDING_SIGNATURE") && (
+
)}
@@ -645,92 +235,221 @@ export default function BookingRequestDetailPage() {
);
}
-function StatusBadge({ status }: { status: string }) {
- const style = STATUS_STYLES[status] ?? {
- label: status,
- color: "bg-muted text-muted-foreground border-border",
- };
+function SectionShell({
+ icon,
+ title,
+ subtitle,
+ children,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ subtitle?: string;
+ children: React.ReactNode;
+}) {
return (
-
- {style.label}
-
+
+
+
+ {icon}
+
+
+
{title}
+ {subtitle && (
+
{subtitle}
+ )}
+
+
+
{children}
+
);
}
-function PriorityBadge({ score }: { score: number }) {
- if (score >= 3) {
- return (
-
- Urgent
-
- );
- }
- if (score === 2) {
- return (
-
- High
-
- );
+function RouteCard({
+ booking,
+ row,
+}: {
+ booking: BookingDetail;
+ row: ReturnType
;
+}) {
+ return (
+ }
+ title="Route & service"
+ subtitle="Corridor and service level"
+ >
+
+
+
+
+
+
+
+
+ {booking.serviceType?.label ??
+ booking.serviceType?.code ??
+ "Rail service"}
+
+
+
+
+
+
+
+
+ {booking.shippingLine && (
+
+ )}
+
+
+ );
+}
+
+function MileCard({ booking }: { booking: BookingDetail }) {
+ if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
+ return null;
}
return (
-
- Normal
-
+ }
+ title="Mile services"
+ subtitle="First and last mile"
+ >
+
+ {booking.firstMilePickupAddress && (
+
+ )}
+ {booking.lastMileDeliveryAddress && (
+
+ )}
+
+
+ );
+}
+
+function CargoCard({ booking }: { booking: BookingDetail }) {
+ const containers = booking.bookingContainers ?? [];
+ return (
+ }
+ title="Cargo specifications"
+ subtitle="Freight and containers"
+ >
+
+
+
+
+
+ {containers.length > 0 && (
+ <>
+
+
+
+
+
+ Container type
+ Qty
+ VGM / unit
+
+
+
+ {containers.map((c) => (
+
+
+ {c.containerType?.label ??
+ c.containerType?.code ??
+ c.containerTypeId}
+
+
+ {c.quantity}
+
+
+ {c.vgmPerUnitTons} t
+
+
+ ))}
+
+
+
+ >
+ )}
+
);
}
function RouteEndpoint({
label,
station,
- icon,
}: {
label: string;
station: string;
- icon: React.ReactNode;
}) {
return (
-
-
-
{icon}
+
+
+
-
-
+
+
{label}
-
{station}
+
{station}
);
}
-function InfoItem({
- icon,
+function MetricTile({
label,
value,
+ highlight,
}: {
- icon?: React.ReactNode;
label: string;
- value?: string | number | null;
+ value: string;
+ highlight?: boolean;
}) {
return (
-
- {icon && (
-
- {icon}
-
+
-
- {label}
-
-
{value ?? "—"}
-
+ >
+
+ {label}
+
+
+ {value}
+
);
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
index 643257bbd..5300dc3ca 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
@@ -1,30 +1,37 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
-import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
ArrowRight,
Calendar,
Clock,
- Eye,
FileText,
- Filter,
- MoreHorizontal,
+ Inbox,
+ LayoutList,
Package,
+ RefreshCw,
Search,
- ShieldCheck,
- Train,
User,
+ X,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
-import { cn } from "@/lib/utils";
-import { api } from "@/services/api";
+import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import {
- BOOKING_STATUSES,
- type BookingRequest,
- mapBookingToRequest,
-} from "./booking-requests.mock";
+ BookingStatusTabs,
+ type BookingStatusTabKey,
+} from "@/components/bookings/BookingStatusTabs";
+import { BookingStatGrid } from "@/components/bookings/BookingStatGrid";
+import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
+import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
+import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
+import { bookingInput, bookingSurface } from "@/components/bookings/booking-ui.styles";
+import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
+import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
+import { useBookingList } from "@/hooks/bookings/useBookings";
+import type { BookingListFilter } from "@/services/bookings.service";
+import type { BookingListRow } from "@/types/booking";
+import { cn } from "@/lib/utils";
import {
DataTable,
DataTableFooter,
@@ -32,190 +39,70 @@ import {
usePagination,
Badge,
Button,
- Card,
- CardHeader,
- CardTitle,
- CardDescription,
- CardContent,
Input,
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- Separator,
} from "@edr/ui-common";
-const STATUS_STYLES: Record
= {
- DRAFT: {
- label: "Draft",
- color: "bg-slate-100 text-slate-700 border-slate-300",
- },
- RFQ_SUBMITTED: {
- label: "RFQ Submitted",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- QUOTATION_SENT: {
- label: "Quotation Sent",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- QUOTATION_APPROVED: {
- label: "Quotation Approved",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- QUOTATION_REJECTED: {
- label: "Quotation Rejected",
- color: "bg-red-50 text-red-700 border-red-200",
- },
- PENDING_APPROVAL: {
- label: "Pending Approval",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- APPROVED: {
- label: "Approved",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- SIGNED_CUSTOMER: {
- label: "Customer Signed",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- FULLY_EXECUTED: {
- label: "Fully Executed",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
- PAID: {
- label: "Paid",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- IN_TRANSIT: {
- label: "In Transit",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- COMPLETED: {
- label: "Completed",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
- CANCELLED: {
- label: "Cancelled",
- color: "bg-red-50 text-red-700 border-red-200",
- },
- PENDING_CONSOLIDATION: {
- label: "Pending Consolidation",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- CONSOLIDATED: {
- label: "Consolidated",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
-};
-
-function StatusBadge({ status }: { status: string }) {
- const style = STATUS_STYLES[status] ?? {
- label: status,
- color: "bg-muted text-muted-foreground border-border",
- };
- return (
-
- {style.label}
-
- );
-}
-
-function PriorityBadge({ score }: { score: number }) {
- if (score >= 3) {
- return (
-
- Urgent
-
- );
- }
- if (score === 2) {
- return (
-
- High
-
- );
- }
- return (
-
- Normal
-
- );
+function getStatusForTab(tab: BookingStatusTabKey): string | undefined {
+ const match = BOOKING_LIST_TABS.find((t) => t.key === tab);
+ return match?.status ?? undefined;
}
export default function BookingRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
- const [statusFilter, setStatusFilter] = useState(null);
+ const [activeTab, setActiveTab] = useState("SUBMITTED");
- const { data: bookingData } = useQuery(
- api.bookings.list.queryOptions({ input: { filter: { page: pagination.pageIndex + 1, pageSize: pagination.pageSize } } }),
- );
- const bookingRequests = useMemo(
- () => (bookingData?.items ?? []).map(mapBookingToRequest),
- [bookingData],
+ const filter: BookingListFilter = useMemo(
+ () => ({
+ page: pagination.pageIndex + 1,
+ pageSize: pagination.pageSize,
+ sortBy: "createdAt",
+ sortOrder: "DESC",
+ ...(getStatusForTab(activeTab) ? { status: getStatusForTab(activeTab) } : {}),
+ }),
+ [pagination.pageIndex, pagination.pageSize, activeTab],
);
- const filtered = useMemo(() => {
+ const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
+
+ const rows = useMemo(() => {
+ const items = (data?.items ?? []).map(toBookingListRow);
const q = query.trim().toLowerCase();
- return bookingRequests.filter((b) => {
- if (
- q &&
- !b.reference.toLowerCase().includes(q) &&
- !b.customer.toLowerCase().includes(q)
- ) {
- return false;
- }
- if (statusFilter && b.status !== statusFilter) {
- return false;
- }
- return true;
- });
- }, [bookingRequests, query, statusFilter]);
+ if (!q) return items;
+ return items.filter(
+ (b) =>
+ b.reference.toLowerCase().includes(q) ||
+ b.customerLabel.toLowerCase().includes(q),
+ );
+ }, [data?.items, query]);
- const total = filtered.length;
+ const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
- const start = pagination.pageIndex * pagination.pageSize;
- const end = Math.min(start + pagination.pageSize, total);
+ const hasSearch = query.trim().length > 0;
+ const showEmpty = !isLoading && !isError && rows.length === 0;
- const paginatedData = useMemo(
- () => filtered.slice(start, end),
- [start, end, filtered],
- );
+ const pendingCount = rows.filter(
+ (b) => b.status === "SUBMITTED" || b.status === "PENDING_APPROVAL",
+ ).length;
+ const urgentCount = rows.filter((b) => b.priorityScore >= 1000).length;
- const pendingCount = bookingRequests.filter(
- (b) => b.status === "PENDING_APPROVAL" || b.status === "RFQ_SUBMITTED",
- ).length;
- const activeCount = bookingRequests.filter(
- (b) => !["COMPLETED", "CANCELLED"].includes(b.status),
- ).length;
- const urgentCount = bookingRequests.filter(
- (b) => b.priorityScore >= 3,
- ).length;
-
- const columns: ColumnDef[] = [
+ const columns: ColumnDef[] = [
{
id: "booking",
- header: "Booking",
+ header: () => Booking ,
cell: ({ row }) => {
const b = row.original;
return (
-
-
-
+
+
-
-
{b.reference}
-
-
- {b.customer}
+
+
{b.reference}
+
+
+ {b.customerLabel}
@@ -224,264 +111,225 @@ export default function BookingRequestsPage() {
},
{
id: "route",
- header: "Route",
+ header: () =>
Route ,
cell: ({ row }) => {
const b = row.original;
return (
-
-
-
{b.originYard}
-
-
{b.destinationYard}
+
+
+
{b.originLabel}
+
+
{b.destinationLabel}
+
+
+
+ {b.tradeDirection}
+
+
+ {b.freightType}
+
-
- {b.tradeDirection}
-
);
},
},
{
id: "status",
- header: "Status",
- cell: ({ row }) =>
,
+ header: () =>
Status ,
+ cell: ({ row }) =>
,
},
{
- id: "service",
- header: "Service",
- cell: ({ row }) => {
- const b = row.original;
- return (
-
-
- {b.serviceType.replace(/_/g, " ")}
-
-
-
- {b.scheduledDate}
-
-
- );
- },
- },
- {
- id: "cargo",
- header: "Cargo",
- cell: ({ row }) => {
- const b = row.original;
- return (
-
-
- {b.cargoType}
-
-
- {b.cargoTotalWeightVgm}T
-
-
- );
- },
+ id: "scheduled",
+ header: () =>
Scheduled ,
+ cell: ({ row }) => (
+
+
+ {row.original.scheduledDate}
+
+ ),
},
{
id: "priority",
- header: "Priority",
- cell: ({ row }) =>
,
+ header: () =>
Priority ,
+ cell: ({ row }) => (
+
+ ),
},
{
id: "amount",
- header: "Amount",
+ header: () => (
+
Amount
+ ),
cell: ({ row }) => {
const b = row.original;
return (
-
- {b.paymentCurrency} {b.totalAmount.toLocaleString()}
+
+ {b.paymentCurrency}{" "}
+ {b.totalAmount.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ })}
);
},
},
{
id: "actions",
- size: 40,
- cell: ({ row }) => {
- const b = row.original;
- return (
- e.stopPropagation()}
- >
-
-
-
-
-
-
-
-
- navigate(`/dashboard/booking-requests/${b.id}`)
- }
- >
-
- View Details
-
-
-
- navigate(`/dashboard/booking-requests/${b.id}`)
- }
- >
-
- Review
-
-
-
-
- );
- },
+ size: 140,
+ header: () => (
+
+ Actions
+
+ ),
+ cell: ({ row }) => (
+
+ ),
},
];
return (
-
-
-
+
+
+
-
-
-
- Booking Requests
-
-
- Review, approve, or reject customer booking requests across the
- freight network.
-
+
+
+
+
+
+
+
+
+
+ Booking requests
+
+
+ Track bookings from submission through payment and operations.
+
+
+
+
+ refetch()}
+ >
+
+ Refresh
+
+
+
-
-
-
+
0 ? "rose" : "default",
+ },
+ ]}
+ />
+
+ {
+ setActiveTab(tab);
+ setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
+ }}
+ counts={{
+ [activeTab]: total,
+ }}
+ />
+
+
+
+
+
{
- setQuery(e.target.value);
- setPagination({
- pageIndex: 0,
- pageSize: pagination.pageSize,
- });
- }}
- placeholder="Search reference or customer..."
- className="pl-8!"
+ onChange={(e) => setQuery(e.target.value)}
+ placeholder="Search reference or customer…"
+ className={bookingInput.search}
/>
+ {query && (
+ setQuery("")}
+ aria-label="Clear search"
+ >
+
+
+ )}
+
+
+
+
+ {total} record{total !== 1 ? "s" : ""}
+
-
-
- }
- />
- }
- />
- } />
- } />
-
-
-
-
-
- All Booking Requests
-
- {total} request{total !== 1 ? "s" : ""} found
-
-
-
-
- {statusFilter && (
- setStatusFilter(null)}
- >
- Clear filter
-
- )}
-
-
-
-
- {statusFilter
- ? (STATUS_STYLES[statusFilter]?.label ?? "Filter")
- : "Filter"}
-
-
-
- {BOOKING_STATUSES.map((s) => (
- setStatusFilter(s)}
- >
- {STATUS_STYLES[s]?.label ?? s}
-
- ))}
-
-
-
-
-
-
-
- navigate(`/dashboard/booking-requests/${row.id}`)
- }
- pagination={{
- pageIndex: pagination.pageIndex,
- pageSize: pagination.pageSize,
- pageCount,
- totalCount: total,
- }}
- tableOptions={{
- state: { pagination },
- onPaginationChange: setPagination,
- }}
- containerClassName="border-b shadow-none"
- footer={DataTableFooter}
+ {showEmpty ? (
+ refetch()}
/>
-
-
+ ) : (
+
+
+ navigate(`/dashboard/booking-requests/${row.id}`)
+ }
+ pagination={{
+ pageIndex: pagination.pageIndex,
+ pageSize: pagination.pageSize,
+ pageCount,
+ totalCount: total,
+ }}
+ tableOptions={{
+ state: { pagination },
+ onPaginationChange: setPagination,
+ manualPagination: true,
+ pageCount,
+ }}
+ containerClassName="border-0 shadow-none [&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/40"
+ footer={DataTableFooter}
+ />
+
+ )}
+
);
}
-
-function StatCard({
- label,
- value,
- icon,
-}: {
- label: string;
- value: number;
- icon: React.ReactNode;
-}) {
- return (
-
-
-
-
- {icon}
-
-
-
- );
-}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts b/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts
index 9adb8eb33..8b738bc5b 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts
@@ -1,180 +1,2 @@
-import type { Freight } from "@edr/types";
-
-export interface BookingRequest {
- id: string;
- reference: string;
- customer: string;
- status: (typeof BOOKING_STATUSES)[number];
- scheduledDate: string;
- totalAmount: number;
- paymentStatus: string;
- contractType: string;
- serviceType: string;
- tradeDirection: string;
- originYard: string;
- destinationYard: string;
- cargoType: string;
- cargoTotalWeightVgm: number;
- isHazardous: boolean;
- paymentCurrency: string;
- priorityScore: number;
- firstMilePickupAddress: string | null;
- lastMileDeliveryAddress: string | null;
- shippingLine: string | null;
- pnrCode: string | null;
- createdBy: string;
- createdAt: string;
- updatedAt: string;
-}
-
-export const BOOKING_STATUSES = [
- "DRAFT",
- "RFQ_SUBMITTED",
- "QUOTATION_SENT",
- "QUOTATION_APPROVED",
- "QUOTATION_REJECTED",
- "PENDING_APPROVAL",
- "APPROVED",
- "SIGNED_CUSTOMER",
- "FULLY_EXECUTED",
- "PAID",
- "IN_TRANSIT",
- "COMPLETED",
- "CANCELLED",
- "PENDING_CONSOLIDATION",
- "CONSOLIDATED",
-] as const;
-
-const customers = [
- "Ethio Cargo Logistics",
- "Djibouti Shipping PLC",
- "Horn of Africa Traders",
- "Addis Freight Forwarders",
- "Red Sea Maritime Services",
- "Dire Dawa Imports Ltd",
- "Awash Agro Industry",
- "Mieso Mineral Exports",
-];
-
-const yards = [
- "Addis Ababa Dry Port",
- "Mojo Inland Container Depot",
- "Dire Dawa Freight Station",
- "Djibouti Port Terminal",
- "Adama Logistics Hub",
- "Awash Cargo Center",
-];
-
-const serviceTypes = ["RAIL", "RAIL_AND_FORWARDING"];
-const tradeDirections = ["EXPORT", "IMPORT", "DOMESTIC"];
-const cargoTypes = ["Containerized", "Bulk", "Liquid", "Refrigerated", "Hazardous"];
-const shippingLines = ["MSC", "CMA CGM", "Maersk", "COSCO", "Hapag-Lloyd", null];
-
-function pick
(arr: T[], index: number): T {
- return arr[index % arr.length];
-}
-
-function randDate(daysAgo: number): string {
- const d = new Date(2026, 4, 28 - daysAgo);
- return d.toISOString();
-}
-
-const now = Date.now();
-
-const INITIAL_REQUESTS: BookingRequest[] = Array.from({ length: 25 }, (_, i) => {
- const statusIndex = i % BOOKING_STATUSES.length;
- const status = BOOKING_STATUSES[statusIndex];
- const customer = pick(customers, i);
-
- return {
- id: String(i + 1),
- reference: `EDR-BK-${String(2026001 + i).slice(-6)}`,
- customer,
- status,
- scheduledDate: new Date(2026, 5, 1 + (i % 28)).toISOString().slice(0, 10),
- totalAmount: 1500 + i * 320 + (i % 7) * 100,
- paymentStatus: status === "PAID" || status === "COMPLETED" ? "PAID" : status === "CANCELLED" ? "REFUNDED" : "PENDING",
- contractType: i % 5 === 0 ? "RENEWAL" : "NEW",
- serviceType: pick(serviceTypes, i),
- tradeDirection: pick(tradeDirections, i),
- originYard: pick(yards, i),
- destinationYard: pick(yards, i + 3),
- cargoType: pick(cargoTypes, i),
- cargoTotalWeightVgm: 10 + ((i * 7) % 90),
- isHazardous: i % 7 === 0,
- paymentCurrency: "USD",
- priorityScore: i % 4 === 0 ? 3 : i % 3 === 0 ? 2 : 1,
- firstMilePickupAddress: i % 3 === 0 ? "Bole Industrial Zone, Addis Ababa" : null,
- lastMileDeliveryAddress: i % 4 === 0 ? "Port Boulevard, Djibouti City" : null,
- shippingLine: pick(shippingLines, i),
- pnrCode: i % 6 === 0 ? `PNR-${202600 + i}` : null,
- createdBy: customer,
- createdAt: randDate(30 - i),
- updatedAt: randDate(2),
- };
-});
-
-export function saveBookingRequestsToStorage(data: BookingRequest[]) {
- if (typeof window !== "undefined" && window.localStorage) {
- localStorage.setItem("edr_backoffice_booking_requests", JSON.stringify(data));
- }
-}
-
-export function getBookingRequestById(id: string): BookingRequest | undefined {
- const requests = getBookingRequests();
- return requests.find((r) => r.id === id);
-}
-
-export function updateBookingRequestStatus(id: string, newStatus: (typeof BOOKING_STATUSES)[number]) {
- const requests = getBookingRequests();
- const idx = requests.findIndex((r) => r.id === id);
- if (idx === -1) return;
- requests[idx] = { ...requests[idx], status: newStatus, updatedAt: new Date().toISOString() };
- saveBookingRequestsToStorage(requests);
-}
-
-export function mapBookingToRequest(booking: Freight.IBooking): BookingRequest {
- return {
- id: booking.id,
- reference: booking.reference,
- customer: booking.customerId,
- status: booking.status as BookingRequest["status"],
- scheduledDate: booking.scheduledDate,
- totalAmount: booking.totalAmount,
- paymentStatus: booking.paymentStatus,
- contractType: booking.contractType,
- serviceType:
- booking.serviceType === "RAIL_ONLY" ? "RAIL" : (booking.serviceType as string),
- tradeDirection: booking.tradeDirection as string,
- originYard: booking.originStation,
- destinationYard: booking.destinationStation,
- cargoType: booking.freightType ?? booking.freightSubtype ?? "",
- cargoTotalWeightVgm: booking.cargoTotalWeightVgm,
- isHazardous: booking.isHazardous,
- paymentCurrency: booking.paymentCurrency,
- priorityScore: booking.priorityScore,
- firstMilePickupAddress: booking.firstMilePickupAddress ?? null,
- lastMileDeliveryAddress: booking.lastMileDeliveryAddress ?? null,
- shippingLine: null,
- pnrCode: null,
- createdBy: booking.customerId,
- createdAt: booking.createdAt,
- updatedAt: booking.updatedAt,
- };
-}
-
-export function getBookingRequests(): BookingRequest[] {
- if (typeof window === "undefined" || !window.localStorage) {
- return INITIAL_REQUESTS;
- }
- const data = localStorage.getItem("edr_backoffice_booking_requests");
- if (!data) {
- localStorage.setItem("edr_backoffice_booking_requests", JSON.stringify(INITIAL_REQUESTS));
- return INITIAL_REQUESTS;
- }
- try {
- return JSON.parse(data);
- } catch {
- return INITIAL_REQUESTS;
- }
-}
+/** @deprecated Use BookingDetail from @/types/booking — kept for gradual migration */
+export type { BookingListRow as BookingRequest } from "@/types/booking";
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/bookings.mock.ts b/apps/edr-freight-web/backoffice/src/pages/bookings/bookings.mock.ts
new file mode 100644
index 000000000..633c7cb32
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/bookings.mock.ts
@@ -0,0 +1,9 @@
+/** Demo portal mock data — booking requests use the live API instead. */
+export interface Booking {
+ id: number | string;
+ customerId: number | string;
+ reference?: string;
+ status?: string;
+}
+
+export const bookings: Booking[] = [];
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
index 7f8f6cfa4..c81f1d6e7 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { Navigate, useLocation, useParams } from "react-router-dom";
import type { ColumnDef } from "@tanstack/react-table";
import { Loader2 } from "lucide-react";
@@ -9,7 +9,6 @@ import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordAct
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
import {
- ruleEngineField,
ruleEngineSurface,
ruleEngineTable,
} from "@/components/ruleEngine/ruleEngineStyles";
@@ -26,6 +25,7 @@ import {
useApprovalChain,
useCargoTypeParentOptions,
useContainerTypeOptions,
+ useLiveRateOptions,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
@@ -41,8 +41,6 @@ import {
DialogDescription,
DialogHeader,
DialogTitle,
- Input,
- Label,
getCoreRowModel,
usePagination,
useReactTable,
@@ -73,9 +71,6 @@ const RuleEngineResourcePage = () => {
const [editing, setEditing] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [chainOpen, setChainOpen] = useState(false);
- const [approveTarget, setApproveTarget] = useState(null);
- const [ceoId, setCeoId] = useState("");
-
const { viewMode, setViewMode } = useRuleEngineViewMode(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
);
@@ -103,33 +98,52 @@ const RuleEngineResourcePage = () => {
);
const editingId = editing?.id ? String(editing.id) : undefined;
+ const usesContainerTypeField = Boolean(
+ config?.formFields.some((f) => f.name === "containerTypeId"),
+ );
+ const usesLiveRateField = Boolean(
+ config?.formFields.some((f) => f.name === "rateId"),
+ );
+
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
- useContainerTypeOptions(config?.slug === "rates");
+ useContainerTypeOptions(
+ config?.slug === "rates",
+ usesContainerTypeField,
+ );
+ const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
+ useLiveRateOptions(usesLiveRateField);
const formFields = useMemo(() => {
if (!config) return [];
- return config.formFields.map((field) =>
- config.slug === "cargo-types" && field.name === "parentGroupId"
- ? {
- ...field,
- options:
- cargoParentOptions ?? [
- { label: "None", value: RULE_ENGINE_SELECT_NONE },
- ],
- }
- : config.slug === "rates" && field.name === "containerTypeId"
- ? {
- ...field,
- options:
- containerTypeOptions ?? [
- { label: "None", value: RULE_ENGINE_SELECT_NONE },
- ],
- }
- : field,
- );
- }, [config, cargoParentOptions, containerTypeOptions]);
+ return config.formFields.map((field) => {
+ if (config.slug === "cargo-types" && field.name === "parentGroupId") {
+ return {
+ ...field,
+ options:
+ cargoParentOptions ?? [
+ { label: "None", value: RULE_ENGINE_SELECT_NONE },
+ ],
+ };
+ }
+ if (field.name === "containerTypeId") {
+ return {
+ ...field,
+ type: "select" as const,
+ options: containerTypeOptions ?? [],
+ };
+ }
+ if (field.name === "rateId") {
+ return {
+ ...field,
+ type: "select" as const,
+ options: liveRateOptions ?? [],
+ };
+ }
+ return field;
+ });
+ }, [config, cargoParentOptions, containerTypeOptions, liveRateOptions]);
const rows = data?.data ?? [];
const meta = data?.meta;
@@ -163,6 +177,13 @@ const RuleEngineResourcePage = () => {
onPaginationChange: setPagination,
});
+ const handleApproveRate = useCallback(
+ (record: RuleEngineRecord) => {
+ approve.mutate(String(record.id));
+ },
+ [approve],
+ );
+
const columns = useMemo((): ColumnDef[] => {
if (!config) return [];
@@ -195,14 +216,14 @@ const RuleEngineResourcePage = () => {
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
}
onSubmitRate={(id) => submit.mutate(id)}
- onApproveRate={setApproveTarget}
+ onApproveRate={handleApproveRate}
/>
),
});
return base;
- }, [config, submit]);
+ }, [config, submit, handleApproveRate]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
@@ -318,7 +339,7 @@ const RuleEngineResourcePage = () => {
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
}
onSubmitRate={(id) => submit.mutate(id)}
- onApproveRate={setApproveTarget}
+ onApproveRate={handleApproveRate}
/>
)}
@@ -337,7 +358,8 @@ const RuleEngineResourcePage = () => {
isSubmitting={create.isPending || update.isPending}
selectOptionsLoading={
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
- (config.slug === "rates" && containerTypeOptionsLoading)
+ (usesContainerTypeField && containerTypeOptionsLoading) ||
+ (usesLiveRateField && liveRateOptionsLoading)
}
onSubmit={handleFormSubmit}
/>
@@ -370,51 +392,6 @@ const RuleEngineResourcePage = () => {
-
!o && setApproveTarget(null)}>
-
-
- Approve rate
- Enter the CEO staff ID to approve this rate.
-
-
-
-
- CEO staff ID
-
- setCeoId(e.target.value)}
- placeholder="UUID"
- className={ruleEngineField.input}
- />
-
-
- setApproveTarget(null)}>
- Cancel
-
- {
- if (!approveTarget) return;
- approve.mutate(
- { id: approveTarget.id, payload: { approvedByCeoId: ceoId.trim() } },
- {
- onSuccess: () => {
- setApproveTarget(null);
- setCeoId("");
- },
- },
- );
- }}
- >
- {approve.isPending ? : "Approve"}
-
-
-
-
-
-
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
index 3fe1b59cb..3ac0286d3 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
@@ -3,7 +3,16 @@ import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export type RuleEngineNavCategory = "configuration" | "rules";
-export type ColumnFormat = "text" | "code" | "boolean" | "activeBadge" | "rateStatus" | "date" | "number";
+export type ColumnFormat =
+ | "text"
+ | "code"
+ | "boolean"
+ | "activeBadge"
+ | "rateStatus"
+ | "date"
+ | "number"
+ | "entityLabel"
+ | "rateLabel";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "select" | "textarea";
@@ -187,7 +196,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "score", label: "Score", type: "number", required: true },
- { name: "conditionCurrency", label: "Condition currency", type: "text", placeholder: "USD (optional)" },
+ {
+ name: "conditionCurrency",
+ label: "Condition currency",
+ type: "select",
+ optional: true,
+ options: [{ label: "Any", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES],
+ placeholder: "Any currency (optional)",
+ },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -226,7 +242,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "triggerCondition", header: "Trigger", accessorKey: "triggerCondition" },
- { id: "rateId", header: "Rate ID", accessorKey: "rateId" },
+ { id: "rateId", header: "Rate", accessorKey: "rate", format: "rateLabel" },
activeColumn,
],
formFields: [
@@ -238,7 +254,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
required: true,
options: SURCHARGE_TRIGGERS,
},
- { name: "rateId", label: "Rate ID", type: "text", required: true, placeholder: "UUID of LIVE rate" },
+ {
+ name: "rateId",
+ label: "Live rate",
+ type: "select",
+ required: true,
+ placeholder: "Select a LIVE rate",
+ },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -249,14 +271,25 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
subtitle: "VGM limits by container and trade direction",
searchPlaceholder: "Search weight limit rules...",
columns: [
- { id: "containerTypeId", header: "Container", accessorKey: "containerTypeId" },
+ {
+ id: "containerType",
+ header: "Container",
+ accessorKey: "containerType",
+ format: "entityLabel",
+ },
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
{ id: "effectiveTo", header: "To", accessorKey: "effectiveTo", format: "date" },
],
formFields: [
- { name: "containerTypeId", label: "Container type ID", type: "text", required: true },
+ {
+ name: "containerTypeId",
+ label: "Container type",
+ type: "select",
+ required: true,
+ placeholder: "Select container type",
+ },
{
name: "tradeDirection",
label: "Trade direction",
@@ -349,7 +382,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "currency", label: "Currency", type: "select", required: true, options: CURRENCIES },
{ name: "rateValue", label: "Rate value", type: "number", required: true },
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
- { name: "proposedByStaffId", label: "Proposed by (staff ID)", type: "text", required: true },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
{ name: "effectiveTo", label: "Effective to", type: "date" },
],
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
index f0d585859..06595e5ca 100644
--- a/apps/edr-freight-web/backoffice/src/services/api.ts
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -1,3 +1,4 @@
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { endpoint } from "@/utils/endpoint";
import type {
CreateFileUploadFieldDto,
@@ -16,7 +17,6 @@ import {
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import {
- ApproveRatePayload,
RuleEngineListResult,
RuleEngineRecord,
RuleEngineResourceSlug,
@@ -27,8 +27,14 @@ import {
ruleEngineService,
RuleEngineListParams,
} from "./ruleEngine/ruleEngine.service";
-import { bookingsService, BookingListFilter } from "./bookings.service";
-import type { Freight, PaginatedResponse } from "@edr/types";
+import {
+ bookingsService,
+ BookingListFilter,
+ type ApproveStepPayload,
+ type PaginatedBookings,
+ type RejectStepPayload,
+} from "./bookings.service";
+import type { BookingDetail } from "@/types/booking";
export const api = {
fileUploadSettings: {
@@ -167,15 +173,21 @@ export const api = {
list: endpoint<
{ resource: RuleEngineResourceSlug; params?: RuleEngineListParams },
RuleEngineListResult
- >("rule-engine", "list", ({ resource, params }) =>
- ruleEngineService.list(resource, params),
+ >(
+ "rule-engine",
+ "list",
+ ({ resource, params }) => ruleEngineService.list(resource, params),
+ ({ resource, params }) => QUERY_KEYS.RULE_ENGINE.list(resource, params),
),
getById: endpoint<
{ resource: RuleEngineResourceSlug; id: string },
RuleEngineRecord
- >("rule-engine", "getById", ({ resource, id }) =>
- ruleEngineService.getById(resource, id),
+ >(
+ "rule-engine",
+ "getById",
+ ({ resource, id }) => ruleEngineService.getById(resource, id),
+ ({ resource, id }) => QUERY_KEYS.RULE_ENGINE.detail(resource, id),
),
create: endpoint<
@@ -209,37 +221,33 @@ export const api = {
({ id }) => ruleEngineService.submitRate(id),
),
- approveRate: endpoint<
- { id: string; payload: ApproveRatePayload },
- RuleEngineRecord
- >("rule-engine", "approveRate", ({ id, payload }) =>
- ruleEngineService.approveRate(id, payload),
+ approveRate: endpoint<{ id: string }, RuleEngineRecord>(
+ "rule-engine",
+ "approveRate",
+ ({ id }) => ruleEngineService.approveRate(id),
),
getApprovalChain: endpoint(
"rule-engine",
"getApprovalChain",
() => ruleEngineService.getApprovalChain(),
+ () => QUERY_KEYS.RULE_ENGINE.chain,
),
},
bookings: {
- list: endpoint<
- { filter?: BookingListFilter },
- PaginatedResponse
- >("bookings", "list", ({ filter }) => bookingsService.list(filter)),
+ list: endpoint<{ filter?: BookingListFilter }, PaginatedBookings>(
+ "bookings",
+ "list",
+ ({ filter }) => bookingsService.list(filter),
+ ({ filter }) => QUERY_KEYS.BOOKINGS.list(filter),
+ ),
- getById: endpoint<{ id: string }, Freight.IBooking>(
+ getById: endpoint<{ id: string }, BookingDetail>(
"bookings",
"getById",
({ id }) => bookingsService.getById(id),
- ),
-
- updateStatus: endpoint<
- { id: string; action: string; reason?: string },
- Freight.IBooking
- >("bookings", "updateStatus", ({ id, action, reason }) =>
- bookingsService.updateStatus(id, { action, reason }),
+ ({ id }) => QUERY_KEYS.BOOKINGS.byId(id),
),
remove: endpoint<{ id: string }, void>(
@@ -247,5 +255,84 @@ export const api = {
"remove",
({ id }) => bookingsService.remove(id),
),
+
+ staffAccept: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "staffAccept",
+ ({ id }) => bookingsService.staffAccept(id),
+ ),
+
+ requestChanges: endpoint<{ id: string; note: string }, BookingDetail>(
+ "bookings",
+ "requestChanges",
+ ({ id, note }) => bookingsService.requestChanges(id, note),
+ ),
+
+ staffReject: endpoint<{ id: string; reason: string }, BookingDetail>(
+ "bookings",
+ "staffReject",
+ ({ id, reason }) => bookingsService.staffReject(id, reason),
+ ),
+
+ approveStep: endpoint(
+ "bookings",
+ "approveStep",
+ (payload) => bookingsService.approveStep(payload),
+ ),
+
+ rejectStep: endpoint(
+ "bookings",
+ "rejectStep",
+ (payload) => bookingsService.rejectStep(payload),
+ ),
+
+ generateContract: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "generateContract",
+ ({ id }) => bookingsService.generateContract(id),
+ ),
+
+ getContractView: endpoint<{ id: string }, import("./bookings.service").ContractView>(
+ "bookings",
+ "getContractView",
+ ({ id }) => bookingsService.getContractView(id),
+ ),
+
+ signContract: endpoint<
+ { id: string } & import("./bookings.service").SignContractPayload,
+ BookingDetail
+ >("bookings", "signContract", ({ id, ...payload }) =>
+ bookingsService.signContract(id, payload),
+ ),
+
+ generatePnr: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "generatePnr",
+ ({ id }) => bookingsService.generatePnr(id),
+ ),
+
+ verifyPayment: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "verifyPayment",
+ ({ id }) => bookingsService.verifyPayment(id),
+ ),
+
+ startTransit: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "startTransit",
+ ({ id }) => bookingsService.startTransit(id),
+ ),
+
+ complete: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "complete",
+ ({ id }) => bookingsService.complete(id),
+ ),
+
+ cancel: endpoint<{ id: string; reason: string }, BookingDetail>(
+ "bookings",
+ "cancel",
+ ({ id, reason }) => bookingsService.cancel(id, reason),
+ ),
},
};
diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts
index e823b2146..fffcdea44 100644
--- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts
@@ -1,51 +1,172 @@
-import type { Freight, PaginatedResponse } from "@edr/types";
-
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
+import type { BookingDetail } from "@/types/booking";
-const BASE = URL_CONSTANTS.BOOKINGS.BASE;
+const B = URL_CONSTANTS.BOOKINGS;
export interface BookingListFilter {
status?: string;
customerId?: string;
- search?: string;
+ freightType?: string;
+ tradeDirection?: string;
+ paymentCurrency?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
+export interface PaginatedBookings {
+ items: BookingDetail[];
+ total: number;
+}
+
+export interface ApproveStepPayload {
+ id: string;
+ stepId: string;
+ requiredRole: string;
+}
+
+export interface RejectStepPayload {
+ id: string;
+ stepId: string;
+ reason: string;
+}
+
+export interface ContractView {
+ bookingId: string;
+ reference: string;
+ status: string;
+ templateKey: string;
+ title: string;
+ html: string;
+ canSignCustomer: boolean;
+ canSignStaff: boolean;
+ hasContractDocument: boolean;
+ signatures: Array<{
+ role: string;
+ signerDisplayName: string;
+ signedAt: string;
+ signatureImageUrl?: string | null;
+ }>;
+}
+
+export interface SignContractPayload {
+ role: "CUSTOMER" | "STAFF";
+ signatureImageBase64: string;
+ signerDisplayName: string;
+ consentText?: string;
+}
+
+async function postBooking(url: string, body?: unknown): Promise {
+ const response = await client.post(url, body ?? {});
+ return unwrap(response.data);
+}
+
export const bookingsService = {
- list: async (
- filter?: BookingListFilter,
- ): Promise> => {
- const response = await client.get>(
- BASE,
- { params: filter },
- );
- return unwrap(response.data);
+ list: async (filter?: BookingListFilter): Promise => {
+ const response = await client.get(B.BASE, {
+ params: filter,
+ });
+ const data = unwrap(response.data);
+ return {
+ items: (data.items ?? []) as BookingDetail[],
+ total: data.total ?? 0,
+ };
},
- getById: async (id: string): Promise => {
- const response = await client.get(
- URL_CONSTANTS.BOOKINGS.BY_ID(id),
- );
- return unwrap(response.data);
- },
-
- updateStatus: async (
- id: string,
- payload: { action: string; reason?: string },
- ): Promise => {
- const response = await client.patch(
- `${URL_CONSTANTS.BOOKINGS.BY_ID(id)}/status`,
- payload,
- );
- return unwrap(response.data);
+ getById: async (id: string): Promise => {
+ const response = await client.get(B.BY_ID(id));
+ return unwrap(response.data) as BookingDetail;
},
remove: async (id: string): Promise => {
- await client.delete(URL_CONSTANTS.BOOKINGS.BY_ID(id));
+ await client.delete(B.BY_ID(id));
},
+
+ staffAccept: (id: string) => postBooking(B.STAFF_ACCEPT(id)),
+
+ requestChanges: (id: string, note: string) =>
+ postBooking(B.STAFF_REQUEST_CHANGES(id), { note }),
+
+ staffReject: (id: string, reason: string) =>
+ postBooking(B.STAFF_REJECT(id), { reason }),
+
+ approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
+ postBooking(B.APPROVE_STEP(id, stepId), { requiredRole }),
+
+ rejectStep: ({ id, stepId, reason }: RejectStepPayload) =>
+ postBooking(B.REJECT_STEP(id, stepId), { reason }),
+
+ generateContract: (id: string) =>
+ postBooking(B.CONTRACT_GENERATE(id)),
+
+ getContractView: async (id: string): Promise => {
+ const response = await client.get(B.CONTRACT_VIEW(id));
+ return unwrap(response.data) as ContractView;
+ },
+
+ downloadContract: async (id: string): Promise => {
+ const response = await client.get(B.CONTRACT_DOWNLOAD(id), {
+ responseType: "blob",
+ });
+ return response.data as Blob;
+ },
+
+ downloadContractDocument: async (id: string): Promise => {
+ const response = await client.get(B.CONTRACT_DOCUMENT(id), {
+ responseType: "blob",
+ });
+ return response.data as Blob;
+ },
+
+ signContract: (id: string, payload: SignContractPayload) =>
+ postBooking(B.CONTRACT_SIGN(id), payload),
+
+ getSummary: async (id: string): Promise<{ summary: string }> => {
+ const response = await client.get<{ summary: string }>(B.SUMMARY(id));
+ return unwrap(response.data);
+ },
+
+ customerSign: (id: string, payload: SignContractPayload) =>
+ postBooking(B.CUSTOMER_SIGN(id), {
+ ...payload,
+ role: "CUSTOMER",
+ }),
+
+ marketingApprove: (id: string, payload: SignContractPayload) =>
+ postBooking(B.MARKETING_APPROVE(id), {
+ ...payload,
+ role: "STAFF",
+ }),
+
+ generatePnr: (id: string) => postBooking(B.PAYMENT_PNR(id)),
+
+ submitPaymentProof: async (id: string, file: File): Promise => {
+ const form = new FormData();
+ form.append("file", file);
+ const response = await client.post(B.PAYMENT_PROOF(id), form, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
+ return unwrap(response.data) as BookingDetail;
+ },
+
+ verifyPayment: (id: string) =>
+ postBooking(B.PAYMENT_VERIFY(id)),
+
+ downloadPaymentRequestLetter: async (id: string): Promise => {
+ const response = await client.get(B.PAYMENT_REQUEST_LETTER(id), {
+ responseType: "blob",
+ });
+ return response.data as Blob;
+ },
+
+ startTransit: (id: string) =>
+ postBooking(B.START_TRANSIT(id)),
+
+ complete: (id: string) => postBooking(B.COMPLETE(id)),
+
+ cancel: (id: string, reason: string) =>
+ postBooking(B.CANCEL(id), { reason }),
};
diff --git a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts
index 65d9476e8..6c5c7f47d 100644
--- a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts
@@ -1,7 +1,6 @@
import { api as client } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
- ApproveRatePayload,
RuleEngineListMeta,
RuleEngineListResult,
RuleEngineRecord,
@@ -143,11 +142,8 @@ export const ruleEngineService = {
return normalizeEntity(response.data);
},
- approveRate: async (
- id: string,
- payload: ApproveRatePayload,
- ): Promise => {
- const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_APPROVE(id), payload);
+ approveRate: async (id: string): Promise => {
+ const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_APPROVE(id));
return normalizeEntity(response.data);
},
diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts
new file mode 100644
index 000000000..caf98f689
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/types/booking.ts
@@ -0,0 +1,126 @@
+/** Mirrors API BOOKING_STATUSES from edr-freight-api booking.entity */
+export const BOOKING_STATUSES = [
+ "DRAFT",
+ "SUBMITTED",
+ "CHANGES_REQUESTED",
+ "PENDING_APPROVAL",
+ "APPROVED_PENDING_SIGNATURE",
+ "APPROVED",
+ "CONTRACT_READY",
+ "SIGNED_CUSTOMER",
+ "FULLY_EXECUTED",
+ "PNR_GENERATED",
+ "PAYMENT_VERIFICATION_IN_PROGRESS",
+ "PAID",
+ "IN_TRANSIT",
+ "COMPLETED",
+ "REJECTED",
+ "CANCELLED",
+ "PENDING_CONSOLIDATION",
+ "CONSOLIDATED",
+] as const;
+
+export type BookingStatus = (typeof BOOKING_STATUSES)[number];
+
+export interface BookingNamedRef {
+ id: string;
+ name?: string;
+ code?: string;
+ label?: string;
+ companyName?: string;
+}
+
+export interface BookingContainerLine {
+ id: string;
+ containerTypeId: string;
+ quantity: number;
+ vgmPerUnitTons: number;
+ containerType?: {
+ id: string;
+ code?: string;
+ label?: string;
+ sizeFt?: number;
+ isReefer?: boolean;
+ };
+}
+
+export interface BookingApprovalStep {
+ id: string;
+ stepOrder: number;
+ requiredRole: string;
+ status: "PENDING" | "APPROVED" | "REJECTED" | "SKIPPED";
+ actionedAt?: string | null;
+ remarks?: string | null;
+}
+
+export interface BookingReviewNote {
+ id: string;
+ note: string;
+ type: string;
+ createdAt: string;
+}
+
+export interface BookingFile {
+ id: string;
+ name: string;
+ mimeType?: string;
+ code?: string;
+}
+
+export interface BookingDetail {
+ id: string;
+ reference: string;
+ customerId: string;
+ status: BookingStatus;
+ scheduledDate: string;
+ totalAmount: number;
+ paymentStatus: string;
+ paymentCurrency: string;
+ contractType: string;
+ freightType: "CONTAINER" | "BULK";
+ tradeDirection: string;
+ cargoTotalWeightVgm: number;
+ isHazardous: boolean;
+ allowConsolidation: boolean;
+ priorityScore: number;
+ pnrCode?: string | null;
+ firstMilePickupAddress?: string | null;
+ lastMileDeliveryAddress?: string | null;
+ equipmentReturn?: string;
+ contractSummary?: string | null;
+ latestChangeRequestNote?: string | null;
+ createdAt: string;
+ updatedAt: string;
+ customer?: BookingNamedRef & { companyName?: string };
+ originYard?: BookingNamedRef;
+ destinationYard?: BookingNamedRef;
+ serviceType?: BookingNamedRef & { code?: string };
+ cargoType?: BookingNamedRef;
+ shippingLine?: BookingNamedRef;
+ bookingContainers?: BookingContainerLine[];
+ approvalSteps?: BookingApprovalStep[];
+ reviewNotes?: BookingReviewNote[];
+ files?: BookingFile[];
+ cargoModifiers?: Array<{
+ id: string;
+ calculatedAmount: number;
+ triggerValue?: number | null;
+ }>;
+}
+
+export interface BookingListRow {
+ id: string;
+ reference: string;
+ customerLabel: string;
+ status: BookingStatus;
+ scheduledDate: string;
+ totalAmount: number;
+ paymentCurrency: string;
+ paymentStatus: string;
+ tradeDirection: string;
+ freightType: string;
+ originLabel: string;
+ destinationLabel: string;
+ priorityScore: number;
+ createdAt: string;
+}
diff --git a/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts b/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts
index 362b21a63..ceede6873 100644
--- a/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts
+++ b/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts
@@ -23,7 +23,3 @@ export interface RuleEngineListResult {
}
export type RuleEngineRecord = Record & { id: string };
-
-export interface ApproveRatePayload {
- approvedByCeoId: string;
-}
diff --git a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
index 0ed1848d7..31748c77d 100644
--- a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
+++ b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
@@ -44,11 +44,19 @@ export function endpoint(
service: string,
action: string,
execute: (input: TInput) => Promise,
+ queryKeyBuilder?: (input: TInput) => readonly unknown[],
) {
- const buildKey = (input?: TInput): readonly unknown[] =>
- input === undefined
+ const buildKey = (input?: TInput): readonly unknown[] => {
+ if (queryKeyBuilder && input !== undefined) {
+ return queryKeyBuilder(input as TInput);
+ }
+ if (queryKeyBuilder && input === undefined) {
+ return queryKeyBuilder(undefined as TInput);
+ }
+ return input === undefined
? [service, action]
: [service, action, input];
+ };
const call = (input: TInput) => execute(input);
diff --git a/apps/edr-freight-web/backoffice/src/utils/queryInvalidation.ts b/apps/edr-freight-web/backoffice/src/utils/queryInvalidation.ts
new file mode 100644
index 000000000..bead8a8a0
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/utils/queryInvalidation.ts
@@ -0,0 +1,56 @@
+import type { QueryClient } from "@tanstack/react-query";
+
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
+import type {
+ RuleEngineListResult,
+ RuleEngineRecord,
+ RuleEngineResourceSlug,
+} from "@/types/rule-engine";
+
+export function invalidateBookings(qc: QueryClient): Promise {
+ return qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
+}
+
+export function invalidateBookingDetail(
+ qc: QueryClient,
+ id: string,
+): Promise {
+ return Promise.all([
+ qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.byId(id) }),
+ invalidateBookings(qc),
+ ]).then(() => undefined);
+}
+
+/** Update a single row in all cached list queries for a rule-engine resource. */
+export function patchRuleEngineListRecord(
+ qc: QueryClient,
+ resource: RuleEngineResourceSlug | string,
+ updated: RuleEngineRecord,
+): void {
+ const updatedId = String(updated.id);
+ qc.setQueriesData>(
+ { queryKey: ["rule-engine", "list", resource] },
+ (old) => {
+ if (!old?.data?.length) return old;
+ const index = old.data.findIndex((row) => String(row.id) === updatedId);
+ if (index === -1) return old;
+ const data = old.data.slice();
+ data[index] = { ...data[index], ...updated };
+ return { ...old, data };
+ },
+ );
+}
+
+/** Invalidate and refetch active rule-engine list queries for a resource. */
+export async function invalidateRuleEngineList(
+ qc: QueryClient,
+ resource: RuleEngineResourceSlug | string,
+): Promise {
+ const queryKey = ["rule-engine", "list", resource] as const;
+ await qc.invalidateQueries({ queryKey });
+ await qc.refetchQueries({ queryKey, type: "active" });
+}
+
+export function invalidateRuleEngineRoot(qc: QueryClient): Promise {
+ return qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.ROOT });
+}
diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx
index 1187422f8..cab3bfd1f 100644
--- a/apps/edr-freight-web/portal/src/App.tsx
+++ b/apps/edr-freight-web/portal/src/App.tsx
@@ -27,6 +27,7 @@ import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import LoginPage from "./pages/accounts/LoginPage";
import MyBookings from "./pages/bookings/MyBookings";
+import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import TrackingPage from "./pages/tracking/TrackingPage";
@@ -102,6 +103,7 @@ const App = () => {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/edr-freight-web/portal/src/components/bookings/ContractSignaturePad.tsx b/apps/edr-freight-web/portal/src/components/bookings/ContractSignaturePad.tsx
new file mode 100644
index 000000000..aacf47858
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/bookings/ContractSignaturePad.tsx
@@ -0,0 +1,105 @@
+import { useEffect, useRef, useState } from "react";
+import { Eraser } from "lucide-react";
+
+import { Button } from "@edr/ui-common";
+import { cn } from "@/lib/utils";
+
+interface ContractSignaturePadProps {
+ onChange: (dataUrl: string | null) => void;
+ className?: string;
+}
+
+export function ContractSignaturePad({
+ onChange,
+ className,
+}: ContractSignaturePadProps) {
+ const canvasRef = useRef(null);
+ const drawing = useRef(false);
+ const [empty, setEmpty] = useState(true);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ const dpr = window.devicePixelRatio || 1;
+ const w = canvas.offsetWidth;
+ const h = canvas.offsetHeight;
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ ctx.scale(dpr, dpr);
+ ctx.strokeStyle = "#111";
+ ctx.lineWidth = 2;
+ ctx.lineCap = "round";
+ }, []);
+
+ const getPos = (e: React.MouseEvent | React.TouchEvent) => {
+ const canvas = canvasRef.current!;
+ const rect = canvas.getBoundingClientRect();
+ if ("touches" in e) {
+ const t = e.touches[0];
+ return { x: t.clientX - rect.left, y: t.clientY - rect.top };
+ }
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
+ };
+
+ const start = (e: React.MouseEvent | React.TouchEvent) => {
+ drawing.current = true;
+ const ctx = canvasRef.current?.getContext("2d");
+ const { x, y } = getPos(e);
+ ctx?.beginPath();
+ ctx?.moveTo(x, y);
+ };
+
+ const move = (e: React.MouseEvent | React.TouchEvent) => {
+ if (!drawing.current) return;
+ const ctx = canvasRef.current?.getContext("2d");
+ const { x, y } = getPos(e);
+ ctx?.lineTo(x, y);
+ ctx?.stroke();
+ setEmpty(false);
+ onChange(canvasRef.current?.toDataURL("image/png") ?? null);
+ };
+
+ const end = () => {
+ drawing.current = false;
+ };
+
+ const clear = () => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ setEmpty(true);
+ onChange(null);
+ };
+
+ return (
+
+
+
+
+
+
Draw your signature above
+
+
+ Clear
+
+
+ {empty && (
+
Signature is required before confirming.
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts
index 84db5e2c2..dcfd7e421 100644
--- a/apps/edr-freight-web/portal/src/constants/URLS.ts
+++ b/apps/edr-freight-web/portal/src/constants/URLS.ts
@@ -82,6 +82,10 @@ export const URL_CONSTANTS = {
BOOKINGS: {
BASE: "/bookings",
BY_ID: (id: string | number) => `/bookings/${id}`,
+ CONTRACT_VIEW: (id: string) => `/bookings/${id}/contract/view`,
+ CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
+ CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`,
+ CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
CANCEL: (id: string | number) => `/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/bookings/${id}/confirm`,
},
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx
new file mode 100644
index 000000000..a8742c9b1
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx
@@ -0,0 +1,165 @@
+import { useCallback, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ ArrowLeft,
+ Download,
+ FileSignature,
+ Loader2,
+ Printer,
+} from "lucide-react";
+import toast from "react-hot-toast";
+
+import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
+import {
+ bookingsService,
+ type SignContractPayload,
+} from "@/services/bookings.service";
+import { Button } from "@edr/ui-common";
+
+export default function BookingContractPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const qc = useQueryClient();
+ const [signOpen, setSignOpen] = useState(false);
+ const [signerName, setSignerName] = useState("");
+ const [signatureData, setSignatureData] = useState(null);
+
+ const { data, isLoading, isError, refetch } = useQuery({
+ queryKey: ["booking-contract-view", id],
+ queryFn: () => bookingsService.getContractView(id!),
+ enabled: Boolean(id),
+ });
+
+ const signMutation = useMutation({
+ mutationFn: (payload: SignContractPayload) =>
+ bookingsService.signContract(id!, payload),
+ onSuccess: () => {
+ toast.success("Contract signed successfully");
+ setSignOpen(false);
+ void refetch();
+ qc.invalidateQueries({ queryKey: ["booking", id] });
+ },
+ onError: () => toast.error("Failed to sign contract"),
+ });
+
+ const downloadPdf = useCallback(async () => {
+ if (!id) return;
+ try {
+ const blob = await bookingsService.downloadContractDocument(id);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `contract-${data?.reference ?? id}.pdf`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch {
+ toast.error("PDF not ready yet. Contact EDR if this persists.");
+ }
+ }, [id, data?.reference]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+
Could not load contract.
+
navigate(-1)}>
+ Go back
+
+
+ );
+ }
+
+ const bodyHtml = extractBodyHtml(data.html);
+
+ return (
+
+
+
+
navigate(`/bookings/${id}`)}>
+
+ Back to booking
+
+
+
window.print()}>
+
+ Print
+
+
+
+ PDF
+
+ {data.canSignCustomer && (
+
setSignOpen(true)}>
+
+ Sign contract
+
+ )}
+
+
+
+
+
+
+ {signOpen && (
+
+
+
Sign contract
+
+ {data.reference} — your signature will be stored securely.
+
+
+
+ Full name
+
+ setSignerName(e.target.value)}
+ />
+
+
+
+ setSignOpen(false)}>
+ Cancel
+
+
+ signMutation.mutate({
+ role: "CUSTOMER",
+ signatureImageBase64: signatureData!,
+ signerDisplayName: signerName.trim(),
+ consentText: "I agree to the terms of this contract.",
+ })
+ }
+ >
+ Confirm signature
+
+
+
+
+ )}
+
+ );
+}
+
+function extractBodyHtml(fullHtml: string): string {
+ const match = fullHtml.match(/]*>([\s\S]*)<\/body>/i);
+ return match ? match[1] : fullHtml;
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx
index 6a4f13227..f8ea6c496 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx
@@ -152,6 +152,27 @@ export default function BookingDetailPage() {
+ {(booking.status === "CONFIRMED" || booking.status === "IN_TRANSIT") && (
+
+
+
+
Contract ready
+
+ Review the agreement and apply your digital signature.
+
+
+ navigate(`/bookings/${booking.id}/contract`)}
+ >
+
+ View & sign contract
+
+
+
+ )}
+
diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts
index 6ac93a742..2dc1ba235 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -1,9 +1,37 @@
import type { Freight, PaginatedResponse } from "@edr/types";
+import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
+const B = URL_CONSTANTS.BOOKINGS;
+
export type CreateBookingPayload = Freight.CreateBookingDto;
+export interface ContractView {
+ bookingId: string;
+ reference: string;
+ status: string;
+ templateKey: string;
+ title: string;
+ html: string;
+ canSignCustomer: boolean;
+ canSignStaff: boolean;
+ hasContractDocument: boolean;
+ signatures: Array<{
+ role: string;
+ signerDisplayName: string;
+ signedAt: string;
+ signatureImageUrl?: string | null;
+ }>;
+}
+
+export interface SignContractPayload {
+ role: "CUSTOMER" | "STAFF";
+ signatureImageBase64: string;
+ signerDisplayName: string;
+ consentText?: string;
+}
+
export const bookingsService = {
list: async (): Promise> => {
const { data } = await client.get("/api/bookings");
@@ -24,4 +52,24 @@ export const bookingsService = {
remove: async (id: string): Promise => {
await client.delete(`/api/bookings/${id}`);
},
+
+ getContractView: async (id: string): Promise => {
+ const { data } = await client.get(B.CONTRACT_VIEW(id));
+ return data.data ?? data;
+ },
+
+ downloadContractDocument: async (id: string): Promise => {
+ const { data } = await client.get(B.CONTRACT_DOCUMENT(id), {
+ responseType: "blob",
+ });
+ return data;
+ },
+
+ signContract: async (
+ id: string,
+ payload: SignContractPayload,
+ ): Promise => {
+ const { data } = await client.post(B.CONTRACT_SIGN(id), payload);
+ return data.data ?? data;
+ },
};
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts
index 2dce17419..85c2f1a3b 100644
--- a/packages/types/src/freight/index.ts
+++ b/packages/types/src/freight/index.ts
@@ -27,6 +27,11 @@ export enum CalculationMethod {
PERCENTAGE = 'PERCENTAGE',
}
+export enum FreightType {
+ Container = 'CONTAINER',
+ Bulk = 'BULK',
+}
+
export enum BookingStatus {
Draft = "DRAFT",
Confirmed = "CONFIRMED",
@@ -176,7 +181,7 @@ export interface IBooking extends BaseEntity {
destinationStation: string;
cargoTotalWeightVgm: number;
- freightType: "BULK" | "BREAK_BULK";
+ freightType: FreightType;
freightSubtype?: string | null;
isHazardous: boolean;
@@ -294,7 +299,8 @@ export interface CreateBookingDto {
originYardId: string;
destinationYardId: string;
tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC";
- cargoTypeId: string;
+ freightType: FreightType;
+ cargoTypeId?: string;
cargoFreeText?: string;
shippingLineId?: string;
cargoTotalWeightVgm: number;
@@ -304,6 +310,6 @@ export interface CreateBookingDto {
startDate?: string;
endDate?: string;
financialTerms?: string;
- containers: CreateBookingContainerDto[];
+ containers?: CreateBookingContainerDto[];
allowConsolidation?: boolean;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b9b737e64..dc317173c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -92,12 +92,18 @@ importers:
dotenv:
specifier: ^17.4.2
version: 17.4.2
+ handlebars:
+ specifier: ^4.7.9
+ version: 4.7.9
minio:
specifier: 7.1.3
version: 7.1.3
pg:
specifier: ^8.13.0
version: 8.21.0
+ puppeteer:
+ specifier: ^24.2.0
+ version: 24.43.1(typescript@5.9.3)
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -2192,6 +2198,11 @@ packages:
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
+ '@puppeteer/browsers@2.13.2':
+ resolution: {integrity: sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -3622,6 +3633,9 @@ packages:
'@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
+ '@tootallnate/quickjs-emscripten@0.23.0':
+ resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
+
'@tria-plc/api-common@0.1.4':
resolution: {integrity: sha512-lm9esp5PDxUyggqxYXbx2CQhZKwtcAaAcGFuuUURw3u5DlzGUNwMOFkQkDLM/fbgN+33+s1RKC7UynnG9NZFww==, tarball: https://npm.pkg.github.com/download/@tria-plc/api-common/0.1.4/de160e4bb61a882efd9c877b4614028f1ffd7cb8}
peerDependencies:
@@ -3903,6 +3917,9 @@ packages:
'@types/yargs@17.0.35':
resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
+ '@types/yauzl@2.10.3':
+ resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
+
'@typescript-eslint/eslint-plugin@8.59.4':
resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4658,6 +4675,10 @@ packages:
resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==}
engines: {node: '>=0.10.0'}
+ ast-types@0.13.4:
+ resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
+ engines: {node: '>=4'}
+
ast-types@0.16.1:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'}
@@ -4698,6 +4719,14 @@ packages:
axios@1.16.1:
resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==}
+ b4a@1.8.1:
+ resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
+ peerDependencies:
+ react-native-b4a: '*'
+ peerDependenciesMeta:
+ react-native-b4a:
+ optional: true
+
babel-jest@29.7.0:
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -4734,6 +4763,47 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
+ bare-events@2.9.1:
+ resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==}
+ peerDependencies:
+ bare-abort-controller: '*'
+ peerDependenciesMeta:
+ bare-abort-controller:
+ optional: true
+
+ bare-fs@4.7.2:
+ resolution: {integrity: sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==}
+ engines: {bare: '>=1.16.0'}
+ peerDependencies:
+ bare-buffer: '*'
+ peerDependenciesMeta:
+ bare-buffer:
+ optional: true
+
+ bare-os@3.9.1:
+ resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==}
+ engines: {bare: '>=1.14.0'}
+
+ bare-path@3.0.1:
+ resolution: {integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==}
+
+ bare-stream@2.13.1:
+ resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==}
+ peerDependencies:
+ bare-abort-controller: '*'
+ bare-buffer: '*'
+ bare-events: '*'
+ peerDependenciesMeta:
+ bare-abort-controller:
+ optional: true
+ bare-buffer:
+ optional: true
+ bare-events:
+ optional: true
+
+ bare-url@2.4.4:
+ resolution: {integrity: sha512-zbQJi2YQUe3SrX19TItQ8DoPj9E1i5rrdE9iHV4PhUif1GodNRSe85lavVGbmU7P4M8579EQi4akGFuhCATWaQ==}
+
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
@@ -4754,6 +4824,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ basic-ftp@5.3.1:
+ resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==}
+ engines: {node: '>=10.0.0'}
+
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -4957,6 +5031,11 @@ packages:
resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==}
engines: {node: '>=6.0'}
+ chromium-bidi@14.0.0:
+ resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==}
+ peerDependencies:
+ devtools-protocol: '*'
+
ci-info@3.9.0:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
engines: {node: '>=8'}
@@ -5317,6 +5396,10 @@ packages:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
+ data-uri-to-buffer@6.0.2:
+ resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
+ engines: {node: '>= 14'}
+
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -5458,6 +5541,10 @@ packages:
resolution: {integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==}
engines: {node: '>=0.10.0'}
+ degenerator@5.0.1:
+ resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
+ engines: {node: '>= 14'}
+
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@@ -5487,6 +5574,9 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+ devtools-protocol@0.0.1608973:
+ resolution: {integrity: sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==}
+
dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
@@ -5720,6 +5810,11 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
+ escodegen@2.1.0:
+ resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
+ engines: {node: '>=6.0'}
+ hasBin: true
+
eslint-config-prettier@9.1.2:
resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==}
hasBin: true
@@ -5852,6 +5947,9 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+ events-universal@1.0.1:
+ resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
+
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@@ -5918,6 +6016,11 @@ packages:
resolution: {integrity: sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==}
engines: {node: '>=0.10.0'}
+ extract-zip@2.0.1:
+ resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
+ engines: {node: '>= 10.17.0'}
+ hasBin: true
+
falsey@0.3.2:
resolution: {integrity: sha512-lxEuefF5MBIVDmE6XeqCdM4BWk1+vYmGZtkbKZ/VFcg6uBBw6fXNEbWmxCjDdQlFc9hy450nkiWwM3VAW6G1qg==}
engines: {node: '>=0.10.0'}
@@ -5933,6 +6036,9 @@ packages:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
+ fast-fifo@1.3.2:
+ resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
+
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -5971,6 +6077,9 @@ packages:
fb-watchman@2.0.2:
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
+ fd-slicer@1.1.0:
+ resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -6222,6 +6331,10 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
+ get-stream@5.2.0:
+ resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
+ engines: {node: '>=8'}
+
get-stream@6.0.1:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
@@ -6238,6 +6351,10 @@ packages:
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
engines: {node: '>= 0.4'}
+ get-uri@6.0.5:
+ resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==}
+ engines: {node: '>= 14'}
+
get-value@2.0.6:
resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==}
engines: {node: '>=0.10.0'}
@@ -7458,6 +7575,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ lru-cache@7.18.3:
+ resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
+ engines: {node: '>=12'}
+
lucide-react@0.513.0:
resolution: {integrity: sha512-CJZKq2g8Y8yN4Aq002GahSXbG2JpFv9kXwyiOAMvUBv7pxeOFHUWKB0mO7MiY4ZVFCV4aNjv2BJFq/z3DgKPQg==}
peerDependencies:
@@ -7634,6 +7755,9 @@ packages:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
+ mitt@3.0.1:
+ resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
+
mixin-deep@1.3.2:
resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==}
engines: {node: '>=0.10.0'}
@@ -7731,6 +7855,10 @@ packages:
'@nestjs/common': '>=9.0.0'
'@nestjs/core': '>=9.0.0'
+ netmask@2.1.1:
+ resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==}
+ engines: {node: '>= 0.4.0'}
+
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
@@ -7952,6 +8080,14 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
+ pac-proxy-agent@7.2.0:
+ resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==}
+ engines: {node: '>= 14'}
+
+ pac-resolver@7.0.1:
+ resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==}
+ engines: {node: '>= 14'}
+
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -8070,6 +8206,9 @@ packages:
resolution: {integrity: sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==}
engines: {node: '>=14.16'}
+ pend@1.2.0:
+ resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
+
perfect-freehand@1.2.3:
resolution: {integrity: sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==}
@@ -8260,6 +8399,10 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
+ progress@2.0.3:
+ resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
+ engines: {node: '>=0.4.0'}
+
promise-breaker@6.0.0:
resolution: {integrity: sha512-BthzO9yTPswGf7etOBiHCVuugs2N01/Q/94dIPls48z2zCmrnDptUUZzfIb+41xq0MnYZ/BzmOd6ikDR4ibNZA==}
@@ -8310,9 +8453,16 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
+ proxy-agent@6.5.0:
+ resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==}
+ engines: {node: '>= 14'}
+
proxy-compare@3.0.1:
resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==}
+ proxy-from-env@1.1.0:
+ resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
@@ -8320,6 +8470,9 @@ packages:
proxy-memoize@3.0.1:
resolution: {integrity: sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==}
+ pump@3.0.4:
+ resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
+
punycode@1.4.1:
resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==}
@@ -8327,6 +8480,15 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ puppeteer-core@24.43.1:
+ resolution: {integrity: sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==}
+ engines: {node: '>=18'}
+
+ puppeteer@24.43.1:
+ resolution: {integrity: sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
@@ -8977,6 +9139,10 @@ packages:
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
engines: {node: '>=18'}
+ smart-buffer@4.2.0:
+ resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
+ engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
+
smob@1.6.2:
resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==}
engines: {node: '>=20.0.0'}
@@ -9001,6 +9167,14 @@ packages:
resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==}
engines: {node: '>=10.0.0'}
+ socks-proxy-agent@8.0.5:
+ resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
+ engines: {node: '>= 14'}
+
+ socks@2.8.9:
+ resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
+ engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
+
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
@@ -9098,6 +9272,9 @@ packages:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
+ streamx@2.26.0:
+ resolution: {integrity: sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==}
+
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
@@ -9284,15 +9461,24 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
+ tar-fs@3.1.2:
+ resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==}
+
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
+ tar-stream@3.2.0:
+ resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
+
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+ teex@1.0.1:
+ resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
+
terser-webpack-plugin@5.6.0:
resolution: {integrity: sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==}
engines: {node: '>= 10.13.0'}
@@ -9351,6 +9537,9 @@ packages:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
engines: {node: '>=8'}
+ text-decoder@1.2.7:
+ resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
+
text-extensions@2.4.0:
resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==}
engines: {node: '>=8'}
@@ -9629,6 +9818,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
+ typed-query-selector@2.12.2:
+ resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==}
+
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
@@ -9988,6 +10180,9 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
+ webdriver-bidi-protocol@0.4.1:
+ resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==}
+
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -10197,6 +10392,9 @@ packages:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
+ yauzl@2.10.0:
+ resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
+
year@0.2.1:
resolution: {integrity: sha512-9GnJUZ0QM4OgXuOzsKNzTJ5EOkums1Xc+3YQXp+Q+UxFjf7zLucp9dQ8QMIft0Szs1E1hUiXFim1OYfEKFq97w==}
engines: {node: '>=0.8'}
@@ -11965,6 +12163,21 @@ snapshots:
'@popperjs/core@2.11.8': {}
+ '@puppeteer/browsers@2.13.2':
+ dependencies:
+ debug: 4.4.3
+ extract-zip: 2.0.1
+ progress: 2.0.3
+ proxy-agent: 6.5.0
+ semver: 7.8.1
+ tar-fs: 3.1.2
+ yargs: 17.7.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - react-native-b4a
+ - supports-color
+
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -13476,6 +13689,8 @@ snapshots:
'@tokenizer/token@0.3.0': {}
+ '@tootallnate/quickjs-emscripten@0.23.0': {}
+
'@tria-plc/api-common@0.1.4(kw56ayyn7pbkaoqn2kt3fjycd4)':
dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.1)(rxjs@7.8.2)
@@ -13925,6 +14140,11 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
+ '@types/yauzl@2.10.3':
+ dependencies:
+ '@types/node': 20.19.41
+ optional: true
+
'@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -15142,6 +15362,10 @@ snapshots:
assign-symbols@1.0.0: {}
+ ast-types@0.13.4:
+ dependencies:
+ tslib: 2.8.1
+
ast-types@0.16.1:
dependencies:
tslib: 2.8.1
@@ -15183,6 +15407,8 @@ snapshots:
- debug
- supports-color
+ b4a@1.8.1: {}
+
babel-jest@29.7.0(@babel/core@7.29.0):
dependencies:
'@babel/core': 7.29.0
@@ -15248,6 +15474,38 @@ snapshots:
balanced-match@4.0.4: {}
+ bare-events@2.9.1: {}
+
+ bare-fs@4.7.2:
+ dependencies:
+ bare-events: 2.9.1
+ bare-path: 3.0.1
+ bare-stream: 2.13.1(bare-events@2.9.1)
+ bare-url: 2.4.4
+ fast-fifo: 1.3.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
+ bare-os@3.9.1: {}
+
+ bare-path@3.0.1:
+ dependencies:
+ bare-os: 3.9.1
+
+ bare-stream@2.13.1(bare-events@2.9.1):
+ dependencies:
+ streamx: 2.26.0
+ teex: 1.0.1
+ optionalDependencies:
+ bare-events: 2.9.1
+ transitivePeerDependencies:
+ - react-native-b4a
+
+ bare-url@2.4.4:
+ dependencies:
+ bare-path: 3.0.1
+
base64-arraybuffer@1.0.2: {}
base64-js@0.0.8: {}
@@ -15266,6 +15524,8 @@ snapshots:
baseline-browser-mapping@2.10.32: {}
+ basic-ftp@5.3.1: {}
+
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -15513,6 +15773,12 @@ snapshots:
chrome-trace-event@1.0.4: {}
+ chromium-bidi@14.0.0(devtools-protocol@0.0.1608973):
+ dependencies:
+ devtools-protocol: 0.0.1608973
+ mitt: 3.0.1
+ zod: 3.25.76
+
ci-info@3.9.0: {}
cjs-module-lexer@1.4.3: {}
@@ -15861,6 +16127,8 @@ snapshots:
data-uri-to-buffer@4.0.1: {}
+ data-uri-to-buffer@6.0.2: {}
+
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0
@@ -15979,6 +16247,12 @@ snapshots:
is-descriptor: 1.0.4
isobject: 3.0.1
+ degenerator@5.0.1:
+ dependencies:
+ ast-types: 0.13.4
+ escodegen: 2.1.0
+ esprima: 4.0.1
+
delayed-stream@1.0.0: {}
delegates@1.0.0:
@@ -15996,6 +16270,8 @@ snapshots:
detect-node-es@1.1.0: {}
+ devtools-protocol@0.0.1608973: {}
+
dezalgo@1.0.4:
dependencies:
asap: 2.0.6
@@ -16304,6 +16580,14 @@ snapshots:
escape-string-regexp@4.0.0: {}
+ escodegen@2.1.0:
+ dependencies:
+ esprima: 4.0.1
+ estraverse: 5.3.0
+ esutils: 2.0.3
+ optionalDependencies:
+ source-map: 0.6.1
+
eslint-config-prettier@9.1.2(eslint@8.57.1):
dependencies:
eslint: 8.57.1
@@ -16480,6 +16764,12 @@ snapshots:
eventemitter3@5.0.4: {}
+ events-universal@1.0.1:
+ dependencies:
+ bare-events: 2.9.1
+ transitivePeerDependencies:
+ - bare-abort-controller
+
events@3.3.0: {}
eventsource-parser@3.0.8: {}
@@ -16623,6 +16913,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ extract-zip@2.0.1:
+ dependencies:
+ debug: 4.4.3
+ get-stream: 5.2.0
+ yauzl: 2.10.0
+ optionalDependencies:
+ '@types/yauzl': 2.10.3
+ transitivePeerDependencies:
+ - supports-color
+
falsey@0.3.2:
dependencies:
kind-of: 5.1.0
@@ -16636,6 +16936,8 @@ snapshots:
fast-equals@5.4.0: {}
+ fast-fifo@1.3.2: {}
+
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -16680,6 +16982,10 @@ snapshots:
dependencies:
bser: 2.1.1
+ fd-slicer@1.1.0:
+ dependencies:
+ pend: 1.2.0
+
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -16956,6 +17262,10 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
+ get-stream@5.2.0:
+ dependencies:
+ pump: 3.0.4
+
get-stream@6.0.1: {}
get-stream@8.0.1: {}
@@ -16971,6 +17281,14 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
+ get-uri@6.0.5:
+ dependencies:
+ basic-ftp: 5.3.1
+ data-uri-to-buffer: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
get-value@2.0.6: {}
git-raw-commits@4.0.0:
@@ -18364,6 +18682,8 @@ snapshots:
dependencies:
yallist: 3.1.1
+ lru-cache@7.18.3: {}
+
lucide-react@0.513.0(react@19.2.6):
dependencies:
react: 19.2.6
@@ -18530,6 +18850,8 @@ snapshots:
yallist: 4.0.0
optional: true
+ mitt@3.0.1: {}
+
mixin-deep@1.3.2:
dependencies:
for-in: 1.0.2
@@ -18644,6 +18966,8 @@ snapshots:
reflect-metadata: 0.1.14
rxjs: 7.8.2
+ netmask@2.1.1: {}
+
next-themes@0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
react: 19.2.6
@@ -18892,6 +19216,24 @@ snapshots:
p-try@2.2.0: {}
+ pac-proxy-agent@7.2.0:
+ dependencies:
+ '@tootallnate/quickjs-emscripten': 0.23.0
+ agent-base: 7.1.4
+ debug: 4.4.3
+ get-uri: 6.0.5
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ pac-resolver: 7.0.1
+ socks-proxy-agent: 8.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ pac-resolver@7.0.1:
+ dependencies:
+ degenerator: 5.0.1
+ netmask: 2.1.1
+
package-json-from-dist@1.0.1: {}
pako@0.2.9: {}
@@ -18990,6 +19332,8 @@ snapshots:
peek-readable@5.4.2: {}
+ pend@1.2.0: {}
+
perfect-freehand@1.2.3: {}
performance-now@2.1.0:
@@ -19135,6 +19479,8 @@ snapshots:
process@0.11.10: {}
+ progress@2.0.3: {}
+
promise-breaker@6.0.0: {}
prompts@2.4.2:
@@ -19222,18 +19568,72 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
+ proxy-agent@6.5.0:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ lru-cache: 7.18.3
+ pac-proxy-agent: 7.2.0
+ proxy-from-env: 1.1.0
+ socks-proxy-agent: 8.0.5
+ transitivePeerDependencies:
+ - supports-color
+
proxy-compare@3.0.1: {}
+ proxy-from-env@1.1.0: {}
+
proxy-from-env@2.1.0: {}
proxy-memoize@3.0.1:
dependencies:
proxy-compare: 3.0.1
+ pump@3.0.4:
+ dependencies:
+ end-of-stream: 1.4.5
+ once: 1.4.0
+
punycode@1.4.1: {}
punycode@2.3.1: {}
+ puppeteer-core@24.43.1:
+ dependencies:
+ '@puppeteer/browsers': 2.13.2
+ chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
+ debug: 4.4.3
+ devtools-protocol: 0.0.1608973
+ typed-query-selector: 2.12.2
+ webdriver-bidi-protocol: 0.4.1
+ ws: 8.20.1
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - bufferutil
+ - react-native-b4a
+ - supports-color
+ - utf-8-validate
+
+ puppeteer@24.43.1(typescript@5.9.3):
+ dependencies:
+ '@puppeteer/browsers': 2.13.2
+ chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
+ cosmiconfig: 9.0.1(typescript@5.9.3)
+ devtools-protocol: 0.0.1608973
+ puppeteer-core: 24.43.1
+ typed-query-selector: 2.12.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - bufferutil
+ - react-native-b4a
+ - supports-color
+ - typescript
+ - utf-8-validate
+
pure-rand@6.1.0: {}
qrcode@1.5.4:
@@ -20067,6 +20467,8 @@ snapshots:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
+ smart-buffer@4.2.0: {}
+
smob@1.6.2: {}
snapdragon-node@2.1.1:
@@ -20110,6 +20512,19 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ socks-proxy-agent@8.0.5:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ socks: 2.8.9
+ transitivePeerDependencies:
+ - supports-color
+
+ socks@2.8.9:
+ dependencies:
+ ip-address: 10.2.0
+ smart-buffer: 4.2.0
+
sonner@2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
react: 19.2.6
@@ -20188,6 +20603,15 @@ snapshots:
streamsearch@1.1.0: {}
+ streamx@2.26.0:
+ dependencies:
+ events-universal: 1.0.1
+ fast-fifo: 1.3.2
+ text-decoder: 1.2.7
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
strict-event-emitter@0.5.1: {}
strict-uri-encode@2.0.0: {}
@@ -20415,6 +20839,18 @@ snapshots:
tapable@2.3.3: {}
+ tar-fs@3.1.2:
+ dependencies:
+ pump: 3.0.4
+ tar-stream: 3.2.0
+ optionalDependencies:
+ bare-fs: 4.7.2
+ bare-path: 3.0.1
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - react-native-b4a
+
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
@@ -20423,6 +20859,17 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
+ tar-stream@3.2.0:
+ dependencies:
+ b4a: 1.8.1
+ bare-fs: 4.7.2
+ fast-fifo: 1.3.2
+ streamx: 2.26.0
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - react-native-b4a
+
tar@6.2.1:
dependencies:
chownr: 2.0.0
@@ -20433,6 +20880,13 @@ snapshots:
yallist: 4.0.0
optional: true
+ teex@1.0.1:
+ dependencies:
+ streamx: 2.26.0
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
terser-webpack-plugin@5.6.0(webpack@5.106.0):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -20470,6 +20924,12 @@ snapshots:
glob: 7.2.3
minimatch: 3.1.5
+ text-decoder@1.2.7:
+ dependencies:
+ b4a: 1.8.1
+ transitivePeerDependencies:
+ - react-native-b4a
+
text-extensions@2.4.0: {}
text-segmentation@1.0.3:
@@ -20759,6 +21219,8 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
+ typed-query-selector@2.12.2: {}
+
typedarray@0.0.6: {}
typeof-article@0.1.1:
@@ -21093,6 +21555,8 @@ snapshots:
web-streams-polyfill@3.3.3: {}
+ webdriver-bidi-protocol@0.4.1: {}
+
webidl-conversions@3.0.1: {}
webidl-conversions@7.0.0: {}
@@ -21347,6 +21811,11 @@ snapshots:
y18n: 5.0.8
yargs-parser: 22.0.0
+ yauzl@2.10.0:
+ dependencies:
+ buffer-crc32: 0.2.13
+ fd-slicer: 1.1.0
+
year@0.2.1: {}
yn@3.1.1: {}
From 9d12d334d472158237f1543d9a98f2fdca14e6d4 Mon Sep 17 00:00:00 2001
From: ghost2023
Date: Thu, 4 Jun 2026 14:16:48 +0300
Subject: [PATCH 04/25] fix: key
---
.../backoffice/src/hooks/rule-engine/useRuleEngine.ts | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
index 858975a41..a8f2536b9 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
@@ -76,9 +76,7 @@ export const useContainerTypeOptions = (
enabled = true,
) =>
useQuery({
- queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("container-types", {
- includeNone,
- }),
+ queryKey: api.ruleEngine.list.queryKey(),
queryFn: () =>
api.ruleEngine.list.call({
resource: "container-types",
From d61c3813cb9fcc5a07a58eee725f3a18040a5cc4 Mon Sep 17 00:00:00 2001
From: ghost2023
Date: Thu, 4 Jun 2026 14:17:33 +0300
Subject: [PATCH 05/25] feat(seed): Add file upload settings seeder for company
onboarding
---
apps/edr-freight-api/src/app.module.ts | 5 +-
.../src/seed/file-upload-settings.seeder.ts | 125 ++++++++++++++++++
2 files changed, 129 insertions(+), 1 deletion(-)
create mode 100644 apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index a4ecf321e..580e77e0c 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -30,6 +30,7 @@ import {
} from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
+import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
@Module({
imports: [
@@ -71,18 +72,20 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder";
BackofficeModule,
DemoPermissionsModule,
],
- providers: [EdrOrgSeeder, DemoUsersSeeder],
+ providers: [EdrOrgSeeder, DemoUsersSeeder, FileUploadSettingsSeeder],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
+ private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
) { }
async onApplicationBootstrap() {
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
+ await this.fileUploadSettingsSeeder.run();
}
}
diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
new file mode 100644
index 000000000..2ef1f79f0
--- /dev/null
+++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
@@ -0,0 +1,125 @@
+import { Injectable, Logger } from "@nestjs/common";
+import { DataSource } from "typeorm";
+
+import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
+import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
+
+const COMPANY_ONBOARDING_DOCUMENTS = [
+ {
+ code: "company_onboarding_documents_customer",
+ label: "Customer onboarding documents",
+ entity: "customer",
+ },
+ {
+ code: "company_onboarding_documents_forwarder",
+ label: "Forwarder onboarding documents",
+ entity: "other",
+ },
+ {
+ code: "company_onboarding_documents_transporter",
+ label: "Transporter onboarding documents",
+ entity: "other",
+ },
+ {
+ code: "company_onboarding_documents_forwarder_dj",
+ label: "Djibouti forwarder onboarding documents",
+ entity: "other",
+ },
+] as const;
+
+const COMPANY_ONBOARDING_DESCRIPTION =
+ "Required documents for external company onboarding. The same set applies to customers, forwarders, transporters, and brokers.";
+
+const COMPANY_ONBOARDING_FIELDS = [
+ {
+ fileKey: "business_license",
+ fileLabel: "Business License / Trade License",
+ helpText: "Verified against the government trade system during registration.",
+ isRequired: true,
+ isMultiple: false,
+ maxFiles: 1,
+ allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
+ maxSizeMb: 10,
+ displayOrder: 1,
+ },
+ {
+ fileKey: "tin_certificate",
+ fileLabel: "TIN Certificate",
+ helpText: "Verified against the TIN registry during registration.",
+ isRequired: true,
+ isMultiple: false,
+ maxFiles: 1,
+ allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
+ maxSizeMb: 10,
+ displayOrder: 2,
+ },
+ {
+ fileKey: "national_id_passport",
+ fileLabel: "National ID / Passport",
+ helpText: "Verified against the National ID API during registration.",
+ isRequired: true,
+ isMultiple: false,
+ maxFiles: 1,
+ allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
+ maxSizeMb: 10,
+ displayOrder: 3,
+ },
+] as const;
+
+@Injectable()
+export class FileUploadSettingsSeeder {
+ private readonly logger = new Logger(FileUploadSettingsSeeder.name);
+
+ constructor(private readonly dataSource: DataSource) {}
+
+ async run() {
+ await this.dataSource.transaction(async (manager) => {
+ const settingRepository = manager.getRepository(FileUploadSetting);
+ const fieldRepository = manager.getRepository(FileUploadField);
+
+ for (const documentSetting of COMPANY_ONBOARDING_DOCUMENTS) {
+ await settingRepository.upsert(
+ {
+ code: documentSetting.code,
+ label: documentSetting.label,
+ description: COMPANY_ONBOARDING_DESCRIPTION,
+ entity: documentSetting.entity,
+ },
+ {
+ conflictPaths: { code: true },
+ },
+ );
+
+ const setting = await settingRepository.findOne({
+ where: { code: documentSetting.code },
+ select: { id: true, code: true },
+ });
+
+ if (!setting) {
+ throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`);
+ }
+
+ await fieldRepository.delete({ settingId: setting.id });
+
+ await fieldRepository.insert(
+ COMPANY_ONBOARDING_FIELDS.map((field, index) => ({
+ settingId: setting.id,
+ fileKey: field.fileKey,
+ fileLabel: field.fileLabel,
+ helpText: field.helpText,
+ isRequired: field.isRequired,
+ isMultiple: field.isMultiple,
+ maxFiles: field.maxFiles,
+ allowedExtensions: [...field.allowedExtensions],
+ maxSizeMb: field.maxSizeMb,
+ displayOrder: field.displayOrder ?? index + 1,
+ })),
+ );
+ }
+ });
+
+ this.logger.log(
+ "Ensured company onboarding file upload settings for external companies",
+ );
+ }
+}
From 8ddd166dca1b89a39eb47606f99b0d47b08f7e2c Mon Sep 17 00:00:00 2001
From: ghost2023
Date: Thu, 4 Jun 2026 14:30:49 +0300
Subject: [PATCH 06/25] feat: setup the ui for document uploading in accounts
onboarding
---
.../src/pages/accounts/CompanyProfileForm.tsx | 230 +++++---
.../src/pages/accounts/DjiboutiAgentForm.tsx | 207 ++++++--
.../ForwarderForm.tsx} | 412 ++++++++++-----
.../src/pages/accounts/OnboardingPage.tsx | 30 +-
.../src/pages/accounts/TransporterForm.tsx | 493 ++++++++++++------
5 files changed, 981 insertions(+), 391 deletions(-)
rename apps/edr-freight-web/portal/src/pages/{customers/on_boarding/CustomerOnboardingPage.tsx => accounts/ForwarderForm.tsx} (58%)
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
index eb8d5a343..3f22d6179 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
@@ -14,10 +14,8 @@ import {
ChevronLeft,
UploadCloud,
} from "lucide-react";
-import type { OnboardingUserType } from "./types";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
-import type { FileUploadSetting } from "@/types/fileUploadSettings";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
@@ -30,7 +28,7 @@ import {
} from "@edr/ui-common";
import { api } from "@/services/api";
-type CompanyStep = "company" | "personnel" | "poa" | "documents";
+type CompanyStep = "company" | "personnel" | "poa" | "documents" | "confirm";
const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -85,6 +83,7 @@ const stepFields: Record = {
],
poa: [],
documents: [],
+ confirm: [],
};
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
@@ -116,13 +115,13 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
}
export default function CompanyProfileForm({
- userType,
+ documentSettingCode,
user,
onSubmit,
isPending,
onBack,
}: {
- userType: OnboardingUserType;
+ documentSettingCode: string;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
@@ -133,9 +132,9 @@ export default function CompanyProfileForm({
Record
>({});
- const { data: uploadSettings = [], isLoading: loadingDocuments } = useQuery(
- api.fileUploadSettings.getByEntity.queryOptions({
- input: { entity: "customer" },
+ const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
+ api.fileUploadSettings.getByCode.queryOptions({
+ input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
@@ -144,6 +143,7 @@ export default function CompanyProfileForm({
register,
handleSubmit,
trigger,
+ watch,
formState: { errors },
} = useForm({
resolver: zodResolver(onboardingSchema),
@@ -173,18 +173,20 @@ export default function CompanyProfileForm({
},
});
- const hasDocuments = uploadSettings.length > 0;
+ const formValues = watch();
+ const hasDocuments = Boolean(uploadSetting?.fields?.length);
+ const totalSteps = 5;
const nextStep = async () => {
if (step === "poa") {
- if (hasDocuments) {
- setStep("documents");
- } else {
- handleSubmit((data) => onSubmit(buildPayload(data, user)))();
- }
+ setStep("documents");
return;
}
if (step === "documents") {
+ setStep("confirm");
+ return;
+ }
+ if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
@@ -201,8 +203,10 @@ export default function CompanyProfileForm({
setStep("company");
} else if (step === "poa") {
setStep("personnel");
- } else {
+ } else if (step === "documents") {
setStep("poa");
+ } else {
+ setStep("documents");
}
};
@@ -228,31 +232,41 @@ export default function CompanyProfileForm({
}
active={step === "personnel"}
- completed={step === "poa"}
+ completed={
+ step === "poa" || step === "documents" || step === "confirm"
+ }
/>
}
active={step === "poa"}
- completed={hasDocuments ? step === "documents" : step === "personnel"}
+ completed={step === "documents" || step === "confirm"}
+ />
+ }
+ active={step === "documents"}
+ completed={step === "confirm"}
+ />
+ }
+ active={step === "confirm"}
+ completed={false}
/>
- {hasDocuments && (
- }
- active={step === "documents"}
- completed={false}
- />
- )}
- {step === "company" && `Step 1 of ${hasDocuments ? 4 : 3} — Company Information`}
- {step === "personnel" && `Step 2 of ${hasDocuments ? 4 : 3} — Personnel Details`}
- {step === "poa" && `Step 3 of ${hasDocuments ? 4 : 3} — Power of Attorney (Optional)`}
- {step === "documents" && "Step 4 of 4 — Upload Documents (Optional)"}
+ {step === "company" &&
+ `Step 1 of ${totalSteps} — Company Information`}
+ {step === "personnel" &&
+ `Step 2 of ${totalSteps} — Personnel Details`}
+ {step === "poa" &&
+ `Step 3 of ${totalSteps} — Power of Attorney (Optional)`}
+ {step === "documents" &&
+ `Step 4 of ${totalSteps} — Upload Documents (Optional)`}
+ {step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`}
>
)}
+
+ {step === "documents" && (
+ <>
+ {loadingDocuments ? (
+
+
+
+ ) : !uploadSetting ? (
+
+ No document requirements found for your account type.
+
+ ) : (
+
+
+
+ )}
+ >
+ )}
+
+ {step === "confirm" && (
+
+
+
+ Review your registration
+
+
+ Confirm the company details below before saving.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
- {step === "company" ? "Change Type" : "Back"}
+ {step === "company"
+ ? "Change Type"
+ : step === "confirm"
+ ? "Back to Documents"
+ : "Back"}
-
- {isPending ? (
- <>
-
- Submitting...
- >
- ) : step === "representative" ? (
- "Complete Registration"
- ) : (
- <>
- Next Step
-
- >
+
+ {step === "documents" && (
+
+ Skip for now
+
)}
-
+
+
onSubmit(buildPayload(data, user))) : nextStep}
+ disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
+ >
+ {isPending ? (
+ <>
+
+ Submitting...
+ >
+ ) : step === "documents" ? (
+ "Continue"
+ ) : step === "confirm" ? (
+ "Submit Registration"
+ ) : (
+ <>
+ Next Step
+
+ >
+ )}
+
+
>
);
}
+function ReviewRow({ label, value }: { label: string; value?: string | null }) {
+ return (
+
+
+ {label}
+
+
+ {value?.trim() ? value : "Not provided"}
+
+
+ );
+}
+
function StepIcon({
icon,
active,
diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/CustomerOnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx
similarity index 58%
rename from apps/edr-freight-web/portal/src/pages/customers/on_boarding/CustomerOnboardingPage.tsx
rename to apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx
index 994dd751e..b933c4707 100644
--- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/CustomerOnboardingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
+import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
@@ -11,11 +11,11 @@ import {
FileText,
CheckCircle2,
Loader2,
+ ChevronLeft,
+ UploadCloud,
} from "lucide-react";
-import useAuth from "@/hooks/useAuth";
-import { api } from "@/services/api";
-import type { CreateCustomerDto } from "@/types/customers";
-import AuthLayout from "@/components/auth/AuthLayout";
+import type { AuthUser } from "@/types/auth";
+import type { CreateCompanyPayload } from "@/services/companies.service";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
@@ -24,14 +24,13 @@ import {
FieldLabel,
FieldError,
FieldGroup,
+ SmartFileInput,
} from "@edr/ui-common";
-import TransporterOnboarding from "./TransportrOnBoarding";
-import DjiboutiForwardingAgentForm from "./DjiboutiFreightForwardingAgent";
-import ImportExportOnBoarding from "./ImportExportOnBoarding";
+import { api } from "@/services/api";
-type OnboardingStep = "company" | "personnel" | "poa";
+type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "confirm";
-const onboardingSchema = z.object({
+const forwarderSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"),
@@ -59,9 +58,9 @@ const onboardingSchema = z.object({
poaLocation: z.string().optional(),
});
-type FormData = z.infer;
+type FormData = z.infer;
-const stepFields: Record = {
+const stepFields: Record = {
company: [
"companyName",
"companyEmail",
@@ -83,20 +82,71 @@ const stepFields: Record = {
"generalManagerPhoneCountryCode",
],
poa: [],
+ documents: [],
+ confirm: [],
};
-export default function CustomerOnboardingPage() {
- const queryClient = useQueryClient();
- const { user } = useAuth();
- const [step, setStep] = useState("company");
+function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
+ return {
+ companyName: data.companyName,
+ companyEmail: data.companyEmail,
+ companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
+ companyLocation: data.companyLocation,
+ companyAddress: data.companyAddress,
+ tin: data.tinNumber,
+ vatNumber: data.vatNumber,
+ fanNumber: data.fanNumber,
+ attributes: {
+ contactPersonName: data.contactPersonName,
+ contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
+ generalManagerName: data.generalManagerName,
+ generalManagerEmail: data.generalManagerEmail,
+ generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
+ poaName: data.poaName || undefined,
+ poaPhone:
+ data.poaPhone && data.poaPhoneCountryCode
+ ? `${data.poaPhoneCountryCode}${data.poaPhone}`
+ : undefined,
+ poaAddress: data.poaAddress || undefined,
+ poaEmail: data.poaEmail || undefined,
+ poaLocation: data.poaLocation || undefined,
+ },
+ };
+}
+
+export default function ForwarderForm({
+ documentSettingCode,
+ user,
+ onSubmit,
+ isPending,
+ onBack,
+}: {
+ documentSettingCode: string;
+ user: AuthUser;
+ onSubmit: (data: CreateCompanyPayload) => void;
+ isPending: boolean;
+ onBack: () => void;
+}) {
+ const [step, setStep] = useState("company");
+ const [documentFiles, setDocumentFiles] = useState<
+ Record
+ >({});
+
+ const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
+ api.fileUploadSettings.getByCode.queryOptions({
+ input: { code: documentSettingCode },
+ refetchOnMount: false,
+ }),
+ );
const {
register,
handleSubmit,
trigger,
+ watch,
formState: { errors },
} = useForm({
- resolver: zodResolver(onboardingSchema),
+ resolver: zodResolver(forwarderSchema),
defaultValues: {
companyName: "",
companyEmail: "",
@@ -123,20 +173,21 @@ export default function CustomerOnboardingPage() {
},
});
- const createCustomerMutation = useMutation({
- mutationFn: (payload: CreateCustomerDto) =>
- api.customers.create.call(payload),
- onSuccess: () => {
- if (user)
- queryClient.invalidateQueries({
- queryKey: api.customers.getByUserId.queryKey({ id: user.id }),
- });
- },
- });
+ const formValues = watch();
+ const hasDocuments = Boolean(uploadSetting?.fields?.length);
+ const totalSteps = 5;
const nextStep = async () => {
if (step === "poa") {
- handleSubmit(onSubmit)();
+ setStep("documents");
+ return;
+ }
+ if (step === "documents") {
+ setStep("confirm");
+ return;
+ }
+ if (step === "confirm") {
+ handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
const fields = stepFields[step];
@@ -145,69 +196,36 @@ export default function CustomerOnboardingPage() {
setStep(step === "company" ? "personnel" : "poa");
};
- const prevStep = () => {
- if (step === "personnel") setStep("company");
- else if (step === "poa") setStep("personnel");
+ const skipDocuments = () => {
+ setStep("confirm");
};
- const onSubmit = async (data: FormData) => {
- const nameParts = (user?.name?.en ?? "").split(" ");
- const payload: CreateCustomerDto = {
- userId: user!.id,
- firstName: nameParts[0] || "",
- lastName: nameParts.slice(-1)[0] || "",
- email: user!.email,
- phone: user!.phoneNumber,
- companyName: data.companyName,
- companyEmail: data.companyEmail,
- companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
- companyLocation: data.companyLocation,
- companyAddress: data.companyAddress,
- contactPersonName: data.contactPersonName,
- contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
- tinNumber: data.tinNumber,
- vatNumber: data.vatNumber,
- fanNumber: data.fanNumber,
- generalManagerName: data.generalManagerName,
- generalManagerEmail: data.generalManagerEmail,
- generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
- poaName: data.poaName || undefined,
- poaPhone:
- data.poaPhone && data.poaPhoneCountryCode
- ? `${data.poaPhoneCountryCode}${data.poaPhone}`
- : undefined,
- poaAddress: data.poaAddress || undefined,
- poaEmail: data.poaEmail || undefined,
- poaLocation: data.poaLocation || undefined,
- };
- createCustomerMutation.mutate(payload);
+ const prevStep = () => {
+ if (step === "company") {
+ onBack();
+ } else if (step === "personnel") {
+ setStep("company");
+ } else if (step === "poa") {
+ setStep("personnel");
+ } else if (step === "documents") {
+ setStep("poa");
+ } else {
+ setStep("documents");
+ }
};
return (
-
+ <>
+
+
+
+ Change account type
+
-
- {/*
*/}
- {/*
*/}
- {/*
}
active={step === "personnel"}
- completed={step === "poa"}
+ completed={step === "poa" || step === "documents" || step === "confirm"}
/>
}
active={step === "poa"}
+ completed={step === "documents" || step === "confirm"}
+ />
+
}
+ active={step === "documents"}
+ completed={step === "confirm"}
+ />
+
}
+ active={step === "confirm"}
completed={false}
/>
- {step === "company" && "Step 1 of 3 — Company Information"}
- {step === "personnel" && "Step 2 of 3 — Personnel Details"}
- {step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"}
+ {step === "company" &&
+ `Step 1 of ${totalSteps} — Company Information`}
+ {step === "personnel" &&
+ `Step 2 of ${totalSteps} — Personnel Details`}
+ {step === "poa" &&
+ `Step 3 of ${totalSteps} — Power of Attorney (Optional)`}
+ {step === "documents" &&
+ `Step 4 of ${totalSteps} — Upload Documents (Optional)`}
+ {step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`}
-
*/}
+
- {/*