Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/vehicle_2

This commit is contained in:
yaschalew
2026-06-25 09:46:34 +03:00
56 changed files with 1744 additions and 558 deletions

View File

@@ -37,7 +37,7 @@
"@nestjs/swagger": "^11.4.2", "@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1", "@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", "@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", "amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1", "amqplib": "^2.0.1",
"axios": "^1.16.1", "axios": "^1.16.1",

View File

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

View File

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

View File

@@ -371,6 +371,17 @@ export class BookingsService {
tradeDirection, tradeDirection,
fallbackType, 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 = const needsConsolidation =

View File

@@ -41,6 +41,7 @@ import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto"; import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto";
import { ETradeResponseDto } from "./dto/etrade-response.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto";
@@ -226,6 +227,17 @@ export class CompaniesController {
await this.companiesService.setOnboardingStep(user.id, dto.step); await this.companiesService.setOnboardingStep(user.id, dto.step);
} }
@Get("onboarding/requirements")
@ApiOperation({
summary:
"What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)",
})
async getOnboardingRequirements(
@CurrentUser() user: CurrentIamUser,
): Promise<OnboardingRequirementsResponseDto> {
return this.companiesService.getOnboardingRequirements(user.id);
}
@Post("onboarding/complete") @Post("onboarding/complete")
@ApiOperation({ summary: "Mark the current user's onboarding as complete" }) @ApiOperation({ summary: "Mark the current user's onboarding as complete" })
async completeOnboarding( async completeOnboarding(

View File

@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm"; import { TypeOrmModule } from "@nestjs/typeorm";
import { HttpModule } from "@nestjs/axios"; import { HttpModule } from "@nestjs/axios";
import { FilesModule } from "../files/files.module"; import { FilesModule } from "../files/files.module";
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
import { MinioModule } from "../minio/minio.module"; import { MinioModule } from "../minio/minio.module";
import { CompaniesController } from "./companies.controller"; import { CompaniesController } from "./companies.controller";
import { CompaniesService } from "./companies.service"; import { CompaniesService } from "./companies.service";
@@ -20,6 +21,7 @@ import { ETradeService } from "./services/etrade.service";
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
HttpModule, HttpModule,
FilesModule, FilesModule,
FileUploadSettingsModule,
MinioModule, MinioModule,
], ],
controllers: [CompaniesController], controllers: [CompaniesController],

View File

@@ -3,6 +3,7 @@ import {
NotFoundException, NotFoundException,
ConflictException, ConflictException,
BadRequestException, BadRequestException,
ForbiddenException,
} from "@nestjs/common"; } from "@nestjs/common";
import { CompaniesRepository } from "./companies.repository"; import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyProfileRepository } from "./company-profile.repository";
@@ -12,7 +13,10 @@ import {
DashboardScope, DashboardScope,
} from "./company-dashboard.repository"; } from "./company-dashboard.repository";
import { MinioService } from "../minio/minio.service"; import { MinioService } from "../minio/minio.service";
import { FilesService } from "../files/files.service";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { ETradeService } from "./services/etrade.service"; import { ETradeService } from "./services/etrade.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { CreateCompanyDto } from "./dto/create-company.dto"; import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto";
@@ -53,9 +57,67 @@ export class CompaniesService {
private readonly profilesRepo: ExternalProfileRepository, private readonly profilesRepo: ExternalProfileRepository,
private readonly dashboardRepo: CompanyDashboardRepository, private readonly dashboardRepo: CompanyDashboardRepository,
private readonly minioService: MinioService, private readonly minioService: MinioService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly etradeService: ETradeService, private readonly etradeService: ETradeService,
) { } ) { }
/**
* Required company-information fields that must be filled before onboarding can
* be submitted. The backend owns this list so the portal never has to know
* which fields are mandatory — it just renders what's reported outstanding.
* `get` reads the value from the company (some live in the attributes blob).
*/
private readonly REQUIRED_COMPANY_INFO: {
key: string;
label: string;
get: (company: Company) => unknown;
}[] = [
{
key: "tinNumber",
label: "Company TIN",
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
},
{ key: "companyEmail", label: "Company email", get: (c) => c.email },
{ key: "companyPhone", label: "Company phone", get: (c) => c.phone },
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
{
key: "contactPersonName",
label: "Contact person name",
get: (c) => c.attributes?.contactPersonName,
},
{
key: "contactPersonPhone",
label: "Contact person phone",
get: (c) => c.attributes?.contactPersonPhone,
},
{
key: "generalManagerName",
label: "General manager name",
get: (c) => c.attributes?.generalManagerName,
},
{
key: "generalManagerEmail",
label: "General manager email",
get: (c) => c.attributes?.generalManagerEmail,
},
{
key: "generalManagerPhone",
label: "General manager phone",
get: (c) => c.attributes?.generalManagerPhone,
},
];
/** The nationality-based document setting code for a company. */
private documentSettingCodeFor(
nationality: CompanyNationality | null | undefined,
): string {
return nationality === CompanyNationality.Foreign
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
async createCompany(dto: CreateCompanyDto): Promise<Company> { async createCompany(dto: CreateCompanyDto): Promise<Company> {
const exists = await this.companiesRepo.existsByTin(dto.tin); const exists = await this.companiesRepo.existsByTin(dto.tin);
if (exists) { if (exists) {
@@ -134,15 +196,13 @@ export class CompaniesService {
input.type, input.type,
); );
if (existing) continue; if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference( // No reference yet — these profiles await backoffice approval, which
input.type, // is when the reference is minted (see setCompanyProfileStatus).
);
await this.companyProfilesRepo.create({ await this.companyProfilesRepo.create({
companyId: company.id, companyId: company.id,
type: input.type, type: input.type,
reference,
businessLicense: input.businessLicense ?? null, businessLicense: input.businessLicense ?? null,
status: ProfileStatus.Active, status: ProfileStatus.Pending,
}); });
} }
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId( company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
@@ -251,12 +311,11 @@ export class CompaniesService {
type, type,
); );
if (existing) continue; if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(type); // No reference yet — minted on backoffice approval (setCompanyProfileStatus).
await this.companyProfilesRepo.create({ await this.companyProfilesRepo.create({
companyId, companyId,
type, type,
reference, status: ProfileStatus.Pending,
status: ProfileStatus.Active,
}); });
} }
} }
@@ -527,6 +586,8 @@ export class CompaniesService {
attrUpdates.contactPersonEmail = dto.contactPersonEmail; attrUpdates.contactPersonEmail = dto.contactPersonEmail;
if (dto.contactPersonPhone !== undefined) if (dto.contactPersonPhone !== undefined)
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone); attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
if (dto.contactVerifiedPhone !== undefined)
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
if (dto.generalManagerName !== undefined) if (dto.generalManagerName !== undefined)
attrUpdates.generalManagerName = dto.generalManagerName; attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined) if (dto.generalManagerEmail !== undefined)
@@ -622,12 +683,33 @@ export class CompaniesService {
profileId: string, profileId: string,
status: ProfileStatus, status: ProfileStatus,
): Promise<CompanyProfile> { ): Promise<CompanyProfile> {
const updated = await this.companyProfilesRepo.updateStatus( const existing = await this.companyProfilesRepo.findById(profileId);
profileId, if (!existing)
status, 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) if (!updated)
throw new NotFoundException(`Company profile ${profileId} not found`); throw new NotFoundException(`Company profile ${profileId} not found`);
// Approving any profile promotes a pending company to active, so the
// customer can start working as soon as their first profile is cleared.
if (status === ProfileStatus.Active) {
const company = await this.companiesRepo.findById(updated.companyId);
if (company && company.status === CompanyStatus.Pending) {
await this.companiesRepo.update(updated.companyId, {
status: CompanyStatus.Active,
});
}
}
return updated; return updated;
} }
@@ -649,7 +731,7 @@ export class CompaniesService {
const existing = await this.companyProfilesRepo.findByType(companyId, type); const existing = await this.companyProfilesRepo.findByType(companyId, type);
if (existing) { if (existing) {
throw new ConflictException( 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 }); await this.profilesRepo.update(profile.id, { onboardingStep: step });
} }
/**
* Server-driven onboarding requirements for the current user's company.
*
* The backend resolves the nationality-based document set, checks which
* company documents and per-profile licenses are already uploaded, and reports
* exactly what is still outstanding. The portal renders this list verbatim and
* relies on `isComplete` to decide when to auto-finish — it never decides for
* itself which documents apply or which fields are mandatory.
*/
async getOnboardingRequirements(
userId: string,
): Promise<OnboardingRequirementsResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
// 1. Required company-information fields.
const missingInfo = this.REQUIRED_COMPANY_INFO.filter(
(f) => !f.get(company),
).map((f) => ({ key: f.key, label: f.label }));
// 2. Nationality-based company documents + which are already uploaded.
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
const [setting, uploadedFiles] = await Promise.all([
this.fileUploadSettingsService
.getByCode(documentSettingCode)
.catch(() => null),
this.filesService.findByResource(company.id, "companies"),
]);
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
const documents = (setting?.fields ?? [])
.slice()
.sort((a, b) => a.displayOrder - b.displayOrder)
.map((f) => ({
fileKey: f.fileKey,
fileLabel: f.fileLabel,
helpText: f.helpText ?? null,
isRequired: f.isRequired,
isMultiple: f.isMultiple,
maxFiles: f.maxFiles,
allowedExtensions: f.allowedExtensions,
maxSizeMb: f.maxSizeMb,
displayOrder: f.displayOrder,
uploaded: uploadedCodes.has(f.fileKey),
}));
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
// 3. Per-operational-profile business licenses.
const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({
profileId: p.id,
type: p.type,
reference: p.reference ?? "",
uploaded: (p.businessLicenseFiles?.length ?? 0) > 0,
}));
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
const outstanding = [
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
...missingLicenses.map(
(p) =>
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
),
];
// Progress spans every required item the user has to satisfy: company-info
// fields, required documents and one license per operational profile.
const requiredDocCount = documents.filter((d) => d.isRequired).length;
const total =
this.REQUIRED_COMPANY_INFO.length +
requiredDocCount +
licenseProfiles.length;
const completed =
total -
(missingInfo.length + missingDocs.length + missingLicenses.length);
return new OnboardingRequirementsResponseDto({
documentSettingCode,
nationality: company.nationality ?? CompanyNationality.Ethiopian,
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
documents,
licenseProfiles,
progress: { completed, total },
isComplete: outstanding.length === 0,
onboardingCompleted: profile.onboardingCompleted,
outstanding,
});
}
/**
* Submit onboarding for review. Validation is delegated entirely to
* getOnboardingRequirements (the same source of truth the portal renders), so
* the gate can never drift from what the UI shows. On success the company and
* all its operational profiles move to PENDING — the backoffice approves each
* profile before it can be used (see setCompanyProfileStatus).
*/
async markOnboardingComplete( async markOnboardingComplete(
userId: string, userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> { ): Promise<{ profile: ExternalProfile; company: Company }> {
@@ -821,23 +997,21 @@ export class CompaniesService {
throw new NotFoundException(`Profile for user ${userId} not found`); throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId; const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId);
// Guard against finishing on a still-draft company (TIN never filled in). const requirements = await this.getOnboardingRequirements(userId);
if (!company.tin || company.tin.startsWith("D")) { if (!requirements.isComplete) {
throw new BadRequestException( throw new BadRequestException(
"Company information is incomplete — please fill in your company details before finishing.", requirements.outstanding[0] ??
"Your onboarding is incomplete. Please complete all required steps before submitting.",
); );
} }
// Every operational profile must have at least one business-license file // Send every operational profile in for approval; the company itself becomes
// (stored directly on the profile). // active once the backoffice approves at least one profile.
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
for (const cp of profiles) { for (const cp of profiles) {
if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) { if (cp.status !== ProfileStatus.Pending) {
throw new BadRequestException( await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending);
`Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`,
);
} }
} }
@@ -852,6 +1026,25 @@ export class CompaniesService {
return this.getCompanyInfoByUserId(userId); return this.getCompanyInfoByUserId(userId);
} }
/**
* Block a customer from booking under a profile that isn't approved yet.
* Called from the booking-create path for self-service bookings; staff- and
* government-initiated bookings bypass this. No-op when the profile can't be
* found (defensive — resolution is best-effort upstream).
*/
async assertCompanyProfileApprovedForBooking(
companyProfileId: string,
): Promise<void> {
const profile = await this.companyProfilesRepo.findById(companyProfileId);
if (!profile) return;
if (profile.status !== ProfileStatus.Active) {
const role = profile.type.replace(/_/g, " ");
throw new ForbiddenException(
`Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`,
);
}
}
/** /**
* Authorize and resolve a company_profile that must belong to the current * Authorize and resolve a company_profile that must belong to the current
* user's company — used before accepting/returning its license files. * user's company — used before accepting/returning its license files.

View File

@@ -30,7 +30,11 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
} }
async generateReference(type: ProfileType): Promise<string> { 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( const result = await this.repository.query(
`SELECT nextval('${seqName}') AS next_id`, `SELECT nextval('${seqName}') AS next_id`,
); );

View File

@@ -0,0 +1,78 @@
/**
* Server-driven description of what a company still needs to finish onboarding.
*
* The portal renders this verbatim instead of deciding for itself which
* documents apply or which fields are mandatory: the backend resolves the
* nationality-based document set, checks which files are already uploaded, and
* reports exactly what is outstanding. `isComplete` is the single source of
* truth the wizard uses to auto-finish.
*/
export interface OnboardingInfoField {
key: string;
label: string;
}
export interface OnboardingDocumentField {
fileKey: string;
fileLabel: string;
helpText: string | null;
isRequired: boolean;
isMultiple: boolean;
maxFiles: number;
allowedExtensions: string[];
maxSizeMb: number;
displayOrder: number;
/** True when a file with this code is already stored for the company. */
uploaded: boolean;
}
export interface OnboardingLicenseProfile {
profileId: string;
type: string;
reference: string;
/** True when at least one business-license file is stored on the profile. */
uploaded: boolean;
}
export class OnboardingRequirementsResponseDto {
/** Resolved document setting code (by nationality) the docs were drawn from. */
documentSettingCode: string;
nationality: string;
/** Required company-information fields and whether each is filled. */
companyInfo: {
complete: boolean;
missingFields: OnboardingInfoField[];
};
/** The document fields the portal should render, with upload state. */
documents: OnboardingDocumentField[];
/** Per-operational-profile business-license requirements. */
licenseProfiles: OnboardingLicenseProfile[];
/** Overall setup progress across fields + documents + licenses. */
progress: { completed: number; total: number };
/** True once every required field, document and license is satisfied. */
isComplete: boolean;
/** Whether the user has already submitted onboarding (awaiting approval). */
onboardingCompleted: boolean;
/** Human-readable list of everything still outstanding (empty when complete). */
outstanding: string[];
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
this.documentSettingCode = init.documentSettingCode;
this.nationality = init.nationality;
this.companyInfo = init.companyInfo;
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;
this.progress = init.progress;
this.isComplete = init.isComplete;
this.onboardingCompleted = init.onboardingCompleted;
this.outstanding = init.outstanding;
}
}

