From be5f3e1811faa833143f61ec5a985ba567e619ea Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Fri, 29 May 2026 11:21:04 +0300 Subject: [PATCH 1/5] feat(freight:backoffice): demo iam --- apps/edr-freight-api/src/app.module.ts | 7 +- .../demo-permissions.controller.ts | 22 ++ .../demo-permissions.module.ts | 8 + .../src/seed/demo-users.seeder.ts | 195 ++++++++++++++++++ apps/edr-freight-web/backoffice/src/App.tsx | 89 +++++--- .../pages/dashboard/demo/DemoUser1Page.tsx | 67 ++++++ .../pages/dashboard/demo/DemoUser2Page.tsx | 67 ++++++ 7 files changed, 427 insertions(+), 28 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts create mode 100644 apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts create mode 100644 apps/edr-freight-api/src/seed/demo-users.seeder.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index f2ca61569..b4d8cda6b 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -21,7 +21,9 @@ import { OtpModule } from './modules/otp/otp.module'; import { ServiceTypesModule } from "./modules/service-types/service-types.module"; import { CargoTypesModule } from "./modules/cargo-types/cargo-types.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; +import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; +import { DemoUsersSeeder } from "./seed/demo-users.seeder"; @Module({ imports: [ @@ -50,17 +52,20 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder"; ServiceTypesModule, CargoTypesModule, BackofficeModule, + DemoPermissionsModule, ], - providers: [EdrOrgSeeder], + providers: [EdrOrgSeeder, DemoUsersSeeder], }) export class AppModule implements OnApplicationBootstrap { constructor( private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, + private readonly demoUsersSeeder: DemoUsersSeeder, ) { } async onApplicationBootstrap() { await this.seeder.run(); await this.edrOrgSeeder.run(); + await this.demoUsersSeeder.run(); } } diff --git a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts new file mode 100644 index 000000000..6f425e1bb --- /dev/null +++ b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, UseGuards } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { PermissionGuard } from "@tria-plc/api-common/modules/auth/services/permission.guard"; + +@ApiTags("demo-permissions") +@Controller() +export class DemoPermissionsController { + @Get("test_user1") + @ApiOperation({ summary: "Permission demo (can:demo:user1)" }) + @UseGuards(PermissionGuard(["can:demo:user1"])) + testUser1() { + return { ok: true, permission: "can:demo:user1" }; + } + + @Get("test_user2") + @ApiOperation({ summary: "Permission demo (can:demo:user2)" }) + @UseGuards(PermissionGuard(["can:demo:user2"])) + testUser2() { + return { ok: true, permission: "can:demo:user2" }; + } +} diff --git a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts new file mode 100644 index 000000000..db73ed728 --- /dev/null +++ b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; + +import { DemoPermissionsController } from "./demo-permissions.controller"; + +@Module({ + controllers: [DemoPermissionsController], +}) +export class DemoPermissionsModule {} diff --git a/apps/edr-freight-api/src/seed/demo-users.seeder.ts b/apps/edr-freight-api/src/seed/demo-users.seeder.ts new file mode 100644 index 000000000..7638b2205 --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-users.seeder.ts @@ -0,0 +1,195 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { + Employee, + Organization, + Permission, + Role, + RolePermission, + User, + UserCredential, + UserRole, +} from "@tria-plc/iamapi-common"; +import { DataSource } from "typeorm"; + +const SEED_FLAG = "SEED_DEMO_USERS"; + +const DEMO_ORG_KEY = "demo_iam"; +const DEMO_ORG_NAME = { en: "Demo IAM" }; + +const DEMO_PERMISSIONS = [ + { key: "can:demo:user1", name: { en: "Can access demo user1" } }, + { key: "can:demo:user2", name: { en: "Can access demo user2" } }, +]; + +const DEMO_ROLES = [ + { key: "demo_user1", name: { en: "Demo User1" } }, + { key: "demo_user2", name: { en: "Demo User2" } }, +]; + +const DEMO_USERS = [ + { + email: "user@gmail.com", + username: "user", + name: { en: "Demo User 1" }, + roleKey: "demo_user1", + }, + { + email: "user2@gmail.com", + username: "user2", + name: { en: "Demo User 2" }, + roleKey: "demo_user2", + }, +]; + +@Injectable() +export class DemoUsersSeeder { + private readonly logger = new Logger(DemoUsersSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; + if (!shouldSeed) { + this.logger.log(`Skipping demo user seed because ${SEED_FLAG} is not enabled`); + return; + } + + await this.dataSource.transaction(async (manager) => { + const organizationRepository = manager.getRepository(Organization); + const employeeRepository = manager.getRepository(Employee); + const permissionRepository = manager.getRepository(Permission); + const roleRepository = manager.getRepository(Role); + const rolePermissionRepository = manager.getRepository(RolePermission); + const userRepository = manager.getRepository(User); + const userCredentialRepository = manager.getRepository(UserCredential); + const userRoleRepository = manager.getRepository(UserRole); + + await organizationRepository.upsert( + { + key: DEMO_ORG_KEY, + name: DEMO_ORG_NAME, + // status defaults to ACTIVE in IAM entity + isGovernmentOrganization: true, + }, + { conflictPaths: { key: true } }, + ); + + const organization = await organizationRepository.findOne({ + where: { key: DEMO_ORG_KEY }, + select: { id: true, key: true }, + }); + + if (!organization) { + throw new Error("demo_org_seed_failed"); + } + + await permissionRepository.upsert(DEMO_PERMISSIONS, { + conflictPaths: { key: true }, + }); + + await roleRepository.upsert(DEMO_ROLES, { + conflictPaths: { key: true }, + }); + + const roles = await roleRepository.find({ where: DEMO_ROLES.map((r) => ({ key: r.key })) }); + const permissions = await permissionRepository.find({ + where: DEMO_PERMISSIONS.map((p) => ({ key: p.key })), + }); + + const roleByKey = new Map(roles.map((r) => [r.key, r])); + const permissionByKey = new Map(permissions.map((p) => [p.key, p])); + + const rolePermissionsToUpsert = [ + { + roleId: roleByKey.get("demo_user1")!.id, + permissionId: permissionByKey.get("can:demo:user1")!.id, + }, + { + roleId: roleByKey.get("demo_user2")!.id, + permissionId: permissionByKey.get("can:demo:user2")!.id, + }, + ]; + + await rolePermissionRepository.upsert(rolePermissionsToUpsert, { + conflictPaths: { roleId: true, permissionId: true }, + }); + + const hashedPassword = await hashPassword("12345678"); + + for (const demoUser of DEMO_USERS) { + const existingUser = await userRepository.findOne({ + where: { email: demoUser.email }, + select: { id: true, email: true }, + }); + + let user = existingUser; + if (!user) { + user = await userRepository.save( + userRepository.create({ + email: demoUser.email, + username: demoUser.username, + name: demoUser.name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + } + + // Ensure an active credential exists for login. + const activeCredentialExists = await userCredentialRepository.exists({ + where: { + userId: user.id, + isActive: true, + }, + }); + + if (!activeCredentialExists) { + await userCredentialRepository.insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + // Login query requires a current employee in an ACTIVE organization. + const employeeExists = await employeeRepository.exists({ + where: { + userId: user.id, + organizationId: organization.id, + isCurrent: true, + }, + }); + + if (!employeeExists) { + await employeeRepository.insert({ + userId: user.id, + organizationId: organization.id, + isCurrent: true, + name: demoUser.name, + }); + } + + const role = roleByKey.get(demoUser.roleKey); + if (!role) { + throw new Error(`missing_role:${demoUser.roleKey}`); + } + + await userRoleRepository.upsert( + { + userId: user.id, + roleId: role.id, + organizationId: organization.id, + }, + { conflictPaths: { userId: true, roleId: true } }, + ); + } + }); + + this.logger.log( + "Seeded demo users + permissions (user@gmail.com, user2@gmail.com; permissions can:demo:user1/can:demo:user2)", + ); + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 7947060ef..8edcaf72f 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,6 +1,6 @@ import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { DashboardLayout, type SidebarItem } from "@edr/ui-common"; -import { LayoutDashboard, Network } from "lucide-react"; +import { LayoutDashboard, Network, Settings } from "lucide-react"; import { useAuth } from "./auth/useAuth"; import LoginPage from "./pages/auth/LoginPage"; @@ -10,39 +10,72 @@ import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; import LoadingScreen from "./components/LoadingScreen"; +import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; +import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; -const sidebarItems: SidebarItem[] = [ - { - label: "Overview", - href: "/dashboard/overview", - icon: , - }, - { - label: "User management", - href: "/dashboard/user-management", - icon: , - children: [ - { - label: "Employees", - href: "/dashboard/user-management/employees", - }, - { - label: "Permissions", - href: "/dashboard/user-management/permissions", - }, - { - label: "Roles", - href: "/dashboard/user-management/roles", - }, - ], - }, -]; +const hasPermission = ( + user: ReturnType["user"], + key: string, +) => { + if (!user) return false; + if (user.permissions?.some((p) => p.key === key)) return true; + return (user.employee ?? []).some((emp) => + (emp.positions ?? []).some((pos) => + (pos.permissions ?? []).some((p) => p.key === key), + ), + ); +}; const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); const { user, logout } = useAuth(); + const sidebarItems: SidebarItem[] = [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + }, + { + label: "User management", + href: "/dashboard/user-management", + icon: , + children: [ + { + label: "Employees", + href: "/dashboard/user-management/employees", + }, + { + label: "Permissions", + href: "/dashboard/user-management/permissions", + }, + { + label: "Roles", + href: "/dashboard/user-management/roles", + }, + ], + }, + ...(hasPermission(user, "can:demo:user1") + ? ([ + { + label: "User1", + href: "/dashboard/user1", + icon: , + }, + ] as SidebarItem[]) + : []), + ...(hasPermission(user, "can:demo:user2") + ? ([ + { + label: "User2", + href: "/dashboard/user2", + icon: , + }, + ] as SidebarItem[]) + : []), + ]; + const displayName = user?.name?.en || user?.username || user?.email || "User"; return ( @@ -87,6 +120,8 @@ const App = () => { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx new file mode 100644 index 000000000..e47cb95bd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; + +import { api } from "@/auth/http"; + +const DemoUser1Page = () => { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + const run = async () => { + setLoading(true); + setError(null); + + try { + const response = await api.get("/test_user1"); + if (cancelled) return; + setData(response.data); + } catch (e: any) { + if (cancelled) return; + const message = + e?.response?.data?.message || + e?.response?.data?.error || + e?.message || + "Request failed"; + setError(String(message)); + } finally { + if (!cancelled) setLoading(false); + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+

User1 Demo

+

+ Calls GET /api/test_user1 (requires{' '} + can:demo:user1). +

+ +
+ {loading ?

Loading...

: null} + {error ? ( +
+ {error} +
+ ) : null} + {!loading && !error ? ( +
+              {JSON.stringify(data, null, 2)}
+            
+ ) : null} +
+
+
+ ); +}; + +export default DemoUser1Page; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx new file mode 100644 index 000000000..5ef7ad172 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; + +import { api } from "@/auth/http"; + +const DemoUser2Page = () => { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + const run = async () => { + setLoading(true); + setError(null); + + try { + const response = await api.get("/test_user2"); + if (cancelled) return; + setData(response.data); + } catch (e: any) { + if (cancelled) return; + const message = + e?.response?.data?.message || + e?.response?.data?.error || + e?.message || + "Request failed"; + setError(String(message)); + } finally { + if (!cancelled) setLoading(false); + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+

User2 Demo

+

+ Calls GET /api/test_user2 (requires{' '} + can:demo:user2). +

+ +
+ {loading ?

Loading...

: null} + {error ? ( +
+ {error} +
+ ) : null} + {!loading && !error ? ( +
+              {JSON.stringify(data, null, 2)}
+            
+ ) : null} +
+
+
+ ); +}; + +export default DemoUser2Page; From 426019ebf79d012507d8581b4499983f824bfa90 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Fri, 29 May 2026 11:45:44 +0300 Subject: [PATCH 2/5] feat(iam:demo): added demo permissions for superadmin --- .../src/seed/demo-users.seeder.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/edr-freight-api/src/seed/demo-users.seeder.ts b/apps/edr-freight-api/src/seed/demo-users.seeder.ts index 7638b2205..8d886e3aa 100644 --- a/apps/edr-freight-api/src/seed/demo-users.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-users.seeder.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { hashPassword } from "@tria-plc/api-common/utils/argon"; import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; import { Employee, Organization, @@ -101,6 +102,11 @@ export class DemoUsersSeeder { const roleByKey = new Map(roles.map((r) => [r.key, r])); const permissionByKey = new Map(permissions.map((p) => [p.key, p])); + const superAdminRole = await roleRepository.findOne({ + where: { key: ERoleKey.SUPER_ADMIN }, + select: { id: true, key: true }, + }); + const rolePermissionsToUpsert = [ { roleId: roleByKey.get("demo_user1")!.id, @@ -110,6 +116,18 @@ export class DemoUsersSeeder { roleId: roleByKey.get("demo_user2")!.id, permissionId: permissionByKey.get("can:demo:user2")!.id, }, + ...(superAdminRole + ? ([ + { + roleId: superAdminRole.id, + permissionId: permissionByKey.get("can:demo:user1")!.id, + }, + { + roleId: superAdminRole.id, + permissionId: permissionByKey.get("can:demo:user2")!.id, + }, + ] as Array<{ roleId: string; permissionId: string }>) + : []), ]; await rolePermissionRepository.upsert(rolePermissionsToUpsert, { From b58e2ee2be41a8639baf15ee0ab5e28dd58fc6da Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 29 May 2026 13:08:27 +0300 Subject: [PATCH 3/5] refactor(bookings): Consolidate and remove steps for streamlined booking flow --- .../src/pages/bookings/NewBookingPage.tsx | 107 +---- .../pages/bookings/new-booking-form/schema.ts | 444 +++++++----------- .../bookings/new-booking-form/shared.tsx | 12 +- .../new-booking-form/step1-contract-type.tsx | 78 ++- .../new-booking-form/step2-service-type.tsx | 170 ++++++- .../step3-first-last-mile.tsx | 68 ++- .../bookings/new-booking-form/step4-route.tsx | 30 +- .../new-booking-form/step5-cargo-details.tsx | 80 +++- .../new-booking-form/step8-review.tsx | 68 +-- .../pages/bookings/new-booking-form/steps.tsx | 3 - packages/types/src/freight/index.ts | 1 + tasks.md | 5 + 12 files changed, 557 insertions(+), 509 deletions(-) create mode 100644 tasks.md diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index f96e76b51..04728e0a2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,15 +1,14 @@ -import { useEffect, useMemo, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; -import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react"; +import { Check, CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "@edr/ui-common"; import Breadcrumbs from "@/components/Breadcrumbs"; import { api } from "@/services/api"; import type { CreateBookingPayload } from "@/services/bookings.service"; import { - MOCK_VALID_CONTRACTS, STEPS, bookingFormSchema, calcWagons, @@ -22,11 +21,8 @@ import { StepIndicator } from "./new-booking-form/StepIndicator"; import { Step1ContractType, Step2ServiceType, - Step3FirstLastMile, Step4Route, Step5CargoDetails, - Step6WagonAllocation, - Step7Documents, Step8Review, } from "./new-booking-form/steps"; import useAuth from "@/hooks/useAuth"; @@ -35,8 +31,6 @@ export default function NewBookingPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const [step, setStep] = useState(1); - const [renewalValidating, setRenewalValidating] = useState(false); - const [renewalValid, setRenewalValid] = useState(null); const { customer } = useAuth(); const createMutation = useMutation({ mutationFn: (payload: CreateBookingPayload) => @@ -56,6 +50,7 @@ export default function NewBookingPage() { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); const containers = form.watch("containers"); + const contractType = form.watch("contractType"); const previousContractRef = form.watch("previousContractRef"); const direction = useMemo( @@ -68,63 +63,20 @@ export default function NewBookingPage() { return calcWagons(containers); }, [containers]); - useEffect(() => { - setRenewalValid(null); - }, [previousContractRef]); - - function validateRenewal() { - const previousContractRef = form.getValues("previousContractRef").trim(); - - if (!previousContractRef) { - form.setError("previousContractRef", { - type: "manual", - message: "Enter a previous contract reference.", - }); - return; - } - - setRenewalValidating(true); - setRenewalValid(null); - setTimeout(() => { - const valid = MOCK_VALID_CONTRACTS.includes( - previousContractRef.toUpperCase(), - ); - setRenewalValidating(false); - setRenewalValid(valid); - if (!valid) { - form.setError("previousContractRef", { - type: "manual", - message: "Contract Reference Number not found or unauthorized.", - }); - } else { - form.clearErrors("previousContractRef"); - } - }, 1200); - } + const renewalValid = contractType === "renewal" && previousContractRef !== ""; async function handleContinue() { const valid = await form.trigger(stepFields[step], { shouldFocus: true }); if (!valid) return; - if (step === 1 && form.getValues("contractType") === "renewal") { - if (renewalValid !== true) { - form.setError("previousContractRef", { - type: "manual", - message: - "Validate the previous contract reference before continuing.", - }); - return; - } - } - setStep((currentStep) => Math.min(STEPS.length, currentStep + 1)); } const handleSubmit = form.handleSubmit((data) => { - if (data.contractType === "renewal" && renewalValid !== true) { + if (data.contractType === "renewal" && !data.previousContractRef) { form.setError("previousContractRef", { type: "manual", - message: "Validate the previous contract reference before submitting.", + message: "Select a previous contract reference.", }); setStep(1); return; @@ -150,18 +102,15 @@ export default function NewBookingPage() { previousContractId: data.previousContractRef || undefined, serviceType: data.serviceType === "rail" ? "RAIL_ONLY" : "RAIL_AND_FORWARDING", - firstMileEnabled: data.firstMileEnabled, - firstMilePickupAddress: data.firstMileEnabled - ? data.pickUpAddress - : undefined, - lastMileEnabled: data.lastMileEnabled, - lastMileDeliveryAddress: data.lastMileEnabled - ? data.deliveryAddress - : undefined, + firstMileEnabled: data.firstMile.enabled, + firstMilePickupAddress: data.firstMile.pickUpAddress ?? undefined, + lastMileEnabled: data.lastMile.enabled, + lastMileDeliveryAddress: data.lastMile.deliveryAddress ?? undefined, equipmentReturn: data.equipmentReturn === "with_return" ? ("WITH_RETURN" as const) : ("WITHOUT_RETURN" as const), + customsClearingEnabled: data.customsClearingEnabled, originStation: data.originYard, destinationStation: data.destinationYard, cargoTotalWeightVgm: totalWeight, @@ -220,37 +169,21 @@ export default function NewBookingPage() { className="flex flex-col" onSubmit={handleSubmit} > -
-
- +
+
- {step === 1 && ( - - )} + {step === 1 && } {step === 2 && } - {step === 3 && } - {step === 4 && } - {step === 5 && ( + {step === 3 && } + {step === 4 && ( )} - {step === 6 && } - {step === 7 && } - {step === 8 && ( + {step === 5 && ( Continue - + ) : ( )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 5ea5d4ded..dbc979595 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -42,123 +42,79 @@ export const MOCK_VALID_CONTRACTS = [ "EDR-2022-55442", ]; -export const REQUIRED_DOC_KEYS = [ - "tin_certificate", - "business_license", - "business_registration", - // "national_id", +export const CONTAINER_TYPES = [ + "Dry Container", + "High Cubic", + "Reefer Container", + "Open Top", + "Flat Rack", + "Tank Container", + "Open Side", +] as const; + +export const SHIPPING_LINES = [ + "MSC", + "CMA CGM", + "Evergreen", + "COSCO", + "Hapag-Lloyd", + "ONE", + "Yang Ming", + "ZIM", + "Messina Line", + "Safmarine", + "Wan Hai", + "Ethiopian Shipping Lines (ESLSE)", ] as const; export const STEPS = [ { id: 1, label: "Contract Type", short: "Contract" }, - { id: 2, label: "Service Type", short: "Service" }, - { id: 3, label: "First & Last Mile", short: "Mile" }, - { id: 4, label: "Route", short: "Route" }, - { id: 5, label: "Cargo Details", short: "Cargo" }, - { id: 6, label: "Wagon Allocation", short: "Wagons" }, - { id: 7, label: "Documents", short: "Docs" }, - { id: 8, label: "Review & Submit", short: "Submit" }, + { id: 2, label: "Service Type & Mile", short: "Service" }, + { id: 3, label: "Route", short: "Route" }, + { id: 4, label: "Cargo Details", short: "Cargo" }, + { id: 5, label: "Review & Submit", short: "Submit" }, ] as const; -export const BOOKING_DOCS_SETTING = { - id: "booking-compliance", - createdAt: "", - updatedAt: "", - deletedAt: null, - code: "booking_compliance_docs", - label: "Compliance Documents", - description: - "Upload your company's legal credentials. All mandatory documents must be submitted before the contract request can be reviewed by EDR Line Staff.", - entity: "booking" as const, - fields: [ - { - id: "f1", - createdAt: "", - updatedAt: "", - deletedAt: null, - settingId: "booking-compliance", - fileKey: "tin_certificate", - fileLabel: "TIN Certificate", - helpText: - "Tax Identification Number certificate issued by ERCA (10-digit TIN).", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 1, - }, - { - id: "f2", - createdAt: "", - updatedAt: "", - deletedAt: null, - settingId: "booking-compliance", - fileKey: "business_license", - fileLabel: "Business / Investment License", - helpText: - "Current business or investment license issued by the relevant government authority.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 2, - }, - { - id: "f3", - createdAt: "", - updatedAt: "", - deletedAt: null, - settingId: "booking-compliance", - fileKey: "business_registration", - fileLabel: "Business Registration Certificate", - helpText: "Certificate of registration from the relevant authority.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 3, - }, - { - id: "f5", - createdAt: "", - updatedAt: "", - deletedAt: null, - settingId: "booking-compliance", - fileKey: "power_of_attorney", - fileLabel: "Power of Attorney (PoA)", - helpText: - "Required only if a representative is signing on behalf of the company.", - isRequired: false, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 5, - }, - ], -}; - -const fileValueSchema = z.union([ - z.custom(), - z.array(z.custom()), - z.null(), -]); - export const bookingFormSchema = z .object({ contractType: z.enum(["new", "renewal"], "Select a contract type."), previousContractRef: z.string(), serviceType: z.enum(["rail", "rail_forwarding"], "Select a service type."), - firstMileEnabled: z.boolean(), - pickUpAddress: z.string(), - lastMileEnabled: z.boolean(), - deliveryAddress: z.string(), + firstMile: z + .object({ + enabled: z.boolean(), + pickUpAddress: z.string(), + }) + .refine( + (data) => { + console.log(data); + return !(data.enabled && !data.pickUpAddress.trim()); + }, + { + message: "Enter the pick-up address.", + path: ["pickUpAddress"], + }, + ), + lastMile: z + .object({ + enabled: z.boolean(), + deliveryAddress: z.string(), + }) + .refine( + (data) => { + console.log(data); + return !(data.enabled && !data.deliveryAddress.trim()); + }, + { + message: "Enter the delivery address.", + path: ["deliveryAddress"], + }, + ), equipmentReturn: z.enum(["with_return", "without_return"]), - originYard: z.string(), - destinationYard: z.string(), + customsClearingEnabled: z.boolean(), + originYard: z.string().min(1, "Select an origin yard."), + destinationYard: z.string().min(1, "Select a destination yard."), + shippingLine: z.string(), cargoType: z.enum(["container", "bulk"], "Select a cargo type."), cargoWeight: z.string(), freightType: z.enum(["bulk", "break_bulk"]).optional(), @@ -171,142 +127,116 @@ export const bookingFormSchema = z containers: z.array( z.object({ type: z.enum(["20ft", "40ft"]), + containerType: z.string().min(1, "Select a container type."), qty: z .string() + .refine((q) => q.length !== 0, "Quantity is required.") .refine((q) => !isNaN(+q), "Enter a valid Number") .refine((qty) => Number(qty) >= 1, "Must be greater than 0"), vgm: z .string() + .refine((vgm) => vgm.length !== 0, "VGM is required.") .refine((vgm) => !isNaN(+vgm), "Enter a valid Number") .refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"), }), ), consolidationEnabled: z.boolean(), - documents: z.record(z.string(), fileValueSchema), notes: z.string(), termsAccepted: z.boolean(), }) - .superRefine((data, ctx) => { - if (data.contractType === "renewal" && !data.previousContractRef.trim()) { - ctx.addIssue({ - code: "custom", - path: ["previousContractRef"], - message: "Enter a previous contract reference.", - }); - } - - if (data.firstMileEnabled && !data.pickUpAddress.trim()) { - ctx.addIssue({ - code: "custom", - path: ["pickUpAddress"], - message: "Enter the pick-up address.", - }); - } - - if (data.lastMileEnabled && !data.deliveryAddress.trim()) { - ctx.addIssue({ - code: "custom", - path: ["deliveryAddress"], - message: "Enter the delivery address.", - }); - } - - if (!data.originYard) { - ctx.addIssue({ - code: "custom", - path: ["originYard"], - message: "Select an origin yard.", - }); - } - - if (!data.destinationYard) { - ctx.addIssue({ - code: "custom", - path: ["destinationYard"], - message: "Select a destination yard.", - }); - } - - if ( - data.originYard && - data.destinationYard && - data.originYard === data.destinationYard - ) { - ctx.addIssue({ - code: "custom", - path: ["destinationYard"], - message: "Destination must be different from origin.", - }); - } - - if (data.cargoType === "bulk") { - if (!data.freightType) { - ctx.addIssue({ - code: "custom", - path: ["freightType"], - message: "Select a freight type.", - }); - } - - if (data.freightType === "bulk") { - if (!data.bulkCommodity) { - ctx.addIssue({ - code: "custom", - path: ["bulkCommodity"], - message: "Select a commodity.", - }); - } - if ( - data.bulkCommodity === "Others" && - !data.bulkCommodityOther.trim() - ) { - ctx.addIssue({ - code: "custom", - path: ["bulkCommodityOther"], - message: "Specify the commodity.", - }); - } - } - - if (data.freightType === "break_bulk") { - if (!data.breakBulkType) { - ctx.addIssue({ - code: "custom", - path: ["breakBulkType"], - message: "Select a break-bulk type.", - }); - } - if ( - data.breakBulkType === "Others" && - !data.breakBulkTypeOther.trim() - ) { - ctx.addIssue({ - code: "custom", - path: ["breakBulkTypeOther"], - message: "Specify the break-bulk type.", - }); - } - } - + .refine( + (data) => + !(data.contractType === "renewal" && !data.previousContractRef.trim()), + { + message: "Enter a previous contract reference.", + path: ["previousContractRef"], + }, + ) + .refine((data) => data.originYard !== "", { + message: "Select an origin yard.", + path: ["originYard"], + }) + .refine((data) => data.destinationYard !== "", { + message: "Select a destination yard.", + path: ["destinationYard"], + }) + .refine( + (data) => + !( + data.originYard && + data.destinationYard && + data.originYard === data.destinationYard + ), + { + message: "Destination must be different from origin.", + path: ["destinationYard"], + }, + ) + .refine((data) => !(data.cargoType === "bulk" && !data.freightType), { + message: "Select a freight type.", + path: ["freightType"], + }) + .refine( + (data) => + !( + data.cargoType === "bulk" && + data.freightType === "bulk" && + !data.bulkCommodity + ), + { message: "Select a commodity.", path: ["bulkCommodity"] }, + ) + .refine( + (data) => + !( + data.cargoType === "bulk" && + data.freightType === "bulk" && + data.bulkCommodity === "Others" && + !data.bulkCommodityOther.trim() + ), + { message: "Specify the commodity.", path: ["bulkCommodityOther"] }, + ) + .refine( + (data) => + !( + data.cargoType === "bulk" && + data.freightType === "break_bulk" && + !data.breakBulkType + ), + { message: "Select a break-bulk type.", path: ["breakBulkType"] }, + ) + .refine( + (data) => + !( + data.cargoType === "bulk" && + data.freightType === "break_bulk" && + data.breakBulkType === "Others" && + !data.breakBulkTypeOther.trim() + ), + { + message: "Specify the break-bulk type.", + path: ["breakBulkTypeOther"], + }, + ) + .refine( + (data) => { + if (data.cargoType !== "bulk") return true; const cargoWeight = Number(data.cargoWeight); - if (!data.cargoWeight || Number.isNaN(cargoWeight) || cargoWeight <= 0) { - ctx.addIssue({ - code: "custom", - path: ["cargoWeight"], - message: "Enter a cargo weight greater than 0.", - }); - } - } - + return ( + !!data.cargoWeight && !Number.isNaN(cargoWeight) && cargoWeight > 0 + ); + }, + { message: "Enter a cargo weight greater than 0.", path: ["cargoWeight"] }, + ) + .refine( + (data) => !(data.cargoType === "container" && data.containers.length === 0), + { message: "Add at least one container.", path: ["containers"] }, + ) + .refine((data) => data.termsAccepted, { + message: "Accept the freight contract terms to submit.", + path: ["termsAccepted"], + }) + .superRefine((data, ctx) => { if (data.cargoType === "container") { - if (data.containers.length === 0) { - ctx.addIssue({ - code: "custom", - path: ["containers"], - message: "Add at least one container.", - }); - } - data.containers.forEach((c, i) => { if (!c.qty || +c.qty < 1) { ctx.addIssue({ @@ -325,39 +255,25 @@ export const bookingFormSchema = z } }); } - - for (const key of REQUIRED_DOC_KEYS) { - const value = data.documents[key]; - const hasFile = Array.isArray(value) ? value.length > 0 : Boolean(value); - if (!hasFile) { - ctx.addIssue({ - code: "custom", - path: ["documents", key], - message: "Upload this required document.", - }); - } - } - - if (!data.termsAccepted) { - ctx.addIssue({ - code: "custom", - path: ["termsAccepted"], - message: "Accept the freight contract terms to submit.", - }); - } }); export type BookingFormValues = z.infer; export const initialBookingFormValues: Partial = { previousContractRef: "", - firstMileEnabled: false, - pickUpAddress: "", - lastMileEnabled: false, - deliveryAddress: "", + firstMile: { + enabled: false, + pickUpAddress: "", + }, + lastMile: { + enabled: false, + deliveryAddress: "", + }, equipmentReturn: "with_return", + customsClearingEnabled: false, originYard: "", destinationYard: "", + shippingLine: "", cargoWeight: "", bulkCommodity: "", bulkCommodityOther: "", @@ -365,25 +281,29 @@ export const initialBookingFormValues: Partial = { breakBulkTypeOther: "", isHazardous: false, isRefrigerated: false, - containers: [{ type: "20ft", qty: "1", vgm: "" }], + containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }], consolidationEnabled: false, - documents: {}, notes: "", termsAccepted: false, }; export const stepFields: Record> = { 1: ["contractType", "previousContractRef"], - 2: ["serviceType"], - 3: [ - "firstMileEnabled", - "pickUpAddress", - "lastMileEnabled", - "deliveryAddress", + 2: [ + "serviceType", + "firstMile", + "lastMile", "equipmentReturn", + "customsClearingEnabled", ], - 4: ["originYard", "destinationYard", "isHazardous", "isRefrigerated"], - 5: [ + 3: [ + "originYard", + "destinationYard", + "isHazardous", + "isRefrigerated", + "shippingLine", + ], + 4: [ "cargoType", "cargoWeight", "freightType", @@ -392,16 +312,16 @@ export const stepFields: Record> = { "breakBulkType", "breakBulkTypeOther", "containers", + "consolidationEnabled", ], - 6: ["consolidationEnabled"], - 7: ["documents"], - 8: ["notes", "termsAccepted"], + 5: ["notes", "termsAccepted"], }; export type RouteDirection = "import" | "export" | "domestic" | null; export interface ContainerConfig { type: "20ft" | "40ft"; + containerType: string; qty: string; vgm: string; } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx index 6cfff801a..120aa48a3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx @@ -22,18 +22,8 @@ import { SelectValue, } from "@edr/ui-common"; import type { BookingFormValues } from "./schema"; -import { REQUIRED_DOC_KEYS } from "./schema"; import { cn } from "@/lib/utils"; -export function getUploadedRequiredCount( - documents: BookingFormValues["documents"], -) { - return REQUIRED_DOC_KEYS.filter((key) => { - const file = documents[key]; - return Array.isArray(file) ? file.length > 0 : Boolean(file); - }).length; -} - export function OptionFieldError({ error }: { error?: { message?: string } }) { return ; } @@ -160,6 +150,8 @@ export function SelectField({ ); } +export { SelectItem }; + export function SelectOptions({ options }: { options: readonly string[] }) { return ( <> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx index e8e045e5c..62a66d0e1 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx @@ -1,22 +1,19 @@ import { Controller, type UseFormReturn } from "react-hook-form"; -import { FileText, Loader2, RefreshCw } from "lucide-react"; -import { Button, Field, FieldLabel, Input } from "@edr/ui-common"; -import { type BookingFormValues } from "./schema"; -import { AlertBox, OptionCard, OptionFieldError, StepHeader } from "./shared"; +import { FileText, RefreshCw } from "lucide-react"; +import { Field } from "@edr/ui-common"; +import { MOCK_VALID_CONTRACTS, type BookingFormValues } from "./schema"; +import { + AlertBox, + OptionCard, + OptionFieldError, + SelectField, + SelectItem, + StepHeader, +} from "./shared"; type BookingForm = UseFormReturn; -export function Step1ContractType({ - form, - renewalValid, - renewalValidating, - onValidate, -}: { - form: BookingForm; - renewalValid: boolean | null; - renewalValidating: boolean; - onValidate: () => void; -}) { +export function Step1ContractType({ form }: { form: BookingForm }) { const contractType = form.watch("contractType"); const previousContractRef = form.watch("previousContractRef"); @@ -38,6 +35,7 @@ export function Step1ContractType({ onClick={() => { field.onChange("new"); form.clearErrors(["contractType", "previousContractRef"]); + form.setValue("previousContractRef", ""); }} >
@@ -45,7 +43,7 @@ export function Step1ContractType({

New Contract

- Blank contract form. A draft ID is auto-generated. + Create a new contract.

@@ -61,7 +59,7 @@ export function Step1ContractType({

Contract Renewal

- Enter a previous reference to auto-populate historical + Select a previous reference to auto-populate historical parameters.

@@ -77,46 +75,26 @@ export function Step1ContractType({ name="previousContractRef" control={form.control} render={({ field, fieldState }) => ( - - - Previous Contract Reference Number - -
- - -
-
+ + {MOCK_VALID_CONTRACTS.map((ref) => ( + + {ref} + + ))} + )} /> - {renewalValid === true && ( + {previousContractRef && ( Contract found. Company details, route, and wagon preferences will be pre-filled. )} - {renewalValid === false && ( - - Contract Reference Number not found or unauthorized. Try{" "} - EDR-2024-10001. - - )}
)}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index 1e4350e1b..07e1638f3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -1,6 +1,6 @@ import { Controller, type UseFormReturn } from "react-hook-form"; -import { Package, Train } from "lucide-react"; -import { Badge, Field, FieldError } from "@edr/ui-common"; +import { FileText, Package, Train, Truck } from "lucide-react"; +import { Badge, Field, FieldError, Input, Switch } from "@edr/ui-common"; import { type BookingFormValues } from "./schema"; import { OptionCard, OptionFieldError, StepHeader } from "./shared"; @@ -8,12 +8,14 @@ type BookingForm = UseFormReturn; export function Step2ServiceType({ form }: { form: BookingForm }) { const serviceType = form.watch("serviceType"); + const firstMileEnabled = form.watch("firstMile.enabled"); + const lastMileEnabled = form.watch("lastMile.enabled"); return (
-

- Customs and Clearance Service cannot be selected independently. It must - be bundled with a Rail Transport service. -

+
+
+ ( +
+
+ +
+

First Mile - Pick-up

+

+ Truck pick-up from your premises (Door to Port) to the + origin rail yard. +

+
+
+ { + field.onChange(value); + if (!value) { + form.setValue("firstMile.pickUpAddress", "", { + shouldDirty: true, + shouldValidate: true, + }); + } + }} + /> +
+ )} + /> + {firstMileEnabled && ( + ( + + + + + )} + /> + )} +
+ +
+ ( +
+
+ +
+

Last Mile - Delivery

+

+ Truck delivery from the destination rail yard to the final + address (Port to Door). +

+
+
+ { + field.onChange(value); + if (!value) { + form.setValue("lastMile.deliveryAddress", "", { + shouldDirty: true, + shouldValidate: true, + }); + form.setValue("equipmentReturn", "with_return", { + shouldDirty: true, + }); + } + }} + /> +
+ )} + /> + {lastMileEnabled && ( + ( + + + + + )} + /> + )} +
+ + {lastMileEnabled && ( +
+ ( +
+
+
+

Equipment Return

+

+ {field.value === "with_return" + ? "Container returned to EDR after unloading." + : "Container retained by the customer after delivery."} +

+
+
+ { + field.onChange(value ? "with_return" : "without_return"); + }} + /> +
+ )} + /> +
+ )} + +
+ ( +
+
+ +
+

+ Customs Clearing Service +

+

+ EDR handles customs documentation and clearance on your + behalf. +

+
+
+ +
+ )} + /> +
+
); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx index 369eb03f1..3bc5629ca 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx @@ -1,7 +1,7 @@ import { Controller, type UseFormReturn } from "react-hook-form"; import { Field, FieldError, Input, Switch } from "@edr/ui-common"; import { type BookingFormValues } from "./schema"; -import { OptionCard, StepHeader } from "./shared"; +import { StepHeader } from "./shared"; type BookingForm = UseFormReturn; @@ -27,7 +27,8 @@ export function Step3FirstLastMile({ form }: { form: BookingForm }) {

First Mile - Pick-up

- Truck pick-up from your premises to the origin rail yard. + Truck pick-up from your premises (Door to Port) to the + origin rail yard.

Last Mile - Delivery

Truck delivery from the destination rail yard to the final - address. + address (Port to Door).

)} - -
-

Equipment Return

-

- Declare whether the container asset will be returned after - unloading. -

- ( -
- field.onChange("with_return")} - > -

With Return

-

- Container returned to EDR after unloading. -

-
- field.onChange("without_return")} - > -

Without Return

-

- Container retained by the customer after delivery. -

-
-
- )} - /> + {lastMileEnabled && ( +
+ ( +
+
+

Equipment Return

+

+ {field.value === "with_return" + ? "Container returned to EDR after unloading." + : "Container retained by the customer after delivery."} +

+
+ { + field.onChange( + value ? "with_return" : "without_return", + ); + }} + /> +
+ )} + /> +
+ )}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 18b4c581b..3d98fc854 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -1,7 +1,12 @@ import { Controller, type UseFormReturn } from "react-hook-form"; import { Flame, MapPin, Snowflake } from "lucide-react"; import { Field, SelectItem, Separator, Switch } from "@edr/ui-common"; -import { type BookingFormValues, getRouteDirection, STATIONS } from "./schema"; +import { + SHIPPING_LINES, + type BookingFormValues, + getRouteDirection, + STATIONS, +} from "./schema"; import { AlertBox, SelectField, @@ -11,6 +16,7 @@ import { } from "./shared"; import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings"; import { DropdownOption } from "@/types/dropdownSettings"; +import { useEffect } from "react"; type BookingForm = UseFormReturn; @@ -39,6 +45,12 @@ export function Step4Route({ form }: { form: BookingForm }) { }; const stationSelectDisabled = stationsLoading || stationOptions.length === 0; + useEffect(() => { + if (direction === "domestic") { + form.setValue("shippingLine", "", { shouldDirty: true }); + } + }, [direction]); + return (
+ {direction && direction != "domestic" && ( + ( + + + + )} + /> + )}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index a26bf1876..7e017efbb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -11,6 +11,8 @@ import { import { BREAK_BULK_TYPES, BULK_COMMODITIES, + CONTAINER_TYPES, + calcWagons, type BookingFormValues, type RouteDirection, } from "./schema"; @@ -98,7 +100,14 @@ export function Step5CargoDetails({ field.onChange("bulk"); form.setValue( "containers", - [{ type: "20ft", qty: "1", vgm: "0" }], + [ + { + type: "20ft", + containerType: "", + qty: "1", + vgm: "", + }, + ], { shouldDirty: true }, ); }} @@ -259,32 +268,27 @@ export function Step5CargoDetails({ {cargoType === "container" && ( <> -
- Container Configuration + Containers
- {direction && ( -

- - Route detected as{" "} - - {direction} - {" "} - workflow -

- )} - {fields.map((field, index) => { const containerType = containers[index]?.type; const vgm = containers[index]?.vgm ?? 0; @@ -293,12 +297,9 @@ export function Step5CargoDetails({ return (
-

- Container #{index + 1} -

{fields.length > 1 && (
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index dbc979595..4da8ed6ef 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -1,3 +1,4 @@ +import { DeepPartial, Path } from "react-hook-form"; import * as z from "zod"; export const STATIONS = [ @@ -82,36 +83,26 @@ export const bookingFormSchema = z serviceType: z.enum(["rail", "rail_forwarding"], "Select a service type."), firstMile: z .object({ - enabled: z.boolean(), + enabled: z.boolean().default(false), pickUpAddress: z.string(), }) - .refine( - (data) => { - console.log(data); - return !(data.enabled && !data.pickUpAddress.trim()); - }, - { - message: "Enter the pick-up address.", - path: ["pickUpAddress"], - }, - ), + .refine((data) => !(data.enabled && !data.pickUpAddress.trim()), { + message: "Enter the pick-up address.", + path: ["pickUpAddress"], + }), lastMile: z .object({ - enabled: z.boolean(), + enabled: z.boolean().default(false), deliveryAddress: z.string(), }) - .refine( - (data) => { - console.log(data); - return !(data.enabled && !data.deliveryAddress.trim()); - }, - { - message: "Enter the delivery address.", - path: ["deliveryAddress"], - }, - ), - equipmentReturn: z.enum(["with_return", "without_return"]), - customsClearingEnabled: z.boolean(), + .refine((data) => !(data.enabled && !data.deliveryAddress.trim()), { + message: "Enter the delivery address.", + path: ["deliveryAddress"], + }), + equipmentReturn: z + .enum(["with_return", "without_return"]) + .default("with_return"), + customsClearingEnabled: z.boolean().default(false), originYard: z.string().min(1, "Select an origin yard."), destinationYard: z.string().min(1, "Select a destination yard."), shippingLine: z.string(), @@ -259,8 +250,9 @@ export const bookingFormSchema = z export type BookingFormValues = z.infer; -export const initialBookingFormValues: Partial = { +export const initialBookingFormValues: DeepPartial = { previousContractRef: "", + firstMile: { enabled: false, pickUpAddress: "", @@ -287,7 +279,7 @@ export const initialBookingFormValues: Partial = { termsAccepted: false, }; -export const stepFields: Record> = { +export const stepFields: Record>> = { 1: ["contractType", "previousContractRef"], 2: [ "serviceType", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index 07e1638f3..e9bd1d331 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from "react"; import { Controller, type UseFormReturn } from "react-hook-form"; import { FileText, Package, Train, Truck } from "lucide-react"; import { Badge, Field, FieldError, Input, Switch } from "@edr/ui-common"; @@ -11,6 +12,59 @@ export function Step2ServiceType({ form }: { form: BookingForm }) { const firstMileEnabled = form.watch("firstMile.enabled"); const lastMileEnabled = form.watch("lastMile.enabled"); + const prevServiceType = useRef(serviceType); + + useEffect(() => { + const prev = prevServiceType.current; + prevServiceType.current = serviceType; + + if (!prev || prev === serviceType) return; + + if (serviceType === "rail") { + form.setValue( + "firstMile", + { enabled: false, pickUpAddress: "" }, + { shouldDirty: true, shouldValidate: true }, + ); + form.setValue( + "lastMile", + { enabled: false, deliveryAddress: "" }, + { shouldDirty: true, shouldValidate: true }, + ); + form.setValue("equipmentReturn", "with_return", { + shouldDirty: true, + }); + form.setValue("customsClearingEnabled", false, { + shouldDirty: true, + }); + } else if (serviceType === "rail_forwarding") { + form.setValue( + "firstMile", + { + enabled: false, + pickUpAddress: "", + }, + { + shouldDirty: false, + shouldValidate: false, + }, + ); + form.setValue( + "lastMile", + { + enabled: false, + deliveryAddress: "", + }, + { + shouldDirty: false, + shouldValidate: false, + }, + ); + } + }, [serviceType, form]); + + const showServiceSections = serviceType === "rail_forwarding"; + return (
-

- Rail Transport & Freight Forwarding -

+

Logistics

Rail transport plus documentation, customs liaison, and a dedicated coordinator. @@ -65,164 +117,172 @@ export function Step2ServiceType({ form }: { form: BookingForm }) { )} /> -

-
- ( -
-
- -
-

First Mile - Pick-up

-

- Truck pick-up from your premises (Door to Port) to the - origin rail yard. -

-
-
- { - field.onChange(value); - if (!value) { - form.setValue("firstMile.pickUpAddress", "", { - shouldDirty: true, - shouldValidate: true, - }); - } - }} - /> -
- )} - /> - {firstMileEnabled && ( - ( - - - - - )} - /> - )} -
- -
- ( -
-
- -
-

Last Mile - Delivery

-

- Truck delivery from the destination rail yard to the final - address (Port to Door). -

-
-
- { - field.onChange(value); - if (!value) { - form.setValue("lastMile.deliveryAddress", "", { - shouldDirty: true, - shouldValidate: true, - }); - form.setValue("equipmentReturn", "with_return", { - shouldDirty: true, - }); - } - }} - /> -
- )} - /> - {lastMileEnabled && ( - ( - - - - - )} - /> - )} -
- - {lastMileEnabled && ( + {showServiceSections && ( +
(
+
-

Equipment Return

+

+ First Mile - Pick-up +

- {field.value === "with_return" - ? "Container returned to EDR after unloading." - : "Container retained by the customer after delivery."} + Truck pick-up from your premises (Door to Port) to the + origin rail yard.

{ - field.onChange(value ? "with_return" : "without_return"); + field.onChange(value); + if (!value) { + form.setValue("firstMile.pickUpAddress", "", { + shouldDirty: true, + shouldValidate: true, + }); + } }} />
)} /> -
- )} - -
- ( -
-
- -
-

- Customs Clearing Service -

-

- EDR handles customs documentation and clearance on your - behalf. -

-
-
- -
+ {firstMileEnabled && ( + ( + + + + + )} + /> )} - /> +
+ +
+ ( +
+
+ +
+

+ Last Mile - Delivery +

+

+ Truck delivery from the destination rail yard to the + final address (Port to Door). +

+
+
+ { + field.onChange(value); + if (!value) { + form.setValue("lastMile.deliveryAddress", "", { + shouldDirty: true, + shouldValidate: true, + }); + form.setValue("equipmentReturn", "with_return", { + shouldDirty: true, + }); + } + }} + /> +
+ )} + /> + {lastMileEnabled && ( + ( + + + + + )} + /> + )} +
+ + {lastMileEnabled && ( +
+ ( +
+
+
+

Equipment Return

+

+ {field.value === "with_return" + ? "Container returned to EDR after unloading." + : "Container retained by the customer after delivery."} +

+
+
+ { + field.onChange( + value ? "with_return" : "without_return", + ); + }} + /> +
+ )} + /> +
+ )} + +
+ ( +
+
+ +
+

+ Customs Clearing Service +

+

+ EDR handles customs documentation and clearance on your + behalf. +

+
+
+ +
+ )} + /> +
-
+ )}
); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index 7e017efbb..1898feb73 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -1,13 +1,6 @@ import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react"; -import { - Button, - Field, - FieldError, - FieldLabel, - Input, - Separator, -} from "@edr/ui-common"; +import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common"; import { BREAK_BULK_TYPES, BULK_COMMODITIES, @@ -83,7 +76,6 @@ export function Step5CargoDetails({ form.setValue("freightType", undefined, { shouldDirty: true, }); - form.setValue("cargoWeight", "", { shouldDirty: true }); }} >
@@ -98,18 +90,7 @@ export function Step5CargoDetails({ selected={cargoType === "bulk"} onClick={() => { field.onChange("bulk"); - form.setValue( - "containers", - [ - { - type: "20ft", - containerType: "", - qty: "1", - vgm: "", - }, - ], - { shouldDirty: true }, - ); + form.setValue("containers", [], { shouldDirty: true }); }} >
@@ -127,143 +108,137 @@ export function Step5CargoDetails({ />
+
+ Weight + ( + + + Total Cargo Weight(Tons)* + +
+ + +
+ +
+ )} + /> +
{cargoType === "bulk" && ( - <> - -
- Freight Type * - ( - -
- field.onChange("bulk")} - > -

Bulk

-

- Coffee, fertilizer, grain, ore, etc. -

-
- field.onChange("break_bulk")} - > -

Break-Bulk

-

- Machinery, vehicles, project cargo, etc. -

-
-
- -
- )} - /> +
+ Freight Type * + ( + +
+ field.onChange("bulk")} + > +

Bulk

+

+ Coffee, fertilizer, grain, ore, etc. +

+
+ field.onChange("break_bulk")} + > +

Break-Bulk

+

+ Machinery, vehicles, project cargo, etc. +

+
+
+ +
+ )} + /> - {freightType === "bulk" && ( -
+ {freightType === "bulk" && ( +
+ ( + + + + )} + /> + {bulkCommodity === "Others" && ( ( - - - + + + + )} /> - {bulkCommodity === "Others" && ( - ( - - - - - )} - /> - )} -
- )} + )} +
+ )} - {freightType === "break_bulk" && ( -
+ {freightType === "break_bulk" && ( +
+ ( + + + + )} + /> + {breakBulkType === "Others" && ( ( - - - + + + + )} /> - {breakBulkType === "Others" && ( - ( - - - - - )} - /> - )} -
- )} -
- - - -
- Weight - ( - - - Total Cargo Weight - VGM (Tons) * - -
- - -
- -
)} - /> -
- +
+ )} +
)} {cargoType === "container" && ( @@ -407,7 +382,7 @@ export function Step5CargoDetails({ control={form.control} render={({ field: vgmField, fieldState }) => ( - VGM (Tons) * + Tons* vgmField.onChange(e.target.value)} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index 196aac9ad..6f72dc63f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -22,12 +22,10 @@ type BookingForm = UseFormReturn; export function Step8Review({ form, setStep, - wagons, direction, }: { form: BookingForm; setStep: (step: number) => void; - wagons: WagonCalcResult | null; direction: RouteDirection; }) { const values = form.watch(); @@ -102,11 +100,6 @@ export function Step8Review({ value={values.contractType === "new" ? "New Contract" : "Renewal"} target={1} /> - 0 ? `${totalVgm.toFixed(1)} tons` : ""} target={4} /> - 1 ? "s" : ""}` - : "" - } - target={4} - />
From bf51ead32dd136b246196295188977662458d3cb Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 29 May 2026 17:18:11 +0300 Subject: [PATCH 5/5] feat(portal): Add Profile page, refactor booking details, and remove documents module --- apps/edr-freight-web/portal/src/App.tsx | 7 +- .../portal/src/pages/ProfilePage.tsx | 326 ++++++++++ .../src/pages/bookings/BookingDetailPage.tsx | 568 ++++++++++------- .../pages/documents/DeleteDocumentDialog.tsx | 61 -- .../src/pages/documents/DocumentsPage.tsx | 576 ------------------ .../src/pages/documents/NewDocumentPage.tsx | 242 -------- .../src/pages/documents/documents.mock.ts | 143 ----- packages/types/src/freight/index.ts | 30 +- 8 files changed, 701 insertions(+), 1252 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/ProfilePage.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/documents/DeleteDocumentDialog.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/documents/DocumentsPage.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/documents/NewDocumentPage.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/documents/documents.mock.ts diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 647878972..139e74280 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -13,10 +13,12 @@ import { FileText, Home, Loader2, + User, } from "lucide-react"; import useAuth from "./hooks/useAuth"; +import ProfilePage from "./pages/ProfilePage"; import MyPortalPage from "./pages/MyPortalPage"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import SignupPage from "./pages/accounts/SignupPage"; @@ -29,7 +31,6 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; import TrackingPage from "./pages/tracking/TrackingPage"; import BillingPage from "./pages/billing/BillingPage"; -import DocumentsPage from "./pages/documents/DocumentsPage"; import { useEffect } from "react"; const sidebarItems: SidebarItem[] = [ @@ -37,7 +38,7 @@ const sidebarItems: SidebarItem[] = [ { label: "My Bookings", href: "/bookings", icon: }, { label: "Tracking", href: "/tracking", icon: }, { label: "Billing", href: "/billing", icon: }, - { label: "Documents", href: "/documents", icon: }, + { label: "Profile", href: "/profile", icon: }, ]; const App = () => { @@ -97,7 +98,7 @@ const App = () => { } /> } /> } /> - } /> + } /> } /> diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx new file mode 100644 index 000000000..e0d364bc1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx @@ -0,0 +1,326 @@ +import { useMemo } from "react"; +import { + User, + Building2, + Phone, + Mail, + MapPin, + ShieldCheck, + Briefcase, + UserCheck, + Building, + Globe, + Fingerprint, + FileCheck, + Settings2, + ExternalLink, +} from "lucide-react"; +import useAuth from "@/hooks/useAuth"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardAction, + Badge, + Separator, + SmartFileInput, + Button, +} from "@edr/ui-common"; +import type { IFileUploadSetting } from "@edr/types/freight"; +import { cn } from "@/lib/utils"; + +export default function ProfilePage() { + const { user, customer, isPending } = useAuth(); + + const documentSettings = useMemo(() => ({ + id: "profile-docs", + code: "customer_documents", + label: "Customer Documents", + entity: "customer", + createdAt: new Date(), + updatedAt: new Date(), + fields: [ + { + id: "doc-tin", + settingId: "profile-docs", + fileKey: "tin_certificate", + fileLabel: "TIN Certificate", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], + maxSizeMb: 5, + order: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "doc-license", + settingId: "profile-docs", + fileKey: "business_license", + fileLabel: "Business/Investment License", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], + maxSizeMb: 5, + order: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "doc-reg", + settingId: "profile-docs", + fileKey: "registration_certificate", + fileLabel: "Business Registration Certificate", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], + maxSizeMb: 5, + order: 3, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "doc-id", + settingId: "profile-docs", + fileKey: "national_id", + fileLabel: "National ID", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], + maxSizeMb: 5, + order: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "doc-poa", + settingId: "profile-docs", + fileKey: "power_of_attorney", + fileLabel: "Power of Attorney", + isRequired: false, + isMultiple: false, + maxFiles: 1, + allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], + maxSizeMb: 5, + order: 5, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }), []); + + if (isPending) { + return ( +
+
+
+ ); + } + + const displayName = user?.name?.en || user?.username || user?.email || "User"; + + return ( +
+
+ {/* Header Section */} +
+
+
+ +
+
+
+

+ {displayName} +

+ + Verified + +
+

+ + {customer?.companyName || "No Company Linked"} +

+
+
+
+ +
+
+ + + +
+ {/* Left Column - Personal & Company Info */} +
+
+ {/* Personal Details Card */} + + + + + Personal Details + + Your account contact information + + + + + + } label="Email Address" value={user?.email} /> + } label="Phone Number" value={user?.phoneNumber} /> + } label="Username" value={user?.username} /> + + + + {/* Company Details Card */} + + + + + Company Details + + Business registration information + + + } label="Location" value={customer?.companyLocation} /> + } label="Address" value={customer?.companyAddress} /> + } label="TIN Number" value={customer?.tinNumber} /> + } label="FAN Number" value={customer?.fanNumber} /> + + +
+ + {/* Personnel Card */} + + + + + Key Personnel + + Management and contact persons + + +
+

+ Contact Person +

+
+ + +
+
+
+

+ General Manager +

+
+ + + +
+
+
+
+ + {/* Power of Attorney Section (Conditional) */} + {customer?.poaName && ( + + + + + Power of Attorney + + Authorized representative details + + + + + + + + + )} +
+ + {/* Right Column - Documents */} +
+ + + + + Documents + + Manage required business documents + + + + + + + +
+ +
+ +

Secure Account

+

+ Your information is protected by enterprise-grade security. + Contact support for verified information updates. +

+
+ +
+
+
+
+
+
+
+ ); +} + +function InfoItem({ + icon, + label, + value, +}: { + icon?: React.ReactNode; + label: string; + value?: string | null; +}) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +
+

+ {label} +

+

+ {value || "—"} +

+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx index 1ce8f76d5..8b153515d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx @@ -1,22 +1,69 @@ -import { Link, useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { - ArrowLeft, - ArrowRight, Calendar, - Flag, MapPin, Package, StickyNote, - Trash2, Train, - User, Weight, + Ship, + Truck, + Anchor, + FileText, + ShieldCheck, + AlertTriangle, + Info, + Clock, + Layers, + CheckCircle2, + History, + ArrowRight, + ClipboardCheck, + CreditCard, + FileSignature, + PackageCheck, } from "lucide-react"; import Breadcrumbs from "@/components/Breadcrumbs"; -import DeleteBookingDialog from "./DeleteBookingDialog"; -import { getBookingById, deleteBooking, type BookingStatus } from "./bookings.mock"; -import { Button, Card } from "@edr/ui-common"; +import { getBookingById } from "./bookings.mock"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Badge, + Separator, +} from "@edr/ui-common"; +import { cn } from "@/lib/utils"; + +// Grouping the 15 granular statuses into 6 logical progress stages for the UI tracker +const PROGRESS_STAGES = [ + { label: "Request", icon: FileText, statuses: ["DRAFT", "RFQ_SUBMITTED"] }, + { label: "Quotation", icon: ClipboardCheck, statuses: ["QUOTATION_SENT", "QUOTATION_APPROVED", "QUOTATION_REJECTED"] }, + { label: "Approval", icon: ShieldCheck, statuses: ["PENDING_APPROVAL", "APPROVED"] }, + { label: "Execution", icon: FileSignature, statuses: ["SIGNED_CUSTOMER", "FULLY_EXECUTED", "PAID"] }, + { label: "In Transit", icon: Train, statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] }, + { label: "Complete", icon: PackageCheck, statuses: ["COMPLETED"] }, +]; + +const STATUS_MAP: Record = { + DRAFT: { title: "Drafting Request", description: "Booking is being prepared and has not been submitted.", color: "text-slate-500", stage: 0 }, + RFQ_SUBMITTED: { title: "RFQ Submitted", description: "Request for Quotation has been sent to the operations team.", color: "text-amber-600", stage: 0 }, + QUOTATION_SENT: { title: "Quotation Received", description: "EDR has sent a formal quotation for your review.", color: "text-sky-600", stage: 1 }, + QUOTATION_APPROVED: { title: "Quotation Approved", description: "You have accepted the quotation terms.", color: "text-emerald-600", stage: 1 }, + QUOTATION_REJECTED: { title: "Quotation Rejected", description: "The quotation was not accepted.", color: "text-red-600", stage: 1 }, + PENDING_APPROVAL: { title: "Internal Approval", description: "Booking is undergoing final administrative review.", color: "text-amber-600", stage: 2 }, + APPROVED: { title: "Booking Approved", description: "Request is fully approved and ready for execution.", color: "text-emerald-600", stage: 2 }, + SIGNED_CUSTOMER: { title: "Customer Signed", description: "Contract has been signed by the customer.", color: "text-sky-600", stage: 3 }, + FULLY_EXECUTED: { title: "Contract Executed", description: "All parties have signed. Operational setup in progress.", color: "text-indigo-600", stage: 3 }, + PAID: { title: "Payment Received", description: "Initial payments confirmed. Cargo ready for dispatch.", color: "text-emerald-600", stage: 3 }, + IN_TRANSIT: { title: "Cargo Moving", description: "Shipment is currently moving through the rail network.", color: "text-sky-600", stage: 4 }, + PENDING_CONSOLIDATION: { title: "Consolidation Node", description: "Cargo is waiting to be consolidated with other shipments.", color: "text-amber-500", stage: 4 }, + CONSOLIDATED: { title: "Load Consolidated", description: "Cargo has been successfully merged into a larger shipment.", color: "text-indigo-500", stage: 4 }, + COMPLETED: { title: "Service Complete", description: "Cargo delivered and service successfully terminated.", color: "text-emerald-600", stage: 5 }, + CANCELLED: { title: "Cancelled", description: "This booking process has been terminated.", color: "text-red-600", stage: -1 }, +}; export default function BookingDetailPage() { const { id } = useParams<{ id: string }>(); @@ -25,37 +72,29 @@ export default function BookingDetailPage() { if (!booking) { return ( -
-
- - -

- Booking not found -

-

- The booking you're looking for doesn't exist or has been removed. -

- - - Back to Bookings - -
-
+
+ +
+ +
+

+ Booking not found +

+
); } + // Normalize status to upper case for mapping + const normalizedStatus = (booking.status === "In Transit" ? "IN_TRANSIT" : booking.status === "Pending" ? "RFQ_SUBMITTED" : booking.status.toUpperCase()) as keyof typeof STATUS_MAP; + const statusConfig = STATUS_MAP[normalizedStatus] || STATUS_MAP.DRAFT; + const currentStageIndex = statusConfig.stage; + return ( -
-
+
+
+ + {/* Breadcrumbs Restored */} - -
-
-
- + {/* Compact Header Card */} + + +
+
+
-
-

- {booking.reference} -

-
- {booking.customer} - - {booking.requestedDate} - - +
+
+

+ {booking.reference} +

+ +
+
+ {booking.customer} + + + + {booking.requestedDate} +
- -
- - - { - deleteBooking(booking.id); - navigate("/bookings"); - }} - > - - -
-
+ - -
- } - /> -
- - + {/* Granular Status Lifecycle */} + + + + + Booking Status Lifecycle + + Track the journey from request to completion + + +
+ {/* Progress Line */} +
+
= 0 ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` : '0%' }} + /> +
+ + {PROGRESS_STAGES.map((stage, idx) => { + const isCompleted = idx < currentStageIndex; + const isActive = idx === currentStageIndex; + + return ( +
+
+ {isCompleted ? : } +
+ + {stage.label} + +
+ ); + })}
- } - /> -
+ +
+
+ {normalizedStatus === "CANCELLED" ? : } +
+
+

+ {statusConfig.title} +

+

+ {statusConfig.description} +

+
+ {normalizedStatus !== "CANCELLED" && normalizedStatus !== "COMPLETED" && ( +
+
+

Est. Waiting

+

1-2 Working Days

+
+ +
+ )} +
+
- {booking.transportMode === "Multimodal" && - booking.legs && - booking.legs.length > 0 ? ( - -
- -

- Transport Legs -

- - {booking.legs.length} legs - -
-
- {booking.legs.map((leg, i) => ( -
-
-
- {i + 1} -
-
-

- Leg {i + 1} · {leg.mode} -

-

- {leg.from || "—"} - - {leg.to || "—"} -

+
+
+ {/* Route & Core Service Card */} + + + + + Route & Service + + + +
+ } + /> +
+
+ +
+ + Rail + +
+ } + /> +
+ +
+ } label="Service" value="Rail & Forwarding" /> + } label="Return" value="With Return" /> + } label="Customs" value="Enabled" /> +
+
+
+ + {/* Mile Services Card */} + + + + + Mile Services + + + +
+

+ First Mile +

+ +
+
+

+ Last Mile +

+

Not requested

+
+
+
+ + {/* Cargo Specifications Card */} + + + + + Cargo Specifications + + + +
+ } label="Category" value={booking.cargoType} /> + } label="Weight" value={`${booking.weightTons} Tons`} /> + } label="Shipping Line" value="MSC" /> +
+ + + +
+

Load Details

+
+ + + + + + + + + + + + + + + +
DescriptionUnitValue
Main Equipment20FT Container4 Units
- ))} -
- - ) : null} + + +
-
- - } - label="Customer" - value={booking.customer} - /> - } - label="Cargo Type" - value={booking.cargoType} - /> - } - label="Container" - value={`${booking.containerCount} × ${booking.containerType}`} - /> - } - label="Weight" - value={`${booking.weightTons} tons`} - /> - +
+ {/* Contract Card */} + + + + + Contract Info + + + + + + +
+ + Hazardous: No + + + Refrigerated: No + +
+
+
- - } - label="Transport Mode" - value={booking.transportMode} - /> - } - label="Requested Date" - value={booking.requestedDate} - /> - } - label="Priority" - value={booking.priority} - /> - - - -
- -

