diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 6044cd534..b2a1f0c1a 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -20,7 +20,9 @@ import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-set import { OtpModule } from './modules/otp/otp.module'; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.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: [ @@ -48,17 +50,20 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder"; OtpModule, RuleEngineModule, 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..8d886e3aa --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-users.seeder.ts @@ -0,0 +1,213 @@ +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, + 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 superAdminRole = await roleRepository.findOne({ + where: { key: ERoleKey.SUPER_ADMIN }, + select: { id: true, key: true }, + }); + + 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, + }, + ...(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, { + 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 8bf91a9a8..55250dacb 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -11,6 +11,8 @@ 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"; import { RuleEnginePage } from "./pages/ruleEngine/RuleEngine"; // Create a QueryClient instance @@ -56,11 +58,74 @@ const sidebarItems: SidebarItem[] = [ }, ]; +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", + }, + ], + }, + { + label: "Rule Engine", + href: "/dashboard/rule-engine", + icon: , + }, + ...(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 ( @@ -99,7 +164,7 @@ const App = () => { return ( - + {/* } /> } /> }> @@ -114,6 +179,24 @@ 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; 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/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 43f1abb21..bfb175895 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -20,9 +20,11 @@ import { const userSchema = z.object({ email: z.string().email("Invalid email address"), - username: z.string().min(3, "Username must be at least 3 characters"), countryCode: z.string().min(1, "Country code is required"), - phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"), + phone: z + .string() + .min(9, "Phone number is too short") + .max(9, "Phone number is too long"), userType: z.string(), name: z.object({ en: z.string().min(2, "Name is required"), @@ -46,7 +48,6 @@ export default function SignupPage() { resolver: zodResolver(userSchema), defaultValues: { email: "", - username: "", countryCode: "+251", phone: "", userType: userType.individual, @@ -58,10 +59,12 @@ export default function SignupPage() { setError(null); setLoading(true); try { - const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone; + const normalizedPhone = data.phone.startsWith("0") + ? data.phone.slice(1) + : data.phone; const payload: SignupPayload = { email: data.email, - username: data.username, + username: data.email, phoneNumber: `${data.countryCode}${normalizedPhone}`, userType: data.userType, name: { en: data.name.en, am: data.name.am ?? "" }, @@ -92,7 +95,12 @@ export default function SignupPage() { "Enterprise-grade operations", "Multi-corridor freight monitoring", ], - stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" }, + stats: { + label: "Active Corridors", + value: "24+", + footer: "Operational", + progress: "w-[95%]", + }, }} >
@@ -125,31 +133,17 @@ export default function SignupPage() { -
- - Username - - - - - - Email Address - - - -
+ + Email Address + + + - - - { - 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/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index f96e76b51..7792c712a 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,7 +50,6 @@ export default function NewBookingPage() { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); const containers = form.watch("containers"); - const previousContractRef = form.watch("previousContractRef"); const direction = useMemo( () => getRouteDirection(originYard, destinationYard), @@ -68,63 +61,18 @@ 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); - } - 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; @@ -149,19 +97,22 @@ export default function NewBookingPage() { data.contractType.toUpperCase() as CreateBookingPayload["contractType"], 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, - equipmentReturn: - data.equipmentReturn === "with_return" - ? ("WITH_RETURN" as const) - : ("WITHOUT_RETURN" as const), + data.service.serviceType === "rail" + ? "RAIL_ONLY" + : "RAIL_AND_FORWARDING", + ...(data.service.serviceType === "rail" + ? {} + : { + 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,43 +171,22 @@ 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 && ( + )}
@@ -277,11 +207,11 @@ export default function NewBookingPage() { {step < STEPS.length ? ( ) : ( )} 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..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 = [ @@ -42,123 +43,69 @@ 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(), - equipmentReturn: z.enum(["with_return", "without_return"]), - originYard: z.string(), - destinationYard: z.string(), + firstMile: z + .object({ + enabled: z.boolean().default(false), + pickUpAddress: z.string(), + }) + .refine((data) => !(data.enabled && !data.pickUpAddress.trim()), { + message: "Enter the pick-up address.", + path: ["pickUpAddress"], + }), + lastMile: z + .object({ + enabled: z.boolean().default(false), + deliveryAddress: z.string(), + }) + .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(), cargoType: z.enum(["container", "bulk"], "Select a cargo type."), cargoWeight: z.string(), freightType: z.enum(["bulk", "break_bulk"]).optional(), @@ -171,142 +118,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 +246,26 @@ 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 = { +export const initialBookingFormValues: DeepPartial = { 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 +273,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> = { +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 +304,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..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,6 +1,7 @@ +import { useEffect, useRef } from "react"; 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 +9,67 @@ 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"); + + 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. @@ -63,10 +117,172 @@ export function Step2ServiceType({ form }: { form: BookingForm }) { )} /> -

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

+ {showServiceSections && ( +
+
+ ( +
+
+ +
+

+ 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..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,16 +1,11 @@ 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, + CONTAINER_TYPES, + calcWagons, type BookingFormValues, type RouteDirection, } from "./schema"; @@ -81,7 +76,6 @@ export function Step5CargoDetails({ form.setValue("freightType", undefined, { shouldDirty: true, }); - form.setValue("cargoWeight", "", { shouldDirty: true }); }} >
@@ -96,11 +90,7 @@ export function Step5CargoDetails({ selected={cargoType === "bulk"} onClick={() => { field.onChange("bulk"); - form.setValue( - "containers", - [{ type: "20ft", qty: "1", vgm: "0" }], - { shouldDirty: true }, - ); + form.setValue("containers", [], { shouldDirty: true }); }} >
@@ -118,173 +108,162 @@ 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" && ( <> -
- 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 +272,9 @@ export function Step5CargoDetails({ return (
-

- Container #{index + 1} -

{fields.length > 1 && ( - - - - - - - - - ); -} 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 */} -
- -