mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat: centralized the user onboaridn requriements
This commit is contained in:
@@ -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<OnboardingRequirementsResponseDto> {
|
||||
return this.companiesService.getOnboardingRequirements(user.id);
|
||||
}
|
||||
|
||||
@Post("onboarding/complete")
|
||||
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
|
||||
async completeOnboarding(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<Company> {
|
||||
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<OnboardingRequirementsResponseDto> {
|
||||
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<void> {
|
||||
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.
|
||||
|
||||
@@ -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<OnboardingRequirementsResponseDto, never>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 }) => <CompanyStatusBadge status={row.original.status} />,
|
||||
cell: ({ row }) => {
|
||||
const pending = (row.original.companyProfiles ?? []).filter(
|
||||
(p) => p.status === "pending",
|
||||
).length;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<CompanyStatusBadge status={row.original.status} />
|
||||
{pending > 0 ? (
|
||||
<Tooltip
|
||||
label={`${pending} profile${pending > 1 ? "s" : ""} awaiting approval`}
|
||||
>
|
||||
<Badge color="yellow" variant="light" size="sm" radius="sm">
|
||||
{pending} pending
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
@@ -216,6 +240,20 @@ export default function CustomersPage() {
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={statusFilter || "all"}
|
||||
onChange={(v) => {
|
||||
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" },
|
||||
]}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
|
||||
@@ -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 && (
|
||||
<OnboardingResumeBanner onResume={openWizard} />
|
||||
)}
|
||||
{!needsOnboarding && <AccountReviewBanner />}
|
||||
<Outlet />
|
||||
<OnboardingWizardDialog
|
||||
opened={needsOnboarding && wizardOpen}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Box, Button, Tooltip } from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Lock, Plus } from "lucide-react";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
|
||||
interface NewBookingButtonProps {
|
||||
label?: string;
|
||||
size?: string;
|
||||
mt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* New-booking entry point that respects approval status: a customer can only
|
||||
* create bookings under a profile once the backoffice has approved it. While the
|
||||
* active profile is pending the button is disabled with an explanation, so the
|
||||
* gate is communicated rather than silently failing at submit time.
|
||||
*/
|
||||
export function NewBookingButton({
|
||||
label = "New booking",
|
||||
size,
|
||||
mt,
|
||||
}: NewBookingButtonProps) {
|
||||
const { canBook, activeProfileStatus } = useAuth();
|
||||
|
||||
if (!canBook) {
|
||||
const message =
|
||||
activeProfileStatus === "pending"
|
||||
? "Your profile is awaiting approval. You'll be able to create bookings as soon as it's approved."
|
||||
: "Bookings aren't available for this profile yet.";
|
||||
return (
|
||||
<Tooltip label={message} multiline w={250} withArrow position="bottom-end">
|
||||
<Box mt={mt}>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size={size}
|
||||
disabled
|
||||
leftSection={<Lock size={16} />}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
component={Link}
|
||||
to="/bookings/new"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size={size}
|
||||
mt={mt}
|
||||
leftSection={<Plus size={16} />}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="bg-gradient-to-r from-[#065f46] via-[#0A6F4D] to-[#0A8A5F] px-6 py-4 shadow-md">
|
||||
@@ -143,3 +146,46 @@ export default function OnboardingResumeBanner({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
|
||||
<Clock size={18} />
|
||||
</span>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-semibold text-amber-900">
|
||||
Your account is under review
|
||||
</span>
|
||||
<span className="text-xs text-amber-800">
|
||||
We're reviewing your {pendingLabel}{" "}
|
||||
{pending.length === 1 ? "profile" : "profiles"}. You can create
|
||||
bookings under a profile as soon as it's approved.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-amber-800">
|
||||
{approved.length} of {profiles.length} approved
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<FormStep>(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 (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton
|
||||
opened={opened || completed}
|
||||
onClose={handleClose}
|
||||
withCloseButton={!completed}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape
|
||||
closeOnEscape={!completed}
|
||||
size={720}
|
||||
radius="lg"
|
||||
padding="xl"
|
||||
@@ -371,20 +408,25 @@ export default function OnboardingWizardDialog({
|
||||
}
|
||||
}}
|
||||
title={
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Group gap="sm" mb={4}>
|
||||
{stepMeta.icon}
|
||||
<Title order={3}>{stepMeta.title}</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm">
|
||||
{stepMeta.description}
|
||||
</Text>
|
||||
</Box>
|
||||
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
|
||||
</Stack>
|
||||
completed ? null : (
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Group gap="sm" mb={4}>
|
||||
{stepMeta.icon}
|
||||
<Title order={3}>{stepMeta.title}</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm">
|
||||
{stepMeta.description}
|
||||
</Text>
|
||||
</Box>
|
||||
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
>
|
||||
{completed ? (
|
||||
<OnboardingCompletePanel onClose={handleClose} />
|
||||
) : (
|
||||
<Stack gap="xl">
|
||||
|
||||
{phase === "nationality" ? (
|
||||
@@ -438,10 +480,65 @@ export default function OnboardingWizardDialog({
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Stack gap="lg" align="center" py="md" ta="center">
|
||||
<Box
|
||||
className="flex h-16 w-16 items-center justify-center rounded-full"
|
||||
style={{ background: "var(--mantine-color-edr-green-1)" }}
|
||||
>
|
||||
<PartyPopper size={32} className="text-[var(--mantine-color-edr-green-7)]" />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Title order={3}>You're all set!</Title>
|
||||
<Text c="edr-muted" size="sm" mt={4} maw={460}>
|
||||
Thanks for completing your company profile. Your application has been
|
||||
submitted and is now with our team for review.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
gap="sm"
|
||||
w="100%"
|
||||
maw={460}
|
||||
p="md"
|
||||
className="rounded-lg"
|
||||
style={{ background: "var(--mantine-color-edr-green-0)" }}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Clock size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" />
|
||||
<Text size="sm" ta="left">
|
||||
Each operational profile (importer, exporter, freight forwarder) is
|
||||
reviewed and approved individually.
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<ShieldCheck size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" />
|
||||
<Text size="sm" ta="left">
|
||||
You can start creating bookings under a profile as soon as it's
|
||||
approved — we'll let you know the moment that happens.
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
|
||||
Go to my dashboard
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuous progress pill: a single rounded track that fills left-to-right as
|
||||
* the user advances, with faint ticks marking each step boundary.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -912,7 +912,7 @@ export default function CompanyProfileForm({
|
||||
{step === "documents"
|
||||
? "Continue"
|
||||
: step === "additional"
|
||||
? "Finish onboarding"
|
||||
? "Submit for review"
|
||||
: "Save & Continue"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -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.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/bookings/new"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
>
|
||||
New booking
|
||||
</Button>
|
||||
<NewBookingButton label="New booking" />
|
||||
</Group>
|
||||
|
||||
{/* ── Summary stat cards ──────────────────────────────────────── */}
|
||||
@@ -779,17 +771,7 @@ export default function MyBookings() {
|
||||
: "Create your first booking to get started."}
|
||||
</Text>
|
||||
{!query && (
|
||||
<Button
|
||||
component={Link}
|
||||
to="/bookings/new"
|
||||
size="sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
mt="md"
|
||||
leftSection={<Plus size={15} />}
|
||||
>
|
||||
Create first booking
|
||||
</Button>
|
||||
<NewBookingButton label="Create first booking" size="sm" mt="md" />
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
|
||||
@@ -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 <Navigate to="/bookings" replace />;
|
||||
}
|
||||
|
||||
if (!auth.isPending && !auth.company) {
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
CompanyProfileResponse,
|
||||
CreateCompanyPayload,
|
||||
DashboardSummary,
|
||||
OnboardingRequirements,
|
||||
ProfileTypeValue,
|
||||
} from "./companies.service";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
@@ -171,6 +172,12 @@ export const api = {
|
||||
"completeOnboarding",
|
||||
companiesService.completeOnboarding,
|
||||
),
|
||||
|
||||
onboardingRequirements: endpoint<void, OnboardingRequirements>(
|
||||
"companies",
|
||||
"onboardingRequirements",
|
||||
companiesService.getOnboardingRequirements,
|
||||
),
|
||||
},
|
||||
|
||||
bookings: {
|
||||
|
||||
@@ -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<OnboardingRequirements> => {
|
||||
const response = await client.get<ApiResponse<OnboardingRequirements>>(
|
||||
URL_CONSTANTS.COMPANIES_API.ONBOARDING_REQUIREMENTS,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
uploadDocuments: async (
|
||||
companyId: string,
|
||||
files: Record<string, File | File[] | 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;
|
||||
}
|
||||
Reference in New Issue
Block a user