mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/vehicle_2
This commit is contained in:
@@ -37,7 +37,7 @@
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
"axios": "^1.16.1",
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { CompaniesController } from "./companies.controller";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
@@ -20,6 +21,7 @@ import { ETradeService } from "./services/etrade.service";
|
||||
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
||||
HttpModule,
|
||||
FilesModule,
|
||||
FileUploadSettingsModule,
|
||||
MinioModule,
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
|
||||
@@ -3,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.
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -32,7 +35,7 @@ import {
|
||||
} from "@/components/customers";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company } from "@/types/customer";
|
||||
import type { Company, CompanyStatus } from "@/types/customer";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
@@ -45,14 +48,17 @@ export default function CustomersPage() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
// "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review).
|
||||
const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>("");
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery],
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
|
||||
);
|
||||
|
||||
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
|
||||
@@ -107,7 +113,25 @@ export default function CustomersPage() {
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <CompanyStatusBadge status={row.original.status} />,
|
||||
cell: ({ row }) => {
|
||||
const pending = (row.original.companyProfiles ?? []).filter(
|
||||
(p) => p.status === "pending",
|
||||
).length;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<CompanyStatusBadge status={row.original.status} />
|
||||
{pending > 0 ? (
|
||||
<Tooltip
|
||||
label={`${pending} profile${pending > 1 ? "s" : ""} awaiting approval`}
|
||||
>
|
||||
<Badge color="yellow" variant="light" size="sm" radius="sm">
|
||||
{pending} pending
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
@@ -216,6 +240,20 @@ export default function CustomersPage() {
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={statusFilter || "all"}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v === "all" ? "" : (v as CompanyStatus));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Pending approval", value: "pending" },
|
||||
{ label: "Active", value: "active" },
|
||||
]}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppLayout, type SidebarItem } from "@/components/AppLayout";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
CalendarCheck,
|
||||
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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
CompanyProfileResponse,
|
||||
CreateCompanyPayload,
|
||||
DashboardSummary,
|
||||
OnboardingRequirements,
|
||||
ProfileTypeValue,
|
||||
} from "./companies.service";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
@@ -171,6 +172,12 @@ export const api = {
|
||||
"completeOnboarding",
|
||||
companiesService.completeOnboarding,
|
||||
),
|
||||
|
||||
onboardingRequirements: endpoint<void, OnboardingRequirements>(
|
||||
"companies",
|
||||
"onboardingRequirements",
|
||||
companiesService.getOnboardingRequirements,
|
||||
),
|
||||
},
|
||||
|
||||
bookings: {
|
||||
|
||||
@@ -82,6 +82,47 @@ export interface CompanyInfoResponse {
|
||||
company: CompanyResponse;
|
||||
}
|
||||
|
||||
/** A single onboarding document field, as resolved and described by the backend. */
|
||||
export interface OnboardingDocumentField {
|
||||
fileKey: string;
|
||||
fileLabel: string;
|
||||
helpText: string | null;
|
||||
isRequired: boolean;
|
||||
isMultiple: boolean;
|
||||
maxFiles: number;
|
||||
allowedExtensions: string[];
|
||||
maxSizeMb: number;
|
||||
displayOrder: number;
|
||||
uploaded: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingLicenseProfile {
|
||||
profileId: string;
|
||||
type: string;
|
||||
reference: string;
|
||||
uploaded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-driven onboarding requirements. The portal renders this verbatim: the
|
||||
* backend decides which documents apply (by nationality) and what is still
|
||||
* outstanding, so the client never hardcodes required fields or document sets.
|
||||
*/
|
||||
export interface OnboardingRequirements {
|
||||
documentSettingCode: string;
|
||||
nationality: string;
|
||||
companyInfo: {
|
||||
complete: boolean;
|
||||
missingFields: { key: string; label: string }[];
|
||||
};
|
||||
documents: OnboardingDocumentField[];
|
||||
licenseProfiles: OnboardingLicenseProfile[];
|
||||
progress: { completed: number; total: number };
|
||||
isComplete: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
outstanding: string[];
|
||||
}
|
||||
|
||||
export interface CompanyProfileInput {
|
||||
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
|
||||
businessLicense?: string;
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@prisma/client": "^6.19.3",
|
||||
"@sendgrid/mail": "^8.1.0",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
|
||||
@@ -496,7 +496,7 @@ async function seedPaymentMethods() {
|
||||
{ type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' },
|
||||
{ type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' },
|
||||
{ type: 'EBIRR', displayName: 'eBirr', region: 'ETHIOPIA' },
|
||||
{ type: 'WAAFI', displayName: 'Waffi', region: 'DJIBOUTI' },
|
||||
{ type: 'WAAFI', displayName: 'Waafi', region: 'DJIBOUTI' },
|
||||
{ type: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' },
|
||||
{ type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' },
|
||||
];
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query, Request, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AgentsService } from './agents.service';
|
||||
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
||||
// IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard.
|
||||
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
|
||||
@ApiTags('Agents')
|
||||
@Controller('agents')
|
||||
// TODO(iam-authz): restrict per route via @UseGuards(PermissionGuard([...])) once the IAM
|
||||
// role→permission mapping (EIamPermissionKey) is confirmed. For now: authenticated IAM users only.
|
||||
@UseGuards(IamJwtGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
export class AgentsController {
|
||||
constructor(private service: AgentsService) {}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'Get agent profile for logged-in IAM user' })
|
||||
getMe(@Request() req: any) {
|
||||
return this.service.getMe(req.user?.id ?? req.user?.sub);
|
||||
}
|
||||
@Post('bookings')
|
||||
@ApiOperation({ summary: 'Create agent booking with cash payment' })
|
||||
createBooking(@Body() dto: CreateAgentBookingDto) {
|
||||
|
||||
@@ -133,4 +133,10 @@ export class AgentsService {
|
||||
take: 20
|
||||
});
|
||||
}
|
||||
|
||||
async getMe(iamUserId: string) {
|
||||
const agent = await this.prisma.agent.findUnique({ where: { iamUserId } });
|
||||
if (!agent) throw new NotFoundException('No agent profile found for this user');
|
||||
return agent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException, SetMetadata } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { GuestBookingService } from './guest-booking.service';
|
||||
@@ -47,7 +46,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get('by-device')
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Get bookings by device ID',
|
||||
description: 'Returns all bookings associated with a device ID (for guest users). Includes saved passenger details and booking history.'
|
||||
@@ -76,8 +75,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'List all bookings with filters (Admin/Agent)',
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.'
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
|
||||
@@ -102,7 +101,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post('guest')
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)',
|
||||
description: `Creates a booking without requiring login. Supports all four booking types.
|
||||
@@ -256,7 +255,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get('saved-passengers')
|
||||
@ApiOperation({
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Get saved passenger profiles',
|
||||
description: 'Retrieve saved passenger details by userId (if logged in) or deviceId (for guest users)'
|
||||
})
|
||||
@@ -417,8 +417,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':id/usage')
|
||||
@ApiOperation({
|
||||
summary: 'Check if booking is in use',
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
description: 'Returns list of modules/data that reference this booking'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Usage information retrieved' })
|
||||
@@ -428,7 +428,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':bookingRef')
|
||||
@ApiOperation({
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Get booking details by reference (no auth required)',
|
||||
description: 'Returns booking with passenger categories, Verifayda verification status, and multi-currency amounts. Works for both guest and authenticated bookings.'
|
||||
})
|
||||
@@ -452,8 +453,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Delete booking (admin only)',
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
description: 'Permanently deletes a booking record'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Booking deleted successfully' })
|
||||
@@ -463,8 +464,8 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Update booking details',
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
description: 'Updates booking information for admin/agent operations'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Booking updated successfully' })
|
||||
|
||||
@@ -1027,9 +1027,10 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
async getByRef(bookingRef: string) {
|
||||
async getByRef(bookingRefOrId: string) {
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId);
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef },
|
||||
where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete, SetMetadata } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { SkipThrottle, Throttle } from '@nestjs/throttler';
|
||||
import { PassengersService } from './passengers.service';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
@@ -19,6 +19,7 @@ export class PassengersController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'List all passengers with filters (Admin/Agent)',
|
||||
description: 'Returns paginated list of passengers with search filters'
|
||||
@@ -86,6 +87,7 @@ export class PassengersController {
|
||||
}
|
||||
|
||||
@Post('verify-fayda')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Verify Ethiopian national ID via Verifayda 2.0',
|
||||
description: `**Standalone endpoint for pre-verification of Ethiopian national IDs**
|
||||
@@ -155,6 +157,7 @@ Pre-verify national ID to auto-fill passenger registration form before submissio
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
@SetMetadata('isPublic', true)
|
||||
@UseGuards(OptionalJwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
@@ -249,6 +252,7 @@ The API automatically detects:
|
||||
}
|
||||
|
||||
@Post('save-details')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Bulk save passenger details from booking flow',
|
||||
description: `**Endpoint for saving multiple passengers in a single booking**
|
||||
@@ -347,6 +351,7 @@ Returns saved passenger details with generated IDs and confirmation.`,
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Update passenger details',
|
||||
description: 'Updates passenger information for admin/agent operations'
|
||||
@@ -358,6 +363,7 @@ Returns saved passenger details with generated IDs and confirmation.`,
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Delete passenger (admin only)',
|
||||
description: 'Permanently deletes a passenger record and associated data'
|
||||
@@ -369,6 +375,7 @@ Returns saved passenger details with generated IDs and confirmation.`,
|
||||
}
|
||||
|
||||
@Get(':id/usage')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Check if passenger is in use',
|
||||
description: 'Returns list of modules/data that reference this passenger'
|
||||
|
||||
@@ -32,46 +32,20 @@ export class PassengersService {
|
||||
const { search, verified, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
let iamUserIdFilter: string[] | null = null;
|
||||
const where: any = {};
|
||||
|
||||
if (search || verified !== undefined) {
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (search) {
|
||||
conditions.push(`(
|
||||
u.email ILIKE $${idx} OR
|
||||
u.phone_number ILIKE $${idx} OR
|
||||
(u.name->>'en') ILIKE $${idx} OR
|
||||
(u.name->>'am') ILIKE $${idx}
|
||||
)`);
|
||||
params.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (verified !== undefined) {
|
||||
if (verified) {
|
||||
conditions.push(`u.metadata->>'faydaVerified' = 'true'`);
|
||||
} else {
|
||||
conditions.push(`(u.metadata IS NULL OR u.metadata->>'faydaVerified' IS DISTINCT FROM 'true')`);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await this.dataSource.query<{ id: string }[]>(
|
||||
`SELECT u.id FROM iam.users u WHERE ${conditions.join(' AND ')}`,
|
||||
params,
|
||||
);
|
||||
iamUserIdFilter = rows.map(r => r.id);
|
||||
|
||||
if (iamUserIdFilter.length === 0) {
|
||||
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
|
||||
}
|
||||
if (search) {
|
||||
where.user = {
|
||||
OR: [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ phone: { contains: search, mode: 'insensitive' } },
|
||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const where: any = {};
|
||||
if (iamUserIdFilter) {
|
||||
where.iamUserId = { in: iamUserIdFilter };
|
||||
if (verified !== undefined) {
|
||||
where.user = { ...(where.user ?? {}), faydaVerified: verified };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
@@ -81,8 +55,22 @@ export class PassengersService {
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: true,
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
_count: { select: { bookings: true } },
|
||||
bookings: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: {
|
||||
contactEmail: true,
|
||||
contactPhone: true,
|
||||
seats: { take: 1, orderBy: { id: 'asc' }, select: {
|
||||
passengerName: true, dateOfBirth: true, passportNumber: true,
|
||||
passportCountry: true, idDocumentType: true, verifaydaVerified: true, faydaVerifiedAt: true,
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.passenger.count({ where }),
|
||||
@@ -97,16 +85,68 @@ export class PassengersService {
|
||||
: [];
|
||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||
|
||||
// Collect guest contact details for bulk SavedPassengerProfile lookup
|
||||
const guestContacts = items
|
||||
.filter(p => !(p as any).user && !p.iamUserId)
|
||||
.map(p => (p as any).bookings?.[0])
|
||||
.filter(Boolean);
|
||||
const guestEmails = guestContacts.map((b: any) => b.contactEmail).filter(Boolean) as string[];
|
||||
const guestPhones = guestContacts.map((b: any) => b.contactPhone).filter(Boolean) as string[];
|
||||
|
||||
const savedProfiles = (guestEmails.length || guestPhones.length)
|
||||
? await this.prisma.savedPassengerProfile.findMany({
|
||||
where: { OR: [
|
||||
...(guestEmails.length ? [{ email: { in: guestEmails } }] : []),
|
||||
...(guestPhones.length ? [{ phone: { in: guestPhones } }] : []),
|
||||
]},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: [];
|
||||
|
||||
// Index by email then phone for O(1) lookup
|
||||
const profileByEmail = new Map(savedProfiles.filter(s => s.email).map(s => [s.email!, s]));
|
||||
const profileByPhone = new Map(savedProfiles.filter(s => s.phone).map(s => [s.phone!, s]));
|
||||
|
||||
return {
|
||||
items: items.map(passenger => {
|
||||
const localUser = (passenger as any).user ?? null;
|
||||
const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
|
||||
const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true';
|
||||
const faydaVerified = localUser?.faydaVerified === true
|
||||
|| iam?.metadata?.faydaVerified === true
|
||||
|| iam?.metadata?.faydaVerified === 'true';
|
||||
const guestBooking = !localUser && !iam ? (passenger as any).bookings?.[0] : null;
|
||||
const guestSeat = guestBooking?.seats?.[0] ?? null;
|
||||
const savedProfile = guestBooking
|
||||
? (profileByEmail.get(guestBooking.contactEmail) ?? profileByPhone.get(guestBooking.contactPhone) ?? null)
|
||||
: null;
|
||||
return {
|
||||
id: passenger.id,
|
||||
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
||||
email: iam?.email ?? null,
|
||||
phone: iam?.phone_number ?? null,
|
||||
fullName: localUser?.fullName ?? iam?.name?.en ?? iam?.name?.am ?? savedProfile?.passengerName ?? guestSeat?.passengerName ?? null,
|
||||
email: localUser?.email ?? iam?.email ?? savedProfile?.email ?? guestBooking?.contactEmail ?? null,
|
||||
phone: localUser?.phone ?? iam?.phone_number ?? savedProfile?.phone ?? guestBooking?.contactPhone ?? null,
|
||||
gender: localUser?.gender ?? iam?.metadata?.gender ?? null,
|
||||
dateOfBirth: localUser?.dateOfBirth
|
||||
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
|
||||
: (iam?.metadata?.dateOfBirth ?? (savedProfile?.dateOfBirth
|
||||
? new Date(savedProfile.dateOfBirth).toISOString().split('T')[0]
|
||||
: (guestSeat?.dateOfBirth ? new Date(guestSeat.dateOfBirth).toISOString().split('T')[0] : null))),
|
||||
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? savedProfile?.nationality ?? null,
|
||||
nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null,
|
||||
faydaVerified,
|
||||
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? guestSeat?.faydaVerifiedAt ?? null,
|
||||
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? savedProfile?.passportNumber ?? guestSeat?.passportNumber ?? null,
|
||||
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? savedProfile?.passportCountry ?? guestSeat?.passportCountry ?? null,
|
||||
passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null,
|
||||
idDocumentType: savedProfile?.idDocumentType ?? guestSeat?.idDocumentType ?? null,
|
||||
verified: faydaVerified,
|
||||
lastLoginAt: localUser?.lastLoginAt ?? null,
|
||||
role: localUser?.role ?? null,
|
||||
loyalty: passenger.loyalty
|
||||
? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 }
|
||||
: null,
|
||||
wallet: (passenger as any).wallet
|
||||
? { balanceMinor: (passenger as any).wallet.balanceMinor, currency: (passenger as any).wallet.currency ?? 'ETB' }
|
||||
: null,
|
||||
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
|
||||
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
|
||||
totalBookings: passenger._count.bookings,
|
||||
@@ -384,7 +424,21 @@ export class PassengersService {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||
|
||||
await this.prisma.passenger.delete({ where: { id } });
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }),
|
||||
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId: id } } }),
|
||||
this.prisma.walletAccount.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.notification.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.journey.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }),
|
||||
this.prisma.booking.deleteMany({ where: { passengerId: id } }),
|
||||
this.prisma.passenger.delete({ where: { id } }),
|
||||
]);
|
||||
|
||||
return { deleted: true, passengerId: id };
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
ApiOkResponse,
|
||||
ApiProduces,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
||||
|
||||
import { SkipThrottle, Throttle } from "@nestjs/throttler";
|
||||
import { Response } from "express";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
@@ -65,7 +66,7 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Post("initiate")
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment with nationality-based payment methods",
|
||||
description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`,
|
||||
@@ -75,14 +76,14 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Get("intents/:bookingId")
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
||||
getIntent(@Param("bookingId") bookingId: string) {
|
||||
return this.service.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
@Get("waafi/return")
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " +
|
||||
@@ -123,7 +124,7 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Get("methods")
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: "List payment systems supported by the platform",
|
||||
description:
|
||||
@@ -136,7 +137,7 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: "Browser checkout redirect",
|
||||
description:
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Post,
|
||||
Patch,
|
||||
Query,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
||||
import { SeatsService } from "./seats.service";
|
||||
import { HoldSeatsDto } from "./seats.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
@@ -30,7 +30,7 @@ export class SeatsController {
|
||||
|
||||
// ── Seat Map ──────────────────────────────────────────────────────────────
|
||||
@Get("seatmap/:scheduleId")
|
||||
@IsPublic()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: "Get seat map filtered by coach type",
|
||||
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.`,
|
||||
@@ -104,6 +104,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
}
|
||||
|
||||
@Post("hold")
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Hold seats for 15 minutes before booking (Public - Guest booking supported)",
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, Patch, SetMetadata, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
|
||||
@ApiTags('System Config')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
@Controller('system-config')
|
||||
@ApiTags('Config')
|
||||
@Controller('config')
|
||||
export class SystemConfigController {
|
||||
constructor(private service: SystemConfigService) {}
|
||||
|
||||
@Get('fayda-status')
|
||||
@SetMetadata('isPublic', true)
|
||||
@SkipThrottle()
|
||||
@ApiOperation({ summary: 'Get Fayda verification enabled status (public)' })
|
||||
getFaydaStatus() {
|
||||
const enabled = process.env.VERIFAYDA_ENABLED !== 'false';
|
||||
return { enabled };
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiOperation({ summary: 'Get all system config (admin)' })
|
||||
getAll() {
|
||||
return this.service.getAll();
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiOperation({ summary: 'Update system config (admin)' })
|
||||
update(@Body() body: Record<string, string>) {
|
||||
return this.service.updateMany(body);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
@@ -9,7 +9,8 @@ export class TicketsController {
|
||||
constructor(private service: TicketsService) {}
|
||||
|
||||
@Post('generate/:bookingId')
|
||||
@ApiOperation({
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Generate ticket for booking (confirmation page)',
|
||||
description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.'
|
||||
})
|
||||
@@ -18,6 +19,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Patch('update-seats/:bookingId')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'Update ticket seats before final confirmation',
|
||||
description: 'Allows users to change selected seats after ticket generation. Removes old seat blocks and creates new ones for updated seats.'
|
||||
@@ -69,9 +71,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get(':bookingRef')
|
||||
@ApiOperation({
|
||||
summary: 'Get ticket with QR code and passenger details (public)',
|
||||
})
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({ summary: 'Get ticket with QR code and passenger details (public)' })
|
||||
getByRef(@Param('bookingRef') ref: string) {
|
||||
return this.service.getByRef(ref);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export class TicketsService {
|
||||
ticketNumber: t.barcodePayload,
|
||||
bookingRef: t.bookingRef,
|
||||
booking: {
|
||||
id: t.booking.id,
|
||||
bookingRef: t.booking.bookingRef,
|
||||
status: t.booking.status,
|
||||
bookingType: t.booking.bookingType,
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function PassengersPage() {
|
||||
case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : '';
|
||||
case 'gender': return p.gender || '';
|
||||
case 'nationality': return p.nationality || '';
|
||||
case 'verified': return p.nationalId ? 'Yes' : 'No';
|
||||
case 'verified': return p.faydaVerified ? 'Yes' : 'No';
|
||||
default: return '';
|
||||
}
|
||||
});
|
||||
@@ -117,15 +117,15 @@ export default function PassengersPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone },
|
||||
{ key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
|
||||
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || 'N/A' },
|
||||
{ key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' },
|
||||
{ key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
|
||||
{ key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' },
|
||||
{
|
||||
key: 'verified', label: 'Status',
|
||||
render: (p: any) => (
|
||||
<Badge variant="status" status={p.nationalId ? 'CONFIRMED' : 'PENDING'}>
|
||||
{p.nationalId ? 'Verified' : 'Unverified'}
|
||||
<Badge variant="status" status={p.faydaVerified ? 'CONFIRMED' : 'PENDING'}>
|
||||
{p.faydaVerified ? 'Verified' : 'Unverified'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
@@ -192,7 +192,7 @@ export default function PassengersPage() {
|
||||
{selectedPassenger && (() => {
|
||||
const p = selectedPassenger;
|
||||
const isVerified = !!p.faydaVerified || !!p.nationalId;
|
||||
const tier = p.passenger?.loyalty?.tier || p.loyalty?.tier;
|
||||
const tier = p.loyalty?.tier || p.loyaltyTier;
|
||||
const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE;
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { ticketsApi, apiClient, stationsApi, excessBaggageApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
|
||||
@@ -25,15 +26,23 @@ export default function TicketsPage() {
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
||||
|
||||
const { user } = useAuthStore();
|
||||
|
||||
// Excess baggage state
|
||||
const [excessModalOpen, setExcessModalOpen] = useState(false);
|
||||
const [excessTicket, setExcessTicket] = useState<any>(null);
|
||||
const [excessKg, setExcessKg] = useState('');
|
||||
const [excessCollectCash, setExcessCollectCash] = useState(false);
|
||||
const [excessAgentId, setExcessAgentId] = useState('');
|
||||
const [excessError, setExcessError] = useState<string | null>(null);
|
||||
const [excessResult, setExcessResult] = useState<any>(null);
|
||||
|
||||
const { data: agentData } = useQuery({
|
||||
queryKey: ['agent-me'],
|
||||
queryFn: () => apiClient.get<any>('/agents/me'),
|
||||
enabled: !!user,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
@@ -105,7 +114,6 @@ export default function TicketsPage() {
|
||||
setExcessTicket(ticket);
|
||||
setExcessKg('');
|
||||
setExcessCollectCash(false);
|
||||
setExcessAgentId('');
|
||||
setExcessError(null);
|
||||
setExcessResult(null);
|
||||
setExcessModalOpen(true);
|
||||
@@ -114,9 +122,11 @@ export default function TicketsPage() {
|
||||
const handleExcessSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!excessTicket) return;
|
||||
const agentId = agentData?.id;
|
||||
if (!agentId) { setExcessError('No agent profile found for your account'); return; }
|
||||
await excessMutation.mutateAsync({
|
||||
bookingId: excessTicket.bookingId,
|
||||
agentId: excessAgentId,
|
||||
bookingId: excessTicket.booking?.id ?? excessTicket.bookingId,
|
||||
agentId,
|
||||
excessWeightKg: parseInt(excessKg),
|
||||
collectCash: excessCollectCash,
|
||||
});
|
||||
@@ -371,6 +381,13 @@ export default function TicketsPage() {
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Baggage',
|
||||
onClick: openExcessModal,
|
||||
variant: 'secondary' as const,
|
||||
icon: Package,
|
||||
show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status),
|
||||
},
|
||||
{
|
||||
label: 'Board',
|
||||
onClick: handleBoard,
|
||||
@@ -411,13 +428,6 @@ export default function TicketsPage() {
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
},
|
||||
{
|
||||
label: 'Excess Baggage',
|
||||
onClick: openExcessModal,
|
||||
variant: 'secondary' as const,
|
||||
icon: Package,
|
||||
show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status),
|
||||
},
|
||||
];
|
||||
|
||||
const stations = stationsData?.items || [];
|
||||
@@ -736,16 +746,16 @@ export default function TicketsPage() {
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Booking: <span className="font-semibold text-foreground">{excessTicket?.booking?.bookingRef}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Agent ID</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Enter your agent ID"
|
||||
value={excessAgentId}
|
||||
onChange={(e) => setExcessAgentId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{agentData && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Agent: <span className="font-semibold text-foreground">{agentData.agentCode}</span>
|
||||
</div>
|
||||
)}
|
||||
{!agentData && (
|
||||
<div className="text-sm text-amber-600 dark:text-amber-400">
|
||||
⚠ No agent profile linked to your account.
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="label">Excess weight (kg)</label>
|
||||
<input
|
||||
|
||||
@@ -413,6 +413,6 @@ export const excessBaggageApi = {
|
||||
|
||||
// System Config API
|
||||
export const systemConfigApi = {
|
||||
getAll: () => apiClient.get<Record<string, string>>('/system-config'),
|
||||
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/system-config', data),
|
||||
getAll: () => apiClient.get<Record<string, string>>('/config'),
|
||||
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/config', data),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react';
|
||||
@@ -29,10 +29,6 @@ export default function ConfirmationPage() {
|
||||
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
|
||||
const confirmAttempted = useRef(false);
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: () => apiClient.patch(`/bookings/${bookingId}/confirm`, { status: 'SUCCEEDED' }),
|
||||
});
|
||||
|
||||
const { data: _booking } = useQuery<BookingWithTicket>({
|
||||
queryKey: ['booking', bookingId],
|
||||
queryFn: async (): Promise<BookingWithTicket> => {
|
||||
@@ -54,13 +50,20 @@ export default function ConfirmationPage() {
|
||||
useEffect(() => {
|
||||
if (bookingId && !confirmAttempted.current) {
|
||||
confirmAttempted.current = true;
|
||||
confirmMutation.mutate();
|
||||
|
||||
apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => {
|
||||
console.error('Failed to generate ticket:', err);
|
||||
|
||||
// Only generate ticket if booking is already CONFIRMED (e.g. wallet payment)
|
||||
// For other payment methods, ticket is generated by the payment webhook after payment completes
|
||||
apiClient.get(`/bookings/${bookingId}`).then((data: any) => {
|
||||
if (data?.status === 'CONFIRMED') {
|
||||
apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => {
|
||||
console.error('Failed to generate ticket:', err);
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('Failed to fetch booking status:', err);
|
||||
});
|
||||
}
|
||||
}, [bookingId, confirmMutation]);
|
||||
}, [bookingId]);
|
||||
|
||||
const copyPNR = () => {
|
||||
if (pnr) {
|
||||
|
||||
@@ -380,6 +380,7 @@ export default function PassengersPage() {
|
||||
const router = useRouter();
|
||||
const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore();
|
||||
const { user, isAuthenticated, updateUser } = useAuthStore();
|
||||
const isInitialized = useAuthStore((s) => s.isInitialized);
|
||||
const [faydaEnabled, setFaydaEnabled] = useState(true);
|
||||
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -435,8 +436,8 @@ export default function PassengersPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const populateForm = async () => {
|
||||
if (!isInitialized) return;
|
||||
if (!isAuthenticated || !user?.id || !searchCriteria) {
|
||||
console.log('Missing required data for population');
|
||||
setFormInitialized(true);
|
||||
return;
|
||||
}
|
||||
@@ -475,7 +476,7 @@ export default function PassengersPage() {
|
||||
};
|
||||
|
||||
populateForm();
|
||||
}, [isAuthenticated, user, searchCriteria, setValue]);
|
||||
}, [isInitialized, isAuthenticated, user, searchCriteria, setValue]);
|
||||
|
||||
const openFaydaVerification = async (index: number) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
@@ -60,21 +60,14 @@ export default function PaymentPage() {
|
||||
|
||||
const paymentMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
// For TELEBIRR and WAAFI, use the initiate endpoint
|
||||
if (data.method === 'TELEBIRR' || data.method === 'WAAFI') {
|
||||
const response = await apiClient.post('/payments/initiate', {
|
||||
// For all payment methods, use the initiate endpoint
|
||||
try {
|
||||
return await apiClient.post("/payments/initiate", {
|
||||
bookingId: data.bookingId,
|
||||
method: data.method,
|
||||
paymentMethodId: data.paymentMethodId,
|
||||
platform: 'web'
|
||||
platform: 'web',
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// For other payment methods, try the regular payment intent API
|
||||
try {
|
||||
return await apiClient.post("/payments/intent", data);
|
||||
} catch (error) {
|
||||
console.log("Payment API not available, using mock payment");
|
||||
// Mock payment response
|
||||
|
||||
@@ -15,21 +15,21 @@ class ApiClient {
|
||||
|
||||
this.client.interceptors.request.use((config) => {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
||||
if (token) {
|
||||
if (token && token !== 'null' && token !== 'undefined') {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me'];
|
||||
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Don't redirect if it's a login or register request (invalid credentials)
|
||||
const isAuthEndpoint = error.config?.url?.includes('/auth/login') ||
|
||||
error.config?.url?.includes('/auth/register');
|
||||
|
||||
if (!isAuthEndpoint && typeof window !== 'undefined') {
|
||||
const url: string = error.config?.url || '';
|
||||
const isPublic = PUBLIC_PREFIXES.some((p) => url.includes(p));
|
||||
if (!isPublic && typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
window.location.href = '/login';
|
||||
|
||||
Binary file not shown.
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user