mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 13:38:20 +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 { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||||
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
|
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
|
||||||
import { CompanyStatsResponseDto } from "./dto/company-stats-response.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 { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
||||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||||
@@ -221,6 +222,17 @@ export class CompaniesController {
|
|||||||
await this.companiesService.setOnboardingStep(user.id, dto.step);
|
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")
|
@Post("onboarding/complete")
|
||||||
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
|
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
|
||||||
async completeOnboarding(
|
async completeOnboarding(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
|||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { FilesModule } from "../files/files.module";
|
import { FilesModule } from "../files/files.module";
|
||||||
|
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
|
||||||
import { MinioModule } from "../minio/minio.module";
|
import { MinioModule } from "../minio/minio.module";
|
||||||
import { CompaniesController } from "./companies.controller";
|
import { CompaniesController } from "./companies.controller";
|
||||||
import { CompaniesService } from "./companies.service";
|
import { CompaniesService } from "./companies.service";
|
||||||
@@ -20,6 +21,7 @@ import { ETradeService } from "./services/etrade.service";
|
|||||||
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
||||||
HttpModule,
|
HttpModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
|
FileUploadSettingsModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
],
|
],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { CompaniesRepository } from "./companies.repository";
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||||
import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
||||||
import { MinioService } from "../minio/minio.service";
|
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 { ETradeService } from "./services/etrade.service";
|
||||||
|
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||||
@@ -50,9 +54,67 @@ export class CompaniesService {
|
|||||||
private readonly profilesRepo: ExternalProfileRepository,
|
private readonly profilesRepo: ExternalProfileRepository,
|
||||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||||
private readonly etradeService: ETradeService,
|
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> {
|
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -624,6 +686,17 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
if (!updated)
|
if (!updated)
|
||||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,6 +882,100 @@ export class CompaniesService {
|
|||||||
await this.profilesRepo.update(profile.id, { onboardingStep: step });
|
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(
|
async markOnboardingComplete(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
@@ -817,23 +984,21 @@ export class CompaniesService {
|
|||||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
|
|
||||||
const companyId = profile.company?.id ?? profile.companyId;
|
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).
|
const requirements = await this.getOnboardingRequirements(userId);
|
||||||
if (!company.tin || company.tin.startsWith("D")) {
|
if (!requirements.isComplete) {
|
||||||
throw new BadRequestException(
|
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
|
// Send every operational profile in for approval; the company itself becomes
|
||||||
// (stored directly on the profile).
|
// active once the backoffice approves at least one profile.
|
||||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
for (const cp of profiles) {
|
for (const cp of profiles) {
|
||||||
if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) {
|
if (cp.status !== ProfileStatus.Pending) {
|
||||||
throw new BadRequestException(
|
await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending);
|
||||||
`Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -842,11 +1007,30 @@ export class CompaniesService {
|
|||||||
onboardingStep: "done",
|
onboardingStep: "done",
|
||||||
});
|
});
|
||||||
await this.companiesRepo.update(companyId, {
|
await this.companiesRepo.update(companyId, {
|
||||||
status: CompanyStatus.Active,
|
status: CompanyStatus.Pending,
|
||||||
});
|
});
|
||||||
return this.getCompanyInfoByUserId(userId);
|
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
|
* Authorize and resolve a company_profile that must belong to the current
|
||||||
* user's company — used before accepting/returning its license files.
|
* 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 {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
SegmentedControl,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -32,7 +35,7 @@ import {
|
|||||||
} from "@/components/customers";
|
} from "@/components/customers";
|
||||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { Company } from "@/types/customer";
|
import type { Company, CompanyStatus } from "@/types/customer";
|
||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
DataTableFooter,
|
DataTableFooter,
|
||||||
@@ -45,14 +48,17 @@ export default function CustomersPage() {
|
|||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
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(
|
const filter = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
search: debouncedQuery,
|
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: {} }));
|
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
|
||||||
@@ -107,7 +113,25 @@ export default function CustomersPage() {
|
|||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
header: "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",
|
id: "contact",
|
||||||
@@ -216,6 +240,20 @@ export default function CustomersPage() {
|
|||||||
style={{ flex: 1, minWidth: "240px" }}
|
style={{ flex: 1, minWidth: "240px" }}
|
||||||
radius="lg"
|
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">
|
<Text size="sm" c="dimmed">
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AppLayout, type SidebarItem } from "@/components/AppLayout";
|
import { AppLayout, type SidebarItem } from "@/components/AppLayout";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
CalendarCheck,
|
CalendarCheck,
|
||||||
Home,
|
Home,
|
||||||
@@ -8,7 +9,6 @@ import {
|
|||||||
Receipt,
|
Receipt,
|
||||||
Settings,
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Navigate,
|
Navigate,
|
||||||
@@ -19,9 +19,11 @@ import {
|
|||||||
useNavigate,
|
useNavigate,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
|
|
||||||
import useAuth from "./hooks/useAuth";
|
import OnboardingResumeBanner, {
|
||||||
import OnboardingResumeBanner from "./components/onboarding/OnboardingResumeBanner";
|
AccountReviewBanner,
|
||||||
|
} from "./components/onboarding/OnboardingResumeBanner";
|
||||||
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
|
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
|
||||||
|
import useAuth from "./hooks/useAuth";
|
||||||
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
||||||
import MyPortalPage from "./pages/MyPortalPage";
|
import MyPortalPage from "./pages/MyPortalPage";
|
||||||
import MySignaturePage from "./pages/MySignaturePage";
|
import MySignaturePage from "./pages/MySignaturePage";
|
||||||
@@ -36,11 +38,11 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
|||||||
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||||
import MyBookings from "./pages/bookings/MyBookings";
|
import MyBookings from "./pages/bookings/MyBookings";
|
||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
import ContractsList from "./pages/contracts/ContractsList";
|
|
||||||
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
|
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
|
||||||
|
import ContractsList from "./pages/contracts/ContractsList";
|
||||||
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
|
||||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||||
|
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||||
|
|
||||||
function FullScreenSpinner() {
|
function FullScreenSpinner() {
|
||||||
@@ -143,9 +145,10 @@ function OnboardingGate() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{needsOnboarding && !wizardOpen && (
|
{needsOnboarding && (
|
||||||
<OnboardingResumeBanner onResume={openWizard} />
|
<OnboardingResumeBanner onResume={openWizard} />
|
||||||
)}
|
)}
|
||||||
|
{!needsOnboarding && <AccountReviewBanner />}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<OnboardingWizardDialog
|
<OnboardingWizardDialog
|
||||||
opened={needsOnboarding && wizardOpen}
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight, Clock } from "lucide-react";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import {
|
import useAuth from "@/hooks/useAuth";
|
||||||
getProfileCompletion,
|
import type { OnboardingRequirements } from "@/services/companies.service";
|
||||||
type ProfileCompletion,
|
|
||||||
} from "@/utils/profileCompletion";
|
|
||||||
|
|
||||||
interface OnboardingResumeBannerProps {
|
interface OnboardingResumeBannerProps {
|
||||||
/** Re-opens the onboarding wizard. */
|
/** Re-opens the onboarding wizard. */
|
||||||
@@ -17,15 +15,16 @@ interface BannerCopy {
|
|||||||
cta: string;
|
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(
|
function getCopy(
|
||||||
completion: ProfileCompletion,
|
requirements: OnboardingRequirements | undefined,
|
||||||
pct: number,
|
pct: number,
|
||||||
isPending: boolean,
|
|
||||||
): BannerCopy {
|
): BannerCopy {
|
||||||
// Until the profile loads, or before anything is filled in, treat it as a
|
// No data yet (or nothing started) — treat it as a fresh start.
|
||||||
// fresh start rather than guessing progress.
|
if (!requirements || requirements.progress.completed === 0) {
|
||||||
if (isPending || completion.completed === 0) {
|
|
||||||
return {
|
return {
|
||||||
title: "Set up your company profile",
|
title: "Set up your company profile",
|
||||||
subtitle: "Unlock bookings, tracking and billing — it only takes a minute.",
|
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) {
|
if (remaining <= 2) {
|
||||||
return {
|
return {
|
||||||
title: `Almost done — you're ${pct}% set up`,
|
title: `Almost done — you're ${pct}% set up`,
|
||||||
subtitle: `Just ${remaining} more ${
|
subtitle: `Just ${remaining} more ${
|
||||||
remaining === 1 ? "detail" : "details"
|
remaining === 1 ? "item" : "items"
|
||||||
} to unlock bookings, tracking and billing.`,
|
} to finish: ${requirements.outstanding.join(", ")}.`,
|
||||||
cta: "Finish onboarding",
|
cta: "Finish onboarding",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `You're ${pct}% set up`,
|
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",
|
cta: "Continue onboarding",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -90,28 +98,23 @@ function ProgressRing({ pct }: { pct: number }) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Prominent banner shown on onboarding-allowed pages after the wizard is
|
* Prominent banner shown on onboarding-allowed pages after the wizard is
|
||||||
* dismissed. It reads the company profile directly so it stays aware of real
|
* dismissed. Progress and copy are read straight from the backend's onboarding
|
||||||
* progress: a percentage ring and the copy adapt as fields get filled, and the
|
* requirements, so the banner always agrees with the wizard about what's left.
|
||||||
* whole banner disappears once every required detail is complete.
|
|
||||||
*/
|
*/
|
||||||
export default function OnboardingResumeBanner({
|
export default function OnboardingResumeBanner({
|
||||||
onResume,
|
onResume,
|
||||||
}: OnboardingResumeBannerProps) {
|
}: OnboardingResumeBannerProps) {
|
||||||
const profileQuery = useQuery(
|
const requirementsQuery = useQuery(
|
||||||
api.companies.getProfile.queryOptions({ retry: false }),
|
api.companies.onboardingRequirements.queryOptions({ retry: false }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const completion = getProfileCompletion(profileQuery.data);
|
const requirements = requirementsQuery.data;
|
||||||
|
const { completed, total } = requirements?.progress ?? {
|
||||||
// Reliably step aside once the user has genuinely finished onboarding.
|
completed: 0,
|
||||||
if (!profileQuery.isPending && completion.isComplete) return null;
|
total: 0,
|
||||||
|
};
|
||||||
const pct = Math.round((completion.completed / completion.total) * 100);
|
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
const { title, subtitle, cta } = getCopy(
|
const { title, subtitle, cta } = getCopy(requirements, pct);
|
||||||
completion,
|
|
||||||
pct,
|
|
||||||
profileQuery.isPending,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gradient-to-r from-[#065f46] via-[#0A6F4D] to-[#0A8A5F] px-6 py-4 shadow-md">
|
<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>
|
</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,
|
ArrowRight,
|
||||||
Building2,
|
Building2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
FileText,
|
FileText,
|
||||||
Globe2,
|
Globe2,
|
||||||
|
PartyPopper,
|
||||||
|
ShieldCheck,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
User,
|
User,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
@@ -142,7 +145,7 @@ export default function OnboardingWizardDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
}: OnboardingWizardDialogProps) {
|
}: OnboardingWizardDialogProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user, company, onboardingStep } = useAuth();
|
const { user, company, onboardingStep, onboardingCompleted } = useAuth();
|
||||||
|
|
||||||
const existingProfiles = company?.company?.companyProfiles ?? [];
|
const existingProfiles = company?.company?.companyProfiles ?? [];
|
||||||
const companyAlreadyStarted = Boolean(company?.company?.id);
|
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
|
// Mirror of CompanyProfileForm's active step so the global header + progress
|
||||||
// pill can reflect it (the form no longer renders its own stepper).
|
// pill can reflect it (the form no longer renders its own stepper).
|
||||||
const [formStep, setFormStep] = useState<FormStep>(resumeFormStep);
|
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.
|
// Saved profile data, for rehydrating the form fields after a refresh.
|
||||||
const profileQuery = useQuery(
|
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(
|
const refreshInfo = useCallback(
|
||||||
() =>
|
() =>
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -225,7 +245,10 @@ export default function OnboardingWizardDialog({
|
|||||||
}
|
}
|
||||||
return api.companies.completeOnboarding.call();
|
return api.companies.completeOnboarding.call();
|
||||||
},
|
},
|
||||||
onSuccess: refreshInfo,
|
onSuccess: async () => {
|
||||||
|
await refreshInfo();
|
||||||
|
setCompleted(true);
|
||||||
|
},
|
||||||
onError: (err) => setStartError(extractApiError(err).message),
|
onError: (err) => setStartError(extractApiError(err).message),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -329,8 +352,22 @@ export default function OnboardingWizardDialog({
|
|||||||
const stepMeta = STEP_META[activeStep];
|
const stepMeta = STEP_META[activeStep];
|
||||||
const activeIdx = WIZARD_STEPS.indexOf(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 = {
|
const formProps = {
|
||||||
documentSettingCode: documentSettingCode(effectiveNationality),
|
documentSettingCode: resolvedDocumentSettingCode,
|
||||||
documentFiles,
|
documentFiles,
|
||||||
onDocumentFilesChange: setDocumentFiles,
|
onDocumentFilesChange: setDocumentFiles,
|
||||||
user,
|
user,
|
||||||
@@ -350,11 +387,11 @@ export default function OnboardingWizardDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened || completed}
|
||||||
onClose={onClose}
|
onClose={handleClose}
|
||||||
withCloseButton
|
withCloseButton={!completed}
|
||||||
closeOnClickOutside={false}
|
closeOnClickOutside={false}
|
||||||
closeOnEscape
|
closeOnEscape={!completed}
|
||||||
size={720}
|
size={720}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
padding="xl"
|
padding="xl"
|
||||||
@@ -371,20 +408,25 @@ export default function OnboardingWizardDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title={
|
title={
|
||||||
<Stack gap="md">
|
completed ? null : (
|
||||||
<Box>
|
<Stack gap="md">
|
||||||
<Group gap="sm" mb={4}>
|
<Box>
|
||||||
{stepMeta.icon}
|
<Group gap="sm" mb={4}>
|
||||||
<Title order={3}>{stepMeta.title}</Title>
|
{stepMeta.icon}
|
||||||
</Group>
|
<Title order={3}>{stepMeta.title}</Title>
|
||||||
<Text c="edr-muted" size="sm">
|
</Group>
|
||||||
{stepMeta.description}
|
<Text c="edr-muted" size="sm">
|
||||||
</Text>
|
{stepMeta.description}
|
||||||
</Box>
|
</Text>
|
||||||
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
|
</Box>
|
||||||
</Stack>
|
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{completed ? (
|
||||||
|
<OnboardingCompletePanel onClose={handleClose} />
|
||||||
|
) : (
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
|
|
||||||
{phase === "nationality" ? (
|
{phase === "nationality" ? (
|
||||||
@@ -438,10 +480,65 @@ export default function OnboardingWizardDialog({
|
|||||||
<CompanyProfileForm {...formProps} />
|
<CompanyProfileForm {...formProps} />
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
)}
|
||||||
</Modal>
|
</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
|
* Continuous progress pill: a single rounded track that fills left-to-right as
|
||||||
* the user advances, with faint ticks marking each step boundary.
|
* 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_START: "/api/companies/onboarding/start",
|
||||||
ONBOARDING_STEP: "/api/companies/onboarding-step",
|
ONBOARDING_STEP: "/api/companies/onboarding-step",
|
||||||
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
|
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
|
||||||
|
ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements",
|
||||||
DASHBOARD: "/api/companies/dashboard",
|
DASHBOARD: "/api/companies/dashboard",
|
||||||
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
|
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
|
||||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
||||||
|
|||||||
@@ -161,6 +161,15 @@ const useAuth = () => {
|
|||||||
companyInfo?.profile?.onboardingCompleted ?? false;
|
companyInfo?.profile?.onboardingCompleted ?? false;
|
||||||
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
|
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. */
|
/** Refetch everything scoped to the active operational profile. */
|
||||||
const invalidateScopedData = async () => {
|
const invalidateScopedData = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -229,6 +238,8 @@ const useAuth = () => {
|
|||||||
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||||
activeProfileType,
|
activeProfileType,
|
||||||
activeCompanyProfileId,
|
activeCompanyProfileId,
|
||||||
|
activeProfileStatus,
|
||||||
|
canBook,
|
||||||
companyType,
|
companyType,
|
||||||
onboardingCompleted,
|
onboardingCompleted,
|
||||||
onboardingStep,
|
onboardingStep,
|
||||||
|
|||||||
@@ -912,7 +912,7 @@ export default function CompanyProfileForm({
|
|||||||
{step === "documents"
|
{step === "documents"
|
||||||
? "Continue"
|
? "Continue"
|
||||||
: step === "additional"
|
: step === "additional"
|
||||||
? "Finish onboarding"
|
? "Submit for review"
|
||||||
: "Save & Continue"}
|
: "Save & Continue"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
LayoutList,
|
LayoutList,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Package,
|
Package,
|
||||||
Plus,
|
|
||||||
Search,
|
Search,
|
||||||
Train,
|
Train,
|
||||||
Wallet,
|
Wallet,
|
||||||
@@ -35,6 +34,7 @@ import {
|
|||||||
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
|
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
|
||||||
import { PayNowButton } from "./payments/PayNowButton";
|
import { PayNowButton } from "./payments/PayNowButton";
|
||||||
import { ModeIndicator } from "@/components/ModeIndicator";
|
import { ModeIndicator } from "@/components/ModeIndicator";
|
||||||
|
import { NewBookingButton } from "@/components/NewBookingButton";
|
||||||
import {
|
import {
|
||||||
BookingTypeBadge,
|
BookingTypeBadge,
|
||||||
CargoModeCell,
|
CargoModeCell,
|
||||||
@@ -623,15 +623,7 @@ export default function MyBookings() {
|
|||||||
Track every cargo booking — from draft to delivery.
|
Track every cargo booking — from draft to delivery.
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
<NewBookingButton label="New booking" />
|
||||||
component={Link}
|
|
||||||
to="/bookings/new"
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<Plus size={16} />}
|
|
||||||
>
|
|
||||||
New booking
|
|
||||||
</Button>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* ── Summary stat cards ──────────────────────────────────────── */}
|
{/* ── Summary stat cards ──────────────────────────────────────── */}
|
||||||
@@ -779,17 +771,7 @@ export default function MyBookings() {
|
|||||||
: "Create your first booking to get started."}
|
: "Create your first booking to get started."}
|
||||||
</Text>
|
</Text>
|
||||||
{!query && (
|
{!query && (
|
||||||
<Button
|
<NewBookingButton label="Create first booking" size="sm" mt="md" />
|
||||||
component={Link}
|
|
||||||
to="/bookings/new"
|
|
||||||
size="sm"
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
mt="md"
|
|
||||||
leftSection={<Plus size={15} />}
|
|
||||||
>
|
|
||||||
Create first booking
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
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 useAuth from "@/hooks/useAuth";
|
||||||
import {
|
import {
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
@@ -63,6 +63,12 @@ export default function NewBookingPage() {
|
|||||||
api.bookings.referenceData.queryOptions(),
|
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) {
|
if (!auth.isPending && !auth.company) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import type {
|
|||||||
CompanyProfileResponse,
|
CompanyProfileResponse,
|
||||||
CreateCompanyPayload,
|
CreateCompanyPayload,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
|
OnboardingRequirements,
|
||||||
ProfileTypeValue,
|
ProfileTypeValue,
|
||||||
} from "./companies.service";
|
} from "./companies.service";
|
||||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||||
@@ -171,6 +172,12 @@ export const api = {
|
|||||||
"completeOnboarding",
|
"completeOnboarding",
|
||||||
companiesService.completeOnboarding,
|
companiesService.completeOnboarding,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
onboardingRequirements: endpoint<void, OnboardingRequirements>(
|
||||||
|
"companies",
|
||||||
|
"onboardingRequirements",
|
||||||
|
companiesService.getOnboardingRequirements,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
bookings: {
|
bookings: {
|
||||||
|
|||||||
@@ -82,6 +82,47 @@ export interface CompanyInfoResponse {
|
|||||||
company: CompanyResponse;
|
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 {
|
export interface CompanyProfileInput {
|
||||||
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
|
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
|
||||||
businessLicense?: string;
|
businessLicense?: string;
|
||||||
@@ -226,6 +267,14 @@ export const companiesService = {
|
|||||||
return unwrap(response.data);
|
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 (
|
uploadDocuments: async (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
files: Record<string, File | File[] | null>,
|
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