From e5d2bb2f637367de5d5eb7d9b95657e58136c413 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 9 Jul 2026 12:51:12 +0000 Subject: [PATCH 1/4] fix: unused var --- .../edr-freight-web/portal/src/pages/contracts/ContractsList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx index b6ba870d6..40a519840 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -33,7 +33,6 @@ import { X, } from "lucide-react"; -import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; import { ContractCustomerAction } from "@/components/customer-actions/ContractCustomerAction"; From 78cb1ac5d3757d7040aaa83d90699ec4cbed5f4c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 10 Jul 2026 08:18:37 +0000 Subject: [PATCH 2/4] feat: add poa and poa delegation file to onboarding --- .../modules/companies/companies.service.ts | 66 ++++++++++++- .../onboarding-requirements-response.dto.ts | 17 ++++ .../src/seed/file-upload-settings.seeder.ts | 24 +++++ .../onboarding/OnboardingWizardDialog.tsx | 14 ++- .../src/pages/accounts/CompanyProfileForm.tsx | 95 +++++++++++++++++-- .../accounts/companyProfileForm/schema.ts | 48 +++++++++- .../portal/src/services/companies.service.ts | 10 ++ 7 files changed, 256 insertions(+), 18 deletions(-) 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 e31851aef..ab2711dfb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -54,6 +54,23 @@ const LICENSE_CODE = "business_license"; /** Code for a license file staged in an open change request (not yet live). */ const LICENSE_PENDING_CODE = "business_license_pending"; +/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */ +const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; +/** company.attributes keys that together mean "a PoA was entered". */ +const POA_ATTRIBUTES = [ + "poaName", + "poaPhone", + "poaEmail", + "poaLocation", + "poaAddress", +] as const; +/** Mandatory once the company operates as a freight forwarder. */ +const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ + { key: "poaName", label: "PoA name" }, + { key: "poaEmail", label: "PoA email" }, + { key: "poaPhone", label: "PoA phone" }, +]; + export interface UserIdentity { userId: string; firstName: string; @@ -1240,6 +1257,29 @@ export class CompaniesService { ); const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); + // 4. Power of Attorney. Optional in general, but a freight forwarder acts on + // other companies' behalf so its PoA is mandatory. Either way, a PoA that + // has been entered must be evidenced by the delegation letter. + const poaRequired = (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ); + const poaProvided = POA_ATTRIBUTES.some((k) => + (company.attributes?.[k] as string | undefined)?.trim(), + ); + const missingPoaFields = poaRequired + ? REQUIRED_POA_FIELDS.filter( + (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), + ) + : []; + // Only gate on the letter once the document set actually carries the field. + const delegationField = (setting?.fields ?? []).find( + (f) => f.fileKey === POA_DELEGATION_FILE_KEY, + ); + const missingDelegation = + Boolean(delegationField) && + (poaRequired || poaProvided) && + !uploadedCodes.has(POA_DELEGATION_FILE_KEY); + const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), ...missingDocs.map((d) => `Upload your ${d.fileLabel}`), @@ -1247,18 +1287,31 @@ export class CompaniesService { (p) => `Upload a business license for your ${p.type.replace(/_/g, " ")} profile`, ), + ...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`), + ...(missingDelegation + ? ["Upload the delegation letter for your Power of Attorney"] + : []), ]; // Progress spans every required item the user has to satisfy: company-info - // fields, required documents and one license per operational profile. + // fields, required documents, one license per operational profile, and the + // PoA details/letter whenever those are mandatory. const requiredDocCount = documents.filter((d) => d.isRequired).length; + const poaItemCount = + (poaRequired ? REQUIRED_POA_FIELDS.length : 0) + + (delegationField && (poaRequired || poaProvided) ? 1 : 0); const total = this.REQUIRED_COMPANY_INFO.length + requiredDocCount + - licenseProfiles.length; + licenseProfiles.length + + poaItemCount; const completed = total - - (missingInfo.length + missingDocs.length + missingLicenses.length); + (missingInfo.length + + missingDocs.length + + missingLicenses.length + + missingPoaFields.length + + (missingDelegation ? 1 : 0)); return new OnboardingRequirementsResponseDto({ documentSettingCode, @@ -1266,6 +1319,13 @@ export class CompaniesService { companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo }, documents, licenseProfiles, + poa: { + required: poaRequired, + provided: poaProvided, + delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY), + missingFields: missingPoaFields, + complete: missingPoaFields.length === 0 && !missingDelegation, + }, progress: { completed, total }, isComplete: outstanding.length === 0, onboardingCompleted: profile.onboardingCompleted, diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 92f9fa513..da908a177 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -35,6 +35,19 @@ export interface OnboardingLicenseProfile { uploaded: boolean; } +export interface OnboardingPoaState { + /** True when the company operates as a freight forwarder — PoA is mandatory. */ + required: boolean; + /** True once any PoA detail has been entered. */ + provided: boolean; + /** True when the delegation letter is stored for the company. */ + delegationLetterUploaded: boolean; + /** PoA details still missing (only populated when `required`). */ + missingFields: OnboardingInfoField[]; + /** False while the PoA step still owes details or a delegation letter. */ + complete: boolean; +} + export class OnboardingRequirementsResponseDto { /** Resolved document setting code (by nationality) the docs were drawn from. */ documentSettingCode: string; @@ -52,6 +65,9 @@ export class OnboardingRequirementsResponseDto { /** Per-operational-profile business-license requirements. */ licenseProfiles: OnboardingLicenseProfile[]; + /** Power of Attorney state, so the wizard needn't re-derive the rule. */ + poa: OnboardingPoaState; + /** Overall setup progress across fields + documents + licenses. */ progress: { completed: number; total: number }; @@ -70,6 +86,7 @@ export class OnboardingRequirementsResponseDto { this.companyInfo = init.companyInfo; this.documents = init.documents; this.licenseProfiles = init.licenseProfiles; + this.poa = init.poa; this.progress = init.progress; this.isComplete = init.isComplete; this.onboardingCompleted = init.onboardingCompleted; diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 99dae5974..13759ddb3 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -18,6 +18,28 @@ interface OnboardingField { const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"]; +/** fileKey of the delegation letter attached to the Power of Attorney step. */ +export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; + +/** + * Seeded as optional: the delegation letter is only mandatory once a PoA has + * been entered, or when the company operates as a freight forwarder. That rule + * spans form fields as well as files, so it lives in the onboarding gate + * (companies.service.getOnboardingRequirements) rather than in `isRequired`. + */ +const poaDelegationField = (displayOrder: number): OnboardingField => ({ + fileKey: POA_DELEGATION_FILE_KEY, + fileLabel: "PoA Delegation Letter", + helpText: + "Signed letter in which the General Manager delegates the representative named above.", + isRequired: false, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder, +}); + /** Documents required from an Ethiopian company at onboarding. */ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ { @@ -54,6 +76,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 3, }, + poaDelegationField(4), ]; /** Documents required from a Foreign company at onboarding. */ @@ -102,6 +125,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 4, }, + poaDelegationField(5), ]; /** Legacy combined set, kept for the older per-company-type codes. */ 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 849ba50c5..7dbc6d7c4 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -392,11 +392,17 @@ export default function OnboardingWizardDialog({ const requiredDocsMissing = requirementDocuments.some( (d) => d.isRequired && !d.uploaded, ); + // The PoA gets the same treatment: a resumed draft that predates the + // delegation-letter requirement (or a forwarder whose PoA is blank) must land + // back on the PoA step, where both the details and the letter are entered. + const poaIncomplete = requirementsQuery.data?.poa?.complete === false; + // Each unmet requirement lowers the ceiling; resume never moves forward. + let ceiling = FORM_STEPS.length - 1; + if (requiredDocsMissing) + ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents")); + if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa")); const effectiveResumeStep: FormStep = - requiredDocsMissing && - FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents") - ? "documents" - : resumeFormStep; + FORM_STEPS[Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)]; const formProps = { documentSettingCode: resolvedDocumentSettingCode, diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index abfb61551..cadb102b4 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -12,7 +12,7 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import type { AuthUser } from "@/types/auth"; @@ -28,9 +28,11 @@ import RoleLicenseStep, { } from "@/components/onboarding/RoleLicenseStep"; import ETradeInfo from "@/components/onboarding/ETradeInfo"; import { + buildOnboardingSchema, type CompanyStep, type FormData, - onboardingSchema, + hasPoaDetails, + POA_DELEGATION_FILE_KEY, stepFields, } from "./companyProfileForm/schema"; import { @@ -155,6 +157,12 @@ export default function CompanyProfileForm({ }), ); + // A freight forwarder signs on other companies' behalf, so its Power of + // Attorney (details + delegation letter) is mandatory rather than optional. + const requirePoa = (roleProfiles ?? []).some( + (p) => p.type === "freight_forwarder", + ); + const { register, control, @@ -164,7 +172,7 @@ export default function CompanyProfileForm({ setValue, formState: { errors }, } = useForm({ - resolver: zodResolver(onboardingSchema), + resolver: zodResolver(buildOnboardingSchema(requirePoa)), defaultValues: { companyName: "", companyEmail: "", @@ -354,7 +362,34 @@ export default function CompanyProfileForm({ } }; - const hasDocuments = Boolean(uploadSetting?.fields?.length); + // The delegation letter is seeded into the same nationality document set as + // the rest, but belongs on the PoA step next to the details it evidences — + // so it's split out here and the Documents step renders the remainder. Both + // halves share `documentFiles`, so the existing bulk upload still carries it. + const poaDocumentField = uploadSetting?.fields?.find( + (f) => f.fileKey === POA_DELEGATION_FILE_KEY, + ); + const documentsSetting = useMemo( + () => + uploadSetting + ? { + ...uploadSetting, + fields: uploadSetting.fields.filter( + (f) => f.fileKey !== POA_DELEGATION_FILE_KEY, + ), + } + : undefined, + [uploadSetting], + ); + const poaDocumentSetting = useMemo( + () => + uploadSetting && poaDocumentField + ? { ...uploadSetting, fields: [poaDocumentField] } + : undefined, + [uploadSetting, poaDocumentField], + ); + + const hasDocuments = Boolean(documentsSetting?.fields?.length); // Hard verification for the documents step: required company-level // documents and a business license per operational profile must both be @@ -368,7 +403,7 @@ export default function CompanyProfileForm({ const validateRequiredDocuments = (): Record => { const errs: Record = {}; - for (const field of uploadSetting?.fields ?? []) { + for (const field of documentsSetting?.fields ?? []) { const min = getMinFiles(field); if (min <= 0) continue; if ((uploadedDocumentKeys ?? []).includes(field.fileKey)) continue; @@ -447,6 +482,20 @@ export default function CompanyProfileForm({ ]; const currentIdx = stepOrder.indexOf(step); + // The delegation letter is what proves the representative was actually + // delegated, so it's required the moment a PoA exists — and unconditionally + // for a freight forwarder, whose PoA itself is mandatory. Skipped entirely + // when the document set predates the field (seeder not yet re-run). + const poaProvided = hasPoaDetails(watch()); + const delegationRequired = + Boolean(poaDocumentField) && (requirePoa || poaProvided); + const delegationPresent = + (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || + (() => { + const v = documentFiles[POA_DELEGATION_FILE_KEY]; + return Array.isArray(v) ? v.length > 0 : v != null; + })(); + /** Validate + persist the current step, returning whether we may advance. */ const saveCurrentStep = async (): Promise => { setSaveError(null); @@ -498,6 +547,20 @@ export default function CompanyProfileForm({ handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + // The PoA step also gates on a file, which lives outside the form state. + if (step === "poa" && delegationRequired && !delegationPresent) { + setDocumentFieldErrors({ + [POA_DELEGATION_FILE_KEY]: "Delegation letter is required", + }); + setSaveError( + requirePoa + ? "Freight forwarders must provide Power of Attorney details and a delegation letter." + : "Upload the delegation letter for the Power of Attorney you entered, or clear the PoA details to skip.", + ); + // Fall through to validate the text fields too, so every problem shows at once. + await trigger(stepFields.poa); + return; + } // Field steps validate + save before advancing. const ok = await saveCurrentStep(); if (!ok) return; @@ -743,8 +806,9 @@ export default function CompanyProfileForm({ {step === "poa" && ( <> - Power of Attorney details are optional. Fill them in if you have - them, or skip to continue. + {requirePoa + ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and a delegation letter are required." + : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation letter authorising them."} {watch("contactPersonName") && ( + + {poaDocumentSetting && ( + <> + + + + )} )} @@ -797,13 +874,13 @@ export default function CompanyProfileForm({ - ) : !uploadSetting ? ( + ) : !documentsSetting ? ( No document requirements found for your account type. ) : ( !v || isValidPhone(v), "Enter a valid phone number"), poaAddress: z.string().optional(), - poaEmail: z.string().optional(), + poaEmail: z + .string() + .optional() + .refine( + (v) => !v || z.string().email().safeParse(v).success, + "Invalid email address", + ), poaLocation: z.string().optional(), }); export type FormData = z.infer; +/** fileKey of the delegation letter uploaded on the Power of Attorney step. */ +export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; + +export const POA_FIELDS = [ + "poaName", + "poaPhone", + "poaEmail", + "poaLocation", + "poaAddress", +] as const satisfies readonly (keyof FormData)[]; + +/** True once the customer has entered any Power of Attorney detail. */ +export const hasPoaDetails = (d: Partial) => + POA_FIELDS.some((f) => d[f]?.trim()); + +/** + * A freight forwarder acts on other companies' behalf, so its PoA is mandatory + * rather than optional. Everyone else keeps the optional PoA — but once they + * start filling it in, the identifying fields have to be complete (the + * delegation-letter upload is enforced alongside this, in CompanyProfileForm, + * since files live outside the form state). + */ +export function buildOnboardingSchema(requirePoa: boolean) { + if (!requirePoa) return onboardingSchema; + return onboardingSchema.superRefine((d, ctx) => { + const required: [keyof FormData, string][] = [ + ["poaName", "PoA name is required for freight forwarders"], + ["poaEmail", "PoA email is required for freight forwarders"], + ["poaPhone", "PoA phone is required for freight forwarders"], + ]; + for (const [path, message] of required) { + if (!d[path]?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message }); + } + } + }); +} + export const stepFields: Record = { company: [ "companyName", @@ -102,7 +146,7 @@ export const stepFields: Record = { "contactPersonEmail", "contactPersonPhone", ], - poa: [], + poa: [...POA_FIELDS], documents: [], additional: [], }; diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index ad93e03a8..b5c1c3cdf 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -144,6 +144,15 @@ export interface OnboardingLicenseProfile { uploaded: boolean; } +/** Power of Attorney state — mandatory for freight forwarders, optional otherwise. */ +export interface OnboardingPoaState { + required: boolean; + provided: boolean; + delegationLetterUploaded: boolean; + missingFields: { key: string; label: string }[]; + complete: boolean; +} + /** * Server-driven onboarding requirements. The portal renders this verbatim: the * backend decides which documents apply (by nationality) and what is still @@ -158,6 +167,7 @@ export interface OnboardingRequirements { }; documents: OnboardingDocumentField[]; licenseProfiles: OnboardingLicenseProfile[]; + poa: OnboardingPoaState; progress: { completed: number; total: number }; isComplete: boolean; onboardingCompleted: boolean; From ea0f264d72c922b840b7e4433ffeac0ade9bec50 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 10 Jul 2026 08:19:12 +0000 Subject: [PATCH 3/4] fea: add poa section to backoffice customer detail page --- .../pages/customers/CustomerDetailPage.tsx | 167 ++++++++++++++++++ .../src/services/customers.service.ts | 5 + .../backoffice/src/types/customer.ts | 5 + 3 files changed, 177 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 317f915fb..5d0655f8d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -7,6 +7,7 @@ import { Card, Center, Container, + Divider, Group, Loader, SimpleGrid, @@ -90,6 +91,9 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) { return query.isLoading ? "loading" : query.isError ? "error" : "success"; } +/** Matches the fileKey seeded in the API's file-upload-settings seeder. */ +const POA_DELEGATION_CODE = "poa_delegation_letter"; + export default function CustomerDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -531,6 +535,26 @@ export default function CustomerDetailPage() { (p) => p.licenseFiles && p.licenseFiles.length > 0, ); + const poaDocuments = useMemo( + () => documents.filter((d) => d.code === POA_DELEGATION_CODE), + [documents], + ); + const poaFields = [ + { label: "PoA name", value: company?.poaName }, + { label: "PoA email", value: company?.poaEmail }, + { label: "PoA phone", value: company?.poaPhone }, + { label: "PoA location", value: company?.poaLocation }, + { label: "PoA address", value: company?.poaAddress }, + ]; + const hasPoaDetails = poaFields.some((f) => f.value?.trim()); + // A freight forwarder acts on other companies' behalf, so its PoA — details + // and delegation letter both — is mandatory rather than optional. + const poaMandatory = (company?.companyProfiles ?? []).some( + (p) => p.type === "freight_forwarder", + ); + const delegationMissing = + (hasPoaDetails || poaMandatory) && poaDocuments.length === 0; + if (isLoading) { return (
@@ -672,6 +696,149 @@ export default function CustomerDetailPage() { + + + + + + Power of Attorney + + {poaMandatory && ( + + Required for freight forwarder + + )} + + {delegationMissing ? ( + + Delegation letter missing + + ) : poaDocuments.length > 0 ? ( + + Delegation letter on file + + ) : ( + + Not provided + + )} + + + {hasPoaDetails ? ( + + {poaFields.map((f) => ( + + ))} + + ) : ( + + No Power of Attorney representative recorded for this + customer. + + )} + + + + + + Delegation letter + + + {documentsQuery.isLoading ? ( + + + + Loading documents… + + + ) : documentsQuery.isError ? ( + + + Failed to load documents. + + void documentsQuery.refetch()} + > + Retry + + + ) : poaDocuments.length === 0 ? ( + + No delegation letter uploaded. + + ) : ( + poaDocuments.map((doc) => ( + + + + + view({ + name: doc.name, + url: fileViewUrl(doc.id), + mimeType: doc.mimeType, + }) + } + > + {doc.name} + + + {formatBytes(doc.size)} ·{" "} + {formatDate(doc.uploadedAt)} + + + + + view({ + name: doc.name, + url: fileViewUrl(doc.id), + mimeType: doc.mimeType, + }) + } + > + + + + + + + + )) + )} + + + + diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts index 0476d41bb..c9a649946 100644 --- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -33,6 +33,11 @@ function mapCompany(dto: Record): Company { generalManagerName: (attrs.generalManagerName as string | null) ?? null, generalManagerEmail: (attrs.generalManagerEmail as string | null) ?? null, generalManagerPhone: (attrs.generalManagerPhone as string | null) ?? null, + poaName: (attrs.poaName as string | null) ?? null, + poaEmail: (attrs.poaEmail as string | null) ?? null, + poaPhone: (attrs.poaPhone as string | null) ?? null, + poaLocation: (attrs.poaLocation as string | null) ?? null, + poaAddress: (attrs.poaAddress as string | null) ?? null, }; } diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 7328decf0..5931ea5d6 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -127,6 +127,11 @@ export interface Company { generalManagerName?: string | null; generalManagerEmail?: string | null; generalManagerPhone?: string | null; + poaName?: string | null; + poaEmail?: string | null; + poaPhone?: string | null; + poaLocation?: string | null; + poaAddress?: string | null; website?: string | null; attributes?: Record | null; companyProfiles: CompanyProfile[]; From e5fce529fe631b23c4c17fcdc0ac0e2af26cf77a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 10 Jul 2026 08:45:39 +0000 Subject: [PATCH 4/4] feat: add poa to the changes approval --- .../modules/companies/companies.controller.ts | 48 +- .../modules/companies/companies.service.ts | 273 +++++++- .../dto/change-request-response.dto.ts | 4 + .../entities/company-change-request.entity.ts | 24 +- .../entities/company-profile.entity.ts | 21 +- .../customers/ChangeRequestReview.tsx | 73 +- .../pages/customers/CustomerDetailPage.tsx | 24 +- .../backoffice/src/types/customer.ts | 11 + .../portal/src/constants/URLS.ts | 3 + .../src/pages/settings/TabDocuments.tsx | 26 +- .../src/pages/settings/TabPowerOfAttorney.tsx | 622 +++++++++++++++--- .../portal/src/services/api.ts | 7 + .../portal/src/services/companies.service.ts | 31 + 13 files changed, 1046 insertions(+), 121 deletions(-) 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 f8fbb26b0..a61cfdb4c 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -34,7 +34,10 @@ import { ResponseCompanyDto, ResponseCompanyProfileDto, } from "./dto/response-company.dto"; -import { ProfileLicenseFileView } from "./entities/company-profile.entity"; +import { + CompanyDocumentFileView, + ProfileLicenseFileView, +} from "./entities/company-profile.entity"; import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto"; import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; @@ -306,6 +309,49 @@ export class CompaniesController { return this.companiesService.listProfileLicenseFiles(user.id, profileId); } + @Get("poa-delegation") + @ApiOperation({ + summary: + "List the Power of Attorney delegation letter (with review state) for the current user's company", + }) + async listPoaDelegation( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.listPoaDelegationFiles(user.id); + } + + @Post("poa-delegation") + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: + "Upload the Power of Attorney delegation letter, replacing any existing one. " + + "For an approved company the upload is staged for backoffice review; during " + + "onboarding it goes live.", + }) + async uploadPoaDelegation( + @CurrentUser() user: CurrentIamUser, + @UploadedFiles() files: Array, + ): Promise { + const file = files?.[0]; + if (!file) { + throw new BadRequestException("A delegation letter file is required"); + } + return this.companiesService.uploadPoaDelegationLetter(user.id, file); + } + + @Delete("poa-delegation/:fileId") + @ApiOperation({ + summary: + "Remove the Power of Attorney delegation letter (staged for review on an approved company).", + }) + async removePoaDelegation( + @CurrentUser() user: CurrentIamUser, + @Param("fileId", ParseUUIDPipe) fileId: string, + ): Promise { + return this.companiesService.removePoaDelegationLetter(user.id, fileId); + } + @Patch("active-mode") @ApiOperation({ summary: "Switch the current user's active operational mode (importer/exporter)", 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 ab2711dfb..ae4703e40 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -37,6 +37,7 @@ import { import { ExternalProfile } from "./entities/external-profile.entity"; import { BusinessLicenseFile, + CompanyDocumentFileView, CompanyProfile, ProfileLicenseFileView, ProfileType, @@ -45,6 +46,7 @@ import { import { ChangeRequestStatus, CompanyChangeRequest, + DocumentChangeIntent, LicenseChangeIntent, } from "./entities/company-change-request.entity"; @@ -56,6 +58,10 @@ const LICENSE_PENDING_CODE = "business_license_pending"; /** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */ const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; +/** Code for a PoA letter staged in an open change request (not yet live). */ +const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; +/** FileRecord resource that company-level documents are stored under. */ +const COMPANY_RESOURCE = "companies"; /** company.attributes keys that together mean "a PoA was entered". */ const POA_ATTRIBUTES = [ "poaName", @@ -782,6 +788,7 @@ export class CompaniesService { const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot); await this.companiesRepo.update(company.id, companyUpdates); await this.applyLicenseChanges(request); + await this.applyDocumentChanges(request); return ( (await this.changeRequestRepo.update(id, { @@ -834,7 +841,12 @@ export class CompaniesService { if (existing) { const prev = existing.documents?.documentFileIds ?? []; await this.changeRequestRepo.update(existing.id, { - documents: { documentFileIds: [...prev, ...fileIds] }, + // Spread the existing documents blob: a bare object would drop any + // licenseChanges/documentChanges already staged on this request. + documents: { + ...existing.documents, + documentFileIds: [...prev, ...fileIds], + }, submittedBy: submittedBy ?? existing.submittedBy ?? null, submittedAt: now, note: null, @@ -866,12 +878,17 @@ export class CompaniesService { ); } await this.discardLicenseChanges(request); + await this.discardDocumentChanges(request); return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.Rejected, - // Staged license uploads were just discarded; drop their intents so an - // amended resubmit never re-references deleted files. - documents: { ...request.documents, licenseChanges: [] }, + // Staged license/document uploads were just discarded; drop their intents + // so an amended resubmit never re-references deleted files. + documents: { + ...request.documents, + licenseChanges: [], + documentChanges: [], + }, note, reviewedBy: reviewerId ?? null, reviewedAt: new Date(), @@ -1731,6 +1748,254 @@ export class CompaniesService { } } + // --------------------------------------------------------------------------- + // Power of Attorney delegation letter + // + // A company-level document that follows the same staged-review model as the + // business license: on an approved (Active) company an upload lands under the + // pending code and the live letter is flagged for removal, so the reviewer + // sees both and approval swaps them atomically. During onboarding it goes live. + // --------------------------------------------------------------------------- + + /** The company's PoA letter(s), with each file's review status resolved. */ + async listPoaDelegationFiles( + userId: string, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + return this.getPoaDelegationView(company.id); + } + + /** + * Upload the PoA delegation letter, replacing whatever is already on file. + * On an Active company this stages an `add` for the new file plus a `remove` + * for each live one; a letter still awaiting approval is withdrawn outright + * rather than stacking a second pending upload. + */ + async uploadPoaDelegationLetter( + userId: string, + file: Express.Multer.File, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const gated = company.status === CompanyStatus.Active; + + const records = await this.filesService.findByResource( + company.id, + COMPANY_RESOURCE, + ); + const live = records.filter((r) => r.code === POA_DELEGATION_FILE_KEY); + const staged = records.filter( + (r) => r.code === POA_DELEGATION_PENDING_CODE, + ); + + // Supersede an unreviewed upload instead of queueing another one. + for (const r of staged) { + await this.filesService.remove(r.id); + await this.withdrawDocumentIntent(company.id, r.id); + } + + const created = await this.filesService.upload({ + resourceId: company.id, + resource: COMPANY_RESOURCE, + code: gated ? POA_DELEGATION_PENDING_CODE : POA_DELEGATION_FILE_KEY, + file, + }); + + if (gated) { + await this.stageDocumentIntent( + company.id, + [ + ...live.map((r) => ({ + op: "remove" as const, + fileId: r.id, + code: POA_DELEGATION_FILE_KEY, + fileName: r.name, + })), + { + op: "add" as const, + fileId: created.id, + code: POA_DELEGATION_FILE_KEY, + fileName: created.name, + }, + ], + userId, + ); + } else { + // Onboarding: no review, so the old letter is simply replaced. + for (const r of live) await this.filesService.remove(r.id); + } + + return this.getPoaDelegationView(company.id); + } + + /** + * Remove the PoA letter. A staged upload is withdrawn outright; a live file on + * an Active company is kept and flagged for deletion on approval; during + * onboarding it is deleted immediately. + */ + async removePoaDelegationLetter( + userId: string, + fileId: string, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const record = await this.filesService.findById(fileId); + if ( + record.resource !== COMPANY_RESOURCE || + record.resourceId !== company.id || + (record.code !== POA_DELEGATION_FILE_KEY && + record.code !== POA_DELEGATION_PENDING_CODE) + ) { + throw new NotFoundException(`Delegation letter ${fileId} not found`); + } + + if (record.code === POA_DELEGATION_PENDING_CODE) { + await this.filesService.remove(fileId); + await this.withdrawDocumentIntent(company.id, fileId); + } else if (company.status === CompanyStatus.Active) { + await this.stageDocumentIntent( + company.id, + [ + { + op: "remove", + fileId, + code: POA_DELEGATION_FILE_KEY, + fileName: record.name, + }, + ], + userId, + ); + } else { + await this.filesService.remove(fileId); + } + + return this.getPoaDelegationView(company.id); + } + + private async getPoaDelegationView( + companyId: string, + ): Promise { + const pending = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + const removeIds = new Set( + (pending?.documents?.documentChanges ?? []) + .filter((c) => c.op === "remove") + .map((c) => c.fileId), + ); + const records = await this.filesService.findByResource( + companyId, + COMPANY_RESOURCE, + ); + return records + .filter( + (r) => + r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE, + ) + .map((r) => ({ + id: r.id, + name: r.name, + size: r.size, + mimeType: r.mimeType, + status: + r.code === POA_DELEGATION_PENDING_CODE + ? ("pending_add" as const) + : removeIds.has(r.id) + ? ("pending_remove" as const) + : ("live" as const), + })); + } + + /** Open or append a pending change request recording document add/remove intents. */ + private async stageDocumentIntent( + companyId: string, + changes: DocumentChangeIntent[], + submittedBy?: string, + ): Promise { + if (changes.length === 0) return; + const now = new Date(); + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (existing) { + const prev = existing.documents?.documentChanges ?? []; + // Re-uploading twice before review would otherwise stage a second `remove` + // for the same live file, and the duplicate would fail on approval. + const seen = new Set(prev.map((c) => `${c.op}:${c.fileId}`)); + const fresh = changes.filter((c) => !seen.has(`${c.op}:${c.fileId}`)); + if (fresh.length === 0) return; + await this.changeRequestRepo.update(existing.id, { + documents: { + ...existing.documents, + documentChanges: [...prev, ...fresh], + }, + submittedBy: submittedBy ?? existing.submittedBy ?? null, + submittedAt: now, + note: null, + }); + } else { + await this.changeRequestRepo.create({ + companyId, + snapshot: {}, + documents: { documentChanges: changes }, + status: ChangeRequestStatus.Pending, + submittedBy: submittedBy ?? null, + submittedAt: now, + }); + } + } + + /** + * Drop a staged document intent referencing `fileId`. If that empties the + * request entirely, delete it so the customer's settings page unlocks. + */ + private async withdrawDocumentIntent( + companyId: string, + fileId: string, + ): Promise { + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (!existing) return; + const remaining = (existing.documents?.documentChanges ?? []).filter( + (c) => c.fileId !== fileId, + ); + const docs = existing.documents ?? {}; + const stillHasWork = + remaining.length > 0 || + (docs.licenseChanges?.length ?? 0) > 0 || + (docs.documentFileIds?.length ?? 0) > 0 || + Object.keys(existing.snapshot ?? {}).length > 0; + + if (stillHasWork) { + await this.changeRequestRepo.update(existing.id, { + documents: { ...docs, documentChanges: remaining }, + }); + } else { + await this.changeRequestRepo.softDelete(existing.id); + } + } + + /** Apply a request's staged document changes: promote adds, delete removes. */ + private async applyDocumentChanges( + request: CompanyChangeRequest, + ): Promise { + for (const change of request.documents?.documentChanges ?? []) { + if (change.op === "add") { + await this.filesService.setCode(change.fileId, change.code); + } else { + await this.filesService.remove(change.fileId); + } + } + } + + /** Discard a rejected request's staged document uploads (adds only). */ + private async discardDocumentChanges( + request: CompanyChangeRequest, + ): Promise { + for (const change of request.documents?.documentChanges ?? []) { + if (change.op === "add") { + await this.filesService.remove(change.fileId); + } + } + } + /** * Resolve which company_profile a new booking belongs to, from the company * and the booking's trade direction. IMPORT → importer profile, EXPORT → diff --git a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts index 579ac6ddd..4a931dae3 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts @@ -1,6 +1,7 @@ import { ChangeRequestStatus, CompanyChangeRequest, + DocumentChangeIntent, LicenseChangeIntent, } from "../entities/company-change-request.entity"; @@ -18,6 +19,8 @@ export class ChangeRequestResponseDto { documentFileIds: string[]; /** Staged business-license add/remove intents attached to this request. */ licenseChanges: LicenseChangeIntent[]; + /** Staged company-document add/remove intents (e.g. the PoA letter). */ + documentChanges: DocumentChangeIntent[]; note: string | null; submittedBy: string | null; submittedAt: Date | null; @@ -33,6 +36,7 @@ export class ChangeRequestResponseDto { this.snapshot = req.snapshot ?? {}; this.documentFileIds = req.documents?.documentFileIds ?? []; this.licenseChanges = req.documents?.licenseChanges ?? []; + this.documentChanges = req.documents?.documentChanges ?? []; this.note = req.note ?? null; this.submittedBy = req.submittedBy ?? null; this.submittedAt = req.submittedAt ?? null; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index cec670787..5ee6739de 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -30,12 +30,34 @@ export interface LicenseChangeIntent { fileName?: string; } +/** + * A staged change to a company-level document, awaiting review. Same semantics + * as {@link LicenseChangeIntent} but keyed by the document's FileRecord `code` + * (e.g. `poa_delegation_letter`) rather than a profile: `add` → uploaded under + * the pending code, promoted to `code` on approval; `remove` → a live file that + * is deleted on approval. A replace is a `remove` plus an `add`. + */ +export interface DocumentChangeIntent { + op: "add" | "remove"; + fileId: string; + /** The live FileRecord code this op targets (the upload setting's fileKey). */ + code: string; + /** File name, snapshotted for the backoffice review screen. */ + fileName?: string; +} + /** File references staged alongside a change request (documents/licenses). */ export interface ChangeRequestDocuments { - /** FileRecord ids uploaded against the company while this request was open. */ + /** + * FileRecord ids uploaded against the company while this request was open. + * These go live immediately — only their ids are recorded, for the reviewer. + * Contrast `documentChanges`, which stages the file behind the pending code. + */ documentFileIds?: string[]; /** Staged per-profile business-license add/remove intents. */ licenseChanges?: LicenseChangeIntent[]; + /** Staged company-level document add/remove intents (e.g. the PoA letter). */ + documentChanges?: DocumentChangeIntent[]; } @Entity({ schema: "freight", name: "company_change_request" }) 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 2266b0c65..d84be5d8b 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 @@ -31,17 +31,28 @@ export interface BusinessLicenseFile { mimeType?: string; } +/** + * `live` — approved & in effect; `pending_add` — uploaded, awaiting approval; + * `pending_remove` — live but flagged for deletion on approval. + */ +export type StagedFileStatus = "live" | "pending_add" | "pending_remove"; + /** A business-license file plus its change-review state, surfaced to clients. */ export interface ProfileLicenseFileView { id: string; name: string; size: number; mimeType: string; - /** - * `live` — approved & in effect; `pending_add` — uploaded, awaiting approval; - * `pending_remove` — live but flagged for deletion on approval. - */ - status: "live" | "pending_add" | "pending_remove"; + status: StagedFileStatus; +} + +/** A company-level document (e.g. the PoA letter) with its change-review state. */ +export interface CompanyDocumentFileView { + id: string; + name: string; + size: number; + mimeType: string; + status: StagedFileStatus; } @Entity({ schema: "freight", name: "company_profiles" }) diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index 924d62436..371a83ccf 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -153,6 +153,7 @@ export function ChangeRequestReview({ company }: { company: Company }) { : ([] as string[]); const docCount = pending?.documentFileIds?.length ?? 0; const licenseChanges = pending?.licenseChanges ?? []; + const documentChanges = pending?.documentChanges ?? []; const confirmReject = () => { if (!rejectId) return; @@ -210,11 +211,75 @@ export function ChangeRequestReview({ company }: { company: Company }) { )} + {documentChanges.length > 0 && ( + + + Document changes + + {documentChanges.map((c, i) => ( + + {c.op === "add" ? ( + + ) : ( + + )} + + {c.op === "add" ? "Add" : "Remove"} + + + view({ + name: c.fileName ?? humanize(c.code), + url: fileViewUrl(c.fileId), + }) + } + style={{ + textDecoration: + c.op === "remove" ? "line-through" : undefined, + }} + > + {c.fileName ?? humanize(c.code)} + + + {humanize(c.code)} + + + ))} + + )} + {docCount > 0 && ( - - {docCount} document{docCount === 1 ? "" : "s"} uploaded with this - request — review them in the Documents tab. - + + + Documents uploaded with this request + + {pending!.documentFileIds.map((fileId, i) => ( + + + + view({ + name: `Document ${i + 1}`, + url: fileViewUrl(fileId), + }) + } + > + Document {i + 1} + + + ))} + )} {licenseChanges.length > 0 && ( diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 5d0655f8d..dc682f1c7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -93,6 +93,8 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) { /** Matches the fileKey seeded in the API's file-upload-settings seeder. */ const POA_DELEGATION_CODE = "poa_delegation_letter"; +/** A letter uploaded by an approved customer, awaiting this reviewer's approval. */ +const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; export default function CustomerDetailPage() { const { id } = useParams<{ id: string }>(); @@ -536,9 +538,15 @@ export default function CustomerDetailPage() { ); const poaDocuments = useMemo( - () => documents.filter((d) => d.code === POA_DELEGATION_CODE), + () => + documents.filter( + (d) => + d.code === POA_DELEGATION_CODE || + d.code === POA_DELEGATION_PENDING_CODE, + ), [documents], ); + const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE); const poaFields = [ { label: "PoA name", value: company?.poaName }, { label: "PoA email", value: company?.poaEmail }, @@ -553,7 +561,7 @@ export default function CustomerDetailPage() { (p) => p.type === "freight_forwarder", ); const delegationMissing = - (hasPoaDetails || poaMandatory) && poaDocuments.length === 0; + (hasPoaDetails || poaMandatory) && poaLive.length === 0; if (isLoading) { return ( @@ -713,7 +721,7 @@ export default function CustomerDetailPage() { Delegation letter missing - ) : poaDocuments.length > 0 ? ( + ) : poaLive.length > 0 ? ( Delegation letter on file @@ -806,6 +814,16 @@ export default function CustomerDetailPage() { {formatBytes(doc.size)} ·{" "} {formatDate(doc.uploadedAt)} + {doc.code === POA_DELEGATION_PENDING_CODE && ( + + Pending approval + + )} `/api/companies/company-profiles/${profileId}/license/${fileId}/replace`, + POA_DELEGATION: "/api/companies/poa-delegation", + POA_DELEGATION_FILE: (fileId: string) => + `/api/companies/poa-delegation/${fileId}`, PROFILE_CHANGE_REQUEST: "/api/companies/profile/change-request", PROFILE_REAPPLY: (profileId: string) => `/api/companies/company-profiles/${profileId}/reapply`, 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 9bd8d7ba9..3f4dcbe41 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx @@ -61,6 +61,13 @@ function documentSettingCode(nationality: string | null | undefined): string { : "company_onboarding_documents_ethiopian"; } +/** + * The delegation letter ships in the same nationality document set, but it is + * edited on the Power of Attorney tab (where it is staged for review alongside + * the PoA details), so it is excluded from this tab's uploader. + */ +const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; + export default function TabDocuments({ profile, mode = "edit", @@ -78,6 +85,17 @@ export default function TabDocuments({ }), ); + const docSetting = useMemo(() => { + const setting = docSettingQuery.data; + if (!setting) return setting; + return { + ...setting, + fields: setting.fields.filter( + (f) => f.fileKey !== POA_DELEGATION_FILE_KEY, + ), + }; + }, [docSettingQuery.data]); + const docsQuery = useQuery( api.companies.documents.queryOptions({ input: { companyId: profile.companyId }, @@ -139,7 +157,7 @@ export default function TabDocuments({ const validateRequired = (): Record => { const errs: Record = {}; - for (const field of docSettingQuery.data?.fields ?? []) { + for (const field of docSetting?.fields ?? []) { const min = getMinFiles(field); if (min <= 0) continue; if (uploadedKeys.includes(field.fileKey)) continue; @@ -169,13 +187,13 @@ export default function TabDocuments({
- ) : !docSettingQuery.data ? ( + ) : !docSetting ? ( No document requirements configured for your account. ) : ( )} - {docSettingQuery.data && ( + {docSetting && ( ; +const LETTER_ACCEPT = ".pdf,.png,.jpg,.jpeg"; + +const STATUS_BADGE: Record< + LicenseFileStatus, + { label: string; bg: string; fg: string } | null +> = { + live: null, + pending_add: { + label: "Pending approval", + bg: "var(--mantine-color-edr-amber-soft-0)", + fg: "var(--mantine-color-edr-amber-text-0)", + }, + pending_remove: { + label: "Removal pending", + bg: "var(--mantine-color-edr-red-soft-0)", + fg: "var(--mantine-color-edr-red-0)", + }, +}; + +function formatBytes(bytes: number): string { + if (!bytes) return ""; + const units = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(1))} ${units[i]}`; +} + interface TabPowerOfAttorneyProps { profile: ProfileResponse; mode?: "edit" | "onboarding"; @@ -43,6 +93,8 @@ export default function TabPowerOfAttorney({ onContinue, }: TabPowerOfAttorneyProps) { const queryClient = useQueryClient(); + const { view, viewer } = useFileViewer(); + const uploadInputRef = useRef(null); const defaultValues = useMemo((): FormData => { return { @@ -59,22 +111,75 @@ export default function TabPowerOfAttorney({ control, handleSubmit, reset, + watch, formState: { errors, isDirty }, } = useForm({ resolver: zodResolver(schema), values: defaultValues, }); + const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({})); + const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]); + + // The letter is staged locally, not uploaded on pick. Uploading immediately + // would open a change request, which locks the whole settings page (see + // SettingsPage's `locked` fieldset) before the text fields could be saved. + // Save submits the file and the fields together, into one change request. + const [pickedFile, setPickedFile] = useState(null); + const [removeIds, setRemoveIds] = useState([]); + const [letterError, setLetterError] = useState(null); + const [saveBlocked, setSaveBlocked] = useState(false); + + /** Letters that will still be on file once the staged edits are applied. */ + const remainingLetters = letters.filter( + (f) => f.status !== "pending_remove" && !removeIds.includes(f.id), + ); + const hasLetterAfterSave = Boolean(pickedFile) || remainingLetters.length > 0; + + // A freight forwarder signs on other companies' behalf, so its PoA — details + // and delegation letter both — is mandatory rather than optional. + const requirePoa = profile.companyProfiles.some( + (p) => p.type === "freight_forwarder", + ); + const poaValues = watch([ + "poaName", + "poaEmail", + "poaPhone", + "poaLocation", + "poaAddress", + ]); + const poaProvided = poaValues.some((v) => v?.trim()); + const letterRequired = requirePoa || poaProvided; + const letterMissing = letterRequired && !hasLetterAfterSave; + + const fileDirty = Boolean(pickedFile) || removeIds.length > 0; + const mutation = useMutation({ - mutationFn: (data: FormData) => - api.companies.updateProfile.call({ + mutationFn: async (data: FormData) => { + // A fresh upload already stages the removal of every live letter, so the + // explicit removals only need applying when no replacement was picked. + if (pickedFile) { + await companiesService.uploadPoaDelegation(pickedFile); + } else { + for (const fileId of removeIds) { + await companiesService.removePoaDelegation(fileId); + } + } + return api.companies.updateProfile.call({ poaName: data.poaName || undefined, poaPhone: data.poaPhone || undefined, poaEmail: data.poaEmail || undefined, poaLocation: data.poaLocation || undefined, poaAddress: data.poaAddress || undefined, - }), + }); + }, onSuccess: () => { + setPickedFile(null); + setRemoveIds([]); + setLetterError(null); + queryClient.invalidateQueries({ + queryKey: api.companies.poaDelegation.queryKey(), + }); queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey(), }); @@ -82,112 +187,431 @@ export default function TabPowerOfAttorney({ }, }); - const onSubmit = (data: FormData) => mutation.mutate(data); + const onSubmit = (data: FormData) => { + // The letter lives outside the form state, so it's gated here rather than + // in the zod resolver. + if (letterMissing) { + setSaveBlocked(true); + return; + } + setSaveBlocked(false); + mutation.mutate(data); + }; + + const resetAll = () => { + reset(); + setPickedFile(null); + setRemoveIds([]); + setSaveBlocked(false); + setLetterError(null); + }; + + const pickFile = (file: File) => { + setLetterError(null); + setSaveBlocked(false); + setPickedFile(file); + }; + + const toggleRemove = (fileId: string) => { + setSaveBlocked(false); + setRemoveIds((prev) => + prev.includes(fileId) + ? prev.filter((id) => id !== fileId) + : [...prev, fileId], + ); + }; return ( - - - - Power of Attorney - - - Power of Attorney details are optional. Fill them in if you have an - authorized representative, or leave blank. - + <> + + + + Power of Attorney + {requirePoa && ( + + Required for freight forwarder + + )} + + + {requirePoa + ? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its delegation letter are required." + : "Power of Attorney details are optional. If you name a representative, upload the delegation letter authorising them."} + -
- - + + + - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + + - - - {mutation.isSuccess && ( - - - - Saved successfully + {/* ------------------------ Delegation letter ------------------------ */} + + + + + + Delegation letter - )} - {mutation.isError && ( - - - - Save failed - - - )} - - - {mode === "edit" && ( + + + + The signed letter in which the General Manager delegates the + representative above. Submitted to EDR for review together with the + details; it takes effect once approved. + + + {saveBlocked && letterMissing && ( + } + > + {requirePoa + ? "Upload the delegation letter before saving — it is required for freight forwarders." + : "Upload the delegation letter for the representative you named, or clear the PoA details."} + )} - + + {letterQuery.isLoading ? ( + + + + ) : letters.length === 0 && !pickedFile ? ( + + + No delegation letter uploaded. + + + ) : ( + + {letters.map((f) => ( + toggleRemove(f.id)} + onViewFile={view} + /> + ))} + {pickedFile && ( + + + + + + {pickedFile.name} + + + {formatBytes(pickedFile.size)} + + + + Submitted on save + + + setPickedFile(null)} + > + + + + + + )} + + )} + + {profile.reviewStatus === "pending" && ( + + + + Awaiting EDR review — this letter takes effect once approved. + + + )} + {letterError && ( + + + + {letterError} + + + )} + + { + const file = e.target.files?.[0]; + if (file) pickFile(file); + e.target.value = ""; + }} + /> + + + + + {mutation.isSuccess && ( + + + + Saved successfully + + + )} + {mutation.isError && ( + + + + Save failed + + + )} + + + {mode === "edit" && ( + + )} + + - - + +
+ + {viewer} + + ); +} + +/** + * One letter already on file. `pending_add` / `pending_remove` reflect a change + * request the backoffice hasn't ruled on yet; `markedForRemoval` and + * `supersededBy` are this session's unsaved edits. + */ +function LetterRow({ + file, + markedForRemoval, + supersededBy, + disabled, + onToggleRemove, + onViewFile, +}: { + file: LicenseFile; + markedForRemoval: boolean; + supersededBy: File | null; + disabled: boolean; + onToggleRemove: () => void; + onViewFile: (file: ViewableFile) => void; +}) { + const badge = STATUS_BADGE[file.status]; + const superseded = Boolean(supersededBy) && file.status !== "pending_remove"; + const struck = + file.status === "pending_remove" || markedForRemoval || superseded; + + return ( + + + + + + onViewFile({ + name: file.name, + url: fileViewUrl(file.id), + mimeType: file.mimeType, + }) + } + style={{ + textAlign: "left", + textDecoration: struck ? "line-through" : undefined, + }} + lineClamp={1} + > + {file.name} + + {file.size > 0 && ( + + {formatBytes(file.size)} + + )} + + + {badge && ( + } + style={{ backgroundColor: badge.bg, color: badge.fg, flexShrink: 0 }} + > + {badge.label} + + )} + {superseded && !markedForRemoval && ( + + Replaced on save + + )} + {markedForRemoval && ( + + Removed on save + + )} + + {file.status !== "pending_remove" && !superseded && ( + + + {markedForRemoval ? : } + + + )} + ); } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 04aaf4153..87cd2c28f 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -59,6 +59,7 @@ import type { CompanyInfoResponse, CompanyNationality, CompanyProfileResponse, + LicenseFile, CreateCompanyPayload, DashboardSummary, OnboardingRequirements, @@ -231,6 +232,12 @@ export const api = { ({ companyId }) => companiesService.getDocuments(companyId), ), + poaDelegation: endpoint( + "companies", + "poaDelegation", + companiesService.getPoaDelegation, + ), + changeRequest: endpoint( "companies", "changeRequest", diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index b5c1c3cdf..106d17f71 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -412,6 +412,37 @@ export const companiesService = { return unwrap(response.data); }, + /** The PoA delegation letter on file, with its review state. */ + getPoaDelegation: async (): Promise => { + const response = await client.get>( + URL_CONSTANTS.COMPANIES_API.POA_DELEGATION, + ); + return unwrap(response.data); + }, + + /** + * Upload the PoA delegation letter, replacing any existing one. On an approved + * company the upload is staged for backoffice review; during onboarding it + * goes live immediately. + */ + uploadPoaDelegation: async (file: File): Promise => { + const formData = new FormData(); + formData.append("poa_delegation_letter", file); + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.POA_DELEGATION, + formData, + ); + return unwrap(response.data); + }, + + /** Remove the PoA delegation letter (staged for review on an approved company). */ + removePoaDelegation: async (fileId: string): Promise => { + const response = await client.delete>( + URL_CONSTANTS.COMPANIES_API.POA_DELEGATION_FILE(fileId), + ); + return unwrap(response.data); + }, + /** List business-license document(s) (with review state) for a company profile. */ getProfileLicense: async (profileId: string): Promise => { const response = await client.get>(