From 9b48955eaf1b87f65e70f6015f3c7d92a182db6f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 11:50:28 +0000 Subject: [PATCH] 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; -}