diff --git a/apps/edr-freight-api/src/migrations/2100000000000-CompanyProfileDefaultPending.ts b/apps/edr-freight-api/src/migrations/2100000000000-CompanyProfileDefaultPending.ts new file mode 100644 index 000000000..aafa9715f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2100000000000-CompanyProfileDefaultPending.ts @@ -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 { + await queryRunner.query( + `ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'pending'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'active'`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index a61cfdb4c..8db9eba66 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -221,7 +221,7 @@ export class CompaniesController { @Post("company-profile") @ApiOperation({ 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( @CurrentUser() user: CurrentIamUser, diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 646d01d47..73826689a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -1,9 +1,11 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { HttpModule } from "@nestjs/axios"; import { FilesModule } from "../files/files.module"; import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.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 { CompaniesService } from "./companies.service"; import { CompaniesRepository } from "./companies.repository"; @@ -17,6 +19,7 @@ import { Booking } from "../bookings/entities/booking.entity"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { ETradeService } from "./services/etrade.service"; +import { CompanyNotifierService } from "./company-notifier.service"; @Module({ imports: [ @@ -31,6 +34,10 @@ import { ETradeService } from "./services/etrade.service"; FilesModule, FileUploadSettingsModule, MinioModule, + // Account-status notifications (CompanyNotifierService). The inbox module + // imports this module back for portal recipient targeting, hence forwardRef. + NotificationsModule, + forwardRef(() => NotificationInboxModule), ], controllers: [CompaniesController], providers: [ @@ -41,6 +48,7 @@ import { ETradeService } from "./services/etrade.service"; CompanyChangeRequestRepository, CompanyDashboardRepository, ETradeService, + CompanyNotifierService, ], exports: [ CompaniesService, diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index ae4703e40..1027de955 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -17,6 +17,7 @@ import { FilesService } from "../files/files.service"; import { FileRecord } from "../files/entities/file.entity"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; import { ETradeService } from "./services/etrade.service"; +import { CompanyNotifierService } from "./company-notifier.service"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; import { CreateCompanyDto } from "./dto/create-company.dto"; @@ -96,6 +97,7 @@ export class CompaniesService { private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, + private readonly companyNotifier: CompanyNotifierService, ) { } /** @@ -585,9 +587,13 @@ export class CompaniesService { } async updateCompany(id: string, dto: UpdateCompanyDto): Promise { - await this.findCompanyById(id); + const before = await this.findCompanyById(id); const updated = await this.companiesRepo.update(id, dto); 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; } @@ -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({ companyId, type, - reference, - status: ProfileStatus.Active, + status: ProfileStatus.Pending, }); } @@ -1131,9 +1136,11 @@ export class CompaniesService { } /** - * Create a single operational profile for the current user's company and - * make it the active mode in the same call. Powers the header "Switch to - * Exporter/Importer" flow when the target profile doesn't exist yet. + * 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: + * 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( userId: string, @@ -1156,8 +1163,7 @@ export class CompaniesService { let created = await this.companyProfilesRepo.findByType(companyId, type); if (!created) { // New self-service roles start Pending (awaiting backoffice approval) and - // carry no reference until approved. The customer can select this mode but - // can't book under it until it's cleared. + // carry no reference until approved. created = await this.companyProfilesRepo.create({ companyId, type, @@ -1166,8 +1172,6 @@ export class CompaniesService { }); } - await this.profilesRepo.update(profile.id, { activeProfileType: type }); - return created; } @@ -1442,10 +1446,12 @@ export class CompaniesService { // browser (which fails on the internal bucket endpoint). /** - * Upload business-license file(s) for one of the user's profiles. During - * onboarding (company not yet Active) they go live immediately; for an Active - * company they're staged under the pending code and recorded as `add` intents - * on a pending change request for backoffice review. Returns the updated view. + * Upload business-license file(s) for one of the user's profiles. For a role + * not yet approved (a fresh onboarding profile, or a newly added service on an + * already-active company) they go live immediately and are reviewed together + * 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( userId: string, @@ -1454,7 +1460,7 @@ export class CompaniesService { ): Promise { const profile = await this.resolveOwnedProfile(userId, profileId); 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 uploaded = await Promise.all( @@ -1486,9 +1492,9 @@ export class CompaniesService { /** * Remove a license file. A staged (pending) file is withdrawn outright - * (soft-deleted, its `add` intent dropped). A live file on an Active company - * is kept and recorded as a `remove` intent for review; during onboarding it - * is deleted immediately. + * (soft-deleted, its `add` intent dropped). A live file on an already-approved + * role is kept and recorded as a `remove` intent for review; on a role still + * awaiting approval it is deleted immediately. */ async removeProfileLicenseFile( userId: string, @@ -1504,7 +1510,7 @@ export class CompaniesService { throw new NotFoundException(`License file ${fileId} not found`); } 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) { // 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 * `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( userId: string, @@ -1540,7 +1546,7 @@ export class CompaniesService { throw new NotFoundException(`License file ${fileId} not found`); } 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({ resourceId: profileId, @@ -2044,13 +2050,17 @@ export class CompaniesService { } async fetchETradeData(tin: string) { - const { businessInfo } = await this.etradeService.resolveCompanyData(tin); + const { businessInfo, companyInfo } = + await this.etradeService.resolveCompanyData(tin); if (!businessInfo) { throw new BadRequestException( "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); return { ...registrationData, tinTaken }; } diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts new file mode 100644 index 000000000..167526988 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -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 { + 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, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts index ef7eb2a21..bfe1f9b72 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -1,6 +1,7 @@ import { CompanyRegistrationData } from "@edr/types"; export class ETradeResponseDto implements CompanyRegistrationData { + companyName!: string; licenceNumber!: string; statusDescription!: string; dateRegistered!: string; @@ -20,6 +21,7 @@ export class ETradeResponseDto implements CompanyRegistrationData { tinTaken?: boolean; constructor(data: CompanyRegistrationData) { + this.companyName = data.companyName; this.licenceNumber = data.licenceNumber; this.statusDescription = data.statusDescription; this.dateRegistered = data.dateRegistered; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index d84be5d8b..ebda7a0b9 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -84,11 +84,17 @@ export class CompanyProfile extends BaseEntity { }) 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({ name: "status", type: "varchar", length: 32, - default: ProfileStatus.Active, + default: ProfileStatus.Pending, }) status!: ProfileStatus; diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 15054c701..b588c241f 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -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( businessInfo: ETradeBusinessInfo, + companyInfo?: ETradeCompanyInfo, ): CompanyRegistrationData { const primaryManager = businessInfo.AssociateShortInfos?.[0]; return { + companyName: + companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "", licenceNumber: businessInfo.LicenceNumber, statusDescription: businessInfo.StatusDescription, dateRegistered: businessInfo.DateRegistered, diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts index 4981a9486..d30ebe3a9 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts @@ -1,4 +1,4 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; @@ -17,8 +17,9 @@ import { WsAuthService } from "./ws-auth.service"; @Module({ imports: [ TypeOrmModule.forFeature([Notification, User, Session]), - // ExternalProfileRepository + CompanyProfileRepository (portal targeting) - CompaniesModule, + // ExternalProfileRepository + CompanyProfileRepository (portal targeting). + // CompaniesModule imports this module back for CompanyNotifierService. + forwardRef(() => CompaniesModule), // BackofficeService.getOrganizationEmployees (staff targeting) BackofficeModule, // EmailClientService + SmsClientService (HIGH-priority fan-out) diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/ActionShell.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/ActionShell.tsx index 0ec7ad59f..bf761b748 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/ActionShell.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/ActionShell.tsx @@ -8,6 +8,12 @@ export interface ActionShellProps { subtitle?: string; /** When true the action is already done — children are hidden, a done badge shows. */ 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; children: ReactNode; } @@ -22,6 +28,7 @@ export function ActionShell({ title, subtitle, done, + keepChildrenWhenDone, doneLabel, children, }: ActionShellProps) { @@ -64,7 +71,7 @@ export function ActionShell({ ) ) : null} - {!done ? children : null} + {!done || keepChildrenWhenDone ? children : null} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AssignRiskCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AssignRiskCard.tsx index 05fd97084..0f655edc5 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AssignRiskCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AssignRiskCard.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Badge, Box, Button, Group, SegmentedControl, Text } from "@mantine/core"; import { ShieldAlert } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -15,22 +15,42 @@ const RISK_COLOR: Record = { export function AssignRiskCard({ bookingId, milestone, + locked = false, }: { bookingId: string; 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 [level, setLevel] = useState("GREEN"); const assigned = milestone.status === "COMPLETED"; const current = milestone.metadata?.riskLevel; + const [level, setLevel] = useState( + 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 ( @@ -60,9 +80,10 @@ export function AssignRiskCard({ size="compact-sm" color="edr-green" loading={assign.isPending} + disabled={assigned && level === current} onClick={() => assign.mutate({ riskLevel: level })} > - Assign risk + {assigned ? "Reassign risk" : "Assign risk"} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx index 57133cb0c..f11619b82 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx @@ -70,7 +70,11 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) { {showTransport ? : null} {riskMs ? ( - + ) : null} diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index e4c5685fd..b091567ee 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -217,7 +217,7 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, company, companyType, createProfileAndSwitch, isAuthenticated } = + const { user, company, companyType, createProfile, isAuthenticated } = useAuth(); // Keep the server session alive while a user is logged in. Runs after @@ -274,7 +274,7 @@ const App = () => { userEmail={userEmail} companyProfiles={companyProfiles} companyType={companyType} - onCreateProfile={createProfileAndSwitch} + onCreateProfile={createProfile} > diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 7dbc6d7c4..f2c9992e3 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -344,6 +344,15 @@ export default function OnboardingWizardDialog({ [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; // 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 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 // only until the requirements query lands (the documents step is reached well // after the draft — and thus the requirements — exist). diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 0f6e98377..be5cd4d7c 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -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, licenseFiles: File[], ): Promise> => { @@ -278,7 +282,7 @@ const useAuth = () => { onboardingCompleted, onboardingStep, switchMode, - createProfileAndSwitch, + createProfile, reapplyProfile, login, signup, diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 2a8443812..5f6cf88d8 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -83,7 +83,11 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ function ProfileHeader({ profile }: { profile: ProfileResponse }) { const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p])); 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 ( (null); const handleETradeDataLoaded = (data: CompanyRegistrationData) => { - // Company name comes from the eTrade manager/owner name on the license. - if (data.managerName) { - setValue("companyName", data.managerName, { shouldValidate: true }); + if (data.companyName) { + setValue("companyName", data.companyName, { shouldValidate: true }); } setValue("licenceNumber", data.licenceNumber); setValue("statusDescription", data.statusDescription); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 33a89e3e1..a30255ddf 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -392,7 +392,7 @@ export default function NewContractPage({ [profileStatusByType, profileTypes], ); - // Create-profile modal state (license upload → createProfileAndSwitch). + // Create-profile modal state (license upload → createProfile). const [createTarget, setCreateTarget] = useState( null, ); @@ -416,7 +416,7 @@ export default function NewContractPage({ type: ProfileTypeValue; files: File[]; }) => { - const res = await auth.createProfileAndSwitch(type, files); + const res = await auth.createProfile(type, files); if (!res.success) { throw new Error(res.error?.message ?? "Failed to create profile"); } diff --git a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx index 89fd0c3f9..fc0407d5e 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx @@ -10,6 +10,7 @@ import { Title, } from "@mantine/core"; import { api } from "@/services/api"; +import type { CompanyProfileResponse } from "@/services/companies.service"; import type { ProfileResponse } from "@/types/profile"; import RoleCard from "./RoleCard"; import { rolesForCompanyType } from "./companyRoles"; @@ -18,6 +19,32 @@ interface CompanyRolesCardProps { 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) { const queryClient = useQueryClient(); @@ -26,17 +53,18 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { [profile.companyType], ); - // Roles already persisted (active + locked), keyed by type -> reference. - const activeByType = useMemo(() => { - const map = new Map(); - for (const p of profile.companyProfiles) map.set(p.type, p.reference); + // Roles already persisted, keyed by type. Existence locks the card; the + // profile's own status decides how it is labelled. + const profileByType = useMemo(() => { + const map = new Map(); + for (const p of profile.companyProfiles) map.set(p.type, p); return map; }, [profile.companyProfiles]); const [selected, setSelected] = useState>(new Set()); 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) => { const next = new Set(prev); if (next.has(type)) next.delete(type); @@ -83,7 +111,8 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { ) : ( {options.map((opt) => { - const isActive = activeByType.has(opt.type); + const existing = profileByType.get(opt.type); + const view = existing ? roleStatusView(existing) : undefined; return ( toggle(opt.type)} /> ); diff --git a/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx index 2e507ff83..0a6b98dc4 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx @@ -7,10 +7,17 @@ export interface RoleCardProps { icon: React.ReactNode; /** Highlighted because the user just selected it (toggleable). */ selected?: boolean; - /** Highlighted and non-interactive because it is already persisted. */ + /** Non-interactive because a profile for this role already exists. */ 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". */ lockedNote?: string; + /** Mantine color for {@link lockedNote}; matches the role's status. */ + lockedNoteColor?: string; onClick?: () => void; } @@ -25,10 +32,12 @@ export default function RoleCard({ icon, selected = false, locked = false, + approved = false, lockedNote, + lockedNoteColor = "edr-green", onClick, }: RoleCardProps) { - const highlighted = selected || locked; + const highlighted = selected || approved; return ( {lockedNote && ( - + {lockedNote} )} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx index 3f4dcbe41..17c7b87ab 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx @@ -385,7 +385,8 @@ function ProfileLicenseRow({ - {ROLE_LABELS[profile.type] ?? profile.type} · {profile.reference} + {ROLE_LABELS[profile.type] ?? profile.type} + {profile.reference ? ` · ${profile.reference}` : ""}