Merge pull request #283 from Tria-plc/freight/fix/type-errors

Onboarding Polish and update
This commit is contained in:
yaschalew10
2026-06-25 09:28:46 +03:00
committed by GitHub
34 changed files with 1502 additions and 413 deletions

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Company-profile references are now minted only when a profile is approved
* (status → Active); pending profiles carry NULL. Drop the NOT NULL constraint
* on freight.company_profiles.reference. The existing unique index is kept —
* Postgres treats NULLs as distinct, so multiple pending (NULL) profiles don't
* collide.
*/
export class MakeCompanyProfileReferenceNullable1810000000002
implements MigrationInterface
{
name = "MakeCompanyProfileReferenceNullable1810000000002";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "reference" DROP NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Reinstating NOT NULL requires every row to have a reference; any pending
// (NULL) profiles get a placeholder so the constraint can be re-applied.
await queryRunner.query(
`UPDATE "freight"."company_profiles" SET "reference" = 'PENDING-' || left(replace("id"::text, '-', ''), 12) WHERE "reference" IS NULL`,
);
await queryRunner.query(
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "reference" SET NOT NULL`,
);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner, Table } from "typeorm";
/**
* Create the public.otp_verifications table backing the OTP module
* (OtpVerification entity). One row per phone, holding the latest server-issued
* code and whether that phone has been verified.
*/
export class CreateOtpVerifications1810000000003
implements MigrationInterface
{
name = "CreateOtpVerifications1810000000003";
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable("otp_verifications");
if (exists) return;
await queryRunner.createTable(
new Table({
name: "otp_verifications",
columns: [
{
name: "id",
type: "uuid",
isPrimary: true,
default: "gen_random_uuid()",
},
{ name: "phone", type: "varchar", isUnique: true },
{ name: "otp", type: "varchar" },
{ name: "verified", type: "boolean", default: false },
{ name: "created_at", type: "timestamptz", default: "now()" },
{ name: "updated_at", type: "timestamptz", default: "now()" },
{ name: "deleted_at", type: "timestamptz", isNullable: true },
],
}),
true,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("otp_verifications", true);
}
}

View File

@@ -371,6 +371,17 @@ export class BookingsService {
tradeDirection,
fallbackType,
);
// A customer booking under their own account may only do so once the
// resolved operational profile has been approved by the backoffice. Staff-
// and government-initiated bookings (companyId supplied explicitly) bypass
// this gate.
const customerSelfBooking = !dto.companyId && !!userId;
if (customerSelfBooking && companyProfileId) {
await this.companiesService.assertCompanyProfileApprovedForBooking(
companyProfileId,
);
}
}
const needsConsolidation =

View File

@@ -41,6 +41,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";
@@ -226,6 +227,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(

View File

@@ -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],

View File

@@ -3,6 +3,7 @@ import {
NotFoundException,
ConflictException,
BadRequestException,
ForbiddenException,
} from "@nestjs/common";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
@@ -12,7 +13,10 @@ import {
DashboardScope,
} 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";
@@ -53,9 +57,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) {
@@ -134,15 +196,13 @@ export class CompaniesService {
input.type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(
input.type,
);
// No reference yet — these profiles await backoffice approval, which
// is when the reference is minted (see setCompanyProfileStatus).
await this.companyProfilesRepo.create({
companyId: company.id,
type: input.type,
reference,
businessLicense: input.businessLicense ?? null,
status: ProfileStatus.Active,
status: ProfileStatus.Pending,
});
}
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
@@ -251,12 +311,11 @@ export class CompaniesService {
type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(type);
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
await this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
status: ProfileStatus.Pending,
});
}
}
@@ -527,6 +586,8 @@ export class CompaniesService {
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
if (dto.contactPersonPhone !== undefined)
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
if (dto.contactVerifiedPhone !== undefined)
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
if (dto.generalManagerName !== undefined)
attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined)
@@ -622,12 +683,33 @@ export class CompaniesService {
profileId: string,
status: ProfileStatus,
): Promise<CompanyProfile> {
const updated = await this.companyProfilesRepo.updateStatus(
profileId,
status,
);
const existing = await this.companyProfilesRepo.findById(profileId);
if (!existing)
throw new NotFoundException(`Company profile ${profileId} not found`);
// A reference number is only minted the first time a profile is approved
// (status → Active). Pending/unapproved profiles carry no reference.
const patch: Partial<CompanyProfile> = { status };
if (status === ProfileStatus.Active && !existing.reference) {
patch.reference = await this.companyProfilesRepo.generateReference(
existing.type,
);
}
const updated = await this.companyProfilesRepo.update(profileId, patch);
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;
}
@@ -649,7 +731,7 @@ export class CompaniesService {
const existing = await this.companyProfilesRepo.findByType(companyId, type);
if (existing) {
throw new ConflictException(
`Company already has a ${type} profile (${existing.reference})`,
`Company already has a ${type} profile (${existing.reference ?? "pending approval"})`,
);
}
@@ -813,6 +895,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 }> {
@@ -821,23 +997,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);
}
}
@@ -852,6 +1026,25 @@ export class CompaniesService {
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.

View File

@@ -30,7 +30,11 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
}
async generateReference(type: ProfileType): Promise<string> {
const seqName = SEQUENCE_MAP[type];
// The sequences live in the same schema as the entity (e.g. "freight"), but
// the connection's search_path is "public" — so the sequence MUST be
// schema-qualified or `nextval` fails with "relation does not exist".
const schema = this.repository.metadata.schema ?? "public";
const seqName = `"${schema}".${SEQUENCE_MAP[type]}`;
const result = await this.repository.query(
`SELECT nextval('${seqName}') AS next_id`,
);

View File

@@ -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;
}
}

View File

@@ -34,6 +34,8 @@ export class ProfileResponseDto {
contactPersonPosition: string | null;
contactPersonEmail: string | null;
contactPersonPhone: string | null;
/** Phone that passed SMS OTP verification (drives the verify-step resume). */
contactVerifiedPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
generalManagerPhone: string | null;
@@ -81,6 +83,7 @@ export class ProfileResponseDto {
this.contactPersonPosition = attrs.contactPersonPosition ?? null;
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null;
this.generalManagerName = attrs.generalManagerName ?? null;
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
this.generalManagerPhone = attrs.generalManagerPhone ?? null;

View File

@@ -28,7 +28,7 @@ export class ResponseCompanyProfileDto {
this.id = profile.id;
this.companyId = profile.companyId;
this.type = profile.type;
this.reference = profile.reference;
this.reference = profile.reference ?? '';
this.status = profile.status;
this.businessLicense = profile.businessLicense;
this.licenseFiles = profile.businessLicenseFiles ?? [];

View File

@@ -67,6 +67,16 @@ export class UpdateProfileDto {
@IsValidPhone()
contactPersonPhone?: string;
/**
* The contact-person phone that completed SMS OTP verification. Persisted so
* the onboarding "verify" step can resume its "done" state after a refresh
* (compared against the current contactPersonPhone on the client).
*/
@IsOptional()
@IsString()
@IsValidPhone()
contactVerifiedPhone?: string;
@IsOptional()
@IsString()
generalManagerName?: string;

View File

@@ -40,14 +40,19 @@ export class CompanyProfile extends BaseEntity {
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
type!: ProfileType;
/**
* Official profile reference (e.g. "EX-00001"). Minted only when the profile
* is approved (status → Active); pending/unapproved profiles carry NULL.
* The unique index tolerates this because Postgres treats NULLs as distinct.
* API responses surface it as "" when absent — see ResponseCompanyProfileDto.
*/
@Column({
name: "reference",
type: "varchar",
length: 20,
nullable: false,
unique: true,
nullable: true,
})
reference!: string;
reference!: string | null;
@Column({
name: "status",

View File

@@ -24,13 +24,9 @@ export class OtpController {
@Post("send")
async sendOtp(
@Body("phone")
phone: string,
@Body("otp")
otp: string
phone: string
) {
return this.otpService.sendOtp(
phone,otp
);
return this.otpService.sendOtp(phone);
}
// ---------------------------------------------------------------------------

View File

@@ -29,11 +29,12 @@ export class OtpService {
// Send OTP
// ---------------------------------------------------------------------------
async sendOtp(phone: string, otp: string) {
async sendOtp(phone: string) {
try {
// generate otp
// const otp =
// this.generateOtp();
// The verification code is generated server-side — never supplied by the
// caller — so the OTP stays a secret known only to the server and the
// recipient of the SMS.
const otp = this.generateOtp();
// find existing phone
const existingPhone =

View File

@@ -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>

View File

@@ -1,4 +1,5 @@
import { AppLayout, type SidebarItem } from "@/components/AppLayout";
import { useDisclosure } from "@mantine/hooks";
import {
CalendarCheck,
Clock,
@@ -8,9 +9,7 @@ import {
MapPin,
Receipt,
Settings,
Sparkles,
} from "lucide-react";
import { useDisclosure } from "@mantine/hooks";
import { useEffect, useRef } from "react";
import {
Navigate,
@@ -21,8 +20,11 @@ import {
useNavigate,
} from "react-router-dom";
import useAuth from "./hooks/useAuth";
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";
@@ -37,11 +39,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() {
@@ -146,10 +148,10 @@ function OnboardingGate() {
return (
<>
{needsOnboarding && !wizardOpen && (
{needsOnboarding && (
<OnboardingResumeBanner onResume={openWizard} />
)}
{awaitingApproval && <PendingApprovalBanner />}
{!needsOnboarding && <AccountReviewBanner />}
<Outlet />
<OnboardingWizardDialog
opened={needsOnboarding && wizardOpen}
@@ -159,41 +161,6 @@ function OnboardingGate() {
);
}
/** Slim sticky prompt shown on allowed pages after the wizard is dismissed. */
function OnboardingResumeBanner({ onResume }: { onResume: () => void }) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#0EA371]/20 bg-[#ECF6F1] px-6 py-3">
<div className="flex items-center gap-2">
<Sparkles size={16} className="text-[#0A6F4D]" />
<span className="text-sm font-medium text-[#0A6F4D]">
Finish setting up your company to unlock bookings, tracking and
billing.
</span>
</div>
<button
type="button"
onClick={onResume}
className="rounded-lg bg-[#0EA371] px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Continue onboarding
</button>
</div>
);
}
/** Shown after onboarding while the company awaits backoffice approval. */
function PendingApprovalBanner() {
return (
<div className="flex flex-wrap items-center gap-2 border-b border-amber-300/50 bg-amber-50 px-6 py-3">
<Clock size={16} className="text-amber-700" />
<span className="text-sm font-medium text-amber-800">
Your company is awaiting EDR approval. You can browse, but creating
bookings is disabled until your company is approved.
</span>
</div>
);
}
/** Keeps authenticated users off the login/signup pages. */
function RedirectIfAuthed() {
const { isPending, isAuthenticated } = useAuth();

View File

@@ -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>
);
}

View File

@@ -0,0 +1,191 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Clock } from "lucide-react";
import { api } from "@/services/api";
import useAuth from "@/hooks/useAuth";
import type { OnboardingRequirements } from "@/services/companies.service";
interface OnboardingResumeBannerProps {
/** Re-opens the onboarding wizard. */
onResume: () => void;
}
interface BannerCopy {
title: string;
subtitle: string;
cta: string;
}
/**
* 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(
requirements: OnboardingRequirements | undefined,
pct: number,
): BannerCopy {
// 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.",
cta: "Start onboarding",
};
}
// 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 ? "item" : "items"
} to finish: ${requirements.outstanding.join(", ")}.`,
cta: "Finish onboarding",
};
}
return {
title: `You're ${pct}% set up`,
subtitle: `${requirements.progress.completed} of ${requirements.progress.total} details added — finish to unlock bookings, tracking and billing.`,
cta: "Continue onboarding",
};
}
/** Circular percentage meter that reads at a glance against the dark banner. */
function ProgressRing({ pct }: { pct: number }) {
const size = 56;
const stroke = 5;
const r = (size - stroke) / 2;
const circumference = 2 * Math.PI * r;
const offset = circumference * (1 - pct / 100);
return (
<span className="relative flex shrink-0 items-center justify-center">
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="rgba(255,255,255,0.22)"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="#6ee7b7"
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{ transition: "stroke-dashoffset 600ms ease" }}
/>
</svg>
<span className="absolute text-sm font-bold text-white">{pct}%</span>
</span>
);
}
/**
* Prominent banner shown on onboarding-allowed pages after the wizard is
* 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 requirementsQuery = useQuery(
api.companies.onboardingRequirements.queryOptions({ retry: false }),
);
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">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<ProgressRing pct={pct} />
<span className="flex flex-col gap-0.5">
<span className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#6ee7b7] opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#6ee7b7]" />
</span>
<span className="text-base font-bold tracking-tight text-white">
{title}
</span>
</span>
<span className="text-sm text-white/80">{subtitle}</span>
</span>
</div>
<button
type="button"
onClick={onResume}
className="inline-flex items-center gap-2 rounded-lg bg-white px-5 py-2.5 text-sm font-semibold text-[#0A6F4D] shadow-sm transition-transform hover:scale-[1.02] hover:bg-white/95"
>
{cta}
<ArrowRight size={16} />
</button>
</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>
);
}

View File

@@ -14,8 +14,11 @@ import {
ArrowRight,
Building2,
CheckCircle2,
Clock,
FileText,
Globe2,
PartyPopper,
ShieldCheck,
UploadCloud,
User,
UserCheck,
@@ -43,6 +46,7 @@ type FormStep =
| "company"
| "personnel"
| "contact"
| "verify"
| "poa"
| "documents"
| "additional";
@@ -50,6 +54,7 @@ const FORM_STEPS: FormStep[] = [
"company",
"personnel",
"contact",
"verify",
"poa",
"documents",
"additional",
@@ -90,6 +95,11 @@ const STEP_META: Record<
title: "Contact Person",
description: "Who should we reach out to about this account?",
},
verify: {
icon: <ShieldCheck size={20} />,
title: "Verify Contact Person",
description: "Confirm the contact phone with a one-time SMS code.",
},
poa: {
icon: <FileText size={20} />,
title: "Power of Attorney",
@@ -142,10 +152,14 @@ 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);
// A draft can exist with zero operational profiles (e.g. an interrupted start).
// Such a draft must re-run role selection so the profiles actually get created
// — otherwise the user is stuck with nothing to upload a license against.
const hasOperationalProfiles = existingProfiles.length > 0;
const savedNationality =
(company?.company?.nationality as CompanyNationality | null) ?? null;
@@ -157,7 +171,11 @@ export default function OnboardingWizardDialog({
// Phases: nationality → role → form. If a draft already exists, resume
// straight into the form with nationality + roles pre-selected.
const [phase, setPhase] = useState<"nationality" | "role" | "form">(
companyAlreadyStarted ? "form" : "nationality",
companyAlreadyStarted
? hasOperationalProfiles
? "form"
: "role"
: "nationality",
);
const [nationality, setNationality] = useState<CompanyNationality | null>(
savedNationality,
@@ -174,6 +192,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 +206,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 +260,10 @@ export default function OnboardingWizardDialog({
}
return api.companies.completeOnboarding.call();
},
onSuccess: refreshInfo,
onSuccess: async () => {
await refreshInfo();
setCompleted(true);
},
onError: (err) => setStartError(extractApiError(err).message),
});
@@ -260,7 +298,9 @@ export default function OnboardingWizardDialog({
resumedRef.current = true;
setRoles(existingProfiles.map((p) => p.type));
setNationality(savedNationality);
setPhase("form");
// Resume into the form only when profiles exist; otherwise send the user to
// role selection so the missing operational profiles get created.
setPhase(hasOperationalProfiles ? "form" : "role");
const idx = FORM_STEPS.indexOf(resumeFormStep);
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -329,8 +369,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,
@@ -346,16 +400,20 @@ export default function OnboardingWizardDialog({
roleProfiles,
licenseFiles,
onLicenseChange: setLicenseFiles,
// Surface a failed final submit (license/document upload or complete) inside
// the form — otherwise the server message (e.g. a 500) would be invisible on
// the submit step.
submitError: phase === "form" ? startError : null,
};
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
opened={opened || completed}
onClose={handleClose}
withCloseButton={!completed}
closeOnClickOutside={false}
closeOnEscape
size={1040}
closeOnEscape={!completed}
size={720}
radius="lg"
padding="xl"
centered
@@ -371,20 +429,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 +501,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.

View File

@@ -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`,

View File

@@ -74,13 +74,6 @@ const useAuth = () => {
setCookie("auth-token", res.token, 7);
setCookie("refresh-token", res.refreshToken, 7);
await authQuery.refetch();
const otpCode = res.otp?.split(" ")?.[6] ?? "";
localStorage.setItem("otp", otpCode);
localStorage.setItem("otp-phone", payload.phoneNumber);
localStorage.setItem("otp-email", payload.email);
api.auth.sendOTP
.call({ phone: payload.phoneNumber, otp: otpCode })
.catch(() => { });
return { success: true, data: res };
} catch (err) {
return { success: false, error: extractApiError(err) };
@@ -164,6 +157,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([
@@ -232,6 +234,8 @@ const useAuth = () => {
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
activeProfileType,
activeCompanyProfileId,
activeProfileStatus,
canBook,
companyType,
companyStatus,
isCompanyApproved,

View File

@@ -9,7 +9,6 @@ import {
HelloSection,
InvoicesSection,
RecentActivitySection,
SetupPrompt,
ShipmentsSection,
StatsSection,
} from "./components";
@@ -67,8 +66,6 @@ export default function MyPortalPage() {
</Group>
)}
<SetupPrompt show={!customer} />
<StatsSection
activeBookingsLength={activeBookings.length}
newActiveThisWeek={newActiveThisWeek}

View File

@@ -1,70 +0,0 @@
import { Box, Group, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Truck, AlertTriangle } from "lucide-react";
import { memo } from "react";
import { Link } from "react-router-dom";
import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile";
import { cv } from "../constants";
const REQUIRED_FIELDS: (keyof ProfileResponse)[] = [
"companyEmail",
"companyPhone",
"companyAddress",
"fanNumber",
"contactPersonName",
"contactPersonPhone",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
];
function isProfileIncomplete(profile?: ProfileResponse | null): boolean {
if (!profile) return true;
return REQUIRED_FIELDS.some((field) => !profile[field]);
}
interface SetupPromptProps {
show: boolean;
}
export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) {
const profileQuery = useQuery(
api.companies.getProfile.queryOptions({ retry: false }),
);
const incomplete = !profileQuery.isPending && isProfileIncomplete(profileQuery.data);
if (!show && !incomplete) return null;
return (
<Box className="rounded-2xl border border-edr-border bg-gradient-to-r from-edr-blue/5 to-edr-green/5 px-7 py-6">
<Group justify="space-between" align="center" wrap="nowrap">
<Box className="flex-1">
<Group gap={6} align="center" mb={6}>
{incomplete && <AlertTriangle size={16} color={cv("edr-orange")} />}
<Text fz={15} fw={700} c="edr-text">
{incomplete ? "Complete Your Profile" : "Setup your Company Profile"}
</Text>
</Group>
<Text fz={13} c="edr-muted" mb={12}>
{incomplete
? "Your company profile is incomplete. Fill in the missing details to unlock all features."
: "Complete your company information to unlock all features and start booking shipments."}
</Text>
<Link to="/settings" className="no-underline">
<Group gap={8} align="center" className="w-fit">
<Text fz={13} fw={600} c="edr-green.7">
{incomplete ? "Complete Profile" : "Complete Setup"}
</Text>
<ArrowRight size={16} color={cv("edr-green.7")} />
</Group>
</Link>
</Box>
<Box className="hidden shrink-0 sm:block">
<Truck size={48} color={cv("edr-blue")} opacity={0.3} />
</Box>
</Group>
</Box>
);
});

View File

@@ -1,15 +1,30 @@
import { Box, Group, Text } from "@mantine/core";
import { memo } from "react";
import type { LucideIcon } from "lucide-react";
import { memo } from "react";
import { cv } from "../constants";
/** Accent families map a KPI to a soft tile + strong ink pair from the theme. */
type Accent = "green" | "amber" | "blue" | "slate";
const ACCENTS: Record<Accent, { soft: string; ink: string }> = {
green: { soft: cv("edr-soft"), ink: cv("edr-green.7") },
amber: { soft: cv("edr-amber-soft"), ink: cv("edr-amber-text") },
blue: { soft: cv("edr-blue-soft"), ink: cv("edr-blue") },
slate: { soft: cv("edr-slate-soft"), ink: cv("edr-slate") },
};
interface StatKpiProps {
icon: LucideIcon;
label: string;
value: string;
delta: string;
deltaColor: string;
/** Color family for the icon chip. */
accent: Accent;
/** Tint of the delta pill — defaults to the card accent. */
deltaTone?: Accent | "muted";
/** Draw a separating border on the left (on wide layouts). */
divider?: boolean;
loading?: boolean;
}
export const StatKpi = memo(function StatKpi({
@@ -17,30 +32,67 @@ export const StatKpi = memo(function StatKpi({
label,
value,
delta,
deltaColor,
accent,
deltaTone,
divider,
loading,
}: StatKpiProps) {
const a = ACCENTS[accent];
const tone = deltaTone ?? accent;
const pill =
tone === "muted"
? { bg: cv("edr-slate-soft2"), fg: cv("edr-muted") }
: { bg: ACCENTS[tone].soft, fg: ACCENTS[tone].ink };
return (
<Box
px={4}
className={
divider ? "lg:border-l lg:border-edr-border lg:pl-7" : undefined
divider
? "flex flex-col lg:border-l lg:border-edr-divider lg:pl-4"
: "flex flex-col"
}
>
<Group gap={6} align="center" mb={7} wrap="nowrap">
<Icon size={15} color={cv("edr-muted")} className="shrink-0" />
<Text fz={12} fw={600} c="edr-muted" truncate>
{label}
</Text>
</Group>
<Group gap={8} align="flex-end" wrap="nowrap">
<Text fz={22} fw={800} lh={1} c="edr-text" truncate>
{value}
</Text>
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>
{delta}
</Text>
{/* Icon chip + metric label, aligned on one line. */}
<Group gap={12} wrap="nowrap" align="start">
<Box
className="flex size-10 shrink-0 items-center justify-center rounded-lg"
style={{ background: a.soft }}
>
<Icon size={18} color={a.ink} strokeWidth={2} />
</Box>
<Box>
<Box className="flex-row! flex items-end gap-2">
<Text
fz={24}
fw={800}
lh={1.1}
c="edr-text"
truncate
className="tracking-tight"
>
{loading ? "—" : value}
</Text>
{delta && !loading && (
<Box
px={8}
py={3}
className="inline-flex w-fit rounded-full"
style={{ background: pill.bg, maxWidth: "100%" }}
>
<Text fz={10} fw={700} lh={1.4} truncate style={{ color: pill.fg }}>
{delta}
</Text>
</Box>
)}
</Box>
<Text fz={12} mt="xs" fw={600} c="edr-muted" truncate>
{label}
</Text>
</Box>
</Group>
{/* Value + its trend pill, grouped together at the bottom of the cell. */}
</Box>
);
});

View File

@@ -1,7 +1,7 @@
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { SimpleGrid } from "@mantine/core";
import { CheckCircle2, Clock3, Truck, Wallet } from "lucide-react";
import { memo } from "react";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { formatPct } from "../constants";
import { Card } from "./Card";
import { StatKpi } from "./StatKpi";
@@ -29,39 +29,51 @@ export const StatsSection = memo(function StatsSection({
completionRate,
spendYtd,
spendYtdChangePct,
dashboardLoading,
}: StatsSectionProps) {
return (
<Card className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5">
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing={0} verticalSpacing={20}>
<Card
padding={24}
className="border-edr-divider! shadow-[0_1px_2px_rgba(16,24,40,0.04)]"
>
<SimpleGrid
cols={{ base: 2, lg: 4 }}
spacing={{ base: 20, lg: 0 }}
>
<StatKpi
icon={Truck}
accent="green"
label="Active Shipments"
value={bookingsLoading ? "—" : activeBookingsLength.toString()}
delta={bookingsLoading ? "" : `+${newActiveThisWeek} this week`}
deltaColor="edr-green.7"
value={activeBookingsLength.toString()}
delta={newActiveThisWeek > 0 ? `+${newActiveThisWeek} this week` : ""}
loading={bookingsLoading}
/>
<StatKpi
icon={Clock3}
accent="amber"
label="Awaiting Payment"
value={outstandingInvoicesLength.toString()}
delta={`${formatCurrency(totalOutstanding || 0, "ETB")} due`}
deltaColor="edr-amber-text"
loading={bookingsLoading}
divider
/>
<StatKpi
icon={CheckCircle2}
accent="blue"
label="Delivered (YTD)"
value={deliveredCount ?? "—"}
delta={completionRate ? `${completionRate}% completed` : ""}
deltaColor="edr-muted"
deltaTone="muted"
loading={dashboardLoading}
divider
/>
<StatKpi
icon={Wallet}
accent="green"
label="Spend YTD"
value={spendYtd ?? "—"}
delta={spendYtdChangePct ? `${formatPct(spendYtdChangePct)} YoY` : ""}
deltaColor="edr-green.7"
loading={dashboardLoading}
divider
/>
</SimpleGrid>

View File

@@ -6,8 +6,8 @@ export { FreightVolumeSection } from "./FreightVolumeSection";
export { HelloSection } from "./HelloSection";
export { InvoicesSection } from "./InvoicesSection";
export { RecentActivitySection } from "./RecentActivitySection";
export { SetupPrompt } from "./SetupPrompt";
export { ShipmentsSection } from "./ShipmentsSection";
export { StatKpi } from "./StatKpi";
export { StatsSection } from "./StatsSection";
export { Stepper } from "./Stepper";

View File

@@ -1,10 +1,10 @@
import {
Alert,
Button,
Checkbox,
Divider,
Group,
Loader,
PinInput,
SimpleGrid,
Stack,
Text,
@@ -16,7 +16,11 @@ import {
AlertCircle,
ArrowLeft,
ArrowRight,
// UserCheck,
CheckCircle2,
RotateCw,
ShieldCheck,
Smartphone,
UserCheck,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
@@ -37,15 +41,27 @@ import RoleLicenseStep, {
type RoleLicenseProfile,
} from "@/components/onboarding/RoleLicenseStep";
import ETradeInfo from "@/components/onboarding/ETradeInfo";
import { extractApiError } from "@/utils/result";
type CompanyStep =
| "company"
| "personnel"
| "contact"
| "verify"
| "poa"
| "documents"
| "additional";
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */
const phoneDigits = (p?: string | null) => (p ?? "").replace(/\D/g, "").slice(-9);
const samePhone = (a?: string | null, b?: string | null) => {
const da = phoneDigits(a);
return da.length === 9 && da === phoneDigits(b);
};
/** Mask all but the first 7 chars of an E.164 phone for display. */
const maskPhone = (p: string) =>
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
const onboardingSchema = z.object({
companyFirstName: z.string().min(1, "First name is required"),
companyLastName: z.string().min(1, "Last name is required"),
@@ -73,11 +89,13 @@ const onboardingSchema = z.object({
renewedFrom: z.string().optional(),
renewalDate: z.string().optional(),
renewedTo: z.string().optional(),
region: z.string().optional(),
zone: z.string().optional(),
woreda: z.string().optional(),
kebele: z.string().optional(),
houseNo: z.string().optional(),
// Address fields are user-entered and required (the registration/license
// fields above are read-only confirmations pulled from eTrade).
region: z.string().min(1, "Region is required"),
zone: z.string().min(1, "Zone is required"),
woreda: z.string().min(1, "Woreda is required"),
kebele: z.string().min(1, "Kebele is required"),
houseNo: z.string().min(1, "House number is required"),
etradePhone: z.string().optional(),
contactPersonFirstName: z
.string()
@@ -95,12 +113,11 @@ const onboardingSchema = z.object({
.string()
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
generalManagerFirstName: z.string().min(1, "GM first name is required"),
generalManagerLastName: z.string().min(1, "GM last name is required"),
generalManagerEmail: z.string().email("Invalid GM email"),
generalManagerName: z.string().min(1, "Manager name is required"),
generalManagerEmail: z.string().email("Invalid Manager email"),
generalManagerPhone: z
.string()
.min(1, "GM phone is required")
.min(1, "Manager phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
poaFirstName: z.string().optional(),
poaLastName: z.string().optional(),
@@ -152,6 +169,7 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"contactPersonEmail",
"contactPersonPhone",
],
verify: [],
poa: [],
documents: [],
additional: [],
@@ -205,7 +223,10 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
}
/** Map one wizard step's form values to the profile-update payload it saves. */
function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePayload> {
function stepPayload(
step: CompanyStep,
d: FormData,
): Partial<UpdateProfilePayload> {
switch (step) {
case "company":
return {
@@ -310,6 +331,20 @@ function toFormValues(p: ProfileResponse): FormData {
};
}
/** A single read-only registration value rendered as a label/value pair. */
function ReadOnlyField({ label, value }: { label: string; value?: string }) {
return (
<Stack gap={2}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" c="edr-text" fw={500}>
{value && value.trim() ? value : "—"}
</Text>
</Stack>
);
}
export default function CompanyProfileForm({
documentSettingCode,
documentFiles: controlledFiles,
@@ -327,6 +362,7 @@ export default function CompanyProfileForm({
roleProfiles,
licenseFiles,
onLicenseChange,
submitError,
}: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
@@ -354,6 +390,8 @@ export default function CompanyProfileForm({
/** Newly-selected license files per profile id. */
licenseFiles?: Record<string, File[]>;
onLicenseChange?: (value: Record<string, File[]>) => void;
/** Server error from the final submit (uploads/complete), shown verbatim. */
submitError?: string | null;
}) {
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false);
@@ -441,6 +479,36 @@ export default function CompanyProfileForm({
values: rehydrate ? toFormValues(rehydrate) : undefined,
});
// eTrade carries no email, so the company/contact email fields start blank.
// Seed them from the registering user's account email — but only while empty,
// so a typed or rehydrated value is never overwritten.
useEffect(() => {
if (!user?.email) return;
if (!watch("companyEmail")) {
setValue("companyEmail", user.email, { shouldValidate: true });
}
if (!watch("contactPersonEmail")) {
setValue("contactPersonEmail", user.email);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.email, rehydrate]);
// Keep the (hidden, derived) company address in sync with the editable address
// fields — so it reflects both the eTrade auto-fill and any later user edits,
// instead of only whatever was composed at lookup time.
const region = watch("region");
const zone = watch("zone");
const woreda = watch("woreda");
const kebele = watch("kebele");
const houseNo = watch("houseNo");
useEffect(() => {
const composed = [houseNo, kebele, woreda, zone, region]
.filter((part) => part && part.trim())
.join(", ");
setValue("companyAddress", composed);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [region, zone, woreda, kebele, houseNo]);
// The business owner/manager pulled from eTrade — powers "Use owner as
// manager" on the General Manager step.
const [etradeOwner, setEtradeOwner] = useState<{
@@ -449,11 +517,6 @@ export default function CompanyProfileForm({
email?: string;
} | null>(null);
// Mirror the three "copy from previous person" checkboxes.
const [ownerIsGm, setOwnerIsGm] = useState(false);
const [gmIsContact, setGmIsContact] = useState(false);
const [contactIsPoa, setContactIsPoa] = useState(false);
const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
// Company name comes from the eTrade manager/owner name on the license.
if (data.managerName) {
@@ -476,18 +539,9 @@ export default function CompanyProfileForm({
"etradePhone",
toEthiopianE164(data.regularPhone || data.mobilePhone),
);
// Compose a readable company address from the granular eTrade parts.
const addressParts = [
data.houseNo,
data.kebele,
data.woreda,
data.zone,
data.region,
].filter((part) => part && part.trim());
if (addressParts.length) {
setValue("companyAddress", addressParts.join(", "));
}
// companyAddress is composed reactively from the address fields below, so
// setting region/zone/woreda/kebele/houseNo above is enough — no need to
// compose it here.
// Pre-fill the company contact phone from eTrade's mobile number.
const mobile = toEthiopianE164(data.mobilePhone || data.regularPhone);
@@ -519,34 +573,137 @@ export default function CompanyProfileForm({
});
};
/** Copy the General Manager into the Contact Person fields (toggleable). */
const toggleGmAsContact = (checked: boolean) => {
setGmIsContact(checked);
if (!checked) return;
setValue("contactPersonFirstName", watch("generalManagerFirstName"));
setValue("contactPersonLastName", watch("generalManagerLastName"));
/** Copy the General Manager into the Contact Person fields (still editable). */
const useGmAsContact = () => {
setValue("contactPersonName", watch("generalManagerName"), {
shouldValidate: true,
});
setValue("contactPersonEmail", watch("generalManagerEmail"));
setValue("contactPersonPhone", watch("generalManagerPhone"));
setValue("contactPersonPhone", watch("generalManagerPhone"), {
shouldValidate: true,
});
};
/** Copy the Contact Person into the PoA fields (toggleable, still editable). */
const toggleContactAsPoa = (checked: boolean) => {
setContactIsPoa(checked);
if (!checked) return;
setValue("poaFirstName", watch("contactPersonFirstName"));
setValue("poaLastName", watch("contactPersonLastName"));
/** Copy the Contact Person into the PoA fields (still editable). */
const useContactAsPoa = () => {
setValue("poaName", watch("contactPersonName"));
setValue("poaEmail", watch("contactPersonEmail"));
setValue("poaPhone", watch("contactPersonPhone"));
};
/** Populate the Contact Person from the currently logged-in user. */
const useLoggedInUserAsContact = () => {
setValue("contactPersonName", user?.name?.en ?? "", {
shouldValidate: true,
});
if (user?.email) setValue("contactPersonEmail", user.email);
setValue("contactPersonPhone", user?.phoneNumber ?? "", {
shouldValidate: true,
});
};
// --- Contact-phone SMS OTP verification -----------------------------------
// The phone we verify is the contact-person phone, normalised to E.164 so it
// matches what the backend persists as `contactVerifiedPhone`.
const contactPhoneE164 = toEthiopianE164(watch("contactPersonPhone") ?? "");
// Source of truth for "already verified" comes from the onboarding/profile
// info (rehydrate) — so a refresh resumes the verify step's "done" state.
const [verifiedPhone, setVerifiedPhone] = useState<string | null>(
rehydrate?.contactVerifiedPhone ?? null,
);
useEffect(() => {
if (rehydrate?.contactVerifiedPhone) {
setVerifiedPhone(rehydrate.contactVerifiedPhone);
}
}, [rehydrate?.contactVerifiedPhone]);
const phoneVerified = samePhone(verifiedPhone, contactPhoneE164);
const [otpSent, setOtpSent] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [sendingOtp, setSendingOtp] = useState(false);
const [verifyingOtp, setVerifyingOtp] = useState(false);
const [otpError, setOtpError] = useState<string | null>(null);
const [resendIn, setResendIn] = useState(0);
// Resend cooldown countdown (no Date.now needed — pure setTimeout ticks).
useEffect(() => {
if (resendIn <= 0) return;
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [resendIn]);
// A changed contact phone invalidates any in-flight code entry (the previous
// code was for a different number). Verified state is handled separately via
// the phone comparison, so this only resets the send/enter UI.
useEffect(() => {
setOtpSent(false);
setOtpCode("");
setOtpError(null);
}, [contactPhoneE164]);
const sendContactOtp = async () => {
setOtpError(null);
if (!contactPhoneE164) {
setOtpError("Enter a valid contact phone number first.");
return;
}
setSendingOtp(true);
try {
await api.auth.sendOTP.call({ phone: contactPhoneE164 });
setOtpSent(true);
setOtpCode("");
setResendIn(60);
} catch (err) {
setOtpError(extractApiError(err).message);
} finally {
setSendingOtp(false);
}
};
const verifyContactOtp = async () => {
setOtpError(null);
if (otpCode.length !== 6) {
setOtpError("Enter the 6-digit code we sent you.");
return;
}
setVerifyingOtp(true);
try {
await api.auth.verifyOTP.call({ phone: contactPhoneE164, otp: otpCode });
setVerifiedPhone(contactPhoneE164);
setOtpSent(false);
// Persist the verified phone so the step resumes as "done" after a refresh
// (best-effort — the OTP itself already succeeded server-side).
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => {});
} catch (err) {
setOtpError(extractApiError(err).message);
} finally {
setVerifyingOtp(false);
}
};
const hasDocuments = Boolean(uploadSetting?.fields?.length);
// The registration/license details come straight from the eTrade lookup and
// are not user-editable — shown as a read-only confirmation once a TIN lookup
// (or rehydration) has filled them in. The address fields below are separate:
// user-entered and required. We watch the values so the display stays current.
const registration = watch([
"licenceNumber",
"statusDescription",
"dateRegistered",
"renewalDate",
"renewedFrom",
"renewedTo",
]);
const hasRegistrationDetails = registration.some((v) => v && v.trim());
// Single source of truth for step sequence — navigation, labels and the
// progress bar all derive from this so adding/removing a step is one edit.
const stepOrder: CompanyStep[] = [
"company",
"personnel",
"contact",
"verify",
"poa",
"documents",
"additional",
@@ -589,6 +746,20 @@ export default function CompanyProfileForm({
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
// Contact-phone verification gates advancing past the verify step. The
// verified phone is already persisted (on verify success), so there's
// nothing extra to save here.
if (step === "verify") {
if (!phoneVerified) {
setSaveError(
"Please verify the contact person's phone number to continue.",
);
return;
}
setSaveError(null);
setStep(stepOrder[currentIdx + 1]);
return;
}
// The documents step has nothing to persist; field steps validate + save
// before advancing.
if (step !== "documents") {
@@ -678,113 +849,96 @@ export default function CompanyProfileForm({
/>
</SimpleGrid>
<>
{hasRegistrationDetails && (
<>
<Divider my="sm" />
<Text fw={600} size="sm" c="edr-text">
Registration Details
</Text>
<Text size="xs" c="edr-muted">
Auto-filled from eTrade these fields cannot be edited.
</Text>
<Group gap="xs" align="center">
<Text fw={600} size="sm" c="edr-text">
Registration Details
</Text>
<Text size="xs" c="dimmed">
from eTrade · read-only
</Text>
</Group>
<SimpleGrid cols={2} spacing="md">
<TextInput
<ReadOnlyField
label="License Number"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.licenceNumber?.message}
{...register("licenceNumber")}
value={watch("licenceNumber")}
/>
<TextInput
<ReadOnlyField
label="Status"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.statusDescription?.message}
{...register("statusDescription")}
value={watch("statusDescription")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
<ReadOnlyField
label="Date Registered"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.dateRegistered?.message}
{...register("dateRegistered")}
value={watch("dateRegistered")}
/>
<TextInput
<ReadOnlyField
label="Renewal Date"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.renewalDate?.message}
{...register("renewalDate")}
value={watch("renewalDate")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
<ReadOnlyField
label="Renewed From"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.renewedFrom?.message}
{...register("renewedFrom")}
value={watch("renewedFrom")}
/>
<TextInput
<ReadOnlyField
label="Renewed To"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.renewedTo?.message}
{...register("renewedTo")}
/>
</SimpleGrid>
<Text fw={600} size="sm" c="edr-text" mt="md">
Address Information
</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Region"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.region?.message}
{...register("region")}
/>
<TextInput
label="Zone"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.zone?.message}
{...register("zone")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Woreda"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.woreda?.message}
{...register("woreda")}
/>
<TextInput
label="Kebele"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.kebele?.message}
{...register("kebele")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="House No"
readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.houseNo?.message}
{...register("houseNo")}
/>
<ControlledPhoneField
control={control}
name="etradePhone"
label="Phone"
value={watch("renewedTo")}
/>
</SimpleGrid>
</>
)}
<Divider my="sm" />
<Text fw={600} size="sm" c="edr-text">
Address Information
</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Region"
placeholder="Tigray"
required
error={errors.region?.message}
{...register("region")}
/>
<TextInput
label="Zone"
placeholder="EASTERN TIGRAY"
required
error={errors.zone?.message}
{...register("zone")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Woreda"
placeholder="EROB"
required
error={errors.woreda?.message}
{...register("woreda")}
/>
<TextInput
label="Kebele"
placeholder="ARAS"
required
error={errors.kebele?.message}
{...register("kebele")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="House No"
placeholder="House Number"
required
error={errors.houseNo?.message}
{...register("houseNo")}
/>
<ControlledPhoneField
control={control}
name="etradePhone"
label="Phone"
/>
</SimpleGrid>
</>
)}
@@ -834,15 +988,33 @@ export default function CompanyProfileForm({
{step === "contact" && (
<>
<Text fw={600} size="sm" c="edr-text">
Contact Person
</Text>
<Checkbox
color="edr-green"
label="Use General Manager as contact person"
checked={gmIsContact}
onChange={(e) => toggleGmAsContact(e.currentTarget.checked)}
/>
<Group justify="space-between" align="center" wrap="nowrap">
<Text fw={600} size="sm" c="edr-text">
Contact Person
</Text>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
<Button
variant="light"
color="edr-green"
size="xs"
leftSection={<UserCheck size={14} />}
onClick={useLoggedInUserAsContact}
>
Use me
</Button>
{watch("generalManagerName") && (
<Button
variant="light"
color="edr-green"
size="xs"
leftSection={<UserCheck size={14} />}
onClick={useGmAsContact}
>
Use General Manager
</Button>
)}
</Group>
</Group>
<SimpleGrid cols={2} spacing="md">
<TextInput
label={<>First Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
@@ -883,32 +1055,132 @@ export default function CompanyProfileForm({
</>
)}
{step === "verify" && (
<Stack gap="md">
<Group gap="xs" align="center">
<ShieldCheck size={18} className="text-[var(--mantine-color-edr-green-7)]" />
<Text fw={600} size="sm" c="edr-text">
Verify the contact person
</Text>
</Group>
<Text size="sm" c="edr-muted">
We'll text a one-time code to the contact person's phone to
confirm it's reachable. This is required before you continue.
</Text>
{!contactPhoneE164 ? (
<Alert
color="yellow"
variant="light"
icon={<AlertCircle size={18} />}
>
Add a valid contact phone number on the previous step first.
</Alert>
) : phoneVerified ? (
<Alert
color="edr-green"
variant="light"
icon={<CheckCircle2 size={18} />}
title="Phone verified"
>
{maskPhone(contactPhoneE164)} has been verified.
</Alert>
) : (
<Stack gap="sm">
<Group gap="xs" align="center">
<Smartphone size={16} className="text-[var(--mantine-color-edr-muted)]" />
<Text size="sm" c="edr-text">
{maskPhone(contactPhoneE164)}
</Text>
</Group>
{!otpSent ? (
<Button
color="edr-green"
variant="light"
onClick={sendContactOtp}
loading={sendingOtp}
leftSection={<Smartphone size={16} />}
style={{ alignSelf: "flex-start" }}
>
Send code via SMS
</Button>
) : (
<Stack gap="sm">
<Text size="sm" c="edr-muted">
Enter the 6-digit code we sent to{" "}
{maskPhone(contactPhoneE164)}.
</Text>
<PinInput
length={6}
type="number"
oneTimeCode
value={otpCode}
onChange={setOtpCode}
/>
<Group gap="sm">
<Button
color="edr-green"
onClick={verifyContactOtp}
loading={verifyingOtp}
disabled={otpCode.length !== 6}
>
Verify
</Button>
<Button
variant="subtle"
color="edr-green"
onClick={sendContactOtp}
loading={sendingOtp}
disabled={resendIn > 0 || sendingOtp}
leftSection={<RotateCw size={14} />}
>
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
</Button>
</Group>
</Stack>
)}
{otpError && (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
>
{otpError}
</Alert>
)}
</Stack>
)}
</Stack>
)}
{step === "poa" && (
<>
<Text size="sm" c="edr-muted">
Power of Attorney details are optional. Fill them in if you have
them, or skip to continue.
</Text>
<Checkbox
color="edr-green"
label="Use contact person as Power of Attorney"
checked={contactIsPoa}
onChange={(e) => toggleContactAsPoa(e.currentTarget.checked)}
<Group justify="space-between" align="center" wrap="nowrap">
<Text size="sm" c="edr-muted">
Power of Attorney details are optional. Fill them in if you
have them, or skip to continue.
</Text>
{watch("contactPersonName") && (
<Button
variant="light"
color="edr-green"
size="xs"
leftSection={<UserCheck size={14} />}
onClick={useContactAsPoa}
style={{ flexShrink: 0 }}
>
Use contact person
</Button>
)}
</Group>
<TextInput
label="PoA Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA First Name"
placeholder="First name"
error={errors.poaFirstName?.message}
{...register("poaFirstName")}
/>
<TextInput
label="PoA Last Name"
placeholder="Last name"
error={errors.poaLastName?.message}
{...register("poaLastName")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Email"
@@ -964,7 +1236,7 @@ export default function CompanyProfileForm({
<RoleLicenseStep
profiles={roleProfiles ?? []}
value={licenseFiles ?? {}}
onChange={onLicenseChange ?? (() => {})}
onChange={onLicenseChange ?? (() => { })}
/>
)}
@@ -983,6 +1255,17 @@ export default function CompanyProfileForm({
</Alert>
)}
{submitError && (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
title="Couldn't submit your application"
>
{submitError}
</Alert>
)}
<Group justify="space-between" pt="xs">
{showBack ? (
<Button
@@ -1001,22 +1284,23 @@ export default function CompanyProfileForm({
disabled={
isPending ||
saving ||
(step === "documents" && !hasDocuments && loadingDocuments)
(step === "documents" && !hasDocuments && loadingDocuments) ||
(step === "verify" && !phoneVerified)
}
loading={isPending || saving}
rightSection={
!isPending &&
!saving &&
step !== "additional" &&
step !== "documents" ? (
!saving &&
step !== "additional" &&
step !== "documents" ? (
<ArrowRight size={16} />
) : undefined
}
>
{step === "documents"
{step === "documents" || step === "verify"
? "Continue"
: step === "additional"
? "Finish onboarding"
? "Submit for review"
: "Save & Continue"}
</Button>
</Group>

View File

@@ -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,
@@ -651,15 +650,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 ──────────────────────────────────────── */}
@@ -823,17 +814,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>
) : (

View File

@@ -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,
@@ -67,6 +67,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

View File

@@ -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: {

View File

@@ -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;
@@ -229,6 +270,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>,

View File

@@ -34,7 +34,8 @@ export interface SignupResponse {
export interface OtpPayload {
phone: string;
otp: string;
/** Required on verify; omitted on send (the server generates the code). */
otp?: string;
}
export interface OtpResponse {

View File

@@ -29,6 +29,8 @@ export interface ProfileResponse {
contactPersonPosition: string | null;
contactPersonEmail: string | null;
contactPersonPhone: string | null;
/** Phone that passed SMS OTP verification (resumes the verify step's state). */
contactVerifiedPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
generalManagerPhone: string | null;
@@ -66,6 +68,7 @@ export interface UpdateProfilePayload {
contactPersonPosition?: string;
contactPersonEmail?: string;
contactPersonPhone?: string;
contactVerifiedPhone?: string;
generalManagerName?: string;
generalManagerEmail?: string;
generalManagerPhone?: string;

View File

@@ -1,13 +1,13 @@
import { cn } from "../lib/utils"
import { cn } from "../lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
className={cn("animate-pulse rounded-md bg-gray-200", className)}
{...props}
/>
)
);
}
export { Skeleton }
export { Skeleton };