From 950bcc0912cb2c3646f648002b80dda96448e553 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 2 Aug 2026 20:29:09 +0000 Subject: [PATCH 1/6] configer the stamp --- .../signatures/dto/save-signature.dto.ts | 8 +- .../modules/signatures/signatures.service.ts | 23 ++- .../components/profile/MySignatureCard.tsx | 174 ++++++++++++------ .../src/services/signatures.service.ts | 3 +- .../components/profile/MySignatureCard.tsx | 168 +++++++++++------ .../portal/src/pages/accounts/SignupPage.tsx | 25 +++ .../portal/src/services/signatures.service.ts | 3 +- 7 files changed, 281 insertions(+), 123 deletions(-) diff --git a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts index a1f245e23..6d7898b53 100644 --- a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts +++ b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts @@ -7,12 +7,14 @@ export class SaveSignatureDto { @MinLength(1) signerDisplayName!: string; - @ApiProperty({ - description: 'PNG signature image as base64 (with or without data URL prefix)', + @ApiPropertyOptional({ + description: + 'PNG signature image as base64 (with or without data URL prefix). Omit to keep the existing saved signature (stamp-only update).', }) + @IsOptional() @IsString() @MinLength(20) - signatureImageBase64!: string; + signatureImageBase64?: string; @ApiPropertyOptional({ description: diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts index 836888dab..d346457a0 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts @@ -12,7 +12,8 @@ import { SavedSignatureDto } from './dto/save-signature.dto'; export interface UpsertSignatureInput { userId: string; signerDisplayName: string; - signatureImageBase64: string; + /** Optional; omitted = keep the existing saved signature (stamp-only update). */ + signatureImageBase64?: string; /** Optional company stamp/seal; omitted = keep the existing saved stamp. */ stampImageBase64?: string; } @@ -46,12 +47,14 @@ export class SignaturesService { const previousFileId = existing?.signatureFileId ?? null; const previousStampFileId = existing?.stampFileId ?? null; - const fileRecord = await this.filesService.upload({ - resourceId: input.userId, - resource: 'saved_signatures', - code: 'signature', - file: this.toUploadFile('signature', input.userId, input.signatureImageBase64), - }); + const fileRecord = input.signatureImageBase64 + ? await this.filesService.upload({ + resourceId: input.userId, + resource: 'saved_signatures', + code: 'signature', + file: this.toUploadFile('signature', input.userId, input.signatureImageBase64), + }) + : null; const stampRecord = input.stampImageBase64 ? await this.filesService.upload({ @@ -65,13 +68,13 @@ export class SignaturesService { const saved = await this.signaturesRepository.upsert({ userId: input.userId, signerDisplayName: input.signerDisplayName, - signatureFileId: fileRecord.id, - // Omitted stamp keeps whatever was saved before. + // Omitted image keeps whatever was saved before. + ...(fileRecord ? { signatureFileId: fileRecord.id } : {}), ...(stampRecord ? { stampFileId: stampRecord.id } : {}), }); const staleIds = [ - previousFileId !== fileRecord.id ? previousFileId : null, + fileRecord && previousFileId !== fileRecord.id ? previousFileId : null, stampRecord && previousStampFileId !== stampRecord.id ? previousStampFileId : null, diff --git a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx index ca9effd27..0a70b3aec 100644 --- a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { FileSignature, Loader2 } from "lucide-react"; +import { FileSignature, Loader2, Stamp } from "lucide-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; @@ -27,9 +27,9 @@ import { } from "@edr/ui-common"; /** - * Lets the signed-in user view and update the reusable signature stored on - * their profile. The same signature is offered for approval when signing a - * booking contract. + * Lets the signed-in user view and update the reusable signature and company + * stamp stored on their profile — managed independently of each other. Both + * are offered when signing a booking contract. */ export function MySignatureCard() { const { user } = useAuth(); @@ -38,42 +38,63 @@ export function MySignatureCard() { ); const saveMutation = useMutation(api.signatures.save.mutationOptions()); - const [open, setOpen] = useState(false); + const [signatureOpen, setSignatureOpen] = useState(false); + const [stampOpen, setStampOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); const [stampData, setStampData] = useState(null); const defaultName = user?.name?.en || user?.username || user?.email || ""; + const savedName = saved?.signerDisplayName ?? defaultName; - const openDialog = () => { - setSignerName(saved?.signerDisplayName ?? defaultName); + const openSignatureDialog = () => { + setSignerName(savedName); setSignatureData(null); - setStampData(saved?.stampImageUrl ?? null); - setOpen(true); + setSignatureOpen(true); }; - const save = () => { + const saveSignature = () => { if (!signatureData || !signerName.trim()) return; saveMutation.mutate( { signerDisplayName: signerName.trim(), signatureImageBase64: signatureData, - // Only send the stamp when it changed — omitted keeps the saved one. - ...(stampData && stampData !== saved?.stampImageUrl - ? { stampImageBase64: stampData } - : {}), + // Stamp untouched — it is managed by its own dialog. }, { onSuccess: () => { toast.success("Signature saved"); - setOpen(false); + setSignatureOpen(false); }, onError: () => toast.error("Failed to save signature"), }, ); }; + const openStampDialog = () => { + setStampData(saved?.stampImageUrl ?? null); + setStampOpen(true); + }; + + const saveStamp = () => { + if (!stampData) return; + saveMutation.mutate( + { + signerDisplayName: savedName || defaultName, + // Signature untouched — stamp-only update. + stampImageBase64: stampData, + }, + { + onSuccess: () => { + toast.success("Stamp saved"); + setStampOpen(false); + }, + onError: () => toast.error("Failed to save stamp"), + }, + ); + }; + return ( @@ -85,47 +106,64 @@ export function MySignatureCard() { This signature can be reused to sign booking contracts. - + {isLoading ? (
- ) : saved?.signatureImageUrl ? ( -
-
- My saved signature -
-

- Saved as {saved.signerDisplayName} -

-
) : ( -

- You have not saved a signature yet. -

- )} - {saved?.stampImageUrl && ( -
-
- My saved company stamp + <> +
+ {saved?.signatureImageUrl ? ( + <> +
+ My saved signature +
+

+ Saved as {saved.signerDisplayName} +

+ + ) : ( +

+ You have not saved a signature yet. +

+ )} +
-

Company stamp

-
+ +
+ {saved?.stampImageUrl ? ( + <> +
+ My saved company stamp +
+

Company stamp

+ + ) : ( +

+ You have not uploaded a company stamp yet. +

+ )} + +
+ )} - - + Save your signature @@ -145,21 +183,16 @@ export function MySignatureCard() { />
- - + + + +
); } diff --git a/apps/edr-freight-web/backoffice/src/services/signatures.service.ts b/apps/edr-freight-web/backoffice/src/services/signatures.service.ts index 577e3b4de..6a71c1836 100644 --- a/apps/edr-freight-web/backoffice/src/services/signatures.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/signatures.service.ts @@ -11,7 +11,8 @@ export interface SavedSignature { export interface SaveSignaturePayload { signerDisplayName: string; - signatureImageBase64: string; + /** Omit to keep the existing saved signature (stamp-only update). */ + signatureImageBase64?: string; /** Omit to keep the existing saved stamp. */ stampImageBase64?: string; } diff --git a/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx index 678829cb3..dd911d284 100644 --- a/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx +++ b/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { FileSignature, Loader2 } from "lucide-react"; +import { FileSignature, Loader2, Stamp } from "lucide-react"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; import { StampUpload } from "@/components/contracts/StampUpload"; @@ -26,41 +26,56 @@ import { } from "@edr/ui-common"; /** - * Lets the signed-in customer view and update the reusable signature stored on - * their profile. The same signature is offered for approval when signing a - * booking contract. + * Lets the signed-in customer view and update the reusable signature and + * company stamp stored on their profile — managed independently of each + * other. Both are offered when signing a booking contract. */ export function MySignatureCard() { const { user } = useAuth(); const { data: saved, isPending } = useMySignature(); const saveMutation = useSaveSignature(); - const [open, setOpen] = useState(false); + const [signatureOpen, setSignatureOpen] = useState(false); + const [stampOpen, setStampOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); const [stampData, setStampData] = useState(null); const defaultName = user?.name?.en || user?.username || user?.email || ""; + const savedName = saved?.signerDisplayName ?? defaultName; - const openDialog = () => { - setSignerName(saved?.signerDisplayName ?? defaultName); + const openSignatureDialog = () => { + setSignerName(savedName); setSignatureData(null); - setStampData(saved?.stampImageUrl ?? null); - setOpen(true); + setSignatureOpen(true); }; - const save = () => { + const saveSignature = () => { if (!signatureData || !signerName.trim()) return; saveMutation.mutate( { signerDisplayName: signerName.trim(), signatureImageBase64: signatureData, - // Only send the stamp when it changed — omitted keeps the saved one. - ...(stampData && stampData !== saved?.stampImageUrl - ? { stampImageBase64: stampData } - : {}), + // Stamp untouched — it is managed by its own dialog. }, - { onSuccess: () => setOpen(false) }, + { onSuccess: () => setSignatureOpen(false) }, + ); + }; + + const openStampDialog = () => { + setStampData(saved?.stampImageUrl ?? null); + setStampOpen(true); + }; + + const saveStamp = () => { + if (!stampData) return; + saveMutation.mutate( + { + signerDisplayName: savedName || defaultName, + // Signature untouched — stamp-only update. + stampImageBase64: stampData, + }, + { onSuccess: () => setStampOpen(false) }, ); }; @@ -75,47 +90,64 @@ export function MySignatureCard() { Reused to approve and sign booking contracts. - + {isPending ? (
- ) : saved?.signatureImageUrl ? ( -
-
- My saved signature -
-

- Saved as {saved.signerDisplayName} -

-
) : ( -

- You have not saved a signature yet. -

- )} - {saved?.stampImageUrl && ( -
-
- My saved company stamp + <> +
+ {saved?.signatureImageUrl ? ( + <> +
+ My saved signature +
+

+ Saved as {saved.signerDisplayName} +

+ + ) : ( +

+ You have not saved a signature yet. +

+ )} +
-

Company stamp

-
+ +
+ {saved?.stampImageUrl ? ( + <> +
+ My saved company stamp +
+

Company stamp

+ + ) : ( +

+ You have not uploaded a company stamp yet. +

+ )} + +
+ )} - - + Save your signature @@ -135,21 +167,16 @@ export function MySignatureCard() { />
- - + + + + ); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index d56272e0c..03a9e515a 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -21,7 +21,9 @@ import AuthShell from "@/components/auth/AuthShell"; import OtpChannelStep, { OTP_LENGTH } from "@/components/auth/OtpChannelStep"; import PasswordChecklist from "@/components/auth/PasswordChecklist"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; +import { StampUpload } from "@/components/contracts/StampUpload"; import { api } from "@/services/api"; +import { signaturesService } from "@/services/signatures.service"; import { confirmPasswordField, passwordField, @@ -59,6 +61,10 @@ export default function SignupPage() { const navigate = useNavigate(); const { signup } = useAuth(); const [error, setError] = useState(null); + // Company stamp image, captured at signup and stored on the new profile so + // it is ready when the customer signs their first contract. Optional — + // individuals without a stamp can add one later from Settings. + const [stampData, setStampData] = useState(null); // Two-stage signup: fill the form, then a mandatory OTP challenge before the // account is actually created. The code goes to BOTH the email and phone just @@ -179,6 +185,18 @@ export default function SignupPage() { }; const result = await signup(payload); if (result.success) { + // Signup logs the user in, so the stamp can land on their profile + // right away. Non-fatal — it can also be added later from Settings. + if (stampData) { + try { + await signaturesService.saveMySignature({ + signerDisplayName: payload.name.en, + stampImageBase64: stampData, + }); + } catch { + // Ignore — account exists; the stamp can be re-uploaded later. + } + } navigate("/portal"); } else { setOtpError(result.error.message); @@ -269,6 +287,13 @@ export default function SignupPage() { {...register("confirmPassword")} /> + + {error ? ( Date: Sun, 2 Aug 2026 21:09:37 +0000 Subject: [PATCH 2/6] configer the stamp --- .../modules/backoffice/backoffice.service.ts | 24 + .../notification-recipients.service.ts | 19 +- apps/edr-freight-web/backoffice/package.json | 2 + .../trainScheduling/Train3DVisualization.tsx | 536 ++++++++++++++++++ .../TrainScheduleV2DetailPage.tsx | 17 + packages/types/src/freight/notifications.ts | 7 + pnpm-lock.yaml | 48 ++ 7 files changed, 650 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index d49cc304e..49384922e 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -59,6 +59,30 @@ export class BackofficeService { ]; } + /** + * IAM user ids of current employees (any org) holding ANY of the given + * permission keys — used by the notification recipients resolver's + * `permissionKeys` selector for department/role-scoped targeting. + */ + async getEmployeeUserIdsByPermission( + permissionKeys: string[], + ): Promise { + if (!permissionKeys.length) return []; + const rows: { userId: string | null }[] = await this.employeeRepository + .createQueryBuilder("employee") + .innerJoin("employee.employeePositions", "employeePosition") + .innerJoin("employeePosition.position", "position") + .innerJoin("position.positionPermission", "positionPermission") + .innerJoin("positionPermission.permission", "permission") + .where("employee.isCurrent = :isCurrent", { isCurrent: true }) + .andWhere("permission.key IN (:...permissionKeys)", { permissionKeys }) + .select("DISTINCT employee.user_id", "userId") + .getRawMany(); + return rows + .map((r) => r.userId) + .filter((id): id is string => Boolean(id)); + } + async createOrganizationUser( organizationId: string, dto: CreateOrganizationUserDto, diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts index be7964a3d..e3e711d19 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -13,9 +13,8 @@ import { ExternalProfileRepository } from "../companies/external-profile.reposit * - `companyId` → all portal users linked to the company (external_profiles). * - `companyProfileId` → resolved to its company, then to that company's users. * - `organizationId` → all current employees of the org (backoffice staff). - * - * NOTE: permission-scoped staff targeting is intentionally unsupported — freight - * has no "users-by-permission" lookup. Target explicit userIds or an org instead. + * - `permissionKeys` → current employees (any org) holding any of these + * permission keys (e.g. department/role-scoped targeting). */ @Injectable() export class NotificationRecipientsService { @@ -82,6 +81,20 @@ export class NotificationRecipientsService { } } + if (recipients.permissionKeys?.length) { + try { + for (const uid of await this.backoffice.getEmployeeUserIdsByPermission( + recipients.permissionKeys, + )) { + ids.add(uid); + } + } catch (err) { + this.logger.warn( + `Failed to resolve permissionKeys recipients: ${(err as Error).message}`, + ); + } + } + return [...ids]; } } diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 568b3a47a..675eced1a 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -55,6 +55,7 @@ "@tanstack/react-table": "^8.21.3", "@tinymce/tinymce-react": "^6.3.0", "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", + "@types/three": "^0.185.3", "@vis.gl/react-google-maps": "^1.8.3", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", @@ -103,6 +104,7 @@ "sonner": "^2.0.7", "stream-browserify": "^3.0.0", "tailwind-merge": "^3.6.0", + "three": "^0.185.1", "tinymce": "^8.6.0", "xlsx": "^0.18.5", "zod": "^3.25.76", diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx new file mode 100644 index 000000000..98e9d7d6a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx @@ -0,0 +1,536 @@ +import { Badge, Box, Button, CloseButton, Divider, Group, Paper, ScrollArea, Stack, Text } from "@mantine/core"; +import { Maximize2, Minimize2, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; + +import type { TrainScheduleDetail } from "@/types/trainScheduling"; + +type Wagon = NonNullable["wagons"][number]; + +interface Train3DVisualizationProps { + schedule: TrainScheduleDetail; + onClose: () => void; +} + +const CONTAINER_PALETTE = [0xc0392b, 0x2471a3, 0x1e8449, 0xd4ac0d, 0x884ea0, 0xca6f1e, 0x117a65, 0x6e2c00]; + +const hashColor = (key: string) => { + let h = 0; + for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0; + return CONTAINER_PALETTE[Math.abs(h) % CONTAINER_PALETTE.length]; +}; + +type WagonKind = "container" | "bulk" | "tank" | "flat"; + +const wagonKind = (w: Wagon): WagonKind => { + const code = `${w.wagonType?.code ?? ""} ${w.wagonType?.name ?? ""}`.toUpperCase(); + if (/TANK|LIQUID|FUEL/.test(code)) return "tank"; + if (/HOPPER|BULK|OPEN|GONDOLA/.test(code) || w.allocations.some((a) => a.bulkLoad)) return "bulk"; + if (/FLAT|CONT/.test(code) || w.allocations.some((a) => a.containerItems?.length)) return "container"; + return "flat"; +}; + +const WAGON_BODY_COLOR: Record = { + container: 0x4a5568, + bulk: 0x7b4a2d, + tank: 0x8a8f98, + flat: 0x3d4852, +}; + +const M = 1; // 1 unit = 1 meter +const GAUGE = 1.435 * M; +const WHEEL_R = 0.46 * M; +const DECK_H = 1.2 * M; + +function buildStars(scene: THREE.Scene) { + const geo = new THREE.BufferGeometry(); + const n = 2500; + const pos = new Float32Array(n * 3); + for (let i = 0; i < n; i++) { + // random point on upper hemisphere, far away + const r = 900; + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(Math.random() * 0.95); // bias above horizon + pos[i * 3] = r * Math.sin(phi) * Math.cos(theta); + pos[i * 3 + 1] = Math.abs(r * Math.cos(phi)) + 20; + pos[i * 3 + 2] = r * Math.sin(phi) * Math.sin(theta); + } + geo.setAttribute("position", new THREE.BufferAttribute(pos, 3)); + const mat = new THREE.PointsMaterial({ color: 0xffffff, size: 1.6, sizeAttenuation: false, transparent: true, opacity: 0.85 }); + scene.add(new THREE.Points(geo, mat)); +} + +function buildTrack(scene: THREE.Scene, length: number) { + const railMat = new THREE.MeshStandardMaterial({ color: 0x9aa0a8, metalness: 0.9, roughness: 0.35 }); + const railGeo = new THREE.BoxGeometry(length, 0.18 * M, 0.08 * M); + for (const z of [-GAUGE / 2, GAUGE / 2]) { + const rail = new THREE.Mesh(railGeo, railMat); + rail.position.set(0, 0.28 * M, z); + scene.add(rail); + } + const sleeperGeo = new THREE.BoxGeometry(0.24 * M, 0.14 * M, 2.4 * M); + const sleeperMat = new THREE.MeshStandardMaterial({ color: 0x3b2f26, roughness: 1 }); + const count = Math.floor(length / 0.65); + const sleepers = new THREE.InstancedMesh(sleeperGeo, sleeperMat, count); + const m4 = new THREE.Matrix4(); + for (let i = 0; i < count; i++) { + m4.setPosition(-length / 2 + i * 0.65, 0.12 * M, 0); + sleepers.setMatrixAt(i, m4); + } + scene.add(sleepers); + // ballast bed + const ballast = new THREE.Mesh( + new THREE.BoxGeometry(length, 0.1 * M, 4.2 * M), + new THREE.MeshStandardMaterial({ color: 0x565a5f, roughness: 1 }), + ); + ballast.position.y = 0.03; + scene.add(ballast); + // ground + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(2200, 800), + new THREE.MeshStandardMaterial({ color: 0x101418, roughness: 1 }), + ); + ground.rotation.x = -Math.PI / 2; + ground.position.y = -0.02; + scene.add(ground); +} + +function addWheels(group: THREE.Group, length: number, wheels: THREE.Mesh[]) { + const geo = new THREE.CylinderGeometry(WHEEL_R, WHEEL_R, 0.25, 20); + const mat = new THREE.MeshStandardMaterial({ color: 0x1c1f24, metalness: 0.7, roughness: 0.4 }); + const bogieOffsets = [-length / 2 + 1.8, length / 2 - 1.8]; + for (const x of bogieOffsets) { + for (const dx of [-0.9, 0.9]) { + for (const z of [-GAUGE / 2, GAUGE / 2]) { + const wheel = new THREE.Mesh(geo, mat); + wheel.rotation.x = Math.PI / 2; + wheel.position.set(x + dx, WHEEL_R + 0.05, z); + group.add(wheel); + wheels.push(wheel); + } + } + const bogie = new THREE.Mesh( + new THREE.BoxGeometry(2.6, 0.4, GAUGE + 0.4), + new THREE.MeshStandardMaterial({ color: 0x22262c, roughness: 0.8 }), + ); + bogie.position.set(x, WHEEL_R + 0.35, 0); + group.add(bogie); + } +} + +function buildLocomotive(loco: { code: string } | undefined, wheels: THREE.Mesh[]): THREE.Group { + const g = new THREE.Group(); + const len = 20 * M; + const bodyMat = new THREE.MeshStandardMaterial({ color: 0x1e6f42, metalness: 0.4, roughness: 0.5 }); + const body = new THREE.Mesh(new THREE.BoxGeometry(len, 3.2, 3), bodyMat); + body.position.y = DECK_H + 1.6; + g.add(body); + const cab = new THREE.Mesh(new THREE.BoxGeometry(4.4, 1.1, 3.05), new THREE.MeshStandardMaterial({ color: 0xf2a516, metalness: 0.3, roughness: 0.5 })); + cab.position.set(len / 2 - 2.6, DECK_H + 3.75, 0); + g.add(cab); + const nose = new THREE.Mesh(new THREE.BoxGeometry(1.6, 2.2, 2.6), bodyMat); + nose.position.set(len / 2 + 0.6, DECK_H + 1.1, 0); + g.add(nose); + // headlight + const light = new THREE.Mesh( + new THREE.SphereGeometry(0.22, 12, 12), + new THREE.MeshStandardMaterial({ color: 0xfff6c9, emissive: 0xfff2a8, emissiveIntensity: 2 }), + ); + light.position.set(len / 2 + 1.4, DECK_H + 1.6, 0); + g.add(light); + const spot = new THREE.SpotLight(0xfff2c0, 60, 90, 0.4, 0.6); + spot.position.copy(light.position); + spot.target.position.set(len / 2 + 40, 0.5, 0); + g.add(spot, spot.target); + addWheels(g, len, wheels); + g.userData.length = len; + g.userData.locoCode = loco?.code; + return g; +} + +function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3D[]): THREE.Group { + const g = new THREE.Group(); + const len = Math.max(8, wagon.lengthMeters || 14) * M; + const kind = wagonKind(wagon); + const bodyMat = new THREE.MeshStandardMaterial({ color: WAGON_BODY_COLOR[kind], metalness: 0.35, roughness: 0.6 }); + + // frame/deck common to all + const deck = new THREE.Mesh(new THREE.BoxGeometry(len, 0.35, 3), bodyMat); + deck.position.y = DECK_H; + g.add(deck); + + if (kind === "container") { + const items = wagon.allocations.flatMap((a) => + (a.containerItems ?? []).map((c) => ({ ...c, bookingRef: a.bookingReference ?? a.bookingId })), + ); + const n = Math.max(items.length, 0); + const slotLen = n > 1 ? len / n - 0.3 : Math.min(12.2, len - 1.5); + items.forEach((c, i) => { + const cx = n > 1 ? -len / 2 + (i + 0.5) * (len / n) : 0; + const box = new THREE.Mesh( + new THREE.BoxGeometry(slotLen, 2.6, 2.44), + new THREE.MeshStandardMaterial({ color: hashColor(c.bookingRef), metalness: 0.2, roughness: 0.55 }), + ); + box.position.set(cx, DECK_H + 0.35 / 2 + 1.3, 0); + // corrugation hint: thin ribs + const rib = new THREE.Mesh( + new THREE.BoxGeometry(slotLen * 0.98, 2.4, 2.5), + new THREE.MeshStandardMaterial({ color: 0x000000, transparent: true, opacity: 0.12 }), + ); + rib.position.copy(box.position); + g.add(box, rib); + }); + } else if (kind === "bulk") { + // open hopper walls + const wallMat = new THREE.MeshStandardMaterial({ color: 0x8a5a34, metalness: 0.25, roughness: 0.7 }); + const wallH = 2.2; + const side = new THREE.BoxGeometry(len, wallH, 0.12); + for (const z of [-1.45, 1.45]) { + const wall = new THREE.Mesh(side, wallMat); + wall.position.set(0, DECK_H + wallH / 2 + 0.17, z); + g.add(wall); + } + const end = new THREE.BoxGeometry(0.12, wallH, 3); + for (const x of [-len / 2 + 0.06, len / 2 - 0.06]) { + const wall = new THREE.Mesh(end, wallMat); + wall.position.set(x, DECK_H + wallH / 2 + 0.17, 0); + g.add(wall); + } + const loaded = wagon.allocations.some((a) => a.bulkLoad) || wagon.assignedWeightTons > 0; + if (loaded) { + // cargo mound: squashed bumpy cylinder rows + const cargoMat = new THREE.MeshStandardMaterial({ color: 0x5c4a33, roughness: 1 }); + const mounds = Math.max(2, Math.floor(len / 4)); + for (let i = 0; i < mounds; i++) { + const mound = new THREE.Mesh(new THREE.SphereGeometry(1.5, 12, 8), cargoMat); + mound.scale.set((len / mounds) * 0.42, 0.55, 0.9); + mound.position.set(-len / 2 + (i + 0.5) * (len / mounds), DECK_H + wallH * 0.85, 0); + g.add(mound); + } + } + } else if (kind === "tank") { + const tank = new THREE.Mesh( + new THREE.CylinderGeometry(1.4, 1.4, len - 1.6, 24), + new THREE.MeshStandardMaterial({ color: 0xb9bec7, metalness: 0.6, roughness: 0.3 }), + ); + tank.rotation.z = Math.PI / 2; + tank.position.y = DECK_H + 1.6; + g.add(tank); + const dome = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.45, 0.5, 16), bodyMat); + dome.position.y = DECK_H + 3.1; + g.add(dome); + } else { + // empty flat: low side rails + const rail = new THREE.Mesh(new THREE.BoxGeometry(len, 0.35, 0.1), bodyMat); + for (const z of [-1.45, 1.45]) { + const r = rail.clone(); + r.position.set(0, DECK_H + 0.35, z); + g.add(r); + } + } + + addWheels(g, len, wheels); + g.userData.length = len; + g.userData.wagonId = wagon.id; + g.traverse((o) => { + o.userData.wagonId = wagon.id; + }); + pickables.push(g); + return g; +} + +function WagonDetailPanel({ wagon, schedule, onClose }: { wagon: Wagon; schedule: TrainScheduleDetail; onClose: () => void }) { + const bookings = schedule.bookings ?? []; + return ( + + + + Wagon {wagon.position ?? wagon.sequenceNo} + {wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""} + + + + + + + {wagon.wagonType?.code ?? "UNKNOWN"} + {wagon.wagonType?.name} + + + Capacity {wagon.capacityTons} t · Loaded {wagon.assignedWeightTons} t + {wagon.tareWeightTons ? ` · Tare ${wagon.tareWeightTons} t` : ""} · {wagon.lengthMeters} m + + {wagon.allocations.length === 0 ? ( + Empty wagon — no allocations. + ) : ( + wagon.allocations.map((alloc) => { + const booking = bookings.find((b) => b.id === alloc.bookingId); + return ( + + + + {alloc.bookingReference ?? "—"} + {alloc.allocatedWeightTons} t + + {booking ? ( + <> + Customer: {booking.customer ?? "—"} + + {booking.origin ?? "?"} → {booking.destination ?? "?"} · {booking.status ?? "—"} + {booking.loadingStatus ? ` · ${booking.loadingStatus}` : ""} + + {booking.contractReference ? ( + Contract {booking.contractReference} + ) : null} + + ) : null} + {(alloc.containerItems ?? []).length > 0 ? ( + <> + + {(alloc.containerItems ?? []).map((c) => ( + + {c.containerNumber ?? "—"} + {c.grossWeightTons ?? "?"} t + + ))} + + ) : null} + {alloc.bulkLoad ? ( + + Bulk: {alloc.bulkLoad.cargoDescription ?? "cargo"} · {alloc.bulkLoad.weightTons} t + + ) : null} + + + ); + }) + )} + + + + ); +} + +export function Train3DVisualization({ schedule, onClose }: Train3DVisualizationProps) { + const containerRef = useRef(null); + const canvasHostRef = useRef(null); + const [selectedWagonId, setSelectedWagonId] = useState(null); + const [isFullscreen, setIsFullscreen] = useState(false); + + const wagons = schedule.trainSet?.wagons ?? []; + const moving = String(schedule.status).toUpperCase() === "DISPATCHED"; + const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null; + + useEffect(() => { + const host = canvasHostRef.current; + if (!host) return; + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x02040a); + scene.fog = new THREE.Fog(0x02040a, 250, 900); + + const camera = new THREE.PerspectiveCamera(55, host.clientWidth / host.clientHeight, 0.1, 2000); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(host.clientWidth, host.clientHeight); + host.appendChild(renderer.domElement); + + // lights: moonlit night + scene.add(new THREE.AmbientLight(0x8899bb, 0.35)); + const moon = new THREE.DirectionalLight(0xbfd0ff, 1.1); + moon.position.set(-80, 120, 60); + scene.add(moon); + const warm = new THREE.PointLight(0xffc873, 0.6, 200); + warm.position.set(0, 25, 30); + scene.add(warm); + + buildStars(scene); + + // train assembly + const wheels: THREE.Mesh[] = []; + const pickables: THREE.Object3D[] = []; + const train = new THREE.Group(); + const gap = 1.0 * M; + let cursor = 0; + const locos = schedule.trainSet?.locomotives?.length + ? schedule.trainSet.locomotives + : schedule.trainSet?.locomotive + ? [schedule.trainSet.locomotive] + : [{ code: "LOCO" }]; + for (const loco of locos) { + const lg = buildLocomotive(loco, wheels); + lg.position.x = cursor - lg.userData.length / 2; + cursor -= lg.userData.length + gap; + train.add(lg); + } + const ordered = [...wagons].sort((a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo)); + for (const wagon of ordered) { + const wg = buildWagon(wagon, wheels, pickables); + wg.position.x = cursor - wg.userData.length / 2; + cursor -= wg.userData.length + gap; + train.add(wg); + } + const trainLen = -cursor; + train.position.x = trainLen / 2; // center train on origin + scene.add(train); + + const trackLen = Math.max(trainLen * 2.5, 400); + buildTrack(scene, moving ? Math.max(trackLen, 1600) : trackLen); + + camera.position.set(trainLen * 0.12, 14, 42); + const controls = new OrbitControls(camera, renderer.domElement); + controls.target.set(0, 2, 0); + controls.maxPolarAngle = Math.PI / 2 - 0.03; + controls.maxDistance = 500; + controls.enableDamping = true; + + // picking + const raycaster = new THREE.Raycaster(); + const pointer = new THREE.Vector2(); + let highlighted: THREE.Object3D | null = null; + const setEmissive = (root: THREE.Object3D, on: boolean) => { + root.traverse((o) => { + const mesh = o as THREE.Mesh; + const mat = mesh.material as THREE.MeshStandardMaterial | undefined; + if (mat?.emissive) { + mat.emissive.setHex(on ? 0x335588 : 0x000000); + mat.emissiveIntensity = on ? 0.9 : 1; + } + }); + }; + const onClick = (e: MouseEvent) => { + const rect = renderer.domElement.getBoundingClientRect(); + pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + raycaster.setFromCamera(pointer, camera); + const hits = raycaster.intersectObjects(pickables, true); + const wagonId = hits[0]?.object.userData.wagonId as string | undefined; + if (highlighted) setEmissive(highlighted, false); + highlighted = wagonId ? (pickables.find((p) => p.userData.wagonId === wagonId) ?? null) : null; + if (highlighted) setEmissive(highlighted, true); + setSelectedWagonId(wagonId ?? null); + }; + renderer.domElement.addEventListener("click", onClick); + + const onResize = () => { + camera.aspect = host.clientWidth / host.clientHeight; + camera.updateProjectionMatrix(); + renderer.setSize(host.clientWidth, host.clientHeight); + }; + const resizeObserver = new ResizeObserver(onResize); + resizeObserver.observe(host); + + let raf = 0; + let last = performance.now(); + const speed = 8; // m/s visual speed when dispatched + const animate = () => { + raf = requestAnimationFrame(animate); + const now = performance.now(); + const dt = Math.min((now - last) / 1000, 0.1); + last = now; + if (moving) { + train.position.x += speed * dt; + for (const w of wheels) w.rotation.y += (speed * dt) / WHEEL_R; + if (train.position.x > trainLen / 2 + 120) train.position.x = trainLen / 2 - 120; + } + controls.update(); + renderer.render(scene, camera); + }; + animate(); + + return () => { + cancelAnimationFrame(raf); + resizeObserver.disconnect(); + renderer.domElement.removeEventListener("click", onClick); + controls.dispose(); + renderer.dispose(); + host.removeChild(renderer.domElement); + scene.traverse((o) => { + const mesh = o as THREE.Mesh; + mesh.geometry?.dispose?.(); + const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + mats.forEach((m) => m?.dispose?.()); + }); + }; + // rebuild scene only when schedule identity/status changes + }, [schedule.id, schedule.status, moving, wagons.length]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const onFsChange = () => setIsFullscreen(Boolean(document.fullscreenElement)); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !document.fullscreenElement) onClose(); + }; + document.addEventListener("fullscreenchange", onFsChange); + window.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("fullscreenchange", onFsChange); + window.removeEventListener("keydown", onKey); + }; + }, [onClose]); + + const toggleFullscreen = () => { + if (document.fullscreenElement) void document.exitFullscreen(); + else void containerRef.current?.requestFullscreen(); + }; + + return ( + + + + + {moving ? "DISPATCHED — TRAIN IN MOTION" : `${schedule.status} — TRAIN STOPPED`} + + + {schedule.route?.name ?? schedule.reference ?? "Train"} · {wagons.length} wagons + + + + + Drag to orbit · scroll to zoom · click a wagon for details + + + + + + + {selectedWagon ? ( + setSelectedWagonId(null)} /> + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index e89977eef..968817985 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -71,6 +71,7 @@ import { PreviewSummary, ScheduleWarningsAlert, } from "@/components/trainScheduling/ScheduleWarningsAlert"; +import { Train3DVisualization } from "@/components/trainScheduling/Train3DVisualization"; import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { TrainConsistView } from "@/components/trainScheduling/compositionEditor"; import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid"; @@ -116,6 +117,7 @@ export default function TrainScheduleV2DetailPage() { const [gatepassNotes, setGatepassNotes] = useState(""); const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false); const [switchTarget, setSwitchTarget] = useState(null); + const [visualization3DOpen, setVisualization3DOpen] = useState(false); const autoPreviewedRef = useRef(false); const detailQuery = useQuery( @@ -946,6 +948,18 @@ export default function TrainScheduleV2DetailPage() { + {(schedule.trainSet?.wagons?.length ?? 0) > 0 ? ( + + ) : null} {canPrintMarshalling ? ( + + + + + + {selectedIndex >= 0 + ? `Wagon ${orderedWagons[selectedIndex].position ?? orderedWagons[selectedIndex].sequenceNo} / ${orderedWagons.length}` + : `${orderedWagons.length} wagons`} + + + - + Drag to orbit · scroll to zoom · click a wagon for details From 6d8e5551ad9b75d0825279cf8efc26c18c8cdfff Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 2 Aug 2026 21:46:00 +0000 Subject: [PATCH 4/6] train 3d visualizations --- .../trainScheduling/Train3DVisualization.tsx | 246 ++++++++++++------ 1 file changed, 166 insertions(+), 80 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx index 975df9603..edb3a6b2b 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx @@ -13,14 +13,22 @@ interface Train3DVisualizationProps { onClose: () => void; } -const CONTAINER_PALETTE = [0xc0392b, 0x2471a3, 0x1e8449, 0xd4ac0d, 0x884ea0, 0xca6f1e, 0x117a65, 0x6e2c00]; +// shipping-line style colors — no purple +const CONTAINER_PALETTE = [0xb63a2b, 0x1f6fb2, 0x1e8449, 0xd9a213, 0xd35f1e, 0x11707f, 0x7a3b1e, 0x35506e]; -const hashColor = (key: string) => { +// wagon body color keyed by wagon type — same type = same color across the train +const WAGON_TYPE_PALETTE = [0x2e6f8e, 0xb0722d, 0x5c7f3b, 0x8e3b3b, 0x3b6e63, 0x8a6d2f, 0x505a7d, 0x6d5540]; +const wagonTypeColor = (w: Wagon, fallback: number) => + w.wagonType?.code ? WAGON_TYPE_PALETTE[Math.abs(hash32(w.wagonType.code)) % WAGON_TYPE_PALETTE.length] : fallback; + +const hash32 = (key: string) => { let h = 0; for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0; - return CONTAINER_PALETTE[Math.abs(h) % CONTAINER_PALETTE.length]; + return h; }; +const hashColor = (key: string) => CONTAINER_PALETTE[Math.abs(hash32(key)) % CONTAINER_PALETTE.length]; + type WagonKind = "container" | "bulk" | "tank" | "flat"; const wagonKind = (w: Wagon): WagonKind => { @@ -74,7 +82,7 @@ function makeLabelSprite(text: string, bg: string): THREE.Sprite { function buildClouds(scene: THREE.Scene, rng: () => number) { const mat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1, transparent: true, opacity: 0.92 }); - for (let i = 0; i < 14; i++) { + for (let i = 0; i < 8; i++) { const cloud = new THREE.Group(); const puffs = 3 + Math.floor(rng() * 4); for (let p = 0; p < puffs; p++) { @@ -83,66 +91,103 @@ function buildClouds(scene: THREE.Scene, rng: () => number) { puff.scale.y = 0.55; cloud.add(puff); } - cloud.position.set((rng() - 0.5) * 1400, 90 + rng() * 60, (rng() - 0.5) * 800); + // high and pushed to the sides so they never sit between camera and train + cloud.position.set((rng() - 0.5) * 1800, 220 + rng() * 80, (rng() > 0.5 ? 1 : -1) * (350 + rng() * 400)); scene.add(cloud); } } -function buildTree(rng: () => number): THREE.Group { +function buildAcacia(rng: () => number): THREE.Group { + // flat-topped acacia const tree = new THREE.Group(); - const trunkH = 2 + rng() * 2; - const trunk = new THREE.Mesh( - new THREE.CylinderGeometry(0.25, 0.4, trunkH, 8), - new THREE.MeshStandardMaterial({ color: 0x6e4a2f, roughness: 1 }), - ); + const trunkH = 2.6 + rng() * 2; + const trunkMat = new THREE.MeshStandardMaterial({ color: 0x7a5230, roughness: 1 }); + const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.35, trunkH, 7), trunkMat); trunk.position.y = trunkH / 2; + trunk.rotation.z = (rng() - 0.5) * 0.25; tree.add(trunk); - const greens = [0x2f7d32, 0x3c8d40, 0x4f9e3f, 0x2e6b28]; - const foliageMat = new THREE.MeshStandardMaterial({ color: greens[Math.floor(rng() * greens.length)], roughness: 1 }); - if (rng() > 0.5) { - // conifer: stacked cones - for (let i = 0; i < 3; i++) { - const cone = new THREE.Mesh(new THREE.ConeGeometry(2.4 - i * 0.6, 2.6, 10), foliageMat); - cone.position.y = trunkH + i * 1.5 + 0.8; - tree.add(cone); - } - } else { - const crown = new THREE.Mesh(new THREE.SphereGeometry(2 + rng() * 1.4, 10, 8), foliageMat); - crown.position.y = trunkH + 1.6; - crown.scale.y = 0.9; - tree.add(crown); + for (const tilt of [-0.6, 0.6]) { + const branch = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.14, trunkH * 0.6, 6), trunkMat); + branch.position.set(tilt * 0.8, trunkH * 0.85, (rng() - 0.5) * 0.6); + branch.rotation.z = tilt; + tree.add(branch); } + const canopyMat = new THREE.MeshStandardMaterial({ + color: rng() > 0.5 ? 0x5d7a35 : 0x6b8342, + roughness: 1, + }); + const canopy = new THREE.Mesh(new THREE.SphereGeometry(2.6 + rng() * 1.6, 10, 8), canopyMat); + canopy.position.y = trunkH + 0.9; + canopy.scale.set(1.2, 0.32, 1.2); // flat umbrella top + tree.add(canopy); return tree; } -function buildAnimal(rng: () => number): THREE.Group { - // low-poly cow / sheep - const isCow = rng() > 0.45; - const bodyColor = isCow ? (rng() > 0.5 ? 0x8b5a3c : 0x54432f) : 0xe8e4da; +function buildShrub(rng: () => number): THREE.Mesh { + const dry = [0x9c8a4d, 0x8f7a3e, 0xa89a5e, 0x7d8a4a]; + const shrub = new THREE.Mesh( + new THREE.SphereGeometry(0.5 + rng() * 0.8, 7, 6), + new THREE.MeshStandardMaterial({ color: dry[Math.floor(rng() * dry.length)], roughness: 1 }), + ); + shrub.scale.y = 0.55; + shrub.position.y = 0.25; + return shrub; +} + +function buildRock(rng: () => number): THREE.Mesh { + const rock = new THREE.Mesh( + new THREE.DodecahedronGeometry(0.5 + rng() * 1.6, 0), + new THREE.MeshStandardMaterial({ color: rng() > 0.5 ? 0xa08d76 : 0x8d7f6d, roughness: 1 }), + ); + rock.scale.set(1 + rng(), 0.55 + rng() * 0.4, 1 + rng()); + rock.position.y = 0.3; + rock.rotation.y = rng() * Math.PI; + return rock; +} + +function buildCamel(rng: () => number): THREE.Group { const g = new THREE.Group(); - const bodyMat = new THREE.MeshStandardMaterial({ color: bodyColor, roughness: 1 }); - const body = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.9, 0.8), bodyMat); - body.position.y = 0.95; + const mat = new THREE.MeshStandardMaterial({ color: rng() > 0.5 ? 0xc49a5f : 0xb28850, roughness: 1 }); + const body = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.95, 0.85), mat); + body.position.y = 1.55; g.add(body); - const head = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.5, 0.45), bodyMat); - head.position.set(0.95, 1.25, 0); + const hump = new THREE.Mesh(new THREE.SphereGeometry(0.55, 10, 8), mat); + hump.position.set(-0.15, 2.25, 0); + hump.scale.set(1.15, 0.9, 0.85); + g.add(hump); + const neck = new THREE.Mesh(new THREE.BoxGeometry(0.3, 1.3, 0.3), mat); + neck.position.set(1.05, 2.35, 0); + neck.rotation.z = -0.25; + g.add(neck); + const head = new THREE.Mesh(new THREE.BoxGeometry(0.65, 0.32, 0.3), mat); + head.position.set(1.45, 2.95, 0); g.add(head); - const legGeo = new THREE.BoxGeometry(0.16, 0.6, 0.16); - for (const [x, z] of [[-0.6, -0.25], [-0.6, 0.25], [0.6, -0.25], [0.6, 0.25]]) { - const leg = new THREE.Mesh(legGeo, bodyMat); - leg.position.set(x, 0.3, z); + const legGeo = new THREE.BoxGeometry(0.17, 1.15, 0.17); + for (const [x, z] of [[-0.8, -0.28], [-0.8, 0.28], [0.8, -0.28], [0.8, 0.28]]) { + const leg = new THREE.Mesh(legGeo, mat); + leg.position.set(x, 0.58, z); g.add(leg); } - if (isCow) { - const patch = new THREE.Mesh( - new THREE.BoxGeometry(0.7, 0.5, 0.82), - new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1 }), - ); - patch.position.set(-0.3, 1.05, 0); - g.add(patch); + g.rotation.y = rng() * Math.PI * 2; + return g; +} + +function buildGoat(rng: () => number): THREE.Group { + const g = new THREE.Group(); + const colors = [0xe8e4da, 0x4a3b2d, 0x9c8a72]; + const mat = new THREE.MeshStandardMaterial({ color: colors[Math.floor(rng() * colors.length)], roughness: 1 }); + const body = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.5, 0.4), mat); + body.position.y = 0.62; + g.add(body); + const head = new THREE.Mesh(new THREE.BoxGeometry(0.32, 0.3, 0.26), mat); + head.position.set(0.55, 0.85, 0); + g.add(head); + const legGeo = new THREE.BoxGeometry(0.09, 0.4, 0.09); + for (const [x, z] of [[-0.32, -0.13], [-0.32, 0.13], [0.32, -0.13], [0.32, 0.13]]) { + const leg = new THREE.Mesh(legGeo, mat); + leg.position.set(x, 0.2, z); + g.add(leg); } - const scale = isCow ? 1 : 0.6; - g.scale.setScalar(scale); g.rotation.y = rng() * Math.PI * 2; return g; } @@ -151,38 +196,67 @@ function buildScenery(scene: THREE.Scene, trackLen: number) { const rng = makeRng(1234567); buildClouds(scene, rng); const spread = Math.max(trackLen, 600); - // grass tufts: darker green patches on the field - const patchMat = new THREE.MeshStandardMaterial({ color: 0x4f8f3a, roughness: 1 }); - const patchGeo = new THREE.CircleGeometry(3, 8); - const patches = new THREE.InstancedMesh(patchGeo, patchMat, 220); + // sand variation: lighter wind-blown patches + const patchMat = new THREE.MeshStandardMaterial({ color: 0xe6cfa3, roughness: 1 }); + const patchGeo = new THREE.CircleGeometry(4, 8); + const patches = new THREE.InstancedMesh(patchGeo, patchMat, 160); const m4 = new THREE.Matrix4(); const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0, 0)); const v = new THREE.Vector3(); const s = new THREE.Vector3(); - for (let i = 0; i < 220; i++) { - const z = (rng() > 0.5 ? 1 : -1) * (6 + rng() * 180); - v.set((rng() - 0.5) * spread, 0.02, z); - s.setScalar(0.5 + rng() * 2); + for (let i = 0; i < 160; i++) { + const z = (rng() > 0.5 ? 1 : -1) * (6 + rng() * 220); + v.set((rng() - 0.5) * spread * 1.4, 0.02, z); + s.setScalar(0.6 + rng() * 2.4); m4.compose(v, q, s); patches.setMatrixAt(i, m4); } scene.add(patches); - for (let i = 0; i < 90; i++) { - const tree = buildTree(rng); - const z = (rng() > 0.5 ? 1 : -1) * (14 + rng() * 170); - tree.position.set((rng() - 0.5) * spread, 0, z); - tree.scale.setScalar(0.8 + rng() * 1.2); + // low dunes on the horizon sides + const duneMat = new THREE.MeshStandardMaterial({ color: 0xcaa878, roughness: 1 }); + for (let i = 0; i < 12; i++) { + const dune = new THREE.Mesh(new THREE.SphereGeometry(30 + rng() * 50, 12, 8), duneMat); + dune.scale.set(1.6 + rng(), 0.12 + rng() * 0.08, 1 + rng()); + dune.position.set((rng() - 0.5) * spread * 1.6, 0, (rng() > 0.5 ? 1 : -1) * (180 + rng() * 200)); + scene.add(dune); + } + // sparse acacias, dry shrubs, rocks + for (let i = 0; i < 34; i++) { + const tree = buildAcacia(rng); + tree.position.set((rng() - 0.5) * spread * 1.2, 0, (rng() > 0.5 ? 1 : -1) * (18 + rng() * 170)); + tree.scale.setScalar(0.9 + rng() * 1.1); scene.add(tree); } - // grazing herds - for (let h = 0; h < 6; h++) { + for (let i = 0; i < 120; i++) { + const shrub = buildShrub(rng); + shrub.position.set((rng() - 0.5) * spread * 1.3, 0.15, (rng() > 0.5 ? 1 : -1) * (8 + rng() * 200)); + scene.add(shrub); + } + for (let i = 0; i < 40; i++) { + const rock = buildRock(rng); + rock.position.x = (rng() - 0.5) * spread * 1.3; + rock.position.z = (rng() > 0.5 ? 1 : -1) * (10 + rng() * 190); + scene.add(rock); + } + // camel caravans + goat herds + for (let h = 0; h < 5; h++) { const cx = (rng() - 0.5) * spread; - const cz = (rng() > 0.5 ? 1 : -1) * (25 + rng() * 120); - const herd = 2 + Math.floor(rng() * 4); - for (let a = 0; a < herd; a++) { - const animal = buildAnimal(rng); - animal.position.set(cx + (rng() - 0.5) * 14, 0, cz + (rng() - 0.5) * 12); - scene.add(animal); + const cz = (rng() > 0.5 ? 1 : -1) * (30 + rng() * 130); + const count = 2 + Math.floor(rng() * 3); + for (let a = 0; a < count; a++) { + const camel = buildCamel(rng); + camel.position.set(cx + a * 4 + (rng() - 0.5) * 2, 0, cz + (rng() - 0.5) * 6); + scene.add(camel); + } + } + for (let h = 0; h < 4; h++) { + const cx = (rng() - 0.5) * spread; + const cz = (rng() > 0.5 ? 1 : -1) * (20 + rng() * 120); + const count = 3 + Math.floor(rng() * 4); + for (let a = 0; a < count; a++) { + const goat = buildGoat(rng); + goat.position.set(cx + (rng() - 0.5) * 10, 0, cz + (rng() - 0.5) * 8); + scene.add(goat); } } } @@ -214,8 +288,8 @@ function buildTrack(scene: THREE.Scene, length: number) { scene.add(ballast); // ground const ground = new THREE.Mesh( - new THREE.PlaneGeometry(2200, 800), - new THREE.MeshStandardMaterial({ color: 0x5da043, roughness: 1 }), + new THREE.PlaneGeometry(4000, 1600), + new THREE.MeshStandardMaterial({ color: 0xd8b98a, roughness: 1 }), ); ground.rotation.x = -Math.PI / 2; ground.position.y = -0.02; @@ -279,7 +353,11 @@ function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3 const g = new THREE.Group(); const len = Math.max(8, wagon.lengthMeters || 14) * M; const kind = wagonKind(wagon); - const bodyMat = new THREE.MeshStandardMaterial({ color: WAGON_BODY_COLOR[kind], metalness: 0.35, roughness: 0.6 }); + const bodyMat = new THREE.MeshStandardMaterial({ + color: wagonTypeColor(wagon, WAGON_BODY_COLOR[kind]), + metalness: 0.35, + roughness: 0.6, + }); // frame/deck common to all const deck = new THREE.Mesh(new THREE.BoxGeometry(len, 0.35, 3), bodyMat); @@ -309,7 +387,11 @@ function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3 }); } else if (kind === "bulk") { // open hopper walls - const wallMat = new THREE.MeshStandardMaterial({ color: 0x8a5a34, metalness: 0.25, roughness: 0.7 }); + const wallMat = new THREE.MeshStandardMaterial({ + color: wagonTypeColor(wagon, 0x8a5a34), + metalness: 0.25, + roughness: 0.7, + }); const wallH = 2.2; const side = new THREE.BoxGeometry(len, wallH, 0.12); for (const z of [-1.45, 1.45]) { @@ -499,10 +581,10 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization if (!host) return; const scene = new THREE.Scene(); - scene.background = new THREE.Color(0x9ed3f5); - scene.fog = new THREE.Fog(0xbfe3f7, 350, 1100); + scene.background = new THREE.Color(0xa7d8f0); + scene.fog = new THREE.Fog(0xd9e8f0, 900, 3200); // far desert haze — never clouds the train - const camera = new THREE.PerspectiveCamera(55, host.clientWidth / host.clientHeight, 0.1, 2000); + const camera = new THREE.PerspectiveCamera(55, host.clientWidth / host.clientHeight, 0.1, 5000); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.15; @@ -511,7 +593,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization host.appendChild(renderer.domElement); // lights: bright sunny day - scene.add(new THREE.HemisphereLight(0xcfe8ff, 0x6f9c4e, 1.0)); + scene.add(new THREE.HemisphereLight(0xcfe8ff, 0xcaa878, 1.0)); const sun = new THREE.DirectionalLight(0xfff4d6, 2.4); sun.position.set(-120, 180, 90); scene.add(sun); @@ -567,6 +649,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization controls.maxPolarAngle = Math.PI / 2 - 0.03; controls.maxDistance = Math.max(600, trainLen * 1.6); controls.enableDamping = true; + controls.zoomToCursor = true; // wheel zooms toward the point under the pointer // camera fly-to: goal recomputed each frame so it tracks a moving train let cameraGoal: (() => { pos: THREE.Vector3; target: THREE.Vector3 }) | null = null; @@ -584,14 +667,17 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization const rearGroup = wagonGroups[wagonGroups.length - 1]?.group ?? frontGroup; cameraApiRef.current = { overview: () => { - // side view far enough to fit the whole train in the 55° fov - const dist = Math.max(40, (trainLen / 2 / Math.tan((55 * Math.PI) / 360)) * 1.15); + // side view fitted with the HORIZONTAL fov — much closer than the naive + // vertical-fov fit, and low to the ground so the train fills the frame + const vFov = (camera.fov * Math.PI) / 180; + const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect); + const dist = Math.max(40, (trainLen / 2 / Math.tan(hFov / 2)) * 1.08); cameraGoal = () => { train.getWorldPosition(worldPos); const cx = worldPos.x - trainLen / 2; // train extends in -x from its origin return { - pos: new THREE.Vector3(cx, Math.max(20, dist * 0.25), dist), - target: new THREE.Vector3(cx, 2, 0), + pos: new THREE.Vector3(cx, Math.max(10, dist * 0.1), dist), + target: new THREE.Vector3(cx, 2.5, 0), }; }; }, From c055abe8c1b65e18a22a34a572987146116d6784 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 2 Aug 2026 21:48:22 +0000 Subject: [PATCH 5/6] train 3d visualizations --- .../trainScheduling/Train3DVisualization.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx index edb3a6b2b..6c5c07f69 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx @@ -113,7 +113,7 @@ function buildAcacia(rng: () => number): THREE.Group { tree.add(branch); } const canopyMat = new THREE.MeshStandardMaterial({ - color: rng() > 0.5 ? 0x5d7a35 : 0x6b8342, + color: rng() > 0.5 ? 0x2f7d32 : 0x3c8d40, roughness: 1, }); const canopy = new THREE.Mesh(new THREE.SphereGeometry(2.6 + rng() * 1.6, 10, 8), canopyMat); @@ -124,7 +124,7 @@ function buildAcacia(rng: () => number): THREE.Group { } function buildShrub(rng: () => number): THREE.Mesh { - const dry = [0x9c8a4d, 0x8f7a3e, 0xa89a5e, 0x7d8a4a]; + const dry = [0x3c7d33, 0x4f9e3f, 0x2e6b28, 0x5da84a]; const shrub = new THREE.Mesh( new THREE.SphereGeometry(0.5 + rng() * 0.8, 7, 6), new THREE.MeshStandardMaterial({ color: dry[Math.floor(rng() * dry.length)], roughness: 1 }), @@ -196,8 +196,8 @@ function buildScenery(scene: THREE.Scene, trackLen: number) { const rng = makeRng(1234567); buildClouds(scene, rng); const spread = Math.max(trackLen, 600); - // sand variation: lighter wind-blown patches - const patchMat = new THREE.MeshStandardMaterial({ color: 0xe6cfa3, roughness: 1 }); + // grass variation: darker meadow patches + const patchMat = new THREE.MeshStandardMaterial({ color: 0x4f8f3a, roughness: 1 }); const patchGeo = new THREE.CircleGeometry(4, 8); const patches = new THREE.InstancedMesh(patchGeo, patchMat, 160); const m4 = new THREE.Matrix4(); @@ -212,8 +212,8 @@ function buildScenery(scene: THREE.Scene, trackLen: number) { patches.setMatrixAt(i, m4); } scene.add(patches); - // low dunes on the horizon sides - const duneMat = new THREE.MeshStandardMaterial({ color: 0xcaa878, roughness: 1 }); + // low green hills on the horizon sides + const duneMat = new THREE.MeshStandardMaterial({ color: 0x4a8a38, roughness: 1 }); for (let i = 0; i < 12; i++) { const dune = new THREE.Mesh(new THREE.SphereGeometry(30 + rng() * 50, 12, 8), duneMat); dune.scale.set(1.6 + rng(), 0.12 + rng() * 0.08, 1 + rng()); @@ -289,7 +289,7 @@ function buildTrack(scene: THREE.Scene, length: number) { // ground const ground = new THREE.Mesh( new THREE.PlaneGeometry(4000, 1600), - new THREE.MeshStandardMaterial({ color: 0xd8b98a, roughness: 1 }), + new THREE.MeshStandardMaterial({ color: 0x5da043, roughness: 1 }), ); ground.rotation.x = -Math.PI / 2; ground.position.y = -0.02; @@ -593,7 +593,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization host.appendChild(renderer.domElement); // lights: bright sunny day - scene.add(new THREE.HemisphereLight(0xcfe8ff, 0xcaa878, 1.0)); + scene.add(new THREE.HemisphereLight(0xcfe8ff, 0x6f9c4e, 1.0)); const sun = new THREE.DirectionalLight(0xfff4d6, 2.4); sun.position.set(-120, 180, 90); scene.add(sun); From f4fd469643fd7d83b77639c52c01be064fd6beb3 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 2 Aug 2026 22:29:58 +0000 Subject: [PATCH 6/6] per-user trade-direction access scope --- apps/edr-freight-api/src/app.module.ts | 2 + .../3150000000000-CreateUserTradeAccess.ts | 43 ++++ ...000000000-YardFacilityOriginDestination.ts | 45 ++++ .../src/modules/billing/billing.controller.ts | 22 +- .../src/modules/billing/billing.module.ts | 2 + .../src/modules/billing/billing.service.ts | 11 + .../booking-reference-data.service.ts | 33 ++- .../modules/bookings/bookings.controller.ts | 14 +- .../src/modules/bookings/bookings.module.ts | 2 + .../modules/bookings/bookings.repository.ts | 6 + .../src/modules/bookings/bookings.service.ts | 2 + .../dto/booking-reference-data.dto.ts | 12 + .../modules/contracts/contracts.controller.ts | 14 +- .../src/modules/contracts/contracts.module.ts | 2 + .../modules/contracts/contracts.repository.ts | 6 + .../modules/contracts/contracts.service.ts | 2 + .../modules/overview/overview.controller.ts | 68 +++++- .../src/modules/overview/overview.module.ts | 2 + .../modules/overview/overview.repository.ts | 103 ++++++-- .../src/modules/overview/overview.service.ts | 71 +++--- .../src/modules/payment/payment.controller.ts | 14 +- .../src/modules/payment/payment.module.ts | 2 + .../src/modules/payment/payment.service.ts | 6 + .../entities/yard-facility.entity.ts | 21 ++ .../services/yard-facilities.service.ts | 60 ++++- .../train-scheduling/booking-batch.service.ts | 6 + .../train-scheduling.controller.ts | 36 ++- .../train-scheduling.module.ts | 2 + .../train-scheduling.service.ts | 14 +- .../dto/upsert-user-trade-access.dto.ts | 16 ++ .../entities/user-trade-access.entity.ts | 27 +++ .../user-trade-access/trade-scope.util.ts | 100 ++++++++ .../user-trade-access.controller.ts | 67 ++++++ .../user-trade-access.module.ts | 15 ++ .../user-trade-access.repository.ts | 23 ++ .../user-trade-access.service.ts | 76 ++++++ .../src/seed/yard-facilities.seeder.ts | 13 +- apps/edr-freight-web/backoffice/src/App.tsx | 14 ++ .../trainScheduling/Train3DVisualization.tsx | 222 ++++++++++++++++-- .../backoffice/src/hooks/useMyTradeAccess.ts | 32 +++ .../pages/bookings/BookingRequestsPage.tsx | 4 +- .../pages/configuration/TradeAccessPage.tsx | 200 ++++++++++++++++ .../contracts/ClearanceDocumentsPage.tsx | 4 +- .../pages/contracts/ContractRequestsPage.tsx | 4 +- .../src/services/userTradeAccess.service.ts | 43 ++++ .../new-contract-form/step4-route.tsx | 49 +++- packages/types/src/freight/index.ts | 26 ++ 47 files changed, 1451 insertions(+), 107 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3150000000000-CreateUserTradeAccess.ts create mode 100644 apps/edr-freight-api/src/migrations/3170000000000-YardFacilityOriginDestination.ts create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/dto/upsert-user-trade-access.dto.ts create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/entities/user-trade-access.entity.ts create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.ts create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.controller.ts create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.module.ts create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.repository.ts create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useMyTradeAccess.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/userTradeAccess.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a3c09df97..66f64678a 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -89,6 +89,7 @@ import { CargoesModule } from "./modules/cargoes/cargoes.module"; import { RoutesModule } from "./modules/routes/routes.module"; import { WarehousesModule } from "./modules/warehouses/warehouses.module"; import { OverviewModule } from "./modules/overview/overview.module"; +import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module"; import { VehiclesModule } from "./modules/vehicles/vehicles.module"; import { DriversModule } from "./modules/drivers/drivers.module"; import { FuelModule } from "./modules/fuel/fuel.module"; @@ -206,6 +207,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar RoutesModule, WarehousesModule, OverviewModule, + UserTradeAccessModule, VehiclesModule, DriversModule, FuelModule, diff --git a/apps/edr-freight-api/src/migrations/3150000000000-CreateUserTradeAccess.ts b/apps/edr-freight-api/src/migrations/3150000000000-CreateUserTradeAccess.ts new file mode 100644 index 000000000..9e7cba924 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3150000000000-CreateUserTradeAccess.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Per-backoffice-user trade-direction scope (import / export / intercity). + * A user with no row (or all three directions) is unrestricted. Admins + * (super_admin / organization_admin) bypass the scope entirely. + */ +export class CreateUserTradeAccess3150000000000 implements MigrationInterface { + name = 'CreateUserTradeAccess3150000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'user_trade_access', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + // IAM user id (iam.users) — no FK, iam schema is externally owned. + { name: 'user_id', type: 'uuid', isUnique: true }, + // Comma-separated subset of IMPORT,EXPORT,DOMESTIC (simple-array). + { name: 'directions', type: 'text', default: "''" }, + { name: 'updated_by_id', type: 'uuid', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.user_trade_access', + new TableIndex({ + name: 'idx_user_trade_access_user_id', + columnNames: ['user_id'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.user_trade_access', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/3170000000000-YardFacilityOriginDestination.ts b/apps/edr-freight-api/src/migrations/3170000000000-YardFacilityOriginDestination.ts new file mode 100644 index 000000000..fb361b702 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3170000000000-YardFacilityOriginDestination.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Handling a freight type is not the same as handling it in both directions. A + * facility can be equipped to load containers onto a train but have no yard + * space to receive and stage inbound ones, so the origin and destination sides + * are stated independently per freight type. + * + * Backfilled from `handles_container` / `handles_bulk` so every existing row + * keeps its current behaviour: a facility that handles a type today handles it + * on both sides until someone narrows it in the backoffice. New rows default + * false — an unconfigured facility offers nothing rather than silently + * offering everything. + */ +export class YardFacilityOriginDestination3170000000000 implements MigrationInterface { + name = 'YardFacilityOriginDestination3170000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + ADD COLUMN IF NOT EXISTS has_container_facility_origin boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS has_bulk_facility_origin boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS has_container_facility_destination boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS has_bulk_facility_destination boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + UPDATE freight.yard_facilities + SET has_container_facility_origin = handles_container, + has_container_facility_destination = handles_container, + has_bulk_facility_origin = handles_bulk, + has_bulk_facility_destination = handles_bulk + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + DROP COLUMN IF EXISTS has_container_facility_origin, + DROP COLUMN IF EXISTS has_bulk_facility_origin, + DROP COLUMN IF EXISTS has_container_facility_destination, + DROP COLUMN IF EXISTS has_bulk_facility_destination + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index b9f0a74c0..49772316a 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -9,7 +9,11 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; +import { CurrentUser } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + import { BookingView } from "../../common/booking-guards"; +import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { BillingService } from "./billing.service"; import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @@ -18,14 +22,26 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @BookingView() @ApiBearerAuth() export class BillingController { - constructor(private readonly billingService: BillingService) {} + constructor( + private readonly billingService: BillingService, + private readonly userTradeAccessService: UserTradeAccessService, + ) {} @Get("invoices") @ApiOperation({ summary: "List invoices (paginated, filterable by company/status/search)", }) - findAll(@Query() query: FilterInvoiceDto) { - return this.billingService.findAllPaginated(query); + async findAll( + @Query() query: FilterInvoiceDto, + @CurrentUser() user: TCurrentUser, + ) { + // Per-user trade-direction scope, applied via each invoice's source booking. + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.billingService.findAllPaginated({ + ...query, + tradeDirections: allowed ?? undefined, + }); } @Get("invoices/:id") diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 771156fd3..a67e6f6ba 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { BillingController } from "./billing.controller"; import { PortalBillingController } from "./portal-billing.controller"; import { PaymentController } from "./payment.controller"; +import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; import { BillingService } from "./billing.service"; import { DocumentsModule } from "./documents/documents.module"; import { Invoice } from "./entities/invoice.entity"; @@ -19,6 +20,7 @@ import { CompaniesModule } from "../companies/companies.module"; forwardRef(() => PaymentModule), CompaniesModule, DocumentsModule, + UserTradeAccessModule, ], controllers: [BillingController, PortalBillingController, PaymentController], providers: [BillingService, InvoiceRepository, InvoiceLineRepository], diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 850791f02..1cc2f0375 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -11,6 +11,7 @@ import { EventEmitter2 } from "@nestjs/event-emitter"; import { DataSource, EntityManager, In } from "typeorm"; import { CompaniesService } from "../companies/companies.service"; +import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentService } from "../payment/payment.service"; import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; import { @@ -167,6 +168,8 @@ export class BillingService { search?: string; page?: number; pageSize?: number; + /** Per-user trade-direction scope, applied via the source booking. */ + tradeDirections?: string[]; } = {}, ): Promise<{ items: Invoice[]; total: number }> { const page = filter.page && filter.page > 0 ? filter.page : 1; @@ -196,6 +199,14 @@ export class BillingService { ); } + if (filter.tradeDirections) { + applyBookingRefDirectionScope( + qb, + "invoice.source_id", + filter.tradeDirections, + ); + } + const [items, total] = await qb.getManyAndCount(); return { items, total }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 06268342b..2c60b8771 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -22,6 +22,7 @@ import { IYardsRepository, YARDS_REPOSITORY, } from "../rule-engine/interfaces/yards.repository.interface"; +import { YardFacilitiesService } from "../rule-engine/services/yard-facilities.service"; import { BookingReferenceCargoTypeChildDto, BookingReferenceCargoTypeGroupDto, @@ -170,11 +171,18 @@ export class BookingReferenceDataService { private readonly shippingLinesRepository: IShippingLinesRepository, @Inject(CARGO_TYPES_REPOSITORY) private readonly cargoTypesRepository: ICargoTypesRepository, + private readonly yardFacilitiesService: YardFacilitiesService, ) { } async getReferenceData(): Promise { - const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] = - await Promise.all([ + const [ + yards, + containerTypes, + serviceTypes, + shippingLines, + cargoTypes, + facilityYards, + ] = await Promise.all([ this.yardsRepository.findAll({ where: { isActive: true }, order: { displayOrder: "ASC", code: "ASC" }, @@ -195,17 +203,30 @@ export class BookingReferenceDataService { where: { isActive: true }, order: { displayOrder: "ASC", code: "ASC" }, }), + this.yardFacilitiesService.listFacilityYards(), ]); + // Every active yard is still listed; a yard with no facility record simply + // reports no capability, so the forms drop it from the pickers themselves. + const facilityByYardId = new Map(facilityYards.map((f) => [f.yardId, f])); + return { - yard: yards.map( - (y): BookingReferenceYardDto => ({ + yard: yards.map((y): BookingReferenceYardDto => { + const facility = facilityByYardId.get(y.id); + return { id: y.id, name: y.label, code: y.code, country: y.country, - }), - ), + hasContainerFacilityOrigin: + facility?.hasContainerFacilityOrigin ?? false, + hasBulkFacilityOrigin: facility?.hasBulkFacilityOrigin ?? false, + hasContainerFacilityDestination: + facility?.hasContainerFacilityDestination ?? false, + hasBulkFacilityDestination: + facility?.hasBulkFacilityDestination ?? false, + }; + }), containers: groupContainersBySize(containerTypes), service: serviceTypes.map( (s): BookingReferenceServiceDto => ({ 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 7b1f34f36..837d0d801 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -43,6 +43,8 @@ import { RoAmendmentDto, } from '../contracts/dto/phased-clearance.dto'; import { BookingReferenceDataService } from './booking-reference-data.service'; +import { scopedDirections } from '../user-trade-access/trade-scope.util'; +import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; import { BookingsService } from './bookings.service'; import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; import { CreateBookingDto } from './dto/create-booking.dto'; @@ -150,6 +152,7 @@ export class BookingsController { private readonly containerReceiptService: ContainerReceiptService, private readonly firstMileService: FirstMileService, private readonly lastMileService: LastMileService, + private readonly userTradeAccessService: UserTradeAccessService, ) {} @Post() @@ -217,7 +220,16 @@ export class BookingsController { // Staff (backoffice) see every booking. Customers (portal) are always // force-scoped to their own company, regardless of any companyId they pass. if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - return this.bookingsService.findAll(filter); + // Per-user trade-direction scope (import/export/intercity checkboxes). + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + const dirs = scopedDirections(allowed, filter.tradeDirection); + return this.bookingsService.findAll( + filter, + undefined, + undefined, + dirs ?? undefined, + ); } // Global Logistics has clearance:view but NOT bookings:view — it is scoped // to the customs document-clearance queue only and never sees the general 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 38d7d2ac6..2063bb01d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,4 +1,5 @@ import { Module, forwardRef } from "@nestjs/common"; +import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; import { ConfigService } from "@nestjs/config"; import { TypeOrmModule } from "@nestjs/typeorm"; import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; @@ -80,6 +81,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; MinioModule, VehiclesModule, CompaniesModule, + UserTradeAccessModule, // CustomersModule, RuleEngineModule, FileUploadSettingsModule, 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 78004f024..185a6aaa7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -17,6 +17,7 @@ import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; +import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview, @@ -64,6 +65,8 @@ export interface BookingListFilterOptions { freightType?: string; bookingType?: string; tradeDirection?: string; + /** Per-user trade-direction scope — `[]` matches nothing. */ + tradeDirections?: string[]; paymentCurrency?: string; paymentStatus?: string; excludePaymentStatus?: string; @@ -1000,6 +1003,9 @@ export class BookingsRepository extends BaseRepository { tradeDirection: options.tradeDirection, }); } + if (options.tradeDirections) { + applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections); + } if (options.paymentCurrency) { qb.andWhere('booking.payment_currency = :paymentCurrency', { paymentCurrency: options.paymentCurrency, 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 b8f73dfc8..67ac73a58 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1619,6 +1619,7 @@ export class BookingsService { filter: FilterBookingDto, forceCompanyId?: string, forceCompanyProfileId?: string, + tradeDirections?: string[], ): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; @@ -1638,6 +1639,7 @@ export class BookingsService { // ANDs both, so cross-company access is impossible. companyId: forceCompanyId ?? filter.companyId, companyProfileId: forceCompanyProfileId ?? filter.companyProfileId, + tradeDirections, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index a793cc558..4c51d85a6 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -13,6 +13,18 @@ export class BookingReferenceYardDto { @ApiProperty({ example: 'Ethiopia' }) country!: string; + + @ApiProperty({ description: 'Can load containers onto a train here.' }) + hasContainerFacilityOrigin!: boolean; + + @ApiProperty({ description: 'Can load bulk cargo onto a train here.' }) + hasBulkFacilityOrigin!: boolean; + + @ApiProperty({ description: 'Can receive containers off a train here.' }) + hasContainerFacilityDestination!: boolean; + + @ApiProperty({ description: 'Can receive bulk cargo off a train here.' }) + hasBulkFacilityDestination!: boolean; } export class BookingReferenceContainerTypeDto { diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 8c004ade0..ec61fdd4a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -59,6 +59,8 @@ import { GlOperationsService } from './gl-operations.service'; import { BookingRequestService } from './booking-request.service'; import { SignaturesService } from '../signatures/signatures.service'; import { BookingsService } from '../bookings/bookings.service'; +import { scopedDirections } from '../user-trade-access/trade-scope.util'; +import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; import { CreateContractDto } from './dto/create-contract.dto'; import { UpdateContractDto } from './dto/update-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto'; @@ -108,6 +110,7 @@ export class ContractsController { private readonly glOperationsService: GlOperationsService, private readonly bookingRequestService: BookingRequestService, private readonly signaturesService: SignaturesService, + private readonly userTradeAccessService: UserTradeAccessService, private readonly bookingClearanceService: BookingClearanceService, private readonly bookingsService: BookingsService, ) {} @@ -210,7 +213,16 @@ export class ContractsController { hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || hasFreightPermission(user, FREIGHT_PERMS.contracts.view) ) { - return this.contractsService.findAll(filter); + // Per-user trade-direction scope (import/export/intercity checkboxes). + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + const dirs = scopedDirections(allowed, filter.tradeDirection); + return this.contractsService.findAll( + filter, + undefined, + undefined, + dirs ?? undefined, + ); } const userId = user?.id; if (!userId) throw new UnauthorizedException('Authentication required'); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 658acf39d..0e60462a4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -1,4 +1,5 @@ import { Module, forwardRef } from '@nestjs/common'; +import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; @@ -89,6 +90,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum NotificationsModule, NotificationInboxModule, CompaniesModule, + UserTradeAccessModule, // Provides the admin-editable contract document templates consumed by // ContractDocumentViewModelBuilder when rendering contract PDFs. ContractTemplatesModule, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 49ef3a29f..a7fddd816 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -5,6 +5,7 @@ import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm' import { Booking } from '../bookings/entities/booking.entity'; import { FileRecord } from '../files/entities/file.entity'; +import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { Contract } from './entities/contract.entity'; import { ContractApprovalStep } from './entities/contract-approval-step.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; @@ -38,6 +39,8 @@ export interface ContractListFilterOptions { serviceTypeId?: string; freightType?: string; tradeDirection?: string; + /** Per-user trade-direction scope — `[]` matches nothing. */ + tradeDirections?: string[]; paymentCurrency?: string; customsClearingEnabled?: boolean; /** true → only contracts with at least one uploaded clearance document. */ @@ -436,6 +439,9 @@ export class ContractsRepository extends BaseRepository { tradeDirection: options.tradeDirection, }); } + if (options.tradeDirections) { + applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections); + } if (options.paymentCurrency) { qb.andWhere('contract.payment_currency = :paymentCurrency', { paymentCurrency: options.paymentCurrency, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 5c0b5e29e..6e170f126 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -748,6 +748,7 @@ export class ContractsService { filter: FilterContractDto, forceCompanyId?: string, forceCompanyProfileId?: string, + tradeDirections?: string[], ): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; @@ -763,6 +764,7 @@ export class ContractsService { serviceTypeId: filter.serviceTypeId, freightType: filter.freightType, tradeDirection: filter.tradeDirection, + tradeDirections, paymentCurrency: filter.paymentCurrency, createdFrom: filter.createdFrom, createdTo: filter.createdTo, diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts index e63cc5d01..4e1ecc6d6 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.controller.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -5,6 +5,8 @@ import { ApiOperation, ApiTags, } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { BookingView } from '../../common/booking-guards'; import { OverviewQueryDto } from './dto/overview-query.dto'; @@ -18,43 +20,79 @@ import { OverviewStaffTabDto, } from './dto/overview-tab-response.dto'; import { OverviewService } from './overview.service'; +import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; @ApiTags('Overview') @ApiBearerAuth() @Controller('overview') export class OverviewController { - constructor(private readonly overviewService: OverviewService) {} + constructor( + private readonly overviewService: OverviewService, + private readonly userTradeAccessService: UserTradeAccessService, + ) {} @Get() @BookingView() @ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' }) @ApiOkResponse({ type: OverviewResponseDto }) - getDashboard(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getDashboard(query.range ?? '30d'); + async getDashboard( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getDashboard( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('bookings') @BookingView() @ApiOperation({ summary: 'Bookings tab metrics and charts' }) @ApiOkResponse({ type: OverviewBookingsTabDto }) - getBookingsTab(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getBookingsTab(query.range ?? '30d'); + async getBookingsTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getBookingsTab( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('contracts') @BookingView() @ApiOperation({ summary: 'Contracts tab metrics and charts' }) @ApiOkResponse({ type: OverviewContractsTabDto }) - getContractsTab(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getContractsTab(query.range ?? '30d'); + async getContractsTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getContractsTab( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('billing') @BookingView() @ApiOperation({ summary: 'Billing tab metrics and charts' }) @ApiOkResponse({ type: OverviewBillingTabDto }) - getBillingTab(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getBillingTab(query.range ?? '30d'); + async getBillingTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getBillingTab( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('operations') @@ -69,8 +107,16 @@ export class OverviewController { @BookingView() @ApiOperation({ summary: 'Customers tab metrics and charts' }) @ApiOkResponse({ type: OverviewCustomersTabDto }) - getCustomersTab(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getCustomersTab(query.range ?? '30d'); + async getCustomersTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getCustomersTab( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('staff') diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts index 43fcdac0f..ff19bb801 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.module.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts @@ -11,6 +11,7 @@ import { Contract } from "../contracts/entities/contract.entity"; import { PaymentEntity } from "../payment/entities/payment.entity"; import { Train } from "../trains/entities/train.entity"; import { Wagon } from "../wagons/entities/wagon.entity"; +import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; import { OverviewController } from "./overview.controller"; import { OverviewRepository } from "./overview.repository"; import { OverviewService } from "./overview.service"; @@ -29,6 +30,7 @@ import { OverviewService } from "./overview.service"; Employee, User, ]), + UserTradeAccessModule, ], controllers: [OverviewController], providers: [OverviewService, OverviewRepository], diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index 4e56f415f..a8957d760 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -24,6 +24,10 @@ import { OVERVIEW_URGENT_PRIORITY_THRESHOLD, } from "./overview.constants"; import { Company } from "../companies/entities/company.entity"; +import { + bookingRefScopeSql, + directionScopeSql, +} from "../user-trade-access/trade-scope.util"; /** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */ const EXCLUDE_GENERAL_CONTRACT_BOOKINGS = @@ -93,7 +97,8 @@ export class OverviewRepository { private readonly userRepository: Repository, ) { } - async getBookingKpis(): Promise { + async getBookingKpis(dirs?: string[]): Promise { + const scope = directionScopeSql("booking.trade_direction", dirs); const row = await this.bookingRepository .createQueryBuilder("booking") .select( @@ -118,6 +123,7 @@ export class OverviewRepository { ) .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(scope.sql, scope.params) .setParameters({ closedStatuses: [...OVERVIEW_CLOSED_STATUSES], needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES], @@ -202,12 +208,13 @@ export class OverviewRepository { }; } - async getBillingKpis(): Promise<{ + async getBillingKpis(dirs?: string[]): Promise<{ revenueMtdEtb: number; revenueMtdUsd: number; pendingPayments: number; successfulPaymentsMtd: number; }> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const revenueRow = await this.paymentRepository .createQueryBuilder("payment") .select( @@ -223,6 +230,7 @@ export class OverviewRepository { .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) + .andWhere(scope.sql, scope.params) .getRawOne>(); const pendingPayments = await this.paymentRepository @@ -230,6 +238,7 @@ export class OverviewRepository { .where("payment.status IN (:...statuses)", { statuses: ["action-required", "processing"], }) + .andWhere(scope.sql, scope.params) .getCount(); return { @@ -261,13 +270,16 @@ export class OverviewRepository { async getBookingTrend( days: number, + dirs?: string[], ): Promise<{ date: string; count: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(scope.sql, scope.params) .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .groupBy("booking.created_at::date") .orderBy("booking.created_at::date", "ASC") @@ -279,13 +291,15 @@ export class OverviewRepository { })); } - async getStatusCounts(): Promise> { + async getStatusCounts(dirs?: string[]): Promise> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.status", "status") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(scope.sql, scope.params) .groupBy("booking.status") .getRawMany<{ status: string; count: string }>(); @@ -296,7 +310,9 @@ export class OverviewRepository { async getPaymentTrend( days: number, + dirs?: string[], ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select( @@ -316,6 +332,7 @@ export class OverviewRepository { `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, { days }, ) + .andWhere(scope.sql, scope.params) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); @@ -327,7 +344,11 @@ export class OverviewRepository { })); } - async getRecentBookings(limit: number): Promise { + async getRecentBookings( + limit: number, + dirs?: string[], + ): Promise { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .leftJoin("booking.company", "company") @@ -341,6 +362,7 @@ export class OverviewRepository { .addSelect("booking.created_at", "createdAt") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(scope.sql, scope.params) .orderBy("booking.created_at", "DESC") .limit(limit) .getRawMany<{ @@ -366,9 +388,10 @@ export class OverviewRepository { })); } - async getBookingsByFreightType(): Promise< - { label: string; count: number }[] - > { + async getBookingsByFreightType( + dirs?: string[], + ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.freight_type", "label") @@ -376,6 +399,7 @@ export class OverviewRepository { .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) .andWhere("booking.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("booking.freight_type") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); @@ -386,7 +410,10 @@ export class OverviewRepository { })); } - async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> { + async getBookingsByCurrency( + dirs?: string[], + ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.payment_currency", "label") @@ -394,6 +421,7 @@ export class OverviewRepository { .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) .andWhere("booking.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("booking.payment_currency") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); @@ -404,11 +432,15 @@ export class OverviewRepository { })); } - async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> { + async getPaymentsByStatus( + dirs?: string[], + ): Promise<{ status: string; count: number }[]> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select("payment.status", "status") .addSelect("COUNT(*)::int", "count") + .where(scope.sql, scope.params) .groupBy("payment.status") .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); @@ -419,9 +451,12 @@ export class OverviewRepository { })); } - async getPaymentsByMethod(): Promise< + async getPaymentsByMethod( + dirs?: string[], + ): Promise< { method: string; count: number; amountEtb: number; amountUsd: number }[] > { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select("payment.method", "method") @@ -434,6 +469,7 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, "amountUsd", ) + .where(scope.sql, scope.params) .groupBy("payment.method") .orderBy("count", "DESC") .getRawMany<{ @@ -451,9 +487,10 @@ export class OverviewRepository { })); } - async getRevenueByCurrency(): Promise< - { currency: string; amount: number }[] - > { + async getRevenueByCurrency( + dirs?: string[], + ): Promise<{ currency: string; amount: number }[]> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select("payment.currency", "currency") @@ -462,6 +499,7 @@ export class OverviewRepository { .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) + .andWhere(scope.sql, scope.params) .groupBy("payment.currency") .getRawMany<{ currency: string; amount: string }>(); @@ -556,7 +594,9 @@ export class OverviewRepository { async getTopCustomersByBookings( limit: number, + dirs?: string[], ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .leftJoin("booking.company", "company") @@ -564,6 +604,7 @@ export class OverviewRepository { .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("company.name") .orderBy("count", "DESC") .limit(limit) @@ -632,7 +673,8 @@ export class OverviewRepository { // ── Contracts (overview Contract tab) ────────────────────────────────────── - async getContractKpis(): Promise { + async getContractKpis(dirs?: string[]): Promise { + const scope = directionScopeSql("contract.trade_direction", dirs); const row = await this.contractRepository .createQueryBuilder("contract") .select( @@ -656,6 +698,7 @@ export class OverviewRepository { "createdToday", ) .where("contract.deleted_at IS NULL") + .andWhere(scope.sql, scope.params) .setParameters({ closedStatuses: [...OVERVIEW_CONTRACT_CLOSED_STATUSES], needsActionStatuses: [...OVERVIEW_CONTRACT_NEEDS_ACTION_STATUSES], @@ -673,24 +716,33 @@ export class OverviewRepository { }; } - async getContractStatusCounts(): Promise> { + async getContractStatusCounts( + dirs?: string[], + ): Promise> { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select("contract.status", "status") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") + .andWhere(scope.sql, scope.params) .groupBy("contract.status") .getRawMany<{ status: string; count: string }>(); return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)])); } - async getContractTrend(days: number): Promise<{ date: string; count: number }[]> { + async getContractTrend( + days: number, + dirs?: string[], + ): Promise<{ date: string; count: number }[]> { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select(`to_char(contract.created_at::date, 'YYYY-MM-DD')`, "date") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") + .andWhere(scope.sql, scope.params) .andWhere(`contract.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .groupBy("contract.created_at::date") .orderBy("contract.created_at::date", "ASC") @@ -699,13 +751,17 @@ export class OverviewRepository { return rows.map((row) => ({ date: row.date, count: Number(row.count) })); } - async getContractsByKind(): Promise<{ label: string; count: number }[]> { + async getContractsByKind( + dirs?: string[], + ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select("contract.contract_kind", "label") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") .andWhere("contract.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("contract.contract_kind") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); @@ -713,13 +769,17 @@ export class OverviewRepository { return rows.map((row) => ({ label: row.label, count: Number(row.count) })); } - async getContractsByFreightType(): Promise<{ label: string; count: number }[]> { + async getContractsByFreightType( + dirs?: string[], + ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select("contract.freight_type", "label") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") .andWhere("contract.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("contract.freight_type") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); @@ -727,7 +787,11 @@ export class OverviewRepository { return rows.map((row) => ({ label: row.label, count: Number(row.count) })); } - async getRecentContracts(limit: number): Promise { + async getRecentContracts( + limit: number, + dirs?: string[], + ): Promise { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .leftJoin("contract.company", "company") @@ -741,6 +805,7 @@ export class OverviewRepository { .addSelect("contract.contract_valid_until", "validUntil") .addSelect("contract.created_at", "createdAt") .where("contract.deleted_at IS NULL") + .andWhere(scope.sql, scope.params) .orderBy("contract.created_at", "DESC") .limit(limit) .getRawMany<{ diff --git a/apps/edr-freight-api/src/modules/overview/overview.service.ts b/apps/edr-freight-api/src/modules/overview/overview.service.ts index 8a7b71b69..4b1f5d6bf 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.service.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.service.ts @@ -40,7 +40,10 @@ export class OverviewService { return { bookingsByPipeline, bookingsByStatus }; } - async getDashboard(range: OverviewRangeQuery = '30d'): Promise { + async getDashboard( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; const [ @@ -55,16 +58,16 @@ export class OverviewService { paymentTrend, recentBookings, ] = await Promise.all([ - this.overviewRepository.getBookingKpis(), - this.overviewRepository.getContractKpis(), + this.overviewRepository.getBookingKpis(dirs), + this.overviewRepository.getContractKpis(dirs), this.overviewRepository.getOperationsKpis(), this.overviewRepository.getCustomerKpis(), - this.overviewRepository.getBillingKpis(), + this.overviewRepository.getBillingKpis(dirs), this.overviewRepository.getStaffKpis(), - this.overviewRepository.getBookingTrend(days), - this.overviewRepository.getStatusCounts(), - this.overviewRepository.getPaymentTrend(days), - this.overviewRepository.getRecentBookings(8), + this.overviewRepository.getBookingTrend(days, dirs), + this.overviewRepository.getStatusCounts(dirs), + this.overviewRepository.getPaymentTrend(days, dirs), + this.overviewRepository.getRecentBookings(8, dirs), ]); const { bookingsByPipeline, bookingsByStatus } = @@ -91,7 +94,10 @@ export class OverviewService { }; } - async getBookingsTab(range: OverviewRangeQuery = '30d'): Promise { + async getBookingsTab( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; const [ @@ -102,12 +108,12 @@ export class OverviewService { bookingsByCurrency, recentBookings, ] = await Promise.all([ - this.overviewRepository.getBookingKpis(), - this.overviewRepository.getBookingTrend(days), - this.overviewRepository.getStatusCounts(), - this.overviewRepository.getBookingsByFreightType(), - this.overviewRepository.getBookingsByCurrency(), - this.overviewRepository.getRecentBookings(8), + this.overviewRepository.getBookingKpis(dirs), + this.overviewRepository.getBookingTrend(days, dirs), + this.overviewRepository.getStatusCounts(dirs), + this.overviewRepository.getBookingsByFreightType(dirs), + this.overviewRepository.getBookingsByCurrency(dirs), + this.overviewRepository.getRecentBookings(8, dirs), ]); const { bookingsByPipeline, bookingsByStatus } = @@ -130,6 +136,7 @@ export class OverviewService { async getContractsTab( range: OverviewRangeQuery = '30d', + dirs?: string[], ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; @@ -141,12 +148,12 @@ export class OverviewService { contractsByFreightType, recentContracts, ] = await Promise.all([ - this.overviewRepository.getContractKpis(), - this.overviewRepository.getContractTrend(days), - this.overviewRepository.getContractStatusCounts(), - this.overviewRepository.getContractsByKind(), - this.overviewRepository.getContractsByFreightType(), - this.overviewRepository.getRecentContracts(8), + this.overviewRepository.getContractKpis(dirs), + this.overviewRepository.getContractTrend(days, dirs), + this.overviewRepository.getContractStatusCounts(dirs), + this.overviewRepository.getContractsByKind(dirs), + this.overviewRepository.getContractsByFreightType(dirs), + this.overviewRepository.getRecentContracts(8, dirs), ]); const contractsByStatus = Object.entries(statusCounts) @@ -170,16 +177,19 @@ export class OverviewService { }; } - async getBillingTab(range: OverviewRangeQuery = '30d'): Promise { + async getBillingTab( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; const [kpis, paymentTrend, paymentsByStatus, paymentsByMethod, revenueByCurrency] = await Promise.all([ - this.overviewRepository.getBillingKpis(), - this.overviewRepository.getPaymentTrend(days), - this.overviewRepository.getPaymentsByStatus(), - this.overviewRepository.getPaymentsByMethod(), - this.overviewRepository.getRevenueByCurrency(), + this.overviewRepository.getBillingKpis(dirs), + this.overviewRepository.getPaymentTrend(days, dirs), + this.overviewRepository.getPaymentsByStatus(dirs), + this.overviewRepository.getPaymentsByMethod(dirs), + this.overviewRepository.getRevenueByCurrency(dirs), ]); return { @@ -217,7 +227,10 @@ export class OverviewService { }; } - async getCustomersTab(range: OverviewRangeQuery = '30d'): Promise { + async getCustomersTab( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] = @@ -225,7 +238,7 @@ export class OverviewService { this.overviewRepository.getCustomerKpis(), this.overviewRepository.getCustomerGrowthTrend(days), this.overviewRepository.getCustomersByType(), - this.overviewRepository.getTopCustomersByBookings(8), + this.overviewRepository.getTopCustomersByBookings(8, dirs), ]); return { diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 2ae62d4f3..3602657de 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -15,8 +15,10 @@ import { ApiProduces, } from "@nestjs/swagger"; import { Response } from "express"; -import { Public } from "@edr/api-common"; +import { CurrentUser, Public } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { BookingStaff, BookingView } from "../../common/booking-guards"; +import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { PaymentService } from "./payment.service"; import { IntentStatusDto } from "./payments.dto"; @@ -24,7 +26,10 @@ import { IntentStatusDto } from "./payments.dto"; @ApiTags("Payment") @Controller("payments") export class PaymentController { - constructor(private readonly paymentService: PaymentService) { } + constructor( + private readonly paymentService: PaymentService, + private readonly userTradeAccessService: UserTradeAccessService, + ) { } // Customer-detail payments tab — same one-of rule as the bookings tab. @Get("by-company/:companyId/customer-view") @@ -52,18 +57,23 @@ export class PaymentController { @ApiQuery({ name: "page", required: false }) @ApiQuery({ name: "pageSize", required: false }) async getAll( + @CurrentUser() user: TCurrentUser, @Query("search") search?: string, @Query("status") status?: string, @Query("method") method?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string, ) { + // Per-user trade-direction scope, applied via the booking in ref_id. + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); return this.paymentService.getAll({ search, status, method, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 10, + tradeDirections: allowed ?? undefined, }); } diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 961aa32bd..220250d93 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -13,6 +13,7 @@ import { import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { BillingModule } from "../billing/billing.module"; +import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; // import { FirstMileModule } from "../first-mile/first-mile.module"; // import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; @@ -64,6 +65,7 @@ function rabbitMQImport(): DynamicModule[] { timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000, }), ConfigModule, + UserTradeAccessModule, forwardRef(() => BillingModule), // forwardRef(() => TrainSchedulingModule), // FirstMileModule, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 0f628e39c..57e0de603 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -7,6 +7,7 @@ import { Logger, NotFoundException, } from "@nestjs/common"; +import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; @@ -110,6 +111,8 @@ export class PaymentService { method?: string; page?: number; pageSize?: number; + /** Per-user trade-direction scope, applied via the booking in ref_id. */ + tradeDirections?: string[]; }) { const { search, status, method, page = 1, pageSize = 10 } = filters; const skip = (page - 1) * pageSize; @@ -128,6 +131,9 @@ export class PaymentService { if (method) { qb.andWhere("payment.method = :method", { method }); } + if (filters.tradeDirections) { + applyBookingRefDirectionScope(qb, "payment.ref_id", filters.tradeDirections); + } const [items, total] = await qb .orderBy("payment.createdAt", "DESC") diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts index 5712eed03..9c69f0135 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts @@ -37,6 +37,27 @@ export class YardFacility extends BaseEntity { @Column({ name: 'handles_bulk', type: 'boolean', default: true }) handlesBulk!: boolean; + /** + * Per-side capability. Loading a type onto a train and receiving it off one + * need different ground: a facility can be equipped to send containers but + * have no space to stage arriving ones. `handles_container` / `handles_bulk` + * stay the coarse "is this type handled here at all" switch; these four say + * on which side. A yard is offered as a contract origin for a freight type + * when it handles the type AND the matching `_origin` flag is set, and as a + * destination on the same rule with `_destination`. + */ + @Column({ name: 'has_container_facility_origin', type: 'boolean', default: false }) + hasContainerFacilityOrigin!: boolean; + + @Column({ name: 'has_bulk_facility_origin', type: 'boolean', default: false }) + hasBulkFacilityOrigin!: boolean; + + @Column({ name: 'has_container_facility_destination', type: 'boolean', default: false }) + hasContainerFacilityDestination!: boolean; + + @Column({ name: 'has_bulk_facility_destination', type: 'boolean', default: false }) + hasBulkFacilityDestination!: boolean; + @Column({ name: 'equipment_notes', type: 'text', nullable: true }) equipmentNotes?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts index 4b69c328f..2579c8d22 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts @@ -13,8 +13,20 @@ export interface YardFacilityInfo { /** Containers need a reach stacker/gantry — not every facility has one. */ handlesContainer: boolean; handlesBulk: boolean; + /** + * The same capability split by side of the trip — loading onto a train and + * receiving off one need different ground. Always false where the coarse + * `handles*` flag for that type is false. + */ + hasContainerFacilityOrigin: boolean; + hasBulkFacilityOrigin: boolean; + hasContainerFacilityDestination: boolean; + hasBulkFacilityDestination: boolean; } +/** Which side of the trip a yard is being considered for. */ +export type YardSide = 'ORIGIN' | 'DESTINATION'; + /** * Which yards can handle cargo, and what kind. * @@ -38,7 +50,11 @@ export class YardFacilitiesService { y.has_facility AS "hasFacility", f.has_warehouse AS "hasWarehouse", f.handles_container AS "handlesContainer", - f.handles_bulk AS "handlesBulk" + f.handles_bulk AS "handlesBulk", + f.has_container_facility_origin AS "hasContainerFacilityOrigin", + f.has_bulk_facility_origin AS "hasBulkFacilityOrigin", + f.has_container_facility_destination AS "hasContainerFacilityDestination", + f.has_bulk_facility_destination AS "hasBulkFacilityDestination" FROM freight.yards y LEFT JOIN freight.yard_facilities f ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`; @@ -51,17 +67,33 @@ export class YardFacilitiesService { hasWarehouse: boolean | null; handlesContainer: boolean | null; handlesBulk: boolean | null; + hasContainerFacilityOrigin: boolean | null; + hasBulkFacilityOrigin: boolean | null; + hasContainerFacilityDestination: boolean | null; + hasBulkFacilityDestination: boolean | null; }): YardFacilityInfo { // No facility record means no capability, whatever the flag says. const hasFacility = Boolean(row.hasFacility); + const handlesContainer = hasFacility && Boolean(row.handlesContainer); + const handlesBulk = hasFacility && Boolean(row.handlesBulk); return { yardId: row.yardId, yardCode: row.yardCode, yardLabel: row.yardLabel, hasFacility, hasWarehouse: hasFacility && Boolean(row.hasWarehouse), - handlesContainer: hasFacility && Boolean(row.handlesContainer), - handlesBulk: hasFacility && Boolean(row.handlesBulk), + handlesContainer, + handlesBulk, + // Gated on the coarse flag so the two can't contradict each other: a + // per-side flag left set on a type the facility no longer handles at all + // never resurrects that type. + hasContainerFacilityOrigin: + handlesContainer && Boolean(row.hasContainerFacilityOrigin), + hasBulkFacilityOrigin: handlesBulk && Boolean(row.hasBulkFacilityOrigin), + hasContainerFacilityDestination: + handlesContainer && Boolean(row.hasContainerFacilityDestination), + hasBulkFacilityDestination: + handlesBulk && Boolean(row.hasBulkFacilityDestination), }; } @@ -97,4 +129,26 @@ export class YardFacilitiesService { ? facility.handlesContainer : facility.handlesBulk; } + + /** + * Can this facility take this cargo on this side of the trip? The rule behind + * the contract's origin/destination yard pickers — keep it here so the API + * and the forms can't drift apart on what is offerable. + */ + canHandleFreightOnSide( + facility: YardFacilityInfo | null, + freightType: string | null | undefined, + side: YardSide, + ): boolean { + if (!facility?.hasFacility) return false; + const isContainer = String(freightType).toUpperCase() === 'CONTAINER'; + if (side === 'ORIGIN') { + return isContainer + ? facility.hasContainerFacilityOrigin + : facility.hasBulkFacilityOrigin; + } + return isContainer + ? facility.hasContainerFacilityDestination + : facility.hasBulkFacilityDestination; + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 3d3b992d3..efdf4f691 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1437,6 +1437,7 @@ export class BookingBatchService implements OnModuleInit { */ async getBatchBoard( query: BatchBoardQueryDto = {}, + allowedDirections?: string[], ): Promise { // Board cards are heavy (per-schedule booking summaries), so the default // page is smaller than the toolkit-wide 20. @@ -1444,6 +1445,11 @@ export class BookingBatchService implements OnModuleInit { defaultPageSize: 12, }); + // The board is IMPORT-only — a user scoped away from IMPORT sees nothing. + if (allowedDirections && !allowedDirections.includes("IMPORT")) { + return { items: [], meta: buildPaginationMeta(0, page, pageSize) }; + } + // Status filter: any subset of the lifecycle. Omitted = all statuses, so // arrived / cancelled / dispatched schedules stay visible as history. const allowedStatuses = new Set(BATCH_BOARD_STATUSES); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 7b0f37b66..485a3ceea 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -1,6 +1,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; import type { AuthUserPayload } from "../../common/resolve-auth-user-id"; +import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { resolveAuthUserId } from "../../common/resolve-auth-user-id"; import { @@ -66,6 +67,7 @@ export class TrainSchedulingController { private readonly intercityService: IntercityService, private readonly bookingJourneyService: BookingJourneyService, private readonly billingService: BillingService, + private readonly userTradeAccessService: UserTradeAccessService, ) { } @Get("my-booking-windows") @@ -130,8 +132,14 @@ export class TrainSchedulingController { summary: "Batch monitoring board: paginated import schedules (all statuses) with bookings grouped by state", }) - getBatchBoard(@Query() query: BatchBoardQueryDto) { - return this.bookingBatchService.getBatchBoard(query); + async getBatchBoard( + @Query() query: BatchBoardQueryDto, + @CurrentUser() user: AuthUserPayload, + ) { + // Batch board is IMPORT-only — a user without IMPORT access sees nothing. + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.bookingBatchService.getBatchBoard(query, allowed ?? undefined); } @Get("batch-board/:scheduleId") @@ -868,15 +876,31 @@ export class TrainSchedulingController { @Get("container/schedules") @TrainSchedulingView() @ApiOperation({ summary: "List container train schedules (paginated)" }) - getContainerTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) { - return this.trainSchedulingService.getContainerTrainSchedules(query); + async getContainerTrainSchedules( + @Query() query: ListTrainSchedulesQueryDto, + @CurrentUser() user: AuthUserPayload, + ) { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.trainSchedulingService.getContainerTrainSchedules( + query, + allowed ?? undefined, + ); } @Get("bulk/schedules") @TrainSchedulingView() @ApiOperation({ summary: "List bulk train schedules (paginated)" }) - getBulkTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) { - return this.trainSchedulingService.getContainerTrainSchedules(query); + async getBulkTrainSchedules( + @Query() query: ListTrainSchedulesQueryDto, + @CurrentUser() user: AuthUserPayload, + ) { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.trainSchedulingService.getContainerTrainSchedules( + query, + allowed ?? undefined, + ); } @Get("container/schedules/:id") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 35503d432..56f2cff00 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; import { BillingModule } from '../billing/billing.module'; +import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; import { LocomotivesModule } from '../locomotives/locomotives.module'; @@ -63,6 +64,7 @@ import { ContractsModule } from '../contracts/contracts.module'; ]), forwardRef(() => BookingsModule), BillingModule, + UserTradeAccessModule, NotificationsModule, NotificationInboxModule, LocomotivesModule, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 9c60c9821..495feedf0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -3899,13 +3899,25 @@ export class TrainSchedulingService { return Object.assign(detail, { warehouseAutomation }); } - async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) { + async getContainerTrainSchedules( + query: ListTrainSchedulesQueryDto = {}, + allowedDirections?: string[], + ) { const { page, pageSize, skip, take } = normalizePagination(query); + // Per-user trade-direction scope: schedules carry a `direction` column. + if (allowedDirections && allowedDirections.length === 0) { + return { + items: [], + meta: buildPaginationMeta(0, page, pageSize), + }; + } + // Exact-match filters (enum/id semantics). Freight type is derived from // the bookings aboard — no column to match — so it rides on `id` as an // EXISTS fragment instead. const base: FindOptionsWhere = {}; + if (allowedDirections) base.direction = In(allowedDirections) as never; if (query.status) base.status = query.status; if (query.originStationId) base.originStationId = query.originStationId; if (query.destinationStationId) base.destinationStationId = query.destinationStationId; diff --git a/apps/edr-freight-api/src/modules/user-trade-access/dto/upsert-user-trade-access.dto.ts b/apps/edr-freight-api/src/modules/user-trade-access/dto/upsert-user-trade-access.dto.ts new file mode 100644 index 000000000..1c2db098e --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/dto/upsert-user-trade-access.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayUnique, IsIn } from 'class-validator'; +import { Freight } from '@edr/types'; + +export class UpsertUserTradeAccessDto { + @ApiProperty({ + description: + 'Trade directions the user may see. All three (or no config row) = unrestricted; empty array = sees nothing.', + isArray: true, + enum: ['IMPORT', 'EXPORT', 'DOMESTIC'], + example: ['IMPORT', 'DOMESTIC'], + }) + @ArrayUnique() + @IsIn(['IMPORT', 'EXPORT', 'DOMESTIC'], { each: true }) + directions!: Freight.ScheduleTradeDirection[]; +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/entities/user-trade-access.entity.ts b/apps/edr-freight-api/src/modules/user-trade-access/entities/user-trade-access.entity.ts new file mode 100644 index 000000000..6ef5ca1fb --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/entities/user-trade-access.entity.ts @@ -0,0 +1,27 @@ +import { BaseEntity } from '@edr/api-common'; +import { Freight } from '@edr/types'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * Which trade directions (IMPORT / EXPORT / DOMESTIC=Intercity) a backoffice + * user may see. No row, or all three directions, means unrestricted. + */ +@Entity({ schema: 'freight', name: 'user_trade_access' }) +export class UserTradeAccess extends BaseEntity { + /** IAM user id (iam.users) — no FK, iam schema is externally owned. */ + @Index() + @Column({ name: 'user_id', type: 'uuid', unique: true }) + userId!: string; + + @Column({ name: 'directions', type: 'text', default: '' }) + directionsRaw!: string; + + @Column({ name: 'updated_by_id', type: 'uuid', nullable: true }) + updatedById!: string | null; + + get directions(): Freight.ScheduleTradeDirection[] { + return this.directionsRaw + ? (this.directionsRaw.split(',') as Freight.ScheduleTradeDirection[]) + : []; + } +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.ts b/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.ts new file mode 100644 index 000000000..37ffa93c9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.ts @@ -0,0 +1,100 @@ +import { Freight } from '@edr/types'; +import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm'; + +/** + * Resolve the effective direction list for a query. + * + * @param allowed the user's scope — null = unrestricted + * @param requested an explicit ?tradeDirection=… filter, if any + * @returns directions to filter by, `null` = no filter, `[]` = show nothing + */ +export function scopedDirections( + allowed: Freight.ScheduleTradeDirection[] | null, + requested?: string | null, +): string[] | null { + if (!allowed) return requested ? [requested] : null; + if (!requested) return [...allowed]; + return allowed.includes(requested as Freight.ScheduleTradeDirection) + ? [requested] + : []; +} + +/** + * Apply a direction scope to a query builder column. + * `dirs = null` → untouched; `dirs = []` → matches nothing. + */ +export function applyDirectionScope( + qb: T, + column: string, + dirs: string[] | null, +): T { + if (dirs === null) return qb; + if (dirs.length === 0) { + qb.andWhere('1 = 0'); + return qb; + } + // Unique param name so multiple scopes can coexist on one query. + const param = `scopeDirs_${column.replace(/\W/g, '_')}`; + qb.andWhere(`${column} IN (:...${param})`, { [param]: dirs }); + return qb; +} + +/** + * SQL-fragment form of {@link applyDirectionScope} for fluent query chains: + * `.andWhere(f.sql, f.params)`. `dirs = null/undefined` → TRUE (no-op). + */ +export function directionScopeSql( + column: string, + dirs: string[] | null | undefined, +): { sql: string; params: Record } { + if (!dirs) return { sql: 'TRUE', params: {} }; + if (dirs.length === 0) return { sql: 'FALSE', params: {} }; + const param = `scopeDirs_${column.replace(/\W/g, '_')}`; + return { sql: `${column} IN (:...${param})`, params: { [param]: dirs } }; +} + +/** + * SQL-fragment form of {@link applyBookingRefDirectionScope}: hides rows whose + * varchar ref column points at a booking outside the scope; rows that do not + * point at a booking stay visible (they carry no direction to scope by). + */ +export function bookingRefScopeSql( + refColumn: string, + dirs: string[] | null | undefined, +): { sql: string; params: Record } { + if (!dirs) return { sql: 'TRUE', params: {} }; + const param = `scopeRefDirs_${refColumn.replace(/\W/g, '_')}`; + const disallowed = dirs.length + ? `b.trade_direction NOT IN (:...${param})` + : 'TRUE'; + return { + sql: `NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id::text = ${refColumn} AND ${disallowed})`, + params: dirs.length ? { [param]: dirs } : {}, + }; +} + +/** + * Scope rows whose direction lives on a related booking referenced by a + * varchar id column (invoices.source_id, payments.ref_id). Rows that do not + * point at a booking stay visible — they carry no direction to scope by. + */ +export function applyBookingRefDirectionScope( + qb: SelectQueryBuilder, + refColumn: string, + dirs: string[] | null, +): SelectQueryBuilder { + if (dirs === null) return qb; + const param = `scopeRefDirs_${refColumn.replace(/\W/g, '_')}`; + const disallowed = dirs.length + ? `b.trade_direction NOT IN (:...${param})` + : 'TRUE'; + qb.andWhere( + new Brackets((w) => { + w.where( + `NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id::text = ${refColumn} AND ${disallowed})`, + ); + }), + ); + if (dirs.length) qb.setParameter(param, dirs); + return qb; +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.controller.ts b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.controller.ts new file mode 100644 index 000000000..3c6255fd9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.controller.ts @@ -0,0 +1,67 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + Param, + ParseUUIDPipe, + Put, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { StaffReference } from '../../common/booking-guards'; +import { isFreightApprovalAdmin } from '../../common/freight-permission.util'; +import { UpsertUserTradeAccessDto } from './dto/upsert-user-trade-access.dto'; +import { UserTradeAccessService } from './user-trade-access.service'; + +@ApiTags('user-trade-access') +@Controller('user-trade-access') +@StaffReference() +@ApiBearerAuth() +export class UserTradeAccessController { + constructor(private readonly service: UserTradeAccessService) {} + + @Get() + @ApiOperation({ summary: 'List every configured user trade-direction scope' }) + list(@CurrentUser() user: TCurrentUser) { + this.assertAdmin(user); + return this.service.listConfigs(); + } + + @Get('me') + @ApiOperation({ summary: "Current user's effective trade-direction scope" }) + async me(@CurrentUser() user: TCurrentUser) { + const allowed = await this.service.resolveAllowedDirections(user); + return { + restricted: allowed !== null, + directions: allowed ?? ['IMPORT', 'EXPORT', 'DOMESTIC'], + }; + } + + @Put(':userId') + @ApiOperation({ + summary: 'Set the trade directions a backoffice user may see', + }) + upsert( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: UpsertUserTradeAccessDto, + @CurrentUser() user: TCurrentUser, + ) { + this.assertAdmin(user); + return this.service.upsert( + userId, + dto.directions, + (user as { id?: string } | null)?.id ?? null, + ); + } + + private assertAdmin(user: TCurrentUser) { + if (!isFreightApprovalAdmin(user)) { + throw new ForbiddenException( + 'Only super or organization admins can manage trade-direction access', + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.module.ts b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.module.ts new file mode 100644 index 000000000..8b3515930 --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { UserTradeAccess } from './entities/user-trade-access.entity'; +import { UserTradeAccessController } from './user-trade-access.controller'; +import { UserTradeAccessRepository } from './user-trade-access.repository'; +import { UserTradeAccessService } from './user-trade-access.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserTradeAccess])], + controllers: [UserTradeAccessController], + providers: [UserTradeAccessService, UserTradeAccessRepository], + exports: [UserTradeAccessService], +}) +export class UserTradeAccessModule {} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.repository.ts b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.repository.ts new file mode 100644 index 000000000..1df85b5ee --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.repository.ts @@ -0,0 +1,23 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { UserTradeAccess } from './entities/user-trade-access.entity'; + +@Injectable() +export class UserTradeAccessRepository extends BaseRepository { + constructor( + @InjectRepository(UserTradeAccess) repository: Repository, + ) { + super(repository); + } + + findByUserId(userId: string): Promise { + return this.repository.findOne({ where: { userId } }); + } + + findAllConfigs(): Promise { + return this.repository.find({ order: { updatedAt: 'DESC' } }); + } +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.service.ts b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.service.ts new file mode 100644 index 000000000..bcabdea8b --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { Freight } from '@edr/types'; +import { isFreightApprovalAdmin } from '../../common/freight-permission.util'; +import { UserTradeAccess } from './entities/user-trade-access.entity'; +import { UserTradeAccessRepository } from './user-trade-access.repository'; + +const ALL: Freight.ScheduleTradeDirection[] = ['IMPORT', 'EXPORT', 'DOMESTIC']; + +/** Loose current-user shape: JWT payloads and TCurrentUser both fit. */ +export type ScopeUser = + | ({ id?: string; sub?: string; roles?: { key?: string }[] } & object) + | null + | undefined; + +export type UserTradeAccessView = { + userId: string; + directions: Freight.ScheduleTradeDirection[]; + updatedAt: Date; +}; + +@Injectable() +export class UserTradeAccessService { + constructor(private readonly repository: UserTradeAccessRepository) {} + + async listConfigs(): Promise { + const rows = await this.repository.findAllConfigs(); + return rows.map((r) => this.toView(r)); + } + + async upsert( + userId: string, + directions: Freight.ScheduleTradeDirection[], + actorId?: string | null, + ): Promise { + // Normalize to canonical order so "all three" compares reliably. + const normalized = ALL.filter((d) => directions.includes(d)); + const existing = await this.repository.findByUserId(userId); + const saved = existing + ? await this.repository.update(existing.id, { + directionsRaw: normalized.join(','), + updatedById: actorId ?? null, + }) + : await this.repository.create({ + userId, + directionsRaw: normalized.join(','), + updatedById: actorId ?? null, + }); + return this.toView(saved as UserTradeAccess); + } + + /** + * Effective scope for the current user. + * `null` = unrestricted (no config, all three directions, admin, or no user + * on the request — routes without auth cannot be scoped). + */ + async resolveAllowedDirections( + user: ScopeUser, + ): Promise { + const userId = user?.id ?? user?.sub; + if (!userId) return null; + if (isFreightApprovalAdmin(user)) return null; + const row = await this.repository.findByUserId(userId); + if (!row) return null; + const dirs = row.directions; + if (dirs.length >= ALL.length) return null; + return dirs; + } + + private toView(row: UserTradeAccess): UserTradeAccessView { + return { + userId: row.userId, + directions: row.directions, + updatedAt: row.updatedAt, + }; + } +} diff --git a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts index 1822a877f..fafbd3a9a 100644 --- a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts +++ b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts @@ -59,14 +59,23 @@ export class YardFacilitiesSeeder { [yard.id], ); + // Every facility works both sides of the trip today, so the per-side + // flags mirror the freight-type flags. Narrow an individual yard here + // when a real one turns out to load a type but not receive it. await this.dataSource.query( `INSERT INTO freight.yard_facilities - (yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes) - VALUES ($1, $2, $3, true, $4) + (yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes, + has_container_facility_origin, has_container_facility_destination, + has_bulk_facility_origin, has_bulk_facility_destination) + VALUES ($1, $2, $3, true, $4, $3, $3, true, true) ON CONFLICT (yard_id) WHERE deleted_at IS NULL DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, handles_container = EXCLUDED.handles_container, handles_bulk = EXCLUDED.handles_bulk, + has_container_facility_origin = EXCLUDED.has_container_facility_origin, + has_container_facility_destination = EXCLUDED.has_container_facility_destination, + has_bulk_facility_origin = EXCLUDED.has_bulk_facility_origin, + has_bulk_facility_destination = EXCLUDED.has_bulk_facility_destination, updated_at = NOW()`, [yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`], ); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index d523d2e8a..ceb2a7d06 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -111,6 +111,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import TradeAccessPage from "./pages/configuration/TradeAccessPage"; import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; @@ -585,6 +586,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/configuration/train-scheduling-rules", permission: FREIGHT_PERMS.trainScheduling.rulesManage, }, + { + label: "Trade access", + href: "/dashboard/configuration/trade-access", + permission: FREIGHT_PERMS.admin, + }, ], }, { @@ -1549,6 +1555,14 @@ const App = () => { } /> + + + + } + /> {/* { + o.userData.locoId = locoId; + }); + pickables.push(g); return g; } @@ -457,6 +466,119 @@ function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3 return g; } +function InfoRow({ label, value }: { label: string; value: string | number | null | undefined }) { + if (value === null || value === undefined || value === "") return null; + return ( + + {label} + {value} + + ); +} + +function LocoDetailPanel({ + locoId, + schedule, + onClose, + onDrive, +}: { + locoId: string; + schedule: TrainScheduleDetail; + onClose: () => void; + onDrive: () => void; +}) { + const locos = schedule.trainSet?.locomotives?.length + ? schedule.trainSet.locomotives + : schedule.trainSet?.locomotive + ? [schedule.trainSet.locomotive] + : []; + const loco = locos.find((l) => l.id === locoId || l.code === locoId); + const ts = schedule.trainSet; + return ( + + + Locomotive {loco?.code ?? ""} + + + + + + {loco ? ( + <> + {loco.name ? {loco.name} : null} + + {loco.status} + + + {loco.maxTrainLengthMeters ? ( + + ) : null} + + ) : ( + No locomotive assigned yet. + )} + + + + + + + + + + + {ts ? ( + <> + + + + + {ts.heaviestLeg ? ( + + ) : null} + + ) : null} + + + + ); +} + function WagonDetailPanel({ wagon, schedule, onClose }: { wagon: Wagon; schedule: TrainScheduleDetail; onClose: () => void }) { const bookings = schedule.bookings ?? []; return ( @@ -547,6 +669,8 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization const containerRef = useRef(null); const canvasHostRef = useRef(null); const [selectedWagonId, setSelectedWagonId] = useState(null); + const [selectedLocoId, setSelectedLocoId] = useState(null); + const [driveMode, setDriveMode] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const cameraApiRef = useRef<{ overview: () => void; @@ -556,7 +680,9 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization } | null>(null); const wagons = schedule.trainSet?.wagons ?? []; - const moving = String(schedule.status).toUpperCase() === "DISPATCHED"; + const dispatched = String(schedule.status).toUpperCase() === "DISPATCHED"; + // drive mode always simulates motion, even before dispatch + const moving = dispatched || driveMode; const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null; const orderedWagons = [...wagons].sort( (a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo), @@ -572,10 +698,16 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization : orderedWagons.length - 1 : (selectedIndex + dir + orderedWagons.length) % orderedWagons.length; const wagon = orderedWagons[next]; + setSelectedLocoId(null); setSelectedWagonId(wagon.id); cameraApiRef.current?.focusWagon(wagon.id); }; + const firstLocoId = + schedule.trainSet?.locomotives?.[0]?.id ?? + schedule.trainSet?.locomotive?.id ?? + "LOCO"; + useEffect(() => { const host = canvasHostRef.current; if (!host) return; @@ -619,11 +751,13 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization : schedule.trainSet?.locomotive ? [schedule.trainSet.locomotive] : [{ code: "LOCO" }]; + const locoGroups: Array<{ id: string; group: THREE.Group }> = []; for (const loco of locos) { - const lg = buildLocomotive(loco, wheels); + const lg = buildLocomotive(loco, wheels, pickables); lg.position.x = cursor - lg.userData.length / 2; cursor -= lg.userData.length + gap; train.add(lg); + locoGroups.push({ id: lg.userData.locoId as string, group: lg }); } const ordered = [...wagons].sort((a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo)); const wagonGroups: Array<{ id: string; group: THREE.Group }> = []; @@ -650,6 +784,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization controls.maxDistance = Math.max(600, trainLen * 1.6); controls.enableDamping = true; controls.zoomToCursor = true; // wheel zooms toward the point under the pointer + controls.enabled = !driveMode; // cab ride owns the camera // camera fly-to: goal recomputed each frame so it tracks a moving train let cameraGoal: (() => { pos: THREE.Vector3; target: THREE.Vector3 }) | null = null; @@ -692,6 +827,20 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization controls.addEventListener("start", () => { cameraGoal = null; }); + if (driveMode) { + // cab ride: camera on the driver's seat of the lead loco, looking down the line + const locoLen = frontGroup.userData.length as number; + cameraGoal = () => { + frontGroup.getWorldPosition(worldPos); + return { + pos: new THREE.Vector3(worldPos.x + locoLen / 2 - 3.2, DECK_H + 3.9, 1.05), + target: new THREE.Vector3(worldPos.x + locoLen / 2 + 140, 2.2, 0), + }; + }; + } else if (moving) { + // dispatched → open following the rolling train so motion is obvious + cameraApiRef.current.overview(); + } // picking const raycaster = new THREE.Raycaster(); @@ -714,11 +863,21 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObjects(pickables, true); const wagonId = hits[0]?.object.userData.wagonId as string | undefined; + const locoId = hits[0]?.object.userData.locoId as string | undefined; if (highlighted) setEmissive(highlighted, false); - highlighted = wagonId ? (pickables.find((p) => p.userData.wagonId === wagonId) ?? null) : null; + highlighted = wagonId + ? (pickables.find((p) => p.userData.wagonId === wagonId) ?? null) + : locoId + ? (locoGroups.find((l) => l.id === locoId)?.group ?? null) + : null; if (highlighted) setEmissive(highlighted, true); setSelectedWagonId(wagonId ?? null); + setSelectedLocoId(locoId && !wagonId ? locoId : null); if (wagonId) cameraApiRef.current?.focusWagon(wagonId); + else if (locoId) { + const lg = locoGroups.find((l) => l.id === locoId); + if (lg) flyToObject(lg.group, 30, 9); + } }; renderer.domElement.addEventListener("click", onClick); @@ -732,20 +891,27 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization let raf = 0; let last = performance.now(); - const speed = 8; // m/s visual speed when dispatched + let elapsed = 0; + const speed = 14; // m/s visual speed when dispatched const animate = () => { raf = requestAnimationFrame(animate); const now = performance.now(); const dt = Math.min((now - last) / 1000, 0.1); last = now; if (moving) { + elapsed += dt; train.position.x += speed * dt; for (const w of wheels) w.rotation.y += (speed * dt) / WHEEL_R; - if (train.position.x > trainLen / 2 + 120) train.position.x = trainLen / 2 - 120; + // subtle rail-joint sway so motion reads even up close + train.position.y = Math.sin(elapsed * 9) * 0.03; + train.rotation.z = Math.sin(elapsed * 4.5) * 0.0025; + // loop over the long track stretch; jump happens far off-screen + const half = finalTrackLen / 2 - trainLen - 40; + if (train.position.x > half + trainLen) train.position.x = -half; } if (cameraGoal) { const goal = cameraGoal(); - const k = Math.min(1, dt * 3.2); + const k = Math.min(1, dt * (driveMode ? 7 : 3.2)); camera.position.lerp(goal.pos, k); controls.target.lerp(goal.target, k); // static scene: release goal once settled so orbiting feels free again @@ -772,12 +938,14 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization }); }; // rebuild scene only when schedule identity/status changes - }, [schedule.id, schedule.status, moving, wagons.length]); // eslint-disable-line react-hooks/exhaustive-deps + }, [schedule.id, schedule.status, moving, driveMode, wagons.length]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const onFsChange = () => setIsFullscreen(Boolean(document.fullscreenElement)); const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !document.fullscreenElement) onClose(); + if (e.key !== "Escape" || document.fullscreenElement) return; + if (driveMode) setDriveMode(false); + else onClose(); }; document.addEventListener("fullscreenchange", onFsChange); window.addEventListener("keydown", onKey); @@ -785,7 +953,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization document.removeEventListener("fullscreenchange", onFsChange); window.removeEventListener("keydown", onKey); }; - }, [onClose]); + }, [onClose, driveMode]); const toggleFullscreen = () => { if (document.fullscreenElement) void document.exitFullscreen(); @@ -807,8 +975,12 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization gap="xs" style={{ position: "absolute", top: 16, left: 16, zIndex: 10 }} > - - {moving ? "DISPATCHED — TRAIN IN MOTION" : `${schedule.status} — TRAIN STOPPED`} + + {driveMode + ? "DRIVER VIEW — SIMULATION" + : dispatched + ? "DISPATCHED — TRAIN IN MOTION" + : `${schedule.status} — TRAIN STOPPED`} {schedule.route?.name ?? schedule.reference ?? "Train"} · {wagons.length} wagons @@ -839,13 +1011,26 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization size="compact-sm" variant="default" leftSection={} - onClick={() => cameraApiRef.current?.front()} + onClick={() => { + setSelectedWagonId(null); + setSelectedLocoId(firstLocoId); + cameraApiRef.current?.front(); + }} > Locomotive +