fix: the issue in the sheets file

This commit is contained in:
Nathnael
2026-07-10 12:10:17 +00:00
parent bd4d7b72fd
commit f737e401b3
23 changed files with 314 additions and 70 deletions

View File

@@ -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'`,
);
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 };
} }

View File

@@ -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,
});
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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)

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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).

View File

@@ -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,

View File

@@ -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

View File

@@ -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);

View File

@@ -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");
} }

View File

@@ -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)}
/> />
); );

View File

@@ -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>
)} )}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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",