Merge pull request #594 from Tria-plc/freight/feat/fixes-v1

POA fixes
This commit is contained in:
Nathnael Wondisha
2026-07-10 11:58:57 +03:00
committed by GitHub
20 changed files with 1476 additions and 137 deletions

View File

@@ -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<CompanyDocumentFileView[]> {
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<Express.Multer.File>,
): Promise<CompanyDocumentFileView[]> {
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<CompanyDocumentFileView[]> {
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
}
@Patch("active-mode")
@ApiOperation({
summary: "Switch the current user's active operational mode (importer/exporter)",

View File

@@ -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";
@@ -54,6 +56,27 @@ 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";
/** 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",
"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;
@@ -765,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, {
@@ -817,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,
@@ -849,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(),
@@ -1240,6 +1274,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 +1304,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 +1336,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,
@@ -1671,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<CompanyDocumentFileView[]> {
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<CompanyDocumentFileView[]> {
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<CompanyDocumentFileView[]> {
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<CompanyDocumentFileView[]> {
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<void> {
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<void> {
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<void> {
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<void> {
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 →

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. */

View File

@@ -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 }) {
</Text>
)}
{documentChanges.length > 0 && (
<Stack gap={8}>
<Text size="sm" fw={600} c="edr-text">
Document changes
</Text>
{documentChanges.map((c, i) => (
<Group key={`${c.fileId}-${i}`} gap={8} wrap="nowrap">
{c.op === "add" ? (
<FilePlus2 size={15} className="text-edr-muted" />
) : (
<FileX2 size={15} className="text-edr-muted" />
)}
<Badge
size="sm"
radius="sm"
variant="light"
color={c.op === "add" ? "green" : "red"}
>
{c.op === "add" ? "Add" : "Remove"}
</Badge>
<Anchor
component="button"
type="button"
size="sm"
onClick={() =>
view({
name: c.fileName ?? humanize(c.code),
url: fileViewUrl(c.fileId),
})
}
style={{
textDecoration:
c.op === "remove" ? "line-through" : undefined,
}}
>
{c.fileName ?? humanize(c.code)}
</Anchor>
<Text size="xs" c="dimmed">
{humanize(c.code)}
</Text>
</Group>
))}
</Stack>
)}
{docCount > 0 && (
<Text size="sm" c="dimmed">
{docCount} document{docCount === 1 ? "" : "s"} uploaded with this
request review them in the Documents tab.
</Text>
<Stack gap={8}>
<Text size="sm" fw={600} c="edr-text">
Documents uploaded with this request
</Text>
{pending!.documentFileIds.map((fileId, i) => (
<Group key={fileId} gap={8} wrap="nowrap">
<FilePlus2 size={15} className="text-edr-muted" />
<Anchor
component="button"
type="button"
size="sm"
onClick={() =>
view({
name: `Document ${i + 1}`,
url: fileViewUrl(fileId),
})
}
>
Document {i + 1}
</Anchor>
</Group>
))}
</Stack>
)}
{licenseChanges.length > 0 && (

View File

@@ -7,6 +7,7 @@ import {
Card,
Center,
Container,
Divider,
Group,
Loader,
SimpleGrid,
@@ -90,6 +91,11 @@ 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";
/** 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 }>();
const navigate = useNavigate();
@@ -531,6 +537,32 @@ export default function CustomerDetailPage() {
(p) => p.licenseFiles && p.licenseFiles.length > 0,
);
const poaDocuments = useMemo(
() =>
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 },
{ 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) && poaLive.length === 0;
if (isLoading) {
return (
<Center mih="60vh">
@@ -672,6 +704,159 @@ export default function CustomerDetailPage() {
</Stack>
</Card>
<Card>
<Stack gap="lg">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Text fw={600} c="edr-text">
Power of Attorney
</Text>
{poaMandatory && (
<Badge size="xs" color="blue" variant="light">
Required for freight forwarder
</Badge>
)}
</Group>
{delegationMissing ? (
<Badge size="sm" color="red" variant="light">
Delegation letter missing
</Badge>
) : poaLive.length > 0 ? (
<Badge size="sm" color="edr-green" variant="light">
Delegation letter on file
</Badge>
) : (
<Badge size="sm" color="gray" variant="light">
Not provided
</Badge>
)}
</Group>
{hasPoaDetails ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{poaFields.map((f) => (
<InfoField
key={f.label}
label={f.label}
value={f.value}
/>
))}
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
No Power of Attorney representative recorded for this
customer.
</Text>
)}
<Divider />
<Stack gap="sm">
<Text
size="xs"
fw={600}
c="edr-muted"
tt="uppercase"
style={{ letterSpacing: "0.04em" }}
>
Delegation letter
</Text>
{documentsQuery.isLoading ? (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading documents
</Text>
</Group>
) : documentsQuery.isError ? (
<Group gap="sm">
<Text size="sm" c="red">
Failed to load documents.
</Text>
<Anchor
component="button"
type="button"
size="xs"
onClick={() => void documentsQuery.refetch()}
>
Retry
</Anchor>
</Group>
) : poaDocuments.length === 0 ? (
<Text size="sm" c="dimmed">
No delegation letter uploaded.
</Text>
) : (
poaDocuments.map((doc) => (
<Group key={doc.id} justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Paperclip
size={14}
className="shrink-0 text-edr-muted"
/>
<Anchor
component="button"
type="button"
size="sm"
lineClamp={1}
onClick={() =>
view({
name: doc.name,
url: fileViewUrl(doc.id),
mimeType: doc.mimeType,
})
}
>
{doc.name}
</Anchor>
<Text size="xs" c="dimmed" className="shrink-0">
{formatBytes(doc.size)} ·{" "}
{formatDate(doc.uploadedAt)}
</Text>
{doc.code === POA_DELEGATION_PENDING_CODE && (
<Badge
size="xs"
color="yellow"
variant="light"
className="shrink-0"
>
Pending approval
</Badge>
)}
</Group>
<Group gap={4} wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
aria-label={`Preview ${doc.name}`}
onClick={() =>
view({
name: doc.name,
url: fileViewUrl(doc.id),
mimeType: doc.mimeType,
})
}
>
<Eye size={16} />
</ActionIcon>
<ActionIcon
component="a"
href={fileViewUrl(doc.id, true)}
variant="subtle"
color="gray"
aria-label={`Download ${doc.name}`}
>
<Download size={16} />
</ActionIcon>
</Group>
</Group>
))
)}
</Stack>
</Stack>
</Card>
<Card>
<Stack gap="md">
<Group justify="space-between">

View File

@@ -33,6 +33,11 @@ function mapCompany(dto: Record<string, unknown>): 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,
};
}

View File

@@ -79,6 +79,15 @@ export interface LicenseChangeIntent {
fileName?: string;
}
/** A staged company-level document add/remove (e.g. the PoA delegation letter). */
export interface DocumentChangeIntent {
op: "add" | "remove";
fileId: string;
/** The live FileRecord code this op targets (the upload setting's fileKey). */
code: string;
fileName?: string;
}
/**
* A staged profile-edit change request. The customer's settings edits land here
* (pending) until a reviewer approves (applies them) or rejects (with a note).
@@ -92,6 +101,8 @@ export interface CompanyChangeRequest {
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;
submittedAt: string | null;
reviewedAt: string | null;
@@ -127,6 +138,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<string, unknown> | null;
companyProfiles: CompanyProfile[];

View File

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

View File

@@ -102,6 +102,9 @@ export const URL_CONSTANTS = {
`/api/companies/company-profiles/${profileId}/license/${fileId}`,
PROFILE_LICENSE_REPLACE: (profileId: string, fileId: string) =>
`/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`,

View File

@@ -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<FormData>({
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<string, string> => {
const errs: Record<string, string> = {};
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<boolean> => {
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" && (
<>
<Text size="sm" c="edr-muted">
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."}
</Text>
{watch("contactPersonName") && (
<LinkCheckboxCard
@@ -788,6 +852,19 @@ export default function CompanyProfileForm({
{...register("poaAddress")}
/>
</SimpleGrid>
{poaDocumentSetting && (
<>
<Divider my="sm" />
<SmartFileInput
file={poaDocumentSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
onChange={handleDocumentFilesChange}
/>
</>
)}
</>
)}
@@ -797,13 +874,13 @@ export default function CompanyProfileForm({
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : !uploadSetting ? (
) : !documentsSetting ? (
<Text size="sm" c="edr-muted" ta="center" py="md">
No document requirements found for your account type.
</Text>
) : (
<SmartFileInput
file={uploadSetting}
file={documentsSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}

View File

@@ -63,12 +63,56 @@ export const onboardingSchema = z.object({
.optional()
.refine((v) => !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<typeof onboardingSchema>;
/** 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<FormData>) =>
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<CompanyStep, (keyof FormData)[]> = {
company: [
"companyName",
@@ -102,7 +146,7 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"contactPersonEmail",
"contactPersonPhone",
],
poa: [],
poa: [...POA_FIELDS],
documents: [],
additional: [],
};

View File

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

View File

@@ -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<string, string> => {
const errs: Record<string, string> = {};
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({
<Center py="xl">
<Loader2 size={24} className="animate-spin" />
</Center>
) : !docSettingQuery.data ? (
) : !docSetting ? (
<Text c="edr-muted" size="sm" ta="center" py="md">
No document requirements configured for your account.
</Text>
) : (
<SmartFileInput
file={docSettingQuery.data}
file={docSetting}
value={documentFiles}
onChange={handleFilesChange}
errors={fieldErrors}
@@ -185,7 +203,7 @@ export default function TabDocuments({
/>
)}
{docSettingQuery.data && (
{docSetting && (
<Group
justify="space-between"
mt="lg"

View File

@@ -1,20 +1,44 @@
import { useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { CheckCircle2, Save, UserCheck, XCircle } from "lucide-react";
import {
CheckCircle2,
Clock,
FileText,
RefreshCw,
Save,
Trash2,
Undo2,
UploadCloud,
UserCheck,
XCircle,
} from "lucide-react";
import {
ActionIcon,
Alert,
Anchor,
Badge,
Button,
Card,
Group,
Loader,
Stack,
Title,
Text,
TextInput,
Button,
Tooltip,
Grid,
} from "@mantine/core";
import { useFileViewer, type ViewableFile } from "@edr/ui-common";
import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig";
import {
companiesService,
type LicenseFile,
type LicenseFileStatus,
} from "@/services/companies.service";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import type { ProfileResponse } from "@/types/profile";
@@ -31,6 +55,32 @@ const schema = z.object({
type FormData = z.infer<typeof schema>;
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<HTMLInputElement>(null);
const defaultValues = useMemo((): FormData => {
return {
@@ -59,22 +111,75 @@ export default function TabPowerOfAttorney({
control,
handleSubmit,
reset,
watch,
formState: { errors, isDirty },
} = useForm<FormData>({
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<File | null>(null);
const [removeIds, setRemoveIds] = useState<string[]>([]);
const [letterError, setLetterError] = useState<string | null>(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 (
<Card padding="lg">
<Group gap="sm" mb="xs">
<UserCheck size={20} />
<Title order={3}>Power of Attorney</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Power of Attorney details are optional. Fill them in if you have an
authorized representative, or leave blank.
</Text>
<>
<Card padding="lg">
<Group gap="sm" mb="xs">
<UserCheck size={20} />
<Title order={3}>Power of Attorney</Title>
{requirePoa && (
<Badge size="sm" variant="light" color="blue">
Required for freight forwarder
</Badge>
)}
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{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."}
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="PoA Full Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="PoA Full Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<Grid>
<Grid.Col span={6}>
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
</Grid.Col>
<Grid.Col span={6}>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={6}>
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
</Grid.Col>
<Grid.Col span={6}>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={6}>
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</Grid.Col>
</Grid>
</Stack>
<Grid>
<Grid.Col span={6}>
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</Grid.Col>
</Grid>
</Stack>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{mutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Saved successfully
{/* ------------------------ Delegation letter ------------------------ */}
<Stack gap="sm" mt="xl">
<Group justify="space-between" align="center">
<Group gap="sm">
<FileText size={18} />
<Text fw={600} c="edr-text">
Delegation letter
</Text>
</Group>
)}
{mutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
Save failed
</Text>
</Group>
)}
</Group>
<Group gap="md">
{mode === "edit" && (
<Button
type="button"
variant="outline"
disabled={mutation.isPending || !isDirty}
onClick={() => reset()}
variant="light"
size="xs"
leftSection={
hasLetterAfterSave ? (
<RefreshCw size={14} />
) : (
<UploadCloud size={14} />
)
}
disabled={mutation.isPending}
onClick={() => uploadInputRef.current?.click()}
>
Reset
{hasLetterAfterSave ? "Replace letter" : "Upload letter"}
</Button>
</Group>
<Text c="edr-muted" size="xs">
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.
</Text>
{saveBlocked && letterMissing && (
<Alert
color="red"
variant="light"
icon={<XCircle size={18} />}
>
{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."}
</Alert>
)}
<Button
type="submit"
leftSection={<Save size={16} />}
loading={mutation.isPending}
>
{mode === "onboarding" ? "Continue" : "Save Changes"}
</Button>
{letterQuery.isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" color="edr-green" />
</Group>
) : letters.length === 0 && !pickedFile ? (
<Card
padding="md"
radius="md"
style={{
borderStyle: "dashed",
backgroundColor: "var(--mantine-color-edr-bg-0)",
}}
>
<Text size="sm" c="edr-muted" ta="center">
No delegation letter uploaded.
</Text>
</Card>
) : (
<Stack gap="xs">
{letters.map((f) => (
<LetterRow
key={f.id}
file={f}
markedForRemoval={removeIds.includes(f.id)}
supersededBy={pickedFile}
disabled={mutation.isPending}
onToggleRemove={() => toggleRemove(f.id)}
onViewFile={view}
/>
))}
{pickedFile && (
<Card
padding="sm"
radius="md"
withBorder
style={{
backgroundColor: "var(--mantine-color-edr-card-0)",
}}
>
<Group gap="sm" wrap="nowrap">
<FileText
size={18}
className="text-edr-muted"
style={{ flexShrink: 0 }}
/>
<Stack gap={0} style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={600} c="edr-text" lineClamp={1}>
{pickedFile.name}
</Text>
<Text size="xs" c="edr-muted">
{formatBytes(pickedFile.size)}
</Text>
</Stack>
<Badge
size="sm"
radius="sm"
variant="light"
color="blue"
style={{ flexShrink: 0 }}
>
Submitted on save
</Badge>
<Tooltip label="Discard" withArrow>
<ActionIcon
variant="subtle"
color="red"
aria-label="Discard selected letter"
disabled={mutation.isPending}
onClick={() => setPickedFile(null)}
>
<Trash2 size={15} />
</ActionIcon>
</Tooltip>
</Group>
</Card>
)}
</Stack>
)}
{profile.reviewStatus === "pending" && (
<Group gap={6} c="edr-amber-text">
<Clock size={13} />
<Text size="xs" fw={500}>
Awaiting EDR review this letter takes effect once approved.
</Text>
</Group>
)}
{letterError && (
<Group gap={6} c="red">
<XCircle size={13} />
<Text size="xs" fw={500}>
{letterError}
</Text>
</Group>
)}
<input
ref={uploadInputRef}
type="file"
accept={LETTER_ACCEPT}
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) pickFile(file);
e.target.value = "";
}}
/>
</Stack>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{mutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Saved successfully
</Text>
</Group>
)}
{mutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
Save failed
</Text>
</Group>
)}
</Group>
<Group gap="md">
{mode === "edit" && (
<Button
type="button"
variant="outline"
disabled={mutation.isPending || (!isDirty && !fileDirty)}
onClick={resetAll}
>
Reset
</Button>
)}
<Button
type="submit"
leftSection={<Save size={16} />}
loading={mutation.isPending}
>
{mode === "onboarding" ? "Continue" : "Save Changes"}
</Button>
</Group>
</Group>
</Group>
</form>
</form>
</Card>
{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 (
<Card
padding="sm"
radius="md"
withBorder
style={{ backgroundColor: "var(--mantine-color-edr-card-0)" }}
>
<Group gap="sm" wrap="nowrap">
<FileText
size={18}
className="text-edr-muted"
style={{ flexShrink: 0 }}
/>
<Stack gap={0} style={{ minWidth: 0, flex: 1 }}>
<Anchor
component="button"
type="button"
size="sm"
fw={600}
onClick={() =>
onViewFile({
name: file.name,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
})
}
style={{
textAlign: "left",
textDecoration: struck ? "line-through" : undefined,
}}
lineClamp={1}
>
{file.name}
</Anchor>
{file.size > 0 && (
<Text size="xs" c="edr-muted">
{formatBytes(file.size)}
</Text>
)}
</Stack>
{badge && (
<Badge
size="sm"
radius="sm"
variant="light"
leftSection={<Clock size={11} />}
style={{ backgroundColor: badge.bg, color: badge.fg, flexShrink: 0 }}
>
{badge.label}
</Badge>
)}
{superseded && !markedForRemoval && (
<Badge
size="sm"
radius="sm"
variant="light"
color="gray"
style={{ flexShrink: 0 }}
>
Replaced on save
</Badge>
)}
{markedForRemoval && (
<Badge
size="sm"
radius="sm"
variant="light"
color="red"
style={{ flexShrink: 0 }}
>
Removed on save
</Badge>
)}
{file.status !== "pending_remove" && !superseded && (
<Tooltip
label={markedForRemoval ? "Keep" : "Remove"}
withArrow
>
<ActionIcon
variant="subtle"
color={markedForRemoval ? "gray" : "red"}
aria-label={
markedForRemoval ? `Keep ${file.name}` : `Remove ${file.name}`
}
disabled={disabled}
onClick={onToggleRemove}
>
{markedForRemoval ? <Undo2 size={15} /> : <Trash2 size={15} />}
</ActionIcon>
</Tooltip>
)}
</Group>
</Card>
);
}

View File

@@ -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<void, LicenseFile[]>(
"companies",
"poaDelegation",
companiesService.getPoaDelegation,
),
changeRequest: endpoint<void, ChangeRequestResponse | null>(
"companies",
"changeRequest",

View File

@@ -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;
@@ -402,6 +412,37 @@ export const companiesService = {
return unwrap(response.data);
},
/** The PoA delegation letter on file, with its review state. */
getPoaDelegation: async (): Promise<LicenseFile[]> => {
const response = await client.get<ApiResponse<LicenseFile[]>>(
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<LicenseFile[]> => {
const formData = new FormData();
formData.append("poa_delegation_letter", file);
const response = await client.post<ApiResponse<LicenseFile[]>>(
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<LicenseFile[]> => {
const response = await client.delete<ApiResponse<LicenseFile[]>>(
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<LicenseFile[]> => {
const response = await client.get<ApiResponse<LicenseFile[]>>(