From 2b4da2397ce4e9c5582bc5c371476ef80757fe5f Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 19 Jun 2026 16:02:12 +0300 Subject: [PATCH 01/97] style: ui fix to the auth shell --- .../portal/src/components/auth/AuthShell.tsx | 80 +++++++++++++------ 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx index 96d55bdd0..5a4adf587 100644 --- a/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx +++ b/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx @@ -11,14 +11,24 @@ export const primaryButtonClass = "h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"; const LeftPanelDecor = () => ( -
+
{[0, 1, 2, 3, 4, 5].map((ring) => ( - + ))}
@@ -26,12 +36,23 @@ const LeftPanelDecor = () => ( ); const RightPanelDecor = () => ( -
+
- + - + @@ -47,18 +68,25 @@ export interface AuthShellProps { taglineBody?: string; } -const LeftPanel = ({ tagline, taglineBody }: Pick) => ( -
+const LeftPanel = ({ + tagline, + taglineBody, +}: Pick) => ( +
Ethio Djibouti Railway -
+
- EDR Freight + EDR Freight ( ); -export default function AuthShell({ children, tagline, taglineBody }: AuthShellProps) { +export default function AuthShell({ + children, + tagline, + taglineBody, +}: AuthShellProps) { return ( <> - - - -
-
+
@@ -136,7 +170,7 @@ export default function AuthShell({ children, tagline, taglineBody }: AuthShellP
-
+
{children}
From 9caf9bbd59a90530b45b2c1a799e31b0d669b57a Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 19 Jun 2026 16:24:20 +0300 Subject: [PATCH 02/97] style: some clean up --- .../layout/FreightDashboardHeader.tsx | 21 ++- .../layout/FreightDashboardLayout.tsx | 69 +++----- .../src/components/layout/FreightSidebar.css | 63 ++++--- .../src/components/layout/FreightSidebar.tsx | 49 ++--- .../backoffice/src/pages/auth/LoginPage.tsx | 145 ++++++++++----- .../backoffice/src/theme/freight-brand.ts | 167 ++++++++++++++++-- 6 files changed, 344 insertions(+), 170 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index cc8074850..e626beb58 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -87,7 +87,12 @@ const FreightDashboardHeader = ({ {pageMeta.title} @@ -120,13 +125,6 @@ const FreightDashboardHeader = ({ - - - -
- + {userName} {userEmail && ( diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx index a8e0b0aa6..9ffc4753a 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx @@ -65,25 +65,24 @@ const FreightDashboardLayout = ({ return ( <> - - - - - + - - - + - - {children} - + {children} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css index 17dce66c3..b8d3e5cbe 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css @@ -8,12 +8,7 @@ max-height: 100%; width: 280px; flex-shrink: 0; - border-radius: 16px; - border: 1px solid #eef1f4; - background: #ffffff; - box-shadow: - 0 1px 2px rgba(15, 23, 42, 0.04), - 0 8px 24px -16px rgba(15, 23, 42, 0.12); + border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; @@ -36,8 +31,9 @@ content: ""; position: absolute; inset: 0; - background: - radial-gradient(120px 80px at 24px 18px, rgba(34, 197, 94, 0.08), transparent 70%); + background: radial-gradient(120px 80px at 24px 18px, + rgba(34, 197, 94, 0.08), + transparent 70%); pointer-events: none; } @@ -50,7 +46,7 @@ width: 44px; height: 44px; border-radius: 13px; - background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 60%, #15805F 100%); + background: linear-gradient(135deg, #2dbf95 0%, #1b9e7a 60%, #15805f 100%); box-shadow: 0 6px 16px -4px rgba(27, 158, 122, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.25); @@ -72,13 +68,16 @@ .fsb-nav::-webkit-scrollbar { width: 6px; } + .fsb-nav::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 3px; } + .fsb-nav::-webkit-scrollbar-thumb:hover { background: #cbd5e1; } + .fsb-nav::-webkit-scrollbar-track { background: transparent; } @@ -121,12 +120,10 @@ } .fsb-item[data-active="true"] { - background: linear-gradient( - 135deg, - rgba(34, 197, 94, 0.12) 0%, - rgba(27, 158, 122, 0.06) 100% - ); - color: #1B9E7A; + background: linear-gradient(135deg, + rgba(34, 197, 94, 0.12) 0%, + rgba(27, 158, 122, 0.06) 100%); + color: #1b9e7a; font-weight: 600; } @@ -139,7 +136,7 @@ width: 3px; height: 22px; border-radius: 0 4px 4px 0; - background: linear-gradient(180deg, #2DBF95 0%, #1B9E7A 100%); + background: linear-gradient(180deg, #2dbf95 0%, #1b9e7a 100%); } .fsb-item-label { @@ -155,8 +152,6 @@ display: flex; align-items: center; justify-content: center; - width: 31px; - height: 31px; border-radius: 9px; flex-shrink: 0; background: #f1f5f9; @@ -164,13 +159,19 @@ transition: all 160ms ease; } +/* ---- Icon well ---- */ +.fsb-icon svg { + height: 24px; + width: 24px; +} + .fsb-item:hover .fsb-icon { background: #e6ebf1; color: #334155; } .fsb-item[data-active="true"] .fsb-icon { - background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 100%); + background: linear-gradient(135deg, #2dbf95 0%, #1b9e7a 100%); color: #ffffff; box-shadow: 0 5px 12px -2px rgba(27, 158, 122, 0.45); } @@ -219,9 +220,11 @@ background: transparent; transition: background-color 150ms ease; } + .fsb-group:hover { background-color: #f5f7fa; } + .fsb-group-label { font-size: 11px; font-weight: 700; @@ -229,8 +232,9 @@ text-transform: uppercase; color: #94a3b8; } + .fsb-group[data-active="true"] .fsb-group-label { - color: #1B9E7A; + color: #1b9e7a; } /* child leaf */ @@ -251,12 +255,14 @@ background-color 150ms ease, color 150ms ease; } + .fsb-child:hover { background-color: #f5f7fa; color: #0f172a; } + .fsb-child[data-active="true"] { - color: #1B9E7A; + color: #1b9e7a; font-weight: 600; background-color: rgba(27, 158, 122, 0.08); } @@ -269,11 +275,13 @@ background: #cbd5e1; transition: all 150ms ease; } + .fsb-child:hover .fsb-dot { background: #94a3b8; } + .fsb-child[data-active="true"] .fsb-dot { - background: #1B9E7A; + background: #1b9e7a; box-shadow: 0 0 0 3px rgba(27, 158, 122, 0.16); } @@ -283,36 +291,41 @@ padding: 12px; border-top: 1px solid #f1f5f9; } + .fsb-status { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 11px; - background: linear-gradient(135deg, #E7F8F2 0%, #f8fafc 100%); + background: linear-gradient(135deg, #e7f8f2 0%, #f8fafc 100%); border: 1px solid #e7f3ec; } + .fsb-pulse { position: relative; width: 9px; height: 9px; border-radius: 50%; - background: #2DBF95; + background: #2dbf95; flex-shrink: 0; } + .fsb-pulse::after { content: ""; position: absolute; inset: 0; border-radius: 50%; - background: #2DBF95; + background: #2dbf95; animation: fsb-pulse 2s ease-out infinite; } + @keyframes fsb-pulse { 0% { transform: scale(1); opacity: 0.6; } + 100% { transform: scale(2.6); opacity: 0; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index a2d685d0f..37a0c2a03 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -11,6 +11,7 @@ import { Box, Stack, Text } from "@mantine/core"; import type { SidebarItem, SidebarSection } from "./types"; import "./FreightSidebar.css"; +import { Link } from "react-router-dom"; export interface FreightSidebarProps { sections: SidebarSection[]; @@ -176,20 +177,7 @@ const FreightSidebar = ({ return ( - { - if (hasChildren) { - setExpanded((current) => ({ - ...current, - [item.href!]: true, - })); - } - navigateTo(e, item.href!); - }} - > + {item.icon && {item.icon}} {item.label} {hasChildren && ( @@ -212,7 +200,7 @@ const FreightSidebar = ({ /> )} - + {hasChildren && isOpen && (
@@ -224,7 +212,16 @@ const FreightSidebar = ({ }; return ( - +
@@ -233,7 +230,11 @@ const FreightSidebar = ({ EDR Freight @@ -257,20 +258,6 @@ const FreightSidebar = ({
))} - -
-
- - - - All systems operational - - - EDR Platform · v1.0 - - -
-
); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx index 23f05f3af..e689ef9f8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -1,6 +1,15 @@ import { type FormEvent, useState } from "react"; import { parsePhoneNumberFromString } from "libphonenumber-js"; -import { Eye, EyeOff, Mail, Smartphone, UserRound, ArrowUpRight, Globe, ChevronDown } from "lucide-react"; +import { + Eye, + EyeOff, + Mail, + Smartphone, + UserRound, + ArrowUpRight, + Globe, + ChevronDown, +} from "lucide-react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; @@ -13,10 +22,25 @@ const loginModes: Array<{ icon: typeof Mail; placeholder: string; }> = [ - { value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" }, - { value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" }, - { value: "username", label: "Username", icon: UserRound, placeholder: "username" }, -]; + { + value: "email", + label: "Email", + icon: Mail, + placeholder: "name@company.com", + }, + { + value: "phone", + label: "Phone", + icon: Smartphone, + placeholder: "09XXXXXXXX", + }, + { + value: "username", + label: "Username", + icon: UserRound, + placeholder: "username", + }, + ]; const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const usernamePattern = /^[a-zA-Z0-9._-]{3,32}$/; @@ -43,7 +67,9 @@ const normalizeIdentifier = (mode: LoginMode, value: string) => { } if (!usernamePattern.test(trimmed)) { - throw new Error("Username must be 3-32 characters and use letters, numbers, ., _, or -."); + throw new Error( + "Username must be 3-32 characters and use letters, numbers, ., _, or -.", + ); } return trimmed; @@ -59,14 +85,24 @@ const primaryButtonClass = "h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"; const LeftPanelDecor = () => ( -
+
{[0, 1, 2, 3, 4, 5].map((ring) => ( - + ))}
@@ -74,12 +110,23 @@ const LeftPanelDecor = () => ( ); const RightPanelDecor = () => ( -
+
@@ -138,13 +189,22 @@ const FormFooter = () => (
© 2026 EDR Freight @@ -176,7 +236,7 @@ const LoginPage = () => { setNormalizedIdentifier(normalized); const result = await login({ email: normalized, password }); - console.log(result) + console.log(result); if (result.mfaRequired) { setNeedsMfa(true); return; @@ -212,15 +272,20 @@ const LoginPage = () => {
-

Get Started

+

+ Get Started +

- Log in to access the freight backoffice & explore all logistics resources. + Log in to access the freight backoffice & explore all logistics + resources.

- +
- - I agree to EDR Freight{" "} - - Terms & Conditions - - . - - - {error ? (
{error}
) : null} - @@ -318,8 +377,10 @@ const LoginPage = () => {

We sent a verification code to{" "} - {normalizedIdentifier}. Enter it below - to complete sign in. + + {normalizedIdentifier} + + . Enter it below to complete sign in.

@@ -354,7 +415,11 @@ const LoginPage = () => { > Back -
diff --git a/apps/edr-freight-web/backoffice/src/theme/freight-brand.ts b/apps/edr-freight-web/backoffice/src/theme/freight-brand.ts index 32192dd36..6c1efd3f3 100644 --- a/apps/edr-freight-web/backoffice/src/theme/freight-brand.ts +++ b/apps/edr-freight-web/backoffice/src/theme/freight-brand.ts @@ -1,4 +1,8 @@ -import { createTheme, type MantineColorsTuple } from "@mantine/core"; +import { + colorsTuple, + createTheme, + type MantineColorsTuple, +} from "@mantine/core"; /** EDR Freight primary brand green */ export const FREIGHT_BRAND = "#1B9E7A"; @@ -17,24 +21,155 @@ export const freightBrand = { mutedBorder: "#B7EBDC", } as const; -/** Mantine green scale with #1B9E7A at index 6 (filled buttons, nav active). */ -const freightGreen: MantineColorsTuple = [ - "#E7F8F2", - "#C5EFE1", - "#9CE4CD", - "#6BD6B5", - "#41C8A0", - "#25B58C", - FREIGHT_BRAND, - FREIGHT_BRAND_DARK, - "#105F47", - "#0A3D2E", +const edrGreen: MantineColorsTuple = [ + "#ecfdf5", + "#d1fae5", + "#a7f3d0", + "#6ee7b7", + "#34d399", + "#0EA371", + "#0A8A5F", + "#0A6F4D", + "#065f46", + "#064e3b", +]; + +const neutral: MantineColorsTuple = [ + "#F4F7FA", + "#eef2f7", + "#E6ECF2", + "#d0dae6", + "#b0bfce", + "#8fa0b2", + "#6B7C8E", + "#4b5a6a", + "#10202F", + "#0C1A2B", ]; export const freightMantineTheme = createTheme({ - primaryColor: "green", colors: { - green: freightGreen, + "edr-green": edrGreen, + gray: neutral, + + // Brand surface + text tokens (single-value semantic colors). + // Each generates --mantine-color-{name}-{0..9} CSS variables. + "edr-bg": colorsTuple("#F7FAFC"), + "edr-card": colorsTuple("#FFFFFF"), + "edr-border": colorsTuple("#E6ECF2"), + "edr-divider": colorsTuple("#EEF1F5"), + "edr-text": colorsTuple("#10202F"), + "edr-muted": colorsTuple("#6B7C8E"), + "edr-soft": colorsTuple("#ECF6F1"), + "edr-ink": colorsTuple("#0C1A2B"), + "edr-accent": colorsTuple("#F2A516"), + + // Semantic status shades (from the design system). + "edr-amber-soft": colorsTuple("#FDF3E0"), + "edr-amber-text": colorsTuple("#9A5B00"), + "edr-blue": colorsTuple("#2E5B96"), + "edr-blue-soft": colorsTuple("#E9F0F8"), + "edr-blue-dot": colorsTuple("#3B6FB0"), + "edr-red": colorsTuple("#C0392B"), + "edr-red-soft": colorsTuple("#FBEAE7"), + "edr-slate": colorsTuple("#475569"), + "edr-slate-soft": colorsTuple("#F1F4F7"), + "edr-slate-soft2": colorsTuple("#EEF2F6"), + "edr-step": colorsTuple("#94A3B8"), + "edr-step-idle": colorsTuple("#D5DBE2"), + "edr-conn-idle": colorsTuple("#E6EAEF"), + }, + primaryColor: "edr-green", + primaryShade: { light: 5, dark: 4 }, + + white: "#ffffff", + black: "#10202F", + + fontFamily: + '"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + + defaultRadius: "md", + + radius: { + xs: "4px", + sm: "6px", + md: "8px", + lg: "12px", + xl: "18px", + }, + + spacing: { + xs: "8px", + sm: "12px", + md: "16px", + lg: "24px", + xl: "32px", + }, + + fontSizes: { + xs: "12px", + sm: "14px", + md: "16px", + lg: "20px", + xl: "24px", + }, + + lineHeights: { + xs: "1.4", + sm: "1.45", + md: "1.55", + lg: "1.55", + xl: "1.5", + }, + + headings: { + fontFamily: '"Inter", var(--mantine-font-family)', + fontWeight: "700", + sizes: { + h1: { fontSize: "36px", lineHeight: "1.1", fontWeight: "800" }, + h2: { fontSize: "28px", lineHeight: "1.2", fontWeight: "700" }, + h3: { fontSize: "22px", lineHeight: "1.3", fontWeight: "600" }, + h4: { fontSize: "18px", lineHeight: "1.4", fontWeight: "600" }, + h5: { fontSize: "15px", lineHeight: "1.45", fontWeight: "600" }, + h6: { fontSize: "13px", lineHeight: "1.45", fontWeight: "600" }, + }, + }, + + shadows: { + xs: "0 1px 2px rgba(16, 24, 40, 0.04)", + sm: "0 1px 3px rgba(16, 24, 40, 0.06)", + md: "0 4px 12px rgba(16, 24, 40, 0.06)", + }, + + components: { + Card: { + defaultProps: { + radius: "xl", + withBorder: true, + shadow: "none", + padding: "lg", + }, + styles: { + root: { borderColor: "#E6ECF2" }, + }, + }, + Button: { + defaultProps: { radius: "md" }, + styles: { root: { fontWeight: 600 } }, + }, + Badge: { + defaultProps: { radius: "xl", variant: "light" }, + styles: { root: { fontWeight: 600, textTransform: "none" } }, + }, + Paper: { + defaultProps: { radius: "xl", shadow: "none", withBorder: true }, + styles: { root: { borderColor: "#E6ECF2" } }, + }, + Table: { + defaultProps: { verticalSpacing: "sm", horizontalSpacing: "md" }, + }, + Title: { + styles: { root: { letterSpacing: "-0.01em" } }, + }, }, - fontFamily: "'Outfit', var(--font-sans)", }); From ed91f817ff890c2492f2e42a45037f870088064b Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 19 Jun 2026 22:07:58 +0000 Subject: [PATCH 03/97] feat: enhance onboarding process and company profile management - Updated CompanyInfoResponseDto to include company profile data in response. - Enhanced ResponseExternalProfileDto to include active profile type and onboarding details. - Added new fields to ExternalProfile entity for active profile type and onboarding status. - Implemented onboarding wizard dialog in the frontend to guide users through the onboarding process. - Introduced API endpoints for managing company profiles and onboarding steps. - Created migrations to add new columns for active mode and onboarding status in the database. - Added functionality to switch between operational modes (importer/exporter) and create profiles as needed. - Improved user experience by ensuring onboarding is enforced for new users and those without completed profiles. --- ...tiveModeAndOnboardingToExternalProfiles.ts | 67 ++++++ ...000000001-AddCompanyProfileIdToBookings.ts | 94 ++++++++ .../modules/bookings/bookings.controller.ts | 10 +- .../modules/bookings/bookings.repository.ts | 6 + .../src/modules/bookings/bookings.service.ts | 63 +++++- .../bookings/entities/booking.entity.ts | 15 ++ .../modules/companies/companies.controller.ts | 55 +++++ .../modules/companies/companies.service.ts | 181 ++++++++++++++- .../companies/company-dashboard.repository.ts | 87 +++++--- .../dto/company-info-response.dto.ts | 2 +- .../dto/create-company-profile.dto.ts | 12 + .../dto/response-external-profile.dto.ts | 25 ++- .../companies/dto/set-active-mode.dto.ts | 7 + .../companies/dto/set-onboarding-step.dto.ts | 7 + .../entities/external-profile.entity.ts | 28 +++ apps/edr-freight-web/portal/src/App.tsx | 54 +++-- .../portal/src/components/AppLayout.tsx | 191 ++++++++++++++-- .../onboarding/OnboardingWizardDialog.tsx | 209 ++++++++++++++++++ .../portal/src/constants/URLS.ts | 4 + .../portal/src/hooks/useAuth.ts | 56 +++++ .../portal/src/services/api.ts | 24 ++ .../portal/src/services/companies.service.ts | 47 ++++ 22 files changed, 1163 insertions(+), 81 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts create mode 100644 apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts create mode 100644 apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx diff --git a/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts b/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts new file mode 100644 index 000000000..8484ca0f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddActiveModeAndOnboardingToExternalProfiles1791000000000 + implements MigrationInterface +{ + name = 'AddActiveModeAndOnboardingToExternalProfiles1791000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + ADD COLUMN IF NOT EXISTS active_profile_type varchar(32); + `); + + await queryRunner.query(` + ALTER TABLE freight.external_profiles + ADD COLUMN IF NOT EXISTS onboarding_step varchar(40); + `); + + await queryRunner.query(` + ALTER TABLE freight.external_profiles + ADD COLUMN IF NOT EXISTS onboarding_completed boolean NOT NULL DEFAULT false; + `); + + // Existing users already use the portal — never re-gate them behind the + // new onboarding wizard. + await queryRunner.query(` + UPDATE freight.external_profiles + SET onboarding_completed = true + WHERE onboarding_completed = false; + `); + + // Backfill the active mode for existing users from their company's + // operational profiles. Prefer importer, then exporter, then whichever + // single profile the company has (forwarder/dj/transporter). + await queryRunner.query(` + UPDATE freight.external_profiles ep + SET active_profile_type = cp.type + FROM ( + SELECT DISTINCT ON (company_id) company_id, type + FROM freight.company_profiles + ORDER BY company_id, + CASE type + WHEN 'importer' THEN 0 + WHEN 'exporter' THEN 1 + ELSE 2 + END + ) cp + WHERE ep.company_id = cp.company_id + AND ep.active_profile_type IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS onboarding_completed; + `); + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS onboarding_step; + `); + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS active_profile_type; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts b/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts new file mode 100644 index 000000000..0ca1a0d28 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts @@ -0,0 +1,94 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCompanyProfileIdToBookings1791000000001 + implements MigrationInterface +{ + name = 'AddCompanyProfileIdToBookings1791000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS company_profile_id UUID; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_company_profile_id + ON freight.bookings(company_profile_id); + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_profile_id' + ) THEN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_company_profile_id" + FOREIGN KEY (company_profile_id) + REFERENCES freight.company_profiles(id); + END IF; + END $$; + `); + + // Backfill by natural mapping: IMPORT → importer profile, EXPORT → exporter + // profile, for each booking's own company. + await queryRunner.query(` + UPDATE freight.bookings b + SET company_profile_id = cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = b.company_id + AND b.company_profile_id IS NULL + AND ( + (b.trade_direction = 'IMPORT' AND cp.type = 'importer') OR + (b.trade_direction = 'EXPORT' AND cp.type = 'exporter') + ); + `); + + // Forwarder / single-profile companies: one profile per company, so the + // mapping is unambiguous regardless of trade direction. + await queryRunner.query(` + UPDATE freight.bookings b + SET company_profile_id = cp.id + FROM freight.company_profiles cp + JOIN freight.companies c ON c.id = cp.company_id + WHERE cp.company_id = b.company_id + AND c.type <> 'customer' + AND b.company_profile_id IS NULL; + `); + + // Remaining customer-owned rows (e.g. DOMESTIC, or a direction with no + // matching profile): attribute to the company's importer profile, else its + // exporter profile, so nothing disappears from the customer's list. + await queryRunner.query(` + UPDATE freight.bookings b + SET company_profile_id = cp.id + FROM ( + SELECT DISTINCT ON (company_id) company_id, id + FROM freight.company_profiles + ORDER BY company_id, + CASE type + WHEN 'importer' THEN 0 + WHEN 'exporter' THEN 1 + ELSE 2 + END + ) cp + WHERE cp.company_id = b.company_id + AND b.company_id IS NOT NULL + AND b.company_profile_id IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_company_profile_id"; + `); + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_bookings_company_profile_id; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS company_profile_id; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index ae0f1765a..77303d08d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -133,7 +133,15 @@ export class BookingsController { await this.bookingsService.resolveCustomerCompanyId(userId); // No linked company yet → no bookings to show (avoids leaking all bookings). if (!companyId) return { items: [], total: 0 }; - return this.bookingsService.findAll(filter, companyId); + // Scope to the active operational profile (importer/exporter) when one + // resolves; otherwise fall back to company-level scoping. + const companyProfileId = + await this.bookingsService.resolveActiveCompanyProfileId(userId); + return this.bookingsService.findAll( + filter, + companyId, + companyProfileId ?? undefined, + ); } @Get('list-summary') diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index b173bfe68..e1e75e049 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -25,6 +25,7 @@ export interface BookingListFilterOptions { schedulingStatuses?: string[]; assignedToSchedule?: 'true' | 'false'; companyId?: string; + companyProfileId?: string; contractType?: string; serviceTypeId?: string; cargoTypeId?: string; @@ -559,6 +560,11 @@ export class BookingsRepository extends BaseRepository { companyId: options.companyId, }); } + if (options.companyProfileId) { + qb.andWhere('booking.company_profile_id = :companyProfileId', { + companyProfileId: options.companyProfileId, + }); + } if (options.contractType) { qb.andWhere('booking.contract_type = :contractType', { contractType: options.contractType, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 4c8ef2cab..5253ca9bd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -10,6 +10,7 @@ import { import { Freight, SchedulingStatus } from '@edr/types'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; +import { ProfileType } from '../companies/entities/company-profile.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; @@ -323,6 +324,29 @@ export class BookingsService { dto.tradeDirection, ); + // Stamp the operational profile this booking belongs to (importer/exporter) + // so the customer portal can scope lists/KPIs to the active mode. Best-effort + // for non-government bookings with a resolved company; never blocks creation. + let companyProfileId: string | null = null; + if (!isGovernment && companyId) { + let fallbackType: ProfileType | null = null; + if (userId) { + try { + const { profile } = + await this.companiesService.getCompanyInfoByUserId(userId); + fallbackType = profile.activeProfileType ?? null; + } catch { + // No profile (e.g. staff creating on behalf) — fall back to mapping. + } + } + companyProfileId = + await this.companiesService.resolveCompanyProfileIdForBooking( + companyId, + tradeDirection, + fallbackType, + ); + } + const allowConsolidation = dto.freightType === 'CONTAINER' ? await this.resolveConsolidation(containers, dto.allowConsolidation) @@ -348,6 +372,7 @@ export class BookingsService { const booking = await this.bookingsRepository.create({ reference, companyId: companyId ?? null, + companyProfileId, isGovernment, governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, trainId: dto.trainId, @@ -504,6 +529,22 @@ export class BookingsService { priorityScore: ruleResult.priorityScore, tradeDirection, }; + // If the route (hence trade direction) changed, re-stamp the operational + // profile so an edited draft doesn't get stranded under the wrong profile. + if ( + tradeDirection !== existing.tradeDirection && + !existing.isGovernment && + existing.companyId + ) { + updates.companyProfileId = + await this.companiesService.resolveCompanyProfileIdForBooking( + existing.companyId, + tradeDirection, + existing.companyProfileId + ? undefined + : (existing.companyProfile?.type as ProfileType | undefined), + ); + } if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); if (dto.startDate) updates.startDate = new Date(dto.startDate); if (dto.endDate) updates.endDate = new Date(dto.endDate); @@ -583,6 +624,7 @@ export class BookingsService { async findAll( filter: FilterBookingDto, forceCompanyId?: string, + forceCompanyProfileId?: string, ): Promise<{ items: Booking[]; total: number }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; @@ -597,7 +639,10 @@ export class BookingsService { assignedToSchedule: filter.assignedToSchedule, // A forced company scope (portal/customer) overrides any caller-provided // companyId so a customer can only ever see their own company's bookings. - companyId: forceCompanyId ?? filter.companyId, + // When an active profile resolves, scope to it; otherwise fall back to the + // company so nothing breaks for not-yet-onboarded customers. + companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId, + companyProfileId: forceCompanyProfileId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, @@ -629,13 +674,18 @@ export class BookingsService { filter: FilterBookingDto, ): Promise<{ items: Booking[]; total: number }> { const { company } = await this.companiesService.getCompanyInfoByUserId(userId); + // Scope to the active operational profile when one resolves; fall back to + // company-level so not-yet-onboarded customers still see their payables. + const companyProfileId = + await this.companiesService.resolveActiveCompanyProfileId(userId); return this.bookingsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 20, statuses: BookingsService.PAYABLE_STATUSES, excludePaymentStatus: 'PAID', - companyId: company.id, + companyId: companyProfileId ? undefined : company.id, + companyProfileId: companyProfileId ?? undefined, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); @@ -655,6 +705,15 @@ export class BookingsService { } } + /** + * Resolve the active company_profile id a customer's bookings should be + * scoped to (importer/exporter mode). Null when not onboarded — callers fall + * back to company-level scoping. + */ + async resolveActiveCompanyProfileId(userId: string): Promise { + return this.companiesService.resolveActiveCompanyProfileId(userId); + } + /** * Authorize a customer's access to a single booking. Staff are scoped at the * controller (they pass `isStaff`); for a customer, the booking must belong diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index c5dac1736..d110391b7 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -3,6 +3,7 @@ import { SchedulingStatus } from '@edr/types'; import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; // import { Customer } from '../../customers/entities/customer.entity'; import { Company } from '../../companies/entities/company.entity'; +import { CompanyProfile } from '../../companies/entities/company-profile.entity'; import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; import { ServiceType } from '../../rule-engine/entities/service-type.entity'; import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity'; @@ -92,6 +93,20 @@ export class Booking extends BaseEntity { @JoinColumn({ name: 'company_id' }) company?: Company | null; + /** + * The operational profile (importer/exporter/forwarder) this booking belongs + * to. Stamped at creation from the booking's trade direction (IMPORT→importer, + * EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder. + * Customer portal lists and dashboard KPIs are scoped by this. Nullable for + * legacy/government/staff-created bookings. + */ + @Column({ name: 'company_profile_id', type: 'uuid', nullable: true }) + companyProfileId?: string | null; + + @ManyToOne(() => CompanyProfile, { nullable: true }) + @JoinColumn({ name: 'company_profile_id' }) + companyProfile?: CompanyProfile | null; + @Column({ name: 'is_government', type: 'boolean', default: false }) isGovernment!: boolean; diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index ac2868ec3..d09effef3 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -24,6 +24,9 @@ import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; +import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; +import { SetActiveModeDto } from "./dto/set-active-mode.dto"; +import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { ResponseCompanyDto, ResponseCompanyProfileDto, @@ -105,6 +108,58 @@ export class CompaniesController { return profiles.map((p) => new ResponseCompanyProfileDto(p)); } + @Post("company-profile") + @ApiOperation({ + summary: + "Create a single operational profile for the current user's company and make it the active mode", + }) + async createCompanyProfile( + @CurrentUser() user: CurrentIamUser, + @Body() dto: CreateCompanyProfileDto, + ): Promise { + const profile = await this.companiesService.createCompanyProfileForUser( + user.id, + dto.type, + dto.businessLicense, + ); + return new ResponseCompanyProfileDto(profile); + } + + @Patch("active-mode") + @ApiOperation({ + summary: "Switch the current user's active operational mode (importer/exporter)", + }) + async setActiveMode( + @CurrentUser() user: CurrentIamUser, + @Body() dto: SetActiveModeDto, + ): Promise { + const { profile, company } = await this.companiesService.setActiveMode( + user.id, + dto.type, + ); + return new CompanyInfoResponseDto(profile, company); + } + + @Patch("onboarding-step") + @ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) + @HttpCode(HttpStatus.NO_CONTENT) + async setOnboardingStep( + @CurrentUser() user: CurrentIamUser, + @Body() dto: SetOnboardingStepDto, + ): Promise { + await this.companiesService.setOnboardingStep(user.id, dto.step); + } + + @Post("onboarding/complete") + @ApiOperation({ summary: "Mark the current user's onboarding as complete" }) + async completeOnboarding( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.markOnboardingComplete(user.id); + return new CompanyInfoResponseDto(profile, company); + } + // Used by portal @Post("create") @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index fe1bc5598..edeaafe57 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -81,6 +81,18 @@ export class CompaniesService { attributes: dto.attributes ?? null, }); + // Default active mode from the chosen role(s): importer wins when both are + // picked, otherwise the first allowed type chosen. + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + const chosenTypes = (dto.companyProfiles ?? []) + .map((p) => p.type) + .filter((t) => allowedTypes.includes(t)); + const activeProfileType = + chosenTypes.find((t) => t === ProfileType.importer) ?? + chosenTypes[0] ?? + allowedTypes[0] ?? + null; + const profile = await this.profilesRepo.create({ userId: identity.userId, companyId: company.id, @@ -90,6 +102,8 @@ export class CompaniesService { phone: identity.phone, jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, + activeProfileType, + onboardingStep: 'company', }); // Persist the operational role(s) chosen during onboarding. Types are @@ -174,6 +188,18 @@ export class CompaniesService { const companyId = profile?.company?.id ?? profile?.companyId ?? null; if (!companyId) return this.emptyDashboardSummary(); + // Scope KPIs to the active operational profile (importer/exporter mode) when + // one resolves; otherwise aggregate across the whole company. + const companyProfileId = profile?.activeProfileType + ? ((await this.companyProfilesRepo.findByType( + companyId, + profile.activeProfileType, + )) ?? null) + : null; + const scope = companyProfileId + ? { companyProfileId: companyProfileId.id } + : { companyId }; + const now = new Date(); const yearStart = new Date(now.getFullYear(), 0, 1); const prevYearStart = new Date(now.getFullYear() - 1, 0, 1); @@ -191,22 +217,22 @@ export class CompaniesService { tonnagePrev, monthlyRows, ] = await Promise.all([ - this.dashboardRepo.countDelivered(companyId, yearStart, now), - this.dashboardRepo.countCommitted(companyId, yearStart, now), - this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now), + this.dashboardRepo.countDelivered(scope, yearStart, now), + this.dashboardRepo.countCommitted(scope, yearStart, now), + this.dashboardRepo.sumPaidSpendByCurrency(scope, yearStart, now), this.dashboardRepo.sumPaidSpendByCurrency( - companyId, + scope, prevYearStart, prevYearToDate, ), - this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now), + this.dashboardRepo.sumCommittedTonnage(scope, yearStart, now), this.dashboardRepo.sumCommittedTonnage( - companyId, + scope, prevYearStart, prevYearToDate, ), this.dashboardRepo.monthlyCommittedTonnage( - companyId, + scope, this.monthsAgo(now, 5), now, ), @@ -505,4 +531,145 @@ export class CompaniesService { return this.companyProfilesRepo.findByCompanyId(companyId); } + + /** + * Create a single operational profile for the current user's company and + * make it the active mode in the same call. Powers the header "Switch to + * Exporter/Importer" flow when the target profile doesn't exist yet. + */ + async createCompanyProfileForUser( + userId: string, + type: ProfileType, + businessLicense?: string, + ): Promise { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + + const companyId = profile.company?.id ?? profile.companyId; + const company = await this.findCompanyById(companyId); + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + if (!allowedTypes.includes(type)) { + throw new BadRequestException( + `Profile type "${type}" is not allowed for company type "${company.type}"`, + ); + } + + let created = await this.companyProfilesRepo.findByType(companyId, type); + if (!created) { + const reference = await this.companyProfilesRepo.generateReference(type); + created = await this.companyProfilesRepo.create({ + companyId, + type, + reference, + businessLicense: businessLicense ?? null, + status: ProfileStatus.Active, + }); + } + + await this.profilesRepo.update(profile.id, { activeProfileType: type }); + + return created; + } + + /** + * Switch the user's active operational mode. The target profile must already + * exist — clients create it first via createCompanyProfileForUser. + */ + async setActiveMode( + userId: string, + type: ProfileType, + ): Promise<{ profile: ExternalProfile; company: Company }> { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + + const companyId = profile.company?.id ?? profile.companyId; + const company = await this.findCompanyById(companyId); + + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + if (!allowedTypes.includes(type)) { + throw new BadRequestException( + `Profile type "${type}" is not allowed for company type "${company.type}"`, + ); + } + + const existing = await this.companyProfilesRepo.findByType(companyId, type); + if (!existing) { + throw new ConflictException( + `No ${type} profile exists yet — create it before switching`, + ); + } + + await this.profilesRepo.update(profile.id, { activeProfileType: type }); + + return this.getCompanyInfoByUserId(userId); + } + + async setOnboardingStep(userId: string, step: string): Promise { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + await this.profilesRepo.update(profile.id, { onboardingStep: step }); + } + + async markOnboardingComplete( + userId: string, + ): Promise<{ profile: ExternalProfile; company: Company }> { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + await this.profilesRepo.update(profile.id, { + onboardingCompleted: true, + onboardingStep: 'done', + }); + return this.getCompanyInfoByUserId(userId); + } + + /** + * Resolve which company_profile a new booking belongs to, from the company + * and the booking's trade direction. IMPORT → importer profile, EXPORT → + * exporter profile; for DOMESTIC or a forwarder/single-profile company (or + * when the natural profile doesn't exist) it falls back to the user's active + * profile, then the company's first profile. Returns null when the company + * has no profiles at all. + */ + async resolveCompanyProfileIdForBooking( + companyId: string, + tradeDirection: string, + fallbackType?: ProfileType | null, + ): Promise { + const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); + if (profiles.length === 0) return null; + + const naturalType = + tradeDirection === 'IMPORT' + ? ProfileType.importer + : tradeDirection === 'EXPORT' + ? ProfileType.exporter + : null; + + const byType = (type?: ProfileType | null) => + type ? profiles.find((p) => p.type === type) : undefined; + + const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0]; + return match?.id ?? null; + } + + /** + * Resolve the company_profile a customer's data should be scoped to, from + * their persisted active mode. Returns null when nothing can be resolved + * (not onboarded yet) so callers can fall back to company-level scoping. + */ + async resolveActiveCompanyProfileId(userId: string): Promise { + try { + const { profile, company } = await this.getCompanyInfoByUserId(userId); + const type = profile.activeProfileType; + if (!type) return null; + const match = company.companyProfiles?.find((p) => p.type === type); + return match?.id ?? null; + } catch { + return null; + } + } } diff --git a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts index 365cf1daa..842a36616 100644 --- a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; @@ -31,6 +31,27 @@ export interface CurrencyTotal { total: number; } +/** + * What the dashboard is scoped to: a single operational profile (the active + * importer/exporter mode) when one resolves, otherwise the whole company + * (legacy / not-yet-onboarded fallback). + */ +export type DashboardScope = + | { companyProfileId: string } + | { companyId: string }; + +/** Apply the scope as a WHERE clause on a bookings query builder. */ +function applyScope( + qb: SelectQueryBuilder, + scope: DashboardScope, +): SelectQueryBuilder { + return 'companyProfileId' in scope + ? qb.where('b.company_profile_id = :companyProfileId', { + companyProfileId: scope.companyProfileId, + }) + : qb.where('b.company_id = :companyId', { companyId: scope.companyId }); +} + export interface MonthlyTonnage { year: number; month: number; // 1-12 @@ -50,35 +71,33 @@ export class CompanyDashboardRepository { private readonly bookings: Repository, ) {} - /** Count of delivered/completed bookings for a company within [from, to). */ - async countDelivered(companyId: string, from: Date, to: Date): Promise { - return this.bookings - .createQueryBuilder('b') - .where('b.company_id = :companyId', { companyId }) + /** Count of delivered/completed bookings within [from, to) for the scope. */ + async countDelivered(scope: DashboardScope, from: Date, to: Date): Promise { + return applyScope(this.bookings.createQueryBuilder('b'), scope) .andWhere('b.deleted_at IS NULL') .andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] }) .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) .getCount(); } - /** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */ - async countCommitted(companyId: string, from: Date, to: Date): Promise { - return this.bookings - .createQueryBuilder('b') - .where('b.company_id = :companyId', { companyId }) + /** Count of committed (non-draft, non-dead) bookings within [from, to) for the scope. */ + async countCommitted(scope: DashboardScope, from: Date, to: Date): Promise { + return applyScope(this.bookings.createQueryBuilder('b'), scope) .andWhere('b.deleted_at IS NULL') .andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] }) .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) .getCount(); } - /** Sum of paid booking totals, grouped by currency, within [from, to). */ - async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise { - const rows = await this.bookings - .createQueryBuilder('b') - .select('b.payment_currency', 'currency') - .addSelect('COALESCE(SUM(b.total_amount), 0)', 'total') - .where('b.company_id = :companyId', { companyId }) + /** Sum of paid booking totals, grouped by currency, within [from, to) for the scope. */ + async sumPaidSpendByCurrency(scope: DashboardScope, from: Date, to: Date): Promise { + const rows = await applyScope( + this.bookings + .createQueryBuilder('b') + .select('b.payment_currency', 'currency') + .addSelect('COALESCE(SUM(b.total_amount), 0)', 'total'), + scope, + ) .andWhere('b.deleted_at IS NULL') .andWhere("b.payment_status = 'PAID'") .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) @@ -88,12 +107,14 @@ export class CompanyDashboardRepository { return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) })); } - /** Total committed tonnage (cargo VGM) for a company within [from, to). */ - async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise { - const row = await this.bookings - .createQueryBuilder('b') - .select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total') - .where('b.company_id = :companyId', { companyId }) + /** Total committed tonnage (cargo VGM) within [from, to) for the scope. */ + async sumCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise { + const row = await applyScope( + this.bookings + .createQueryBuilder('b') + .select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'), + scope, + ) .andWhere('b.deleted_at IS NULL') .andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] }) .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) @@ -102,14 +123,16 @@ export class CompanyDashboardRepository { return Number(row?.total ?? 0); } - /** Committed tonnage grouped by calendar month within [from, to). */ - async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise { - const rows = await this.bookings - .createQueryBuilder('b') - .select('EXTRACT(YEAR FROM b.created_at)', 'year') - .addSelect('EXTRACT(MONTH FROM b.created_at)', 'month') - .addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total') - .where('b.company_id = :companyId', { companyId }) + /** Committed tonnage grouped by calendar month within [from, to) for the scope. */ + async monthlyCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise { + const rows = await applyScope( + this.bookings + .createQueryBuilder('b') + .select('EXTRACT(YEAR FROM b.created_at)', 'year') + .addSelect('EXTRACT(MONTH FROM b.created_at)', 'month') + .addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'), + scope, + ) .andWhere('b.deleted_at IS NULL') .andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] }) .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index f6ffb8296..04fd42816 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -8,7 +8,7 @@ export class CompanyInfoResponseDto { company: ResponseCompanyDto; constructor(profile: ExternalProfile, company: Company) { - this.profile = new ResponseExternalProfileDto(profile); + this.profile = new ResponseExternalProfileDto(profile, company); this.company = new ResponseCompanyDto(company); } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts new file mode 100644 index 000000000..9ac6c13b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts @@ -0,0 +1,12 @@ +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ProfileType } from '../entities/company-profile.entity'; + +export class CreateCompanyProfileDto { + @IsEnum(ProfileType) + type!: ProfileType; + + @IsOptional() + @IsString() + @MaxLength(100) + businessLicense?: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts index a33585845..7e17bcc60 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -1,4 +1,8 @@ -import { ExternalProfile } from '../entities/external-profile.entity'; +import { Company } from '../entities/company.entity'; +import { + ExternalProfile, +} from '../entities/external-profile.entity'; +import { ProfileType } from '../entities/company-profile.entity'; export class ResponseExternalProfileDto { id: string; @@ -11,10 +15,20 @@ export class ResponseExternalProfileDto { nationalId?: string | null; jobTitle?: string | null; isPrimaryContact: boolean; + /** The active operational mode (importer/exporter/forwarder). */ + activeProfileType?: ProfileType | null; + /** + * The id of the company_profile matching activeProfileType, resolved + * server-side so the client never re-derives it. Null until a company + * (with profiles) is loaded and a matching profile exists. + */ + activeCompanyProfileId?: string | null; + onboardingStep?: string | null; + onboardingCompleted: boolean; createdAt: Date; updatedAt: Date; - constructor(profile: ExternalProfile) { + constructor(profile: ExternalProfile, company?: Company) { this.id = profile.id; this.userId = profile.userId; this.companyId = profile.companyId; @@ -25,6 +39,13 @@ export class ResponseExternalProfileDto { this.nationalId = profile.nationalId; this.jobTitle = profile.jobTitle; this.isPrimaryContact = profile.isPrimaryContact; + this.activeProfileType = profile.activeProfileType ?? null; + this.onboardingStep = profile.onboardingStep ?? null; + this.onboardingCompleted = profile.onboardingCompleted ?? false; + this.activeCompanyProfileId = + company?.companyProfiles?.find( + (p) => p.type === profile.activeProfileType, + )?.id ?? null; this.createdAt = profile.createdAt; this.updatedAt = profile.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts new file mode 100644 index 000000000..ac8f57a93 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { ProfileType } from '../entities/company-profile.entity'; + +export class SetActiveModeDto { + @IsEnum(ProfileType) + type!: ProfileType; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts new file mode 100644 index 000000000..43967abc8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MaxLength } from 'class-validator'; + +export class SetOnboardingStepDto { + @IsString() + @MaxLength(40) + step!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts index 91a014f10..3b1554cc9 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -1,6 +1,7 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm'; import { Company } from './company.entity'; +import { ProfileType } from './company-profile.entity'; @Entity({ schema: 'freight', name: 'external_profiles' }) @Index(['userId']) @@ -36,4 +37,31 @@ export class ExternalProfile extends BaseEntity { @Column({ name: 'is_primary_contact', type: 'boolean', default: false }) isPrimaryContact!: boolean; + + /** + * The operational profile the user is currently "in" (importer vs exporter, + * or the single forwarder profile). Drives header switching and scopes the + * customer's bookings / dashboard to that company_profile. Nullable for + * users who haven't picked a role yet. + */ + @Column({ + name: 'active_profile_type', + type: 'varchar', + length: 32, + nullable: true, + enum: ProfileType, + }) + activeProfileType?: ProfileType | null; + + /** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */ + @Column({ + name: 'onboarding_step', + type: 'varchar', + length: 40, + nullable: true, + }) + onboardingStep?: string | null; + + @Column({ name: 'onboarding_completed', type: 'boolean', default: false }) + onboardingCompleted!: boolean; } diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index bbcdba42e..5394c1fb8 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -19,13 +19,13 @@ import { } from "react-router-dom"; import useAuth from "./hooks/useAuth"; +import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import ProfilePage from "./pages/ProfilePage"; import MySignaturePage from "./pages/MySignaturePage"; import SettingsPage from "./pages/SettingsPage"; import LoginPage from "./pages/accounts/LoginPage"; -import OnboardingPage from "./pages/accounts/OnboardingPage"; import SetPasswordPage from "./pages/accounts/SetPasswordPage"; import SignupPage from "./pages/accounts/SignupPage"; import VerificationOtpPage from "./pages/accounts/VerificationOtpPage"; @@ -74,9 +74,8 @@ function RequireAuth() { } /** - * Sends authenticated users without a company to onboarding. - * Only redirects on a confirmed "no company" response — never on a - * transient query error. + * Waits for the company query so downstream routes can rely on it being + * resolved. Onboarding is enforced by OnboardingGate, not here. */ function RequireCompany() { const { customerQuery } = useAuth(); @@ -85,13 +84,29 @@ function RequireCompany() { return ; } -/** Keeps already-onboarded users out of the onboarding flow. */ -function RequireNoCompany() { - const { customerQuery } = useAuth(); +/** + * Enforces first-run onboarding. Until the user finishes, only the home + * (/portal) page is reachable; any attempt to navigate elsewhere bounces back + * to home with the blocking wizard dialog open. New users (no company yet) are + * treated the same as users who haven't completed onboarding. + */ +function OnboardingGate() { + const { company, onboardingCompleted } = useAuth(); + const location = useLocation(); - if (customerQuery.isPending) return ; - if (customerQuery.data) return ; - return ; + const needsOnboarding = !company || !onboardingCompleted; + const onHome = location.pathname === "/portal"; + + if (needsOnboarding && !onHome) { + return ; + } + + return ( + <> + + + + ); } /** Keeps authenticated users off the login/signup pages. */ @@ -146,7 +161,14 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, company } = useAuth(); + const { + user, + company, + activeProfileType, + companyType, + switchMode, + createProfileAndSwitch, + } = useAuth(); const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; @@ -176,10 +198,6 @@ const App = () => { } /> }> - }> - } /> - - }> { userName={displayName} userEmail={userEmail} companyProfiles={companyProfiles} + companyType={companyType} + activeProfileType={activeProfileType} + onSwitchMode={switchMode} + onCreateProfile={createProfileAndSwitch} > - + } > diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index e0de92ec5..f96eaf03d 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -2,13 +2,16 @@ import { AppShell, Avatar, Box, + Button, Divider, Group, Menu, + Modal, NavLink, ScrollArea, Stack, Text, + TextInput, UnstyledButton, useComputedColorScheme, useMantineColorScheme, @@ -16,7 +19,9 @@ import { } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { + ArrowLeftRight, Bell, + Check, ChevronDown, FileSignature, LogOut, @@ -29,7 +34,12 @@ import { User, X, } from "lucide-react"; -import { type CSSProperties, Fragment, type ReactNode } from "react"; +import { + type CSSProperties, + Fragment, + type ReactNode, + useState, +} from "react"; export interface SidebarItem { label: string; @@ -49,9 +59,23 @@ export interface AppLayoutProps { userEmail?: string; /** Operational profiles for the company — surfaced as reference chips in the account menu. */ companyProfiles?: { type: string; reference: string; status?: string }[]; + /** Company type (e.g. "customer", "forwarder") — gates the importer/exporter switch. */ + companyType?: string | null; + /** The active operational mode (importer/exporter/...). */ + activeProfileType?: string | null; + /** Switch to an existing profile of the given type. */ + onSwitchMode?: (type: ImporterExporter) => Promise | void; + /** Create the profile of the given type (with business license) then switch. */ + onCreateProfile?: ( + type: ImporterExporter, + businessLicense?: string, + ) => Promise | void; children: ReactNode; } +type ImporterExporter = "importer" | "exporter"; +type SwitchResult = { success: boolean; error?: string }; + const PROFILE_TYPE_LABELS: Record = { importer: "Importer", exporter: "Exporter", @@ -117,6 +141,10 @@ export function AppLayout({ userName = "User", userEmail, companyProfiles = [], + companyType, + activeProfileType, + onSwitchMode, + onCreateProfile, children, }: AppLayoutProps) { const [mobileOpen, { toggle: toggleMobile }] = useDisclosure(); @@ -142,6 +170,56 @@ export function AppLayout({ const initials = getInitials(userName); const activePage = getActivePage(sidebarItems, activePath); + // ── Importer/Exporter mode switching (customer companies only) ── + const isCustomer = companyType === "customer"; + const targetMode: ImporterExporter = + activeProfileType === "importer" ? "exporter" : "importer"; + const targetExists = companyProfiles.some((p) => p.type === targetMode); + const canSwitch = + isCustomer && + (activeProfileType === "importer" || activeProfileType === "exporter"); + + const [switching, setSwitching] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [businessLicense, setBusinessLicense] = useState(""); + const [createError, setCreateError] = useState(null); + + const handleSwitchClick = async () => { + if (targetExists) { + setSwitching(true); + try { + await onSwitchMode?.(targetMode); + } finally { + setSwitching(false); + } + } else { + setBusinessLicense(""); + setCreateError(null); + setCreateOpen(true); + } + }; + + const handleCreateConfirm = async () => { + setSwitching(true); + setCreateError(null); + try { + const res = await onCreateProfile?.( + targetMode, + businessLicense.trim() || undefined, + ); + if (res && !res.success) { + setCreateError(res.error ?? "Failed to create profile"); + return; + } + setCreateOpen(false); + } finally { + setSwitching(false); + } + }; + + const modeLabel = (m: ImporterExporter) => + m === "importer" ? "Importer" : "Exporter"; + const isItemActive = (item: SidebarItem) => activePath === item.href.toLowerCase() || activePath.startsWith(item.href.toLowerCase() + "/"); @@ -212,8 +290,25 @@ export function AppLayout({ - {/* Right: search + bell + avatar */} + {/* Right: switch + search + bell + avatar */} + {/* Importer/Exporter mode switch (customer companies only) */} + {canSwitch && ( + + )} + {/* Search pill */} - {companyProfiles.map((p) => ( - - { + const isActive = p.type === activeProfileType; + return ( + - {PROFILE_TYPE_LABELS[p.type] ?? p.type} - - - {p.reference} - - - ))} + + {isActive && ( + + )} + + {PROFILE_TYPE_LABELS[p.type] ?? p.type} + + + + {p.reference} + + + ); + })} @@ -686,6 +795,48 @@ export function AppLayout({ > {children} + + {/* Create-profile modal — opens when switching to a mode the company + doesn't have a profile for yet. */} + (switching ? undefined : setCreateOpen(false))} + title={`Set up your ${modeLabel(targetMode)} profile`} + centered + radius="lg" + > + + + You don't have an {modeLabel(targetMode).toLowerCase()} profile yet. + Add your business license to create one and switch to{" "} + {modeLabel(targetMode).toLowerCase()} mode. A new reference will be + generated automatically. + + setBusinessLicense(e.currentTarget.value)} + error={createError ?? undefined} + /> + + + + + + ); } diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx new file mode 100644 index 000000000..8e9f59b16 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -0,0 +1,209 @@ +import { Button, Group, Modal, ScrollArea, Stack, Text } from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2 } from "lucide-react"; +import { useCallback, useState } from "react"; + +import useAuth from "@/hooks/useAuth"; +import { api } from "@/services/api"; +import type { + CompanyProfileInput, + CreateCompanyPayload, +} from "@/services/companies.service"; +import { companiesService } from "@/services/companies.service"; +import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm"; +import ForwarderForm from "@/pages/accounts/ForwarderForm"; +import { FREIGHT_FORWARDER } from "@/pages/settings/companyRoles"; +import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect"; + +interface OnboardingWizardDialogProps { + opened: boolean; +} + +/** Map the chosen operational roles to the company type they belong to. */ +function companyTypeForRoles(roles: string[]): string { + return roles.includes(FREIGHT_FORWARDER.type) ? "forwarder" : "customer"; +} + +/** Document upload setting code per company type. */ +function documentSettingCode(companyType: string): string { + return companyType === "forwarder" + ? "company_onboarding_documents_forwarder" + : "company_onboarding_documents_customer"; +} + +/** + * Blocking, non-dismissable first-run onboarding wizard. Step 1 picks the + * operational role(s); the remaining steps reuse the existing company/forwarder + * forms. On completion the company is created with its company_profiles and the + * active mode is set server-side, then onboarding is marked complete. + */ +export default function OnboardingWizardDialog({ + opened, +}: OnboardingWizardDialogProps) { + const queryClient = useQueryClient(); + const { user, company } = useAuth(); + + // A company already exists but onboarding wasn't marked complete (e.g. the + // browser closed after create but before finishing). Don't re-create it — + // just let the user finish. + const companyAlreadyCreated = Boolean(company?.company?.id); + + // "role" → pick roles; otherwise the company/forwarder form drives its own + // internal steps. + const [phase, setPhase] = useState<"role" | "form">("role"); + const [roles, setRoles] = useState([]); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); + + const createCompanyMutation = useMutation({ + mutationFn: (payload: CreateCompanyPayload) => + api.companies.create.call(payload), + onSuccess: async (data) => { + const hasFiles = Object.values(documentFiles).some( + (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), + ); + if (hasFiles) { + await companiesService.uploadDocuments(data.company.id, documentFiles); + } + // Mark onboarding complete, then refresh the company info so the gate + // releases and the header reflects the new profile(s). + await api.companies.completeOnboarding.call(); + await queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }); + }, + }); + + const finishMutation = useMutation({ + mutationFn: () => api.companies.completeOnboarding.call(), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }); + }, + }); + + const handleRolesContinue = useCallback(() => { + setPhase("form"); + // Best-effort: remember that the user moved past role selection. + api.companies.setOnboardingStep.call({ step: "company" }).catch(() => {}); + }, []); + + const handleBackToRoles = useCallback(() => setPhase("role"), []); + + const handleSubmit = useCallback( + (payload: CreateCompanyPayload) => { + const companyProfiles: CompanyProfileInput[] = roles.map((type) => ({ + type: type as CompanyProfileInput["type"], + })); + createCompanyMutation.mutate({ + ...payload, + companyType: companyTypeForRoles(roles), + companyProfiles, + }); + }, + [roles, createCompanyMutation], + ); + + if (!user) return null; + + const isForwarder = roles.includes(FREIGHT_FORWARDER.type); + // Importer+Exporter (or either alone) is a valid customer selection. + const rolesValid = roles.length > 0; + const companyType = companyTypeForRoles(roles); + + return ( + {}} + withCloseButton={false} + closeOnClickOutside={false} + closeOnEscape={false} + size="xl" + radius="lg" + padding="xl" + centered + scrollAreaComponent={ScrollArea.Autosize} + overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} + title={ + + + Complete your onboarding + + + {companyAlreadyCreated + ? "You're almost there — finish to start using the portal." + : phase === "role" + ? "Tell us what your company does to get started." + : "Set up your company profile to finish."} + + + } + > + {companyAlreadyCreated ? ( + + + + Your company profile is set up. Click finish to complete onboarding + and unlock the rest of the portal. + + + + + + ) : phase === "role" ? ( + + + + + ) : isForwarder ? ( + + ) : ( + + )} + + ); +} + +function RoleContinueBar({ + disabled, + onClick, +}: { + disabled: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 08b26a255..195dcb692 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -84,6 +84,10 @@ export const URL_CONSTANTS = { CREATE: "/api/companies/create", PROFILE: "/api/companies/profile", COMPANY_PROFILES: "/api/companies/company-profiles", + COMPANY_PROFILE: "/api/companies/company-profile", + ACTIVE_MODE: "/api/companies/active-mode", + ONBOARDING_STEP: "/api/companies/onboarding-step", + ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", DASHBOARD: "/api/companies/dashboard", DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, }, diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 06900270e..3b3d84813 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -1,4 +1,5 @@ import { api } from "@/services/api"; +import type { ProfileTypeValue } from "@/services/companies.service"; import type { LoginPayload, LoginResponse, @@ -149,6 +150,54 @@ const useAuth = () => { } }; + // Active-mode (importer/exporter) state, sourced from the persisted profile. + const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null; + const activeProfileType = companyInfo?.profile?.activeProfileType ?? null; + const activeCompanyProfileId = + companyInfo?.profile?.activeCompanyProfileId ?? null; + const companyType = companyInfo?.company?.type ?? null; + const onboardingCompleted = + companyInfo?.profile?.onboardingCompleted ?? false; + const onboardingStep = companyInfo?.profile?.onboardingStep ?? null; + + /** Refetch everything scoped to the active operational profile. */ + const invalidateScopedData = async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: api.companies.getDashboard.queryKey(), + }), + queryClient.invalidateQueries({ queryKey: ["bookings"] }), + ]); + }; + + const switchMode = async ( + type: ProfileTypeValue, + ): Promise> => { + try { + await api.companies.setActiveMode.call({ type }); + await invalidateScopedData(); + return { success: true, data: undefined }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const createProfileAndSwitch = async ( + type: ProfileTypeValue, + businessLicense?: string, + ): Promise> => { + try { + await api.companies.createCompanyProfile.call({ type, businessLicense }); + await invalidateScopedData(); + return { success: true, data: undefined }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + const logout = async () => { try { await api.auth.logout.call(); @@ -174,6 +223,13 @@ const useAuth = () => { user: isAuthenticated ? (authQuery.data ?? null) : null, company: isAuthenticated ? (companyQuery.data ?? null) : null, customer: isAuthenticated ? (companyQuery.data ?? null) : null, + activeProfileType, + activeCompanyProfileId, + companyType, + onboardingCompleted, + onboardingStep, + switchMode, + createProfileAndSwitch, login, signup, setPassword, diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 194a1dd48..8615061d7 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -41,6 +41,7 @@ import type { CompanyProfileResponse, CreateCompanyPayload, DashboardSummary, + ProfileTypeValue, } from "./companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { @@ -135,6 +136,29 @@ export const api = { "addCompanyProfiles", companiesService.addCompanyProfiles, ), + + createCompanyProfile: endpoint< + { type: ProfileTypeValue; businessLicense?: string }, + CompanyProfileResponse + >("companies", "createCompanyProfile", companiesService.createCompanyProfile), + + setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>( + "companies", + "setActiveMode", + companiesService.setActiveMode, + ), + + setOnboardingStep: endpoint<{ step: string }, void>( + "companies", + "setOnboardingStep", + companiesService.setOnboardingStep, + ), + + completeOnboarding: endpoint( + "companies", + "completeOnboarding", + companiesService.completeOnboarding, + ), }, bookings: { diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 149705ec1..e71949045 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -5,6 +5,13 @@ import type { ApiResponse } from "@/types/apiResponse"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import { isAxiosError } from "axios"; +export type ProfileTypeValue = + | "importer" + | "exporter" + | "freight_forwarder" + | "dj_freight_forwarder" + | "transporter"; + export interface ExternalProfileResponse { id: string; userId: string; @@ -16,6 +23,12 @@ export interface ExternalProfileResponse { nationalId: string | null; jobTitle: string | null; isPrimaryContact: boolean; + /** The active operational mode (importer/exporter/forwarder). */ + activeProfileType: ProfileTypeValue | null; + /** Id of the company_profile matching activeProfileType (server-resolved). */ + activeCompanyProfileId: string | null; + onboardingStep: string | null; + onboardingCompleted: boolean; createdAt: string; updatedAt: string; } @@ -152,6 +165,40 @@ export const companiesService = { return unwrap(response.data); }, + /** Create a single operational profile and make it the active mode. */ + createCompanyProfile: async (payload: { + type: ProfileTypeValue; + businessLicense?: string; + }): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE, + payload, + ); + return unwrap(response.data); + }, + + /** Switch the active operational mode (target profile must already exist). */ + setActiveMode: async (payload: { + type: ProfileTypeValue; + }): Promise => { + const response = await client.patch>( + URL_CONSTANTS.COMPANIES_API.ACTIVE_MODE, + payload, + ); + return unwrap(response.data); + }, + + setOnboardingStep: async (payload: { step: string }): Promise => { + await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload); + }, + + completeOnboarding: async (): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.ONBOARDING_COMPLETE, + ); + return unwrap(response.data); + }, + uploadDocuments: async ( companyId: string, files: Record, From 19dea313b7214102941e6382ddd62c34024bd162 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 19 Jun 2026 22:24:59 +0000 Subject: [PATCH 04/97] feat: implement onboarding wizard enhancements and resume functionality --- apps/edr-freight-web/portal/src/App.tsx | 81 +++++++++++++++++-- .../portal/src/components/AppLayout.tsx | 6 +- .../onboarding/OnboardingWizardDialog.tsx | 53 +++++++++--- .../src/pages/accounts/CompanyProfileForm.tsx | 16 +++- .../src/pages/accounts/ForwarderForm.tsx | 16 +++- .../portal/src/services/api.ts | 2 - 6 files changed, 149 insertions(+), 25 deletions(-) diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 5394c1fb8..e436eaff6 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -6,8 +6,10 @@ import { MapPin, Receipt, Settings, + Sparkles, User, } from "lucide-react"; +import { useDisclosure } from "@mantine/hooks"; import { useEffect, useRef } from "react"; import { Navigate, @@ -85,30 +87,95 @@ function RequireCompany() { } /** - * Enforces first-run onboarding. Until the user finishes, only the home - * (/portal) page is reachable; any attempt to navigate elsewhere bounces back - * to home with the blocking wizard dialog open. New users (no company yet) are - * treated the same as users who haven't completed onboarding. + * Routes an un-onboarded user may still visit. The wizard auto-opens but is + * dismissable, so they can browse these freely; any other route forces the + * wizard back open and bounces them home. + */ +const ONBOARDING_ALLOWED_PATHS = ["/portal", "/signature"]; + +function isOnboardingAllowedPath(pathname: string): boolean { + const path = pathname.toLowerCase(); + return ONBOARDING_ALLOWED_PATHS.some( + (p) => path === p || path.startsWith(p + "/"), + ); +} + +/** + * Enforces first-run onboarding. The home (dashboard) and signature pages stay + * reachable while onboarding is incomplete; the wizard auto-opens on login but + * can be dismissed to use those pages. Visiting any other page bounces back to + * home and re-opens the wizard. New users (no company yet) are treated the same + * as users who haven't completed onboarding. */ function OnboardingGate() { const { company, onboardingCompleted } = useAuth(); const location = useLocation(); const needsOnboarding = !company || !onboardingCompleted; - const onHome = location.pathname === "/portal"; + const allowedHere = isOnboardingAllowedPath(location.pathname); - if (needsOnboarding && !onHome) { + // Open by default while onboarding is pending (covers the login case). + const [wizardOpen, { open: openWizard, close: closeWizard }] = + useDisclosure(false); + + // Re-evaluate on every navigation: force the wizard open on blocked routes, + // and auto-open on first arrival while onboarding is pending. + useEffect(() => { + if (needsOnboarding && !allowedHere) { + openWizard(); + } + }, [needsOnboarding, allowedHere, location.pathname, openWizard]); + + // Auto-open once when onboarding becomes/loads as pending (login). + const autoOpenedRef = useRef(false); + useEffect(() => { + if (needsOnboarding && !autoOpenedRef.current) { + autoOpenedRef.current = true; + openWizard(); + } + if (!needsOnboarding) autoOpenedRef.current = false; + }, [needsOnboarding, openWizard]); + + if (needsOnboarding && !allowedHere) { return ; } return ( <> + {needsOnboarding && !wizardOpen && ( + + )} - + ); } +/** Slim sticky prompt shown on allowed pages after the wizard is dismissed. */ +function OnboardingResumeBanner({ onResume }: { onResume: () => void }) { + return ( +
+
+ + + Finish setting up your company to unlock bookings, tracking and + billing. + +
+ +
+ ); +} + /** Keeps authenticated users off the login/signup pages. */ function RedirectIfAuthed() { const { isPending, isAuthenticated } = useAuth(); diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index f96eaf03d..db415705d 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -74,7 +74,9 @@ export interface AppLayoutProps { } type ImporterExporter = "importer" | "exporter"; -type SwitchResult = { success: boolean; error?: string }; +type SwitchResult = + | { success: true; data?: unknown } + | { success: false; error?: { message?: string } }; const PROFILE_TYPE_LABELS: Record = { importer: "Importer", @@ -208,7 +210,7 @@ export function AppLayout({ businessLicense.trim() || undefined, ); if (res && !res.success) { - setCreateError(res.error ?? "Failed to create profile"); + setCreateError(res.error?.message ?? "Failed to create profile"); return; } setCreateOpen(false); diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 8e9f59b16..9b11267cc 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -15,8 +15,20 @@ import ForwarderForm from "@/pages/accounts/ForwarderForm"; import { FREIGHT_FORWARDER } from "@/pages/settings/companyRoles"; import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect"; +/** Form steps shared by CompanyProfileForm and ForwarderForm. */ +type FormStep = "company" | "personnel" | "poa" | "documents" | "confirm"; +const FORM_STEPS: FormStep[] = [ + "company", + "personnel", + "poa", + "documents", + "confirm", +]; + interface OnboardingWizardDialogProps { opened: boolean; + /** Dismiss the dialog (user clicked the close icon). */ + onClose: () => void; } /** Map the chosen operational roles to the company type they belong to. */ @@ -39,15 +51,24 @@ function documentSettingCode(companyType: string): string { */ export default function OnboardingWizardDialog({ opened, + onClose, }: OnboardingWizardDialogProps) { const queryClient = useQueryClient(); - const { user, company } = useAuth(); + const { user, company, onboardingStep } = useAuth(); // A company already exists but onboarding wasn't marked complete (e.g. the // browser closed after create but before finishing). Don't re-create it — // just let the user finish. const companyAlreadyCreated = Boolean(company?.company?.id); + // Resume position from the backend-persisted step. A form step means the user + // had already passed role selection. Cross-session we still start at role + // selection (the roles + field values aren't persisted), but within a session + // the dialog stays mounted so dismiss/reopen continues exactly where it was. + const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep) + ? (onboardingStep as FormStep) + : "company"; + // "role" → pick roles; otherwise the company/forwarder form drives its own // internal steps. const [phase, setPhase] = useState<"role" | "form">("role"); @@ -84,13 +105,20 @@ export default function OnboardingWizardDialog({ }, }); - const handleRolesContinue = useCallback(() => { - setPhase("form"); - // Best-effort: remember that the user moved past role selection. - api.companies.setOnboardingStep.call({ step: "company" }).catch(() => {}); + // Persist the resume step to the backend (best-effort, fire-and-forget). + const persistStep = useCallback((step: string) => { + api.companies.setOnboardingStep.call({ step }).catch(() => {}); }, []); - const handleBackToRoles = useCallback(() => setPhase("role"), []); + const handleRolesContinue = useCallback(() => { + setPhase("form"); + persistStep("company"); + }, [persistStep]); + + const handleBackToRoles = useCallback(() => { + setPhase("role"); + persistStep("role"); + }, [persistStep]); const handleSubmit = useCallback( (payload: CreateCompanyPayload) => { @@ -116,14 +144,15 @@ export default function OnboardingWizardDialog({ return ( {}} - withCloseButton={false} + onClose={onClose} + withCloseButton closeOnClickOutside={false} - closeOnEscape={false} - size="xl" + closeOnEscape + size={1040} radius="lg" padding="xl" centered + keepMounted scrollAreaComponent={ScrollArea.Autosize} overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} title={ @@ -173,6 +202,8 @@ export default function OnboardingWizardDialog({ onSubmit={handleSubmit} isPending={createCompanyMutation.isPending} onBack={handleBackToRoles} + initialStep={resumeFormStep} + onStepChange={persistStep} /> ) : ( )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 3a26c92b2..913726008 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -22,7 +22,7 @@ import { UploadCloud, User, } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -126,6 +126,8 @@ export default function CompanyProfileForm({ onSubmit, isPending, onBack, + initialStep, + onStepChange, }: { documentSettingCode: string; documentFiles?: Record; @@ -134,8 +136,18 @@ export default function CompanyProfileForm({ onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; + /** Step to resume at (defaults to "company"). */ + initialStep?: CompanyStep; + /** Reports the active step so the parent can persist resume progress. */ + onStepChange?: (step: CompanyStep) => void; }) { - const [step, setStep] = useState("company"); + const [step, setStep] = useState(initialStep ?? "company"); + + // Report each step change up so the wizard can persist it for resume. + useEffect(() => { + onStepChange?.(step); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step]); const [internalFiles, setInternalFiles] = useState< Record >({}); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index 71db345ec..6f06d9c48 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -11,7 +11,7 @@ import { UploadCloud, User, } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -91,6 +91,8 @@ export default function ForwarderForm({ onSubmit, isPending, onBack, + initialStep, + onStepChange, }: { documentSettingCode: string; documentFiles?: Record; @@ -99,8 +101,18 @@ export default function ForwarderForm({ onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; + /** Step to resume at (defaults to "company"). */ + initialStep?: ForwarderStep; + /** Reports the active step so the parent can persist resume progress. */ + onStepChange?: (step: ForwarderStep) => void; }) { - const [step, setStep] = useState("company"); + const [step, setStep] = useState(initialStep ?? "company"); + + // Report each step change up so the wizard can persist it for resume. + useEffect(() => { + onStepChange?.(step); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step]); const [internalFiles, setInternalFiles] = useState>({}); const documentFiles = controlledFiles ?? internalFiles; const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 8615061d7..e2c92fde6 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -2,11 +2,9 @@ import type { Freight, PaginatedResponse } from "@edr/types"; import { endpoint } from "@/utils/endpoint"; import type { CreateFileUploadFieldDto, - CreateFileUploadSettingDto, FileUploadField, FileUploadSetting, UpdateFileUploadFieldDto, - UpdateFileUploadSettingDto, } from "@/types/fileUploadSettings"; import { bookingsService, From 9f5d28713956adb47bfa24ebbe45c37a1fea662c Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 19 Jun 2026 22:50:24 +0000 Subject: [PATCH 05/97] feat: implement onboarding process with draft company and profile creation --- .../modules/companies/companies.controller.ts | 25 +++ .../modules/companies/companies.service.ts | 136 ++++++++++++- .../companies/dto/start-onboarding.dto.ts | 13 ++ .../onboarding/OnboardingWizardDialog.tsx | 181 ++++++++++-------- .../portal/src/constants/URLS.ts | 1 + .../src/pages/accounts/CompanyProfileForm.tsx | 83 ++++++-- .../src/pages/accounts/ForwarderForm.tsx | 77 ++++++-- .../portal/src/services/api.ts | 5 + .../portal/src/services/companies.service.ts | 12 ++ 9 files changed, 423 insertions(+), 110 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index d09effef3..21cbfd67b 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -27,6 +27,7 @@ import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; import { SetActiveModeDto } from "./dto/set-active-mode.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; +import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { ResponseCompanyDto, ResponseCompanyProfileDto, @@ -108,6 +109,30 @@ export class CompaniesController { return profiles.map((p) => new ResponseCompanyProfileDto(p)); } + @Post("onboarding/start") + @ApiOperation({ + summary: + "Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", + }) + async startOnboarding( + @CurrentUser() user: CurrentIamUser, + @Body() dto: StartOnboardingDto, + ): Promise { + const nameParts = (user.name?.en ?? "").split(" "); + const { profile, company } = await this.companiesService.startOnboarding( + { + userId: user.id, + firstName: nameParts[0] || "", + lastName: nameParts.slice(-1)[0] || "", + email: user.email ?? "", + phone: user.phoneNumber ?? "", + }, + dto.companyType, + dto.roles, + ); + return new CompanyInfoResponseDto(profile, company); + } + @Post("company-profile") @ApiOperation({ summary: diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index edeaafe57..65a736e1f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -15,7 +15,7 @@ import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.d import { UpdateProfileDto } from "./dto/update-profile.dto"; import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; -import { Company } from "./entities/company.entity"; +import { Company, CompanyStatus, CompanyType } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { CompanyProfile, @@ -137,6 +137,112 @@ export class CompaniesService { return { company, profile }; } + /** + * Begin onboarding: create a DRAFT company + the user's external profile + the + * chosen operational role(s) up front, so every subsequent wizard step can + * save incrementally (PATCH /profile, /onboarding-step) against existing rows. + * + * Idempotent: if the user already has a profile, returns it unchanged (only + * adding any newly-chosen roles). The draft company carries a placeholder TIN + * (the real one is filled on the Company Information step) and stays + * status=pending / onboardingCompleted=false until the wizard finishes. + */ + async startOnboarding( + identity: UserIdentity, + companyType: CompanyType, + roles: ProfileType[], + ): Promise<{ profile: ExternalProfile; company: Company }> { + // Already started — reuse the existing draft, just ensure roles exist. + const existing = await this.profilesRepo.findByUserId(identity.userId); + if (existing) { + const companyId = existing.company?.id ?? existing.companyId; + await this.ensureCompanyProfiles(companyId, companyType, roles); + return this.getCompanyInfoByUserId(identity.userId); + } + + // A profile may exist for the same email under a different IAM id — block + // duplicates as the final create does. + const byEmail = await this.profilesRepo.findByEmail(identity.email); + if (byEmail) { + throw new ConflictException( + `Profile with email ${identity.email} already exists`, + ); + } + + const allowedTypes = this.getProfileTypeForCompanyType(companyType); + const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); + const activeProfileType = + chosenTypes.find((t) => t === ProfileType.importer) ?? + chosenTypes[0] ?? + allowedTypes[0] ?? + null; + + const company = await this.companiesRepo.create({ + name: identity.firstName + ? `${identity.firstName}'s company` + : "New company", + type: companyType, + tin: await this.generateDraftTin(), + country: "Ethiopia", + status: CompanyStatus.Pending, + }); + + await this.profilesRepo.create({ + userId: identity.userId, + companyId: company.id, + firstName: identity.firstName, + lastName: identity.lastName, + email: identity.email, + phone: identity.phone, + isPrimaryContact: true, + activeProfileType, + onboardingStep: "company", + onboardingCompleted: false, + }); + + await this.ensureCompanyProfiles(company.id, companyType, chosenTypes); + + return this.getCompanyInfoByUserId(identity.userId); + } + + /** Create any of the requested operational profiles that don't exist yet. */ + private async ensureCompanyProfiles( + companyId: string, + companyType: CompanyType, + roles: ProfileType[], + ): Promise { + const allowedTypes = this.getProfileTypeForCompanyType(companyType); + for (const type of roles) { + if (!allowedTypes.includes(type)) continue; + const existing = await this.companyProfilesRepo.findByType( + companyId, + type, + ); + if (existing) continue; + const reference = await this.companyProfilesRepo.generateReference(type); + await this.companyProfilesRepo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + }); + } + } + + /** + * A unique 10-char placeholder TIN for a draft company (the column is + * NOT NULL + unique). Overwritten with the real TIN on the company step. + */ + private async generateDraftTin(): Promise { + for (let i = 0; i < 10; i++) { + const candidate = + "D" + Math.floor(Math.random() * 1_000_000_000).toString().padStart(9, "0"); + if (!(await this.companiesRepo.existsByTin(candidate))) return candidate; + } + // Extremely unlikely; fall back to a timestamp-derived value. + return ("D" + Date.now().toString()).slice(0, 10); + } + async findAllCompanies(): Promise { return this.companiesRepo.findAll({ order: { name: "ASC" } }); } @@ -356,7 +462,17 @@ export class CompaniesService { companyUpdates.country = dto.companyLocation; if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress; - if (dto.tin !== undefined) companyUpdates.tin = dto.tin; + if (dto.tin !== undefined && dto.tin !== company.tin) { + // Reject a TIN already taken by a different company (the user's own draft + // placeholder is fine to overwrite). + const owner = await this.companiesRepo.findByTin(dto.tin); + if (owner && owner.id !== company.id) { + throw new ConflictException( + `Company with TIN ${dto.tin} already exists`, + ); + } + companyUpdates.tin = dto.tin; + } if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; if (dto.fanNumber !== undefined) { companyUpdates.fanNumber = dto.fanNumber; @@ -619,9 +735,23 @@ export class CompaniesService { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); + + const companyId = profile.company?.id ?? profile.companyId; + const company = await this.findCompanyById(companyId); + + // Guard against finishing on a still-draft company (TIN never filled in). + if (!company.tin || company.tin.startsWith("D")) { + throw new BadRequestException( + "Company information is incomplete — please fill in your company details before finishing.", + ); + } + await this.profilesRepo.update(profile.id, { onboardingCompleted: true, - onboardingStep: 'done', + onboardingStep: "done", + }); + await this.companiesRepo.update(companyId, { + status: CompanyStatus.Active, }); return this.getCompanyInfoByUserId(userId); } diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts new file mode 100644 index 000000000..edbe13145 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts @@ -0,0 +1,13 @@ +import { ArrayMinSize, IsArray, IsEnum } from "class-validator"; +import { CompanyType } from "../entities/company.entity"; +import { ProfileType } from "../entities/company-profile.entity"; + +export class StartOnboardingDto { + @IsEnum(CompanyType) + companyType!: CompanyType; + + @IsArray() + @ArrayMinSize(1) + @IsEnum(ProfileType, { each: true }) + roles!: ProfileType[]; +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 9b11267cc..6cd7f21b1 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -1,15 +1,16 @@ -import { Button, Group, Modal, ScrollArea, Stack, Text } from "@mantine/core"; +import { Modal, ScrollArea, Stack, Text } from "@mantine/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { CheckCircle2 } from "lucide-react"; import { useCallback, useState } from "react"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; import type { - CompanyProfileInput, CreateCompanyPayload, + ProfileTypeValue, } from "@/services/companies.service"; import { companiesService } from "@/services/companies.service"; +import type { UpdateProfilePayload } from "@/types/profile"; +import { extractApiError } from "@/utils/result"; import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm"; import ForwarderForm from "@/pages/accounts/ForwarderForm"; import { FREIGHT_FORWARDER } from "@/pages/settings/companyRoles"; @@ -44,10 +45,11 @@ function documentSettingCode(companyType: string): string { } /** - * Blocking, non-dismissable first-run onboarding wizard. Step 1 picks the - * operational role(s); the remaining steps reuse the existing company/forwarder - * forms. On completion the company is created with its company_profiles and the - * active mode is set server-side, then onboarding is marked complete. + * First-run onboarding wizard with a "draft-first" flow: picking the role(s) + * immediately creates a draft company + profile on the backend, so every + * subsequent step saves its data incrementally (PATCH /profile, /onboarding-step) + * against existing rows. The final step uploads documents and marks onboarding + * complete. Dismissable — the gate keeps it reachable until finished. */ export default function OnboardingWizardDialog({ opened, @@ -56,53 +58,59 @@ export default function OnboardingWizardDialog({ const queryClient = useQueryClient(); const { user, company, onboardingStep } = useAuth(); - // A company already exists but onboarding wasn't marked complete (e.g. the - // browser closed after create but before finishing). Don't re-create it — - // just let the user finish. - const companyAlreadyCreated = Boolean(company?.company?.id); + const existingProfiles = company?.company?.companyProfiles ?? []; + const companyAlreadyStarted = Boolean(company?.company?.id); - // Resume position from the backend-persisted step. A form step means the user - // had already passed role selection. Cross-session we still start at role - // selection (the roles + field values aren't persisted), but within a session - // the dialog stays mounted so dismiss/reopen continues exactly where it was. + // Resume position from the backend-persisted step. const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep) ? (onboardingStep as FormStep) : "company"; - // "role" → pick roles; otherwise the company/forwarder form drives its own - // internal steps. - const [phase, setPhase] = useState<"role" | "form">("role"); - const [roles, setRoles] = useState([]); + // If a draft already exists, resume straight into the form with its roles + // pre-selected; otherwise start at role selection. + const [phase, setPhase] = useState<"role" | "form">( + companyAlreadyStarted ? "form" : "role", + ); + const [roles, setRoles] = useState( + existingProfiles.map((p) => p.type), + ); const [documentFiles, setDocumentFiles] = useState< Record >({}); + const [startError, setStartError] = useState(null); - const createCompanyMutation = useMutation({ - mutationFn: (payload: CreateCompanyPayload) => - api.companies.create.call(payload), - onSuccess: async (data) => { + const refreshInfo = useCallback( + () => + queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }), + [queryClient], + ); + + // Begin onboarding: create the draft company + profile + role(s). + const startMutation = useMutation({ + mutationFn: (vars: { companyType: string; roles: ProfileTypeValue[] }) => + api.companies.startOnboarding.call(vars), + onSuccess: async () => { + await refreshInfo(); + setPhase("form"); + }, + onError: (err) => setStartError(extractApiError(err).message), + }); + + // Finalize: upload any documents, then mark onboarding complete. + const finishMutation = useMutation({ + mutationFn: async () => { + const companyId = company?.company?.id; const hasFiles = Object.values(documentFiles).some( (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), ); - if (hasFiles) { - await companiesService.uploadDocuments(data.company.id, documentFiles); + if (companyId && hasFiles) { + await companiesService.uploadDocuments(companyId, documentFiles); } - // Mark onboarding complete, then refresh the company info so the gate - // releases and the header reflects the new profile(s). - await api.companies.completeOnboarding.call(); - await queryClient.invalidateQueries({ - queryKey: api.companies.getInfo.queryKey(), - }); - }, - }); - - const finishMutation = useMutation({ - mutationFn: () => api.companies.completeOnboarding.call(), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: api.companies.getInfo.queryKey(), - }); + return api.companies.completeOnboarding.call(); }, + onSuccess: refreshInfo, }); // Persist the resume step to the backend (best-effort, fire-and-forget). @@ -111,27 +119,39 @@ export default function OnboardingWizardDialog({ }, []); const handleRolesContinue = useCallback(() => { - setPhase("form"); - persistStep("company"); - }, [persistStep]); + setStartError(null); + startMutation.mutate({ + companyType: companyTypeForRoles(roles), + roles: roles as ProfileTypeValue[], + }); + }, [roles, startMutation]); const handleBackToRoles = useCallback(() => { setPhase("role"); persistStep("role"); }, [persistStep]); - const handleSubmit = useCallback( - (payload: CreateCompanyPayload) => { - const companyProfiles: CompanyProfileInput[] = roles.map((type) => ({ - type: type as CompanyProfileInput["type"], - })); - createCompanyMutation.mutate({ - ...payload, - companyType: companyTypeForRoles(roles), - companyProfiles, - }); + // Save the current step's fields to the draft (PATCH /profile). Returns false + // to keep the form on the current step when the save fails. + const saveStep = useCallback( + async (data: Partial): Promise => { + try { + await api.companies.updateProfile.call(data as UpdateProfilePayload); + return true; + } catch { + return false; + } }, - [roles, createCompanyMutation], + [], + ); + + // Final confirm step → finalize onboarding (no company create; it already + // exists as a draft that's been filled in step-by-step). + const handleSubmit = useCallback( + (_payload: CreateCompanyPayload) => { + finishMutation.mutate(); + }, + [finishMutation], ); if (!user) return null; @@ -161,37 +181,26 @@ export default function OnboardingWizardDialog({ Complete your onboarding - {companyAlreadyCreated - ? "You're almost there — finish to start using the portal." - : phase === "role" - ? "Tell us what your company does to get started." - : "Set up your company profile to finish."} + {phase === "role" + ? "Tell us what your company does to get started." + : "Set up your company profile to finish."} } > - {companyAlreadyCreated ? ( - - - - Your company profile is set up. Click finish to complete onboarding - and unlock the rest of the portal. - - - - - - ) : phase === "role" ? ( + {phase === "role" ? ( - + {startError && ( + + {startError} + + )} + ) : isForwarder ? ( ) : ( )} @@ -224,19 +235,21 @@ export default function OnboardingWizardDialog({ function RoleContinueBar({ disabled, + loading, onClick, }: { disabled: boolean; + loading?: boolean; onClick: () => void; }) { return ( ); } diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 195dcb692..3854ecdca 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -86,6 +86,7 @@ export const URL_CONSTANTS = { COMPANY_PROFILES: "/api/companies/company-profiles", COMPANY_PROFILE: "/api/companies/company-profile", ACTIVE_MODE: "/api/companies/active-mode", + ONBOARDING_START: "/api/companies/onboarding/start", ONBOARDING_STEP: "/api/companies/onboarding-step", ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", DASHBOARD: "/api/companies/dashboard", diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 913726008..d51f31490 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -28,6 +28,7 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; +import type { UpdateProfilePayload } from "@/types/profile"; import PhoneInput from "@/components/auth/PhoneInput"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; @@ -118,6 +119,44 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { }; } +/** Map one wizard step's form values to the profile-update payload it saves. */ +function stepPayload(step: CompanyStep, d: FormData): Partial { + switch (step) { + case "company": + return { + companyName: d.companyName, + companyEmail: d.companyEmail, + companyPhone: `${d.companyPhoneCountryCode}${d.companyPhone}`, + companyLocation: d.companyLocation, + companyAddress: d.companyAddress, + tin: d.tinNumber, + vatNumber: d.vatNumber, + fanNumber: d.fanNumber, + }; + case "personnel": + return { + contactPersonName: d.contactPersonName, + contactPersonPhone: `${d.contactPersonPhoneCountryCode}${d.contactPersonPhone}`, + generalManagerName: d.generalManagerName, + generalManagerEmail: d.generalManagerEmail, + generalManagerPhone: `${d.generalManagerPhoneCountryCode}${d.generalManagerPhone}`, + }; + case "poa": + return { + poaName: d.poaName || undefined, + poaPhone: + d.poaPhone && d.poaPhoneCountryCode + ? `${d.poaPhoneCountryCode}${d.poaPhone}` + : undefined, + poaEmail: d.poaEmail || undefined, + poaLocation: d.poaLocation || undefined, + poaAddress: d.poaAddress || undefined, + }; + default: + return {}; + } +} + export default function CompanyProfileForm({ documentSettingCode, documentFiles: controlledFiles, @@ -128,6 +167,7 @@ export default function CompanyProfileForm({ onBack, initialStep, onStepChange, + onSaveStep, }: { documentSettingCode: string; documentFiles?: Record; @@ -140,8 +180,11 @@ export default function CompanyProfileForm({ initialStep?: CompanyStep; /** Reports the active step so the parent can persist resume progress. */ onStepChange?: (step: CompanyStep) => void; + /** Persist the current step's data before advancing (returns false to block). */ + onSaveStep?: (data: Partial) => Promise; }) { const [step, setStep] = useState(initialStep ?? "company"); + const [saving, setSaving] = useState(false); // Report each step change up so the wizard can persist it for resume. useEffect(() => { @@ -199,22 +242,34 @@ export default function CompanyProfileForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); const totalSteps = 5; + /** Validate + persist the current step, returning whether we may advance. */ + const saveCurrentStep = async (): Promise => { + const isValid = await trigger(stepFields[step]); + if (!isValid) return false; + if (!onSaveStep) return true; + setSaving(true); + try { + return await onSaveStep(stepPayload(step, watch())); + } finally { + setSaving(false); + } + }; + const nextStep = async () => { - if (step === "poa") { - setStep("documents"); + if (step === "confirm") { + handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } if (step === "documents") { setStep("confirm"); return; } - if (step === "confirm") { - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); - return; - } - const isValid = await trigger(stepFields[step]); - if (!isValid) return; - setStep(step === "company" ? "personnel" : "poa"); + // company / personnel / poa: validate + save before advancing. + const ok = await saveCurrentStep(); + if (!ok) return; + setStep( + step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents", + ); }; const prevStep = () => { @@ -593,11 +648,15 @@ export default function CompanyProfileForm({ } disabled={ isPending || + saving || (step === "documents" && !hasDocuments && loadingDocuments) } - loading={isPending} + loading={isPending || saving} rightSection={ - !isPending && step !== "confirm" && step !== "documents" ? ( + !isPending && + !saving && + step !== "confirm" && + step !== "documents" ? ( ) : undefined } @@ -606,7 +665,7 @@ export default function CompanyProfileForm({ ? "Continue" : step === "confirm" ? "Submit Registration" - : "Next Step"} + : "Save & Continue"}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index 6f06d9c48..8efcd6a7f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -17,6 +17,7 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; +import type { UpdateProfilePayload } from "@/types/profile"; import PhoneInput from "@/components/auth/PhoneInput"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; @@ -83,6 +84,44 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { }; } +/** Map one wizard step's form values to the profile-update payload it saves. */ +function stepPayload(step: ForwarderStep, d: FormData): Partial { + switch (step) { + case "company": + return { + companyName: d.companyName, + companyEmail: d.companyEmail, + companyPhone: `${d.companyPhoneCountryCode}${d.companyPhone}`, + companyLocation: d.companyLocation, + companyAddress: d.companyAddress, + tin: d.tinNumber, + vatNumber: d.vatNumber, + fanNumber: d.fanNumber, + }; + case "personnel": + return { + contactPersonName: d.contactPersonName, + contactPersonPhone: `${d.contactPersonPhoneCountryCode}${d.contactPersonPhone}`, + generalManagerName: d.generalManagerName, + generalManagerEmail: d.generalManagerEmail, + generalManagerPhone: `${d.generalManagerPhoneCountryCode}${d.generalManagerPhone}`, + }; + case "poa": + return { + poaName: d.poaName || undefined, + poaPhone: + d.poaPhone && d.poaPhoneCountryCode + ? `${d.poaPhoneCountryCode}${d.poaPhone}` + : undefined, + poaEmail: d.poaEmail || undefined, + poaLocation: d.poaLocation || undefined, + poaAddress: d.poaAddress || undefined, + }; + default: + return {}; + } +} + export default function ForwarderForm({ documentSettingCode, documentFiles: controlledFiles, @@ -93,6 +132,7 @@ export default function ForwarderForm({ onBack, initialStep, onStepChange, + onSaveStep, }: { documentSettingCode: string; documentFiles?: Record; @@ -105,8 +145,11 @@ export default function ForwarderForm({ initialStep?: ForwarderStep; /** Reports the active step so the parent can persist resume progress. */ onStepChange?: (step: ForwarderStep) => void; + /** Persist the current step's data before advancing (returns false to block). */ + onSaveStep?: (data: Partial) => Promise; }) { const [step, setStep] = useState(initialStep ?? "company"); + const [saving, setSaving] = useState(false); // Report each step change up so the wizard can persist it for resume. useEffect(() => { @@ -136,13 +179,25 @@ export default function ForwarderForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); const totalSteps = 5; - const nextStep = async () => { - if (step === "poa") { setStep("documents"); return; } - if (step === "documents") { setStep("confirm"); return; } - if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + /** Validate + persist the current step, returning whether we may advance. */ + const saveCurrentStep = async (): Promise => { const isValid = await trigger(stepFields[step]); - if (!isValid) return; - setStep(step === "company" ? "personnel" : "poa"); + if (!isValid) return false; + if (!onSaveStep) return true; + setSaving(true); + try { + return await onSaveStep(stepPayload(step, watch())); + } finally { + setSaving(false); + } + }; + + const nextStep = async () => { + if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + if (step === "documents") { setStep("confirm"); return; } + const ok = await saveCurrentStep(); + if (!ok) return; + setStep(step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents"); }; const skipDocuments = () => setStep("confirm"); @@ -423,18 +478,18 @@ export default function ForwarderForm({ {step === "documents" && ( - )} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index e2c92fde6..8f14b0062 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -140,6 +140,11 @@ export const api = { CompanyProfileResponse >("companies", "createCompanyProfile", companiesService.createCompanyProfile), + startOnboarding: endpoint< + { companyType: string; roles: ProfileTypeValue[] }, + CompanyInfoResponse + >("companies", "startOnboarding", companiesService.startOnboarding), + setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>( "companies", "setActiveMode", diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index e71949045..2789a9e3a 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -177,6 +177,18 @@ export const companiesService = { return unwrap(response.data); }, + /** Begin onboarding — create the draft company + profile + role(s) up front. */ + startOnboarding: async (payload: { + companyType: string; + roles: ProfileTypeValue[]; + }): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.ONBOARDING_START, + payload, + ); + return unwrap(response.data); + }, + /** Switch the active operational mode (target profile must already exist). */ setActiveMode: async (payload: { type: ProfileTypeValue; From ce8189d5fe7889c0d5a08c12c57029abe2b22c1e Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 19 Jun 2026 23:04:39 +0000 Subject: [PATCH 06/97] feat: enhance onboarding forms with error handling and step persistence --- .../modules/companies/companies.service.ts | 2 +- .../onboarding/OnboardingWizardDialog.tsx | 35 ++++++--- .../src/pages/accounts/CompanyProfileForm.tsx | 75 +++++++++++++++---- .../src/pages/accounts/ForwarderForm.tsx | 56 ++++++++++++-- 4 files changed, 132 insertions(+), 36 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 65a736e1f..dae561470 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -468,7 +468,7 @@ export class CompaniesService { const owner = await this.companiesRepo.findByTin(dto.tin); if (owner && owner.id !== company.id) { throw new ConflictException( - `Company with TIN ${dto.tin} already exists`, + `This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`, ); } companyUpdates.tin = dto.tin; diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 6cd7f21b1..ddb046004 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -1,6 +1,6 @@ import { Modal, ScrollArea, Stack, Text } from "@mantine/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; @@ -113,8 +113,14 @@ export default function OnboardingWizardDialog({ onSuccess: refreshInfo, }); - // Persist the resume step to the backend (best-effort, fire-and-forget). + // Persist the resume step to the backend, but only ever move FORWARD — going + // Back must never downgrade the furthest step the user reached, so reopening + // always lands on the furthest step. + const furthestIdxRef = useRef(FORM_STEPS.indexOf(resumeFormStep)); const persistStep = useCallback((step: string) => { + const idx = FORM_STEPS.indexOf(step as FormStep); + if (idx < 0 || idx <= furthestIdxRef.current) return; + furthestIdxRef.current = idx; api.companies.setOnboardingStep.call({ step }).catch(() => {}); }, []); @@ -126,20 +132,21 @@ export default function OnboardingWizardDialog({ }); }, [roles, startMutation]); - const handleBackToRoles = useCallback(() => { - setPhase("role"); - persistStep("role"); - }, [persistStep]); + // Note: no "back to role selection" — once the draft is created the role(s) + // are fixed; the form's first-step Back is a no-op so progress never resets. + const handleBackToRoles = useCallback(() => {}, []); - // Save the current step's fields to the draft (PATCH /profile). Returns false - // to keep the form on the current step when the save fails. + // Save the current step's fields to the draft (PATCH /profile). Returns the + // server error message on failure so the form can show it (e.g. duplicate TIN). const saveStep = useCallback( - async (data: Partial): Promise => { + async ( + data: Partial, + ): Promise<{ ok: true } | { ok: false; error: string }> => { try { await api.companies.updateProfile.call(data as UpdateProfilePayload); - return true; - } catch { - return false; + return { ok: true }; + } catch (err) { + return { ok: false, error: extractApiError(err).message }; } }, [], @@ -211,7 +218,9 @@ export default function OnboardingWizardDialog({ onSubmit={handleSubmit} isPending={finishMutation.isPending} onBack={handleBackToRoles} + hideFirstStepBack initialStep={resumeFormStep} + resyncOpen={opened} onStepChange={persistStep} onSaveStep={saveStep} /> @@ -224,7 +233,9 @@ export default function OnboardingWizardDialog({ onSubmit={handleSubmit} isPending={finishMutation.isPending} onBack={handleBackToRoles} + hideFirstStepBack initialStep={resumeFormStep} + resyncOpen={opened} onStepChange={persistStep} onSaveStep={saveStep} /> diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index d51f31490..2ce65b29e 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -1,4 +1,5 @@ import { + Alert, Box, Button, Divider, @@ -13,6 +14,7 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { + AlertCircle, ArrowLeft, ArrowRight, Building2, @@ -22,7 +24,7 @@ import { UploadCloud, User, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -166,6 +168,8 @@ export default function CompanyProfileForm({ isPending, onBack, initialStep, + resyncOpen, + hideFirstStepBack, onStepChange, onSaveStep, }: { @@ -178,19 +182,38 @@ export default function CompanyProfileForm({ onBack: () => void; /** Step to resume at (defaults to "company"). */ initialStep?: CompanyStep; + /** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */ + resyncOpen?: boolean; + /** Hide the Back button on the first step (onboarding can't go back to role pick). */ + hideFirstStepBack?: boolean; /** Reports the active step so the parent can persist resume progress. */ onStepChange?: (step: CompanyStep) => void; - /** Persist the current step's data before advancing (returns false to block). */ - onSaveStep?: (data: Partial) => Promise; + /** Persist the current step's data before advancing; returns an error to show. */ + onSaveStep?: ( + data: Partial, + ) => Promise<{ ok: true } | { ok: false; error: string }>; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); // Report each step change up so the wizard can persist it for resume. useEffect(() => { onStepChange?.(step); // eslint-disable-next-line react-hooks/exhaustive-deps }, [step]); + + // On reopen, jump to the furthest step reached (initialStep) so progress + // never appears to reset. + const wasOpen = useRef(resyncOpen); + useEffect(() => { + if (resyncOpen && !wasOpen.current && initialStep) { + setStep(initialStep); + setSaveError(null); + } + wasOpen.current = resyncOpen; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resyncOpen]); const [internalFiles, setInternalFiles] = useState< Record >({}); @@ -244,12 +267,18 @@ export default function CompanyProfileForm({ /** Validate + persist the current step, returning whether we may advance. */ const saveCurrentStep = async (): Promise => { + setSaveError(null); const isValid = await trigger(stepFields[step]); if (!isValid) return false; if (!onSaveStep) return true; setSaving(true); try { - return await onSaveStep(stepPayload(step, watch())); + const res = await onSaveStep(stepPayload(step, watch())); + if (!res.ok) { + setSaveError(res.error); + return false; + } + return true; } finally { setSaving(false); } @@ -273,6 +302,7 @@ export default function CompanyProfileForm({ }; const prevStep = () => { + setSaveError(null); if (step === "company") onBack(); else if (step === "personnel") setStep("company"); else if (step === "poa") setStep("personnel"); @@ -280,6 +310,10 @@ export default function CompanyProfileForm({ else setStep("documents"); }; + // Back is hidden on the first step during onboarding (can't return to role + // selection); otherwise always available. + const showBack = !(hideFirstStepBack && step === "company"); + const STEPS: { key: CompanyStep; icon: React.ReactNode }[] = [ { key: "company", icon: }, { key: "personnel", icon: }, @@ -627,18 +661,29 @@ export default function CompanyProfileForm({ )} - - + {saveError} + + )} + + + {showBack ? ( + + ) : ( + + )} + {showBack ? ( + + ) : ( + + )} {step === "documents" && (