mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
feat(freight): non-terminal change-request review + unified customer timeline
Backoffice can now "Request changes" on a pending settings change request without rejecting it outright: a new ChangesRequested status keeps the row open so the customer's next edit appends into the same request instead of starting a fresh cycle, and the reviewer's note persists across that round instead of being cleared on resubmit. Version History and Review History (previously two separate, differently-shaped lists) are merged into one chronological timeline under a new History tab, including document changes shown as a real previous-vs-current diff (both files openable). Bug fixes surfaced while wiring this up: - Replacing a single-file document slot left the old file live alongside the new one instead of retiring it (customer settings + onboarding uploads). - The "previous" file in a document diff 404'd once superseded — the preview route now also matches soft-deleted records. - A document replace was recorded twice in the timeline (once at upload, once again at change-request approval).
This commit is contained in:
@@ -731,6 +731,25 @@ export class CompaniesController {
|
|||||||
return new ChangeRequestResponseDto(request);
|
return new ChangeRequestResponseDto(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("change-requests/:id/request-changes")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)",
|
||||||
|
})
|
||||||
|
async requestChangeRequestChanges(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RejectChangeRequestDto,
|
||||||
|
): Promise<ChangeRequestResponseDto> {
|
||||||
|
const request = await this.companiesService.requestChangeRequestChanges(
|
||||||
|
id,
|
||||||
|
dto.note,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
return new ChangeRequestResponseDto(request);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":companyId/profiles")
|
@Post(":companyId/profiles")
|
||||||
@BookingStaff(FREIGHT_PERMS.customers.update)
|
@BookingStaff(FREIGHT_PERMS.customers.update)
|
||||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ import {
|
|||||||
DocumentChangeIntent,
|
DocumentChangeIntent,
|
||||||
LicenseChangeIntent,
|
LicenseChangeIntent,
|
||||||
} from "./entities/company-change-request.entity";
|
} from "./entities/company-change-request.entity";
|
||||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
import {
|
||||||
|
CompanyRevision,
|
||||||
|
CompanyRevisionChange,
|
||||||
|
} from "./entities/company-revision.entity";
|
||||||
|
|
||||||
/** FileRecord `resource` + `code` slots for business-license documents. */
|
/** FileRecord `resource` + `code` slots for business-license documents. */
|
||||||
const LICENSE_RESOURCE = "company_profiles";
|
const LICENSE_RESOURCE = "company_profiles";
|
||||||
@@ -869,9 +872,10 @@ export class CompaniesService {
|
|||||||
before: Company,
|
before: Company,
|
||||||
patch: Record<string, any>,
|
patch: Record<string, any>,
|
||||||
actorId?: string | null,
|
actorId?: string | null,
|
||||||
|
extraChanges: CompanyRevisionChange[] = [],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const changes = diffCompanyUpdate(before, patch);
|
const changes = [...diffCompanyUpdate(before, patch), ...extraChanges];
|
||||||
if (changes.length === 0) return;
|
if (changes.length === 0) return;
|
||||||
await this.revisionRepo.create({
|
await this.revisionRepo.create({
|
||||||
companyId: before.id,
|
companyId: before.id,
|
||||||
@@ -992,10 +996,14 @@ export class CompaniesService {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
request =
|
request =
|
||||||
(await this.changeRequestRepo.update(existing.id, {
|
(await this.changeRequestRepo.update(existing.id, {
|
||||||
|
// Note is left untouched: if this request was ChangesRequested, the
|
||||||
|
// reviewer's ask stays visible on the resubmitted (Pending) row —
|
||||||
|
// clearing it here would hide what was asked for right when the
|
||||||
|
// reviewer comes back to check whether it was actually addressed.
|
||||||
snapshot: { ...(existing.snapshot ?? {}), ...staged },
|
snapshot: { ...(existing.snapshot ?? {}), ...staged },
|
||||||
submittedBy: userId,
|
submittedBy: userId,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
status: ChangeRequestStatus.Pending,
|
||||||
})) ?? existing;
|
})) ?? existing;
|
||||||
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
|
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
|
||||||
} else {
|
} else {
|
||||||
@@ -1039,6 +1047,47 @@ export class CompaniesService {
|
|||||||
return this.revisionRepo.findByCompanyId(companyId);
|
return this.revisionRepo.findByCompanyId(companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair adjacent remove-then-add intents into one before/after revision
|
||||||
|
* change — that's exactly how a "replace" is staged (see
|
||||||
|
* `replaceProfileLicenseFile`: `[{op:'remove',...}, {op:'add',...}]`
|
||||||
|
* pushed together, and later merges only ever append after that pair, so
|
||||||
|
* adjacency is preserved). A remove or add with no adjacent partner (a pure
|
||||||
|
* add, or a pure removal) stands alone.
|
||||||
|
*/
|
||||||
|
private pairReplaceIntents<
|
||||||
|
T extends { op: "add" | "remove"; fileId: string; fileName?: string },
|
||||||
|
>(intents: T[], labelFor: (intent: T) => string): CompanyRevisionChange[] {
|
||||||
|
const changes: CompanyRevisionChange[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < intents.length) {
|
||||||
|
const current = intents[i];
|
||||||
|
const next = intents[i + 1];
|
||||||
|
if (current.op === "remove" && next?.op === "add") {
|
||||||
|
changes.push({
|
||||||
|
field: `document:${current.fileId}`,
|
||||||
|
label: labelFor(next),
|
||||||
|
from: current.fileName ?? null,
|
||||||
|
to: next.fileName ?? null,
|
||||||
|
fromFileId: current.fileId,
|
||||||
|
toFileId: next.fileId,
|
||||||
|
});
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
changes.push({
|
||||||
|
field: `document:${current.fileId}`,
|
||||||
|
label: labelFor(current),
|
||||||
|
from: current.op === "remove" ? (current.fileName ?? null) : null,
|
||||||
|
to: current.op === "add" ? (current.fileName ?? null) : null,
|
||||||
|
fromFileId: current.op === "remove" ? current.fileId : null,
|
||||||
|
toFileId: current.op === "add" ? current.fileId : null,
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Approve a pending change request: apply its snapshot to the live Company and
|
* Approve a pending change request: apply its snapshot to the live Company and
|
||||||
* mark the request approved. Any staged documents are already attached to the
|
* mark the request approved. Any staged documents are already attached to the
|
||||||
@@ -1068,6 +1117,30 @@ export class CompaniesService {
|
|||||||
await this.applyLicenseChanges(request);
|
await this.applyLicenseChanges(request);
|
||||||
await this.applyDocumentChanges(request);
|
await this.applyDocumentChanges(request);
|
||||||
|
|
||||||
|
// This is the ONLY place post-approval FIELD/license/PoA-document changes
|
||||||
|
// land on the live row — without this call, everything the #419
|
||||||
|
// change-request flow does to those is invisible in Version History.
|
||||||
|
// `documentFileIds` (the general bulk company-documents upload) is
|
||||||
|
// deliberately NOT re-recorded here — those documents go live immediately
|
||||||
|
// at upload time and are already recorded there (see
|
||||||
|
// `uploadCompanyDocuments`); redoing it here would double the entry.
|
||||||
|
const documentChanges: CompanyRevisionChange[] = [
|
||||||
|
...this.pairReplaceIntents(
|
||||||
|
request.documents?.licenseChanges ?? [],
|
||||||
|
() => "Business license",
|
||||||
|
),
|
||||||
|
...this.pairReplaceIntents(
|
||||||
|
request.documents?.documentChanges ?? [],
|
||||||
|
(intent) => intent.code,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
await this.recordCompanyRevision(
|
||||||
|
company,
|
||||||
|
companyUpdates,
|
||||||
|
reviewerId,
|
||||||
|
documentChanges,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(await this.changeRequestRepo.update(id, {
|
(await this.changeRequestRepo.update(id, {
|
||||||
status: ChangeRequestStatus.Approved,
|
status: ChangeRequestStatus.Approved,
|
||||||
@@ -1078,12 +1151,62 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fresh upload under a single-file document slot (`isMultiple: false`)
|
||||||
|
* replaces whatever was there, not adds to it — soft-delete the prior live
|
||||||
|
* file(s) for that code, and describe each replacement (plus each genuinely
|
||||||
|
* new upload) as a revision change carrying both file ids, so the reviewer
|
||||||
|
* can open the previous and current file. Multi-file slots are left alone
|
||||||
|
* (genuinely additive, no single "the" document to diff against). Unrecognised
|
||||||
|
* codes (no matching field in the nationality's document setting) are also
|
||||||
|
* left alone — safer to under-clean than to guess wrong. Independent of the
|
||||||
|
* change-request review outcome: nothing else in this flow ever retires a
|
||||||
|
* superseded document, on approve OR reject — these documents go live the
|
||||||
|
* moment they're uploaded.
|
||||||
|
*/
|
||||||
|
private async replaceSingleFileCompanyDocuments(
|
||||||
|
company: Company,
|
||||||
|
before: FileRecord[],
|
||||||
|
uploaded: FileRecord[],
|
||||||
|
): Promise<CompanyRevisionChange[]> {
|
||||||
|
const setting = await this.fileUploadSettingsService
|
||||||
|
.getByCode(this.documentSettingCodeFor(company.nationality))
|
||||||
|
.catch(() => null);
|
||||||
|
const fields = setting?.fields ?? [];
|
||||||
|
const singleFileCodes = new Set(
|
||||||
|
fields.filter((f) => !f.isMultiple).map((f) => f.fileKey),
|
||||||
|
);
|
||||||
|
const labelByCode = new Map(fields.map((f) => [f.fileKey, f.fileLabel]));
|
||||||
|
const uploadedIds = new Set(uploaded.map((f) => f.id));
|
||||||
|
|
||||||
|
const changes: CompanyRevisionChange[] = [];
|
||||||
|
const toRemove: FileRecord[] = [];
|
||||||
|
for (const file of uploaded) {
|
||||||
|
if (!singleFileCodes.has(file.code)) continue;
|
||||||
|
const prior = before.find(
|
||||||
|
(f) => f.code === file.code && !uploadedIds.has(f.id),
|
||||||
|
);
|
||||||
|
changes.push({
|
||||||
|
field: `document:${file.code}`,
|
||||||
|
label: labelByCode.get(file.code) ?? file.code,
|
||||||
|
from: prior?.name ?? null,
|
||||||
|
to: file.name,
|
||||||
|
fromFileId: prior?.id ?? null,
|
||||||
|
toFileId: file.id,
|
||||||
|
});
|
||||||
|
if (prior) toRemove.push(prior);
|
||||||
|
}
|
||||||
|
await Promise.all(toRemove.map((f) => this.filesService.remove(f.id)));
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload company documents. For an approved company this also opens/updates a
|
* Upload company documents. For an approved company this also opens/updates a
|
||||||
* pending change request (recording the uploaded file ids) so the upload is
|
* pending change request (recording the uploaded file ids) so the upload is
|
||||||
* reviewed and the customer is locked until it clears — consistent with the
|
* reviewed and the customer is locked until it clears — consistent with the
|
||||||
* field-edit review. During onboarding (company not yet active) it's a plain
|
* field-edit review. During onboarding (company not yet active) it's a plain
|
||||||
* upload with no review.
|
* upload with no review. Either way the documents go live immediately, so
|
||||||
|
* the revision history is recorded right away too, not gated on a decision.
|
||||||
*/
|
*/
|
||||||
async uploadCompanyDocuments(
|
async uploadCompanyDocuments(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
@@ -1091,11 +1214,20 @@ export class CompaniesService {
|
|||||||
submittedBy?: string,
|
submittedBy?: string,
|
||||||
): Promise<FileRecord[]> {
|
): Promise<FileRecord[]> {
|
||||||
const company = await this.findCompanyById(companyId);
|
const company = await this.findCompanyById(companyId);
|
||||||
|
const before = await this.filesService.findByResource(
|
||||||
|
companyId,
|
||||||
|
"companies",
|
||||||
|
);
|
||||||
const uploaded = await this.filesService.uploadMany(
|
const uploaded = await this.filesService.uploadMany(
|
||||||
companyId,
|
companyId,
|
||||||
"companies",
|
"companies",
|
||||||
files,
|
files,
|
||||||
);
|
);
|
||||||
|
const documentChanges = await this.replaceSingleFileCompanyDocuments(
|
||||||
|
company,
|
||||||
|
before,
|
||||||
|
uploaded,
|
||||||
|
);
|
||||||
await this.resolveDocumentChangeRequests(
|
await this.resolveDocumentChangeRequests(
|
||||||
companyId,
|
companyId,
|
||||||
"companies",
|
"companies",
|
||||||
@@ -1108,14 +1240,9 @@ export class CompaniesService {
|
|||||||
uploaded.map((f) => f.id),
|
uploaded.map((f) => f.id),
|
||||||
submittedBy,
|
submittedBy,
|
||||||
);
|
);
|
||||||
} else if (uploaded.length > 0) {
|
}
|
||||||
await this.recordCompanyRevision(
|
if (documentChanges.length > 0) {
|
||||||
company,
|
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges);
|
||||||
{
|
|
||||||
documents: uploaded.map((f) => f.name).join(", "),
|
|
||||||
},
|
|
||||||
submittedBy,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return uploaded;
|
return uploaded;
|
||||||
}
|
}
|
||||||
@@ -1231,7 +1358,8 @@ export class CompaniesService {
|
|||||||
},
|
},
|
||||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||||
|
status: ChangeRequestStatus.Pending,
|
||||||
});
|
});
|
||||||
if (company) {
|
if (company) {
|
||||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||||
@@ -1292,6 +1420,37 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for specific fixes without rejecting outright: unlike
|
||||||
|
* {@link rejectChangeRequest}, staged license/document intents are kept (the
|
||||||
|
* row stays open), so the customer's next edit is appended to this SAME
|
||||||
|
* request — via the merge branches in `updateProfile`/`stageDocumentChange`/
|
||||||
|
* `stageLicenseChange`/`stageDocumentIntent`/`stageIdentityChange` — instead
|
||||||
|
* of starting a fresh cycle.
|
||||||
|
*/
|
||||||
|
async requestChangeRequestChanges(
|
||||||
|
id: string,
|
||||||
|
note: string,
|
||||||
|
reviewerId?: string,
|
||||||
|
): Promise<CompanyChangeRequest> {
|
||||||
|
const request = await this.changeRequestRepo.findById(id);
|
||||||
|
if (!request)
|
||||||
|
throw new NotFoundException(`Change request ${id} not found`);
|
||||||
|
if (request.status !== ChangeRequestStatus.Pending) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Change request ${id} is already ${request.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(await this.changeRequestRepo.update(id, {
|
||||||
|
status: ChangeRequestStatus.ChangesRequested,
|
||||||
|
note,
|
||||||
|
reviewedBy: reviewerId ?? null,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
})) ?? request
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async deleteCompany(id: string): Promise<void> {
|
async deleteCompany(id: string): Promise<void> {
|
||||||
await this.findCompanyById(id);
|
await this.findCompanyById(id);
|
||||||
await this.companiesRepo.softDelete(id);
|
await this.companiesRepo.softDelete(id);
|
||||||
@@ -2326,7 +2485,8 @@ export class CompaniesService {
|
|||||||
},
|
},
|
||||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||||
|
status: ChangeRequestStatus.Pending,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.changeRequestRepo.create({
|
await this.changeRequestRepo.create({
|
||||||
@@ -2652,7 +2812,8 @@ export class CompaniesService {
|
|||||||
snapshot,
|
snapshot,
|
||||||
submittedBy: userId,
|
submittedBy: userId,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||||
|
status: ChangeRequestStatus.Pending,
|
||||||
});
|
});
|
||||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||||
return;
|
return;
|
||||||
@@ -2932,7 +3093,8 @@ export class CompaniesService {
|
|||||||
},
|
},
|
||||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||||
|
status: ChangeRequestStatus.Pending,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.changeRequestRepo.create({
|
await this.changeRequestRepo.create({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Repository } from "typeorm";
|
import { FindOperator, Repository } from "typeorm";
|
||||||
|
|
||||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,16 @@ type Row = Pick<CompanyChangeRequest, "id" | "status"> & { createdAt: Date };
|
|||||||
|
|
||||||
const COMPANY_ID = "company-1";
|
const COMPANY_ID = "company-1";
|
||||||
|
|
||||||
|
/** Matches a row's status against either a plain value or an `In([...])` operator. */
|
||||||
|
function statusMatches(
|
||||||
|
rowStatus: ChangeRequestStatus,
|
||||||
|
where: ChangeRequestStatus | FindOperator<ChangeRequestStatus> | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (where === undefined) return true;
|
||||||
|
if (where instanceof FindOperator) return where.value.includes(rowStatus);
|
||||||
|
return rowStatus === where;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stands in for the TypeORM repository over a fixed set of rows, honouring the
|
* Stands in for the TypeORM repository over a fixed set of rows, honouring the
|
||||||
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
|
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
|
||||||
@@ -17,13 +27,17 @@ const COMPANY_ID = "company-1";
|
|||||||
function mockRepositoryOver(rows: Row[]) {
|
function mockRepositoryOver(rows: Row[]) {
|
||||||
return {
|
return {
|
||||||
findOne: jest.fn(
|
findOne: jest.fn(
|
||||||
({ where }: { where: Partial<Row> & { companyId: string } }) =>
|
({
|
||||||
|
where,
|
||||||
|
}: {
|
||||||
|
where: { companyId: string; status?: Row["status"] | FindOperator<Row["status"]> };
|
||||||
|
}) =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
rows
|
rows
|
||||||
.filter(
|
.filter(
|
||||||
(row) =>
|
(row) =>
|
||||||
where.companyId === COMPANY_ID &&
|
where.companyId === COMPANY_ID &&
|
||||||
(where.status === undefined || row.status === where.status),
|
statusMatches(row.status, where.status),
|
||||||
)
|
)
|
||||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
|
||||||
null,
|
null,
|
||||||
@@ -57,6 +71,21 @@ describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
|
|||||||
expect(result?.id).toBe("pending");
|
expect(result?.id).toBe("pending");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats a changes-requested request as open, same as pending", async () => {
|
||||||
|
const changesRequested: Row = {
|
||||||
|
id: "changes-requested",
|
||||||
|
status: ChangeRequestStatus.ChangesRequested,
|
||||||
|
createdAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await subject([
|
||||||
|
rejected,
|
||||||
|
changesRequested,
|
||||||
|
]).findLatestOpenByCompanyId(COMPANY_ID);
|
||||||
|
|
||||||
|
expect(result?.id).toBe("changes-requested");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns the latest rejected request when nothing is pending", async () => {
|
it("returns the latest rejected request when nothing is pending", async () => {
|
||||||
const result = await subject([rejected]).findLatestOpenByCompanyId(
|
const result = await subject([rejected]).findLatestOpenByCompanyId(
|
||||||
COMPANY_ID,
|
COMPANY_ID,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { In, Repository } from "typeorm";
|
||||||
import { BaseRepository } from "@edr/api-common";
|
import { BaseRepository } from "@edr/api-common";
|
||||||
import {
|
import {
|
||||||
ChangeRequestStatus,
|
ChangeRequestStatus,
|
||||||
CompanyChangeRequest,
|
CompanyChangeRequest,
|
||||||
} from "./entities/company-change-request.entity";
|
} from "./entities/company-change-request.entity";
|
||||||
|
|
||||||
|
/** Statuses that mean "still open, awaiting the customer's next edit" — Pending and ChangesRequested behave identically here, they just carry a note or not. */
|
||||||
|
const OPEN_FOR_EDIT_STATUSES = [
|
||||||
|
ChangeRequestStatus.Pending,
|
||||||
|
ChangeRequestStatus.ChangesRequested,
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
|
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
|
|||||||
super(repo);
|
super(repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The company's current pending request, if any. */
|
/** The company's current open request (Pending or ChangesRequested), if any — the row the next edit appends to. */
|
||||||
async findPendingByCompanyId(
|
async findPendingByCompanyId(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
): Promise<CompanyChangeRequest | null> {
|
): Promise<CompanyChangeRequest | null> {
|
||||||
return this.repository.findOne({
|
return this.repository.findOne({
|
||||||
where: { companyId, status: ChangeRequestStatus.Pending },
|
where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) },
|
||||||
order: { createdAt: "DESC" },
|
order: { createdAt: "DESC" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ export class CompanyInfoResponseDto {
|
|||||||
/**
|
/**
|
||||||
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
|
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
|
||||||
* settings + new-contract/booking creation disabled) and the reapply banner.
|
* settings + new-contract/booking creation disabled) and the reapply banner.
|
||||||
|
* `changes_requested` is the soft variant of `rejected`: same edit-and-resubmit
|
||||||
|
* call to action, but the customer's edit appends to this SAME request
|
||||||
|
* instead of starting a fresh one.
|
||||||
*/
|
*/
|
||||||
review: {
|
review: {
|
||||||
status: 'pending' | 'rejected';
|
status: 'pending' | 'rejected' | 'changes_requested';
|
||||||
note: string | null;
|
note: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
@@ -30,12 +33,13 @@ export class CompanyInfoResponseDto {
|
|||||||
const open =
|
const open =
|
||||||
changeRequest &&
|
changeRequest &&
|
||||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||||
|
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||||
? changeRequest
|
? changeRequest
|
||||||
: null;
|
: null;
|
||||||
this.review = open
|
this.review = open
|
||||||
? {
|
? {
|
||||||
status: open.status as 'pending' | 'rejected',
|
status: open.status as 'pending' | 'rejected' | 'changes_requested',
|
||||||
note: open.note ?? null,
|
note: open.note ?? null,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -68,10 +68,12 @@ export class ProfileResponseDto {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
||||||
* settings page; `"rejected"` surfaces the note and prefills the (declined)
|
* settings page; `"rejected"`/`"changes_requested"` both surface the note and
|
||||||
* proposed values from `pendingChanges` so the customer can amend & resubmit.
|
* prefill the proposed values from `pendingChanges` so the customer can amend
|
||||||
|
* & resubmit — `"changes_requested"` just appends the edit to this same
|
||||||
|
* request instead of starting a fresh one.
|
||||||
*/
|
*/
|
||||||
reviewStatus: "pending" | "rejected" | null;
|
reviewStatus: "pending" | "rejected" | "changes_requested" | null;
|
||||||
reviewNote: string | null;
|
reviewNote: string | null;
|
||||||
pendingChanges: Record<string, any> | null;
|
pendingChanges: Record<string, any> | null;
|
||||||
|
|
||||||
@@ -127,7 +129,8 @@ export class ProfileResponseDto {
|
|||||||
const openReview =
|
const openReview =
|
||||||
changeRequest &&
|
changeRequest &&
|
||||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||||
|
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||||
? changeRequest
|
? changeRequest
|
||||||
: null;
|
: null;
|
||||||
this.reviewStatus =
|
this.reviewStatus =
|
||||||
@@ -135,7 +138,9 @@ export class ProfileResponseDto {
|
|||||||
? "pending"
|
? "pending"
|
||||||
: openReview?.status === ChangeRequestStatus.Rejected
|
: openReview?.status === ChangeRequestStatus.Rejected
|
||||||
? "rejected"
|
? "rejected"
|
||||||
: null;
|
: openReview?.status === ChangeRequestStatus.ChangesRequested
|
||||||
|
? "changes_requested"
|
||||||
|
: null;
|
||||||
this.reviewNote = openReview?.note ?? null;
|
this.reviewNote = openReview?.note ?? null;
|
||||||
this.pendingChanges = openReview?.snapshot ?? null;
|
this.pendingChanges = openReview?.snapshot ?? null;
|
||||||
this.identity = buildCompanyIdentityState(company);
|
this.identity = buildCompanyIdentityState(company);
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import { Company } from "./company.entity";
|
|||||||
/**
|
/**
|
||||||
* Lifecycle of a customer's proposed profile change. Edits made on the portal
|
* Lifecycle of a customer's proposed profile change. Edits made on the portal
|
||||||
* settings page by an already-approved company are staged here (not written to
|
* settings page by an already-approved company are staged here (not written to
|
||||||
* the live Company row) until a backoffice reviewer approves — at which point
|
* the live Company row) until a backoffice reviewer resolves it:
|
||||||
* the snapshot is applied — or rejects with a note, after which the customer can
|
* - Approved — the snapshot is applied to the live Company row.
|
||||||
* amend and resubmit.
|
* - Rejected — terminal for this row; the customer's next edit starts a fresh one.
|
||||||
|
* - ChangesRequested — soft: the row stays open with the reviewer's note attached,
|
||||||
|
* so the customer's next edit is appended (merged) into this SAME row instead
|
||||||
|
* of starting a new cycle.
|
||||||
*/
|
*/
|
||||||
export enum ChangeRequestStatus {
|
export enum ChangeRequestStatus {
|
||||||
Pending = "pending",
|
Pending = "pending",
|
||||||
Approved = "approved",
|
Approved = "approved",
|
||||||
Rejected = "rejected",
|
Rejected = "rejected",
|
||||||
|
ChangesRequested = "changes_requested",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ import { BaseEntity } from "@edr/api-common";
|
|||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||||
import { Company } from "./company.entity";
|
import { Company } from "./company.entity";
|
||||||
|
|
||||||
/** One recorded field/document change, as shown on the customer's version history. */
|
/**
|
||||||
|
* One recorded field/document change, as shown on the customer's version
|
||||||
|
* history. A document change carries `fromFileId`/`toFileId` alongside the
|
||||||
|
* display names, so the reviewer can open the previous and current file —
|
||||||
|
* not just read that "a document changed."
|
||||||
|
*/
|
||||||
export interface CompanyRevisionChange {
|
export interface CompanyRevisionChange {
|
||||||
field: string;
|
field: string;
|
||||||
label: string;
|
label: string;
|
||||||
from: string | null;
|
from: string | null;
|
||||||
to: string | null;
|
to: string | null;
|
||||||
|
fromFileId?: string | null;
|
||||||
|
toFileId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ export class FilesController {
|
|||||||
@Query("download") download: string | undefined,
|
@Query("download") download: string | undefined,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
const record = await this.filesService.findById(fileId);
|
// Includes soft-deleted records: a superseded document (replaced via a
|
||||||
|
// single-file document slot, or resolved as part of a license/PoA swap)
|
||||||
|
// is only reachable by UUID through the change-request/version-history
|
||||||
|
// diff, where reviewers need to open the "previous" file to compare it.
|
||||||
|
const record = await this.filesService.findByIdIncludingDeleted(fileId);
|
||||||
|
|
||||||
// Chat attachments are cross-tenant sensitive and this route has no
|
// Chat attachments are cross-tenant sensitive and this route has no
|
||||||
// ownership check, so a leaked/guessed UUID would hand one company's file to
|
// ownership check, so a leaked/guessed UUID would hand one company's file to
|
||||||
@@ -63,7 +67,9 @@ export class FilesController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stream } = await this.filesService.streamById(fileId);
|
const { stream } = await this.filesService.streamById(fileId, {
|
||||||
|
includeDeleted: true,
|
||||||
|
});
|
||||||
const forceDownload = download === "1" || download === "true";
|
const forceDownload = download === "1" || download === "true";
|
||||||
const disposition = forceDownload ? "attachment" : "inline";
|
const disposition = forceDownload ? "attachment" : "inline";
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,23 @@ export class FilesService {
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link findById}, but also matches a soft-deleted record — a
|
||||||
|
* superseded document (replaced via a single-file slot, or a resolved
|
||||||
|
* license/PoA swap) is exactly this: gone from every live listing, but its
|
||||||
|
* id is still handed to reviewers in the change-request/version-history
|
||||||
|
* diff so they can open the "previous" file for comparison. Only the
|
||||||
|
* preview/download route should use this; every other caller wants the
|
||||||
|
* default (soft-deleted = not found).
|
||||||
|
*/
|
||||||
|
async findByIdIncludingDeleted(id: string): Promise<FileRecord> {
|
||||||
|
const record = await this.filesRepository.findById(id, {
|
||||||
|
withDeleted: true,
|
||||||
|
});
|
||||||
|
if (!record) throw new NotFoundException(`File ${id} not found`);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a reviewer verdict on one document. `change_requested` keeps the note
|
* Record a reviewer verdict on one document. `change_requested` keeps the note
|
||||||
* (the customer sees it verbatim); any other verdict clears it, so a stale
|
* (the customer sees it verbatim); any other verdict clears it, so a stale
|
||||||
@@ -360,8 +377,11 @@ export class FilesService {
|
|||||||
|
|
||||||
async streamById(
|
async streamById(
|
||||||
id: string,
|
id: string,
|
||||||
|
opts: { includeDeleted?: boolean } = {},
|
||||||
): Promise<{ stream: Readable; record: FileRecord }> {
|
): Promise<{ stream: Readable; record: FileRecord }> {
|
||||||
const record = await this.findById(id);
|
const record = opts.includeDeleted
|
||||||
|
? await this.findByIdIncludingDeleted(id)
|
||||||
|
: await this.findById(id);
|
||||||
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
||||||
const stream = await this.minioService.getFileStream(objectName);
|
const stream = await this.minioService.getFileStream(objectName);
|
||||||
return { stream, record };
|
return { stream, record };
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Anchor,
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
@@ -27,11 +26,11 @@ import { useAuth } from "@/auth/useAuth";
|
|||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { fetchViewableFile } from "@/services/files.service";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
import type { Company } from "@/types/customer";
|
||||||
import { formatDate, humanize } from "./format";
|
import { formatDate, humanize } from "./format";
|
||||||
|
|
||||||
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
|
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
|
||||||
const FIELD_LABELS: Record<string, string> = {
|
export const FIELD_LABELS: Record<string, string> = {
|
||||||
companyName: "Company name",
|
companyName: "Company name",
|
||||||
companyEmail: "Company email",
|
companyEmail: "Company email",
|
||||||
companyPhone: "Company phone",
|
companyPhone: "Company phone",
|
||||||
@@ -69,7 +68,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Best-effort current value on the live company for a proposed field key. */
|
/** Best-effort current value on the live company for a proposed field key. */
|
||||||
function currentValue(company: Company, key: string): string {
|
export function currentValue(company: Company, key: string): string {
|
||||||
const c = company as unknown as Record<string, unknown>;
|
const c = company as unknown as Record<string, unknown>;
|
||||||
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
|
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
|
||||||
const map: Record<string, unknown> = {
|
const map: Record<string, unknown> = {
|
||||||
@@ -160,7 +159,7 @@ function FaydaIdentityDiff({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiffRow({
|
export function DiffRow({
|
||||||
label,
|
label,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
@@ -201,8 +200,9 @@ function DiffRow({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Backoffice review surface for a customer's staged profile edits. Shows the
|
* Backoffice review surface for a customer's staged profile edits. Shows the
|
||||||
* pending change request as a proposed-vs-current diff with Approve / Reject
|
* pending change request as a proposed-vs-current diff with Approve / Reject /
|
||||||
* (with note) actions, plus a short history of past decisions.
|
* Request changes actions. Past decisions live in the History tab's unified
|
||||||
|
* timeline (see {@link CompanyTimeline}), not here.
|
||||||
*/
|
*/
|
||||||
export function ChangeRequestReview({ company }: { company: Company }) {
|
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -216,16 +216,21 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
const reject = useMutation(
|
const reject = useMutation(
|
||||||
api.customers.rejectChangeRequest.mutationOptions(),
|
api.customers.rejectChangeRequest.mutationOptions(),
|
||||||
);
|
);
|
||||||
|
const requestChanges = useMutation(
|
||||||
|
api.customers.requestChangeRequestChanges.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
const [rejectId, setRejectId] = useState<string | null>(null);
|
const [actionTarget, setActionTarget] = useState<{
|
||||||
|
id: string;
|
||||||
|
kind: "reject" | "request-changes";
|
||||||
|
} | null>(null);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
const requests = query.data ?? [];
|
const requests = query.data ?? [];
|
||||||
const pending = requests.find((r) => r.status === "pending");
|
const pending = requests.find((r) => r.status === "pending");
|
||||||
const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
|
|
||||||
|
|
||||||
if (!pending && history.length === 0) return null;
|
if (!pending) return null;
|
||||||
|
|
||||||
const proposedKeys = pending
|
const proposedKeys = pending
|
||||||
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
|
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
|
||||||
@@ -237,13 +242,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
const licenseChanges = pending?.licenseChanges ?? [];
|
const licenseChanges = pending?.licenseChanges ?? [];
|
||||||
const documentChanges = pending?.documentChanges ?? [];
|
const documentChanges = pending?.documentChanges ?? [];
|
||||||
|
|
||||||
const confirmReject = () => {
|
const confirmAction = () => {
|
||||||
if (!rejectId) return;
|
if (!actionTarget) return;
|
||||||
reject.mutate(
|
const mutation = actionTarget.kind === "reject" ? reject : requestChanges;
|
||||||
{ id: rejectId, note: note.trim() },
|
mutation.mutate(
|
||||||
|
{ id: actionTarget.id, note: note.trim() },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setRejectId(null);
|
setActionTarget(null);
|
||||||
setNote("");
|
setNote("");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -270,6 +276,18 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{pending.note && (
|
||||||
|
<Alert
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
icon={<AlertTriangle size={16} />}
|
||||||
|
>
|
||||||
|
Changes were requested on an earlier round of this same
|
||||||
|
submission: <strong>{pending.note}</strong> — check whether
|
||||||
|
this resubmission actually addresses it before approving.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{proposedKeys.length > 0 ? (
|
{proposedKeys.length > 0 ? (
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||||
{proposedKeys.map((key) => (
|
{proposedKeys.map((key) => (
|
||||||
@@ -418,12 +436,22 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
variant="light"
|
variant="light"
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRejectId(pending.id);
|
setActionTarget({ id: pending.id, kind: "reject" });
|
||||||
setNote("");
|
setNote("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
onClick={() => {
|
||||||
|
setActionTarget({ id: pending.id, kind: "request-changes" });
|
||||||
|
setNote("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Request changes
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
loading={approve.isPending}
|
loading={approve.isPending}
|
||||||
@@ -437,51 +465,31 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{history.length > 0 && (
|
|
||||||
<Card withBorder>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text fw={600} c="edr-text">
|
|
||||||
Review history
|
|
||||||
</Text>
|
|
||||||
{history.map((r: CompanyChangeRequest) => (
|
|
||||||
<Group key={r.id} gap="sm" wrap="nowrap" align="flex-start">
|
|
||||||
<Badge
|
|
||||||
color={r.status === "approved" ? "edr-green" : "red"}
|
|
||||||
variant="light"
|
|
||||||
radius="md"
|
|
||||||
tt="capitalize"
|
|
||||||
>
|
|
||||||
{r.status}
|
|
||||||
</Badge>
|
|
||||||
<Box style={{ flex: 1 }}>
|
|
||||||
<Text size="sm" c="edr-text">
|
|
||||||
{formatDate(r.reviewedAt ?? r.updatedAt)}
|
|
||||||
</Text>
|
|
||||||
{r.note && (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Note: {r.note}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={rejectId !== null}
|
opened={actionTarget !== null}
|
||||||
onClose={() => setRejectId(null)}
|
onClose={() => setActionTarget(null)}
|
||||||
title="Reject changes"
|
title={
|
||||||
|
actionTarget?.kind === "reject" ? "Reject changes" : "Request changes"
|
||||||
|
}
|
||||||
centered
|
centered
|
||||||
radius="lg"
|
radius="lg"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Alert color="red" variant="light" icon={<AlertTriangle size={18} />}>
|
<Alert
|
||||||
The customer will see this note and can amend and resubmit.
|
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
||||||
|
variant="light"
|
||||||
|
icon={<AlertTriangle size={18} />}
|
||||||
|
>
|
||||||
|
{actionTarget?.kind === "reject"
|
||||||
|
? "The customer will see this note and can amend and resubmit."
|
||||||
|
: "The customer will see this note and can keep editing this same request — no need to start over."}
|
||||||
</Alert>
|
</Alert>
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Reason for rejection"
|
label={
|
||||||
|
actionTarget?.kind === "reject"
|
||||||
|
? "Reason for rejection"
|
||||||
|
: "What needs to change"
|
||||||
|
}
|
||||||
placeholder="e.g. The company address doesn't match the trade license."
|
placeholder="e.g. The company address doesn't match the trade license."
|
||||||
autosize
|
autosize
|
||||||
minRows={3}
|
minRows={3}
|
||||||
@@ -492,18 +500,20 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => setRejectId(null)}
|
onClick={() => setActionTarget(null)}
|
||||||
disabled={reject.isPending}
|
disabled={reject.isPending || requestChanges.isPending}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
||||||
loading={reject.isPending}
|
loading={reject.isPending || requestChanges.isPending}
|
||||||
disabled={note.trim().length === 0}
|
disabled={note.trim().length === 0}
|
||||||
onClick={confirmReject}
|
onClick={confirmAction}
|
||||||
>
|
>
|
||||||
Reject changes
|
{actionTarget?.kind === "reject"
|
||||||
|
? "Reject changes"
|
||||||
|
: "Request changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Badge, Box, Card, Group, Stack, Text } from "@mantine/core";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { History } from "lucide-react";
|
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import { formatDate } from "./format";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Onboarding-phase edit history: what changed on the company record before it
|
|
||||||
* reached Active, the write path that has no approval gate (unlike edits made
|
|
||||||
* after approval, which go through {@link ChangeRequestReview} instead).
|
|
||||||
*/
|
|
||||||
export function CompanyRevisionHistory({ companyId }: { companyId: string }) {
|
|
||||||
const query = useQuery(
|
|
||||||
api.customers.revisions.queryOptions({ input: { id: companyId } }),
|
|
||||||
);
|
|
||||||
const revisions = query.data ?? [];
|
|
||||||
if (revisions.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card withBorder>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Group gap="xs">
|
|
||||||
<History size={16} />
|
|
||||||
<Text fw={600} c="edr-text">
|
|
||||||
Version history
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{revisions.map((rev) => (
|
|
||||||
<Box key={rev.id}>
|
|
||||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
|
||||||
<Badge color="gray" variant="light" radius="md" tt="none">
|
|
||||||
{formatDate(rev.createdAt)}
|
|
||||||
</Badge>
|
|
||||||
<Text size="sm" c="edr-text" tt="capitalize">
|
|
||||||
{rev.summary}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{rev.changes.length > 0 && (
|
|
||||||
<Stack gap={2} ml={4} mt={4}>
|
|
||||||
{rev.changes.map((c, i) => (
|
|
||||||
<Text key={i} size="xs" c="dimmed">
|
|
||||||
{c.label}: {c.from ?? "—"} → {c.to ?? "—"}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { FilePlus2, FileX2, History } from "lucide-react";
|
||||||
|
import { useFileViewer } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type {
|
||||||
|
Company,
|
||||||
|
CompanyChangeRequest,
|
||||||
|
CompanyRevision,
|
||||||
|
CompanyRevisionChange,
|
||||||
|
DocumentChangeIntent,
|
||||||
|
LicenseChangeIntent,
|
||||||
|
} from "@/types/customer";
|
||||||
|
import { DiffRow, FIELD_LABELS, currentValue } from "./ChangeRequestReview";
|
||||||
|
import { formatDate, humanize } from "./format";
|
||||||
|
|
||||||
|
interface DocDiff {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
fromFile: { id: string; name: string } | null;
|
||||||
|
toFile: { id: string; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldDiff {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineEntry {
|
||||||
|
id: string;
|
||||||
|
kind: "approved" | "rejected" | "changes_requested" | "revision";
|
||||||
|
at: string;
|
||||||
|
note?: string | null;
|
||||||
|
summary?: string;
|
||||||
|
fieldDiffs: FieldDiff[];
|
||||||
|
docDiffs: DocDiff[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_BADGE: Record<TimelineEntry["kind"], { label: string; color: string }> = {
|
||||||
|
approved: { label: "Approved", color: "edr-green" },
|
||||||
|
rejected: { label: "Rejected", color: "red" },
|
||||||
|
changes_requested: { label: "Changes requested", color: "yellow" },
|
||||||
|
revision: { label: "Recorded", color: "blue" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair adjacent remove-then-add intents into one before/after doc diff — a
|
||||||
|
* "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together
|
||||||
|
* (see `replaceProfileLicenseFile` and friends), and later merges only ever
|
||||||
|
* append after that pair, so adjacency survives. A remove or add with no
|
||||||
|
* adjacent partner stands alone.
|
||||||
|
*/
|
||||||
|
function pairIntents(
|
||||||
|
intents: (LicenseChangeIntent | DocumentChangeIntent)[],
|
||||||
|
labelFor: (intent: LicenseChangeIntent | DocumentChangeIntent) => string,
|
||||||
|
): DocDiff[] {
|
||||||
|
const diffs: DocDiff[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < intents.length) {
|
||||||
|
const current = intents[i];
|
||||||
|
const next = intents[i + 1];
|
||||||
|
if (current.op === "remove" && next?.op === "add") {
|
||||||
|
diffs.push({
|
||||||
|
key: `${current.fileId}-${next.fileId}`,
|
||||||
|
label: labelFor(next),
|
||||||
|
fromFile: { id: current.fileId, name: current.fileName ?? "Document" },
|
||||||
|
toFile: { id: next.fileId, name: next.fileName ?? "Document" },
|
||||||
|
});
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
diffs.push({
|
||||||
|
key: `${current.fileId}-${i}`,
|
||||||
|
label: labelFor(current),
|
||||||
|
fromFile:
|
||||||
|
current.op === "remove"
|
||||||
|
? { id: current.fileId, name: current.fileName ?? "Document" }
|
||||||
|
: null,
|
||||||
|
toFile:
|
||||||
|
current.op === "add"
|
||||||
|
? { id: current.fileId, name: current.fileName ?? "Document" }
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return diffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Historical field diffs on a change request only ever recorded the proposed
|
||||||
|
* ("to") value — there is no stored "before" snapshot — so `from` reads the
|
||||||
|
* CURRENT company value. That's exact for the most recent entry; for an older
|
||||||
|
* one it can drift if the field changed again since. A real limitation of the
|
||||||
|
* data model, not something this view can reconstruct.
|
||||||
|
*/
|
||||||
|
function fromChangeRequest(
|
||||||
|
r: CompanyChangeRequest,
|
||||||
|
company: Company,
|
||||||
|
): TimelineEntry {
|
||||||
|
const proposedKeys = Object.keys(r.snapshot ?? {}).filter(
|
||||||
|
(k) => k !== "faydaIdentity",
|
||||||
|
);
|
||||||
|
const fieldDiffs: FieldDiff[] = proposedKeys.map((key) => ({
|
||||||
|
key,
|
||||||
|
label: FIELD_LABELS[key] ?? humanize(key),
|
||||||
|
from: currentValue(company, key),
|
||||||
|
to:
|
||||||
|
r.snapshot[key] === null || r.snapshot[key] === undefined || r.snapshot[key] === ""
|
||||||
|
? "—"
|
||||||
|
: String(r.snapshot[key]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const docDiffs: DocDiff[] = [
|
||||||
|
...pairIntents(r.licenseChanges, () => "Business license"),
|
||||||
|
...pairIntents(r.documentChanges, (c) =>
|
||||||
|
humanize((c as DocumentChangeIntent).code),
|
||||||
|
),
|
||||||
|
...r.documentFileIds.map((fileId, i) => ({
|
||||||
|
key: fileId,
|
||||||
|
label: "Document",
|
||||||
|
fromFile: null,
|
||||||
|
toFile: { id: fileId, name: `Document ${i + 1}` },
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
kind: r.status as TimelineEntry["kind"],
|
||||||
|
at: r.reviewedAt ?? r.updatedAt,
|
||||||
|
note: r.note,
|
||||||
|
fieldDiffs,
|
||||||
|
docDiffs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRevision(rev: CompanyRevision): TimelineEntry {
|
||||||
|
const isDocChange = (c: CompanyRevisionChange) => c.field.startsWith("document:");
|
||||||
|
const fieldDiffs: FieldDiff[] = rev.changes
|
||||||
|
.filter((c) => !isDocChange(c))
|
||||||
|
.map((c) => ({ key: c.field, label: c.label, from: c.from ?? "—", to: c.to ?? "—" }));
|
||||||
|
const docDiffs: DocDiff[] = rev.changes
|
||||||
|
.filter(isDocChange)
|
||||||
|
.map((c) => ({
|
||||||
|
key: c.field,
|
||||||
|
label: c.label,
|
||||||
|
fromFile: c.fromFileId ? { id: c.fromFileId, name: c.from ?? "Document" } : null,
|
||||||
|
toFile: c.toFileId ? { id: c.toFileId, name: c.to ?? "Document" } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: rev.id,
|
||||||
|
kind: "revision",
|
||||||
|
at: rev.createdAt,
|
||||||
|
summary: rev.summary,
|
||||||
|
fieldDiffs,
|
||||||
|
docDiffs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One combined, chronological timeline of everything that's happened to a
|
||||||
|
* company's record: onboarding-phase edits (no approval gate, from
|
||||||
|
* `CompanyRevision`) and post-approval settings changes (reviewed via
|
||||||
|
* `CompanyChangeRequest`) used to live in two separate, differently-shaped
|
||||||
|
* lists — merged here into one sorted feed so "what changed and when" has a
|
||||||
|
* single answer instead of two places to check.
|
||||||
|
*/
|
||||||
|
export function CompanyTimeline({ company }: { company: Company }) {
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
const changeRequestsQuery = useQuery(
|
||||||
|
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
||||||
|
);
|
||||||
|
const revisionsQuery = useQuery(
|
||||||
|
api.customers.revisions.queryOptions({ input: { id: company.id } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const entries: TimelineEntry[] = [
|
||||||
|
...(changeRequestsQuery.data ?? [])
|
||||||
|
.filter((r) => r.status !== "pending")
|
||||||
|
.map((r) => fromChangeRequest(r, company)),
|
||||||
|
...(revisionsQuery.data ?? []).map(fromRevision),
|
||||||
|
].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
|
||||||
|
|
||||||
|
const openFile = (file: { id: string; name: string }) =>
|
||||||
|
void fetchViewableFile(file.id, file.name).then(view);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card withBorder>
|
||||||
|
<Stack align="center" gap={6} py="xl">
|
||||||
|
<History size={24} className="text-edr-muted" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No changes recorded yet.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const badge = KIND_BADGE[entry.kind];
|
||||||
|
return (
|
||||||
|
<Card key={entry.id} withBorder>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Badge color={badge.color} variant="light" radius="md">
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
{entry.summary && (
|
||||||
|
<Text size="sm" c="edr-text" tt="capitalize">
|
||||||
|
{entry.summary}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatDate(entry.at)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{entry.note && (
|
||||||
|
<Alert color="yellow" variant="light">
|
||||||
|
<Text size="sm">
|
||||||
|
<strong>Note:</strong> {entry.note}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry.fieldDiffs.length > 0 && (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||||
|
{entry.fieldDiffs.map((f) => (
|
||||||
|
<DiffRow key={f.key} label={f.label} from={f.from} to={f.to} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry.docDiffs.length > 0 && (
|
||||||
|
<Stack gap={8}>
|
||||||
|
{entry.docDiffs.map((d) => (
|
||||||
|
<Group key={d.key} gap={8} wrap="nowrap">
|
||||||
|
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
|
||||||
|
{d.label}
|
||||||
|
</Text>
|
||||||
|
{d.fromFile && (
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<FileX2 size={14} className="text-edr-muted" />
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
td="line-through"
|
||||||
|
onClick={() => openFile(d.fromFile!)}
|
||||||
|
>
|
||||||
|
{d.fromFile.name}
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{d.fromFile && d.toFile && (
|
||||||
|
<Text size="sm" c="edr-muted">
|
||||||
|
→
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{d.toFile && (
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<FilePlus2 size={14} className="text-edr-muted" />
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openFile(d.toFile!)}
|
||||||
|
>
|
||||||
|
{d.toFile.name}
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry.fieldDiffs.length === 0 && entry.docDiffs.length === 0 && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No details recorded for this entry.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{viewer}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export {
|
|||||||
ChangeRequestReview,
|
ChangeRequestReview,
|
||||||
ChangeRequestPendingBadge,
|
ChangeRequestPendingBadge,
|
||||||
} from "./ChangeRequestReview";
|
} from "./ChangeRequestReview";
|
||||||
export { CompanyRevisionHistory } from "./CompanyRevisionHistory";
|
export { CompanyTimeline } from "./CompanyTimeline";
|
||||||
export {
|
export {
|
||||||
RequestDocumentChangeModal,
|
RequestDocumentChangeModal,
|
||||||
type RequestDocumentChangeModalProps,
|
type RequestDocumentChangeModalProps,
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/companies/change-requests/${id}/approve`,
|
`/companies/change-requests/${id}/approve`,
|
||||||
CHANGE_REQUEST_REJECT: (id: string) =>
|
CHANGE_REQUEST_REJECT: (id: string) =>
|
||||||
`/companies/change-requests/${id}/reject`,
|
`/companies/change-requests/${id}/reject`,
|
||||||
|
CHANGE_REQUEST_REQUEST_CHANGES: (id: string) =>
|
||||||
|
`/companies/change-requests/${id}/request-changes`,
|
||||||
DOCUMENT_REQUEST_CHANGE: (fileId: string) =>
|
DOCUMENT_REQUEST_CHANGE: (fileId: string) =>
|
||||||
`/companies/documents/${fileId}/request-change`,
|
`/companies/documents/${fileId}/request-change`,
|
||||||
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
|
History,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
IdCard,
|
IdCard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
@@ -39,8 +40,8 @@ import {
|
|||||||
BookingStatusBadge,
|
BookingStatusBadge,
|
||||||
ChangeRequestPendingBadge,
|
ChangeRequestPendingBadge,
|
||||||
ChangeRequestReview,
|
ChangeRequestReview,
|
||||||
CompanyRevisionHistory,
|
|
||||||
CompanyStatusBadge,
|
CompanyStatusBadge,
|
||||||
|
CompanyTimeline,
|
||||||
CompanyTypeBadge,
|
CompanyTypeBadge,
|
||||||
InvoiceStatusBadge,
|
InvoiceStatusBadge,
|
||||||
PaymentStatusBadge,
|
PaymentStatusBadge,
|
||||||
@@ -717,6 +718,9 @@ export default function CustomerDetailPage() {
|
|||||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||||
Invoices
|
Invoices
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="history" leftSection={<History size={16} />}>
|
||||||
|
History
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
{/* OVERVIEW */}
|
{/* OVERVIEW */}
|
||||||
@@ -738,7 +742,6 @@ export default function CustomerDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ChangeRequestReview company={company} />
|
<ChangeRequestReview company={company} />
|
||||||
<CompanyRevisionHistory companyId={company.id} />
|
|
||||||
|
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
items={[
|
items={[
|
||||||
@@ -1287,6 +1290,11 @@ export default function CustomerDetailPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* HISTORY */}
|
||||||
|
<Tabs.Panel value="history" pt="lg">
|
||||||
|
<CompanyTimeline company={company} />
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<RequestDocumentChangeModal
|
<RequestDocumentChangeModal
|
||||||
|
|||||||
@@ -2810,6 +2810,21 @@ export const api = {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
requestChangeRequestChanges: endpoint<
|
||||||
|
{ id: string; note: string },
|
||||||
|
CompanyChangeRequest
|
||||||
|
>(
|
||||||
|
"customers",
|
||||||
|
"requestChangeRequestChanges",
|
||||||
|
({ id, note }) => customersService.requestChangeRequestChanges(id, note),
|
||||||
|
undefined,
|
||||||
|
(_input, data) => [
|
||||||
|
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
|
||||||
|
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||||
|
QUERY_KEYS.CUSTOMERS.ROOT,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask the customer to correct one document. Invalidates the documents list
|
* Ask the customer to correct one document. Invalidates the documents list
|
||||||
* and the company itself, since an open request blocks role approval.
|
* and the company itself, since an open request blocks role approval.
|
||||||
|
|||||||
@@ -173,6 +173,19 @@ export const customersService = {
|
|||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Ask for specific changes without rejecting — the request stays open for the customer's next edit to append to. */
|
||||||
|
requestChangeRequestChanges(
|
||||||
|
id: string,
|
||||||
|
note: string,
|
||||||
|
): Promise<CompanyChangeRequest> {
|
||||||
|
return apiClient
|
||||||
|
.post<CompanyChangeRequest>(
|
||||||
|
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REQUEST_CHANGES(id),
|
||||||
|
{ note },
|
||||||
|
)
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask the customer to correct one uploaded document. Narrower than rejecting
|
* Ask the customer to correct one uploaded document. Narrower than rejecting
|
||||||
* the whole role: the customer keeps their other documents and only re-uploads
|
* the whole role: the customer keeps their other documents and only re-uploads
|
||||||
|
|||||||
@@ -69,7 +69,11 @@ export interface CompanyProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Lifecycle of a staged customer profile-edit review. */
|
/** Lifecycle of a staged customer profile-edit review. */
|
||||||
export type ChangeRequestStatus = "pending" | "approved" | "rejected";
|
export type ChangeRequestStatus =
|
||||||
|
| "pending"
|
||||||
|
| "approved"
|
||||||
|
| "rejected"
|
||||||
|
| "changes_requested";
|
||||||
|
|
||||||
/** A staged business-license add/remove on one profile, awaiting review. */
|
/** A staged business-license add/remove on one profile, awaiting review. */
|
||||||
export interface LicenseChangeIntent {
|
export interface LicenseChangeIntent {
|
||||||
@@ -110,12 +114,18 @@ export interface CompanyChangeRequest {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One field/document change recorded on a company revision. */
|
/**
|
||||||
|
* One field/document change recorded on a company revision. A document change
|
||||||
|
* carries `fromFileId`/`toFileId` alongside the display names, so the
|
||||||
|
* previous and current file can both be opened, not just named.
|
||||||
|
*/
|
||||||
export interface CompanyRevisionChange {
|
export interface CompanyRevisionChange {
|
||||||
field: string;
|
field: string;
|
||||||
label: string;
|
label: string;
|
||||||
from: string | null;
|
from: string | null;
|
||||||
to: string | null;
|
to: string | null;
|
||||||
|
fromFileId?: string | null;
|
||||||
|
toFileId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -150,10 +150,15 @@ export default function OnboardingResumeBanner({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Post-onboarding review banner. Surfaces (in priority order):
|
* Post-onboarding review banner. Surfaces (in priority order):
|
||||||
|
* 0. The account is suspended/blacklisted — hard lock.
|
||||||
* 1. A pending profile-edit review — the whole account is locked until an admin
|
* 1. A pending profile-edit review — the whole account is locked until an admin
|
||||||
* approves the submitted changes.
|
* approves the submitted changes.
|
||||||
* 2. A rejected profile-edit review — links to Settings to amend & resubmit.
|
* 2. Backoffice requested changes — soft: same edit-and-resubmit call to
|
||||||
* 3. Per-operational-profile approval — bookings unlock as each role clears.
|
* action as a rejection, but the edit appends to the same request.
|
||||||
|
* 3. A rejected profile-edit review — links to Settings to amend & resubmit
|
||||||
|
* (starts a fresh request).
|
||||||
|
* 4/5. Company- and per-operational-profile approval — bookings unlock as
|
||||||
|
* each role clears.
|
||||||
* Self-hides when there's nothing outstanding.
|
* Self-hides when there's nothing outstanding.
|
||||||
*/
|
*/
|
||||||
export function AccountReviewBanner() {
|
export function AccountReviewBanner() {
|
||||||
@@ -207,7 +212,41 @@ export function AccountReviewBanner() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Profile-edit review rejected — prompt to fix & resubmit.
|
// 2. Backoffice asked for specific changes — soft: same edit-and-resubmit
|
||||||
|
// call to action as a rejection, but the copy stays collaborative since
|
||||||
|
// the edit appends to this same request instead of starting over.
|
||||||
|
if (reviewStatus === "changes_requested") {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
|
||||||
|
<AlertTriangle size={18} />
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-semibold text-amber-900">
|
||||||
|
Changes requested on your submission
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-amber-800">
|
||||||
|
{reviewNote
|
||||||
|
? `Reviewer note: ${reviewNote}`
|
||||||
|
: "Please update the requested details and resubmit for review."}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-transform hover:scale-[1.02]"
|
||||||
|
>
|
||||||
|
Review & resubmit
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Profile-edit review rejected — prompt to fix & resubmit.
|
||||||
if (reviewStatus === "rejected") {
|
if (reviewStatus === "rejected") {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-red-200 bg-red-50 px-6 py-3">
|
<div className="border-b border-red-200 bg-red-50 px-6 py-3">
|
||||||
@@ -239,7 +278,7 @@ export function AccountReviewBanner() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Company approved and awaiting its first operational profile — nothing
|
// 4. Company approved and awaiting its first operational profile — nothing
|
||||||
// profile-specific to report yet, but the account itself is pending.
|
// profile-specific to report yet, but the account itself is pending.
|
||||||
if (profiles.length === 0) {
|
if (profiles.length === 0) {
|
||||||
if (companyStatus === "pending") {
|
if (companyStatus === "pending") {
|
||||||
@@ -259,7 +298,7 @@ export function AccountReviewBanner() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Per-operational-profile approval (existing behaviour).
|
// 5. Per-operational-profile approval (existing behaviour).
|
||||||
if (pending.length === 0) {
|
if (pending.length === 0) {
|
||||||
// Nothing outstanding — a quiet confirmation that the account is live.
|
// Nothing outstanding — a quiet confirmation that the account is live.
|
||||||
if (companyStatus === "active") {
|
if (companyStatus === "active") {
|
||||||
|
|||||||
@@ -294,6 +294,26 @@ export default function SettingsPage() {
|
|||||||
Attorney stay editable.
|
Attorney stay editable.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
{reviewStatus === "changes_requested" && (
|
||||||
|
<Alert
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
icon={<AlertTriangle size={18} />}
|
||||||
|
title="Changes requested on your submission"
|
||||||
|
>
|
||||||
|
<Stack gap={4}>
|
||||||
|
{profile.reviewNote && (
|
||||||
|
<Text size="sm">
|
||||||
|
<strong>Reviewer note:</strong> {profile.reviewNote}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="sm">
|
||||||
|
Please update the requested details below and save again to
|
||||||
|
resubmit for review.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
{reviewStatus === "rejected" && (
|
{reviewStatus === "rejected" && (
|
||||||
<Alert
|
<Alert
|
||||||
color="red"
|
color="red"
|
||||||
|
|||||||
@@ -94,10 +94,12 @@ export interface CompanyInfoResponse {
|
|||||||
company: CompanyResponse;
|
company: CompanyResponse;
|
||||||
/**
|
/**
|
||||||
* Open profile-edit review, if any. `pending` locks the settings page + new
|
* Open profile-edit review, if any. `pending` locks the settings page + new
|
||||||
* contract/booking creation; `rejected` surfaces the note for reapply.
|
* contract/booking creation; `rejected`/`changes_requested` both surface the
|
||||||
|
* note for reapply — `changes_requested` just means the edit appends to the
|
||||||
|
* same request instead of starting a fresh one.
|
||||||
*/
|
*/
|
||||||
review?: {
|
review?: {
|
||||||
status: "pending" | "rejected";
|
status: "pending" | "rejected" | "changes_requested";
|
||||||
note: string | null;
|
note: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
@@ -106,7 +108,7 @@ export interface CompanyInfoResponse {
|
|||||||
export interface ChangeRequestResponse {
|
export interface ChangeRequestResponse {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
status: "pending" | "approved" | "rejected";
|
status: "pending" | "approved" | "rejected" | "changes_requested";
|
||||||
snapshot: Record<string, any>;
|
snapshot: Record<string, any>;
|
||||||
documentFileIds: string[];
|
documentFileIds: string[];
|
||||||
note: string | null;
|
note: string | null;
|
||||||
|
|||||||
@@ -51,10 +51,12 @@ export interface ProfileResponse {
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
/**
|
/**
|
||||||
* Open profile-edit review. `pending` → the settings page is read-only until an
|
* Open profile-edit review. `pending` → the settings page is read-only until an
|
||||||
* admin decides; `rejected` → the note explains why and the forms prefill the
|
* admin decides; `rejected`/`changes_requested` → the note explains why and
|
||||||
* declined values so the customer can amend & resubmit.
|
* the forms prefill the declined values so the customer can amend & resubmit
|
||||||
|
* (`changes_requested` appends that edit to this same request instead of
|
||||||
|
* starting a fresh one).
|
||||||
*/
|
*/
|
||||||
reviewStatus?: "pending" | "rejected" | null;
|
reviewStatus?: "pending" | "rejected" | "changes_requested" | null;
|
||||||
reviewNote?: string | null;
|
reviewNote?: string | null;
|
||||||
pendingChanges?: Record<string, any> | null;
|
pendingChanges?: Record<string, any> | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user