diff --git a/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts b/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts new file mode 100644 index 000000000..8484ca0f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddActiveModeAndOnboardingToExternalProfiles1791000000000 + implements MigrationInterface +{ + name = 'AddActiveModeAndOnboardingToExternalProfiles1791000000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts b/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts new file mode 100644 index 000000000..0ca1a0d28 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts @@ -0,0 +1,94 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCompanyProfileIdToBookings1791000000001 + implements MigrationInterface +{ + name = 'AddCompanyProfileIdToBookings1791000000001'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index ae0f1765a..77303d08d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index b173bfe68..e1e75e049 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -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 { 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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 4c8ef2cab..5253ca9bd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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 { + 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 diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index c5dac1736..d110391b7 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index ac2868ec3..d09effef3 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 { + 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 { + 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 { + 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 { + const { profile, company } = + await this.companiesService.markOnboardingComplete(user.id); + return new CompanyInfoResponseDto(profile, company); + } + // Used by portal @Post("create") @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index fe1bc5598..edeaafe57 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } + } } diff --git a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts index 365cf1daa..842a36616 100644 --- a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts @@ -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, + scope: DashboardScope, +): SelectQueryBuilder { + 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, ) {} - /** Count of delivered/completed bookings for a company within [from, to). */ - async countDelivered(companyId: string, from: Date, to: Date): Promise { - 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 { + 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 { - 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 { + 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 { - 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 { + 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 { - 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 { + 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 { - 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 { + 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 }) diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index f6ffb8296..04fd42816 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -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); } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts new file mode 100644 index 000000000..9ac6c13b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts index a33585845..7e17bcc60 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts new file mode 100644 index 000000000..ac8f57a93 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { ProfileType } from '../entities/company-profile.entity'; + +export class SetActiveModeDto { + @IsEnum(ProfileType) + type!: ProfileType; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts new file mode 100644 index 000000000..43967abc8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MaxLength } from 'class-validator'; + +export class SetOnboardingStepDto { + @IsString() + @MaxLength(40) + step!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts index 91a014f10..3b1554cc9 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -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; } diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index bbcdba42e..5394c1fb8 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -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 ; } -/** 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 ; - if (customerQuery.data) return ; - return ; + const needsOnboarding = !company || !onboardingCompleted; + const onHome = location.pathname === "/portal"; + + if (needsOnboarding && !onHome) { + return ; + } + + return ( + <> + + + + ); } /** 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 = () => { } /> }> - }> - } /> - - }> { userName={displayName} userEmail={userEmail} companyProfiles={companyProfiles} + companyType={companyType} + activeProfileType={activeProfileType} + onSwitchMode={switchMode} + onCreateProfile={createProfileAndSwitch} > - + } > diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index e0de92ec5..f96eaf03d 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -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 | void; + /** Create the profile of the given type (with business license) then switch. */ + onCreateProfile?: ( + type: ImporterExporter, + businessLicense?: string, + ) => Promise | void; children: ReactNode; } +type ImporterExporter = "importer" | "exporter"; +type SwitchResult = { success: boolean; error?: string }; + const PROFILE_TYPE_LABELS: Record = { 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(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({ - {/* Right: search + bell + avatar */} + {/* Right: switch + search + bell + avatar */} + {/* Importer/Exporter mode switch (customer companies only) */} + {canSwitch && ( + + )} + {/* Search pill */} - {companyProfiles.map((p) => ( - - { + const isActive = p.type === activeProfileType; + return ( + - {PROFILE_TYPE_LABELS[p.type] ?? p.type} - - - {p.reference} - - - ))} + + {isActive && ( + + )} + + {PROFILE_TYPE_LABELS[p.type] ?? p.type} + + + + {p.reference} + + + ); + })} @@ -686,6 +795,48 @@ export function AppLayout({ > {children} + + {/* Create-profile modal — opens when switching to a mode the company + doesn't have a profile for yet. */} + (switching ? undefined : setCreateOpen(false))} + title={`Set up your ${modeLabel(targetMode)} profile`} + centered + radius="lg" + > + + + 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. + + setBusinessLicense(e.currentTarget.value)} + error={createError ?? undefined} + /> + + + + + + ); } diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx new file mode 100644 index 000000000..8e9f59b16 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -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([]); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); + + 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 ( + {}} + withCloseButton={false} + closeOnClickOutside={false} + closeOnEscape={false} + size="xl" + radius="lg" + padding="xl" + centered + scrollAreaComponent={ScrollArea.Autosize} + overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} + title={ + + + Complete your onboarding + + + {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."} + + + } + > + {companyAlreadyCreated ? ( + + + + Your company profile is set up. Click finish to complete onboarding + and unlock the rest of the portal. + + + + + + ) : phase === "role" ? ( + + + + + ) : isForwarder ? ( + + ) : ( + + )} + + ); +} + +function RoleContinueBar({ + disabled, + onClick, +}: { + disabled: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 08b26a255..195dcb692 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -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`, }, diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 06900270e..3b3d84813 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -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> => { + 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> => { + 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, diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 194a1dd48..8615061d7 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -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( + "companies", + "completeOnboarding", + companiesService.completeOnboarding, + ), }, bookings: { diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 149705ec1..e71949045 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -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 => { + const response = await client.post>( + 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 => { + const response = await client.patch>( + URL_CONSTANTS.COMPANIES_API.ACTIVE_MODE, + payload, + ); + return unwrap(response.data); + }, + + setOnboardingStep: async (payload: { step: string }): Promise => { + await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload); + }, + + completeOnboarding: async (): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.ONBOARDING_COMPLETE, + ); + return unwrap(response.data); + }, + uploadDocuments: async ( companyId: string, files: Record,