From 9d5c195d94903de616471b651d78bb177817c545 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 08:16:49 +0000 Subject: [PATCH 01/11] style: improvment to the booking --- apps/edr-freight-web/portal/src/App.tsx | 24 +-- .../onboarding/OnboardingResumeBanner.tsx | 145 ++++++++++++++++++ .../onboarding/OnboardingWizardDialog.tsx | 2 +- .../src/pages/MyPortalPage/MyPortalPage.tsx | 4 - .../MyPortalPage/components/SetupPrompt.tsx | 70 --------- .../pages/MyPortalPage/components/index.ts | 2 +- .../portal/src/utils/profileCompletion.ts | 56 +++++++ 7 files changed, 204 insertions(+), 99 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/MyPortalPage/components/SetupPrompt.tsx create mode 100644 apps/edr-freight-web/portal/src/utils/profileCompletion.ts diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 15137cb26..3a32f2b78 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -7,7 +7,6 @@ import { MapPin, Receipt, Settings, - Sparkles, } from "lucide-react"; import { useDisclosure } from "@mantine/hooks"; import { useEffect, useRef } from "react"; @@ -21,6 +20,7 @@ import { } from "react-router-dom"; import useAuth from "./hooks/useAuth"; +import OnboardingResumeBanner from "./components/onboarding/OnboardingResumeBanner"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; @@ -155,28 +155,6 @@ function OnboardingGate() { ); } -/** 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/onboarding/OnboardingResumeBanner.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx new file mode 100644 index 000000000..e0df7346f --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx @@ -0,0 +1,145 @@ +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight } from "lucide-react"; +import { api } from "@/services/api"; +import { + getProfileCompletion, + type ProfileCompletion, +} from "@/utils/profileCompletion"; + +interface OnboardingResumeBannerProps { + /** Re-opens the onboarding wizard. */ + onResume: () => void; +} + +interface BannerCopy { + title: string; + subtitle: string; + cta: string; +} + +/** Picks wording based on how far through setup the user actually is. */ +function getCopy( + completion: ProfileCompletion, + pct: number, + isPending: boolean, +): BannerCopy { + // Until the profile loads, or before anything is filled in, treat it as a + // fresh start rather than guessing progress. + if (isPending || completion.completed === 0) { + return { + title: "Set up your company profile", + subtitle: "Unlock bookings, tracking and billing — it only takes a minute.", + cta: "Start onboarding", + }; + } + + const remaining = completion.total - completion.completed; + if (remaining <= 2) { + return { + title: `Almost done — you're ${pct}% set up`, + subtitle: `Just ${remaining} more ${ + remaining === 1 ? "detail" : "details" + } to unlock bookings, tracking and billing.`, + cta: "Finish onboarding", + }; + } + + return { + title: `You're ${pct}% set up`, + subtitle: `${completion.completed} of ${completion.total} details added — finish to unlock bookings, tracking and billing.`, + cta: "Continue onboarding", + }; +} + +/** Circular percentage meter that reads at a glance against the dark banner. */ +function ProgressRing({ pct }: { pct: number }) { + const size = 56; + const stroke = 5; + const r = (size - stroke) / 2; + const circumference = 2 * Math.PI * r; + const offset = circumference * (1 - pct / 100); + + return ( + + + + + + {pct}% + + ); +} + +/** + * Prominent banner shown on onboarding-allowed pages after the wizard is + * dismissed. It reads the company profile directly so it stays aware of real + * progress: a percentage ring and the copy adapt as fields get filled, and the + * whole banner disappears once every required detail is complete. + */ +export default function OnboardingResumeBanner({ + onResume, +}: OnboardingResumeBannerProps) { + const profileQuery = useQuery( + api.companies.getProfile.queryOptions({ retry: false }), + ); + + const completion = getProfileCompletion(profileQuery.data); + + // Reliably step aside once the user has genuinely finished onboarding. + if (!profileQuery.isPending && completion.isComplete) return null; + + const pct = Math.round((completion.completed / completion.total) * 100); + const { title, subtitle, cta } = getCopy( + completion, + pct, + profileQuery.isPending, + ); + + return ( +
+
+
+ + + + + + + + + {title} + + + {subtitle} + +
+ +
+
+ ); +} 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 3628d7403..b25f6f12e 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -355,7 +355,7 @@ export default function OnboardingWizardDialog({ withCloseButton closeOnClickOutside={false} closeOnEscape - size={1040} + size={720} radius="lg" padding="xl" centered diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx index 0a4dcb6ce..13ab980b8 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx @@ -7,7 +7,6 @@ import { HelloSection, InvoicesSection, RecentActivitySection, - SetupPrompt, ShipmentsSection, StatsSection, } from "./components"; @@ -16,7 +15,6 @@ import { useMyPortalData } from "./hooks"; export default function MyPortalPage() { const navigate = useNavigate(); const { - customer, bookingsQuery, dashboardQuery, allBookings, @@ -40,8 +38,6 @@ export default function MyPortalPage() { - - !profile[field]); -} - -interface SetupPromptProps { - show: boolean; -} - -export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) { - const profileQuery = useQuery( - api.companies.getProfile.queryOptions({ retry: false }), - ); - - const incomplete = !profileQuery.isPending && isProfileIncomplete(profileQuery.data); - - if (!show && !incomplete) return null; - - return ( - - - - - {incomplete && } - - {incomplete ? "Complete Your Profile" : "Setup your Company Profile"} - - - - {incomplete - ? "Your company profile is incomplete. Fill in the missing details to unlock all features." - : "Complete your company information to unlock all features and start booking shipments."} - - - - - {incomplete ? "Complete Profile" : "Complete Setup"} - - - - - - - - - - - ); -}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts index de3ddb448..4bf94f45f 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts @@ -6,8 +6,8 @@ export { FreightVolumeSection } from "./FreightVolumeSection"; export { HelloSection } from "./HelloSection"; export { InvoicesSection } from "./InvoicesSection"; export { RecentActivitySection } from "./RecentActivitySection"; -export { SetupPrompt } from "./SetupPrompt"; export { ShipmentsSection } from "./ShipmentsSection"; export { StatKpi } from "./StatKpi"; export { StatsSection } from "./StatsSection"; export { Stepper } from "./Stepper"; + diff --git a/apps/edr-freight-web/portal/src/utils/profileCompletion.ts b/apps/edr-freight-web/portal/src/utils/profileCompletion.ts new file mode 100644 index 000000000..5e95af40d --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/profileCompletion.ts @@ -0,0 +1,56 @@ +import type { ProfileResponse } from "@/types/profile"; + +/** + * Company-profile fields that must be filled before onboarding is considered + * finished. Shared between the portal SetupPrompt and the onboarding banner so + * both agree on what "done" means. + */ +export const REQUIRED_PROFILE_FIELDS: (keyof ProfileResponse)[] = [ + "companyEmail", + "companyPhone", + "companyAddress", + "fanNumber", + "contactPersonName", + "contactPersonPhone", + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", +]; + +export interface ProfileCompletion { + /** Number of required fields that are filled in. */ + completed: number; + /** Total number of required fields. */ + total: number; + /** Required fields still missing a value. */ + missing: (keyof ProfileResponse)[]; + /** True when every required field is filled. */ + isComplete: boolean; +} + +/** Breaks a profile down into how much of the required setup is complete. */ +export function getProfileCompletion( + profile?: ProfileResponse | null, +): ProfileCompletion { + const total = REQUIRED_PROFILE_FIELDS.length; + if (!profile) { + return { + completed: 0, + total, + missing: [...REQUIRED_PROFILE_FIELDS], + isComplete: false, + }; + } + const missing = REQUIRED_PROFILE_FIELDS.filter((field) => !profile[field]); + return { + completed: total - missing.length, + total, + missing, + isComplete: missing.length === 0, + }; +} + +/** Convenience predicate kept for existing call sites. */ +export function isProfileIncomplete(profile?: ProfileResponse | null): boolean { + return !getProfileCompletion(profile).isComplete; +} From c9694177d8206c36b8f4983257dbe8bf80e10076 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 08:55:09 +0000 Subject: [PATCH 02/11] style: ui improvement to kpi in portal --- .../pages/MyPortalPage/components/StatKpi.tsx | 88 +++++++++++++++---- .../MyPortalPage/components/StatsSection.tsx | 30 +++++-- 2 files changed, 91 insertions(+), 27 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx index 413caafc4..a17af733d 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx @@ -1,15 +1,30 @@ import { Box, Group, Text } from "@mantine/core"; -import { memo } from "react"; import type { LucideIcon } from "lucide-react"; +import { memo } from "react"; import { cv } from "../constants"; +/** Accent families map a KPI to a soft tile + strong ink pair from the theme. */ +type Accent = "green" | "amber" | "blue" | "slate"; + +const ACCENTS: Record = { + green: { soft: cv("edr-soft"), ink: cv("edr-green.7") }, + amber: { soft: cv("edr-amber-soft"), ink: cv("edr-amber-text") }, + blue: { soft: cv("edr-blue-soft"), ink: cv("edr-blue") }, + slate: { soft: cv("edr-slate-soft"), ink: cv("edr-slate") }, +}; + interface StatKpiProps { icon: LucideIcon; label: string; value: string; delta: string; - deltaColor: string; + /** Color family for the icon chip. */ + accent: Accent; + /** Tint of the delta pill — defaults to the card accent. */ + deltaTone?: Accent | "muted"; + /** Draw a separating border on the left (on wide layouts). */ divider?: boolean; + loading?: boolean; } export const StatKpi = memo(function StatKpi({ @@ -17,30 +32,67 @@ export const StatKpi = memo(function StatKpi({ label, value, delta, - deltaColor, + accent, + deltaTone, divider, + loading, }: StatKpiProps) { + const a = ACCENTS[accent]; + const tone = deltaTone ?? accent; + const pill = + tone === "muted" + ? { bg: cv("edr-slate-soft2"), fg: cv("edr-muted") } + : { bg: ACCENTS[tone].soft, fg: ACCENTS[tone].ink }; + return ( - - - - {label} - - - - - {value} - - - {delta} - + {/* Icon chip + metric label, aligned on one line. */} + + + + + + + + {loading ? "—" : value} + + {delta && !loading && ( + + + {delta} + + + )} + + + {label} + + + + {/* Value + its trend pill, grouped together at the bottom of the cell. */} + ); }); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx index 8a0545fb7..dbd12546b 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx @@ -1,7 +1,7 @@ +import { formatCurrency } from "@/pages/billing/invoices.mock"; import { SimpleGrid } from "@mantine/core"; import { CheckCircle2, Clock3, Truck, Wallet } from "lucide-react"; import { memo } from "react"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; import { formatPct } from "../constants"; import { Card } from "./Card"; import { StatKpi } from "./StatKpi"; @@ -29,39 +29,51 @@ export const StatsSection = memo(function StatsSection({ completionRate, spendYtd, spendYtdChangePct, + dashboardLoading, }: StatsSectionProps) { return ( - - + + 0 ? `+${newActiveThisWeek} this week` : ""} + loading={bookingsLoading} /> From 43be6892aa7bcd1379388e16f854a22f71d7707c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 11:50:04 +0000 Subject: [PATCH 03/11] fix: block the user from booking if not approved --- .../src/modules/bookings/bookings.service.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 b6f3ee220..1f93eccc3 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -361,6 +361,17 @@ export class BookingsService { tradeDirection, fallbackType, ); + + // A customer booking under their own account may only do so once the + // resolved operational profile has been approved by the backoffice. Staff- + // and government-initiated bookings (companyId supplied explicitly) bypass + // this gate. + const customerSelfBooking = !dto.companyId && !!userId; + if (customerSelfBooking && companyProfileId) { + await this.companiesService.assertCompanyProfileApprovedForBooking( + companyProfileId, + ); + } } const allowConsolidation = From 9b48955eaf1b87f65e70f6015f3c7d92a182db6f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 11:50:28 +0000 Subject: [PATCH 04/11] feat: centralized the user onboaridn requriements --- .../modules/companies/companies.controller.ts | 12 + .../src/modules/companies/companies.module.ts | 2 + .../modules/companies/companies.service.ts | 206 +++++++++++++++++- .../onboarding-requirements-response.dto.ts | 78 +++++++ .../src/pages/customers/CustomersPage.tsx | 44 +++- apps/edr-freight-web/portal/src/App.tsx | 15 +- .../src/components/NewBookingButton.tsx | 60 +++++ .../onboarding/OnboardingResumeBanner.tsx | 108 ++++++--- .../onboarding/OnboardingWizardDialog.tsx | 135 ++++++++++-- .../portal/src/constants/URLS.ts | 1 + .../portal/src/hooks/useAuth.ts | 11 + .../src/pages/accounts/CompanyProfileForm.tsx | 2 +- .../portal/src/pages/bookings/MyBookings.tsx | 26 +-- .../src/pages/bookings/NewBookingPage.tsx | 8 +- .../portal/src/services/api.ts | 7 + .../portal/src/services/companies.service.ts | 49 +++++ .../portal/src/utils/profileCompletion.ts | 56 ----- 17 files changed, 670 insertions(+), 150 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts create mode 100644 apps/edr-freight-web/portal/src/components/NewBookingButton.tsx delete mode 100644 apps/edr-freight-web/portal/src/utils/profileCompletion.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 fff8fc7e5..2feebffb5 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -40,6 +40,7 @@ import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto"; import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; +import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -221,6 +222,17 @@ export class CompaniesController { await this.companiesService.setOnboardingStep(user.id, dto.step); } + @Get("onboarding/requirements") + @ApiOperation({ + summary: + "What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)", + }) + async getOnboardingRequirements( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.getOnboardingRequirements(user.id); + } + @Post("onboarding/complete") @ApiOperation({ summary: "Mark the current user's onboarding as complete" }) async completeOnboarding( diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index d275c57a7..88871f8ad 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { HttpModule } from "@nestjs/axios"; import { FilesModule } from "../files/files.module"; +import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module"; import { MinioModule } from "../minio/minio.module"; import { CompaniesController } from "./companies.controller"; import { CompaniesService } from "./companies.service"; @@ -20,6 +21,7 @@ import { ETradeService } from "./services/etrade.service"; TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), HttpModule, FilesModule, + FileUploadSettingsModule, MinioModule, ], controllers: [CompaniesController], 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 12fcb6238..b145f354c 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -3,13 +3,17 @@ import { NotFoundException, ConflictException, BadRequestException, + ForbiddenException, } from "@nestjs/common"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository } from "./company-dashboard.repository"; import { MinioService } from "../minio/minio.service"; +import { FilesService } from "../files/files.service"; +import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; import { ETradeService } from "./services/etrade.service"; +import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; @@ -50,9 +54,67 @@ export class CompaniesService { private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, private readonly minioService: MinioService, + private readonly filesService: FilesService, + private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, ) { } + /** + * Required company-information fields that must be filled before onboarding can + * be submitted. The backend owns this list so the portal never has to know + * which fields are mandatory — it just renders what's reported outstanding. + * `get` reads the value from the company (some live in the attributes blob). + */ + private readonly REQUIRED_COMPANY_INFO: { + key: string; + label: string; + get: (company: Company) => unknown; + }[] = [ + { + key: "tinNumber", + label: "Company TIN", + get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null), + }, + { key: "companyEmail", label: "Company email", get: (c) => c.email }, + { key: "companyPhone", label: "Company phone", get: (c) => c.phone }, + { key: "companyAddress", label: "Company address", get: (c) => c.address }, + { key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber }, + { + key: "contactPersonName", + label: "Contact person name", + get: (c) => c.attributes?.contactPersonName, + }, + { + key: "contactPersonPhone", + label: "Contact person phone", + get: (c) => c.attributes?.contactPersonPhone, + }, + { + key: "generalManagerName", + label: "General manager name", + get: (c) => c.attributes?.generalManagerName, + }, + { + key: "generalManagerEmail", + label: "General manager email", + get: (c) => c.attributes?.generalManagerEmail, + }, + { + key: "generalManagerPhone", + label: "General manager phone", + get: (c) => c.attributes?.generalManagerPhone, + }, + ]; + + /** The nationality-based document setting code for a company. */ + private documentSettingCodeFor( + nationality: CompanyNationality | null | undefined, + ): string { + return nationality === CompanyNationality.Foreign + ? "company_onboarding_documents_foreign" + : "company_onboarding_documents_ethiopian"; + } + async createCompany(dto: CreateCompanyDto): Promise { const exists = await this.companiesRepo.existsByTin(dto.tin); if (exists) { @@ -624,6 +686,17 @@ export class CompaniesService { ); if (!updated) throw new NotFoundException(`Company profile ${profileId} not found`); + + // Approving any profile promotes a pending company to active, so the + // customer can start working as soon as their first profile is cleared. + if (status === ProfileStatus.Active) { + const company = await this.companiesRepo.findById(updated.companyId); + if (company && company.status === CompanyStatus.Pending) { + await this.companiesRepo.update(updated.companyId, { + status: CompanyStatus.Active, + }); + } + } return updated; } @@ -809,6 +882,100 @@ export class CompaniesService { await this.profilesRepo.update(profile.id, { onboardingStep: step }); } + /** + * Server-driven onboarding requirements for the current user's company. + * + * The backend resolves the nationality-based document set, checks which + * company documents and per-profile licenses are already uploaded, and reports + * exactly what is still outstanding. The portal renders this list verbatim and + * relies on `isComplete` to decide when to auto-finish — it never decides for + * itself which documents apply or which fields are mandatory. + */ + async getOnboardingRequirements( + userId: string, + ): Promise { + const { profile, company } = await this.getCompanyInfoByUserId(userId); + + // 1. Required company-information fields. + const missingInfo = this.REQUIRED_COMPANY_INFO.filter( + (f) => !f.get(company), + ).map((f) => ({ key: f.key, label: f.label })); + + // 2. Nationality-based company documents + which are already uploaded. + const documentSettingCode = this.documentSettingCodeFor(company.nationality); + const [setting, uploadedFiles] = await Promise.all([ + this.fileUploadSettingsService + .getByCode(documentSettingCode) + .catch(() => null), + this.filesService.findByResource(company.id, "companies"), + ]); + const uploadedCodes = new Set(uploadedFiles.map((f) => f.code)); + const documents = (setting?.fields ?? []) + .slice() + .sort((a, b) => a.displayOrder - b.displayOrder) + .map((f) => ({ + fileKey: f.fileKey, + fileLabel: f.fileLabel, + helpText: f.helpText ?? null, + isRequired: f.isRequired, + isMultiple: f.isMultiple, + maxFiles: f.maxFiles, + allowedExtensions: f.allowedExtensions, + maxSizeMb: f.maxSizeMb, + displayOrder: f.displayOrder, + uploaded: uploadedCodes.has(f.fileKey), + })); + const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded); + + // 3. Per-operational-profile business licenses. + const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({ + profileId: p.id, + type: p.type, + reference: p.reference, + uploaded: (p.businessLicenseFiles?.length ?? 0) > 0, + })); + const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); + + const outstanding = [ + ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), + ...missingDocs.map((d) => `Upload your ${d.fileLabel}`), + ...missingLicenses.map( + (p) => + `Upload a business license for your ${p.type.replace(/_/g, " ")} profile`, + ), + ]; + + // Progress spans every required item the user has to satisfy: company-info + // fields, required documents and one license per operational profile. + const requiredDocCount = documents.filter((d) => d.isRequired).length; + const total = + this.REQUIRED_COMPANY_INFO.length + + requiredDocCount + + licenseProfiles.length; + const completed = + total - + (missingInfo.length + missingDocs.length + missingLicenses.length); + + return new OnboardingRequirementsResponseDto({ + documentSettingCode, + nationality: company.nationality ?? CompanyNationality.Ethiopian, + companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo }, + documents, + licenseProfiles, + progress: { completed, total }, + isComplete: outstanding.length === 0, + onboardingCompleted: profile.onboardingCompleted, + outstanding, + }); + } + + /** + * Submit onboarding for review. Validation is delegated entirely to + * getOnboardingRequirements (the same source of truth the portal renders), so + * the gate can never drift from what the UI shows. On success the company and + * all its operational profiles move to PENDING — the backoffice approves each + * profile before it can be used (see setCompanyProfileStatus). + */ async markOnboardingComplete( userId: string, ): Promise<{ profile: ExternalProfile; company: Company }> { @@ -817,23 +984,21 @@ export class CompaniesService { 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")) { + const requirements = await this.getOnboardingRequirements(userId); + if (!requirements.isComplete) { throw new BadRequestException( - "Company information is incomplete — please fill in your company details before finishing.", + requirements.outstanding[0] ?? + "Your onboarding is incomplete. Please complete all required steps before submitting.", ); } - // Every operational profile must have at least one business-license file - // (stored directly on the profile). + // Send every operational profile in for approval; the company itself becomes + // active once the backoffice approves at least one profile. const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); for (const cp of profiles) { - if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) { - throw new BadRequestException( - `Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`, - ); + if (cp.status !== ProfileStatus.Pending) { + await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending); } } @@ -842,11 +1007,30 @@ export class CompaniesService { onboardingStep: "done", }); await this.companiesRepo.update(companyId, { - status: CompanyStatus.Active, + status: CompanyStatus.Pending, }); return this.getCompanyInfoByUserId(userId); } + /** + * Block a customer from booking under a profile that isn't approved yet. + * Called from the booking-create path for self-service bookings; staff- and + * government-initiated bookings bypass this. No-op when the profile can't be + * found (defensive — resolution is best-effort upstream). + */ + async assertCompanyProfileApprovedForBooking( + companyProfileId: string, + ): Promise { + const profile = await this.companyProfilesRepo.findById(companyProfileId); + if (!profile) return; + if (profile.status !== ProfileStatus.Active) { + const role = profile.type.replace(/_/g, " "); + throw new ForbiddenException( + `Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`, + ); + } + } + /** * Authorize and resolve a company_profile that must belong to the current * user's company — used before accepting/returning its license files. diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts new file mode 100644 index 000000000..92f9fa513 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -0,0 +1,78 @@ +/** + * Server-driven description of what a company still needs to finish onboarding. + * + * The portal renders this verbatim instead of deciding for itself which + * documents apply or which fields are mandatory: the backend resolves the + * nationality-based document set, checks which files are already uploaded, and + * reports exactly what is outstanding. `isComplete` is the single source of + * truth the wizard uses to auto-finish. + */ + +export interface OnboardingInfoField { + key: string; + label: string; +} + +export interface OnboardingDocumentField { + fileKey: string; + fileLabel: string; + helpText: string | null; + isRequired: boolean; + isMultiple: boolean; + maxFiles: number; + allowedExtensions: string[]; + maxSizeMb: number; + displayOrder: number; + /** True when a file with this code is already stored for the company. */ + uploaded: boolean; +} + +export interface OnboardingLicenseProfile { + profileId: string; + type: string; + reference: string; + /** True when at least one business-license file is stored on the profile. */ + uploaded: boolean; +} + +export class OnboardingRequirementsResponseDto { + /** Resolved document setting code (by nationality) the docs were drawn from. */ + documentSettingCode: string; + nationality: string; + + /** Required company-information fields and whether each is filled. */ + companyInfo: { + complete: boolean; + missingFields: OnboardingInfoField[]; + }; + + /** The document fields the portal should render, with upload state. */ + documents: OnboardingDocumentField[]; + + /** Per-operational-profile business-license requirements. */ + licenseProfiles: OnboardingLicenseProfile[]; + + /** Overall setup progress across fields + documents + licenses. */ + progress: { completed: number; total: number }; + + /** True once every required field, document and license is satisfied. */ + isComplete: boolean; + + /** Whether the user has already submitted onboarding (awaiting approval). */ + onboardingCompleted: boolean; + + /** Human-readable list of everything still outstanding (empty when complete). */ + outstanding: string[]; + + constructor(init: Omit) { + this.documentSettingCode = init.documentSettingCode; + this.nationality = init.nationality; + this.companyInfo = init.companyInfo; + this.documents = init.documents; + this.licenseProfiles = init.licenseProfiles; + this.progress = init.progress; + this.isComplete = init.isComplete; + this.onboardingCompleted = init.onboardingCompleted; + this.outstanding = init.outstanding; + } +} diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index e06d310f4..aff353055 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -1,11 +1,14 @@ import { ActionIcon, + Badge, Box, Card, Group, + SegmentedControl, Stack, Text, TextInput, + Tooltip, } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { useQuery } from "@tanstack/react-query"; @@ -32,7 +35,7 @@ import { } from "@/components/customers"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; -import type { Company } from "@/types/customer"; +import type { Company, CompanyStatus } from "@/types/customer"; import { DataTable, DataTableFooter, @@ -45,14 +48,17 @@ export default function CustomersPage() { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); + // "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review). + const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>(""); const filter = useMemo( () => ({ page: pagination.pageIndex + 1, pageSize: pagination.pageSize, search: debouncedQuery, + status: statusFilter || undefined, }), - [pagination.pageIndex, pagination.pageSize, debouncedQuery], + [pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter], ); const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} })); @@ -107,7 +113,25 @@ export default function CustomersPage() { { id: "status", header: "Status", - cell: ({ row }) => , + cell: ({ row }) => { + const pending = (row.original.companyProfiles ?? []).filter( + (p) => p.status === "pending", + ).length; + return ( + + + {pending > 0 ? ( + 1 ? "s" : ""} awaiting approval`} + > + + {pending} pending + + + ) : null} + + ); + }, }, { id: "contact", @@ -216,6 +240,20 @@ export default function CustomersPage() { style={{ flex: 1, minWidth: "240px" }} radius="lg" /> + { + setStatusFilter(v === "all" ? "" : (v as CompanyStatus)); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + data={[ + { label: "All", value: "all" }, + { label: "Pending approval", value: "pending" }, + { label: "Active", value: "active" }, + ]} + /> {total} record{total !== 1 ? "s" : ""} diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 3a32f2b78..9082773b6 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -1,4 +1,5 @@ import { AppLayout, type SidebarItem } from "@/components/AppLayout"; +import { useDisclosure } from "@mantine/hooks"; import { CalendarCheck, Home, @@ -8,7 +9,6 @@ import { Receipt, Settings, } from "lucide-react"; -import { useDisclosure } from "@mantine/hooks"; import { useEffect, useRef } from "react"; import { Navigate, @@ -19,9 +19,11 @@ import { useNavigate, } from "react-router-dom"; -import useAuth from "./hooks/useAuth"; -import OnboardingResumeBanner from "./components/onboarding/OnboardingResumeBanner"; +import OnboardingResumeBanner, { + AccountReviewBanner, +} from "./components/onboarding/OnboardingResumeBanner"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; +import useAuth from "./hooks/useAuth"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import MySignaturePage from "./pages/MySignaturePage"; @@ -36,11 +38,11 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import EditBookingPage from "./pages/bookings/EditBookingPage"; import MyBookings from "./pages/bookings/MyBookings"; import NewBookingPage from "./pages/bookings/NewBookingPage"; -import ContractsList from "./pages/contracts/ContractsList"; import ContractDetailPage from "./pages/contracts/ContractDetailPage"; +import ContractsList from "./pages/contracts/ContractsList"; import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; -import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; import PaymentFailurePage from "./pages/payments/PaymentFailurePage"; +import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; import TrackingPage from "./pages/tracking/TrackingPage"; function FullScreenSpinner() { @@ -143,9 +145,10 @@ function OnboardingGate() { return ( <> - {needsOnboarding && !wizardOpen && ( + {needsOnboarding && ( )} + {!needsOnboarding && } + + + + + ); + } + + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx index e0df7346f..d0024c6f6 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx @@ -1,10 +1,8 @@ import { useQuery } from "@tanstack/react-query"; -import { ArrowRight } from "lucide-react"; +import { ArrowRight, Clock } from "lucide-react"; import { api } from "@/services/api"; -import { - getProfileCompletion, - type ProfileCompletion, -} from "@/utils/profileCompletion"; +import useAuth from "@/hooks/useAuth"; +import type { OnboardingRequirements } from "@/services/companies.service"; interface OnboardingResumeBannerProps { /** Re-opens the onboarding wizard. */ @@ -17,15 +15,16 @@ interface BannerCopy { cta: string; } -/** Picks wording based on how far through setup the user actually is. */ +/** + * Wording is driven entirely by the backend's outstanding-items list — the + * client never decides what's required, it just narrates what's left. + */ function getCopy( - completion: ProfileCompletion, + requirements: OnboardingRequirements | undefined, pct: number, - isPending: boolean, ): BannerCopy { - // Until the profile loads, or before anything is filled in, treat it as a - // fresh start rather than guessing progress. - if (isPending || completion.completed === 0) { + // No data yet (or nothing started) — treat it as a fresh start. + if (!requirements || requirements.progress.completed === 0) { return { title: "Set up your company profile", subtitle: "Unlock bookings, tracking and billing — it only takes a minute.", @@ -33,20 +32,29 @@ function getCopy( }; } - const remaining = completion.total - completion.completed; + // Everything's filled in but not yet submitted for review. + if (requirements.isComplete) { + return { + title: "Everything's ready to go", + subtitle: "Submit your profile to send it for approval.", + cta: "Submit for review", + }; + } + + const remaining = requirements.outstanding.length; if (remaining <= 2) { return { title: `Almost done — you're ${pct}% set up`, subtitle: `Just ${remaining} more ${ - remaining === 1 ? "detail" : "details" - } to unlock bookings, tracking and billing.`, + remaining === 1 ? "item" : "items" + } to finish: ${requirements.outstanding.join(", ")}.`, cta: "Finish onboarding", }; } return { title: `You're ${pct}% set up`, - subtitle: `${completion.completed} of ${completion.total} details added — finish to unlock bookings, tracking and billing.`, + subtitle: `${requirements.progress.completed} of ${requirements.progress.total} details added — finish to unlock bookings, tracking and billing.`, cta: "Continue onboarding", }; } @@ -90,28 +98,23 @@ function ProgressRing({ pct }: { pct: number }) { /** * Prominent banner shown on onboarding-allowed pages after the wizard is - * dismissed. It reads the company profile directly so it stays aware of real - * progress: a percentage ring and the copy adapt as fields get filled, and the - * whole banner disappears once every required detail is complete. + * dismissed. Progress and copy are read straight from the backend's onboarding + * requirements, so the banner always agrees with the wizard about what's left. */ export default function OnboardingResumeBanner({ onResume, }: OnboardingResumeBannerProps) { - const profileQuery = useQuery( - api.companies.getProfile.queryOptions({ retry: false }), + const requirementsQuery = useQuery( + api.companies.onboardingRequirements.queryOptions({ retry: false }), ); - const completion = getProfileCompletion(profileQuery.data); - - // Reliably step aside once the user has genuinely finished onboarding. - if (!profileQuery.isPending && completion.isComplete) return null; - - const pct = Math.round((completion.completed / completion.total) * 100); - const { title, subtitle, cta } = getCopy( - completion, - pct, - profileQuery.isPending, - ); + const requirements = requirementsQuery.data; + const { completed, total } = requirements?.progress ?? { + completed: 0, + total: 0, + }; + const pct = total > 0 ? Math.round((completed / total) * 100) : 0; + const { title, subtitle, cta } = getCopy(requirements, pct); return (
@@ -143,3 +146,46 @@ export default function OnboardingResumeBanner({
); } + +/** + * Shown once onboarding is submitted but the company's operational profiles are + * still being reviewed. Communicates that approval is per-profile and that + * bookings unlock as each profile is cleared. Self-hides when nothing is pending. + */ +export function AccountReviewBanner() { + const { company } = useAuth(); + const profiles = company?.company?.companyProfiles ?? []; + const pending = profiles.filter((p) => p.status === "pending"); + const approved = profiles.filter((p) => p.status === "active"); + + if (profiles.length === 0 || pending.length === 0) return null; + + const pendingLabel = pending + .map((p) => p.type.replace(/_/g, " ")) + .join(", "); + + return ( +
+
+
+ + + + + + Your account is under review + + + We're reviewing your {pendingLabel}{" "} + {pending.length === 1 ? "profile" : "profiles"}. You can create + bookings under a profile as soon as it's approved. + + +
+ + {approved.length} of {profiles.length} approved + +
+
+ ); +} 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 b25f6f12e..845453200 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -14,8 +14,11 @@ import { ArrowRight, Building2, CheckCircle2, + Clock, FileText, Globe2, + PartyPopper, + ShieldCheck, UploadCloud, User, UserCheck, @@ -142,7 +145,7 @@ export default function OnboardingWizardDialog({ onClose, }: OnboardingWizardDialogProps) { const queryClient = useQueryClient(); - const { user, company, onboardingStep } = useAuth(); + const { user, company, onboardingStep, onboardingCompleted } = useAuth(); const existingProfiles = company?.company?.companyProfiles ?? []; const companyAlreadyStarted = Boolean(company?.company?.id); @@ -174,6 +177,10 @@ export default function OnboardingWizardDialog({ // Mirror of CompanyProfileForm's active step so the global header + progress // pill can reflect it (the form no longer renders its own stepper). const [formStep, setFormStep] = useState(resumeFormStep); + // Once submission succeeds we swap the whole wizard body for a congratulations + // panel, and keep the modal open (the gate would otherwise tear it down the + // moment onboardingCompleted flips true). + const [completed, setCompleted] = useState(false); // Saved profile data, for rehydrating the form fields after a refresh. const profileQuery = useQuery( @@ -184,6 +191,19 @@ export default function OnboardingWizardDialog({ }), ); + // Server-driven onboarding requirements: the backend decides which document + // set applies (by nationality) and what's still outstanding, so the client + // never makes that choice itself. This is the heavier "second request" — it's + // only issued while onboarding is still incomplete; once the getInfo flag says + // we're done, it never fires. + const requirementsQuery = useQuery( + api.companies.onboardingRequirements.queryOptions({ + enabled: companyAlreadyStarted && !onboardingCompleted, + retry: false, + refetchOnWindowFocus: false, + }), + ); + const refreshInfo = useCallback( () => queryClient.invalidateQueries({ @@ -225,7 +245,10 @@ export default function OnboardingWizardDialog({ } return api.companies.completeOnboarding.call(); }, - onSuccess: refreshInfo, + onSuccess: async () => { + await refreshInfo(); + setCompleted(true); + }, onError: (err) => setStartError(extractApiError(err).message), }); @@ -329,8 +352,22 @@ export default function OnboardingWizardDialog({ const stepMeta = STEP_META[activeStep]; const activeIdx = WIZARD_STEPS.indexOf(activeStep); + // Closing from the congratulations panel also clears the completed flag so a + // future reopen (shouldn't happen once onboarded) starts clean. + const handleClose = useCallback(() => { + if (completed) setCompleted(false); + onClose(); + }, [completed, onClose]); + + // Prefer the backend-resolved document code; fall back to the local mapping + // only until the requirements query lands (the documents step is reached well + // after the draft — and thus the requirements — exist). + const resolvedDocumentSettingCode = + requirementsQuery.data?.documentSettingCode ?? + documentSettingCode(effectiveNationality); + const formProps = { - documentSettingCode: documentSettingCode(effectiveNationality), + documentSettingCode: resolvedDocumentSettingCode, documentFiles, onDocumentFilesChange: setDocumentFiles, user, @@ -350,11 +387,11 @@ export default function OnboardingWizardDialog({ return ( - - - {stepMeta.icon} - {stepMeta.title} - - - {stepMeta.description} - - - -
+ completed ? null : ( + + + + {stepMeta.icon} + {stepMeta.title} + + + {stepMeta.description} + + + + + ) } > + {completed ? ( + + ) : ( {phase === "nationality" ? ( @@ -438,10 +480,65 @@ export default function OnboardingWizardDialog({ )} + )} ); } +/** + * Replaces the wizard body once onboarding is submitted: congratulates the user + * and sets the expectation that their company is now under review, and that + * bookings unlock per profile as the team approves each one. + */ +function OnboardingCompletePanel({ onClose }: { onClose: () => void }) { + return ( + + + + + + + You're all set! + + Thanks for completing your company profile. Your application has been + submitted and is now with our team for review. + + + + + + + + Each operational profile (importer, exporter, freight forwarder) is + reviewed and approved individually. + + + + + + You can start creating bookings under a profile as soon as it's + approved — we'll let you know the moment that happens. + + + + + + + ); +} + /** * Continuous progress pill: a single rounded track that fills left-to-right as * the user advances, with faint ticks marking each step boundary. diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 6a22ef954..3d30b7d15 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -89,6 +89,7 @@ export const URL_CONSTANTS = { ONBOARDING_START: "/api/companies/onboarding/start", ONBOARDING_STEP: "/api/companies/onboarding-step", ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", + ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements", DASHBOARD: "/api/companies/dashboard", FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info", 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 0f4f4e6a1..c44db9ac3 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -161,6 +161,15 @@ const useAuth = () => { companyInfo?.profile?.onboardingCompleted ?? false; const onboardingStep = companyInfo?.profile?.onboardingStep ?? null; + // Booking is gated on backoffice approval of the active operational profile: + // a customer can only book under a profile once its status is "active". + const activeProfile = + companyInfo?.company?.companyProfiles?.find( + (p) => p.id === activeCompanyProfileId, + ) ?? null; + const activeProfileStatus = activeProfile?.status ?? null; + const canBook = activeProfileStatus === "active"; + /** Refetch everything scoped to the active operational profile. */ const invalidateScopedData = async () => { await Promise.all([ @@ -229,6 +238,8 @@ const useAuth = () => { customer: isAuthenticated ? (companyQuery.data ?? null) : null, activeProfileType, activeCompanyProfileId, + activeProfileStatus, + canBook, companyType, onboardingCompleted, onboardingStep, 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 115351440..41113f6fc 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -912,7 +912,7 @@ export default function CompanyProfileForm({ {step === "documents" ? "Continue" : step === "additional" - ? "Finish onboarding" + ? "Submit for review" : "Save & Continue"} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index 5d7200008..e7e128a10 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { ActionIcon, @@ -25,7 +25,6 @@ import { LayoutList, MoreVertical, Package, - Plus, Search, Train, Wallet, @@ -35,6 +34,7 @@ import { import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal"; import { PayNowButton } from "./payments/PayNowButton"; import { ModeIndicator } from "@/components/ModeIndicator"; +import { NewBookingButton } from "@/components/NewBookingButton"; import { BookingTypeBadge, CargoModeCell, @@ -623,15 +623,7 @@ export default function MyBookings() { Track every cargo booking — from draft to delivery. - + {/* ── Summary stat cards ──────────────────────────────────────── */} @@ -779,17 +771,7 @@ export default function MyBookings() { : "Create your first booking to get started."} {!query && ( - + )} ) : ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index dd76d893a..bd62d1952 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -29,7 +29,7 @@ import { } from "lucide-react"; import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; -import { useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; import { BookingFormInputValues, @@ -63,6 +63,12 @@ export default function NewBookingPage() { api.bookings.referenceData.queryOptions(), ); + // Booking is gated on profile approval: a customer whose active profile isn't + // approved yet is bounced back to the list, where the gate is explained. + if (!auth.isPending && auth.company && !auth.canBook) { + return ; + } + if (!auth.isPending && !auth.company) { return ( ( + "companies", + "onboardingRequirements", + companiesService.getOnboardingRequirements, + ), }, 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 791c0f8de..fd44dc276 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -82,6 +82,47 @@ export interface CompanyInfoResponse { company: CompanyResponse; } +/** A single onboarding document field, as resolved and described by the backend. */ +export interface OnboardingDocumentField { + fileKey: string; + fileLabel: string; + helpText: string | null; + isRequired: boolean; + isMultiple: boolean; + maxFiles: number; + allowedExtensions: string[]; + maxSizeMb: number; + displayOrder: number; + uploaded: boolean; +} + +export interface OnboardingLicenseProfile { + profileId: string; + type: string; + reference: string; + uploaded: boolean; +} + +/** + * Server-driven onboarding requirements. The portal renders this verbatim: the + * backend decides which documents apply (by nationality) and what is still + * outstanding, so the client never hardcodes required fields or document sets. + */ +export interface OnboardingRequirements { + documentSettingCode: string; + nationality: string; + companyInfo: { + complete: boolean; + missingFields: { key: string; label: string }[]; + }; + documents: OnboardingDocumentField[]; + licenseProfiles: OnboardingLicenseProfile[]; + progress: { completed: number; total: number }; + isComplete: boolean; + onboardingCompleted: boolean; + outstanding: string[]; +} + export interface CompanyProfileInput { type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter"; businessLicense?: string; @@ -226,6 +267,14 @@ export const companiesService = { return unwrap(response.data); }, + /** Server-driven list of outstanding onboarding requirements + completeness. */ + getOnboardingRequirements: async (): Promise => { + const response = await client.get>( + URL_CONSTANTS.COMPANIES_API.ONBOARDING_REQUIREMENTS, + ); + return unwrap(response.data); + }, + uploadDocuments: async ( companyId: string, files: Record, diff --git a/apps/edr-freight-web/portal/src/utils/profileCompletion.ts b/apps/edr-freight-web/portal/src/utils/profileCompletion.ts deleted file mode 100644 index 5e95af40d..000000000 --- a/apps/edr-freight-web/portal/src/utils/profileCompletion.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { ProfileResponse } from "@/types/profile"; - -/** - * Company-profile fields that must be filled before onboarding is considered - * finished. Shared between the portal SetupPrompt and the onboarding banner so - * both agree on what "done" means. - */ -export const REQUIRED_PROFILE_FIELDS: (keyof ProfileResponse)[] = [ - "companyEmail", - "companyPhone", - "companyAddress", - "fanNumber", - "contactPersonName", - "contactPersonPhone", - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", -]; - -export interface ProfileCompletion { - /** Number of required fields that are filled in. */ - completed: number; - /** Total number of required fields. */ - total: number; - /** Required fields still missing a value. */ - missing: (keyof ProfileResponse)[]; - /** True when every required field is filled. */ - isComplete: boolean; -} - -/** Breaks a profile down into how much of the required setup is complete. */ -export function getProfileCompletion( - profile?: ProfileResponse | null, -): ProfileCompletion { - const total = REQUIRED_PROFILE_FIELDS.length; - if (!profile) { - return { - completed: 0, - total, - missing: [...REQUIRED_PROFILE_FIELDS], - isComplete: false, - }; - } - const missing = REQUIRED_PROFILE_FIELDS.filter((field) => !profile[field]); - return { - completed: total - missing.length, - total, - missing, - isComplete: missing.length === 0, - }; -} - -/** Convenience predicate kept for existing call sites. */ -export function isProfileIncomplete(profile?: ProfileResponse | null): boolean { - return !getProfileCompletion(profile).isComplete; -} From 66d3ae8093aa6ce9603965d16cef88264ba72586 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 12:18:31 +0000 Subject: [PATCH 05/11] fix: onboarding --- .../companies/company-profile.repository.ts | 6 +- .../onboarding/OnboardingWizardDialog.tsx | 14 +- .../src/pages/accounts/CompanyProfileForm.tsx | 252 +++++++++--------- 3 files changed, 149 insertions(+), 123 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index bc2d95224..15aec5ac6 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -30,7 +30,11 @@ export class CompanyProfileRepository extends BaseRepository { } async generateReference(type: ProfileType): Promise { - const seqName = SEQUENCE_MAP[type]; + // The sequences live in the same schema as the entity (e.g. "freight"), but + // the connection's search_path is "public" — so the sequence MUST be + // schema-qualified or `nextval` fails with "relation does not exist". + const schema = this.repository.metadata.schema ?? "public"; + const seqName = `"${schema}".${SEQUENCE_MAP[type]}`; const result = await this.repository.query( `SELECT nextval('${seqName}') AS next_id`, ); 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 845453200..a34676539 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -149,6 +149,10 @@ export default function OnboardingWizardDialog({ const existingProfiles = company?.company?.companyProfiles ?? []; const companyAlreadyStarted = Boolean(company?.company?.id); + // A draft can exist with zero operational profiles (e.g. an interrupted start). + // Such a draft must re-run role selection so the profiles actually get created + // — otherwise the user is stuck with nothing to upload a license against. + const hasOperationalProfiles = existingProfiles.length > 0; const savedNationality = (company?.company?.nationality as CompanyNationality | null) ?? null; @@ -160,7 +164,11 @@ export default function OnboardingWizardDialog({ // Phases: nationality → role → form. If a draft already exists, resume // straight into the form with nationality + roles pre-selected. const [phase, setPhase] = useState<"nationality" | "role" | "form">( - companyAlreadyStarted ? "form" : "nationality", + companyAlreadyStarted + ? hasOperationalProfiles + ? "form" + : "role" + : "nationality", ); const [nationality, setNationality] = useState( savedNationality, @@ -283,7 +291,9 @@ export default function OnboardingWizardDialog({ resumedRef.current = true; setRoles(existingProfiles.map((p) => p.type)); setNationality(savedNationality); - setPhase("form"); + // Resume into the form only when profiles exist; otherwise send the user to + // role selection so the missing operational profiles get created. + setPhase(hasOperationalProfiles ? "form" : "role"); const idx = FORM_STEPS.indexOf(resumeFormStep); if (idx > furthestIdxRef.current) furthestIdxRef.current = idx; // eslint-disable-next-line react-hooks/exhaustive-deps 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 41113f6fc..67fd0d9ca 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -1,7 +1,6 @@ import { Alert, Button, - Checkbox, Divider, Group, Loader, @@ -12,12 +11,7 @@ import { } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; -import { - AlertCircle, - ArrowLeft, - ArrowRight, - UserCheck, -} from "lucide-react"; +import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -86,11 +80,11 @@ const onboardingSchema = z.object({ .string() .min(1, "Contact person phone is required") .refine(isValidPhone, "Enter a valid phone number"), - generalManagerName: z.string().min(1, "GM name is required"), - generalManagerEmail: z.string().email("Invalid GM email"), + generalManagerName: z.string().min(1, "Manager name is required"), + generalManagerEmail: z.string().email("Invalid Manager email"), generalManagerPhone: z .string() - .min(1, "GM phone is required") + .min(1, "Manager phone is required") .refine(isValidPhone, "Enter a valid phone number"), poaName: z.string().optional(), poaPhone: z @@ -171,7 +165,10 @@ 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 { +function stepPayload( + step: CompanyStep, + d: FormData, +): Partial { switch (step) { case "company": return { @@ -262,6 +259,20 @@ function toFormValues(p: ProfileResponse): FormData { }; } +/** A single read-only registration value rendered as a label/value pair. */ +function ReadOnlyField({ label, value }: { label: string; value?: string }) { + return ( + + + {label} + + + {value && value.trim() ? value : "—"} + + + ); +} + export default function CompanyProfileForm({ documentSettingCode, documentFiles: controlledFiles, @@ -389,6 +400,20 @@ export default function CompanyProfileForm({ values: rehydrate ? toFormValues(rehydrate) : undefined, }); + // eTrade carries no email, so the company/contact email fields start blank. + // Seed them from the registering user's account email — but only while empty, + // so a typed or rehydrated value is never overwritten. + useEffect(() => { + if (!user?.email) return; + if (!watch("companyEmail")) { + setValue("companyEmail", user.email, { shouldValidate: true }); + } + if (!watch("contactPersonEmail")) { + setValue("contactPersonEmail", user.email); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.email, rehydrate]); + // The business owner/manager pulled from eTrade — powers "Use owner as // manager" on the General Manager step. Null until a TIN lookup succeeds. const [etradeOwner, setEtradeOwner] = useState<{ @@ -396,11 +421,6 @@ export default function CompanyProfileForm({ phone: string; } | null>(null); - // Mirror the two "copy from previous person" checkboxes so they can be - // re-toggled (re-checking re-pulls the latest values). - const [gmIsContact, setGmIsContact] = useState(false); - const [contactIsPoa, setContactIsPoa] = useState(false); - const handleETradeDataLoaded = (data: CompanyRegistrationData) => { // Company name comes from the eTrade manager/owner name on the license. if (data.managerName) { @@ -457,19 +477,19 @@ export default function CompanyProfileForm({ }); }; - /** Copy the General Manager into the Contact Person fields (toggleable). */ - const toggleGmAsContact = (checked: boolean) => { - setGmIsContact(checked); - if (!checked) return; - setValue("contactPersonName", watch("generalManagerName")); + /** Copy the General Manager into the Contact Person fields (still editable). */ + const useGmAsContact = () => { + setValue("contactPersonName", watch("generalManagerName"), { + shouldValidate: true, + }); setValue("contactPersonEmail", watch("generalManagerEmail")); - setValue("contactPersonPhone", watch("generalManagerPhone")); + setValue("contactPersonPhone", watch("generalManagerPhone"), { + shouldValidate: true, + }); }; - /** Copy the Contact Person into the PoA fields (toggleable, still editable). */ - const toggleContactAsPoa = (checked: boolean) => { - setContactIsPoa(checked); - if (!checked) return; + /** Copy the Contact Person into the PoA fields (still editable). */ + const useContactAsPoa = () => { setValue("poaName", watch("contactPersonName")); setValue("poaEmail", watch("contactPersonEmail")); setValue("poaPhone", watch("contactPersonPhone")); @@ -477,6 +497,25 @@ export default function CompanyProfileForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); + // Registration + address details come straight from the eTrade lookup and are + // not user-editable — only shown once a TIN lookup (or rehydration) has filled + // them in. We watch the values so the read-only display reflects the latest. + const registration = watch([ + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewalDate", + "renewedFrom", + "renewedTo", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + "etradePhone", + ]); + const hasRegistrationDetails = registration.some((v) => v && v.trim()); + // Single source of truth for step sequence — navigation, labels and the // progress bar all derive from this so adding/removing a step is one edit. const stepOrder: CompanyStep[] = [ @@ -606,51 +645,41 @@ export default function CompanyProfileForm({ /> - <> + {hasRegistrationDetails && ( + <> - - Registration Details - + + + Registration Details + + + from eTrade · read-only + + - - - - - - - - - - @@ -658,47 +687,15 @@ export default function CompanyProfileForm({ Address Information - - - - - - - - - - + + + + + + + )} )} @@ -746,15 +743,22 @@ export default function CompanyProfileForm({ {step === "contact" && ( <> - - Contact Person - - toggleGmAsContact(e.currentTarget.checked)} - /> + + + Contact Person + + {watch("generalManagerName") && ( + + )} + - - Power of Attorney details are optional. Fill them in if you have - them, or skip to continue. - - toggleContactAsPoa(e.currentTarget.checked)} - /> + + + Power of Attorney details are optional. Fill them in if you + have them, or skip to continue. + + {watch("contactPersonName") && ( + + )} + {})} + onChange={onLicenseChange ?? (() => { })} /> )} @@ -902,9 +914,9 @@ export default function CompanyProfileForm({ loading={isPending || saving} rightSection={ !isPending && - !saving && - step !== "additional" && - step !== "documents" ? ( + !saving && + step !== "additional" && + step !== "documents" ? ( ) : undefined } From b31b27ce52184df74ebe3574b490711dcab7e62d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 12:45:10 +0000 Subject: [PATCH 06/11] fix: the company onboading flow --- .../src/pages/accounts/CompanyProfileForm.tsx | 119 ++++++++++++------ 1 file changed, 81 insertions(+), 38 deletions(-) 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 67fd0d9ca..8675f66d0 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -63,11 +63,13 @@ const onboardingSchema = z.object({ renewedFrom: z.string().optional(), renewalDate: z.string().optional(), renewedTo: z.string().optional(), - region: z.string().optional(), - zone: z.string().optional(), - woreda: z.string().optional(), - kebele: z.string().optional(), - houseNo: z.string().optional(), + // Address fields are user-entered and required (the registration/license + // fields above are read-only confirmations pulled from eTrade). + region: z.string().min(1, "Region is required"), + zone: z.string().min(1, "Zone is required"), + woreda: z.string().min(1, "Woreda is required"), + kebele: z.string().min(1, "Kebele is required"), + houseNo: z.string().min(1, "House number is required"), etradePhone: z.string().optional(), contactPersonName: z.string().min(1, "Contact person name is required"), contactPersonPosition: z.string().optional(), @@ -414,6 +416,22 @@ export default function CompanyProfileForm({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [user?.email, rehydrate]); + // Keep the (hidden, derived) company address in sync with the editable address + // fields — so it reflects both the eTrade auto-fill and any later user edits, + // instead of only whatever was composed at lookup time. + const region = watch("region"); + const zone = watch("zone"); + const woreda = watch("woreda"); + const kebele = watch("kebele"); + const houseNo = watch("houseNo"); + useEffect(() => { + const composed = [houseNo, kebele, woreda, zone, region] + .filter((part) => part && part.trim()) + .join(", "); + setValue("companyAddress", composed); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [region, zone, woreda, kebele, houseNo]); + // The business owner/manager pulled from eTrade — powers "Use owner as // manager" on the General Manager step. Null until a TIN lookup succeeds. const [etradeOwner, setEtradeOwner] = useState<{ @@ -441,18 +459,9 @@ export default function CompanyProfileForm({ "etradePhone", toEthiopianE164(data.regularPhone || data.mobilePhone), ); - - // Compose a readable company address from the granular eTrade parts. - const addressParts = [ - data.houseNo, - data.kebele, - data.woreda, - data.zone, - data.region, - ].filter((part) => part && part.trim()); - if (addressParts.length) { - setValue("companyAddress", addressParts.join(", ")); - } + // companyAddress is composed reactively from the address fields below, so + // setting region/zone/woreda/kebele/houseNo above is enough — no need to + // compose it here. // Pre-fill the company contact phone from eTrade's mobile number. const mobile = toEthiopianE164(data.mobilePhone || data.regularPhone); @@ -497,9 +506,10 @@ export default function CompanyProfileForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); - // Registration + address details come straight from the eTrade lookup and are - // not user-editable — only shown once a TIN lookup (or rehydration) has filled - // them in. We watch the values so the read-only display reflects the latest. + // The registration/license details come straight from the eTrade lookup and + // are not user-editable — shown as a read-only confirmation once a TIN lookup + // (or rehydration) has filled them in. The address fields below are separate: + // user-entered and required. We watch the values so the display stays current. const registration = watch([ "licenceNumber", "statusDescription", @@ -507,12 +517,6 @@ export default function CompanyProfileForm({ "renewalDate", "renewedFrom", "renewedTo", - "region", - "zone", - "woreda", - "kebele", - "houseNo", - "etradePhone", ]); const hasRegistrationDetails = registration.some((v) => v && v.trim()); @@ -682,20 +686,59 @@ export default function CompanyProfileForm({ value={watch("renewedTo")} /> - - - Address Information - - - - - - - - - )} + + + + Address Information + + + + + + + + + + + + + )} From 2261675e2191b7dc6434598068572f5274615c43 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 12:48:42 +0000 Subject: [PATCH 07/11] chore: add api errors --- .../onboarding/OnboardingWizardDialog.tsx | 4 ++++ .../src/pages/accounts/CompanyProfileForm.tsx | 14 ++++++++++++++ 2 files changed, 18 insertions(+) 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 a34676539..a12c959c4 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -393,6 +393,10 @@ export default function OnboardingWizardDialog({ roleProfiles, licenseFiles, onLicenseChange: setLicenseFiles, + // Surface a failed final submit (license/document upload or complete) inside + // the form — otherwise the server message (e.g. a 500) would be invisible on + // the submit step. + submitError: phase === "form" ? startError : null, }; return ( 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 8675f66d0..80d2b4db3 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -292,6 +292,7 @@ export default function CompanyProfileForm({ roleProfiles, licenseFiles, onLicenseChange, + submitError, }: { documentSettingCode: string; documentFiles?: Record; @@ -319,6 +320,8 @@ export default function CompanyProfileForm({ /** Newly-selected license files per profile id. */ licenseFiles?: Record; onLicenseChange?: (value: Record) => void; + /** Server error from the final submit (uploads/complete), shown verbatim. */ + submitError?: string | null; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -934,6 +937,17 @@ export default function CompanyProfileForm({ )} + {submitError && ( + } + title="Couldn't submit your application" + > + {submitError} + + )} + {showBack ? ( - )} + {watch("generalManagerName") && ( + + )} + )} + {step === "verify" && ( + + + + + Verify the contact person + + + + We'll text a one-time code to the contact person's phone to + confirm it's reachable. This is required before you continue. + + + {!contactPhoneE164 ? ( + } + > + Add a valid contact phone number on the previous step first. + + ) : phoneVerified ? ( + } + title="Phone verified" + > + {maskPhone(contactPhoneE164)} has been verified. + + ) : ( + + + + + {maskPhone(contactPhoneE164)} + + + + {!otpSent ? ( + + ) : ( + + + Enter the 6-digit code we sent to{" "} + {maskPhone(contactPhoneE164)}. + + + + + + + + )} + + {otpError && ( + } + > + {otpError} + + )} + + )} + + )} + {step === "poa" && ( <> @@ -966,7 +1205,8 @@ export default function CompanyProfileForm({ disabled={ isPending || saving || - (step === "documents" && !hasDocuments && loadingDocuments) + (step === "documents" && !hasDocuments && loadingDocuments) || + (step === "verify" && !phoneVerified) } loading={isPending || saving} rightSection={ @@ -978,7 +1218,7 @@ export default function CompanyProfileForm({ ) : undefined } > - {step === "documents" + {step === "documents" || step === "verify" ? "Continue" : step === "additional" ? "Submit for review" diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index d95949bb9..e9a84dd93 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -34,7 +34,8 @@ export interface SignupResponse { export interface OtpPayload { phone: string; - otp: string; + /** Required on verify; omitted on send (the server generates the code). */ + otp?: string; } export interface OtpResponse { diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index 951a1f129..3d3f2bad6 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -29,6 +29,8 @@ export interface ProfileResponse { contactPersonPosition: string | null; contactPersonEmail: string | null; contactPersonPhone: string | null; + /** Phone that passed SMS OTP verification (resumes the verify step's state). */ + contactVerifiedPhone: string | null; generalManagerName: string | null; generalManagerEmail: string | null; generalManagerPhone: string | null; @@ -66,6 +68,7 @@ export interface UpdateProfilePayload { contactPersonPosition?: string; contactPersonEmail?: string; contactPersonPhone?: string; + contactVerifiedPhone?: string; generalManagerName?: string; generalManagerEmail?: string; generalManagerPhone?: string;