mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat: enhance onboarding process and company profile management
- Updated CompanyInfoResponseDto to include company profile data in response. - Enhanced ResponseExternalProfileDto to include active profile type and onboarding details. - Added new fields to ExternalProfile entity for active profile type and onboarding status. - Implemented onboarding wizard dialog in the frontend to guide users through the onboarding process. - Introduced API endpoints for managing company profiles and onboarding steps. - Created migrations to add new columns for active mode and onboarding status in the database. - Added functionality to switch between operational modes (importer/exporter) and create profiles as needed. - Improved user experience by ensuring onboarding is enforced for new users and those without completed profiles.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddActiveModeAndOnboardingToExternalProfiles1791000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddActiveModeAndOnboardingToExternalProfiles1791000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
ADD COLUMN IF NOT EXISTS active_profile_type varchar(32);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
ADD COLUMN IF NOT EXISTS onboarding_step varchar(40);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
ADD COLUMN IF NOT EXISTS onboarding_completed boolean NOT NULL DEFAULT false;
|
||||
`);
|
||||
|
||||
// Existing users already use the portal — never re-gate them behind the
|
||||
// new onboarding wizard.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.external_profiles
|
||||
SET onboarding_completed = true
|
||||
WHERE onboarding_completed = false;
|
||||
`);
|
||||
|
||||
// Backfill the active mode for existing users from their company's
|
||||
// operational profiles. Prefer importer, then exporter, then whichever
|
||||
// single profile the company has (forwarder/dj/transporter).
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.external_profiles ep
|
||||
SET active_profile_type = cp.type
|
||||
FROM (
|
||||
SELECT DISTINCT ON (company_id) company_id, type
|
||||
FROM freight.company_profiles
|
||||
ORDER BY company_id,
|
||||
CASE type
|
||||
WHEN 'importer' THEN 0
|
||||
WHEN 'exporter' THEN 1
|
||||
ELSE 2
|
||||
END
|
||||
) cp
|
||||
WHERE ep.company_id = cp.company_id
|
||||
AND ep.active_profile_type IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
DROP COLUMN IF EXISTS onboarding_completed;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
DROP COLUMN IF EXISTS onboarding_step;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
DROP COLUMN IF EXISTS active_profile_type;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCompanyProfileIdToBookings1791000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddCompanyProfileIdToBookings1791000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS company_profile_id UUID;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_company_profile_id
|
||||
ON freight.bookings(company_profile_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_profile_id'
|
||||
) THEN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_company_profile_id"
|
||||
FOREIGN KEY (company_profile_id)
|
||||
REFERENCES freight.company_profiles(id);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Backfill by natural mapping: IMPORT → importer profile, EXPORT → exporter
|
||||
// profile, for each booking's own company.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET company_profile_id = cp.id
|
||||
FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = b.company_id
|
||||
AND b.company_profile_id IS NULL
|
||||
AND (
|
||||
(b.trade_direction = 'IMPORT' AND cp.type = 'importer') OR
|
||||
(b.trade_direction = 'EXPORT' AND cp.type = 'exporter')
|
||||
);
|
||||
`);
|
||||
|
||||
// Forwarder / single-profile companies: one profile per company, so the
|
||||
// mapping is unambiguous regardless of trade direction.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET company_profile_id = cp.id
|
||||
FROM freight.company_profiles cp
|
||||
JOIN freight.companies c ON c.id = cp.company_id
|
||||
WHERE cp.company_id = b.company_id
|
||||
AND c.type <> 'customer'
|
||||
AND b.company_profile_id IS NULL;
|
||||
`);
|
||||
|
||||
// Remaining customer-owned rows (e.g. DOMESTIC, or a direction with no
|
||||
// matching profile): attribute to the company's importer profile, else its
|
||||
// exporter profile, so nothing disappears from the customer's list.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET company_profile_id = cp.id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (company_id) company_id, id
|
||||
FROM freight.company_profiles
|
||||
ORDER BY company_id,
|
||||
CASE type
|
||||
WHEN 'importer' THEN 0
|
||||
WHEN 'exporter' THEN 1
|
||||
ELSE 2
|
||||
END
|
||||
) cp
|
||||
WHERE cp.company_id = b.company_id
|
||||
AND b.company_id IS NOT NULL
|
||||
AND b.company_profile_id IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_company_profile_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_bookings_company_profile_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS company_profile_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,15 @@ export class BookingsController {
|
||||
await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||
// No linked company yet → no bookings to show (avoids leaking all bookings).
|
||||
if (!companyId) return { items: [], total: 0 };
|
||||
return this.bookingsService.findAll(filter, companyId);
|
||||
// Scope to the active operational profile (importer/exporter) when one
|
||||
// resolves; otherwise fall back to company-level scoping.
|
||||
const companyProfileId =
|
||||
await this.bookingsService.resolveActiveCompanyProfileId(userId);
|
||||
return this.bookingsService.findAll(
|
||||
filter,
|
||||
companyId,
|
||||
companyProfileId ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('list-summary')
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface BookingListFilterOptions {
|
||||
schedulingStatuses?: string[];
|
||||
assignedToSchedule?: 'true' | 'false';
|
||||
companyId?: string;
|
||||
companyProfileId?: string;
|
||||
contractType?: string;
|
||||
serviceTypeId?: string;
|
||||
cargoTypeId?: string;
|
||||
@@ -559,6 +560,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
companyId: options.companyId,
|
||||
});
|
||||
}
|
||||
if (options.companyProfileId) {
|
||||
qb.andWhere('booking.company_profile_id = :companyProfileId', {
|
||||
companyProfileId: options.companyProfileId,
|
||||
});
|
||||
}
|
||||
if (options.contractType) {
|
||||
qb.andWhere('booking.contract_type = :contractType', {
|
||||
contractType: options.contractType,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Freight, SchedulingStatus } from '@edr/types';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { FilesService } from '../files/files.service';
|
||||
@@ -323,6 +324,29 @@ export class BookingsService {
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
// Stamp the operational profile this booking belongs to (importer/exporter)
|
||||
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
|
||||
// for non-government bookings with a resolved company; never blocks creation.
|
||||
let companyProfileId: string | null = null;
|
||||
if (!isGovernment && companyId) {
|
||||
let fallbackType: ProfileType | null = null;
|
||||
if (userId) {
|
||||
try {
|
||||
const { profile } =
|
||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
fallbackType = profile.activeProfileType ?? null;
|
||||
} catch {
|
||||
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
||||
}
|
||||
}
|
||||
companyProfileId =
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
companyId,
|
||||
tradeDirection,
|
||||
fallbackType,
|
||||
);
|
||||
}
|
||||
|
||||
const allowConsolidation =
|
||||
dto.freightType === 'CONTAINER'
|
||||
? await this.resolveConsolidation(containers, dto.allowConsolidation)
|
||||
@@ -348,6 +372,7 @@ export class BookingsService {
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
companyId: companyId ?? null,
|
||||
companyProfileId,
|
||||
isGovernment,
|
||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||
trainId: dto.trainId,
|
||||
@@ -504,6 +529,22 @@ export class BookingsService {
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
tradeDirection,
|
||||
};
|
||||
// If the route (hence trade direction) changed, re-stamp the operational
|
||||
// profile so an edited draft doesn't get stranded under the wrong profile.
|
||||
if (
|
||||
tradeDirection !== existing.tradeDirection &&
|
||||
!existing.isGovernment &&
|
||||
existing.companyId
|
||||
) {
|
||||
updates.companyProfileId =
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
existing.companyId,
|
||||
tradeDirection,
|
||||
existing.companyProfileId
|
||||
? undefined
|
||||
: (existing.companyProfile?.type as ProfileType | undefined),
|
||||
);
|
||||
}
|
||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
||||
if (dto.endDate) updates.endDate = new Date(dto.endDate);
|
||||
@@ -583,6 +624,7 @@ export class BookingsService {
|
||||
async findAll(
|
||||
filter: FilterBookingDto,
|
||||
forceCompanyId?: string,
|
||||
forceCompanyProfileId?: string,
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
@@ -597,7 +639,10 @@ export class BookingsService {
|
||||
assignedToSchedule: filter.assignedToSchedule,
|
||||
// A forced company scope (portal/customer) overrides any caller-provided
|
||||
// companyId so a customer can only ever see their own company's bookings.
|
||||
companyId: forceCompanyId ?? filter.companyId,
|
||||
// When an active profile resolves, scope to it; otherwise fall back to the
|
||||
// company so nothing breaks for not-yet-onboarded customers.
|
||||
companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId,
|
||||
companyProfileId: forceCompanyProfileId,
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
@@ -629,13 +674,18 @@ export class BookingsService {
|
||||
filter: FilterBookingDto,
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
// Scope to the active operational profile when one resolves; fall back to
|
||||
// company-level so not-yet-onboarded customers still see their payables.
|
||||
const companyProfileId =
|
||||
await this.companiesService.resolveActiveCompanyProfileId(userId);
|
||||
|
||||
return this.bookingsRepository.findAllPaginated({
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 20,
|
||||
statuses: BookingsService.PAYABLE_STATUSES,
|
||||
excludePaymentStatus: 'PAID',
|
||||
companyId: company.id,
|
||||
companyId: companyProfileId ? undefined : company.id,
|
||||
companyProfileId: companyProfileId ?? undefined,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -655,6 +705,15 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active company_profile id a customer's bookings should be
|
||||
* scoped to (importer/exporter mode). Null when not onboarded — callers fall
|
||||
* back to company-level scoping.
|
||||
*/
|
||||
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
||||
return this.companiesService.resolveActiveCompanyProfileId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a customer's access to a single booking. Staff are scoped at the
|
||||
* controller (they pass `isStaff`); for a customer, the booking must belong
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SchedulingStatus } from '@edr/types';
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
// import { Customer } from '../../customers/entities/customer.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
||||
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
|
||||
@@ -92,6 +93,20 @@ export class Booking extends BaseEntity {
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company | null;
|
||||
|
||||
/**
|
||||
* The operational profile (importer/exporter/forwarder) this booking belongs
|
||||
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
||||
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
|
||||
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
|
||||
* legacy/government/staff-created bookings.
|
||||
*/
|
||||
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
|
||||
companyProfileId?: string | null;
|
||||
|
||||
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||
@JoinColumn({ name: 'company_profile_id' })
|
||||
companyProfile?: CompanyProfile | null;
|
||||
|
||||
@Column({ name: 'is_government', type: 'boolean', default: false })
|
||||
isGovernment!: boolean;
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
||||
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
|
||||
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||
import {
|
||||
ResponseCompanyDto,
|
||||
ResponseCompanyProfileDto,
|
||||
@@ -105,6 +108,58 @@ export class CompaniesController {
|
||||
return profiles.map((p) => new ResponseCompanyProfileDto(p));
|
||||
}
|
||||
|
||||
@Post("company-profile")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a single operational profile for the current user's company and make it the active mode",
|
||||
})
|
||||
async createCompanyProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: CreateCompanyProfileDto,
|
||||
): Promise<ResponseCompanyProfileDto> {
|
||||
const profile = await this.companiesService.createCompanyProfileForUser(
|
||||
user.id,
|
||||
dto.type,
|
||||
dto.businessLicense,
|
||||
);
|
||||
return new ResponseCompanyProfileDto(profile);
|
||||
}
|
||||
|
||||
@Patch("active-mode")
|
||||
@ApiOperation({
|
||||
summary: "Switch the current user's active operational mode (importer/exporter)",
|
||||
})
|
||||
async setActiveMode(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: SetActiveModeDto,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } = await this.companiesService.setActiveMode(
|
||||
user.id,
|
||||
dto.type,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Patch("onboarding-step")
|
||||
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async setOnboardingStep(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: SetOnboardingStepDto,
|
||||
): Promise<void> {
|
||||
await this.companiesService.setOnboardingStep(user.id, dto.step);
|
||||
}
|
||||
|
||||
@Post("onboarding/complete")
|
||||
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
|
||||
async completeOnboarding(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } =
|
||||
await this.companiesService.markOnboardingComplete(user.id);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
// Used by portal
|
||||
@Post("create")
|
||||
@ApiOperation({
|
||||
|
||||
@@ -81,6 +81,18 @@ export class CompaniesService {
|
||||
attributes: dto.attributes ?? null,
|
||||
});
|
||||
|
||||
// Default active mode from the chosen role(s): importer wins when both are
|
||||
// picked, otherwise the first allowed type chosen.
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
const chosenTypes = (dto.companyProfiles ?? [])
|
||||
.map((p) => p.type)
|
||||
.filter((t) => allowedTypes.includes(t));
|
||||
const activeProfileType =
|
||||
chosenTypes.find((t) => t === ProfileType.importer) ??
|
||||
chosenTypes[0] ??
|
||||
allowedTypes[0] ??
|
||||
null;
|
||||
|
||||
const profile = await this.profilesRepo.create({
|
||||
userId: identity.userId,
|
||||
companyId: company.id,
|
||||
@@ -90,6 +102,8 @@ export class CompaniesService {
|
||||
phone: identity.phone,
|
||||
jobTitle: dto.jobTitle ?? null,
|
||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||
activeProfileType,
|
||||
onboardingStep: 'company',
|
||||
});
|
||||
|
||||
// Persist the operational role(s) chosen during onboarding. Types are
|
||||
@@ -174,6 +188,18 @@ export class CompaniesService {
|
||||
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
|
||||
if (!companyId) return this.emptyDashboardSummary();
|
||||
|
||||
// Scope KPIs to the active operational profile (importer/exporter mode) when
|
||||
// one resolves; otherwise aggregate across the whole company.
|
||||
const companyProfileId = profile?.activeProfileType
|
||||
? ((await this.companyProfilesRepo.findByType(
|
||||
companyId,
|
||||
profile.activeProfileType,
|
||||
)) ?? null)
|
||||
: null;
|
||||
const scope = companyProfileId
|
||||
? { companyProfileId: companyProfileId.id }
|
||||
: { companyId };
|
||||
|
||||
const now = new Date();
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
||||
@@ -191,22 +217,22 @@ export class CompaniesService {
|
||||
tonnagePrev,
|
||||
monthlyRows,
|
||||
] = await Promise.all([
|
||||
this.dashboardRepo.countDelivered(companyId, yearStart, now),
|
||||
this.dashboardRepo.countCommitted(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
|
||||
this.dashboardRepo.countDelivered(scope, yearStart, now),
|
||||
this.dashboardRepo.countCommitted(scope, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(scope, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(
|
||||
companyId,
|
||||
scope,
|
||||
prevYearStart,
|
||||
prevYearToDate,
|
||||
),
|
||||
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumCommittedTonnage(scope, yearStart, now),
|
||||
this.dashboardRepo.sumCommittedTonnage(
|
||||
companyId,
|
||||
scope,
|
||||
prevYearStart,
|
||||
prevYearToDate,
|
||||
),
|
||||
this.dashboardRepo.monthlyCommittedTonnage(
|
||||
companyId,
|
||||
scope,
|
||||
this.monthsAgo(now, 5),
|
||||
now,
|
||||
),
|
||||
@@ -505,4 +531,145 @@ export class CompaniesService {
|
||||
|
||||
return this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single operational profile for the current user's company and
|
||||
* make it the active mode in the same call. Powers the header "Switch to
|
||||
* Exporter/Importer" flow when the target profile doesn't exist yet.
|
||||
*/
|
||||
async createCompanyProfileForUser(
|
||||
userId: string,
|
||||
type: ProfileType,
|
||||
businessLicense?: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
|
||||
const companyId = profile.company?.id ?? profile.companyId;
|
||||
const company = await this.findCompanyById(companyId);
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
if (!allowedTypes.includes(type)) {
|
||||
throw new BadRequestException(
|
||||
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
||||
);
|
||||
}
|
||||
|
||||
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
||||
if (!created) {
|
||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||
created = await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
businessLicense: businessLicense ?? null,
|
||||
status: ProfileStatus.Active,
|
||||
});
|
||||
}
|
||||
|
||||
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the user's active operational mode. The target profile must already
|
||||
* exist — clients create it first via createCompanyProfileForUser.
|
||||
*/
|
||||
async setActiveMode(
|
||||
userId: string,
|
||||
type: ProfileType,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
|
||||
const companyId = profile.company?.id ?? profile.companyId;
|
||||
const company = await this.findCompanyById(companyId);
|
||||
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
if (!allowedTypes.includes(type)) {
|
||||
throw new BadRequestException(
|
||||
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
||||
if (!existing) {
|
||||
throw new ConflictException(
|
||||
`No ${type} profile exists yet — create it before switching`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
||||
|
||||
return this.getCompanyInfoByUserId(userId);
|
||||
}
|
||||
|
||||
async setOnboardingStep(userId: string, step: string): Promise<void> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
await this.profilesRepo.update(profile.id, { onboardingStep: step });
|
||||
}
|
||||
|
||||
async markOnboardingComplete(
|
||||
userId: string,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
await this.profilesRepo.update(profile.id, {
|
||||
onboardingCompleted: true,
|
||||
onboardingStep: 'done',
|
||||
});
|
||||
return this.getCompanyInfoByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which company_profile a new booking belongs to, from the company
|
||||
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
||||
* exporter profile; for DOMESTIC or a forwarder/single-profile company (or
|
||||
* when the natural profile doesn't exist) it falls back to the user's active
|
||||
* profile, then the company's first profile. Returns null when the company
|
||||
* has no profiles at all.
|
||||
*/
|
||||
async resolveCompanyProfileIdForBooking(
|
||||
companyId: string,
|
||||
tradeDirection: string,
|
||||
fallbackType?: ProfileType | null,
|
||||
): Promise<string | null> {
|
||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
if (profiles.length === 0) return null;
|
||||
|
||||
const naturalType =
|
||||
tradeDirection === 'IMPORT'
|
||||
? ProfileType.importer
|
||||
: tradeDirection === 'EXPORT'
|
||||
? ProfileType.exporter
|
||||
: null;
|
||||
|
||||
const byType = (type?: ProfileType | null) =>
|
||||
type ? profiles.find((p) => p.type === type) : undefined;
|
||||
|
||||
const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0];
|
||||
return match?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the company_profile a customer's data should be scoped to, from
|
||||
* their persisted active mode. Returns null when nothing can be resolved
|
||||
* (not onboarded yet) so callers can fall back to company-level scoping.
|
||||
*/
|
||||
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
const type = profile.activeProfileType;
|
||||
if (!type) return null;
|
||||
const match = company.companyProfiles?.find((p) => p.type === type);
|
||||
return match?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
@@ -31,6 +31,27 @@ export interface CurrencyTotal {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the dashboard is scoped to: a single operational profile (the active
|
||||
* importer/exporter mode) when one resolves, otherwise the whole company
|
||||
* (legacy / not-yet-onboarded fallback).
|
||||
*/
|
||||
export type DashboardScope =
|
||||
| { companyProfileId: string }
|
||||
| { companyId: string };
|
||||
|
||||
/** Apply the scope as a WHERE clause on a bookings query builder. */
|
||||
function applyScope(
|
||||
qb: SelectQueryBuilder<Booking>,
|
||||
scope: DashboardScope,
|
||||
): SelectQueryBuilder<Booking> {
|
||||
return 'companyProfileId' in scope
|
||||
? qb.where('b.company_profile_id = :companyProfileId', {
|
||||
companyProfileId: scope.companyProfileId,
|
||||
})
|
||||
: qb.where('b.company_id = :companyId', { companyId: scope.companyId });
|
||||
}
|
||||
|
||||
export interface MonthlyTonnage {
|
||||
year: number;
|
||||
month: number; // 1-12
|
||||
@@ -50,35 +71,33 @@ export class CompanyDashboardRepository {
|
||||
private readonly bookings: Repository<Booking>,
|
||||
) {}
|
||||
|
||||
/** Count of delivered/completed bookings for a company within [from, to). */
|
||||
async countDelivered(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
return this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
/** Count of delivered/completed bookings within [from, to) for the scope. */
|
||||
async countDelivered(scope: DashboardScope, from: Date, to: Date): Promise<number> {
|
||||
return applyScope(this.bookings.createQueryBuilder('b'), scope)
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */
|
||||
async countCommitted(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
return this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
/** Count of committed (non-draft, non-dead) bookings within [from, to) for the scope. */
|
||||
async countCommitted(scope: DashboardScope, from: Date, to: Date): Promise<number> {
|
||||
return applyScope(this.bookings.createQueryBuilder('b'), scope)
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/** Sum of paid booking totals, grouped by currency, within [from, to). */
|
||||
async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise<CurrencyTotal[]> {
|
||||
const rows = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('b.payment_currency', 'currency')
|
||||
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
/** Sum of paid booking totals, grouped by currency, within [from, to) for the scope. */
|
||||
async sumPaidSpendByCurrency(scope: DashboardScope, from: Date, to: Date): Promise<CurrencyTotal[]> {
|
||||
const rows = await applyScope(
|
||||
this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('b.payment_currency', 'currency')
|
||||
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total'),
|
||||
scope,
|
||||
)
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere("b.payment_status = 'PAID'")
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
@@ -88,12 +107,14 @@ export class CompanyDashboardRepository {
|
||||
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
|
||||
}
|
||||
|
||||
/** Total committed tonnage (cargo VGM) for a company within [from, to). */
|
||||
async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
const row = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
/** Total committed tonnage (cargo VGM) within [from, to) for the scope. */
|
||||
async sumCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise<number> {
|
||||
const row = await applyScope(
|
||||
this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'),
|
||||
scope,
|
||||
)
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
@@ -102,14 +123,16 @@ export class CompanyDashboardRepository {
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
/** Committed tonnage grouped by calendar month within [from, to). */
|
||||
async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise<MonthlyTonnage[]> {
|
||||
const rows = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
|
||||
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
|
||||
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
/** Committed tonnage grouped by calendar month within [from, to) for the scope. */
|
||||
async monthlyCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise<MonthlyTonnage[]> {
|
||||
const rows = await applyScope(
|
||||
this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
|
||||
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
|
||||
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'),
|
||||
scope,
|
||||
)
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
|
||||
@@ -8,7 +8,7 @@ export class CompanyInfoResponseDto {
|
||||
company: ResponseCompanyDto;
|
||||
|
||||
constructor(profile: ExternalProfile, company: Company) {
|
||||
this.profile = new ResponseExternalProfileDto(profile);
|
||||
this.profile = new ResponseExternalProfileDto(profile, company);
|
||||
this.company = new ResponseCompanyDto(company);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class CreateCompanyProfileDto {
|
||||
@IsEnum(ProfileType)
|
||||
type!: ProfileType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
import { Company } from '../entities/company.entity';
|
||||
import {
|
||||
ExternalProfile,
|
||||
} from '../entities/external-profile.entity';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class ResponseExternalProfileDto {
|
||||
id: string;
|
||||
@@ -11,10 +15,20 @@ export class ResponseExternalProfileDto {
|
||||
nationalId?: string | null;
|
||||
jobTitle?: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
/** The active operational mode (importer/exporter/forwarder). */
|
||||
activeProfileType?: ProfileType | null;
|
||||
/**
|
||||
* The id of the company_profile matching activeProfileType, resolved
|
||||
* server-side so the client never re-derives it. Null until a company
|
||||
* (with profiles) is loaded and a matching profile exists.
|
||||
*/
|
||||
activeCompanyProfileId?: string | null;
|
||||
onboardingStep?: string | null;
|
||||
onboardingCompleted: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(profile: ExternalProfile) {
|
||||
constructor(profile: ExternalProfile, company?: Company) {
|
||||
this.id = profile.id;
|
||||
this.userId = profile.userId;
|
||||
this.companyId = profile.companyId;
|
||||
@@ -25,6 +39,13 @@ export class ResponseExternalProfileDto {
|
||||
this.nationalId = profile.nationalId;
|
||||
this.jobTitle = profile.jobTitle;
|
||||
this.isPrimaryContact = profile.isPrimaryContact;
|
||||
this.activeProfileType = profile.activeProfileType ?? null;
|
||||
this.onboardingStep = profile.onboardingStep ?? null;
|
||||
this.onboardingCompleted = profile.onboardingCompleted ?? false;
|
||||
this.activeCompanyProfileId =
|
||||
company?.companyProfiles?.find(
|
||||
(p) => p.type === profile.activeProfileType,
|
||||
)?.id ?? null;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class SetActiveModeDto {
|
||||
@IsEnum(ProfileType)
|
||||
type!: ProfileType;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class SetOnboardingStepDto {
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
step!: string;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Company } from './company.entity';
|
||||
import { ProfileType } from './company-profile.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'external_profiles' })
|
||||
@Index(['userId'])
|
||||
@@ -36,4 +37,31 @@ export class ExternalProfile extends BaseEntity {
|
||||
|
||||
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
||||
isPrimaryContact!: boolean;
|
||||
|
||||
/**
|
||||
* The operational profile the user is currently "in" (importer vs exporter,
|
||||
* or the single forwarder profile). Drives header switching and scopes the
|
||||
* customer's bookings / dashboard to that company_profile. Nullable for
|
||||
* users who haven't picked a role yet.
|
||||
*/
|
||||
@Column({
|
||||
name: 'active_profile_type',
|
||||
type: 'varchar',
|
||||
length: 32,
|
||||
nullable: true,
|
||||
enum: ProfileType,
|
||||
})
|
||||
activeProfileType?: ProfileType | null;
|
||||
|
||||
/** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */
|
||||
@Column({
|
||||
name: 'onboarding_step',
|
||||
type: 'varchar',
|
||||
length: 40,
|
||||
nullable: true,
|
||||
})
|
||||
onboardingStep?: string | null;
|
||||
|
||||
@Column({ name: 'onboarding_completed', type: 'boolean', default: false })
|
||||
onboardingCompleted!: boolean;
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@ import {
|
||||
} from "react-router-dom";
|
||||
|
||||
import useAuth from "./hooks/useAuth";
|
||||
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
|
||||
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
||||
import MyPortalPage from "./pages/MyPortalPage";
|
||||
import ProfilePage from "./pages/ProfilePage";
|
||||
import MySignaturePage from "./pages/MySignaturePage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import LoginPage from "./pages/accounts/LoginPage";
|
||||
import OnboardingPage from "./pages/accounts/OnboardingPage";
|
||||
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
||||
import SignupPage from "./pages/accounts/SignupPage";
|
||||
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
|
||||
@@ -74,9 +74,8 @@ function RequireAuth() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends authenticated users without a company to onboarding.
|
||||
* Only redirects on a confirmed "no company" response — never on a
|
||||
* transient query error.
|
||||
* Waits for the company query so downstream routes can rely on it being
|
||||
* resolved. Onboarding is enforced by OnboardingGate, not here.
|
||||
*/
|
||||
function RequireCompany() {
|
||||
const { customerQuery } = useAuth();
|
||||
@@ -85,13 +84,29 @@ function RequireCompany() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/** Keeps already-onboarded users out of the onboarding flow. */
|
||||
function RequireNoCompany() {
|
||||
const { customerQuery } = useAuth();
|
||||
/**
|
||||
* Enforces first-run onboarding. Until the user finishes, only the home
|
||||
* (/portal) page is reachable; any attempt to navigate elsewhere bounces back
|
||||
* to home with the blocking wizard dialog open. New users (no company yet) are
|
||||
* treated the same as users who haven't completed onboarding.
|
||||
*/
|
||||
function OnboardingGate() {
|
||||
const { company, onboardingCompleted } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||
if (customerQuery.data) return <Navigate to="/portal" replace />;
|
||||
return <Outlet />;
|
||||
const needsOnboarding = !company || !onboardingCompleted;
|
||||
const onHome = location.pathname === "/portal";
|
||||
|
||||
if (needsOnboarding && !onHome) {
|
||||
return <Navigate to="/portal" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<OnboardingWizardDialog opened={needsOnboarding} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps authenticated users off the login/signup pages. */
|
||||
@@ -146,7 +161,14 @@ const sidebarItems: SidebarItem[] = [
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, company } = useAuth();
|
||||
const {
|
||||
user,
|
||||
company,
|
||||
activeProfileType,
|
||||
companyType,
|
||||
switchMode,
|
||||
createProfileAndSwitch,
|
||||
} = useAuth();
|
||||
|
||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||
const userEmail = user?.email;
|
||||
@@ -176,10 +198,6 @@ const App = () => {
|
||||
<Route path="/set-password" element={<SetPasswordPage />} />
|
||||
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<RequireNoCompany />}>
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
</Route>
|
||||
|
||||
<Route element={<RequireCompany />}>
|
||||
<Route
|
||||
element={
|
||||
@@ -192,8 +210,12 @@ const App = () => {
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
companyProfiles={companyProfiles}
|
||||
companyType={companyType}
|
||||
activeProfileType={activeProfileType}
|
||||
onSwitchMode={switchMode}
|
||||
onCreateProfile={createProfileAndSwitch}
|
||||
>
|
||||
<Outlet />
|
||||
<OnboardingGate />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -2,13 +2,16 @@ import {
|
||||
AppShell,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
NavLink,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
useComputedColorScheme,
|
||||
useMantineColorScheme,
|
||||
@@ -16,7 +19,9 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Bell,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FileSignature,
|
||||
LogOut,
|
||||
@@ -29,7 +34,12 @@ import {
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { type CSSProperties, Fragment, type ReactNode } from "react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
Fragment,
|
||||
type ReactNode,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface SidebarItem {
|
||||
label: string;
|
||||
@@ -49,9 +59,23 @@ export interface AppLayoutProps {
|
||||
userEmail?: string;
|
||||
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
|
||||
companyProfiles?: { type: string; reference: string; status?: string }[];
|
||||
/** Company type (e.g. "customer", "forwarder") — gates the importer/exporter switch. */
|
||||
companyType?: string | null;
|
||||
/** The active operational mode (importer/exporter/...). */
|
||||
activeProfileType?: string | null;
|
||||
/** Switch to an existing profile of the given type. */
|
||||
onSwitchMode?: (type: ImporterExporter) => Promise<SwitchResult> | void;
|
||||
/** Create the profile of the given type (with business license) then switch. */
|
||||
onCreateProfile?: (
|
||||
type: ImporterExporter,
|
||||
businessLicense?: string,
|
||||
) => Promise<SwitchResult> | void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
type ImporterExporter = "importer" | "exporter";
|
||||
type SwitchResult = { success: boolean; error?: string };
|
||||
|
||||
const PROFILE_TYPE_LABELS: Record<string, string> = {
|
||||
importer: "Importer",
|
||||
exporter: "Exporter",
|
||||
@@ -117,6 +141,10 @@ export function AppLayout({
|
||||
userName = "User",
|
||||
userEmail,
|
||||
companyProfiles = [],
|
||||
companyType,
|
||||
activeProfileType,
|
||||
onSwitchMode,
|
||||
onCreateProfile,
|
||||
children,
|
||||
}: AppLayoutProps) {
|
||||
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
||||
@@ -142,6 +170,56 @@ export function AppLayout({
|
||||
const initials = getInitials(userName);
|
||||
const activePage = getActivePage(sidebarItems, activePath);
|
||||
|
||||
// ── Importer/Exporter mode switching (customer companies only) ──
|
||||
const isCustomer = companyType === "customer";
|
||||
const targetMode: ImporterExporter =
|
||||
activeProfileType === "importer" ? "exporter" : "importer";
|
||||
const targetExists = companyProfiles.some((p) => p.type === targetMode);
|
||||
const canSwitch =
|
||||
isCustomer &&
|
||||
(activeProfileType === "importer" || activeProfileType === "exporter");
|
||||
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [businessLicense, setBusinessLicense] = useState("");
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const handleSwitchClick = async () => {
|
||||
if (targetExists) {
|
||||
setSwitching(true);
|
||||
try {
|
||||
await onSwitchMode?.(targetMode);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
} else {
|
||||
setBusinessLicense("");
|
||||
setCreateError(null);
|
||||
setCreateOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateConfirm = async () => {
|
||||
setSwitching(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const res = await onCreateProfile?.(
|
||||
targetMode,
|
||||
businessLicense.trim() || undefined,
|
||||
);
|
||||
if (res && !res.success) {
|
||||
setCreateError(res.error ?? "Failed to create profile");
|
||||
return;
|
||||
}
|
||||
setCreateOpen(false);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const modeLabel = (m: ImporterExporter) =>
|
||||
m === "importer" ? "Importer" : "Exporter";
|
||||
|
||||
const isItemActive = (item: SidebarItem) =>
|
||||
activePath === item.href.toLowerCase() ||
|
||||
activePath.startsWith(item.href.toLowerCase() + "/");
|
||||
@@ -212,8 +290,25 @@ export function AppLayout({
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Right: search + bell + avatar */}
|
||||
{/* Right: switch + search + bell + avatar */}
|
||||
<Group gap={10} wrap="nowrap" align="center">
|
||||
{/* Importer/Exporter mode switch (customer companies only) */}
|
||||
{canSwitch && (
|
||||
<Button
|
||||
onClick={handleSwitchClick}
|
||||
loading={switching}
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius={999}
|
||||
size="sm"
|
||||
leftSection={<ArrowLeftRight size={15} strokeWidth={1.8} />}
|
||||
styles={{ root: { height: 36 } }}
|
||||
visibleFrom="xs"
|
||||
>
|
||||
Switch to {modeLabel(targetMode)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Search pill */}
|
||||
<Group
|
||||
gap={8}
|
||||
@@ -322,25 +417,39 @@ export function AppLayout({
|
||||
<Divider />
|
||||
<Box px="sm" py="xs">
|
||||
<Stack gap={6}>
|
||||
{companyProfiles.map((p) => (
|
||||
<Group
|
||||
key={p.reference}
|
||||
justify="space-between"
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
style={{ color: textColor }}
|
||||
{companyProfiles.map((p) => {
|
||||
const isActive = p.type === activeProfileType;
|
||||
return (
|
||||
<Group
|
||||
key={p.reference}
|
||||
justify="space-between"
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
>
|
||||
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
|
||||
</Text>
|
||||
<Text size="xs" ff="monospace" c="dimmed">
|
||||
{p.reference}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isActive && (
|
||||
<Check
|
||||
size={13}
|
||||
color={primaryDarkColor}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
size="xs"
|
||||
fw={isActive ? 700 : 600}
|
||||
style={{
|
||||
color: isActive ? primaryDarkColor : textColor,
|
||||
}}
|
||||
>
|
||||
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" ff="monospace" c="dimmed">
|
||||
{p.reference}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
@@ -686,6 +795,48 @@ export function AppLayout({
|
||||
>
|
||||
{children}
|
||||
</AppShell.Main>
|
||||
|
||||
{/* Create-profile modal — opens when switching to a mode the company
|
||||
doesn't have a profile for yet. */}
|
||||
<Modal
|
||||
opened={createOpen}
|
||||
onClose={() => (switching ? undefined : setCreateOpen(false))}
|
||||
title={`Set up your ${modeLabel(targetMode)} profile`}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
You don't have an {modeLabel(targetMode).toLowerCase()} profile yet.
|
||||
Add your business license to create one and switch to{" "}
|
||||
{modeLabel(targetMode).toLowerCase()} mode. A new reference will be
|
||||
generated automatically.
|
||||
</Text>
|
||||
<TextInput
|
||||
label="Business license"
|
||||
placeholder="e.g. BL-123456"
|
||||
value={businessLicense}
|
||||
onChange={(e) => setBusinessLicense(e.currentTarget.value)}
|
||||
error={createError ?? undefined}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
disabled={switching}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleCreateConfirm}
|
||||
loading={switching}
|
||||
>
|
||||
Create & switch
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Button, Group, Modal, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CompanyProfileInput,
|
||||
CreateCompanyPayload,
|
||||
} from "@/services/companies.service";
|
||||
import { companiesService } from "@/services/companies.service";
|
||||
import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm";
|
||||
import ForwarderForm from "@/pages/accounts/ForwarderForm";
|
||||
import { FREIGHT_FORWARDER } from "@/pages/settings/companyRoles";
|
||||
import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect";
|
||||
|
||||
interface OnboardingWizardDialogProps {
|
||||
opened: boolean;
|
||||
}
|
||||
|
||||
/** Map the chosen operational roles to the company type they belong to. */
|
||||
function companyTypeForRoles(roles: string[]): string {
|
||||
return roles.includes(FREIGHT_FORWARDER.type) ? "forwarder" : "customer";
|
||||
}
|
||||
|
||||
/** Document upload setting code per company type. */
|
||||
function documentSettingCode(companyType: string): string {
|
||||
return companyType === "forwarder"
|
||||
? "company_onboarding_documents_forwarder"
|
||||
: "company_onboarding_documents_customer";
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking, non-dismissable first-run onboarding wizard. Step 1 picks the
|
||||
* operational role(s); the remaining steps reuse the existing company/forwarder
|
||||
* forms. On completion the company is created with its company_profiles and the
|
||||
* active mode is set server-side, then onboarding is marked complete.
|
||||
*/
|
||||
export default function OnboardingWizardDialog({
|
||||
opened,
|
||||
}: OnboardingWizardDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, company } = useAuth();
|
||||
|
||||
// A company already exists but onboarding wasn't marked complete (e.g. the
|
||||
// browser closed after create but before finishing). Don't re-create it —
|
||||
// just let the user finish.
|
||||
const companyAlreadyCreated = Boolean(company?.company?.id);
|
||||
|
||||
// "role" → pick roles; otherwise the company/forwarder form drives its own
|
||||
// internal steps.
|
||||
const [phase, setPhase] = useState<"role" | "form">("role");
|
||||
const [roles, setRoles] = useState<string[]>([]);
|
||||
const [documentFiles, setDocumentFiles] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
|
||||
const createCompanyMutation = useMutation({
|
||||
mutationFn: (payload: CreateCompanyPayload) =>
|
||||
api.companies.create.call(payload),
|
||||
onSuccess: async (data) => {
|
||||
const hasFiles = Object.values(documentFiles).some(
|
||||
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
|
||||
);
|
||||
if (hasFiles) {
|
||||
await companiesService.uploadDocuments(data.company.id, documentFiles);
|
||||
}
|
||||
// Mark onboarding complete, then refresh the company info so the gate
|
||||
// releases and the header reflects the new profile(s).
|
||||
await api.companies.completeOnboarding.call();
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getInfo.queryKey(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const finishMutation = useMutation({
|
||||
mutationFn: () => api.companies.completeOnboarding.call(),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getInfo.queryKey(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleRolesContinue = useCallback(() => {
|
||||
setPhase("form");
|
||||
// Best-effort: remember that the user moved past role selection.
|
||||
api.companies.setOnboardingStep.call({ step: "company" }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleBackToRoles = useCallback(() => setPhase("role"), []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(payload: CreateCompanyPayload) => {
|
||||
const companyProfiles: CompanyProfileInput[] = roles.map((type) => ({
|
||||
type: type as CompanyProfileInput["type"],
|
||||
}));
|
||||
createCompanyMutation.mutate({
|
||||
...payload,
|
||||
companyType: companyTypeForRoles(roles),
|
||||
companyProfiles,
|
||||
});
|
||||
},
|
||||
[roles, createCompanyMutation],
|
||||
);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const isForwarder = roles.includes(FREIGHT_FORWARDER.type);
|
||||
// Importer+Exporter (or either alone) is a valid customer selection.
|
||||
const rolesValid = roles.length > 0;
|
||||
const companyType = companyTypeForRoles(roles);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {}}
|
||||
withCloseButton={false}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
padding="xl"
|
||||
centered
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
|
||||
title={
|
||||
<Stack gap={2}>
|
||||
<Text fz={20} fw={800} c="edr-text" className="tracking-tight">
|
||||
Complete your onboarding
|
||||
</Text>
|
||||
<Text size="sm" c="edr-muted">
|
||||
{companyAlreadyCreated
|
||||
? "You're almost there — finish to start using the portal."
|
||||
: phase === "role"
|
||||
? "Tell us what your company does to get started."
|
||||
: "Set up your company profile to finish."}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{companyAlreadyCreated ? (
|
||||
<Stack gap="lg" align="center" py="md">
|
||||
<CheckCircle2 size={48} className="text-[var(--mantine-color-edr-green-6)]" />
|
||||
<Text ta="center" c="edr-muted" size="sm" maw={420}>
|
||||
Your company profile is set up. Click finish to complete onboarding
|
||||
and unlock the rest of the portal.
|
||||
</Text>
|
||||
<Group justify="center">
|
||||
<Button
|
||||
color="edr-green"
|
||||
size="md"
|
||||
loading={finishMutation.isPending}
|
||||
onClick={() => finishMutation.mutate()}
|
||||
>
|
||||
Finish onboarding
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<OnboardingRoleSelect value={roles} onChange={setRoles} />
|
||||
<RoleContinueBar disabled={!rolesValid} onClick={handleRolesContinue} />
|
||||
</Stack>
|
||||
) : isForwarder ? (
|
||||
<ForwarderForm
|
||||
documentSettingCode={documentSettingCode(companyType)}
|
||||
documentFiles={documentFiles}
|
||||
onDocumentFilesChange={setDocumentFiles}
|
||||
user={user}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={createCompanyMutation.isPending}
|
||||
onBack={handleBackToRoles}
|
||||
/>
|
||||
) : (
|
||||
<CompanyProfileForm
|
||||
documentSettingCode={documentSettingCode(companyType)}
|
||||
documentFiles={documentFiles}
|
||||
onDocumentFilesChange={setDocumentFiles}
|
||||
user={user}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={createCompanyMutation.isPending}
|
||||
onBack={handleBackToRoles}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleContinueBar({
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="ml-auto rounded-lg bg-[var(--mantine-color-edr-green-6)] px-5 py-2.5 text-sm font-semibold text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,10 @@ export const URL_CONSTANTS = {
|
||||
CREATE: "/api/companies/create",
|
||||
PROFILE: "/api/companies/profile",
|
||||
COMPANY_PROFILES: "/api/companies/company-profiles",
|
||||
COMPANY_PROFILE: "/api/companies/company-profile",
|
||||
ACTIVE_MODE: "/api/companies/active-mode",
|
||||
ONBOARDING_STEP: "/api/companies/onboarding-step",
|
||||
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
|
||||
DASHBOARD: "/api/companies/dashboard",
|
||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from "@/services/api";
|
||||
import type { ProfileTypeValue } from "@/services/companies.service";
|
||||
import type {
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
@@ -149,6 +150,54 @@ const useAuth = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Active-mode (importer/exporter) state, sourced from the persisted profile.
|
||||
const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
|
||||
const activeProfileType = companyInfo?.profile?.activeProfileType ?? null;
|
||||
const activeCompanyProfileId =
|
||||
companyInfo?.profile?.activeCompanyProfileId ?? null;
|
||||
const companyType = companyInfo?.company?.type ?? null;
|
||||
const onboardingCompleted =
|
||||
companyInfo?.profile?.onboardingCompleted ?? false;
|
||||
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
|
||||
|
||||
/** Refetch everything scoped to the active operational profile. */
|
||||
const invalidateScopedData = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getInfo.queryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getDashboard.queryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ["bookings"] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const switchMode = async (
|
||||
type: ProfileTypeValue,
|
||||
): Promise<Result<void>> => {
|
||||
try {
|
||||
await api.companies.setActiveMode.call({ type });
|
||||
await invalidateScopedData();
|
||||
return { success: true, data: undefined };
|
||||
} catch (err) {
|
||||
return { success: false, error: extractApiError(err) };
|
||||
}
|
||||
};
|
||||
|
||||
const createProfileAndSwitch = async (
|
||||
type: ProfileTypeValue,
|
||||
businessLicense?: string,
|
||||
): Promise<Result<void>> => {
|
||||
try {
|
||||
await api.companies.createCompanyProfile.call({ type, businessLicense });
|
||||
await invalidateScopedData();
|
||||
return { success: true, data: undefined };
|
||||
} catch (err) {
|
||||
return { success: false, error: extractApiError(err) };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await api.auth.logout.call();
|
||||
@@ -174,6 +223,13 @@ const useAuth = () => {
|
||||
user: isAuthenticated ? (authQuery.data ?? null) : null,
|
||||
company: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||
activeProfileType,
|
||||
activeCompanyProfileId,
|
||||
companyType,
|
||||
onboardingCompleted,
|
||||
onboardingStep,
|
||||
switchMode,
|
||||
createProfileAndSwitch,
|
||||
login,
|
||||
signup,
|
||||
setPassword,
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
CompanyProfileResponse,
|
||||
CreateCompanyPayload,
|
||||
DashboardSummary,
|
||||
ProfileTypeValue,
|
||||
} from "./companies.service";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type {
|
||||
@@ -135,6 +136,29 @@ export const api = {
|
||||
"addCompanyProfiles",
|
||||
companiesService.addCompanyProfiles,
|
||||
),
|
||||
|
||||
createCompanyProfile: endpoint<
|
||||
{ type: ProfileTypeValue; businessLicense?: string },
|
||||
CompanyProfileResponse
|
||||
>("companies", "createCompanyProfile", companiesService.createCompanyProfile),
|
||||
|
||||
setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>(
|
||||
"companies",
|
||||
"setActiveMode",
|
||||
companiesService.setActiveMode,
|
||||
),
|
||||
|
||||
setOnboardingStep: endpoint<{ step: string }, void>(
|
||||
"companies",
|
||||
"setOnboardingStep",
|
||||
companiesService.setOnboardingStep,
|
||||
),
|
||||
|
||||
completeOnboarding: endpoint<void, CompanyInfoResponse>(
|
||||
"companies",
|
||||
"completeOnboarding",
|
||||
companiesService.completeOnboarding,
|
||||
),
|
||||
},
|
||||
|
||||
bookings: {
|
||||
|
||||
@@ -5,6 +5,13 @@ import type { ApiResponse } from "@/types/apiResponse";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
export type ProfileTypeValue =
|
||||
| "importer"
|
||||
| "exporter"
|
||||
| "freight_forwarder"
|
||||
| "dj_freight_forwarder"
|
||||
| "transporter";
|
||||
|
||||
export interface ExternalProfileResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -16,6 +23,12 @@ export interface ExternalProfileResponse {
|
||||
nationalId: string | null;
|
||||
jobTitle: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
/** The active operational mode (importer/exporter/forwarder). */
|
||||
activeProfileType: ProfileTypeValue | null;
|
||||
/** Id of the company_profile matching activeProfileType (server-resolved). */
|
||||
activeCompanyProfileId: string | null;
|
||||
onboardingStep: string | null;
|
||||
onboardingCompleted: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -152,6 +165,40 @@ export const companiesService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Create a single operational profile and make it the active mode. */
|
||||
createCompanyProfile: async (payload: {
|
||||
type: ProfileTypeValue;
|
||||
businessLicense?: string;
|
||||
}): Promise<CompanyProfileResponse> => {
|
||||
const response = await client.post<ApiResponse<CompanyProfileResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE,
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Switch the active operational mode (target profile must already exist). */
|
||||
setActiveMode: async (payload: {
|
||||
type: ProfileTypeValue;
|
||||
}): Promise<CompanyInfoResponse> => {
|
||||
const response = await client.patch<ApiResponse<CompanyInfoResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.ACTIVE_MODE,
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
setOnboardingStep: async (payload: { step: string }): Promise<void> => {
|
||||
await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload);
|
||||
},
|
||||
|
||||
completeOnboarding: async (): Promise<CompanyInfoResponse> => {
|
||||
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.ONBOARDING_COMPLETE,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
uploadDocuments: async (
|
||||
companyId: string,
|
||||
files: Record<string, File | File[] | null>,
|
||||
|
||||
Reference in New Issue
Block a user