mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: the issue in the sheets file
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `company_profiles.status` defaulted to 'active', so any insert that omitted
|
||||||
|
* the column produced an operational role that was approved without ever being
|
||||||
|
* reviewed. Every live write path already passes 'pending' explicitly; this
|
||||||
|
* closes the hole at the schema level.
|
||||||
|
*
|
||||||
|
* Deliberately no data backfill. A role approved through setCompanyProfileStatus
|
||||||
|
* always stamps `reviewed_at`, so `status = 'active' AND reviewed_at IS NULL`
|
||||||
|
* flags a role that skipped review — but it also matches rows approved before
|
||||||
|
* `reviewed_at` existed (migration 2000000000001). Auditing that set is a
|
||||||
|
* judgement call about real customers, not something to automate here.
|
||||||
|
*/
|
||||||
|
export class CompanyProfileDefaultPending2100000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'CompanyProfileDefaultPending2100000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'pending'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'active'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -221,7 +221,7 @@ export class CompaniesController {
|
|||||||
@Post("company-profile")
|
@Post("company-profile")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Create a single operational profile for the current user's company and make it the active mode",
|
"Create a single operational profile for the current user's company. The role starts pending and does not become the active mode",
|
||||||
})
|
})
|
||||||
async createCompanyProfile(
|
async createCompanyProfile(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module, forwardRef } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { FilesModule } from "../files/files.module";
|
import { FilesModule } from "../files/files.module";
|
||||||
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
|
||||||
import { MinioModule } from "../minio/minio.module";
|
import { MinioModule } from "../minio/minio.module";
|
||||||
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
|
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||||
import { CompaniesController } from "./companies.controller";
|
import { CompaniesController } from "./companies.controller";
|
||||||
import { CompaniesService } from "./companies.service";
|
import { CompaniesService } from "./companies.service";
|
||||||
import { CompaniesRepository } from "./companies.repository";
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
@@ -17,6 +19,7 @@ import { Booking } from "../bookings/entities/booking.entity";
|
|||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
import { ETradeService } from "./services/etrade.service";
|
import { ETradeService } from "./services/etrade.service";
|
||||||
|
import { CompanyNotifierService } from "./company-notifier.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -31,6 +34,10 @@ import { ETradeService } from "./services/etrade.service";
|
|||||||
FilesModule,
|
FilesModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
|
// Account-status notifications (CompanyNotifierService). The inbox module
|
||||||
|
// imports this module back for portal recipient targeting, hence forwardRef.
|
||||||
|
NotificationsModule,
|
||||||
|
forwardRef(() => NotificationInboxModule),
|
||||||
],
|
],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -41,6 +48,7 @@ import { ETradeService } from "./services/etrade.service";
|
|||||||
CompanyChangeRequestRepository,
|
CompanyChangeRequestRepository,
|
||||||
CompanyDashboardRepository,
|
CompanyDashboardRepository,
|
||||||
ETradeService,
|
ETradeService,
|
||||||
|
CompanyNotifierService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CompaniesService,
|
CompaniesService,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { FilesService } from "../files/files.service";
|
|||||||
import { FileRecord } from "../files/entities/file.entity";
|
import { FileRecord } from "../files/entities/file.entity";
|
||||||
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||||
import { ETradeService } from "./services/etrade.service";
|
import { ETradeService } from "./services/etrade.service";
|
||||||
|
import { CompanyNotifierService } from "./company-notifier.service";
|
||||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||||
@@ -96,6 +97,7 @@ export class CompaniesService {
|
|||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||||
private readonly etradeService: ETradeService,
|
private readonly etradeService: ETradeService,
|
||||||
|
private readonly companyNotifier: CompanyNotifierService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -585,9 +587,13 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||||
await this.findCompanyById(id);
|
const before = await this.findCompanyById(id);
|
||||||
const updated = await this.companiesRepo.update(id, dto);
|
const updated = await this.companiesRepo.update(id, dto);
|
||||||
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
||||||
|
|
||||||
|
// Suspending or blacklisting locks the customer out, so they must be told.
|
||||||
|
// This is the only path that writes those statuses.
|
||||||
|
this.companyNotifier.statusChanged(updated, before.status);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1051,13 +1057,12 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
// No reference is minted here: it is issued by setCompanyProfileStatus when
|
||||||
|
// a reviewer approves the role. Creating it Active would bypass that review.
|
||||||
return this.companyProfilesRepo.create({
|
return this.companyProfilesRepo.create({
|
||||||
companyId,
|
companyId,
|
||||||
type,
|
type,
|
||||||
reference,
|
status: ProfileStatus.Pending,
|
||||||
status: ProfileStatus.Active,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,9 +1136,11 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a single operational profile for the current user's company and
|
* Create a single operational profile for the current user's company. The new
|
||||||
* make it the active mode in the same call. Powers the header "Switch to
|
* role starts Pending, so it deliberately does NOT become the active mode:
|
||||||
* Exporter/Importer" flow when the target profile doesn't exist yet.
|
* switching onto an unapproved profile would strip the user of `canBook` and
|
||||||
|
* block them from creating contracts under the role they already had approved.
|
||||||
|
* Callers switch explicitly via {@link setActiveMode} once the role is Active.
|
||||||
*/
|
*/
|
||||||
async createCompanyProfileForUser(
|
async createCompanyProfileForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1156,8 +1163,7 @@ export class CompaniesService {
|
|||||||
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
||||||
if (!created) {
|
if (!created) {
|
||||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||||
// carry no reference until approved. The customer can select this mode but
|
// carry no reference until approved.
|
||||||
// can't book under it until it's cleared.
|
|
||||||
created = await this.companyProfilesRepo.create({
|
created = await this.companyProfilesRepo.create({
|
||||||
companyId,
|
companyId,
|
||||||
type,
|
type,
|
||||||
@@ -1166,8 +1172,6 @@ export class CompaniesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1442,10 +1446,12 @@ export class CompaniesService {
|
|||||||
// browser (which fails on the internal bucket endpoint).
|
// browser (which fails on the internal bucket endpoint).
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload business-license file(s) for one of the user's profiles. During
|
* Upload business-license file(s) for one of the user's profiles. For a role
|
||||||
* onboarding (company not yet Active) they go live immediately; for an Active
|
* not yet approved (a fresh onboarding profile, or a newly added service on an
|
||||||
* company they're staged under the pending code and recorded as `add` intents
|
* already-active company) they go live immediately and are reviewed together
|
||||||
* on a pending change request for backoffice review. Returns the updated view.
|
* with the role itself. Only for an already-approved role are they staged under
|
||||||
|
* the pending code and recorded as `add` intents on a pending change request —
|
||||||
|
* a licence swap on a live role is a change; a licence on a new role is not.
|
||||||
*/
|
*/
|
||||||
async addProfileLicenseFiles(
|
async addProfileLicenseFiles(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1454,7 +1460,7 @@ export class CompaniesService {
|
|||||||
): Promise<ProfileLicenseFileView[]> {
|
): Promise<ProfileLicenseFileView[]> {
|
||||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||||
const company = await this.findCompanyById(profile.companyId);
|
const company = await this.findCompanyById(profile.companyId);
|
||||||
const gated = company.status === CompanyStatus.Active;
|
const gated = profile.status === ProfileStatus.Active;
|
||||||
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
|
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
|
||||||
|
|
||||||
const uploaded = await Promise.all(
|
const uploaded = await Promise.all(
|
||||||
@@ -1486,9 +1492,9 @@ export class CompaniesService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a license file. A staged (pending) file is withdrawn outright
|
* Remove a license file. A staged (pending) file is withdrawn outright
|
||||||
* (soft-deleted, its `add` intent dropped). A live file on an Active company
|
* (soft-deleted, its `add` intent dropped). A live file on an already-approved
|
||||||
* is kept and recorded as a `remove` intent for review; during onboarding it
|
* role is kept and recorded as a `remove` intent for review; on a role still
|
||||||
* is deleted immediately.
|
* awaiting approval it is deleted immediately.
|
||||||
*/
|
*/
|
||||||
async removeProfileLicenseFile(
|
async removeProfileLicenseFile(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1504,7 +1510,7 @@ export class CompaniesService {
|
|||||||
throw new NotFoundException(`License file ${fileId} not found`);
|
throw new NotFoundException(`License file ${fileId} not found`);
|
||||||
}
|
}
|
||||||
const company = await this.findCompanyById(profile.companyId);
|
const company = await this.findCompanyById(profile.companyId);
|
||||||
const gated = company.status === CompanyStatus.Active;
|
const gated = profile.status === ProfileStatus.Active;
|
||||||
|
|
||||||
if (record.code === LICENSE_PENDING_CODE) {
|
if (record.code === LICENSE_PENDING_CODE) {
|
||||||
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
|
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
|
||||||
@@ -1526,7 +1532,7 @@ export class CompaniesService {
|
|||||||
/**
|
/**
|
||||||
* Replace a live license file with a freshly uploaded one — recorded as a
|
* Replace a live license file with a freshly uploaded one — recorded as a
|
||||||
* `remove` of the old file plus an `add` of the new, so approval swaps them
|
* `remove` of the old file plus an `add` of the new, so approval swaps them
|
||||||
* atomically. During onboarding the swap is applied immediately.
|
* atomically. On a role still awaiting approval the swap is applied immediately.
|
||||||
*/
|
*/
|
||||||
async replaceProfileLicenseFile(
|
async replaceProfileLicenseFile(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1540,7 +1546,7 @@ export class CompaniesService {
|
|||||||
throw new NotFoundException(`License file ${fileId} not found`);
|
throw new NotFoundException(`License file ${fileId} not found`);
|
||||||
}
|
}
|
||||||
const company = await this.findCompanyById(profile.companyId);
|
const company = await this.findCompanyById(profile.companyId);
|
||||||
const gated = company.status === CompanyStatus.Active;
|
const gated = profile.status === ProfileStatus.Active;
|
||||||
|
|
||||||
const created = await this.filesService.upload({
|
const created = await this.filesService.upload({
|
||||||
resourceId: profileId,
|
resourceId: profileId,
|
||||||
@@ -2044,13 +2050,17 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetchETradeData(tin: string) {
|
async fetchETradeData(tin: string) {
|
||||||
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
|
const { businessInfo, companyInfo } =
|
||||||
|
await this.etradeService.resolveCompanyData(tin);
|
||||||
if (!businessInfo) {
|
if (!businessInfo) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const registrationData = this.etradeService.extractRegistrationData(businessInfo);
|
const registrationData = this.etradeService.extractRegistrationData(
|
||||||
|
businessInfo,
|
||||||
|
companyInfo,
|
||||||
|
);
|
||||||
const tinTaken = await this.companiesRepo.existsByTin(tin);
|
const tinTaken = await this.companiesRepo.existsByTin(tin);
|
||||||
return { ...registrationData, tinTaken };
|
return { ...registrationData, tinTaken };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
NotificationAudience,
|
||||||
|
NotificationPriority,
|
||||||
|
NotificationType,
|
||||||
|
} from "@edr/types";
|
||||||
|
|
||||||
|
import { Company, CompanyStatus } from "./entities/company.entity";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||||
|
|
||||||
|
/** Account statuses that lock the customer out and therefore must be told to them. */
|
||||||
|
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
|
||||||
|
CompanyStatus.Suspended,
|
||||||
|
CompanyStatus.Blacklisted,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer notifications for company account-status changes. Mirrors
|
||||||
|
* {@link ContractNotifierService}: SMS + email direct to the company contact,
|
||||||
|
* plus a persisted in-app item. Every send is fire-and-forget and never throws —
|
||||||
|
* a notification failure must not roll back the status change itself.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CompanyNotifierService {
|
||||||
|
private readonly logger = new Logger(CompanyNotifierService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Send SMS + email to the company contact; log-only on failure. */
|
||||||
|
private async notifyContact(company: Company, message: string): Promise<void> {
|
||||||
|
const phone = company.contactPersonPhone ?? company.phone ?? null;
|
||||||
|
const email = company.email ?? company.generalManagerEmail ?? null;
|
||||||
|
|
||||||
|
if (phone) {
|
||||||
|
try {
|
||||||
|
await this.notifications.directSend("sms", phone, message);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`SMS failed for ${company.id}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
try {
|
||||||
|
await this.notifications.directSend("email", email, message);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Email failed for ${company.id}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!phone && !email) {
|
||||||
|
this.logger.warn(`No contact on file for ${company.id} — not notified`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the customer their account was suspended or blacklisted. Called only on
|
||||||
|
* a real transition into one of those statuses; other status writes are silent.
|
||||||
|
*/
|
||||||
|
statusChanged(company: Company, previous: CompanyStatus): void {
|
||||||
|
const status = company.status;
|
||||||
|
if (status === previous) return;
|
||||||
|
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.inbox.notify({
|
||||||
|
recipients: { companyId: company.id },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.ACCOUNT_STATUS,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
link: "/settings",
|
||||||
|
data: { companyId: company.id, status },
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CompanyRegistrationData } from "@edr/types";
|
import { CompanyRegistrationData } from "@edr/types";
|
||||||
|
|
||||||
export class ETradeResponseDto implements CompanyRegistrationData {
|
export class ETradeResponseDto implements CompanyRegistrationData {
|
||||||
|
companyName!: string;
|
||||||
licenceNumber!: string;
|
licenceNumber!: string;
|
||||||
statusDescription!: string;
|
statusDescription!: string;
|
||||||
dateRegistered!: string;
|
dateRegistered!: string;
|
||||||
@@ -20,6 +21,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
|
|||||||
tinTaken?: boolean;
|
tinTaken?: boolean;
|
||||||
|
|
||||||
constructor(data: CompanyRegistrationData) {
|
constructor(data: CompanyRegistrationData) {
|
||||||
|
this.companyName = data.companyName;
|
||||||
this.licenceNumber = data.licenceNumber;
|
this.licenceNumber = data.licenceNumber;
|
||||||
this.statusDescription = data.statusDescription;
|
this.statusDescription = data.statusDescription;
|
||||||
this.dateRegistered = data.dateRegistered;
|
this.dateRegistered = data.dateRegistered;
|
||||||
|
|||||||
@@ -84,11 +84,17 @@ export class CompanyProfile extends BaseEntity {
|
|||||||
})
|
})
|
||||||
reference!: string | null;
|
reference!: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A newly requested operational role is unreviewed, so it defaults to Pending.
|
||||||
|
* Only {@link CompaniesService.setCompanyProfileStatus} may promote it to
|
||||||
|
* Active — an approved-by-default role would let a customer self-grant a
|
||||||
|
* service (e.g. importer) without any documentation review.
|
||||||
|
*/
|
||||||
@Column({
|
@Column({
|
||||||
name: "status",
|
name: "status",
|
||||||
type: "varchar",
|
type: "varchar",
|
||||||
length: 32,
|
length: 32,
|
||||||
default: ProfileStatus.Active,
|
default: ProfileStatus.Pending,
|
||||||
})
|
})
|
||||||
status!: ProfileStatus;
|
status!: ProfileStatus;
|
||||||
|
|
||||||
|
|||||||
@@ -87,12 +87,21 @@ export class ETradeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `companyInfo` carries the registered organization name (`BusinessName`);
|
||||||
|
* `businessInfo` only carries the licence's `TradeName`. Pass both so the
|
||||||
|
* company name resolves to the legal entity rather than the trade name — and
|
||||||
|
* never to `ManagerNameEng`, which is the manager's personal name.
|
||||||
|
*/
|
||||||
extractRegistrationData(
|
extractRegistrationData(
|
||||||
businessInfo: ETradeBusinessInfo,
|
businessInfo: ETradeBusinessInfo,
|
||||||
|
companyInfo?: ETradeCompanyInfo,
|
||||||
): CompanyRegistrationData {
|
): CompanyRegistrationData {
|
||||||
const primaryManager = businessInfo.AssociateShortInfos?.[0];
|
const primaryManager = businessInfo.AssociateShortInfos?.[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
companyName:
|
||||||
|
companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "",
|
||||||
licenceNumber: businessInfo.LicenceNumber,
|
licenceNumber: businessInfo.LicenceNumber,
|
||||||
statusDescription: businessInfo.StatusDescription,
|
statusDescription: businessInfo.StatusDescription,
|
||||||
dateRegistered: businessInfo.DateRegistered,
|
dateRegistered: businessInfo.DateRegistered,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module, forwardRef } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
|
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
|
||||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
@@ -17,8 +17,9 @@ import { WsAuthService } from "./ws-auth.service";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Notification, User, Session]),
|
TypeOrmModule.forFeature([Notification, User, Session]),
|
||||||
// ExternalProfileRepository + CompanyProfileRepository (portal targeting)
|
// ExternalProfileRepository + CompanyProfileRepository (portal targeting).
|
||||||
CompaniesModule,
|
// CompaniesModule imports this module back for CompanyNotifierService.
|
||||||
|
forwardRef(() => CompaniesModule),
|
||||||
// BackofficeService.getOrganizationEmployees (staff targeting)
|
// BackofficeService.getOrganizationEmployees (staff targeting)
|
||||||
BackofficeModule,
|
BackofficeModule,
|
||||||
// EmailClientService + SmsClientService (HIGH-priority fan-out)
|
// EmailClientService + SmsClientService (HIGH-priority fan-out)
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export interface ActionShellProps {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
/** When true the action is already done — children are hidden, a done badge shows. */
|
/** When true the action is already done — children are hidden, a done badge shows. */
|
||||||
done?: boolean;
|
done?: boolean;
|
||||||
|
/**
|
||||||
|
* Keep the input controls mounted alongside the done badge. For actions whose
|
||||||
|
* value stays correctable after completion (e.g. customs risk), rather than
|
||||||
|
* the default one-and-done actions.
|
||||||
|
*/
|
||||||
|
keepChildrenWhenDone?: boolean;
|
||||||
doneLabel?: ReactNode;
|
doneLabel?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -22,6 +28,7 @@ export function ActionShell({
|
|||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
done,
|
done,
|
||||||
|
keepChildrenWhenDone,
|
||||||
doneLabel,
|
doneLabel,
|
||||||
children,
|
children,
|
||||||
}: ActionShellProps) {
|
}: ActionShellProps) {
|
||||||
@@ -64,7 +71,7 @@ export function ActionShell({
|
|||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
{!done ? children : null}
|
{!done || keepChildrenWhenDone ? children : null}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Badge, Box, Button, Group, SegmentedControl, Text } from "@mantine/core";
|
import { Badge, Box, Button, Group, SegmentedControl, Text } from "@mantine/core";
|
||||||
import { ShieldAlert } from "lucide-react";
|
import { ShieldAlert } from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
@@ -15,22 +15,42 @@ const RISK_COLOR: Record<Freight.CustomsRiskLevel, string> = {
|
|||||||
export function AssignRiskCard({
|
export function AssignRiskCard({
|
||||||
bookingId,
|
bookingId,
|
||||||
milestone,
|
milestone,
|
||||||
|
locked = false,
|
||||||
}: {
|
}: {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
milestone: Freight.IClearanceMilestone;
|
milestone: Freight.IClearanceMilestone;
|
||||||
|
/**
|
||||||
|
* Duty has already been advised off this risk level, so the decision is now
|
||||||
|
* final. Until then a mis-assigned level must stay correctable — the server
|
||||||
|
* accepts reassignment and overwrites the milestone metadata.
|
||||||
|
*/
|
||||||
|
locked?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const assign = useAssignRisk(bookingId);
|
const assign = useAssignRisk(bookingId);
|
||||||
const [level, setLevel] = useState<Freight.CustomsRiskLevel>("GREEN");
|
|
||||||
|
|
||||||
const assigned = milestone.status === "COMPLETED";
|
const assigned = milestone.status === "COMPLETED";
|
||||||
const current = milestone.metadata?.riskLevel;
|
const current = milestone.metadata?.riskLevel;
|
||||||
|
const [level, setLevel] = useState<Freight.CustomsRiskLevel>(
|
||||||
|
current ?? "GREEN",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The milestone loads (and refetches after a reassignment) after first render,
|
||||||
|
// so mirror the persisted level onto the control whenever it changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (current) setLevel(current);
|
||||||
|
}, [current]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionShell
|
<ActionShell
|
||||||
icon={ShieldAlert}
|
icon={ShieldAlert}
|
||||||
title="Customs risk"
|
title="Customs risk"
|
||||||
subtitle="Assign the customs examination risk level."
|
subtitle={
|
||||||
|
assigned && !locked
|
||||||
|
? "Reassign the customs examination risk level."
|
||||||
|
: "Assign the customs examination risk level."
|
||||||
|
}
|
||||||
done={assigned}
|
done={assigned}
|
||||||
|
keepChildrenWhenDone={!locked}
|
||||||
doneLabel={
|
doneLabel={
|
||||||
current ? (
|
current ? (
|
||||||
<Badge color={RISK_COLOR[current]} variant="filled" radius="sm">
|
<Badge color={RISK_COLOR[current]} variant="filled" radius="sm">
|
||||||
@@ -60,9 +80,10 @@ export function AssignRiskCard({
|
|||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
loading={assign.isPending}
|
loading={assign.isPending}
|
||||||
|
disabled={assigned && level === current}
|
||||||
onClick={() => assign.mutate({ riskLevel: level })}
|
onClick={() => assign.mutate({ riskLevel: level })}
|
||||||
>
|
>
|
||||||
Assign risk
|
{assigned ? "Reassign risk" : "Assign risk"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
|
|||||||
{showTransport ? <TransportDocumentCard bookingId={bookingId} /> : null}
|
{showTransport ? <TransportDocumentCard bookingId={bookingId} /> : null}
|
||||||
|
|
||||||
{riskMs ? (
|
{riskMs ? (
|
||||||
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
|
<AssignRiskCard
|
||||||
|
bookingId={bookingId}
|
||||||
|
milestone={riskMs}
|
||||||
|
locked={dutyMs?.status === "COMPLETED"}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<IncidentReportCard bookingId={bookingId} />
|
<IncidentReportCard bookingId={bookingId} />
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
const App = () => {
|
const App = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, company, companyType, createProfileAndSwitch, isAuthenticated } =
|
const { user, company, companyType, createProfile, isAuthenticated } =
|
||||||
useAuth();
|
useAuth();
|
||||||
|
|
||||||
// Keep the server session alive while a user is logged in. Runs after
|
// Keep the server session alive while a user is logged in. Runs after
|
||||||
@@ -274,7 +274,7 @@ const App = () => {
|
|||||||
userEmail={userEmail}
|
userEmail={userEmail}
|
||||||
companyProfiles={companyProfiles}
|
companyProfiles={companyProfiles}
|
||||||
companyType={companyType}
|
companyType={companyType}
|
||||||
onCreateProfile={createProfileAndSwitch}
|
onCreateProfile={createProfile}
|
||||||
>
|
>
|
||||||
<OnboardingGate />
|
<OnboardingGate />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -344,6 +344,15 @@ export default function OnboardingWizardDialog({
|
|||||||
[finishMutation],
|
[finishMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Closing from the congratulations panel also clears the completed flag so a
|
||||||
|
// future reopen (shouldn't happen once onboarded) starts clean.
|
||||||
|
// Must stay above the `!user` early return: `user` flips to null while
|
||||||
|
// useAuth refetches, and skipping a hook on that render breaks hook order.
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (completed) setCompleted(false);
|
||||||
|
onClose();
|
||||||
|
}, [completed, onClose]);
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
// Any non-empty combination of importer/exporter/freight-forwarder is valid.
|
// Any non-empty combination of importer/exporter/freight-forwarder is valid.
|
||||||
@@ -365,13 +374,6 @@ export default function OnboardingWizardDialog({
|
|||||||
const stepMeta = STEP_META[activeStep];
|
const stepMeta = STEP_META[activeStep];
|
||||||
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
||||||
|
|
||||||
// Closing from the congratulations panel also clears the completed flag so a
|
|
||||||
// future reopen (shouldn't happen once onboarded) starts clean.
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
if (completed) setCompleted(false);
|
|
||||||
onClose();
|
|
||||||
}, [completed, onClose]);
|
|
||||||
|
|
||||||
// Prefer the backend-resolved document code; fall back to the local mapping
|
// Prefer the backend-resolved document code; fall back to the local mapping
|
||||||
// only until the requirements query lands (the documents step is reached well
|
// only until the requirements query lands (the documents step is reached well
|
||||||
// after the draft — and thus the requirements — exist).
|
// after the draft — and thus the requirements — exist).
|
||||||
|
|||||||
@@ -211,7 +211,11 @@ const useAuth = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createProfileAndSwitch = async (
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
const createProfile = async (
|
||||||
type: ProfileTypeValue,
|
type: ProfileTypeValue,
|
||||||
licenseFiles: File[],
|
licenseFiles: File[],
|
||||||
): Promise<Result<void>> => {
|
): Promise<Result<void>> => {
|
||||||
@@ -278,7 +282,7 @@ const useAuth = () => {
|
|||||||
onboardingCompleted,
|
onboardingCompleted,
|
||||||
onboardingStep,
|
onboardingStep,
|
||||||
switchMode,
|
switchMode,
|
||||||
createProfileAndSwitch,
|
createProfile,
|
||||||
reapplyProfile,
|
reapplyProfile,
|
||||||
login,
|
login,
|
||||||
signup,
|
signup,
|
||||||
|
|||||||
@@ -83,7 +83,11 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
|||||||
function ProfileHeader({ profile }: { profile: ProfileResponse }) {
|
function ProfileHeader({ profile }: { profile: ProfileResponse }) {
|
||||||
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
|
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
|
||||||
const roleOptions = rolesForCompanyType(profile.companyType);
|
const roleOptions = rolesForCompanyType(profile.companyType);
|
||||||
const activeRoles = roleOptions.filter((o) => refByType.has(o.type));
|
// Only an approved role is a role the company actually operates as. A pending
|
||||||
|
// one carries no reference yet, and must not read as granted.
|
||||||
|
const activeRoles = roleOptions.filter(
|
||||||
|
(o) => refByType.get(o.type)?.status === "active",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -248,9 +248,8 @@ export default function CompanyProfileForm({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
|
const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
|
||||||
// Company name comes from the eTrade manager/owner name on the license.
|
if (data.companyName) {
|
||||||
if (data.managerName) {
|
setValue("companyName", data.companyName, { shouldValidate: true });
|
||||||
setValue("companyName", data.managerName, { shouldValidate: true });
|
|
||||||
}
|
}
|
||||||
setValue("licenceNumber", data.licenceNumber);
|
setValue("licenceNumber", data.licenceNumber);
|
||||||
setValue("statusDescription", data.statusDescription);
|
setValue("statusDescription", data.statusDescription);
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ export default function NewContractPage({
|
|||||||
[profileStatusByType, profileTypes],
|
[profileStatusByType, profileTypes],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create-profile modal state (license upload → createProfileAndSwitch).
|
// Create-profile modal state (license upload → createProfile).
|
||||||
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
|
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -416,7 +416,7 @@ export default function NewContractPage({
|
|||||||
type: ProfileTypeValue;
|
type: ProfileTypeValue;
|
||||||
files: File[];
|
files: File[];
|
||||||
}) => {
|
}) => {
|
||||||
const res = await auth.createProfileAndSwitch(type, files);
|
const res = await auth.createProfile(type, files);
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
throw new Error(res.error?.message ?? "Failed to create profile");
|
throw new Error(res.error?.message ?? "Failed to create profile");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import type { CompanyProfileResponse } from "@/services/companies.service";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
import RoleCard from "./RoleCard";
|
import RoleCard from "./RoleCard";
|
||||||
import { rolesForCompanyType } from "./companyRoles";
|
import { rolesForCompanyType } from "./companyRoles";
|
||||||
@@ -18,6 +19,32 @@ interface CompanyRolesCardProps {
|
|||||||
profile: ProfileResponse;
|
profile: ProfileResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How an existing role reads on the card. Only an `active` role is "approved" —
|
||||||
|
* anything else is locked (a profile already exists, so it cannot be re-added)
|
||||||
|
* but must never be presented as granted.
|
||||||
|
*/
|
||||||
|
function roleStatusView(p: CompanyProfileResponse): {
|
||||||
|
note: string;
|
||||||
|
color: string;
|
||||||
|
approved: boolean;
|
||||||
|
} {
|
||||||
|
switch (p.status) {
|
||||||
|
case "active":
|
||||||
|
return { note: `Active · ${p.reference}`, color: "edr-green", approved: true };
|
||||||
|
case "pending":
|
||||||
|
return { note: "Pending review", color: "yellow.7", approved: false };
|
||||||
|
case "rejected":
|
||||||
|
return { note: "Rejected", color: "red.7", approved: false };
|
||||||
|
case "suspended":
|
||||||
|
return { note: "Suspended", color: "orange.7", approved: false };
|
||||||
|
case "blacklisted":
|
||||||
|
return { note: "Blacklisted", color: "red.7", approved: false };
|
||||||
|
default:
|
||||||
|
return { note: p.status, color: "edr-muted", approved: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -26,17 +53,18 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
[profile.companyType],
|
[profile.companyType],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Roles already persisted (active + locked), keyed by type -> reference.
|
// Roles already persisted, keyed by type. Existence locks the card; the
|
||||||
const activeByType = useMemo(() => {
|
// profile's own status decides how it is labelled.
|
||||||
const map = new Map<string, string>();
|
const profileByType = useMemo(() => {
|
||||||
for (const p of profile.companyProfiles) map.set(p.type, p.reference);
|
const map = new Map<string, CompanyProfileResponse>();
|
||||||
|
for (const p of profile.companyProfiles) map.set(p.type, p);
|
||||||
return map;
|
return map;
|
||||||
}, [profile.companyProfiles]);
|
}, [profile.companyProfiles]);
|
||||||
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const toggle = (type: string) => {
|
const toggle = (type: string) => {
|
||||||
if (activeByType.has(type)) return; // add-only: active roles are locked
|
if (profileByType.has(type)) return; // add-only: existing roles are locked
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(type)) next.delete(type);
|
if (next.has(type)) next.delete(type);
|
||||||
@@ -83,7 +111,8 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
) : (
|
) : (
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
{options.map((opt) => {
|
{options.map((opt) => {
|
||||||
const isActive = activeByType.has(opt.type);
|
const existing = profileByType.get(opt.type);
|
||||||
|
const view = existing ? roleStatusView(existing) : undefined;
|
||||||
return (
|
return (
|
||||||
<RoleCard
|
<RoleCard
|
||||||
key={opt.type}
|
key={opt.type}
|
||||||
@@ -91,10 +120,10 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
description={opt.description}
|
description={opt.description}
|
||||||
icon={opt.icon}
|
icon={opt.icon}
|
||||||
selected={selected.has(opt.type)}
|
selected={selected.has(opt.type)}
|
||||||
locked={isActive}
|
locked={Boolean(existing)}
|
||||||
lockedNote={
|
approved={view?.approved}
|
||||||
isActive ? `Active · ${activeByType.get(opt.type)}` : undefined
|
lockedNote={view?.note}
|
||||||
}
|
lockedNoteColor={view?.color}
|
||||||
onClick={() => toggle(opt.type)}
|
onClick={() => toggle(opt.type)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,10 +7,17 @@ export interface RoleCardProps {
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
/** Highlighted because the user just selected it (toggleable). */
|
/** Highlighted because the user just selected it (toggleable). */
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
/** Highlighted and non-interactive because it is already persisted. */
|
/** Non-interactive because a profile for this role already exists. */
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
|
/**
|
||||||
|
* Approved by a reviewer. Drives the green "granted" treatment, which a
|
||||||
|
* merely-locked role (e.g. still pending review) must not receive.
|
||||||
|
*/
|
||||||
|
approved?: boolean;
|
||||||
/** Small note under the description, e.g. "Active · IM-00001". */
|
/** Small note under the description, e.g. "Active · IM-00001". */
|
||||||
lockedNote?: string;
|
lockedNote?: string;
|
||||||
|
/** Mantine color for {@link lockedNote}; matches the role's status. */
|
||||||
|
lockedNoteColor?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,10 +32,12 @@ export default function RoleCard({
|
|||||||
icon,
|
icon,
|
||||||
selected = false,
|
selected = false,
|
||||||
locked = false,
|
locked = false,
|
||||||
|
approved = false,
|
||||||
lockedNote,
|
lockedNote,
|
||||||
|
lockedNoteColor = "edr-green",
|
||||||
onClick,
|
onClick,
|
||||||
}: RoleCardProps) {
|
}: RoleCardProps) {
|
||||||
const highlighted = selected || locked;
|
const highlighted = selected || approved;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
@@ -37,8 +46,12 @@ export default function RoleCard({
|
|||||||
className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${
|
className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${
|
||||||
highlighted
|
highlighted
|
||||||
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
|
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
|
||||||
: "border-edr-border! bg-edr-card! hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
|
: "border-edr-border! bg-edr-card!"
|
||||||
} ${locked ? "cursor-default" : ""}`}
|
} ${
|
||||||
|
locked
|
||||||
|
? "cursor-default"
|
||||||
|
: "hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Group gap="md" wrap="nowrap" align="start">
|
<Group gap="md" wrap="nowrap" align="start">
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
@@ -58,7 +71,7 @@ export default function RoleCard({
|
|||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
{lockedNote && (
|
{lockedNote && (
|
||||||
<Text size="xs" c="edr-green" mt={6} fw={600}>
|
<Text size="xs" c={lockedNoteColor} mt={6} fw={600}>
|
||||||
{lockedNote}
|
{lockedNote}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -385,7 +385,8 @@ function ProfileLicenseRow({
|
|||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Text size="sm" fw={700} c="edr-text">
|
<Text size="sm" fw={700} c="edr-text">
|
||||||
{ROLE_LABELS[profile.type] ?? profile.type} · {profile.reference}
|
{ROLE_LABELS[profile.type] ?? profile.type}
|
||||||
|
{profile.reference ? ` · ${profile.reference}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ export interface ETradeCompanyInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyRegistrationData {
|
export interface CompanyRegistrationData {
|
||||||
|
/**
|
||||||
|
* The registered organization name — `ETradeCompanyInfo.BusinessName`, falling
|
||||||
|
* back to the licence's `TradeName`. Never the manager/owner's personal name;
|
||||||
|
* that is {@link managerName}.
|
||||||
|
*/
|
||||||
|
companyName: string;
|
||||||
licenceNumber: string;
|
licenceNumber: string;
|
||||||
statusDescription: string;
|
statusDescription: string;
|
||||||
dateRegistered: string;
|
dateRegistered: string;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export enum NotificationPriority {
|
|||||||
export enum NotificationType {
|
export enum NotificationType {
|
||||||
GENERIC = "GENERIC",
|
GENERIC = "GENERIC",
|
||||||
// Portal-facing (customer)
|
// Portal-facing (customer)
|
||||||
|
ACCOUNT_STATUS = "ACCOUNT_STATUS",
|
||||||
CLEARANCE_DECISION = "CLEARANCE_DECISION",
|
CLEARANCE_DECISION = "CLEARANCE_DECISION",
|
||||||
DOCUMENT_ACTION = "DOCUMENT_ACTION",
|
DOCUMENT_ACTION = "DOCUMENT_ACTION",
|
||||||
BOOKING_STATUS = "BOOKING_STATUS",
|
BOOKING_STATUS = "BOOKING_STATUS",
|
||||||
|
|||||||
Reference in New Issue
Block a user