View File

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

View File

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

View File

@@ -67,6 +67,16 @@ export class UpdateProfileDto {
@IsValidPhone() @IsValidPhone()
contactPersonPhone?: string; 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() @IsOptional()
@IsString() @IsString()
generalManagerName?: string; generalManagerName?: string;

View File

@@ -40,14 +40,19 @@ export class CompanyProfile extends BaseEntity {
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType }) @Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
type!: 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({ @Column({
name: "reference", name: "reference",
type: "varchar", type: "varchar",
length: 20, length: 20,
nullable: false, nullable: true,
unique: true,
}) })
reference!: string; reference!: string | null;
@Column({ @Column({
name: "status", name: "status",

View File

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

View File

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

View File

@@ -1,11 +1,14 @@
import { import {
ActionIcon, ActionIcon,
Badge,
Box, Box,
Card, Card,
Group, Group,
SegmentedControl,
Stack, Stack,
Text, Text,
TextInput, TextInput,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -32,7 +35,7 @@ import {
} from "@/components/customers"; } from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { Company } from "@/types/customer"; import type { Company, CompanyStatus } from "@/types/customer";
import { import {
DataTable, DataTable,
DataTableFooter, DataTableFooter,
@@ -45,14 +48,17 @@ export default function CustomersPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300); const [debouncedQuery] = useDebouncedValue(query, 300);
// "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review).
const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>("");
const filter = useMemo( const filter = useMemo(
() => ({ () => ({
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
search: debouncedQuery, search: debouncedQuery,
status: statusFilter || undefined,
}), }),
[pagination.pageIndex, pagination.pageSize, debouncedQuery], [pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
); );
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} })); const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
@@ -107,7 +113,25 @@ export default function CustomersPage() {
{ {
id: "status", id: "status",
header: "Status", header: "Status",
cell: ({ row }) => <CompanyStatusBadge status={row.original.status} />, cell: ({ row }) => {
const pending = (row.original.companyProfiles ?? []).filter(
(p) => p.status === "pending",
).length;
return (
<Group gap={6} wrap="nowrap">
<CompanyStatusBadge status={row.original.status} />
{pending > 0 ? (
<Tooltip
label={`${pending} profile${pending > 1 ? "s" : ""} awaiting approval`}
>
<Badge color="yellow" variant="light" size="sm" radius="sm">
{pending} pending
</Badge>
</Tooltip>
) : null}
</Group>
);
},
}, },
{ {
id: "contact", id: "contact",
@@ -216,6 +240,20 @@ export default function CustomersPage() {
style={{ flex: 1, minWidth: "240px" }} style={{ flex: 1, minWidth: "240px" }}
radius="lg" radius="lg"
/> />
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
onChange={(v) => {
setStatusFilter(v === "all" ? "" : (v as CompanyStatus));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending approval", value: "pending" },
{ label: "Active", value: "active" },
]}
/>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""} {total} record{total !== 1 ? "s" : ""}
</Text> </Text>

View File

@@ -1,4 +1,5 @@
import { AppLayout, type SidebarItem } from "@/components/AppLayout"; import { AppLayout, type SidebarItem } from "@/components/AppLayout";
import { useDisclosure } from "@mantine/hooks";
import { import {
CalendarCheck, CalendarCheck,
Clock, Clock,
@@ -8,9 +9,7 @@ import {
MapPin, MapPin,
Receipt, Receipt,
Settings, Settings,
Sparkles,
} from "lucide-react"; } from "lucide-react";
import { useDisclosure } from "@mantine/hooks";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import {
Navigate, Navigate,
@@ -21,8 +20,11 @@ import {
useNavigate, useNavigate,
} from "react-router-dom"; } from "react-router-dom";
import useAuth from "./hooks/useAuth"; import OnboardingResumeBanner, {
AccountReviewBanner,
} from "./components/onboarding/OnboardingResumeBanner";
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
import useAuth from "./hooks/useAuth";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage"; import MyPortalPage from "./pages/MyPortalPage";
import MySignaturePage from "./pages/MySignaturePage"; import MySignaturePage from "./pages/MySignaturePage";
@@ -37,11 +39,11 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import EditBookingPage from "./pages/bookings/EditBookingPage"; import EditBookingPage from "./pages/bookings/EditBookingPage";
import MyBookings from "./pages/bookings/MyBookings"; import MyBookings from "./pages/bookings/MyBookings";
import NewBookingPage from "./pages/bookings/NewBookingPage"; import NewBookingPage from "./pages/bookings/NewBookingPage";
import ContractsList from "./pages/contracts/ContractsList";
import ContractDetailPage from "./pages/contracts/ContractDetailPage"; import ContractDetailPage from "./pages/contracts/ContractDetailPage";
import ContractsList from "./pages/contracts/ContractsList";
import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import PaymentFailurePage from "./pages/payments/PaymentFailurePage"; import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import TrackingPage from "./pages/tracking/TrackingPage"; import TrackingPage from "./pages/tracking/TrackingPage";
function FullScreenSpinner() { function FullScreenSpinner() {
@@ -146,10 +148,10 @@ function OnboardingGate() {
return ( return (
<> <>
{needsOnboarding && !wizardOpen && ( {needsOnboarding && (
<OnboardingResumeBanner onResume={openWizard} /> <OnboardingResumeBanner onResume={openWizard} />
)} )}
{awaitingApproval && <PendingApprovalBanner />} {!needsOnboarding && <AccountReviewBanner />}
<Outlet /> <Outlet />
<OnboardingWizardDialog <OnboardingWizardDialog
opened={needsOnboarding && wizardOpen} 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. */ /** Keeps authenticated users off the login/signup pages. */
function RedirectIfAuthed() { function RedirectIfAuthed() {
const { isPending, isAuthenticated } = useAuth(); const { isPending, isAuthenticated } = useAuth();

View File

@@ -0,0 +1,60 @@
import { Box, Button, Tooltip } from "@mantine/core";
import { Link } from "react-router-dom";
import { Lock, Plus } from "lucide-react";
import useAuth from "@/hooks/useAuth";
interface NewBookingButtonProps {
label?: string;
size?: string;
mt?: string;
}
/**
* New-booking entry point that respects approval status: a customer can only
* create bookings under a profile once the backoffice has approved it. While the
* active profile is pending the button is disabled with an explanation, so the
* gate is communicated rather than silently failing at submit time.
*/
export function NewBookingButton({
label = "New booking",
size,
mt,
}: NewBookingButtonProps) {
const { canBook, activeProfileStatus } = useAuth();
if (!canBook) {
const message =
activeProfileStatus === "pending"
? "Your profile is awaiting approval. You'll be able to create bookings as soon as it's approved."
: "Bookings aren't available for this profile yet.";
return (
<Tooltip label={message} multiline w={250} withArrow position="bottom-end">
<Box mt={mt}>
<Button
color="edr-green"
radius="md"
size={size}
disabled
leftSection={<Lock size={16} />}
>
{label}
</Button>
</Box>
</Tooltip>
);
}
return (
<Button
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
size={size}
mt={mt}
leftSection={<Plus size={16} />}
>
{label}
</Button>
);
}

View File

@@ -0,0 +1,191 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Clock } from "lucide-react";
import { api } from "@/services/api";
import useAuth from "@/hooks/useAuth";
import type { OnboardingRequirements } from "@/services/companies.service";
interface OnboardingResumeBannerProps {
/** Re-opens the onboarding wizard. */
onResume: () => void;
}
interface BannerCopy {
title: string;
subtitle: string;
cta: string;
}
/**
* Wording is driven entirely by the backend's outstanding-items list — the
* client never decides what's required, it just narrates what's left.
*/
function getCopy(
requirements: OnboardingRequirements | undefined,
pct: number,
): BannerCopy {
// No data yet (or nothing started) — treat it as a fresh start.
if (!requirements || requirements.progress.completed === 0) {
return {
title: "Set up your company profile",
subtitle: "Unlock bookings, tracking and billing — it only takes a minute.",
cta: "Start onboarding",
};
}
// Everything's filled in but not yet submitted for review.
if (requirements.isComplete) {
return {
title: "Everything's ready to go",
subtitle: "Submit your profile to send it for approval.",
cta: "Submit for review",
};
}
const remaining = requirements.outstanding.length;
if (remaining <= 2) {
return {
title: `Almost done — you're ${pct}% set up`,
subtitle: `Just ${remaining} more ${
remaining === 1 ? "item" : "items"
} to finish: ${requirements.outstanding.join(", ")}.`,
cta: "Finish onboarding",
};
}
return {
title: `You're ${pct}% set up`,
subtitle: `${requirements.progress.completed} of ${requirements.progress.total} details added — finish to unlock bookings, tracking and billing.`,
cta: "Continue onboarding",
};
}
/** Circular percentage meter that reads at a glance against the dark banner. */
function ProgressRing({ pct }: { pct: number }) {
const size = 56;
const stroke = 5;
const r = (size - stroke) / 2;
const circumference = 2 * Math.PI * r;
const offset = circumference * (1 - pct / 100);
return (
<span className="relative flex shrink-0 items-center justify-center">
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="rgba(255,255,255,0.22)"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="#6ee7b7"
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{ transition: "stroke-dashoffset 600ms ease" }}
/>
</svg>
<span className="absolute text-sm font-bold text-white">{pct}%</span>
</span>
);
}
/**
* Prominent banner shown on onboarding-allowed pages after the wizard is
* dismissed. Progress and copy are read straight from the backend's onboarding
* requirements, so the banner always agrees with the wizard about what's left.
*/
export default function OnboardingResumeBanner({
onResume,
}: OnboardingResumeBannerProps) {
const requirementsQuery = useQuery(
api.companies.onboardingRequirements.queryOptions({ retry: false }),
);
const requirements = requirementsQuery.data;
const { completed, total } = requirements?.progress ?? {
completed: 0,
total: 0,
};
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
const { title, subtitle, cta } = getCopy(requirements, pct);
return (
<div className="bg-gradient-to-r from-[#065f46] via-[#0A6F4D] to-[#0A8A5F] px-6 py-4 shadow-md">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<ProgressRing pct={pct} />
<span className="flex flex-col gap-0.5">
<span className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#6ee7b7] opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#6ee7b7]" />
</span>
<span className="text-base font-bold tracking-tight text-white">
{title}
</span>
</span>
<span className="text-sm text-white/80">{subtitle}</span>
</span>
</div>
<button
type="button"
onClick={onResume}
className="inline-flex items-center gap-2 rounded-lg bg-white px-5 py-2.5 text-sm font-semibold text-[#0A6F4D] shadow-sm transition-transform hover:scale-[1.02] hover:bg-white/95"
>
{cta}
<ArrowRight size={16} />
</button>
</div>
</div>
);
}
/**
* Shown once onboarding is submitted but the company's operational profiles are
* still being reviewed. Communicates that approval is per-profile and that
* bookings unlock as each profile is cleared. Self-hides when nothing is pending.
*/
export function AccountReviewBanner() {
const { company } = useAuth();
const profiles = company?.company?.companyProfiles ?? [];
const pending = profiles.filter((p) => p.status === "pending");
const approved = profiles.filter((p) => p.status === "active");
if (profiles.length === 0 || pending.length === 0) return null;
const pendingLabel = pending
.map((p) => p.type.replace(/_/g, " "))
.join(", ");
return (
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<Clock size={18} />
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-semibold text-amber-900">
Your account is under review
</span>
<span className="text-xs text-amber-800">
We're reviewing your {pendingLabel}{" "}
{pending.length === 1 ? "profile" : "profiles"}. You can create
bookings under a profile as soon as it's approved.
</span>
</span>
</div>
<span className="text-xs font-medium text-amber-800">
{approved.length} of {profiles.length} approved
</span>
</div>
</div>
);
}

View File

@@ -14,8 +14,11 @@ import {
ArrowRight, ArrowRight,
Building2, Building2,
CheckCircle2, CheckCircle2,
Clock,
FileText, FileText,
Globe2, Globe2,
PartyPopper,
ShieldCheck,
UploadCloud, UploadCloud,
User, User,
UserCheck, UserCheck,
@@ -43,6 +46,7 @@ type FormStep =
| "company" | "company"
| "personnel" | "personnel"
| "contact" | "contact"
| "verify"
| "poa" | "poa"
| "documents" | "documents"
| "additional"; | "additional";
@@ -50,6 +54,7 @@ const FORM_STEPS: FormStep[] = [
"company", "company",
"personnel", "personnel",
"contact", "contact",
"verify",
"poa", "poa",
"documents", "documents",
"additional", "additional",
@@ -90,6 +95,11 @@ const STEP_META: Record<
title: "Contact Person", title: "Contact Person",
description: "Who should we reach out to about this account?", 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: { poa: {
icon: <FileText size={20} />, icon: <FileText size={20} />,
title: "Power of Attorney", title: "Power of Attorney",
@@ -142,10 +152,14 @@ export default function OnboardingWizardDialog({
onClose, onClose,
}: OnboardingWizardDialogProps) { }: OnboardingWizardDialogProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user, company, onboardingStep } = useAuth(); const { user, company, onboardingStep, onboardingCompleted } = useAuth();
const existingProfiles = company?.company?.companyProfiles ?? []; const existingProfiles = company?.company?.companyProfiles ?? [];
const companyAlreadyStarted = Boolean(company?.company?.id); const companyAlreadyStarted = Boolean(company?.company?.id);
// 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 = const savedNationality =
(company?.company?.nationality as CompanyNationality | null) ?? null; (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 // Phases: nationality → role → form. If a draft already exists, resume
// straight into the form with nationality + roles pre-selected. // straight into the form with nationality + roles pre-selected.
const [phase, setPhase] = useState<"nationality" | "role" | "form">( const [phase, setPhase] = useState<"nationality" | "role" | "form">(
companyAlreadyStarted ? "form" : "nationality", companyAlreadyStarted
? hasOperationalProfiles
? "form"
: "role"
: "nationality",
); );
const [nationality, setNationality] = useState<CompanyNationality | null>( const [nationality, setNationality] = useState<CompanyNationality | null>(
savedNationality, savedNationality,
@@ -174,6 +192,10 @@ export default function OnboardingWizardDialog({
// Mirror of CompanyProfileForm's active step so the global header + progress // Mirror of CompanyProfileForm's active step so the global header + progress
// pill can reflect it (the form no longer renders its own stepper). // pill can reflect it (the form no longer renders its own stepper).
const [formStep, setFormStep] = useState<FormStep>(resumeFormStep); const [formStep, setFormStep] = useState<FormStep>(resumeFormStep);
// Once submission succeeds we swap the whole wizard body for a congratulations
// panel, and keep the modal open (the gate would otherwise tear it down the
// moment onboardingCompleted flips true).
const [completed, setCompleted] = useState(false);
// Saved profile data, for rehydrating the form fields after a refresh. // Saved profile data, for rehydrating the form fields after a refresh.
const profileQuery = useQuery( const profileQuery = useQuery(
@@ -184,6 +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( const refreshInfo = useCallback(
() => () =>
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -225,7 +260,10 @@ export default function OnboardingWizardDialog({
} }
return api.companies.completeOnboarding.call(); return api.companies.completeOnboarding.call();
}, },
onSuccess: refreshInfo, onSuccess: async () => {
await refreshInfo();
setCompleted(true);
},
onError: (err) => setStartError(extractApiError(err).message), onError: (err) => setStartError(extractApiError(err).message),
}); });
@@ -260,7 +298,9 @@ export default function OnboardingWizardDialog({
resumedRef.current = true; resumedRef.current = true;
setRoles(existingProfiles.map((p) => p.type)); setRoles(existingProfiles.map((p) => p.type));
setNationality(savedNationality); 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); const idx = FORM_STEPS.indexOf(resumeFormStep);
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx; if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -329,8 +369,22 @@ export default function OnboardingWizardDialog({
const stepMeta = STEP_META[activeStep]; const stepMeta = STEP_META[activeStep];
const activeIdx = WIZARD_STEPS.indexOf(activeStep); const activeIdx = WIZARD_STEPS.indexOf(activeStep);
// Closing from the congratulations panel also clears the completed flag so a
// future reopen (shouldn't happen once onboarded) starts clean.
const handleClose = useCallback(() => {
if (completed) setCompleted(false);
onClose();
}, [completed, onClose]);
// Prefer the backend-resolved document code; fall back to the local mapping
// only until the requirements query lands (the documents step is reached well
// after the draft — and thus the requirements — exist).
const resolvedDocumentSettingCode =
requirementsQuery.data?.documentSettingCode ??
documentSettingCode(effectiveNationality);
const formProps = { const formProps = {
documentSettingCode: documentSettingCode(effectiveNationality), documentSettingCode: resolvedDocumentSettingCode,
documentFiles, documentFiles,
onDocumentFilesChange: setDocumentFiles, onDocumentFilesChange: setDocumentFiles,
user, user,
@@ -346,16 +400,20 @@ export default function OnboardingWizardDialog({
roleProfiles, roleProfiles,
licenseFiles, licenseFiles,
onLicenseChange: setLicenseFiles, 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 ( return (
<Modal <Modal
opened={opened} opened={opened || completed}
onClose={onClose} onClose={handleClose}
withCloseButton withCloseButton={!completed}
closeOnClickOutside={false} closeOnClickOutside={false}
closeOnEscape closeOnEscape={!completed}
size={1040} size={720}
radius="lg" radius="lg"
padding="xl" padding="xl"
centered centered
@@ -371,6 +429,7 @@ export default function OnboardingWizardDialog({
} }
}} }}
title={ title={
completed ? null : (
<Stack gap="md"> <Stack gap="md">
<Box> <Box>
<Group gap="sm" mb={4}> <Group gap="sm" mb={4}>
@@ -383,8 +442,12 @@ export default function OnboardingWizardDialog({
</Box> </Box>
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} /> <ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
</Stack> </Stack>
)
} }
> >
{completed ? (
<OnboardingCompletePanel onClose={handleClose} />
) : (
<Stack gap="xl"> <Stack gap="xl">
{phase === "nationality" ? ( {phase === "nationality" ? (
@@ -438,10 +501,65 @@ export default function OnboardingWizardDialog({
<CompanyProfileForm {...formProps} /> <CompanyProfileForm {...formProps} />
)} )}
</Stack> </Stack>
)}
</Modal> </Modal>
); );
} }
/**
* Replaces the wizard body once onboarding is submitted: congratulates the user
* and sets the expectation that their company is now under review, and that
* bookings unlock per profile as the team approves each one.
*/
function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
return (
<Stack gap="lg" align="center" py="md" ta="center">
<Box
className="flex h-16 w-16 items-center justify-center rounded-full"
style={{ background: "var(--mantine-color-edr-green-1)" }}
>
<PartyPopper size={32} className="text-[var(--mantine-color-edr-green-7)]" />
</Box>
<Box>
<Title order={3}>You're all set!</Title>
<Text c="edr-muted" size="sm" mt={4} maw={460}>
Thanks for completing your company profile. Your application has been
submitted and is now with our team for review.
</Text>
</Box>
<Stack
gap="sm"
w="100%"
maw={460}
p="md"
className="rounded-lg"
style={{ background: "var(--mantine-color-edr-green-0)" }}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
<Clock size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" />
<Text size="sm" ta="left">
Each operational profile (importer, exporter, freight forwarder) is
reviewed and approved individually.
</Text>
</Group>
<Group gap="sm" wrap="nowrap" align="flex-start">
<ShieldCheck size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" />
<Text size="sm" ta="left">
You can start creating bookings under a profile as soon as it's
approved we'll let you know the moment that happens.
</Text>
</Group>
</Stack>
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
Go to my dashboard
</Button>
</Stack>
);
}
/** /**
* Continuous progress pill: a single rounded track that fills left-to-right as * Continuous progress pill: a single rounded track that fills left-to-right as
* the user advances, with faint ticks marking each step boundary. * the user advances, with faint ticks marking each step boundary.

View File

@@ -89,6 +89,7 @@ export const URL_CONSTANTS = {
ONBOARDING_START: "/api/companies/onboarding/start", ONBOARDING_START: "/api/companies/onboarding/start",
ONBOARDING_STEP: "/api/companies/onboarding-step", ONBOARDING_STEP: "/api/companies/onboarding-step",
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements",
DASHBOARD: "/api/companies/dashboard", DASHBOARD: "/api/companies/dashboard",
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info", FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,

View File

@@ -74,13 +74,6 @@ const useAuth = () => {
setCookie("auth-token", res.token, 7); setCookie("auth-token", res.token, 7);
setCookie("refresh-token", res.refreshToken, 7); setCookie("refresh-token", res.refreshToken, 7);
await authQuery.refetch(); 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 }; return { success: true, data: res };
} catch (err) { } catch (err) {
return { success: false, error: extractApiError(err) }; return { success: false, error: extractApiError(err) };
@@ -164,6 +157,15 @@ const useAuth = () => {
companyInfo?.profile?.onboardingCompleted ?? false; companyInfo?.profile?.onboardingCompleted ?? false;
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null; const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
// Booking is gated on backoffice approval of the active operational profile:
// a customer can only book under a profile once its status is "active".
const activeProfile =
companyInfo?.company?.companyProfiles?.find(
(p) => p.id === activeCompanyProfileId,
) ?? null;
const activeProfileStatus = activeProfile?.status ?? null;
const canBook = activeProfileStatus === "active";
/** Refetch everything scoped to the active operational profile. */ /** Refetch everything scoped to the active operational profile. */
const invalidateScopedData = async () => { const invalidateScopedData = async () => {
await Promise.all([ await Promise.all([
@@ -232,6 +234,8 @@ const useAuth = () => {
customer: isAuthenticated ? (companyQuery.data ?? null) : null, customer: isAuthenticated ? (companyQuery.data ?? null) : null,
activeProfileType, activeProfileType,
activeCompanyProfileId, activeCompanyProfileId,
activeProfileStatus,
canBook,
companyType, companyType,
companyStatus, companyStatus,
isCompanyApproved, isCompanyApproved,

View File

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

View File

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

View File

@@ -1,15 +1,30 @@
import { Box, Group, Text } from "@mantine/core"; import { Box, Group, Text } from "@mantine/core";
import { memo } from "react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { memo } from "react";
import { cv } from "../constants"; 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 { interface StatKpiProps {
icon: LucideIcon; icon: LucideIcon;
label: string; label: string;
value: string; value: string;
delta: 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; divider?: boolean;
loading?: boolean;
} }
export const StatKpi = memo(function StatKpi({ export const StatKpi = memo(function StatKpi({
@@ -17,30 +32,67 @@ export const StatKpi = memo(function StatKpi({
label, label,
value, value,
delta, delta,
deltaColor, accent,
deltaTone,
divider, divider,
loading,
}: StatKpiProps) { }: 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 ( return (
<Box <Box
px={4}
className={ 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 chip + metric label, aligned on one line. */}
<Icon size={15} color={cv("edr-muted")} className="shrink-0" /> <Group gap={12} wrap="nowrap" align="start">
<Text fz={12} fw={600} c="edr-muted" truncate> <Box
{label} 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> </Text>
</Group> {delta && !loading && (
<Group gap={8} align="flex-end" wrap="nowrap"> <Box
<Text fz={22} fw={800} lh={1} c="edr-text" truncate> px={8}
{value} py={3}
</Text> className="inline-flex w-fit rounded-full"
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate> style={{ background: pill.bg, maxWidth: "100%" }}
>
<Text fz={10} fw={700} lh={1.4} truncate style={{ color: pill.fg }}>
{delta} {delta}
</Text> </Text>
</Box>
)}
</Box>
<Text fz={12} mt="xs" fw={600} c="edr-muted" truncate>
{label}
</Text>
</Box>
</Group> </Group>
{/* Value + its trend pill, grouped together at the bottom of the cell. */}
</Box> </Box>
); );
}); });

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import { import {
Alert, Alert,
Button, Button,
Checkbox,
Divider, Divider,
Group, Group,
Loader, Loader,
PinInput,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
@@ -16,7 +16,11 @@ import {
AlertCircle, AlertCircle,
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
// UserCheck, CheckCircle2,
RotateCw,
ShieldCheck,
Smartphone,
UserCheck,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -37,15 +41,27 @@ import RoleLicenseStep, {
type RoleLicenseProfile, type RoleLicenseProfile,
} from "@/components/onboarding/RoleLicenseStep"; } from "@/components/onboarding/RoleLicenseStep";
import ETradeInfo from "@/components/onboarding/ETradeInfo"; import ETradeInfo from "@/components/onboarding/ETradeInfo";
import { extractApiError } from "@/utils/result";
type CompanyStep = type CompanyStep =
| "company" | "company"
| "personnel" | "personnel"
| "contact" | "contact"
| "verify"
| "poa" | "poa"
| "documents" | "documents"
| "additional"; | "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({ const onboardingSchema = z.object({
companyFirstName: z.string().min(1, "First name is required"), companyFirstName: z.string().min(1, "First name is required"),
companyLastName: z.string().min(1, "Last name is required"), companyLastName: z.string().min(1, "Last name is required"),
@@ -73,11 +89,13 @@ const onboardingSchema = z.object({
renewedFrom: z.string().optional(), renewedFrom: z.string().optional(),
renewalDate: z.string().optional(), renewalDate: z.string().optional(),
renewedTo: z.string().optional(), renewedTo: z.string().optional(),
region: z.string().optional(), // Address fields are user-entered and required (the registration/license
zone: z.string().optional(), // fields above are read-only confirmations pulled from eTrade).
woreda: z.string().optional(), region: z.string().min(1, "Region is required"),
kebele: z.string().optional(), zone: z.string().min(1, "Zone is required"),
houseNo: z.string().optional(), 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(), etradePhone: z.string().optional(),
contactPersonFirstName: z contactPersonFirstName: z
.string() .string()
@@ -95,12 +113,11 @@ const onboardingSchema = z.object({
.string() .string()
.min(1, "Contact person phone is required") .min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"), .refine(isValidPhone, "Enter a valid phone number"),
generalManagerFirstName: z.string().min(1, "GM first name is required"), generalManagerName: z.string().min(1, "Manager name is required"),
generalManagerLastName: z.string().min(1, "GM last name is required"), generalManagerEmail: z.string().email("Invalid Manager email"),
generalManagerEmail: z.string().email("Invalid GM email"),
generalManagerPhone: z generalManagerPhone: z
.string() .string()
.min(1, "GM phone is required") .min(1, "Manager phone is required")
.refine(isValidPhone, "Enter a valid phone number"), .refine(isValidPhone, "Enter a valid phone number"),
poaFirstName: z.string().optional(), poaFirstName: z.string().optional(),
poaLastName: z.string().optional(), poaLastName: z.string().optional(),
@@ -152,6 +169,7 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"contactPersonEmail", "contactPersonEmail",
"contactPersonPhone", "contactPersonPhone",
], ],
verify: [],
poa: [], poa: [],
documents: [], documents: [],
additional: [], 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. */ /** 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) { switch (step) {
case "company": case "company":
return { 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({ export default function CompanyProfileForm({
documentSettingCode, documentSettingCode,
documentFiles: controlledFiles, documentFiles: controlledFiles,
@@ -327,6 +362,7 @@ export default function CompanyProfileForm({
roleProfiles, roleProfiles,
licenseFiles, licenseFiles,
onLicenseChange, onLicenseChange,
submitError,
}: { }: {
documentSettingCode: string; documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>; documentFiles?: Record<string, File | File[] | null>;
@@ -354,6 +390,8 @@ export default function CompanyProfileForm({
/** Newly-selected license files per profile id. */ /** Newly-selected license files per profile id. */
licenseFiles?: Record<string, File[]>; licenseFiles?: Record<string, File[]>;
onLicenseChange?: (value: Record<string, File[]>) => void; 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 [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -441,6 +479,36 @@ export default function CompanyProfileForm({
values: rehydrate ? toFormValues(rehydrate) : undefined, 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 // The business owner/manager pulled from eTrade — powers "Use owner as
// manager" on the General Manager step. // manager" on the General Manager step.
const [etradeOwner, setEtradeOwner] = useState<{ const [etradeOwner, setEtradeOwner] = useState<{
@@ -449,11 +517,6 @@ export default function CompanyProfileForm({
email?: string; email?: string;
} | null>(null); } | 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) => { const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
// Company name comes from the eTrade manager/owner name on the license. // Company name comes from the eTrade manager/owner name on the license.
if (data.managerName) { if (data.managerName) {
@@ -476,18 +539,9 @@ export default function CompanyProfileForm({
"etradePhone", "etradePhone",
toEthiopianE164(data.regularPhone || data.mobilePhone), toEthiopianE164(data.regularPhone || data.mobilePhone),
); );
// companyAddress is composed reactively from the address fields below, so
// Compose a readable company address from the granular eTrade parts. // setting region/zone/woreda/kebele/houseNo above is enough — no need to
const addressParts = [ // compose it here.
data.houseNo,
data.kebele,
data.woreda,
data.zone,
data.region,
].filter((part) => part && part.trim());
if (addressParts.length) {
setValue("companyAddress", addressParts.join(", "));
}
// Pre-fill the company contact phone from eTrade's mobile number. // Pre-fill the company contact phone from eTrade's mobile number.
const mobile = toEthiopianE164(data.mobilePhone || data.regularPhone); 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). */ /** Copy the General Manager into the Contact Person fields (still editable). */
const toggleGmAsContact = (checked: boolean) => { const useGmAsContact = () => {
setGmIsContact(checked); setValue("contactPersonName", watch("generalManagerName"), {
if (!checked) return; shouldValidate: true,
setValue("contactPersonFirstName", watch("generalManagerFirstName")); });
setValue("contactPersonLastName", watch("generalManagerLastName"));
setValue("contactPersonEmail", watch("generalManagerEmail")); 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). */ /** Copy the Contact Person into the PoA fields (still editable). */
const toggleContactAsPoa = (checked: boolean) => { const useContactAsPoa = () => {
setContactIsPoa(checked); setValue("poaName", watch("contactPersonName"));
if (!checked) return;
setValue("poaFirstName", watch("contactPersonFirstName"));
setValue("poaLastName", watch("contactPersonLastName"));
setValue("poaEmail", watch("contactPersonEmail")); setValue("poaEmail", watch("contactPersonEmail"));
setValue("poaPhone", watch("contactPersonPhone")); 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); 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 // 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. // progress bar all derive from this so adding/removing a step is one edit.
const stepOrder: CompanyStep[] = [ const stepOrder: CompanyStep[] = [
"company", "company",
"personnel", "personnel",
"contact", "contact",
"verify",
"poa", "poa",
"documents", "documents",
"additional", "additional",
@@ -589,6 +746,20 @@ export default function CompanyProfileForm({
handleSubmit((data) => onSubmit(buildPayload(data, user)))(); handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return; 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 // The documents step has nothing to persist; field steps validate + save
// before advancing. // before advancing.
if (step !== "documents") { if (step !== "documents") {
@@ -678,78 +849,62 @@ export default function CompanyProfileForm({
/> />
</SimpleGrid> </SimpleGrid>
{hasRegistrationDetails && (
<> <>
<Divider my="sm" /> <Divider my="sm" />
<Group gap="xs" align="center">
<Text fw={600} size="sm" c="edr-text"> <Text fw={600} size="sm" c="edr-text">
Registration Details Registration Details
</Text> </Text>
<Text size="xs" c="edr-muted"> <Text size="xs" c="dimmed">
Auto-filled from eTrade these fields cannot be edited. from eTrade · read-only
</Text> </Text>
</Group>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <ReadOnlyField
label="License Number" label="License Number"
readOnly value={watch("licenceNumber")}
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.licenceNumber?.message}
{...register("licenceNumber")}
/> />
<TextInput <ReadOnlyField
label="Status" label="Status"
readOnly value={watch("statusDescription")}
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.statusDescription?.message}
{...register("statusDescription")}
/> />
</SimpleGrid> <ReadOnlyField
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Date Registered" label="Date Registered"
readOnly value={watch("dateRegistered")}
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.dateRegistered?.message}
{...register("dateRegistered")}
/> />
<TextInput <ReadOnlyField
label="Renewal Date" label="Renewal Date"
readOnly value={watch("renewalDate")}
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.renewalDate?.message}
{...register("renewalDate")}
/> />
</SimpleGrid> <ReadOnlyField
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Renewed From" label="Renewed From"
readOnly value={watch("renewedFrom")}
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.renewedFrom?.message}
{...register("renewedFrom")}
/> />
<TextInput <ReadOnlyField
label="Renewed To" label="Renewed To"
readOnly value={watch("renewedTo")}
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.renewedTo?.message}
{...register("renewedTo")}
/> />
</SimpleGrid> </SimpleGrid>
</>
)}
<Text fw={600} size="sm" c="edr-text" mt="md"> <Divider my="sm" />
<Text fw={600} size="sm" c="edr-text">
Address Information Address Information
</Text> </Text>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Region" label="Region"
readOnly placeholder="Tigray"
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }} required
error={errors.region?.message} error={errors.region?.message}
{...register("region")} {...register("region")}
/> />
<TextInput <TextInput
label="Zone" label="Zone"
readOnly placeholder="EASTERN TIGRAY"
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }} required
error={errors.zone?.message} error={errors.zone?.message}
{...register("zone")} {...register("zone")}
/> />
@@ -757,15 +912,15 @@ export default function CompanyProfileForm({
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Woreda" label="Woreda"
readOnly placeholder="EROB"
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }} required
error={errors.woreda?.message} error={errors.woreda?.message}
{...register("woreda")} {...register("woreda")}
/> />
<TextInput <TextInput
label="Kebele" label="Kebele"
readOnly placeholder="ARAS"
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }} required
error={errors.kebele?.message} error={errors.kebele?.message}
{...register("kebele")} {...register("kebele")}
/> />
@@ -773,8 +928,8 @@ export default function CompanyProfileForm({
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="House No" label="House No"
readOnly placeholder="House Number"
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }} required
error={errors.houseNo?.message} error={errors.houseNo?.message}
{...register("houseNo")} {...register("houseNo")}
/> />
@@ -785,7 +940,6 @@ export default function CompanyProfileForm({
/> />
</SimpleGrid> </SimpleGrid>
</> </>
</>
)} )}
{step === "personnel" && ( {step === "personnel" && (
@@ -834,15 +988,33 @@ export default function CompanyProfileForm({
{step === "contact" && ( {step === "contact" && (
<> <>
<Group justify="space-between" align="center" wrap="nowrap">
<Text fw={600} size="sm" c="edr-text"> <Text fw={600} size="sm" c="edr-text">
Contact Person Contact Person
</Text> </Text>
<Checkbox <Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
<Button
variant="light"
color="edr-green" color="edr-green"
label="Use General Manager as contact person" size="xs"
checked={gmIsContact} leftSection={<UserCheck size={14} />}
onChange={(e) => toggleGmAsContact(e.currentTarget.checked)} 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"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label={<>First Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>} 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" && ( {step === "poa" && (
<> <>
<Group justify="space-between" align="center" wrap="nowrap">
<Text size="sm" c="edr-muted"> <Text size="sm" c="edr-muted">
Power of Attorney details are optional. Fill them in if you have Power of Attorney details are optional. Fill them in if you
them, or skip to continue. have them, or skip to continue.
</Text> </Text>
<Checkbox {watch("contactPersonName") && (
<Button
variant="light"
color="edr-green" color="edr-green"
label="Use contact person as Power of Attorney" size="xs"
checked={contactIsPoa} leftSection={<UserCheck size={14} />}
onChange={(e) => toggleContactAsPoa(e.currentTarget.checked)} onClick={useContactAsPoa}
/> style={{ flexShrink: 0 }}
<SimpleGrid cols={2} spacing="md"> >
Use contact person
</Button>
)}
</Group>
<TextInput <TextInput
label="PoA First Name" label="PoA Name"
placeholder="First name" placeholder="Authorized Representative Name"
error={errors.poaFirstName?.message} error={errors.poaName?.message}
{...register("poaFirstName")} {...register("poaName")}
/> />
<TextInput
label="PoA Last Name"
placeholder="Last name"
error={errors.poaLastName?.message}
{...register("poaLastName")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="PoA Email" label="PoA Email"
@@ -964,7 +1236,7 @@ export default function CompanyProfileForm({
<RoleLicenseStep <RoleLicenseStep
profiles={roleProfiles ?? []} profiles={roleProfiles ?? []}
value={licenseFiles ?? {}} value={licenseFiles ?? {}}
onChange={onLicenseChange ?? (() => {})} onChange={onLicenseChange ?? (() => { })}
/> />
)} )}
@@ -983,6 +1255,17 @@ export default function CompanyProfileForm({
</Alert> </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"> <Group justify="space-between" pt="xs">
{showBack ? ( {showBack ? (
<Button <Button
@@ -1001,7 +1284,8 @@ export default function CompanyProfileForm({
disabled={ disabled={
isPending || isPending ||
saving || saving ||
(step === "documents" && !hasDocuments && loadingDocuments) (step === "documents" && !hasDocuments && loadingDocuments) ||
(step === "verify" && !phoneVerified)
} }
loading={isPending || saving} loading={isPending || saving}
rightSection={ rightSection={
@@ -1013,10 +1297,10 @@ export default function CompanyProfileForm({
) : undefined ) : undefined
} }
> >
{step === "documents" {step === "documents" || step === "verify"
? "Continue" ? "Continue"
: step === "additional" : step === "additional"
? "Finish onboarding" ? "Submit for review"
: "Save & Continue"} : "Save & Continue"}
</Button> </Button>
</Group> </Group>

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
ActionIcon, ActionIcon,
@@ -25,7 +25,6 @@ import {
LayoutList, LayoutList,
MoreVertical, MoreVertical,
Package, Package,
Plus,
Search, Search,
Train, Train,
Wallet, Wallet,
@@ -651,15 +650,7 @@ export default function MyBookings() {
Track every cargo booking from draft to delivery. Track every cargo booking from draft to delivery.
</Text> </Text>
</Box> </Box>
<Button <NewBookingButton label="New booking" />
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
>
New booking
</Button>
</Group> </Group>
{/* ── Summary stat cards ──────────────────────────────────────── */} {/* ── Summary stat cards ──────────────────────────────────────── */}
@@ -823,17 +814,7 @@ export default function MyBookings() {
: "Create your first booking to get started."} : "Create your first booking to get started."}
</Text> </Text>
{!query && ( {!query && (
<Button <NewBookingButton label="Create first booking" size="sm" mt="md" />
component={Link}
to="/bookings/new"
size="sm"
color="edr-green"
radius="md"
mt="md"
leftSection={<Plus size={15} />}
>
Create first booking
</Button>
)} )}
</Stack> </Stack>
) : ( ) : (

View File

@@ -29,7 +29,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { Navigate, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import { import {
BookingFormInputValues, BookingFormInputValues,
@@ -67,6 +67,12 @@ export default function NewBookingPage() {
api.bookings.referenceData.queryOptions(), api.bookings.referenceData.queryOptions(),
); );
// Booking is gated on profile approval: a customer whose active profile isn't
// approved yet is bounced back to the list, where the gate is explained.
if (!auth.isPending && auth.company && !auth.canBook) {
return <Navigate to="/bookings" replace />;
}
if (!auth.isPending && !auth.company) { if (!auth.isPending && !auth.company) {
return ( return (
<Box <Box

View File

@@ -44,6 +44,7 @@ import type {
CompanyProfileResponse, CompanyProfileResponse,
CreateCompanyPayload, CreateCompanyPayload,
DashboardSummary, DashboardSummary,
OnboardingRequirements,
ProfileTypeValue, ProfileTypeValue,
} from "./companies.service"; } from "./companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
@@ -171,6 +172,12 @@ export const api = {
"completeOnboarding", "completeOnboarding",
companiesService.completeOnboarding, companiesService.completeOnboarding,
), ),
onboardingRequirements: endpoint<void, OnboardingRequirements>(
"companies",
"onboardingRequirements",
companiesService.getOnboardingRequirements,
),
}, },
bookings: { bookings: {

View File

@@ -82,6 +82,47 @@ export interface CompanyInfoResponse {
company: CompanyResponse; company: CompanyResponse;
} }
/** A single onboarding document field, as resolved and described by the backend. */
export interface OnboardingDocumentField {
fileKey: string;
fileLabel: string;
helpText: string | null;
isRequired: boolean;
isMultiple: boolean;
maxFiles: number;
allowedExtensions: string[];
maxSizeMb: number;
displayOrder: number;
uploaded: boolean;
}
export interface OnboardingLicenseProfile {
profileId: string;
type: string;
reference: string;
uploaded: boolean;
}
/**
* Server-driven onboarding requirements. The portal renders this verbatim: the
* backend decides which documents apply (by nationality) and what is still
* outstanding, so the client never hardcodes required fields or document sets.
*/
export interface OnboardingRequirements {
documentSettingCode: string;
nationality: string;
companyInfo: {
complete: boolean;
missingFields: { key: string; label: string }[];
};
documents: OnboardingDocumentField[];
licenseProfiles: OnboardingLicenseProfile[];
progress: { completed: number; total: number };
isComplete: boolean;
onboardingCompleted: boolean;
outstanding: string[];
}
export interface CompanyProfileInput { export interface CompanyProfileInput {
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter"; type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
businessLicense?: string; businessLicense?: string;
@@ -229,6 +270,14 @@ export const companiesService = {
return unwrap(response.data); return unwrap(response.data);
}, },
/** Server-driven list of outstanding onboarding requirements + completeness. */
getOnboardingRequirements: async (): Promise<OnboardingRequirements> => {
const response = await client.get<ApiResponse<OnboardingRequirements>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_REQUIREMENTS,
);
return unwrap(response.data);
},
uploadDocuments: async ( uploadDocuments: async (
companyId: string, companyId: string,
files: Record<string, File | File[] | null>, files: Record<string, File | File[] | null>,

View File

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

View File

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

View File

@@ -38,7 +38,7 @@
"@prisma/client": "^6.19.3", "@prisma/client": "^6.19.3",
"@sendgrid/mail": "^8.1.0", "@sendgrid/mail": "^8.1.0",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", "@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", "@types/bcrypt": "^6.0.0",
"amqp-connection-manager": "^5.0.0", "amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1", "amqplib": "^2.0.1",

View File

@@ -496,7 +496,7 @@ async function seedPaymentMethods() {
{ type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' }, { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' },
{ type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' }, { type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' },
{ type: 'EBIRR', displayName: 'eBirr', 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: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' },
{ type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' }, { type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' },
]; ];

View File

@@ -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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service'; import { AgentsService } from './agents.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; 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'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
@ApiTags('Agents') @ApiTags('Agents')
@Controller('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) @UseGuards(IamJwtGuard)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
export class AgentsController { export class AgentsController {
constructor(private service: AgentsService) {} 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') @Post('bookings')
@ApiOperation({ summary: 'Create agent booking with cash payment' }) @ApiOperation({ summary: 'Create agent booking with cash payment' })
createBooking(@Body() dto: CreateAgentBookingDto) { createBooking(@Body() dto: CreateAgentBookingDto) {

View File

@@ -133,4 +133,10 @@ export class AgentsService {
take: 20 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;
}
} }

View File

@@ -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 { 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 { Throttle } from '@nestjs/throttler';
import { BookingsService } from './bookings.service'; import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service'; import { GuestBookingService } from './guest-booking.service';
@@ -47,7 +46,7 @@ export class BookingsController {
} }
@Get('by-device') @Get('by-device')
@IsPublic() @SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'Get bookings by device ID', 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.' 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() @Get()
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'List all bookings with filters (Admin/Agent)',
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.' 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' }) @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
@@ -102,7 +101,7 @@ export class BookingsController {
} }
@Post('guest') @Post('guest')
@IsPublic() @SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)', 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. description: `Creates a booking without requiring login. Supports all four booking types.
@@ -256,6 +255,7 @@ export class BookingsController {
} }
@Get('saved-passengers') @Get('saved-passengers')
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'Get saved passenger profiles', summary: 'Get saved passenger profiles',
description: 'Retrieve saved passenger details by userId (if logged in) or deviceId (for guest users)' 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') @Get(':id/usage')
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'Check if booking is in use',
description: 'Returns list of modules/data that reference this booking' description: 'Returns list of modules/data that reference this booking'
}) })
@ApiResponse({ status: 200, description: 'Usage information retrieved' }) @ApiResponse({ status: 200, description: 'Usage information retrieved' })
@@ -428,6 +428,7 @@ export class BookingsController {
} }
@Get(':bookingRef') @Get(':bookingRef')
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'Get booking details by reference (no auth required)', 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.' 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') @Delete(':id')
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'Delete booking (admin only)',
description: 'Permanently deletes a booking record' description: 'Permanently deletes a booking record'
}) })
@ApiResponse({ status: 200, description: 'Booking deleted successfully' }) @ApiResponse({ status: 200, description: 'Booking deleted successfully' })
@@ -463,8 +464,8 @@ export class BookingsController {
} }
@Patch(':id') @Patch(':id')
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'Update booking details',
description: 'Updates booking information for admin/agent operations' description: 'Updates booking information for admin/agent operations'
}) })
@ApiResponse({ status: 200, description: 'Booking updated successfully' }) @ApiResponse({ status: 200, description: 'Booking updated successfully' })

View File

@@ -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({ const booking = await this.prisma.booking.findUnique({
where: { bookingRef }, where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
include: { include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },

View File

@@ -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 { 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 { PassengersService } from './passengers.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto'; import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
@@ -19,6 +19,7 @@ export class PassengersController {
) {} ) {}
@Get() @Get()
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'List all passengers with filters (Admin/Agent)', summary: 'List all passengers with filters (Admin/Agent)',
description: 'Returns paginated list of passengers with search filters' description: 'Returns paginated list of passengers with search filters'
@@ -86,6 +87,7 @@ export class PassengersController {
} }
@Post('verify-fayda') @Post('verify-fayda')
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'Verify Ethiopian national ID via Verifayda 2.0', summary: 'Verify Ethiopian national ID via Verifayda 2.0',
description: `**Standalone endpoint for pre-verification of Ethiopian national IDs** 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') @Post('register')
@SetMetadata('isPublic', true)
@UseGuards(OptionalJwtGuard) @UseGuards(OptionalJwtGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({
@@ -249,6 +252,7 @@ The API automatically detects:
} }
@Post('save-details') @Post('save-details')
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'Bulk save passenger details from booking flow', summary: 'Bulk save passenger details from booking flow',
description: `**Endpoint for saving multiple passengers in a single booking** 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') @Patch(':id')
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'Update passenger details', summary: 'Update passenger details',
description: 'Updates passenger information for admin/agent operations' description: 'Updates passenger information for admin/agent operations'
@@ -358,6 +363,7 @@ Returns saved passenger details with generated IDs and confirmation.`,
} }
@Delete(':id') @Delete(':id')
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'Delete passenger (admin only)', summary: 'Delete passenger (admin only)',
description: 'Permanently deletes a passenger record and associated data' 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') @Get(':id/usage')
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'Check if passenger is in use', summary: 'Check if passenger is in use',
description: 'Returns list of modules/data that reference this passenger' description: 'Returns list of modules/data that reference this passenger'

View File

@@ -32,46 +32,20 @@ export class PassengersService {
const { search, verified, page = 1, pageSize = 20 } = filters; const { search, verified, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize; 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) { if (search) {
conditions.push(`( where.user = {
u.email ILIKE $${idx} OR OR: [
u.phone_number ILIKE $${idx} OR { email: { contains: search, mode: 'insensitive' } },
(u.name->>'en') ILIKE $${idx} OR { phone: { contains: search, mode: 'insensitive' } },
(u.name->>'am') ILIKE $${idx} { fullName: { contains: search, mode: 'insensitive' } },
)`); ],
params.push(`%${search}%`); };
idx++;
} }
if (verified !== undefined) { if (verified !== undefined) {
if (verified) { where.user = { ...(where.user ?? {}), faydaVerified: 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 } };
}
}
const where: any = {};
if (iamUserIdFilter) {
where.iamUserId = { in: iamUserIdFilter };
} }
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
@@ -81,8 +55,22 @@ export class PassengersService {
take: pageSize, take: pageSize,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
user: true,
loyalty: true, loyalty: true,
wallet: true,
_count: { select: { bookings: 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 }), this.prisma.passenger.count({ where }),
@@ -97,16 +85,68 @@ export class PassengersService {
: []; : [];
const iamMap = new Map(iamRows.map(r => [r.id, r])); 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 { return {
items: items.map(passenger => { items: items.map(passenger => {
const localUser = (passenger as any).user ?? null;
const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined; 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 { return {
id: passenger.id, id: passenger.id,
fullName: iam?.name?.en ?? iam?.name?.am ?? null, fullName: localUser?.fullName ?? iam?.name?.en ?? iam?.name?.am ?? savedProfile?.passengerName ?? guestSeat?.passengerName ?? null,
email: iam?.email ?? null, email: localUser?.email ?? iam?.email ?? savedProfile?.email ?? guestBooking?.contactEmail ?? null,
phone: iam?.phone_number ?? 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, 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', loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
loyaltyPoints: passenger.loyalty?.pointsBalance || 0, loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
totalBookings: passenger._count.bookings, totalBookings: passenger._count.bookings,
@@ -384,7 +424,21 @@ export class PassengersService {
const passenger = await this.prisma.passenger.findUnique({ where: { id } }); const passenger = await this.prisma.passenger.findUnique({ where: { id } });
if (!passenger) throw new NotFoundException('Passenger not found'); 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 }; return { deleted: true, passengerId: id };
} }

View File

@@ -7,6 +7,7 @@ import {
Post, Post,
Query, Query,
Res, Res,
SetMetadata,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { import {
@@ -17,7 +18,7 @@ import {
ApiOkResponse, ApiOkResponse,
ApiProduces, ApiProduces,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
import { SkipThrottle, Throttle } from "@nestjs/throttler"; import { SkipThrottle, Throttle } from "@nestjs/throttler";
import { Response } from "express"; import { Response } from "express";
import { PaymentsService } from "./payments.service"; import { PaymentsService } from "./payments.service";
@@ -65,7 +66,7 @@ export class PaymentsController {
} }
@Post("initiate") @Post("initiate")
@IsPublic() @SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: "Initiate payment with nationality-based payment methods", 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`, 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") @Get("intents/:bookingId")
@IsPublic() @SetMetadata('isPublic', true)
@ApiOperation({ summary: "Get payment intent status for a booking" }) @ApiOperation({ summary: "Get payment intent status for a booking" })
getIntent(@Param("bookingId") bookingId: string) { getIntent(@Param("bookingId") bookingId: string) {
return this.service.getIntentByBookingId(bookingId); return this.service.getIntentByBookingId(bookingId);
} }
@Get("waafi/return") @Get("waafi/return")
@IsPublic() @SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: summary:
"DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " + "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") @Get("methods")
@IsPublic() @SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: "List payment systems supported by the platform", summary: "List payment systems supported by the platform",
description: description:
@@ -136,7 +137,7 @@ export class PaymentsController {
} }
@Get("checkout") @Get("checkout")
@IsPublic() @SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: "Browser checkout redirect", summary: "Browser checkout redirect",
description: description:

View File

@@ -7,6 +7,7 @@ import {
Post, Post,
Patch, Patch,
Query, Query,
SetMetadata,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { import {
@@ -17,7 +18,6 @@ import {
ApiQuery, ApiQuery,
ApiResponse, ApiResponse,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
import { SeatsService } from "./seats.service"; import { SeatsService } from "./seats.service";
import { HoldSeatsDto } from "./seats.dto"; import { HoldSeatsDto } from "./seats.dto";
import { JwtGuard } from "../../common/jwt.guard"; import { JwtGuard } from "../../common/jwt.guard";
@@ -30,7 +30,7 @@ export class SeatsController {
// ── Seat Map ────────────────────────────────────────────────────────────── // ── Seat Map ──────────────────────────────────────────────────────────────
@Get("seatmap/:scheduleId") @Get("seatmap/:scheduleId")
@IsPublic() @SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: "Get seat map filtered by coach type", 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.`, 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") @Post("hold")
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: summary:
"Hold seats for 15 minutes before booking (Public - Guest booking supported)", "Hold seats for 15 minutes before booking (Public - Guest booking supported)",

View File

@@ -1,23 +1,38 @@
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Patch, SetMetadata, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { SkipThrottle } from '@nestjs/throttler';
import { SystemConfigService } from './system-config.service'; import { SystemConfigService } from './system-config.service';
import { IamGuard } from '../../common/iam-adapter'; import { IamGuard } from '../../common/iam-adapter';
import { Roles } from '../../common/roles.decorator'; import { Roles } from '../../common/roles.decorator';
@ApiTags('System Config') @ApiTags('Config')
@ApiBearerAuth('IAM-auth') @Controller('config')
@UseGuards(IamGuard)
@Roles('ADMIN')
@Controller('system-config')
export class SystemConfigController { export class SystemConfigController {
constructor(private service: SystemConfigService) {} 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() @Get()
@ApiBearerAuth('IAM-auth')
@UseGuards(IamGuard)
@Roles('ADMIN')
@ApiOperation({ summary: 'Get all system config (admin)' })
getAll() { getAll() {
return this.service.getAll(); return this.service.getAll();
} }
@Patch() @Patch()
@ApiBearerAuth('IAM-auth')
@UseGuards(IamGuard)
@Roles('ADMIN')
@ApiOperation({ summary: 'Update system config (admin)' })
update(@Body() body: Record<string, string>) { update(@Body() body: Record<string, string>) {
return this.service.updateMany(body); return this.service.updateMany(body);
} }

View File

@@ -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 { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service'; import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
@@ -9,6 +9,7 @@ export class TicketsController {
constructor(private service: TicketsService) {} constructor(private service: TicketsService) {}
@Post('generate/:bookingId') @Post('generate/:bookingId')
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'Generate ticket for booking (confirmation page)', 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.' 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') @Patch('update-seats/:bookingId')
@SetMetadata('isPublic', true)
@ApiOperation({ @ApiOperation({
summary: 'Update ticket seats before final confirmation', 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.' 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') @Get(':bookingRef')
@ApiOperation({ @SetMetadata('isPublic', true)
summary: 'Get ticket with QR code and passenger details (public)', @ApiOperation({ summary: 'Get ticket with QR code and passenger details (public)' })
})
getByRef(@Param('bookingRef') ref: string) { getByRef(@Param('bookingRef') ref: string) {
return this.service.getByRef(ref); return this.service.getByRef(ref);
} }

View File

@@ -85,6 +85,7 @@ export class TicketsService {
ticketNumber: t.barcodePayload, ticketNumber: t.barcodePayload,
bookingRef: t.bookingRef, bookingRef: t.bookingRef,
booking: { booking: {
id: t.booking.id,
bookingRef: t.booking.bookingRef, bookingRef: t.booking.bookingRef,
status: t.booking.status, status: t.booking.status,
bookingType: t.booking.bookingType, bookingType: t.booking.bookingType,

View File

@@ -91,7 +91,7 @@ export default function PassengersPage() {
case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : ''; case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : '';
case 'gender': return p.gender || ''; case 'gender': return p.gender || '';
case 'nationality': return p.nationality || ''; case 'nationality': return p.nationality || '';
case 'verified': return p.nationalId ? 'Yes' : 'No'; case 'verified': return p.faydaVerified ? 'Yes' : 'No';
default: return ''; default: return '';
} }
}); });
@@ -117,15 +117,15 @@ export default function PassengersPage() {
</div> </div>
), ),
}, },
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone }, { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || 'N/A' },
{ key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
{ key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || '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: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' },
{ {
key: 'verified', label: 'Status', key: 'verified', label: 'Status',
render: (p: any) => ( render: (p: any) => (
<Badge variant="status" status={p.nationalId ? 'CONFIRMED' : 'PENDING'}> <Badge variant="status" status={p.faydaVerified ? 'CONFIRMED' : 'PENDING'}>
{p.nationalId ? 'Verified' : 'Unverified'} {p.faydaVerified ? 'Verified' : 'Unverified'}
</Badge> </Badge>
), ),
}, },
@@ -192,7 +192,7 @@ export default function PassengersPage() {
{selectedPassenger && (() => { {selectedPassenger && (() => {
const p = selectedPassenger; const p = selectedPassenger;
const isVerified = !!p.faydaVerified || !!p.nationalId; 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; const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE;
return ( return (

View File

@@ -11,6 +11,7 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { ticketsApi, apiClient, stationsApi, excessBaggageApi } from '@/lib/api'; import { ticketsApi, apiClient, stationsApi, excessBaggageApi } from '@/lib/api';
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils'; import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
export default function TicketsPage() { export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' }); const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
@@ -25,15 +26,23 @@ export default function TicketsPage() {
const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null); const [selectedTicket, setSelectedTicket] = useState<any>(null);
const { user } = useAuthStore();
// Excess baggage state // Excess baggage state
const [excessModalOpen, setExcessModalOpen] = useState(false); const [excessModalOpen, setExcessModalOpen] = useState(false);
const [excessTicket, setExcessTicket] = useState<any>(null); const [excessTicket, setExcessTicket] = useState<any>(null);
const [excessKg, setExcessKg] = useState(''); const [excessKg, setExcessKg] = useState('');
const [excessCollectCash, setExcessCollectCash] = useState(false); const [excessCollectCash, setExcessCollectCash] = useState(false);
const [excessAgentId, setExcessAgentId] = useState('');
const [excessError, setExcessError] = useState<string | null>(null); const [excessError, setExcessError] = useState<string | null>(null);
const [excessResult, setExcessResult] = useState<any>(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 }) => ( 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"> <div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p> <p className="text-xs text-muted-foreground mb-1">{label}</p>
@@ -105,7 +114,6 @@ export default function TicketsPage() {
setExcessTicket(ticket); setExcessTicket(ticket);
setExcessKg(''); setExcessKg('');
setExcessCollectCash(false); setExcessCollectCash(false);
setExcessAgentId('');
setExcessError(null); setExcessError(null);
setExcessResult(null); setExcessResult(null);
setExcessModalOpen(true); setExcessModalOpen(true);
@@ -114,9 +122,11 @@ export default function TicketsPage() {
const handleExcessSubmit = async (e: React.FormEvent) => { const handleExcessSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!excessTicket) return; if (!excessTicket) return;
const agentId = agentData?.id;
if (!agentId) { setExcessError('No agent profile found for your account'); return; }
await excessMutation.mutateAsync({ await excessMutation.mutateAsync({
bookingId: excessTicket.bookingId, bookingId: excessTicket.booking?.id ?? excessTicket.bookingId,
agentId: excessAgentId, agentId,
excessWeightKg: parseInt(excessKg), excessWeightKg: parseInt(excessKg),
collectCash: excessCollectCash, collectCash: excessCollectCash,
}); });
@@ -371,6 +381,13 @@ export default function TicketsPage() {
]; ];
const actions = [ 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', label: 'Board',
onClick: handleBoard, onClick: handleBoard,
@@ -411,13 +428,6 @@ export default function TicketsPage() {
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, 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 || []; const stations = stationsData?.items || [];
@@ -736,16 +746,16 @@ export default function TicketsPage() {
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Booking: <span className="font-semibold text-foreground">{excessTicket?.booking?.bookingRef}</span> Booking: <span className="font-semibold text-foreground">{excessTicket?.booking?.bookingRef}</span>
</div> </div>
<div> {agentData && (
<label className="label">Agent ID</label> <div className="text-sm text-muted-foreground">
<input Agent: <span className="font-semibold text-foreground">{agentData.agentCode}</span>
className="input"
placeholder="Enter your agent ID"
value={excessAgentId}
onChange={(e) => setExcessAgentId(e.target.value)}
required
/>
</div> </div>
)}
{!agentData && (
<div className="text-sm text-amber-600 dark:text-amber-400">
No agent profile linked to your account.
</div>
)}
<div> <div>
<label className="label">Excess weight (kg)</label> <label className="label">Excess weight (kg)</label>
<input <input

View File

@@ -413,6 +413,6 @@ export const excessBaggageApi = {
// System Config API // System Config API
export const systemConfigApi = { export const systemConfigApi = {
getAll: () => apiClient.get<Record<string, string>>('/system-config'), getAll: () => apiClient.get<Record<string, string>>('/config'),
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/system-config', data), update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/config', data),
}; };

View File

@@ -4,7 +4,7 @@ export const dynamic = 'force-dynamic';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store'; 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 { apiClient } from '@/lib/api-client';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-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 [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
const confirmAttempted = useRef(false); const confirmAttempted = useRef(false);
const confirmMutation = useMutation({
mutationFn: () => apiClient.patch(`/bookings/${bookingId}/confirm`, { status: 'SUCCEEDED' }),
});
const { data: _booking } = useQuery<BookingWithTicket>({ const { data: _booking } = useQuery<BookingWithTicket>({
queryKey: ['booking', bookingId], queryKey: ['booking', bookingId],
queryFn: async (): Promise<BookingWithTicket> => { queryFn: async (): Promise<BookingWithTicket> => {
@@ -54,13 +50,20 @@ export default function ConfirmationPage() {
useEffect(() => { useEffect(() => {
if (bookingId && !confirmAttempted.current) { if (bookingId && !confirmAttempted.current) {
confirmAttempted.current = true; confirmAttempted.current = true;
confirmMutation.mutate();
// 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) => { apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => {
console.error('Failed to generate ticket:', err); console.error('Failed to generate ticket:', err);
}); });
} }
}, [bookingId, confirmMutation]); }).catch((err) => {
console.error('Failed to fetch booking status:', err);
});
}
}, [bookingId]);
const copyPNR = () => { const copyPNR = () => {
if (pnr) { if (pnr) {

View File

@@ -380,6 +380,7 @@ export default function PassengersPage() {
const router = useRouter(); const router = useRouter();
const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore(); const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore();
const { user, isAuthenticated, updateUser } = useAuthStore(); const { user, isAuthenticated, updateUser } = useAuthStore();
const isInitialized = useAuthStore((s) => s.isInitialized);
const [faydaEnabled, setFaydaEnabled] = useState(true); const [faydaEnabled, setFaydaEnabled] = useState(true);
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({}); const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -435,8 +436,8 @@ export default function PassengersPage() {
useEffect(() => { useEffect(() => {
const populateForm = async () => { const populateForm = async () => {
if (!isInitialized) return;
if (!isAuthenticated || !user?.id || !searchCriteria) { if (!isAuthenticated || !user?.id || !searchCriteria) {
console.log('Missing required data for population');
setFormInitialized(true); setFormInitialized(true);
return; return;
} }
@@ -475,7 +476,7 @@ export default function PassengersPage() {
}; };
populateForm(); populateForm();
}, [isAuthenticated, user, searchCriteria, setValue]); }, [isInitialized, isAuthenticated, user, searchCriteria, setValue]);
const openFaydaVerification = async (index: number) => { const openFaydaVerification = async (index: number) => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;

View File

@@ -60,21 +60,14 @@ export default function PaymentPage() {
const paymentMutation = useMutation({ const paymentMutation = useMutation({
mutationFn: async (data: any) => { mutationFn: async (data: any) => {
// For TELEBIRR and WAAFI, use the initiate endpoint // For all payment methods, use the initiate endpoint
if (data.method === 'TELEBIRR' || data.method === 'WAAFI') { try {
const response = await apiClient.post('/payments/initiate', { return await apiClient.post("/payments/initiate", {
bookingId: data.bookingId, bookingId: data.bookingId,
method: data.method, method: data.method,
paymentMethodId: data.paymentMethodId, 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) { } catch (error) {
console.log("Payment API not available, using mock payment"); console.log("Payment API not available, using mock payment");
// Mock payment response // Mock payment response

View File

@@ -15,21 +15,21 @@ class ApiClient {
this.client.interceptors.request.use((config) => { this.client.interceptors.request.use((config) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
if (token) { if (token && token !== 'null' && token !== 'undefined') {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
return config; return config;
}); });
const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me'];
this.client.interceptors.response.use( this.client.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
// Don't redirect if it's a login or register request (invalid credentials) const url: string = error.config?.url || '';
const isAuthEndpoint = error.config?.url?.includes('/auth/login') || const isPublic = PUBLIC_PREFIXES.some((p) => url.includes(p));
error.config?.url?.includes('/auth/register'); if (!isPublic && typeof window !== 'undefined') {
if (!isAuthEndpoint && typeof window !== 'undefined') {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user'); localStorage.removeItem('auth_user');
window.location.href = '/login'; window.location.href = '/login';

View File

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