{booking.cargoDescription}

-
-
- - -
- -

{booking.specialInstructions}

-
-
+ {/* Notes Card */} + + + Additional Info + + +
+

Description

+

"{booking.cargoDescription}"

+
+ +
+

Instructions

+
+

+ + {booking.specialInstructions} +

+
+
+
+
+
@@ -233,68 +369,64 @@ function RouteEndpoint({ }) { return (
-
- {icon} +
+ {icon &&
{icon}
}
-
-

+

+

{label}

-

{station}

+

{station}

); } -function DetailCard({ - title, - children, -}: { - title: string; - children: React.ReactNode; +function InfoItem({ + icon, + label, + value +}: { + icon?: React.ReactNode; + label: string; + value?: string | number | null }) { return ( - -

{title}

-
{children}
-
- ); -} - -function DetailRow({ - icon, - label, - value, -}: { - icon: React.ReactNode; - label: string; - value: string; -}) { - return ( -
-
{icon}
-
-

{label}

-

{value}

+
+ {icon &&
{icon}
} +
+

{label}

+

{value || "—"}

); } -function StatusBadge({ status }: { status: BookingStatus }) { - const styles: Record = { - Pending: "bg-amber-100 text-amber-700", - Confirmed: "bg-sky-100 text-sky-700", - "In Transit": "bg-indigo-100 text-indigo-700", - Delivered: "bg-emerald-100 text-emerald-700", - Cancelled: "bg-red-100 text-red-700", +function StatusBadge({ status }: { status: string }) { + const statusColors: Record = { + DRAFT: "bg-slate-50 text-slate-700 border-slate-200", + RFQ_SUBMITTED: "bg-amber-50 text-amber-700 border-amber-200", + QUOTATION_SENT: "bg-sky-50 text-sky-700 border-sky-200", + QUOTATION_APPROVED: "bg-emerald-50 text-emerald-700 border-emerald-200", + QUOTATION_REJECTED: "bg-red-50 text-red-700 border-red-200", + PENDING_APPROVAL: "bg-amber-50 text-amber-700 border-amber-200", + APPROVED: "bg-emerald-50 text-emerald-700 border-emerald-200", + SIGNED_CUSTOMER: "bg-sky-50 text-sky-700 border-sky-200", + FULLY_EXECUTED: "bg-indigo-50 text-indigo-700 border-indigo-200", + PAID: "bg-emerald-50 text-emerald-700 border-emerald-200", + IN_TRANSIT: "bg-sky-50 text-sky-700 border-sky-200", + COMPLETED: "bg-indigo-50 text-indigo-700 border-indigo-200", + CANCELLED: "bg-red-50 text-red-700 border-red-200", + PENDING_CONSOLIDATION: "bg-amber-50 text-amber-700 border-amber-200", + CONSOLIDATED: "bg-indigo-50 text-indigo-700 border-indigo-200", }; return ( - - {status} - + {status.replace(/_/g, ' ')} + ); } diff --git a/apps/edr-freight-web/portal/src/pages/documents/DeleteDocumentDialog.tsx b/apps/edr-freight-web/portal/src/pages/documents/DeleteDocumentDialog.tsx deleted file mode 100644 index 4bd53adc6..000000000 --- a/apps/edr-freight-web/portal/src/pages/documents/DeleteDocumentDialog.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import type { ReactNode } from "react"; - -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; - -import { Button } from "@/components/ui/button"; - -export interface DeleteDocumentDialogProps { - documentName: string; - onConfirm?: () => void; - children: ReactNode; -} - -export default function DeleteDocumentDialog({ - documentName, - onConfirm, - children, -}: DeleteDocumentDialogProps) { - return ( - - {children} - - - - - Delete document? - - - - This will permanently delete{" "} - {documentName}{" "} - and remove it from object storage. This action cannot be undone. - - - - - - - - - - - - - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/documents/DocumentsPage.tsx b/apps/edr-freight-web/portal/src/pages/documents/DocumentsPage.tsx deleted file mode 100644 index 25c1b1dbe..000000000 --- a/apps/edr-freight-web/portal/src/pages/documents/DocumentsPage.tsx +++ /dev/null @@ -1,576 +0,0 @@ -import { useMemo, useState } from "react"; -import { - CheckCircle2, - Clock, - Download, - Eye, - File, - FileImage, - FileSpreadsheet, - FileText, - Filter, - HardDrive, - LayoutGrid, - List, - MoreHorizontal, - Pencil, - Plus, - Search, - Trash2, -} from "lucide-react"; - -import Breadcrumbs from "@/components/Breadcrumbs"; -import NewDocumentPage from "./NewDocumentPage"; -import DeleteDocumentDialog from "./DeleteDocumentDialog"; -import { - documents, - formatBytes, - type DocumentFormat, - type DocumentRecord, - type DocumentStatus, -} from "./documents.mock"; -import { - DataTable, - DataTableFooter, - type ColumnDef, - usePagination, - Button, - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Input, - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, -} from "@edr/ui-common"; - -type FilterValue = "All" | DocumentStatus; -type ViewMode = "grid" | "table"; - -const FILTERS: FilterValue[] = [ - "All", - "Draft", - "Pending Review", - "Approved", - "Rejected", - "Expired", -]; - -export default function DocumentsPage() { - const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [filter, setFilter] = useState("All"); - const [query, setQuery] = useState(""); - const [view, setView] = useState("table"); - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - return documents.filter((d) => { - if (filter !== "All" && d.status !== filter) return false; - if (!q) return true; - return ( - d.name.toLowerCase().includes(q) || - d.type.toLowerCase().includes(q) || - d.linkedReference.toLowerCase().includes(q) || - d.uploadedBy.toLowerCase().includes(q) - ); - }); - }, [filter, query]); - - const total = filtered.length; - const pageCount = Math.ceil(total / pagination.pageSize); - const start = pagination.pageIndex * pagination.pageSize; - const end = Math.min(start + pagination.pageSize, total); - - const paginatedData = useMemo( - () => filtered.slice(start, end), - [start, end, filtered], - ); - - const totalSize = documents.reduce((sum, d) => sum + d.sizeBytes, 0); - const approvedCount = documents.filter((d) => d.status === "Approved").length; - const pendingCount = documents.filter( - (d) => d.status === "Pending Review", - ).length; - - const columns: ColumnDef[] = [ - { - id: "document", - header: "Document", - cell: ({ row }) => { - const doc = row.original; - return ( -
-
- -
-
-

{doc.name}

-

- {doc.format} · By {doc.uploadedBy} -

-
-
- ); - }, - }, - { - accessorKey: "type", - header: "Type", - }, - { - id: "linkedTo", - header: "Linked To", - cell: ({ row }) => ( -
-

{row.original.linkedReference}

-

{row.original.linkedType}

-
- ), - }, - { - id: "size", - header: "Size", - cell: ({ row }) => ( - - {formatBytes(row.original.sizeBytes)} - - ), - }, - { - accessorKey: "uploadedAt", - header: "Uploaded", - }, - { - accessorKey: "status", - header: "Status", - cell: ({ row }) => , - }, - { - id: "actions", - size: 40, - cell: ({ row }) => { - const doc = row.original; - return ( -
e.stopPropagation()} - > - - - - - - - - Preview - - - - Download - - - - e.preventDefault()}> - - Edit - - - - - e.preventDefault()} - variant="destructive" - > - - Delete - - - - -
- ); - }, - }, - ]; - - return ( -
-
- - - -
-

- Documents -

-

- Manage freight documents linked to bookings, consignments, and - invoices. -

-
- -
-
- - { - setQuery(e.target.value); - setPagination({ - pageIndex: 0, - pageSize: pagination.pageSize, - }); - }} - placeholder="Search documents..." - className="pl-8!" - /> -
- - - - -
-
- -
- - -
-

Total Documents

-

- {documents.length} -

-
-
- -
-
-
- - - -
-

Approved

-

- {approvedCount} -

-
-
- -
-
-
- - - -
-

Pending Review

-

- {pendingCount} -

-
-
- -
-
-
- - - -
-

Storage Used

-

- {formatBytes(totalSize)} -

-
-
- -
-
-
-
- - -
-
- {FILTERS.map((f) => { - const isActive = f === filter; - const count = - f === "All" - ? documents.length - : documents.filter((d) => d.status === f).length; - return ( - - ); - })} -
- -
- setView("grid")} - label="Grid view" - > - - Grid - - setView("table")} - label="Table view" - > - - Table - -
-
-
- - {paginatedData.length === 0 ? ( - -

- No documents match your filters. -

-
- ) : view === "grid" ? ( -
- {paginatedData.map((doc) => ( - - ))} -
- ) : ( - - -
- Document Library - - All freight documents stored in the system. - -
- -
- - - { }} - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount: pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - }} - containerClassName="border-b shadow-none" - footer={DataTableFooter} - /> - -
- )} -
-
- ); -} - -function ViewToggleButton({ - active, - onClick, - label, - children, -}: { - active: boolean; - onClick: () => void; - label: string; - children: React.ReactNode; -}) { - return ( - - ); -} - -function FormatIcon({ format }: { format: DocumentFormat }) { - if (format === "PDF") return ; - if (format === "DOCX") return ; - if (format === "XLSX") return ; - if (format === "PNG" || format === "JPG") return ; - return ; -} - -function DocumentCard({ doc }: { doc: DocumentRecord }) { - return ( - -
-
-
-
- -
-
-

- {doc.name} -

-

{doc.type}

-
-
- -
- -
- - - - -
- -

By {doc.uploadedBy}

- -
e.stopPropagation()} - > - - - - - - - - Preview - - - - Download - - - - e.preventDefault()}> - - Edit - - - - - e.preventDefault()} - variant="destructive" - > - - Delete - - - - -
-
-
- ); -} - -function MetaRow({ label, value }: { label: string; value: string }) { - return ( -
-

{label}

-

{value}

-
- ); -} - -function StatusBadge({ status }: { status: DocumentStatus }) { - const styles: Record = { - Draft: "bg-slate-100 text-slate-600", - "Pending Review": "bg-amber-100 text-amber-700", - Approved: "bg-emerald-100 text-emerald-700", - Rejected: "bg-red-100 text-red-700", - Expired: "bg-slate-200 text-slate-700", - }; - - return ( - - {status} - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/documents/NewDocumentPage.tsx b/apps/edr-freight-web/portal/src/pages/documents/NewDocumentPage.tsx deleted file mode 100644 index 96ae42703..000000000 --- a/apps/edr-freight-web/portal/src/pages/documents/NewDocumentPage.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { useState, type ReactNode } from "react"; -import { FileUp, Hash } from "lucide-react"; - -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; - -import { bookings } from "../bookings/bookings.mock"; -import { consignments } from "../consignments/consignments.mock"; -import { customers } from "../customers/customers.mock"; -import type { - DocumentLinkType, - DocumentStatus, - DocumentType, -} from "./documents.mock"; - -export interface DocumentFormData { - name?: string; - type?: DocumentType; - status?: DocumentStatus; - linkedType?: DocumentLinkType; - linkedReference?: string; - notes?: string; -} - -export interface NewDocumentPageProps { - mode?: "create" | "edit"; - document?: DocumentFormData; - children?: ReactNode; -} - -const selectClass = - "flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"; - -export default function NewDocumentPage({ - mode = "create", - document, - children, -}: NewDocumentPageProps = {}) { - const isEdit = mode === "edit"; - const title = isEdit ? "Edit Document" : "Upload Document"; - const description = isEdit - ? "Update document metadata." - : "Upload a freight document and link it to a booking, consignment, or invoice."; - const submitLabel = isEdit ? "Save Changes" : "Upload"; - - const [linkedType, setLinkedType] = useState( - document?.linkedType ?? "Booking", - ); - const [fileName, setFileName] = useState(""); - - const referenceOptions = (() => { - if (linkedType === "Booking") { - return bookings.map((b) => ({ - value: b.reference, - label: `${b.reference} — ${b.customer}`, - })); - } - if (linkedType === "Consignment") { - return consignments.map((c) => ({ - value: c.trackingNumber, - label: `${c.trackingNumber} — ${c.customer}`, - })); - } - if (linkedType === "Customer") { - return customers.map((c) => ({ - value: c.company, - label: c.company, - })); - } - return [] as Array<{ value: string; label: string }>; - })(); - - return ( - - - {children ?? } - - - - - {title} - {description} - - -
- {/* File picker — drop zone */} - {!isEdit ? ( -
- - -
- ) : null} - - {/* Document Name */} -
- -
- - -
-
- - {/* Document Type */} -
- - -
- - {/* Linked To */} -
- - -
- - {/* Reference */} -
- - {referenceOptions.length > 0 ? ( - - ) : ( - - )} -
- - {/* Status */} -
- - -
- - {/* Notes */} -
- -