diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index fdfd4e344..7665d82eb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -731,6 +731,25 @@ export class CompaniesController { 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 { + const request = await this.companiesService.requestChangeRequestChanges( + id, + dto.note, + user.id, + ); + return new ChangeRequestResponseDto(request); + } + @Post(":companyId/profiles") @BookingStaff(FREIGHT_PERMS.customers.update) @ApiOperation({ summary: "Add a profile (employee) to a company" }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 5dae44f2b..d9798af5d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -70,7 +70,10 @@ import { DocumentChangeIntent, LicenseChangeIntent, } 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. */ const LICENSE_RESOURCE = "company_profiles"; @@ -869,9 +872,10 @@ export class CompaniesService { before: Company, patch: Record, actorId?: string | null, + extraChanges: CompanyRevisionChange[] = [], ): Promise { try { - const changes = diffCompanyUpdate(before, patch); + const changes = [...diffCompanyUpdate(before, patch), ...extraChanges]; if (changes.length === 0) return; await this.revisionRepo.create({ companyId: before.id, @@ -992,10 +996,14 @@ export class CompaniesService { if (existing) { request = (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 }, submittedBy: userId, submittedAt: now, - note: null, + status: ChangeRequestStatus.Pending, })) ?? existing; this.companyNotifier.changeRequestSubmitted(company, request.id, false); } else { @@ -1039,6 +1047,47 @@ export class CompaniesService { 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 * 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.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 ( (await this.changeRequestRepo.update(id, { 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 { + 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 * pending change request (recording the uploaded file ids) so the upload is * 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 - * 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( companyId: string, @@ -1091,11 +1214,20 @@ export class CompaniesService { submittedBy?: string, ): Promise { const company = await this.findCompanyById(companyId); + const before = await this.filesService.findByResource( + companyId, + "companies", + ); const uploaded = await this.filesService.uploadMany( companyId, "companies", files, ); + const documentChanges = await this.replaceSingleFileCompanyDocuments( + company, + before, + uploaded, + ); await this.resolveDocumentChangeRequests( companyId, "companies", @@ -1108,14 +1240,9 @@ export class CompaniesService { uploaded.map((f) => f.id), submittedBy, ); - } else if (uploaded.length > 0) { - await this.recordCompanyRevision( - company, - { - documents: uploaded.map((f) => f.name).join(", "), - }, - submittedBy, - ); + } + if (documentChanges.length > 0) { + await this.recordCompanyRevision(company, {}, submittedBy, documentChanges); } return uploaded; } @@ -1231,7 +1358,8 @@ export class CompaniesService { }, submittedBy: submittedBy ?? existing.submittedBy ?? null, submittedAt: now, - note: null, + // Note left untouched — see the comment in updateProfile's merge branch. + status: ChangeRequestStatus.Pending, }); if (company) { 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 { + 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 { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); @@ -2326,7 +2485,8 @@ export class CompaniesService { }, submittedBy: submittedBy ?? existing.submittedBy ?? null, submittedAt: now, - note: null, + // Note left untouched — see the comment in updateProfile's merge branch. + status: ChangeRequestStatus.Pending, }); } else { await this.changeRequestRepo.create({ @@ -2652,7 +2812,8 @@ export class CompaniesService { snapshot, submittedBy: userId, 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); return; @@ -2932,7 +3093,8 @@ export class CompaniesService { }, submittedBy: submittedBy ?? existing.submittedBy ?? null, submittedAt: now, - note: null, + // Note left untouched — see the comment in updateProfile's merge branch. + status: ChangeRequestStatus.Pending, }); } else { await this.changeRequestRepo.create({ diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts index 73271e0af..1dc4b5a84 100644 --- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts @@ -1,4 +1,4 @@ -import { Repository } from "typeorm"; +import { FindOperator, Repository } from "typeorm"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { @@ -10,6 +10,16 @@ type Row = Pick & { createdAt: Date }; 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 | 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 * `where.status` filter and the `createdAt DESC` ordering findOne relies on. @@ -17,13 +27,17 @@ const COMPANY_ID = "company-1"; function mockRepositoryOver(rows: Row[]) { return { findOne: jest.fn( - ({ where }: { where: Partial & { companyId: string } }) => + ({ + where, + }: { + where: { companyId: string; status?: Row["status"] | FindOperator }; + }) => Promise.resolve( rows .filter( (row) => 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] ?? null, @@ -57,6 +71,21 @@ describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => { 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 () => { const result = await subject([rejected]).findLatestOpenByCompanyId( COMPANY_ID, diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts index eb44d56cb..8fb81e6f5 100644 --- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts @@ -1,12 +1,18 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { In, Repository } from "typeorm"; import { BaseRepository } from "@edr/api-common"; import { ChangeRequestStatus, CompanyChangeRequest, } 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() export class CompanyChangeRequestRepository extends BaseRepository { constructor( @@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository { return this.repository.findOne({ - where: { companyId, status: ChangeRequestStatus.Pending }, + where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) }, order: { createdAt: "DESC" }, }); } diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index 2634e0943..34071e813 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -13,9 +13,12 @@ export class CompanyInfoResponseDto { /** * Open profile-edit review, if any. Drives the portal-wide lock (pending → * 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: { - status: 'pending' | 'rejected'; + status: 'pending' | 'rejected' | 'changes_requested'; note: string | null; } | null; @@ -30,12 +33,13 @@ export class CompanyInfoResponseDto { const open = changeRequest && (changeRequest.status === ChangeRequestStatus.Pending || - changeRequest.status === ChangeRequestStatus.Rejected) + changeRequest.status === ChangeRequestStatus.Rejected || + changeRequest.status === ChangeRequestStatus.ChangesRequested) ? changeRequest : null; this.review = open ? { - status: open.status as 'pending' | 'rejected', + status: open.status as 'pending' | 'rejected' | 'changes_requested', note: open.note ?? null, } : null; diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 6072268dc..f0a19dad7 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -68,10 +68,12 @@ export class ProfileResponseDto { /** * Open profile-edit review, if any. `reviewStatus === "pending"` locks the - * settings page; `"rejected"` surfaces the note and prefills the (declined) - * proposed values from `pendingChanges` so the customer can amend & resubmit. + * settings page; `"rejected"`/`"changes_requested"` both surface the note and + * 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; pendingChanges: Record | null; @@ -127,7 +129,8 @@ export class ProfileResponseDto { const openReview = changeRequest && (changeRequest.status === ChangeRequestStatus.Pending || - changeRequest.status === ChangeRequestStatus.Rejected) + changeRequest.status === ChangeRequestStatus.Rejected || + changeRequest.status === ChangeRequestStatus.ChangesRequested) ? changeRequest : null; this.reviewStatus = @@ -135,7 +138,9 @@ export class ProfileResponseDto { ? "pending" : openReview?.status === ChangeRequestStatus.Rejected ? "rejected" - : null; + : openReview?.status === ChangeRequestStatus.ChangesRequested + ? "changes_requested" + : null; this.reviewNote = openReview?.note ?? null; this.pendingChanges = openReview?.snapshot ?? null; this.identity = buildCompanyIdentityState(company); diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index 5ee6739de..2ba39ecad 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -5,14 +5,18 @@ import { Company } from "./company.entity"; /** * 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 - * the live Company row) until a backoffice reviewer approves — at which point - * the snapshot is applied — or rejects with a note, after which the customer can - * amend and resubmit. + * the live Company row) until a backoffice reviewer resolves it: + * - Approved — the snapshot is applied to the live Company row. + * - 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 { Pending = "pending", Approved = "approved", Rejected = "rejected", + ChangesRequested = "changes_requested", } /** diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts index 4872dfff9..222a8364f 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts @@ -2,12 +2,19 @@ import { BaseEntity } from "@edr/api-common"; import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; 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 { field: string; label: string; from: string | null; to: string | null; + fromFileId?: string | null; + toFileId?: string | null; } /** diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts index 6978ad446..fc58a731c 100644 --- a/apps/edr-freight-api/src/modules/files/files.controller.ts +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -51,7 +51,11 @@ export class FilesController { @Query("download") download: string | undefined, @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 // 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 disposition = forceDownload ? "attachment" : "inline"; diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index bc446b508..e76240704 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -245,6 +245,23 @@ export class FilesService { 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 { + 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 * (the customer sees it verbatim); any other verdict clears it, so a stale @@ -360,8 +377,11 @@ export class FilesService { async streamById( id: string, + opts: { includeDeleted?: boolean } = {}, ): 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 stream = await this.minioService.getFileStream(objectName); return { stream, record }; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index 253d8aea5..196161f92 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -2,7 +2,6 @@ import { Alert, Anchor, Badge, - Box, Button, Card, Group, @@ -27,11 +26,11 @@ import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { fetchViewableFile } from "@/services/files.service"; import { api } from "@/services/api"; -import type { Company, CompanyChangeRequest } from "@/types/customer"; +import type { Company } from "@/types/customer"; import { formatDate, humanize } from "./format"; /** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */ -const FIELD_LABELS: Record = { +export const FIELD_LABELS: Record = { companyName: "Company name", companyEmail: "Company email", companyPhone: "Company phone", @@ -69,7 +68,7 @@ const FIELD_LABELS: Record = { }; /** 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; const attrs = (company.attributes ?? {}) as Record; const map: Record = { @@ -160,7 +159,7 @@ function FaydaIdentityDiff({ ); } -function DiffRow({ +export function DiffRow({ label, from, to, @@ -201,8 +200,9 @@ function DiffRow({ /** * Backoffice review surface for a customer's staged profile edits. Shows the - * pending change request as a proposed-vs-current diff with Approve / Reject - * (with note) actions, plus a short history of past decisions. + * pending change request as a proposed-vs-current diff with Approve / Reject / + * Request changes actions. Past decisions live in the History tab's unified + * timeline (see {@link CompanyTimeline}), not here. */ export function ChangeRequestReview({ company }: { company: Company }) { const { user } = useAuth(); @@ -216,16 +216,21 @@ export function ChangeRequestReview({ company }: { company: Company }) { const reject = useMutation( api.customers.rejectChangeRequest.mutationOptions(), ); + const requestChanges = useMutation( + api.customers.requestChangeRequestChanges.mutationOptions(), + ); const { view, viewer } = useFileViewer(); - const [rejectId, setRejectId] = useState(null); + const [actionTarget, setActionTarget] = useState<{ + id: string; + kind: "reject" | "request-changes"; + } | null>(null); const [note, setNote] = useState(""); const requests = query.data ?? []; 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 ? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity") @@ -237,13 +242,14 @@ export function ChangeRequestReview({ company }: { company: Company }) { const licenseChanges = pending?.licenseChanges ?? []; const documentChanges = pending?.documentChanges ?? []; - const confirmReject = () => { - if (!rejectId) return; - reject.mutate( - { id: rejectId, note: note.trim() }, + const confirmAction = () => { + if (!actionTarget) return; + const mutation = actionTarget.kind === "reject" ? reject : requestChanges; + mutation.mutate( + { id: actionTarget.id, note: note.trim() }, { onSuccess: () => { - setRejectId(null); + setActionTarget(null); setNote(""); }, }, @@ -270,6 +276,18 @@ export function ChangeRequestReview({ company }: { company: Company }) { + {pending.note && ( + } + > + Changes were requested on an earlier round of this same + submission: {pending.note} — check whether + this resubmission actually addresses it before approving. + + )} + {proposedKeys.length > 0 ? ( {proposedKeys.map((key) => ( @@ -418,12 +436,22 @@ export function ChangeRequestReview({ company }: { company: Company }) { variant="light" color="red" onClick={() => { - setRejectId(pending.id); + setActionTarget({ id: pending.id, kind: "reject" }); setNote(""); }} > Reject +