mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 08:25:43 +00:00
feat: add poa to the changes approval
This commit is contained in:
@@ -34,7 +34,10 @@ import {
|
|||||||
ResponseCompanyDto,
|
ResponseCompanyDto,
|
||||||
ResponseCompanyProfileDto,
|
ResponseCompanyProfileDto,
|
||||||
} from "./dto/response-company.dto";
|
} 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 { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||||
@@ -306,6 +309,49 @@ export class CompaniesController {
|
|||||||
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
|
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")
|
@Patch("active-mode")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Switch the current user's active operational mode (importer/exporter)",
|
summary: "Switch the current user's active operational mode (importer/exporter)",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||||
import {
|
import {
|
||||||
BusinessLicenseFile,
|
BusinessLicenseFile,
|
||||||
|
CompanyDocumentFileView,
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
ProfileLicenseFileView,
|
ProfileLicenseFileView,
|
||||||
ProfileType,
|
ProfileType,
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ChangeRequestStatus,
|
ChangeRequestStatus,
|
||||||
CompanyChangeRequest,
|
CompanyChangeRequest,
|
||||||
|
DocumentChangeIntent,
|
||||||
LicenseChangeIntent,
|
LicenseChangeIntent,
|
||||||
} from "./entities/company-change-request.entity";
|
} 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. */
|
/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
|
||||||
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
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". */
|
/** company.attributes keys that together mean "a PoA was entered". */
|
||||||
const POA_ATTRIBUTES = [
|
const POA_ATTRIBUTES = [
|
||||||
"poaName",
|
"poaName",
|
||||||
@@ -782,6 +788,7 @@ export class CompaniesService {
|
|||||||
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
|
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
|
||||||
await this.companiesRepo.update(company.id, companyUpdates);
|
await this.companiesRepo.update(company.id, companyUpdates);
|
||||||
await this.applyLicenseChanges(request);
|
await this.applyLicenseChanges(request);
|
||||||
|
await this.applyDocumentChanges(request);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(await this.changeRequestRepo.update(id, {
|
(await this.changeRequestRepo.update(id, {
|
||||||
@@ -834,7 +841,12 @@ export class CompaniesService {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
const prev = existing.documents?.documentFileIds ?? [];
|
const prev = existing.documents?.documentFileIds ?? [];
|
||||||
await this.changeRequestRepo.update(existing.id, {
|
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,
|
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
note: null,
|
||||||
@@ -866,12 +878,17 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.discardLicenseChanges(request);
|
await this.discardLicenseChanges(request);
|
||||||
|
await this.discardDocumentChanges(request);
|
||||||
return (
|
return (
|
||||||
(await this.changeRequestRepo.update(id, {
|
(await this.changeRequestRepo.update(id, {
|
||||||
status: ChangeRequestStatus.Rejected,
|
status: ChangeRequestStatus.Rejected,
|
||||||
// Staged license uploads were just discarded; drop their intents so an
|
// Staged license/document uploads were just discarded; drop their intents
|
||||||
// amended resubmit never re-references deleted files.
|
// so an amended resubmit never re-references deleted files.
|
||||||
documents: { ...request.documents, licenseChanges: [] },
|
documents: {
|
||||||
|
...request.documents,
|
||||||
|
licenseChanges: [],
|
||||||
|
documentChanges: [],
|
||||||
|
},
|
||||||
note,
|
note,
|
||||||
reviewedBy: reviewerId ?? null,
|
reviewedBy: reviewerId ?? null,
|
||||||
reviewedAt: new Date(),
|
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<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
|
* Resolve which company_profile a new booking belongs to, from the company
|
||||||
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ChangeRequestStatus,
|
ChangeRequestStatus,
|
||||||
CompanyChangeRequest,
|
CompanyChangeRequest,
|
||||||
|
DocumentChangeIntent,
|
||||||
LicenseChangeIntent,
|
LicenseChangeIntent,
|
||||||
} from "../entities/company-change-request.entity";
|
} from "../entities/company-change-request.entity";
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ export class ChangeRequestResponseDto {
|
|||||||
documentFileIds: string[];
|
documentFileIds: string[];
|
||||||
/** Staged business-license add/remove intents attached to this request. */
|
/** Staged business-license add/remove intents attached to this request. */
|
||||||
licenseChanges: LicenseChangeIntent[];
|
licenseChanges: LicenseChangeIntent[];
|
||||||
|
/** Staged company-document add/remove intents (e.g. the PoA letter). */
|
||||||
|
documentChanges: DocumentChangeIntent[];
|
||||||
note: string | null;
|
note: string | null;
|
||||||
submittedBy: string | null;
|
submittedBy: string | null;
|
||||||
submittedAt: Date | null;
|
submittedAt: Date | null;
|
||||||
@@ -33,6 +36,7 @@ export class ChangeRequestResponseDto {
|
|||||||
this.snapshot = req.snapshot ?? {};
|
this.snapshot = req.snapshot ?? {};
|
||||||
this.documentFileIds = req.documents?.documentFileIds ?? [];
|
this.documentFileIds = req.documents?.documentFileIds ?? [];
|
||||||
this.licenseChanges = req.documents?.licenseChanges ?? [];
|
this.licenseChanges = req.documents?.licenseChanges ?? [];
|
||||||
|
this.documentChanges = req.documents?.documentChanges ?? [];
|
||||||
this.note = req.note ?? null;
|
this.note = req.note ?? null;
|
||||||
this.submittedBy = req.submittedBy ?? null;
|
this.submittedBy = req.submittedBy ?? null;
|
||||||
this.submittedAt = req.submittedAt ?? null;
|
this.submittedAt = req.submittedAt ?? null;
|
||||||
|
|||||||
@@ -30,12 +30,34 @@ export interface LicenseChangeIntent {
|
|||||||
fileName?: string;
|
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). */
|
/** File references staged alongside a change request (documents/licenses). */
|
||||||
export interface ChangeRequestDocuments {
|
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[];
|
documentFileIds?: string[];
|
||||||
/** Staged per-profile business-license add/remove intents. */
|
/** Staged per-profile business-license add/remove intents. */
|
||||||
licenseChanges?: LicenseChangeIntent[];
|
licenseChanges?: LicenseChangeIntent[];
|
||||||
|
/** Staged company-level document add/remove intents (e.g. the PoA letter). */
|
||||||
|
documentChanges?: DocumentChangeIntent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ schema: "freight", name: "company_change_request" })
|
@Entity({ schema: "freight", name: "company_change_request" })
|
||||||
|
|||||||
@@ -31,17 +31,28 @@ export interface BusinessLicenseFile {
|
|||||||
mimeType?: string;
|
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. */
|
/** A business-license file plus its change-review state, surfaced to clients. */
|
||||||
export interface ProfileLicenseFileView {
|
export interface ProfileLicenseFileView {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
size: number;
|
size: number;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
/**
|
status: StagedFileStatus;
|
||||||
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
|
}
|
||||||
* `pending_remove` — live but flagged for deletion on approval.
|
|
||||||
*/
|
/** A company-level document (e.g. the PoA letter) with its change-review state. */
|
||||||
status: "live" | "pending_add" | "pending_remove";
|
export interface CompanyDocumentFileView {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
status: StagedFileStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ schema: "freight", name: "company_profiles" })
|
@Entity({ schema: "freight", name: "company_profiles" })
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
: ([] as string[]);
|
: ([] as string[]);
|
||||||
const docCount = pending?.documentFileIds?.length ?? 0;
|
const docCount = pending?.documentFileIds?.length ?? 0;
|
||||||
const licenseChanges = pending?.licenseChanges ?? [];
|
const licenseChanges = pending?.licenseChanges ?? [];
|
||||||
|
const documentChanges = pending?.documentChanges ?? [];
|
||||||
|
|
||||||
const confirmReject = () => {
|
const confirmReject = () => {
|
||||||
if (!rejectId) return;
|
if (!rejectId) return;
|
||||||
@@ -210,11 +211,75 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
</Text>
|
</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 && (
|
{docCount > 0 && (
|
||||||
<Text size="sm" c="dimmed">
|
<Stack gap={8}>
|
||||||
{docCount} document{docCount === 1 ? "" : "s"} uploaded with this
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
request — review them in the Documents tab.
|
Documents uploaded with this request
|
||||||
</Text>
|
</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 && (
|
{licenseChanges.length > 0 && (
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) {
|
|||||||
|
|
||||||
/** Matches the fileKey seeded in the API's file-upload-settings seeder. */
|
/** Matches the fileKey seeded in the API's file-upload-settings seeder. */
|
||||||
const POA_DELEGATION_CODE = "poa_delegation_letter";
|
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() {
|
export default function CustomerDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -536,9 +538,15 @@ export default function CustomerDetailPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const poaDocuments = useMemo(
|
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],
|
[documents],
|
||||||
);
|
);
|
||||||
|
const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE);
|
||||||
const poaFields = [
|
const poaFields = [
|
||||||
{ label: "PoA name", value: company?.poaName },
|
{ label: "PoA name", value: company?.poaName },
|
||||||
{ label: "PoA email", value: company?.poaEmail },
|
{ label: "PoA email", value: company?.poaEmail },
|
||||||
@@ -553,7 +561,7 @@ export default function CustomerDetailPage() {
|
|||||||
(p) => p.type === "freight_forwarder",
|
(p) => p.type === "freight_forwarder",
|
||||||
);
|
);
|
||||||
const delegationMissing =
|
const delegationMissing =
|
||||||
(hasPoaDetails || poaMandatory) && poaDocuments.length === 0;
|
(hasPoaDetails || poaMandatory) && poaLive.length === 0;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -713,7 +721,7 @@ export default function CustomerDetailPage() {
|
|||||||
<Badge size="sm" color="red" variant="light">
|
<Badge size="sm" color="red" variant="light">
|
||||||
Delegation letter missing
|
Delegation letter missing
|
||||||
</Badge>
|
</Badge>
|
||||||
) : poaDocuments.length > 0 ? (
|
) : poaLive.length > 0 ? (
|
||||||
<Badge size="sm" color="edr-green" variant="light">
|
<Badge size="sm" color="edr-green" variant="light">
|
||||||
Delegation letter on file
|
Delegation letter on file
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -806,6 +814,16 @@ export default function CustomerDetailPage() {
|
|||||||
{formatBytes(doc.size)} ·{" "}
|
{formatBytes(doc.size)} ·{" "}
|
||||||
{formatDate(doc.uploadedAt)}
|
{formatDate(doc.uploadedAt)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{doc.code === POA_DELEGATION_PENDING_CODE && (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
Pending approval
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ export interface LicenseChangeIntent {
|
|||||||
fileName?: string;
|
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
|
* 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).
|
* (pending) until a reviewer approves (applies them) or rejects (with a note).
|
||||||
@@ -92,6 +101,8 @@ export interface CompanyChangeRequest {
|
|||||||
documentFileIds: string[];
|
documentFileIds: string[];
|
||||||
/** Staged business-license add/remove intents attached to this request. */
|
/** Staged business-license add/remove intents attached to this request. */
|
||||||
licenseChanges: LicenseChangeIntent[];
|
licenseChanges: LicenseChangeIntent[];
|
||||||
|
/** Staged company-document add/remove intents (e.g. the PoA letter). */
|
||||||
|
documentChanges: DocumentChangeIntent[];
|
||||||
note: string | null;
|
note: string | null;
|
||||||
submittedAt: string | null;
|
submittedAt: string | null;
|
||||||
reviewedAt: string | null;
|
reviewedAt: string | null;
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ export const URL_CONSTANTS = {
|
|||||||
`/api/companies/company-profiles/${profileId}/license/${fileId}`,
|
`/api/companies/company-profiles/${profileId}/license/${fileId}`,
|
||||||
PROFILE_LICENSE_REPLACE: (profileId: string, fileId: string) =>
|
PROFILE_LICENSE_REPLACE: (profileId: string, fileId: string) =>
|
||||||
`/api/companies/company-profiles/${profileId}/license/${fileId}/replace`,
|
`/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_CHANGE_REQUEST: "/api/companies/profile/change-request",
|
||||||
PROFILE_REAPPLY: (profileId: string) =>
|
PROFILE_REAPPLY: (profileId: string) =>
|
||||||
`/api/companies/company-profiles/${profileId}/reapply`,
|
`/api/companies/company-profiles/${profileId}/reapply`,
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ function documentSettingCode(nationality: string | null | undefined): string {
|
|||||||
: "company_onboarding_documents_ethiopian";
|
: "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({
|
export default function TabDocuments({
|
||||||
profile,
|
profile,
|
||||||
mode = "edit",
|
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(
|
const docsQuery = useQuery(
|
||||||
api.companies.documents.queryOptions({
|
api.companies.documents.queryOptions({
|
||||||
input: { companyId: profile.companyId },
|
input: { companyId: profile.companyId },
|
||||||
@@ -139,7 +157,7 @@ export default function TabDocuments({
|
|||||||
|
|
||||||
const validateRequired = (): Record<string, string> => {
|
const validateRequired = (): Record<string, string> => {
|
||||||
const errs: 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);
|
const min = getMinFiles(field);
|
||||||
if (min <= 0) continue;
|
if (min <= 0) continue;
|
||||||
if (uploadedKeys.includes(field.fileKey)) continue;
|
if (uploadedKeys.includes(field.fileKey)) continue;
|
||||||
@@ -169,13 +187,13 @@ export default function TabDocuments({
|
|||||||
<Center py="xl">
|
<Center py="xl">
|
||||||
<Loader2 size={24} className="animate-spin" />
|
<Loader2 size={24} className="animate-spin" />
|
||||||
</Center>
|
</Center>
|
||||||
) : !docSettingQuery.data ? (
|
) : !docSetting ? (
|
||||||
<Text c="edr-muted" size="sm" ta="center" py="md">
|
<Text c="edr-muted" size="sm" ta="center" py="md">
|
||||||
No document requirements configured for your account.
|
No document requirements configured for your account.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<SmartFileInput
|
<SmartFileInput
|
||||||
file={docSettingQuery.data}
|
file={docSetting}
|
||||||
value={documentFiles}
|
value={documentFiles}
|
||||||
onChange={handleFilesChange}
|
onChange={handleFilesChange}
|
||||||
errors={fieldErrors}
|
errors={fieldErrors}
|
||||||
@@ -185,7 +203,7 @@ export default function TabDocuments({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{docSettingQuery.data && (
|
{docSetting && (
|
||||||
<Group
|
<Group
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
mt="lg"
|
mt="lg"
|
||||||
|
|||||||
@@ -1,20 +1,44 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { CheckCircle2, Save, UserCheck, XCircle } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
RefreshCw,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Undo2,
|
||||||
|
UploadCloud,
|
||||||
|
UserCheck,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Anchor,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Stack,
|
Stack,
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Button,
|
Tooltip,
|
||||||
Grid,
|
Grid,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useFileViewer, type ViewableFile } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
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 { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
|
|
||||||
@@ -31,6 +55,32 @@ const schema = z.object({
|
|||||||
|
|
||||||
type FormData = z.infer<typeof schema>;
|
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 {
|
interface TabPowerOfAttorneyProps {
|
||||||
profile: ProfileResponse;
|
profile: ProfileResponse;
|
||||||
mode?: "edit" | "onboarding";
|
mode?: "edit" | "onboarding";
|
||||||
@@ -43,6 +93,8 @@ export default function TabPowerOfAttorney({
|
|||||||
onContinue,
|
onContinue,
|
||||||
}: TabPowerOfAttorneyProps) {
|
}: TabPowerOfAttorneyProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const defaultValues = useMemo((): FormData => {
|
const defaultValues = useMemo((): FormData => {
|
||||||
return {
|
return {
|
||||||
@@ -59,22 +111,75 @@ export default function TabPowerOfAttorney({
|
|||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
|
watch,
|
||||||
formState: { errors, isDirty },
|
formState: { errors, isDirty },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
values: defaultValues,
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: FormData) =>
|
mutationFn: async (data: FormData) => {
|
||||||
api.companies.updateProfile.call({
|
// 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,
|
poaName: data.poaName || undefined,
|
||||||
poaPhone: data.poaPhone || undefined,
|
poaPhone: data.poaPhone || undefined,
|
||||||
poaEmail: data.poaEmail || undefined,
|
poaEmail: data.poaEmail || undefined,
|
||||||
poaLocation: data.poaLocation || undefined,
|
poaLocation: data.poaLocation || undefined,
|
||||||
poaAddress: data.poaAddress || undefined,
|
poaAddress: data.poaAddress || undefined,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
setPickedFile(null);
|
||||||
|
setRemoveIds([]);
|
||||||
|
setLetterError(null);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.poaDelegation.queryKey(),
|
||||||
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: api.companies.getProfile.queryKey(),
|
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 (
|
return (
|
||||||
<Card padding="lg">
|
<>
|
||||||
<Group gap="sm" mb="xs">
|
<Card padding="lg">
|
||||||
<UserCheck size={20} />
|
<Group gap="sm" mb="xs">
|
||||||
<Title order={3}>Power of Attorney</Title>
|
<UserCheck size={20} />
|
||||||
</Group>
|
<Title order={3}>Power of Attorney</Title>
|
||||||
<Text c="edr-muted" size="sm" mb="lg">
|
{requirePoa && (
|
||||||
Power of Attorney details are optional. Fill them in if you have an
|
<Badge size="sm" variant="light" color="blue">
|
||||||
authorized representative, or leave blank.
|
Required for freight forwarder
|
||||||
</Text>
|
</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)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Full Name"
|
label="PoA Full Name"
|
||||||
placeholder="Authorized Representative Name"
|
placeholder="Authorized Representative Name"
|
||||||
error={errors.poaName?.message}
|
error={errors.poaName?.message}
|
||||||
{...register("poaName")}
|
{...register("poaName")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Email"
|
label="PoA Email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="poa@company.com"
|
placeholder="poa@company.com"
|
||||||
error={errors.poaEmail?.message}
|
error={errors.poaEmail?.message}
|
||||||
{...register("poaEmail")}
|
{...register("poaEmail")}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<ControlledPhoneField
|
<ControlledPhoneField
|
||||||
control={control}
|
control={control}
|
||||||
name="poaPhone"
|
name="poaPhone"
|
||||||
label="PoA Phone"
|
label="PoA Phone"
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Location"
|
label="PoA Location"
|
||||||
placeholder="City, Country"
|
placeholder="City, Country"
|
||||||
error={errors.poaLocation?.message}
|
error={errors.poaLocation?.message}
|
||||||
{...register("poaLocation")}
|
{...register("poaLocation")}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Address"
|
label="PoA Address"
|
||||||
placeholder="Full Address"
|
placeholder="Full Address"
|
||||||
error={errors.poaAddress?.message}
|
error={errors.poaAddress?.message}
|
||||||
{...register("poaAddress")}
|
{...register("poaAddress")}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group
|
{/* ------------------------ Delegation letter ------------------------ */}
|
||||||
justify="space-between"
|
<Stack gap="sm" mt="xl">
|
||||||
mt="xl"
|
<Group justify="space-between" align="center">
|
||||||
pt="md"
|
<Group gap="sm">
|
||||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
<FileText size={18} />
|
||||||
>
|
<Text fw={600} c="edr-text">
|
||||||
<Group gap="xs">
|
Delegation letter
|
||||||
{mutation.isSuccess && (
|
|
||||||
<Group gap={6} c="green">
|
|
||||||
<CheckCircle2 size={16} />
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
Saved successfully
|
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="light"
|
||||||
disabled={mutation.isPending || !isDirty}
|
size="xs"
|
||||||
onClick={() => reset()}
|
leftSection={
|
||||||
|
hasLetterAfterSave ? (
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
) : (
|
||||||
|
<UploadCloud size={14} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onClick={() => uploadInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
Reset
|
{hasLetterAfterSave ? "Replace letter" : "Upload letter"}
|
||||||
</Button>
|
</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"
|
{letterQuery.isLoading ? (
|
||||||
leftSection={<Save size={16} />}
|
<Group justify="center" py="md">
|
||||||
loading={mutation.isPending}
|
<Loader size="sm" color="edr-green" />
|
||||||
>
|
</Group>
|
||||||
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
) : letters.length === 0 && !pickedFile ? (
|
||||||
</Button>
|
<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>
|
||||||
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import type {
|
|||||||
CompanyInfoResponse,
|
CompanyInfoResponse,
|
||||||
CompanyNationality,
|
CompanyNationality,
|
||||||
CompanyProfileResponse,
|
CompanyProfileResponse,
|
||||||
|
LicenseFile,
|
||||||
CreateCompanyPayload,
|
CreateCompanyPayload,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
OnboardingRequirements,
|
OnboardingRequirements,
|
||||||
@@ -231,6 +232,12 @@ export const api = {
|
|||||||
({ companyId }) => companiesService.getDocuments(companyId),
|
({ companyId }) => companiesService.getDocuments(companyId),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
poaDelegation: endpoint<void, LicenseFile[]>(
|
||||||
|
"companies",
|
||||||
|
"poaDelegation",
|
||||||
|
companiesService.getPoaDelegation,
|
||||||
|
),
|
||||||
|
|
||||||
changeRequest: endpoint<void, ChangeRequestResponse | null>(
|
changeRequest: endpoint<void, ChangeRequestResponse | null>(
|
||||||
"companies",
|
"companies",
|
||||||
"changeRequest",
|
"changeRequest",
|
||||||
|
|||||||
@@ -412,6 +412,37 @@ export const companiesService = {
|
|||||||
return unwrap(response.data);
|
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. */
|
/** List business-license document(s) (with review state) for a company profile. */
|
||||||
getProfileLicense: async (profileId: string): Promise<LicenseFile[]> => {
|
getProfileLicense: async (profileId: string): Promise<LicenseFile[]> => {
|
||||||
const response = await client.get<ApiResponse<LicenseFile[]>>(
|
const response = await client.get<ApiResponse<LicenseFile[]>>(
|
||||||
|
|||||||
Reference in New Issue
Block a user