mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +00:00
chore: rm the active company profile
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the `active_profile_type` "active mode" column. A booking/contract now
|
||||||
|
* resolves its company_profile from the trade direction at creation time (with
|
||||||
|
* a forwarder passing an explicit companyProfileId), so no per-user active mode
|
||||||
|
* is stored. `onboarding_step` / `onboarding_completed` are unaffected.
|
||||||
|
*/
|
||||||
|
export class DropActiveProfileTypeFromExternalProfiles2450000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'DropActiveProfileTypeFromExternalProfiles2450000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.external_profiles
|
||||||
|
DROP COLUMN IF EXISTS active_profile_type;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.external_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS active_profile_type varchar(32);
|
||||||
|
`);
|
||||||
|
// Rebuild the mode the same way the original column was backfilled:
|
||||||
|
// importer first, then exporter, then whichever profile the company has.
|
||||||
|
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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
|||||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
|
||||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
@@ -743,21 +742,13 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
companyProfileId = profile.id;
|
companyProfileId = profile.id;
|
||||||
} else if (companyId) {
|
} else if (companyId) {
|
||||||
let fallbackType: ProfileType | null = null;
|
// No explicit profile pin: resolve from the booking's trade direction
|
||||||
if (userId) {
|
// (import→importer, export→exporter; otherwise the first profile). A
|
||||||
try {
|
// forwarder booking sends dto.companyProfileId and takes the branch above.
|
||||||
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 =
|
companyProfileId =
|
||||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||||
companyId,
|
companyId,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
fallbackType,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// A customer booking under their own account may only do so once the
|
// A customer booking under their own account may only do so once the
|
||||||
@@ -1065,9 +1056,6 @@ export class BookingsService {
|
|||||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||||
existing.companyId,
|
existing.companyId,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
existing.companyProfileId
|
|
||||||
? undefined
|
|
||||||
: (existing.companyProfile?.type as ProfileType | undefined),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||||
@@ -1389,15 +1377,6 @@ 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
|
* 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
|
* controller (they pass `isStaff`); for a customer, the booking must belong
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
|||||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||||
import { CreateCompanyProfileDto } from "./dto/create-company-profile.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 { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||||
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||||
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
||||||
@@ -353,21 +352,6 @@ export class CompaniesController {
|
|||||||
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@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")
|
@Patch("onboarding-step")
|
||||||
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
|||||||
@@ -201,18 +201,6 @@ export class CompaniesService {
|
|||||||
attributes: dto.attributes ?? null,
|
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({
|
const profile = await this.profilesRepo.create({
|
||||||
userId: identity.userId,
|
userId: identity.userId,
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
@@ -220,7 +208,6 @@ export class CompaniesService {
|
|||||||
lastName: identity.lastName,
|
lastName: identity.lastName,
|
||||||
jobTitle: dto.jobTitle ?? null,
|
jobTitle: dto.jobTitle ?? null,
|
||||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||||
activeProfileType,
|
|
||||||
onboardingStep: "company",
|
onboardingStep: "company",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,11 +280,6 @@ export class CompaniesService {
|
|||||||
|
|
||||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||||
const activeProfileType =
|
|
||||||
chosenTypes.find((t) => t === ProfileType.importer) ??
|
|
||||||
chosenTypes[0] ??
|
|
||||||
allowedTypes[0] ??
|
|
||||||
null;
|
|
||||||
|
|
||||||
const company = await this.companiesRepo.create({
|
const company = await this.companiesRepo.create({
|
||||||
name: identity.firstName
|
name: identity.firstName
|
||||||
@@ -316,7 +298,6 @@ export class CompaniesService {
|
|||||||
firstName: identity.firstName,
|
firstName: identity.firstName,
|
||||||
lastName: identity.lastName,
|
lastName: identity.lastName,
|
||||||
isPrimaryContact: true,
|
isPrimaryContact: true,
|
||||||
activeProfileType,
|
|
||||||
onboardingStep: "company",
|
onboardingStep: "company",
|
||||||
onboardingCompleted: false,
|
onboardingCompleted: false,
|
||||||
});
|
});
|
||||||
@@ -1213,40 +1194,50 @@ export class CompaniesService {
|
|||||||
if (!updated)
|
if (!updated)
|
||||||
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
||||||
|
|
||||||
// Suspension and reactivation lock/unlock a role the customer relies on —
|
// Every reviewed transition that changes what the customer can do is told
|
||||||
// tell them, and carry the staff message so they know why.
|
// to them, carrying the staff message so they know why. Approval has no
|
||||||
const reactivated =
|
// message (the note is cleared); the others require one.
|
||||||
status === ProfileStatus.Active &&
|
const change =
|
||||||
existing.status === ProfileStatus.Suspended;
|
status === ProfileStatus.Suspended
|
||||||
if (status === ProfileStatus.Suspended || reactivated) {
|
? "suspended"
|
||||||
|
: status === ProfileStatus.Rejected
|
||||||
|
? "rejected"
|
||||||
|
: status === ProfileStatus.Active
|
||||||
|
? existing.status === ProfileStatus.Suspended
|
||||||
|
? "reactivated"
|
||||||
|
: "approved"
|
||||||
|
: null;
|
||||||
|
if (change) {
|
||||||
const company = await this.companiesRepo.findById(updated.companyId);
|
const company = await this.companiesRepo.findById(updated.companyId);
|
||||||
if (company) {
|
if (company) {
|
||||||
this.companyNotifier.profileStatusChanged(
|
this.companyNotifier.profileStatusChanged(
|
||||||
company,
|
company,
|
||||||
updated.type,
|
updated.type,
|
||||||
status === ProfileStatus.Suspended ? "suspended" : "reactivated",
|
change,
|
||||||
note ?? "",
|
note ?? "",
|
||||||
);
|
);
|
||||||
}
|
// The first approved role promotes a pending company to active — a
|
||||||
}
|
// bigger event (the account itself goes live), so tell them that too.
|
||||||
|
if (
|
||||||
// Approving any profile promotes a pending company to active, so the
|
status === ProfileStatus.Active &&
|
||||||
// customer can start working as soon as their first profile is cleared.
|
company.status === CompanyStatus.Pending
|
||||||
if (status === ProfileStatus.Active) {
|
) {
|
||||||
const company = await this.companiesRepo.findById(updated.companyId);
|
await this.companiesRepo.update(updated.companyId, {
|
||||||
if (company && company.status === CompanyStatus.Pending) {
|
status: CompanyStatus.Active,
|
||||||
await this.companiesRepo.update(updated.companyId, {
|
});
|
||||||
status: CompanyStatus.Active,
|
this.companyNotifier.companyApproved(company);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer reapplies for a rejected operational role (after fixing whatever the
|
* Customer reapplies for a rejected or suspended operational role (after
|
||||||
* reviewer flagged, e.g. re-uploading a license): flip it back to Pending and
|
* fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it
|
||||||
* clear the rejection note so it re-enters the approval queue.
|
* back to Pending and clear the review note so it re-enters the approval
|
||||||
|
* queue. Suspension is a staff lockout, so resubmitting is an appeal — the
|
||||||
|
* backoffice still has to approve before the role goes live again.
|
||||||
*/
|
*/
|
||||||
async reapplyCompanyProfile(
|
async reapplyCompanyProfile(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1261,9 +1252,12 @@ export class CompaniesService {
|
|||||||
if (!target || target.companyId !== companyId) {
|
if (!target || target.companyId !== companyId) {
|
||||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||||
}
|
}
|
||||||
if (target.status !== ProfileStatus.Rejected) {
|
if (
|
||||||
|
target.status !== ProfileStatus.Rejected &&
|
||||||
|
target.status !== ProfileStatus.Suspended
|
||||||
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Only a rejected role can be resubmitted for approval",
|
"Only a rejected or suspended role can be resubmitted for approval",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1387,10 +1381,9 @@ export class CompaniesService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a single operational profile for the current user's company. The new
|
* Create a single operational profile for the current user's company. The new
|
||||||
* role starts Pending, so it deliberately does NOT become the active mode:
|
* role starts Pending and carries no reference until a backoffice reviewer
|
||||||
* switching onto an unapproved profile would strip the user of `canBook` and
|
* approves it; a booking/contract resolves its profile from the trade
|
||||||
* block them from creating contracts under the role they already had approved.
|
* direction at creation time, so no "active mode" is stored.
|
||||||
* Callers switch explicitly via {@link setActiveMode} once the role is Active.
|
|
||||||
*/
|
*/
|
||||||
async createCompanyProfileForUser(
|
async createCompanyProfileForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1425,40 +1418,6 @@ export class CompaniesService {
|
|||||||
return created;
|
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> {
|
async setOnboardingStep(userId: string, step: string): Promise<void> {
|
||||||
const profile = await this.profilesRepo.findByUserId(userId);
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
if (!profile)
|
if (!profile)
|
||||||
@@ -2329,15 +2288,14 @@ export class CompaniesService {
|
|||||||
/**
|
/**
|
||||||
* Resolve which company_profile a new booking belongs to, from the company
|
* Resolve which company_profile a new booking belongs to, from the company
|
||||||
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
||||||
* exporter profile; for DOMESTIC or a forwarder/single-profile company (or
|
* exporter profile; for DOMESTIC (or when the natural profile doesn't exist,
|
||||||
* when the natural profile doesn't exist) it falls back to the user's active
|
* e.g. a freight forwarder) it falls back to the company's first profile.
|
||||||
* profile, then the company's first profile. Returns null when the company
|
* Callers that need a specific role (a forwarder) pass an explicit
|
||||||
* has no profiles at all.
|
* companyProfileId instead. Returns null when the company has no profiles.
|
||||||
*/
|
*/
|
||||||
async resolveCompanyProfileIdForBooking(
|
async resolveCompanyProfileIdForBooking(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
tradeDirection: string,
|
tradeDirection: string,
|
||||||
fallbackType?: ProfileType | null,
|
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
if (profiles.length === 0) return null;
|
if (profiles.length === 0) return null;
|
||||||
@@ -2349,30 +2307,12 @@ export class CompaniesService {
|
|||||||
? ProfileType.exporter
|
? ProfileType.exporter
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const byType = (type?: ProfileType | null) =>
|
const match =
|
||||||
type ? profiles.find((p) => p.type === type) : undefined;
|
(naturalType && profiles.find((p) => p.type === naturalType)) ??
|
||||||
|
profiles[0];
|
||||||
const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0];
|
|
||||||
return match?.id ?? null;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchETradeData(tin: string) {
|
async fetchETradeData(tin: string) {
|
||||||
const { businessInfo, companyInfo } =
|
const { businessInfo, companyInfo } =
|
||||||
await this.etradeService.resolveCompanyData(tin);
|
await this.etradeService.resolveCompanyData(tin);
|
||||||
|
|||||||
@@ -59,23 +59,13 @@ export class CompanyNotifierService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** SMS + email + in-app account-status item to the company contact. */
|
||||||
* Tell the customer their account was suspended or blacklisted. Called only on
|
private notifyAccount(
|
||||||
* a real transition into one of those statuses; other status writes are silent.
|
company: Company,
|
||||||
*/
|
title: string,
|
||||||
statusChanged(company: Company, previous: CompanyStatus): void {
|
body: string,
|
||||||
const status = company.status;
|
link = "/settings",
|
||||||
if (status === previous) return;
|
): void {
|
||||||
if (!PUNITIVE_STATUSES.includes(status)) return;
|
|
||||||
|
|
||||||
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
|
|
||||||
const title = `Account ${label}`;
|
|
||||||
const body =
|
|
||||||
`Your company account has been ${label}. ` +
|
|
||||||
`You will not be able to submit new contracts or bookings. ` +
|
|
||||||
`Please contact EDR support for assistance.`;
|
|
||||||
|
|
||||||
this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`);
|
|
||||||
void this.notifyContact(company, `${title}. ${body}`);
|
void this.notifyContact(company, `${title}. ${body}`);
|
||||||
void this.inbox.notify({
|
void this.inbox.notify({
|
||||||
recipients: { companyId: company.id },
|
recipients: { companyId: company.id },
|
||||||
@@ -83,32 +73,88 @@ export class CompanyNotifierService {
|
|||||||
type: NotificationType.ACCOUNT_STATUS,
|
type: NotificationType.ACCOUNT_STATUS,
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
link: "/settings",
|
link,
|
||||||
data: { companyId: company.id, status },
|
data: { companyId: company.id, status: company.status },
|
||||||
priority: NotificationPriority.HIGH,
|
priority: NotificationPriority.HIGH,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tell the customer one of their operational roles was suspended or
|
* Tell the customer their account changed status. Fires on the transitions
|
||||||
* reactivated, quoting the staff message — the service layer requires one for
|
* that change what they can do: suspended/blacklisted (locked out) and
|
||||||
* both transitions, so the customer always learns why, not just what.
|
* reactivated (back to Active from a lockout). Silent otherwise.
|
||||||
|
*/
|
||||||
|
statusChanged(company: Company, previous: CompanyStatus): void {
|
||||||
|
const status = company.status;
|
||||||
|
if (status === previous) return;
|
||||||
|
|
||||||
|
if (status === CompanyStatus.Active && PUNITIVE_STATUSES.includes(previous)) {
|
||||||
|
this.logger.log(`ACCOUNT_REACTIVATED — ${company.id}`);
|
||||||
|
this.notifyAccount(
|
||||||
|
company,
|
||||||
|
"Account reactivated",
|
||||||
|
"Your company account has been reactivated. " +
|
||||||
|
"You can submit new contracts and bookings again.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PUNITIVE_STATUSES.includes(status)) return;
|
||||||
|
|
||||||
|
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
|
||||||
|
this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`);
|
||||||
|
this.notifyAccount(
|
||||||
|
company,
|
||||||
|
`Account ${label}`,
|
||||||
|
`Your company account has been ${label}. ` +
|
||||||
|
`You will not be able to submit new contracts or bookings. ` +
|
||||||
|
`Please contact EDR support for assistance.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the customer their company account was approved and is now live — the
|
||||||
|
* first operational role clearing review promotes a pending company to Active.
|
||||||
|
*/
|
||||||
|
companyApproved(company: Company): void {
|
||||||
|
this.logger.log(`ACCOUNT_APPROVED — ${company.id}`);
|
||||||
|
this.notifyAccount(
|
||||||
|
company,
|
||||||
|
"Account approved",
|
||||||
|
"Your company account has been approved and is now active. " +
|
||||||
|
"You can start submitting bookings and contracts.",
|
||||||
|
"/dashboard",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the customer one of their operational roles changed review status —
|
||||||
|
* approved, rejected, suspended, or reactivated — quoting the staff message
|
||||||
|
* when one was given (rejection/suspension/reactivation require one; approval
|
||||||
|
* carries none).
|
||||||
*/
|
*/
|
||||||
profileStatusChanged(
|
profileStatusChanged(
|
||||||
company: Company,
|
company: Company,
|
||||||
profileType: string,
|
profileType: string,
|
||||||
change: "suspended" | "reactivated",
|
change: "approved" | "rejected" | "suspended" | "reactivated",
|
||||||
staffMessage: string,
|
staffMessage: string,
|
||||||
): void {
|
): void {
|
||||||
const title = `${profileType} role ${change}`;
|
const title = `${profileType} role ${change}`;
|
||||||
const consequence =
|
const consequence: Record<typeof change, string> = {
|
||||||
change === "suspended"
|
approved: "You can now operate under this role.",
|
||||||
? `You will not be able to operate under this role until it is reactivated; ` +
|
rejected:
|
||||||
`your other roles are unaffected.`
|
"You will not be able to operate under this role. Amend the required " +
|
||||||
: `You can operate under this role again.`;
|
"documents and resubmit it for approval from your settings page.",
|
||||||
|
suspended:
|
||||||
|
"You will not be able to operate under this role until it is " +
|
||||||
|
"reactivated; your other roles are unaffected.",
|
||||||
|
reactivated: "You can operate under this role again.",
|
||||||
|
};
|
||||||
|
const message = staffMessage.trim();
|
||||||
const body =
|
const body =
|
||||||
`Your company's ${profileType} role has been ${change}. ` +
|
`Your company's ${profileType} role has been ${change}. ` +
|
||||||
`${consequence} Message from EDR staff: ${staffMessage}`;
|
`${consequence[change]}` +
|
||||||
|
(message ? ` Message from EDR staff: ${message}` : "");
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`PROFILE_${change.toUpperCase()} — ${company.id} / ${profileType}`,
|
`PROFILE_${change.toUpperCase()} — ${company.id} / ${profileType}`,
|
||||||
@@ -121,7 +167,7 @@ export class CompanyNotifierService {
|
|||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
link: "/settings",
|
link: "/settings",
|
||||||
data: { companyId: company.id, profileType, change, staffMessage },
|
data: { companyId: company.id, profileType, change, staffMessage: message },
|
||||||
priority: NotificationPriority.HIGH,
|
priority: NotificationPriority.HIGH,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class CompanyInfoResponseDto {
|
|||||||
company: Company,
|
company: Company,
|
||||||
changeRequest?: CompanyChangeRequest | null,
|
changeRequest?: CompanyChangeRequest | null,
|
||||||
) {
|
) {
|
||||||
this.profile = new ResponseExternalProfileDto(profile, company);
|
this.profile = new ResponseExternalProfileDto(profile);
|
||||||
this.company = new ResponseCompanyDto(company);
|
this.company = new ResponseCompanyDto(company);
|
||||||
|
|
||||||
const open =
|
const open =
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Company } from '../entities/company.entity';
|
|
||||||
import {
|
import {
|
||||||
ExternalProfile,
|
ExternalProfile,
|
||||||
} from '../entities/external-profile.entity';
|
} from '../entities/external-profile.entity';
|
||||||
import { ProfileType } from '../entities/company-profile.entity';
|
|
||||||
|
|
||||||
export class ResponseExternalProfileDto {
|
export class ResponseExternalProfileDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,20 +11,12 @@ export class ResponseExternalProfileDto {
|
|||||||
nationalId?: string | null;
|
nationalId?: string | null;
|
||||||
jobTitle?: string | null;
|
jobTitle?: string | null;
|
||||||
isPrimaryContact: boolean;
|
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;
|
onboardingStep?: string | null;
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
constructor(profile: ExternalProfile, company?: Company) {
|
constructor(profile: ExternalProfile) {
|
||||||
this.id = profile.id;
|
this.id = profile.id;
|
||||||
this.userId = profile.userId;
|
this.userId = profile.userId;
|
||||||
this.companyId = profile.companyId;
|
this.companyId = profile.companyId;
|
||||||
@@ -35,13 +25,8 @@ export class ResponseExternalProfileDto {
|
|||||||
this.nationalId = profile.nationalId;
|
this.nationalId = profile.nationalId;
|
||||||
this.jobTitle = profile.jobTitle;
|
this.jobTitle = profile.jobTitle;
|
||||||
this.isPrimaryContact = profile.isPrimaryContact;
|
this.isPrimaryContact = profile.isPrimaryContact;
|
||||||
this.activeProfileType = profile.activeProfileType ?? null;
|
|
||||||
this.onboardingStep = profile.onboardingStep ?? null;
|
this.onboardingStep = profile.onboardingStep ?? null;
|
||||||
this.onboardingCompleted = profile.onboardingCompleted ?? false;
|
this.onboardingCompleted = profile.onboardingCompleted ?? false;
|
||||||
this.activeCompanyProfileId =
|
|
||||||
company?.companyProfiles?.find(
|
|
||||||
(p) => p.type === profile.activeProfileType,
|
|
||||||
)?.id ?? null;
|
|
||||||
this.createdAt = profile.createdAt;
|
this.createdAt = profile.createdAt;
|
||||||
this.updatedAt = profile.updatedAt;
|
this.updatedAt = profile.updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { IsEnum } from 'class-validator';
|
|
||||||
import { ProfileType } from '../entities/company-profile.entity';
|
|
||||||
|
|
||||||
export class SetActiveModeDto {
|
|
||||||
@IsEnum(ProfileType)
|
|
||||||
type!: ProfileType;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
import { Company } from './company.entity';
|
import { Company } from './company.entity';
|
||||||
import { ProfileType } from './company-profile.entity';
|
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'external_profiles' })
|
@Entity({ schema: 'freight', name: 'external_profiles' })
|
||||||
@Index(['userId'])
|
@Index(['userId'])
|
||||||
@@ -32,21 +31,6 @@ export class ExternalProfile extends BaseEntity {
|
|||||||
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
||||||
isPrimaryContact!: boolean;
|
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'). */
|
/** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */
|
||||||
@Column({
|
@Column({
|
||||||
name: 'onboarding_step',
|
name: 'onboarding_step',
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { YardCountry } from '@edr/types';
|
|||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
|
||||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
@@ -188,31 +187,31 @@ export class ContractsService {
|
|||||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||||
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
||||||
|
|
||||||
// Stamp the operational profile (importer/exporter) for portal scoping.
|
// Stamp the operational profile for portal scoping. A forwarder contract
|
||||||
|
// pins its profile explicitly (trade direction can't tell it apart from a
|
||||||
|
// direct import/export); everything else resolves from the trade direction.
|
||||||
let companyProfileId: string | null = null;
|
let companyProfileId: string | null = null;
|
||||||
if (!isGovernment && companyId) {
|
if (!isGovernment && companyId) {
|
||||||
let fallbackType: ProfileType | null = null;
|
if (dto.companyProfileId) {
|
||||||
if (userId) {
|
const profile =
|
||||||
try {
|
await this.companiesService.getActiveCompanyProfileForBooking(
|
||||||
const { profile } =
|
companyId,
|
||||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
dto.companyProfileId,
|
||||||
fallbackType = profile.activeProfileType ?? null;
|
);
|
||||||
} catch {
|
companyProfileId = profile.id;
|
||||||
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
} else {
|
||||||
}
|
companyProfileId =
|
||||||
}
|
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||||
companyProfileId =
|
companyId,
|
||||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
dto.tradeDirection,
|
||||||
companyId,
|
);
|
||||||
dto.tradeDirection,
|
|
||||||
fallbackType,
|
|
||||||
);
|
|
||||||
|
|
||||||
const customerSelfBooking = !dto.companyId && !!userId;
|
const customerSelfBooking = !dto.companyId && !!userId;
|
||||||
if (customerSelfBooking && companyProfileId) {
|
if (customerSelfBooking && companyProfileId) {
|
||||||
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
||||||
companyProfileId,
|
companyProfileId,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,16 @@ export class CreateContractDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Explicit company profile to stamp the contract to (a forwarder contract); ' +
|
||||||
|
'commercial contracts otherwise auto-resolve from trade direction.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
companyProfileId?: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' })
|
@ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' })
|
||||||
@IsIn([...CONTRACT_KINDS])
|
@IsIn([...CONTRACT_KINDS])
|
||||||
contractKind!: string;
|
contractKind!: string;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
AppShell,
|
AppShell,
|
||||||
Avatar,
|
Avatar,
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
|
Ban,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -201,29 +204,49 @@ export function AppLayout({
|
|||||||
CUSTOMER_SERVICES.includes(p.type as ServiceType),
|
CUSTOMER_SERVICES.includes(p.type as ServiceType),
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
// Suspended services are also hidden by default (the profile exists) — surface
|
||||||
|
// them so the customer can appeal by resubmitting a fresh business license.
|
||||||
|
const suspendedServices = isCustomer
|
||||||
|
? companyProfiles.filter(
|
||||||
|
(p) =>
|
||||||
|
p.status === "suspended" &&
|
||||||
|
p.id &&
|
||||||
|
CUSTOMER_SERVICES.includes(p.type as ServiceType),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
const canManageServices =
|
const canManageServices =
|
||||||
isCustomer && (addableServices.length > 0 || rejectedServices.length > 0);
|
isCustomer &&
|
||||||
|
(addableServices.length > 0 ||
|
||||||
|
rejectedServices.length > 0 ||
|
||||||
|
suspendedServices.length > 0);
|
||||||
|
|
||||||
const [switching, setSwitching] = useState(false);
|
const [switching, setSwitching] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createTarget, setCreateTarget] = useState<ServiceType>("importer");
|
const [createTarget, setCreateTarget] = useState<ServiceType>("importer");
|
||||||
// Non-null while resubmitting a rejected service; null while creating a new one.
|
// Non-null while resubmitting a rejected/suspended service; null while creating a new one.
|
||||||
const [reapplyId, setReapplyId] = useState<string | null>(null);
|
const [reapplyId, setReapplyId] = useState<string | null>(null);
|
||||||
|
// Status of the profile being resubmitted ("rejected" | "suspended") — drives
|
||||||
|
// the modal copy; null for a brand-new profile.
|
||||||
|
const [reapplyStatus, setReapplyStatus] = useState<string | null>(null);
|
||||||
|
// Reason the profile was suspended/rejected, surfaced in the modal.
|
||||||
|
const [reapplyNote, setReapplyNote] = useState<string | null>(null);
|
||||||
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
const openServiceModal = (
|
const openServiceModal = (
|
||||||
type: ServiceType,
|
type: ServiceType,
|
||||||
profileId: string | null,
|
profile?: { id?: string; status?: string; reviewNote?: string | null },
|
||||||
) => {
|
) => {
|
||||||
setCreateTarget(type);
|
setCreateTarget(type);
|
||||||
setReapplyId(profileId);
|
setReapplyId(profile?.id ?? null);
|
||||||
|
setReapplyStatus(profile?.status ?? null);
|
||||||
|
setReapplyNote(profile?.reviewNote ?? null);
|
||||||
setLicenseFiles([]);
|
setLicenseFiles([]);
|
||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddService = (type: ServiceType) => openServiceModal(type, null);
|
const handleAddService = (type: ServiceType) => openServiceModal(type);
|
||||||
|
|
||||||
const handleCreateConfirm = async () => {
|
const handleCreateConfirm = async () => {
|
||||||
const isReapply = reapplyId !== null;
|
const isReapply = reapplyId !== null;
|
||||||
@@ -250,6 +273,7 @@ export function AppLayout({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
|
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
|
||||||
|
const isSuspendedAppeal = reapplyStatus === "suspended";
|
||||||
|
|
||||||
const isItemActive = (item: SidebarItem) =>
|
const isItemActive = (item: SidebarItem) =>
|
||||||
activePath === item.href.toLowerCase() ||
|
activePath === item.href.toLowerCase() ||
|
||||||
@@ -372,7 +396,7 @@ export function AppLayout({
|
|||||||
key={p.id}
|
key={p.id}
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openServiceModal(p.type as ServiceType, p.id!)
|
openServiceModal(p.type as ServiceType, p)
|
||||||
}
|
}
|
||||||
leftSection={<RefreshCw size={15} strokeWidth={1.8} />}
|
leftSection={<RefreshCw size={15} strokeWidth={1.8} />}
|
||||||
>
|
>
|
||||||
@@ -381,6 +405,30 @@ export function AppLayout({
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{suspendedServices.length > 0 && (
|
||||||
|
<>
|
||||||
|
{(addableServices.length > 0 ||
|
||||||
|
rejectedServices.length > 0) && <Menu.Divider />}
|
||||||
|
<Menu.Label>Suspended — appeal</Menu.Label>
|
||||||
|
{suspendedServices.map((p) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={p.id}
|
||||||
|
color="orange"
|
||||||
|
onClick={() =>
|
||||||
|
openServiceModal(p.type as ServiceType, p)
|
||||||
|
}
|
||||||
|
leftSection={<Ban size={15} strokeWidth={1.8} />}
|
||||||
|
rightSection={
|
||||||
|
<Badge size="xs" color="orange" variant="light">
|
||||||
|
Suspended
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{serviceLabel(p.type as ServiceType)}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
)}
|
)}
|
||||||
@@ -849,25 +897,39 @@ export function AppLayout({
|
|||||||
opened={createOpen}
|
opened={createOpen}
|
||||||
onClose={() => (switching ? undefined : setCreateOpen(false))}
|
onClose={() => (switching ? undefined : setCreateOpen(false))}
|
||||||
title={
|
title={
|
||||||
reapplyId
|
isSuspendedAppeal
|
||||||
? `Resubmit your ${serviceLabel(createTarget)} service`
|
? `Appeal suspension — ${serviceLabel(createTarget)}`
|
||||||
: `Set up your ${serviceLabel(createTarget)} profile`
|
: reapplyId
|
||||||
|
? `Resubmit your ${serviceLabel(createTarget)} service`
|
||||||
|
: `Set up your ${serviceLabel(createTarget)} profile`
|
||||||
}
|
}
|
||||||
centered
|
centered
|
||||||
radius="lg"
|
radius="lg"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{reapplyId
|
{isSuspendedAppeal
|
||||||
? `Your ${serviceLabel(
|
? `Your ${serviceLabel(
|
||||||
createTarget,
|
createTarget,
|
||||||
).toLowerCase()} service was rejected. Replace the business license if needed, then resubmit for approval.`
|
).toLowerCase()} service is currently suspended. Replace the business license if needed and resubmit — this sends your appeal back to EDR for review.`
|
||||||
: `You don't have a ${serviceLabel(
|
: reapplyId
|
||||||
createTarget,
|
? `Your ${serviceLabel(
|
||||||
).toLowerCase()} profile yet. Add your business license to create one and switch to ${serviceLabel(
|
createTarget,
|
||||||
createTarget,
|
).toLowerCase()} service was rejected. Replace the business license if needed, then resubmit for approval.`
|
||||||
).toLowerCase()}.`}
|
: `You don't have a ${serviceLabel(
|
||||||
|
createTarget,
|
||||||
|
).toLowerCase()} profile yet. Add your business license to create one — it goes to EDR for approval before you can operate under it.`}
|
||||||
</Text>
|
</Text>
|
||||||
|
{isSuspendedAppeal && reapplyNote && (
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
variant="light"
|
||||||
|
icon={<Ban size={16} />}
|
||||||
|
title="Reason for suspension"
|
||||||
|
>
|
||||||
|
{reapplyNote}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<FileInput
|
<FileInput
|
||||||
label={reapplyId ? "Business license (optional)" : "Business license"}
|
label={reapplyId ? "Business license (optional)" : "Business license"}
|
||||||
multiple
|
multiple
|
||||||
@@ -892,7 +954,11 @@ export function AppLayout({
|
|||||||
onClick={handleCreateConfirm}
|
onClick={handleCreateConfirm}
|
||||||
loading={switching}
|
loading={switching}
|
||||||
>
|
>
|
||||||
{reapplyId ? "Resubmit" : "Create & switch"}
|
{isSuspendedAppeal
|
||||||
|
? "Submit appeal"
|
||||||
|
: reapplyId
|
||||||
|
? "Resubmit"
|
||||||
|
: "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -11,22 +11,21 @@ interface NewBookingButtonProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* New-booking entry point that respects approval status: a customer can only
|
* New-booking entry point that respects approval status: a customer can only
|
||||||
* create bookings under a profile once the backoffice has approved it. While the
|
* create bookings once the backoffice has approved at least one operational
|
||||||
* active profile is pending the button is disabled with an explanation, so the
|
* role. While every role is still pending the button is disabled with an
|
||||||
* gate is communicated rather than silently failing at submit time.
|
* explanation, so the gate is communicated rather than failing at submit time.
|
||||||
*/
|
*/
|
||||||
export function NewBookingButton({
|
export function NewBookingButton({
|
||||||
label = "New booking",
|
label = "New booking",
|
||||||
size,
|
size,
|
||||||
mt,
|
mt,
|
||||||
}: NewBookingButtonProps) {
|
}: NewBookingButtonProps) {
|
||||||
const { canBook, activeProfileStatus } = useAuth();
|
const { canBook, hasPendingProfile } = useAuth();
|
||||||
|
|
||||||
if (!canBook) {
|
if (!canBook) {
|
||||||
const message =
|
const message = hasPendingProfile
|
||||||
activeProfileStatus === "pending"
|
? "Your role is awaiting approval. You'll be able to create bookings as soon as it's approved."
|
||||||
? "Your profile is awaiting approval. You'll be able to create bookings as soon as it's approved."
|
: "Bookings aren't available until one of your roles is approved.";
|
||||||
: "Bookings aren't available for this profile yet.";
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={message} multiline w={250} withArrow position="bottom-end">
|
<Tooltip label={message} multiline w={250} withArrow position="bottom-end">
|
||||||
<Box mt={mt}>
|
<Box mt={mt}>
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ export const URL_CONSTANTS = {
|
|||||||
PROFILE: "/api/companies/profile",
|
PROFILE: "/api/companies/profile",
|
||||||
COMPANY_PROFILES: "/api/companies/company-profiles",
|
COMPANY_PROFILES: "/api/companies/company-profiles",
|
||||||
COMPANY_PROFILE: "/api/companies/company-profile",
|
COMPANY_PROFILE: "/api/companies/company-profile",
|
||||||
ACTIVE_MODE: "/api/companies/active-mode",
|
|
||||||
ONBOARDING_START: "/api/companies/onboarding/start",
|
ONBOARDING_START: "/api/companies/onboarding/start",
|
||||||
ONBOARDING_STEP: "/api/companies/onboarding-step",
|
ONBOARDING_STEP: "/api/companies/onboarding-step",
|
||||||
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
|
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
|
||||||
|
|||||||
@@ -158,11 +158,7 @@ const useAuth = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Active-mode (importer/exporter) state, sourced from the persisted profile.
|
|
||||||
const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
|
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 companyType = companyInfo?.company?.type ?? null;
|
||||||
const companyStatus = companyInfo?.company?.status ?? null;
|
const companyStatus = companyInfo?.company?.status ?? null;
|
||||||
// A company can create bookings only once an admin has approved it (active).
|
// A company can create bookings only once an admin has approved it (active).
|
||||||
@@ -171,14 +167,13 @@ const useAuth = () => {
|
|||||||
companyInfo?.profile?.onboardingCompleted ?? false;
|
companyInfo?.profile?.onboardingCompleted ?? false;
|
||||||
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
|
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
|
||||||
|
|
||||||
// Booking is gated on backoffice approval of the active operational profile:
|
// A booking/contract stamps its operational profile from the trade direction
|
||||||
// a customer can only book under a profile once its status is "active".
|
// at creation time, so there's no "active mode": the customer can create work
|
||||||
const activeProfile =
|
// as long as they have at least one backoffice-approved operational role.
|
||||||
companyInfo?.company?.companyProfiles?.find(
|
const companyProfiles = companyInfo?.company?.companyProfiles ?? [];
|
||||||
(p) => p.id === activeCompanyProfileId,
|
const hasActiveProfile = companyProfiles.some((p) => p.status === "active");
|
||||||
) ?? null;
|
const hasPendingProfile = companyProfiles.some((p) => p.status === "pending");
|
||||||
const activeProfileStatus = activeProfile?.status ?? null;
|
const canBook = hasActiveProfile;
|
||||||
const canBook = activeProfileStatus === "active";
|
|
||||||
|
|
||||||
// Profile-edit review: while a change request is pending the customer is
|
// Profile-edit review: while a change request is pending the customer is
|
||||||
// locked out of editing and of creating new contracts/bookings; a rejected
|
// locked out of editing and of creating new contracts/bookings; a rejected
|
||||||
@@ -188,7 +183,7 @@ const useAuth = () => {
|
|||||||
const reviewNote = review?.note ?? null;
|
const reviewNote = review?.note ?? null;
|
||||||
const isUnderReview = reviewStatus === "pending";
|
const isUnderReview = reviewStatus === "pending";
|
||||||
|
|
||||||
/** Refetch everything scoped to the active operational profile. */
|
/** Refetch company info, dashboard, and bookings after a profile change. */
|
||||||
const invalidateScopedData = async () => {
|
const invalidateScopedData = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -201,16 +196,6 @@ const useAuth = () => {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
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) };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add an operational role. The new role starts pending review, so the active
|
* Add an operational role. The new role starts pending review, so the active
|
||||||
* mode is left untouched — the user keeps working under their approved role.
|
* mode is left untouched — the user keeps working under their approved role.
|
||||||
@@ -276,10 +261,9 @@ const useAuth = () => {
|
|||||||
user: isAuthenticated ? (authQuery.data ?? null) : null,
|
user: isAuthenticated ? (authQuery.data ?? null) : null,
|
||||||
company: isAuthenticated ? (companyQuery.data ?? null) : null,
|
company: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||||
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||||
activeProfileType,
|
|
||||||
activeCompanyProfileId,
|
|
||||||
activeProfileStatus,
|
|
||||||
canBook,
|
canBook,
|
||||||
|
hasActiveProfile,
|
||||||
|
hasPendingProfile,
|
||||||
companyType,
|
companyType,
|
||||||
companyStatus,
|
companyStatus,
|
||||||
isCompanyApproved,
|
isCompanyApproved,
|
||||||
@@ -288,7 +272,6 @@ const useAuth = () => {
|
|||||||
isUnderReview,
|
isUnderReview,
|
||||||
onboardingCompleted,
|
onboardingCompleted,
|
||||||
onboardingStep,
|
onboardingStep,
|
||||||
switchMode,
|
|
||||||
createProfile,
|
createProfile,
|
||||||
reapplyProfile,
|
reapplyProfile,
|
||||||
login,
|
login,
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ export function captureApiError(error: unknown): void {
|
|||||||
/** Company context, as returned by `useAuth().company`. */
|
/** Company context, as returned by `useAuth().company`. */
|
||||||
interface IdentifyCompany {
|
interface IdentifyCompany {
|
||||||
company?: { id?: string; type?: string | null; status?: string | null } | null;
|
company?: { id?: string; type?: string | null; status?: string | null } | null;
|
||||||
profile?: { activeProfileType?: string | null } | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,7 +135,6 @@ export function useIdentify(
|
|||||||
company_id: company?.company?.id,
|
company_id: company?.company?.id,
|
||||||
company_type: company?.company?.type,
|
company_type: company?.company?.type,
|
||||||
company_status: company?.company?.status,
|
company_status: company?.company?.status,
|
||||||
active_profile_type: company?.profile?.activeProfileType,
|
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
user?.id,
|
user?.id,
|
||||||
@@ -146,6 +144,5 @@ export function useIdentify(
|
|||||||
company?.company?.id,
|
company?.company?.id,
|
||||||
company?.company?.type,
|
company?.company?.type,
|
||||||
company?.company?.status,
|
company?.company?.status,
|
||||||
company?.profile?.activeProfileType,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { api } from "@/services/api";
|
|||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Clock,
|
Clock,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
Layers,
|
Layers,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -37,6 +39,8 @@ import {
|
|||||||
UserCog,
|
UserCog,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { companiesService } from "@/services/companies.service";
|
import { companiesService } from "@/services/companies.service";
|
||||||
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
|
import { useFileViewer } from "@edr/ui-common";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
@@ -300,8 +304,6 @@ export default function SettingsPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<OperationalServicesCard profile={profile} />
|
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={(value) => value && setTab(value as SettingsTab)}
|
onChange={(value) => value && setTab(value as SettingsTab)}
|
||||||
@@ -354,6 +356,7 @@ export default function SettingsPage() {
|
|||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
<TabCompanyProfile mode="edit" profile={profile} />
|
<TabCompanyProfile mode="edit" profile={profile} />
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
<OperationalServicesCard profile={profile} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="contact">
|
<Tabs.Panel value="contact">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
@@ -404,6 +407,7 @@ const ROLE_STATUS: Record<string, { color: string; label: string }> = {
|
|||||||
*/
|
*/
|
||||||
function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
|
function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
const roles = profile.companyProfiles;
|
const roles = profile.companyProfiles;
|
||||||
|
|
||||||
const refresh = () =>
|
const refresh = () =>
|
||||||
@@ -431,7 +435,8 @@ function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
|
|||||||
if (roles.length === 0) return null;
|
if (roles.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="lg" radius="lg">
|
<>
|
||||||
|
<Card padding="lg" radius="lg" mt="lg">
|
||||||
<Group gap="sm" mb="md">
|
<Group gap="sm" mb="md">
|
||||||
<Layers size={20} />
|
<Layers size={20} />
|
||||||
<Title order={3}>Operational Services</Title>
|
<Title order={3}>Operational Services</Title>
|
||||||
@@ -465,10 +470,62 @@ function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
{r.status === "rejected" && r.reviewNote && (
|
{(r.status === "rejected" || r.status === "suspended") &&
|
||||||
<Text size="sm" c="red.7">
|
r.reviewNote && (
|
||||||
<strong>Reviewer note:</strong> {r.reviewNote}
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c={r.status === "suspended" ? "orange.7" : "red.7"}
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
{r.status === "suspended"
|
||||||
|
? "Suspension reason:"
|
||||||
|
: "Reviewer note:"}
|
||||||
|
</strong>{" "}
|
||||||
|
{r.reviewNote}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{r.licenseFiles.length === 0 ? (
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
No license document
|
||||||
</Text>
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap={4}>
|
||||||
|
{r.licenseFiles.map((f) => (
|
||||||
|
<Group key={f.id} gap="xs" wrap="nowrap">
|
||||||
|
<FileText
|
||||||
|
size={14}
|
||||||
|
className="text-edr-muted"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="xs"
|
||||||
|
lineClamp={1}
|
||||||
|
onClick={() =>
|
||||||
|
void fetchViewableFile(f.id, f.name).then(view)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</Anchor>
|
||||||
|
{f.status !== "live" && (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
radius="sm"
|
||||||
|
variant="light"
|
||||||
|
color={
|
||||||
|
f.status === "pending_remove" ? "red" : "yellow"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{f.status === "pending_remove"
|
||||||
|
? "Removal pending"
|
||||||
|
: "Pending"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -484,7 +541,9 @@ function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
{viewer}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
bookingFormSchema,
|
bookingFormSchema,
|
||||||
getRouteDirection,
|
getRouteDirection,
|
||||||
initialBookingFormValues,
|
initialBookingFormValues,
|
||||||
|
isForwarderOperation,
|
||||||
operationToProfileType,
|
operationToProfileType,
|
||||||
operationToTradeDirection,
|
operationToTradeDirection,
|
||||||
stepFields,
|
stepFields,
|
||||||
@@ -384,25 +385,23 @@ export default function NewBookingPage() {
|
|||||||
[profileTypes],
|
[profileTypes],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stamp the booking to the right operational profile. Import/Export (and their
|
// The company_profile this booking belongs to, derived from the selected
|
||||||
// "as FF" variants) switch the active mode so the matching onboarding documents
|
// operation: import→importer, export→exporter, and the "as FF" variants →
|
||||||
// are attached; Intercity uses whatever profile is already active.
|
// freight_forwarder. A forwarder booking is pinned explicitly on submit
|
||||||
const handleOperationSelect = (op: OperationType) => {
|
// (companyProfileId) because trade direction alone can't distinguish it.
|
||||||
if (op === "intercity") return;
|
const selectedProfile = useMemo(() => {
|
||||||
const target = operationToProfileType(op, profileTypes);
|
|
||||||
if (auth.activeProfileType !== target) {
|
|
||||||
void auth.switchMode(target as never);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Onboarding documents for the active profile — shown read-only in the
|
|
||||||
// Documents step and attached to the booking on submit by the backend.
|
|
||||||
const onboardingDocs = useMemo(() => {
|
|
||||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||||
const active =
|
if (!operationType) return profiles[0] ?? null;
|
||||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
const targetType = operationToProfileType(operationType, profileTypes);
|
||||||
return active?.licenseFiles ?? [];
|
return profiles.find((p) => p.type === targetType) ?? profiles[0] ?? null;
|
||||||
}, [auth.company, auth.activeCompanyProfileId]);
|
}, [auth.company, operationType, profileTypes]);
|
||||||
|
|
||||||
|
// Onboarding documents for the resolved profile — shown read-only in the
|
||||||
|
// Documents step and attached to the booking on submit by the backend.
|
||||||
|
const onboardingDocs = useMemo(
|
||||||
|
() => selectedProfile?.licenseFiles ?? [],
|
||||||
|
[selectedProfile],
|
||||||
|
);
|
||||||
|
|
||||||
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
|
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
|
||||||
null,
|
null,
|
||||||
@@ -483,7 +482,16 @@ export default function NewBookingPage() {
|
|||||||
(s) => s.id === data.serviceTypeId,
|
(s) => s.id === data.serviceTypeId,
|
||||||
)!;
|
)!;
|
||||||
|
|
||||||
|
// Pin the profile only for a forwarder booking — trade direction resolves
|
||||||
|
// importer/exporter on its own, but can't tell a forwarder apart.
|
||||||
|
const forwarderProfileId =
|
||||||
|
data.operationType &&
|
||||||
|
isForwarderOperation(data.operationType, profileTypes)
|
||||||
|
? selectedProfile?.id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...(forwarderProfileId ? { companyProfileId: forwarderProfileId } : {}),
|
||||||
bookingType: isContract
|
bookingType: isContract
|
||||||
? Freight.BookingType.GeneralContract
|
? Freight.BookingType.GeneralContract
|
||||||
: Freight.BookingType.OneTime,
|
: Freight.BookingType.OneTime,
|
||||||
@@ -711,7 +719,6 @@ export default function NewBookingPage() {
|
|||||||
<Step0OperationType
|
<Step0OperationType
|
||||||
form={form}
|
form={form}
|
||||||
allowedOperations={allowedOperations}
|
allowedOperations={allowedOperations}
|
||||||
onSelect={handleOperationSelect}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { type UseFormReturn } from "react-hook-form";
|
|||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import {
|
import {
|
||||||
|
operationToProfileType,
|
||||||
type BookingDocuments,
|
type BookingDocuments,
|
||||||
type BookingFormInputValues,
|
type BookingFormInputValues,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
@@ -58,11 +59,20 @@ export function StepDocuments({ form }: { form: BookingForm }) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Documents already on file from onboarding (read-only reference).
|
// Documents already on file from onboarding (read-only reference), for the
|
||||||
|
// profile this booking's operation resolves to (importer/exporter/forwarder).
|
||||||
const onboardingDocs = (() => {
|
const onboardingDocs = (() => {
|
||||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||||
|
const operationType = form.watch("operationType");
|
||||||
|
const targetType = operationType
|
||||||
|
? operationToProfileType(
|
||||||
|
operationType,
|
||||||
|
profiles.map((p) => p.type),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
const active =
|
const active =
|
||||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
(targetType && profiles.find((p) => p.type === targetType)) ??
|
||||||
|
profiles[0];
|
||||||
return active?.licenseFiles ?? [];
|
return active?.licenseFiles ?? [];
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
} from "./new-contract-form/schema";
|
} from "./new-contract-form/schema";
|
||||||
import {
|
import {
|
||||||
getRouteDirection,
|
getRouteDirection,
|
||||||
|
isForwarderOperation,
|
||||||
operationToProfileType,
|
operationToProfileType,
|
||||||
operationToTradeDirection,
|
operationToTradeDirection,
|
||||||
} from "./new-contract-form/helpers";
|
} from "./new-contract-form/helpers";
|
||||||
@@ -469,10 +470,8 @@ export default function NewContractPage({
|
|||||||
form.setValue("operationType", undefined as never, { shouldDirty: true });
|
form.setValue("operationType", undefined as never, { shouldDirty: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Case 1 — approved: proceed, switching the active profile if needed.
|
// Case 1 — approved: proceed. The profile is resolved from the operation at
|
||||||
if (auth.activeProfileType !== target) {
|
// submit time (a forwarder operation pins it explicitly), so nothing to set.
|
||||||
void auth.switchMode(target as never);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateProfileConfirm = () => {
|
const handleCreateProfileConfirm = () => {
|
||||||
@@ -512,10 +511,14 @@ export default function NewContractPage({
|
|||||||
|
|
||||||
const onboardingDocs = useMemo(() => {
|
const onboardingDocs = useMemo(() => {
|
||||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||||
|
const targetType = operationType
|
||||||
|
? operationToProfileType(operationType, profileTypes)
|
||||||
|
: null;
|
||||||
const active =
|
const active =
|
||||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
(targetType && profiles.find((p) => p.type === targetType)) ??
|
||||||
|
profiles[0];
|
||||||
return active?.licenseFiles ?? [];
|
return active?.licenseFiles ?? [];
|
||||||
}, [auth.company, auth.activeCompanyProfileId]);
|
}, [auth.company, operationType, profileTypes]);
|
||||||
|
|
||||||
async function handleContinue() {
|
async function handleContinue() {
|
||||||
const fields = contractStepFields[step];
|
const fields = contractStepFields[step];
|
||||||
@@ -569,7 +572,20 @@ export default function NewContractPage({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Pin the profile only for a forwarder contract (trade direction can't tell
|
||||||
|
// a forwarder apart from a direct import/export).
|
||||||
|
const forwarderProfileId =
|
||||||
|
data.operationType &&
|
||||||
|
isForwarderOperation(data.operationType, profileTypes)
|
||||||
|
? (auth.company?.company?.companyProfiles ?? []).find(
|
||||||
|
(p) =>
|
||||||
|
p.type ===
|
||||||
|
operationToProfileType(data.operationType!, profileTypes),
|
||||||
|
)?.id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...(forwarderProfileId ? { companyProfileId: forwarderProfileId } : {}),
|
||||||
contractKind: isGeneral
|
contractKind: isGeneral
|
||||||
? Freight.ContractKind.General
|
? Freight.ContractKind.General
|
||||||
: Freight.ContractKind.OneTime,
|
: Freight.ContractKind.OneTime,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type ContractFormInputValues,
|
type ContractFormInputValues,
|
||||||
type ContractFormValues,
|
type ContractFormValues,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
import { operationToProfileType } from "./helpers";
|
||||||
import { StepCard, StepHeader } from "./shared";
|
import { StepCard, StepHeader } from "./shared";
|
||||||
|
|
||||||
type ContractForm = UseFormReturn<
|
type ContractForm = UseFormReturn<
|
||||||
@@ -73,8 +74,16 @@ export function StepDocuments({
|
|||||||
|
|
||||||
const onboardingDocs = (() => {
|
const onboardingDocs = (() => {
|
||||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||||
|
const operationType = form.watch("operationType");
|
||||||
|
const targetType = operationType
|
||||||
|
? operationToProfileType(
|
||||||
|
operationType,
|
||||||
|
profiles.map((p) => p.type),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
const active =
|
const active =
|
||||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
(targetType && profiles.find((p) => p.type === targetType)) ??
|
||||||
|
profiles[0];
|
||||||
return active?.licenseFiles ?? [];
|
return active?.licenseFiles ?? [];
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -240,12 +240,6 @@ export const api = {
|
|||||||
CompanyInfoResponse
|
CompanyInfoResponse
|
||||||
>("companies", "startOnboarding", companiesService.startOnboarding),
|
>("companies", "startOnboarding", companiesService.startOnboarding),
|
||||||
|
|
||||||
setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>(
|
|
||||||
"companies",
|
|
||||||
"setActiveMode",
|
|
||||||
companiesService.setActiveMode,
|
|
||||||
),
|
|
||||||
|
|
||||||
setOnboardingStep: endpoint<{ step: string }, void>(
|
setOnboardingStep: endpoint<{ step: string }, void>(
|
||||||
"companies",
|
"companies",
|
||||||
"setOnboardingStep",
|
"setOnboardingStep",
|
||||||
|
|||||||
@@ -45,10 +45,6 @@ export interface ExternalProfileResponse {
|
|||||||
nationalId: string | null;
|
nationalId: string | null;
|
||||||
jobTitle: string | null;
|
jobTitle: string | null;
|
||||||
isPrimaryContact: boolean;
|
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;
|
onboardingStep: string | null;
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -323,17 +319,6 @@ export const companiesService = {
|
|||||||
return unwrap(response.data);
|
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> => {
|
setOnboardingStep: async (payload: { step: string }): Promise<void> => {
|
||||||
await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload);
|
await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user