mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(companies): per-document change requests and resubmission review queue
Two review-workflow gaps for freight customer onboarding: Request for change per document. Backoffice can now flag a single uploaded document (company document, profile licence, or POA delegation letter) with a note the customer sees, instead of rejecting the whole role over it. Adds review_status/review_note/reviewed_by/reviewed_at to freight.files (migration AddFileReviewStatus, partial index for the gate), a POST documents/:fileId/request-change endpoint, the backoffice action + modal, and a portal banner/badge so the customer knows what to re-upload. Re-uploading clears the flag. Approving a role is blocked while any of its documents has an open correction; the gate check and the status write share a pessimistic write lock on the company row (as does the change-request write) so a correction can never slip in between the check and the profile going Active. Resubmission is visible to reviewers. When a customer resubmits a rejected role or amends a change request, backoffice staff are notified (allBackoffice inbox item, deep-linked to the customer) and the resubmission surfaces in a new "Pending changes" list view + KPI, since such companies are status = active and never matched the pending-approval filter.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-document review state, so a backoffice reviewer can request a correction
|
||||
* on one specific onboarding document instead of rejecting the whole role.
|
||||
*
|
||||
* Until now `freight.files` carried no status at all: the `pending_add` /
|
||||
* `pending_remove` badges the portal shows are derived by diffing live rows
|
||||
* against an open company change request, which says nothing about whether a
|
||||
* reviewer is happy with a given document. `review_status` is that missing
|
||||
* verdict — NULL means never reviewed, which is the state every existing row
|
||||
* correctly starts in, so no backfill is needed.
|
||||
*
|
||||
* The partial index serves the approval gate, which asks "does this company (or
|
||||
* profile) still have any document with an open change request?" on every
|
||||
* role-status write.
|
||||
*/
|
||||
export class AddFileReviewStatus2430000000000 implements MigrationInterface {
|
||||
name = 'AddFileReviewStatus2430000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.files
|
||||
ADD COLUMN IF NOT EXISTS review_status varchar(32) NULL,
|
||||
ADD COLUMN IF NOT EXISTS review_note text NULL,
|
||||
ADD COLUMN IF NOT EXISTS reviewed_by uuid NULL,
|
||||
ADD COLUMN IF NOT EXISTS reviewed_at timestamptz NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_files_open_change_request"
|
||||
ON freight.files (resource, resource_id)
|
||||
WHERE review_status = 'change_requested' AND deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_files_open_change_request"`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.files
|
||||
DROP COLUMN IF EXISTS review_status,
|
||||
DROP COLUMN IF EXISTS review_note,
|
||||
DROP COLUMN IF EXISTS reviewed_by,
|
||||
DROP COLUMN IF EXISTS reviewed_at
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
|
||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
||||
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
|
||||
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
|
||||
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
|
||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||
@@ -494,6 +495,9 @@ export class CompaniesController {
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
uploadedAt: f.createdAt,
|
||||
reviewStatus: f.reviewStatus,
|
||||
reviewNote: f.reviewNote,
|
||||
reviewedAt: f.reviewedAt,
|
||||
// Raw `f.url` is an un-signed MinIO path the browser can't open — sign
|
||||
// it so the file previews/downloads in the client.
|
||||
url: f.url ? await this.filesService.signUrl(f.url) : f.url,
|
||||
@@ -501,6 +505,35 @@ export class CompaniesController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post("documents/:fileId/request-change")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({
|
||||
summary: "Ask the customer to correct one uploaded document",
|
||||
description:
|
||||
"Flags a single document with a reason the customer sees, notifies them, " +
|
||||
"and blocks role approval until they re-upload. Narrower than rejecting " +
|
||||
"the whole role.",
|
||||
})
|
||||
async requestDocumentChange(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
@Body() dto: RequestDocumentChangeDto,
|
||||
) {
|
||||
const file = await this.companiesService.requestDocumentChange(
|
||||
fileId,
|
||||
dto.note,
|
||||
user.id,
|
||||
);
|
||||
return {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
code: file.code,
|
||||
reviewStatus: file.reviewStatus,
|
||||
reviewNote: file.reviewNote,
|
||||
reviewedAt: file.reviewedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@Post(":companyId/documents")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
|
||||
@@ -29,6 +29,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
)
|
||||
)`;
|
||||
|
||||
/**
|
||||
* A company waiting on a reviewer to decide an edit it submitted after being
|
||||
* approved. These rows are `status = active`, so the pending-application filter
|
||||
* can never surface them — the review queue needs its own predicate.
|
||||
*/
|
||||
private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS (
|
||||
SELECT 1 FROM freight.company_change_request ccr
|
||||
WHERE ccr.company_id = company.id
|
||||
AND ccr.status = 'pending'
|
||||
AND ccr.deleted_at IS NULL
|
||||
)`;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Company)
|
||||
repo: Repository<Company>,
|
||||
@@ -67,6 +79,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
kind,
|
||||
status,
|
||||
onboardingCompleted,
|
||||
hasPendingChangeRequest,
|
||||
sortBy = 'name',
|
||||
sortOrder = 'ASC',
|
||||
} = query;
|
||||
@@ -99,6 +112,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPendingChangeRequest !== undefined) {
|
||||
qb.andWhere(
|
||||
hasPendingChangeRequest
|
||||
? CompaniesRepository.PENDING_CHANGE_REQUEST_SQL
|
||||
: `NOT ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const term = `%${search.trim()}%`;
|
||||
qb.andWhere(
|
||||
@@ -143,6 +164,12 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
.addGroupBy(CompaniesRepository.DRAFT_SQL)
|
||||
.getRawMany();
|
||||
|
||||
const pendingChanges = await this.repository
|
||||
.createQueryBuilder('company')
|
||||
.where('company.deleted_at IS NULL')
|
||||
.andWhere(CompaniesRepository.PENDING_CHANGE_REQUEST_SQL)
|
||||
.getCount();
|
||||
|
||||
const map = new Map<string, number>();
|
||||
let onboarding = 0;
|
||||
let total = 0;
|
||||
@@ -160,6 +187,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
onboarding,
|
||||
suspended: map.get('suspended') ?? 0,
|
||||
blacklisted: map.get('blacklisted') ?? 0,
|
||||
pendingChanges,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
@@ -98,6 +99,7 @@ export class CompaniesService {
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
private readonly etradeService: ETradeService,
|
||||
private readonly companyNotifier: CompanyNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -748,7 +750,15 @@ export class CompaniesService {
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
})) ?? existing;
|
||||
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
|
||||
} else {
|
||||
// Rejecting a request leaves it Rejected rather than reopening it, so a
|
||||
// customer amending after a rejection lands here with a fresh Pending row.
|
||||
// That is the resubmission case the reviewer needs flagged.
|
||||
const history = await this.changeRequestRepo.findByCompanyId(company.id);
|
||||
const resubmitted = history.some(
|
||||
(r) => r.status === ChangeRequestStatus.Rejected,
|
||||
);
|
||||
request = await this.changeRequestRepo.create({
|
||||
companyId: company.id,
|
||||
snapshot: fields,
|
||||
@@ -756,6 +766,11 @@ export class CompaniesService {
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
});
|
||||
this.companyNotifier.changeRequestSubmitted(
|
||||
company,
|
||||
request.id,
|
||||
resubmitted,
|
||||
);
|
||||
}
|
||||
|
||||
// Live company is unchanged; surface the pending state for the settings page.
|
||||
@@ -827,6 +842,12 @@ export class CompaniesService {
|
||||
"companies",
|
||||
files,
|
||||
);
|
||||
await this.resolveDocumentChangeRequests(
|
||||
companyId,
|
||||
"companies",
|
||||
uploaded.map((f) => f.code),
|
||||
uploaded.map((f) => f.id),
|
||||
);
|
||||
if (company.status === CompanyStatus.Active) {
|
||||
await this.stageDocumentChange(
|
||||
company.id,
|
||||
@@ -837,6 +858,95 @@ export class CompaniesService {
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the `change_requested` flag from the documents a fresh upload replaces.
|
||||
*
|
||||
* Uploading does not overwrite the old row — it adds a new one under the same
|
||||
* `code` — so the flagged original would otherwise linger and keep the approval
|
||||
* gate closed even after the customer did exactly what was asked. Only rows of
|
||||
* the same code are touched, and never the newly uploaded ones.
|
||||
*/
|
||||
private async resolveDocumentChangeRequests(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
codes: string[],
|
||||
uploadedIds: string[],
|
||||
): Promise<void> {
|
||||
if (codes.length === 0) return;
|
||||
const replaced = new Set(codes);
|
||||
const fresh = new Set(uploadedIds);
|
||||
const open = await this.filesService.findWithOpenChangeRequest(
|
||||
[resourceId],
|
||||
resource,
|
||||
);
|
||||
await Promise.all(
|
||||
open
|
||||
.filter((f) => replaced.has(f.code) && !fresh.has(f.id))
|
||||
.map((f) => this.filesService.clearReview(f.id)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice: ask the customer to correct one specific document, instead of
|
||||
* rejecting their whole role over it. Mirrors the contract change-request
|
||||
* flow — a note the customer sees verbatim, plus a block on approval until
|
||||
* they re-upload.
|
||||
*/
|
||||
async requestDocumentChange(
|
||||
fileId: string,
|
||||
note: string,
|
||||
reviewerId?: string,
|
||||
): Promise<FileRecord> {
|
||||
const file = await this.filesService.findById(fileId);
|
||||
const companyId = await this.resolveDocumentCompanyId(file);
|
||||
const company = await this.findCompanyById(companyId);
|
||||
|
||||
// Flag the document while holding a write lock on its company row. The
|
||||
// approval gate takes the same lock before it reads the flags, so the two
|
||||
// serialize: a change request can never land in the window between the gate
|
||||
// checking "any open corrections?" and writing the profile Active.
|
||||
const updated = await this.dataSource.transaction(async (manager) => {
|
||||
await manager.findOne(Company, {
|
||||
where: { id: companyId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
return this.filesService.setReviewStatus(
|
||||
file.id,
|
||||
"change_requested",
|
||||
note,
|
||||
reviewerId,
|
||||
);
|
||||
});
|
||||
this.companyNotifier.documentChangeRequested(
|
||||
company,
|
||||
file.name,
|
||||
note,
|
||||
file.id,
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which company a stored document belongs to. Company documents are keyed by
|
||||
* the company id directly; profile licences and POA letters hang off a company
|
||||
* profile, so those resolve through it.
|
||||
*/
|
||||
private async resolveDocumentCompanyId(file: FileRecord): Promise<string> {
|
||||
if (file.resource === "companies") return file.resourceId;
|
||||
if (file.resource === "company_profiles") {
|
||||
const profile = await this.companyProfilesRepo.findById(file.resourceId);
|
||||
if (!profile) {
|
||||
throw new NotFoundException(
|
||||
`Company profile ${file.resourceId} not found`,
|
||||
);
|
||||
}
|
||||
return profile.companyId;
|
||||
}
|
||||
throw new BadRequestException(
|
||||
`Documents on "${file.resource}" do not support change requests`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Open or append a pending change request recording staged document uploads. */
|
||||
private async stageDocumentChange(
|
||||
companyId: string,
|
||||
@@ -847,6 +957,7 @@ export class CompaniesService {
|
||||
const now = new Date();
|
||||
const existing =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
const company = await this.companiesRepo.findById(companyId);
|
||||
if (existing) {
|
||||
const prev = existing.documents?.documentFileIds ?? [];
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
@@ -860,8 +971,15 @@ export class CompaniesService {
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||
}
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
const history = await this.changeRequestRepo.findByCompanyId(companyId);
|
||||
const resubmitted = history.some(
|
||||
(r) => r.status === ChangeRequestStatus.Rejected,
|
||||
);
|
||||
const created = await this.changeRequestRepo.create({
|
||||
companyId,
|
||||
snapshot: {},
|
||||
documents: { documentFileIds: fileIds },
|
||||
@@ -869,6 +987,13 @@ export class CompaniesService {
|
||||
submittedBy: submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.changeRequestSubmitted(
|
||||
company,
|
||||
created.id,
|
||||
resubmitted,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -987,6 +1112,61 @@ export class CompaniesService {
|
||||
}
|
||||
}
|
||||
|
||||
// Anything other than approval has no document gate and no concurrency
|
||||
// hazard — apply it directly.
|
||||
if (status !== ProfileStatus.Active) {
|
||||
return this.applyProfileStatus(existing, status, note, reviewerId);
|
||||
}
|
||||
|
||||
// Approving over an outstanding document correction would silently accept the
|
||||
// very document a reviewer just rejected, and would strand the customer's
|
||||
// "please fix this" banner with nothing left to fix. The gate check and the
|
||||
// status write share a write lock on the company row — `requestDocumentChange`
|
||||
// takes the same lock, so a fresh correction can never land in the window
|
||||
// between "any open corrections?" and the profile going Active. Suspend and
|
||||
// blacklist skip all this — staff must always be able to act against a bad
|
||||
// account.
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.findOne(Company, {
|
||||
where: { id: existing.companyId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
|
||||
const [companyDocs, profileDocs] = await Promise.all([
|
||||
this.filesService.findWithOpenChangeRequest(
|
||||
[existing.companyId],
|
||||
"companies",
|
||||
),
|
||||
this.filesService.findWithOpenChangeRequest(
|
||||
[existing.id],
|
||||
"company_profiles",
|
||||
),
|
||||
]);
|
||||
const pending = [...companyDocs, ...profileDocs];
|
||||
if (pending.length > 0) {
|
||||
const names = pending.map((f) => f.name).join(", ");
|
||||
throw new BadRequestException(
|
||||
`This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
|
||||
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.applyProfileStatus(existing, status, note, reviewerId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a reviewed profile status (reference minting, note handling, reviewer
|
||||
* stamp) and promote the company if this is its first approved role. Split out
|
||||
* of `setCompanyProfileStatus` so the approval path can run it inside the gate
|
||||
* transaction while every other status skips that overhead.
|
||||
*/
|
||||
private async applyProfileStatus(
|
||||
existing: CompanyProfile,
|
||||
status: ProfileStatus,
|
||||
note?: string,
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyProfile> {
|
||||
// A reference number is only minted the first time a profile is approved
|
||||
// (status → Active). Pending/unapproved profiles carry no reference.
|
||||
const patch: Partial<CompanyProfile> = { status };
|
||||
@@ -1008,9 +1188,9 @@ export class CompaniesService {
|
||||
patch.reviewedAt = new Date();
|
||||
}
|
||||
|
||||
const updated = await this.companyProfilesRepo.update(profileId, patch);
|
||||
const updated = await this.companyProfilesRepo.update(existing.id, patch);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
||||
|
||||
// Approving any profile promotes a pending company to active, so the
|
||||
// customer can start working as soon as their first profile is cleared.
|
||||
@@ -1057,6 +1237,13 @@ export class CompaniesService {
|
||||
});
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
|
||||
// The role is back in the pending queue — tell the reviewers, otherwise the
|
||||
// resubmission is invisible until someone happens to reopen the customer.
|
||||
const company = await this.companiesRepo.findById(companyId);
|
||||
if (company) {
|
||||
this.companyNotifier.roleReapplied(company, updated.id, updated.type);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -1512,6 +1699,15 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// A fresh licence upload answers any correction the reviewer asked for on the
|
||||
// previous one, so the old row must stop blocking approval.
|
||||
await this.resolveDocumentChangeRequests(
|
||||
profileId,
|
||||
LICENSE_RESOURCE,
|
||||
[LICENSE_CODE, LICENSE_PENDING_CODE],
|
||||
uploaded.map((r) => r.id),
|
||||
);
|
||||
|
||||
return this.getProfileLicenseView(profileId, company.id);
|
||||
}
|
||||
|
||||
@@ -1593,6 +1789,13 @@ export class CompaniesService {
|
||||
await this.filesService.remove(fileId);
|
||||
}
|
||||
|
||||
await this.resolveDocumentChangeRequests(
|
||||
profileId,
|
||||
LICENSE_RESOURCE,
|
||||
[LICENSE_CODE, LICENSE_PENDING_CODE],
|
||||
[created.id],
|
||||
);
|
||||
|
||||
return this.getProfileLicenseView(profileId, company.id);
|
||||
}
|
||||
|
||||
@@ -1689,6 +1892,8 @@ export class CompaniesService {
|
||||
: pendingRemoveIds.has(r.id)
|
||||
? ("pending_remove" as const)
|
||||
: ("live" as const),
|
||||
reviewStatus: r.reviewStatus,
|
||||
reviewNote: r.reviewNote,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1855,6 +2060,13 @@ export class CompaniesService {
|
||||
for (const r of live) await this.filesService.remove(r.id);
|
||||
}
|
||||
|
||||
await this.resolveDocumentChangeRequests(
|
||||
company.id,
|
||||
COMPANY_RESOURCE,
|
||||
[POA_DELEGATION_FILE_KEY, POA_DELEGATION_PENDING_CODE],
|
||||
[created.id],
|
||||
);
|
||||
|
||||
return this.getPoaDelegationView(company.id);
|
||||
}
|
||||
|
||||
@@ -1932,6 +2144,8 @@ export class CompaniesService {
|
||||
: removeIds.has(r.id)
|
||||
? ("pending_remove" as const)
|
||||
: ("live" as const),
|
||||
reviewStatus: r.reviewStatus,
|
||||
reviewNote: r.reviewNote,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -88,4 +88,106 @@ export class CompanyNotifierService {
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Backoffice-facing: work has arrived back in the review queue ────────────
|
||||
|
||||
/**
|
||||
* Persist + push an in-app item to every backoffice staff user, deep-linked to
|
||||
* the customer's detail page.
|
||||
*
|
||||
* The recipient resolver has no role/permission targeting (see
|
||||
* `notification-recipients.service.ts`) — `allBackoffice` is the narrowest
|
||||
* selector available, so marketing is reached by notifying all staff.
|
||||
*/
|
||||
private notifyStaff(
|
||||
company: Company,
|
||||
title: string,
|
||||
body: string,
|
||||
data: Record<string, unknown> = {},
|
||||
): void {
|
||||
void this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
body,
|
||||
link: `/dashboard/customers/${company.id}`,
|
||||
data: { companyId: company.id, companyName: company.name, ...data },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer resubmitted an operational role after it was rejected for
|
||||
* adjustment. Without this the role silently flips back to Pending and nobody
|
||||
* is told there is anything to look at again.
|
||||
*/
|
||||
roleReapplied(company: Company, profileId: string, profileType: string): void {
|
||||
this.logger.log(`ROLE_REAPPLIED — ${company.id} / ${profileId}`);
|
||||
this.notifyStaff(
|
||||
company,
|
||||
"Customer resubmitted a role for approval",
|
||||
`${company.name} has adjusted and resubmitted its ${profileType} role. ` +
|
||||
`It is back in the pending approval queue for review.`,
|
||||
{ profileId, profileType },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer submitted (or amended and resubmitted) a profile change request.
|
||||
* `resubmitted` distinguishes the two so the reviewer knows this is a second
|
||||
* look at something they already sent back.
|
||||
*/
|
||||
changeRequestSubmitted(
|
||||
company: Company,
|
||||
changeRequestId: string,
|
||||
resubmitted: boolean,
|
||||
): void {
|
||||
this.logger.log(
|
||||
`CHANGE_REQUEST_${resubmitted ? "RESUBMITTED" : "SUBMITTED"} — ${company.id}`,
|
||||
);
|
||||
this.notifyStaff(
|
||||
company,
|
||||
resubmitted
|
||||
? "Customer resubmitted profile changes"
|
||||
: "Customer submitted profile changes",
|
||||
resubmitted
|
||||
? `${company.name} has adjusted the changes you sent back and resubmitted ` +
|
||||
`them. They are pending your review.`
|
||||
: `${company.name} has submitted profile changes that are pending review.`,
|
||||
{ changeRequestId },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Customer-facing: a specific document needs correcting ──────────────────
|
||||
|
||||
/**
|
||||
* Tell the customer a reviewer wants one specific document corrected. Mirrors
|
||||
* the contract `changesRequested` flow: SMS + email out, plus an in-app item
|
||||
* deep-linked to the documents tab where they can re-upload.
|
||||
*/
|
||||
documentChangeRequested(
|
||||
company: Company,
|
||||
documentName: string,
|
||||
note: string,
|
||||
fileId: string,
|
||||
): void {
|
||||
const title = "Document change requested";
|
||||
const body =
|
||||
`A reviewer has asked you to correct "${documentName}". ` +
|
||||
`Reason: ${note} ` +
|
||||
`Please upload a corrected version from your settings page.`;
|
||||
|
||||
this.logger.log(`DOCUMENT_CHANGE_REQUESTED — ${company.id} / ${fileId}`);
|
||||
void this.notifyContact(company, `${title}. ${body}`);
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: company.id },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
title,
|
||||
body,
|
||||
link: "/settings",
|
||||
data: { companyId: company.id, fileId, documentName },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,10 @@ export class CompanyStatsResponseDto {
|
||||
onboarding!: number;
|
||||
suspended!: number;
|
||||
blacklisted!: number;
|
||||
/**
|
||||
* Approved customers with an open profile change request. Counted separately
|
||||
* because they are `active` and so are invisible to the `pending` KPI, even
|
||||
* though they are just as much waiting on a reviewer.
|
||||
*/
|
||||
pendingChanges!: number;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,17 @@ export class ListCompaniesQueryDto {
|
||||
@IsBoolean()
|
||||
onboardingCompleted?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"`true` = only companies with an open (pending) profile change request. " +
|
||||
"These are already-approved customers, so they never appear under " +
|
||||
"`status=pending` and would otherwise be invisible in the review queue.",
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
|
||||
@IsBoolean()
|
||||
hasPendingChangeRequest?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ["name", "createdAt", "updatedAt"],
|
||||
default: "name",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MaxLength, MinLength } from "class-validator";
|
||||
|
||||
export class RequestDocumentChangeDto {
|
||||
/** What is wrong with this document — shown verbatim to the customer. */
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(2000)
|
||||
note!: string;
|
||||
}
|
||||
@@ -37,8 +37,20 @@ export interface BusinessLicenseFile {
|
||||
*/
|
||||
export type StagedFileStatus = "live" | "pending_add" | "pending_remove";
|
||||
|
||||
/**
|
||||
* Reviewer verdict on a document, as surfaced to clients. Distinct from
|
||||
* {@link StagedFileStatus}: that describes where the file sits in the staged
|
||||
* add/remove workflow, this describes whether a reviewer wants it corrected.
|
||||
*/
|
||||
export interface FileReviewView {
|
||||
/** `change_requested` while the customer still owes a corrected upload. */
|
||||
reviewStatus?: "change_requested" | "approved" | null;
|
||||
/** The reviewer's reason, shown verbatim to the customer. */
|
||||
reviewNote?: string | null;
|
||||
}
|
||||
|
||||
/** A business-license file plus its change-review state, surfaced to clients. */
|
||||
export interface ProfileLicenseFileView {
|
||||
export interface ProfileLicenseFileView extends FileReviewView {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
@@ -47,7 +59,7 @@ export interface ProfileLicenseFileView {
|
||||
}
|
||||
|
||||
/** A company-level document (e.g. the PoA letter) with its change-review state. */
|
||||
export interface CompanyDocumentFileView {
|
||||
export interface CompanyDocumentFileView extends FileReviewView {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
/**
|
||||
* Reviewer verdict on a single stored document.
|
||||
*
|
||||
* `null` (the default) means "not reviewed" — the state every file starts in and
|
||||
* the only state the customer is not blocked by. `change_requested` is raised by
|
||||
* a backoffice reviewer against one specific document and is what the customer
|
||||
* must clear by re-uploading; `approved` records an explicit sign-off.
|
||||
*/
|
||||
export type FileReviewStatus = "change_requested" | "approved";
|
||||
|
||||
@Entity({ schema: "freight", name: "files" })
|
||||
export class FileRecord extends BaseEntity {
|
||||
@Column({ name: "resource_id", type: "uuid" })
|
||||
@@ -23,4 +33,24 @@ export class FileRecord extends BaseEntity {
|
||||
|
||||
@Column({ name: "mime_type", type: "varchar", length: 255 })
|
||||
mimeType!: string;
|
||||
|
||||
/** Reviewer verdict, or `null` while the document has never been reviewed. */
|
||||
@Column({
|
||||
name: "review_status",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
reviewStatus!: FileReviewStatus | null;
|
||||
|
||||
/** Why a change was requested — shown verbatim to the customer. */
|
||||
@Column({ name: "review_note", type: "text", nullable: true })
|
||||
reviewNote!: string | null;
|
||||
|
||||
@Column({ name: "reviewed_by", type: "uuid", nullable: true })
|
||||
reviewedBy!: string | null;
|
||||
|
||||
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||
reviewedAt!: Date | null;
|
||||
}
|
||||
|
||||
@@ -48,4 +48,24 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
||||
): Promise<void> {
|
||||
await this.repository.delete({ resourceId, resource, code });
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents belonging to any of the given resources that a reviewer has asked
|
||||
* the customer to correct. Used by the approval gate, so it takes a list of
|
||||
* resource ids (a company plus each of its company profiles) in one query.
|
||||
*/
|
||||
async findWithOpenChangeRequest(
|
||||
resourceIds: string[],
|
||||
resource: string,
|
||||
): Promise<FileRecord[]> {
|
||||
if (resourceIds.length === 0) return [];
|
||||
return this.repository.find({
|
||||
where: {
|
||||
resourceId: In(resourceIds),
|
||||
resource,
|
||||
reviewStatus: "change_requested",
|
||||
},
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Readable } from "stream";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { FilesRepository } from "./files.repository";
|
||||
import { FileRecord } from "./entities/file.entity";
|
||||
import { FileRecord, FileReviewStatus } from "./entities/file.entity";
|
||||
|
||||
export interface CreateFileInput {
|
||||
resourceId: string;
|
||||
@@ -169,6 +169,53 @@ export class FilesService {
|
||||
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
|
||||
* reason can never outlive the request it explained.
|
||||
*/
|
||||
async setReviewStatus(
|
||||
id: string,
|
||||
status: FileReviewStatus,
|
||||
note: string | null,
|
||||
reviewerId?: string,
|
||||
): Promise<FileRecord> {
|
||||
const record = await this.findById(id);
|
||||
const updated = await this.filesRepository.update(record.id, {
|
||||
reviewStatus: status,
|
||||
reviewNote: status === "change_requested" ? (note ?? null) : null,
|
||||
reviewedBy: reviewerId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
});
|
||||
if (!updated) throw new NotFoundException(`File ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop any reviewer verdict from a document, returning it to "not reviewed".
|
||||
* Called when a customer re-uploads: the new bytes have not been looked at, so
|
||||
* carrying the old `change_requested` forward would keep them blocked forever.
|
||||
*/
|
||||
async clearReview(id: string): Promise<void> {
|
||||
await this.filesRepository.update(id, {
|
||||
reviewStatus: null,
|
||||
reviewNote: null,
|
||||
reviewedBy: null,
|
||||
reviewedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Documents across these resources still awaiting a customer correction. */
|
||||
findWithOpenChangeRequest(
|
||||
resourceIds: string[],
|
||||
resource: string,
|
||||
): Promise<FileRecord[]> {
|
||||
return this.filesRepository.findWithOpenChangeRequest(
|
||||
resourceIds,
|
||||
resource,
|
||||
);
|
||||
}
|
||||
|
||||
/** Soft-delete a stored file row by id (object bytes are left in MinIO). */
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.filesRepository.softDelete(id);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { FilePen } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { CustomerDocument } from "@/types/customer";
|
||||
|
||||
export interface RequestDocumentChangeModalProps {
|
||||
/** The document under review; `null` closes the modal. */
|
||||
document: CustomerDocument | null;
|
||||
companyId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the customer to correct one uploaded document.
|
||||
*
|
||||
* Deliberately narrower than rejecting a whole role: the customer keeps every
|
||||
* other document and only re-uploads this one. The role cannot be approved
|
||||
* while the request is open, so the note has to say what is actually wrong —
|
||||
* it is shown to the customer verbatim.
|
||||
*/
|
||||
export function RequestDocumentChangeModal({
|
||||
document,
|
||||
companyId,
|
||||
onClose,
|
||||
}: RequestDocumentChangeModalProps) {
|
||||
const requestChange = useMutation(
|
||||
api.customers.requestDocumentChange.mutationOptions(),
|
||||
);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
// Re-opening on a document that already has an open request should show what
|
||||
// was asked for, so the reviewer edits the reason rather than retyping it.
|
||||
useEffect(() => {
|
||||
setNote(document?.reviewNote ?? "");
|
||||
}, [document?.id, document?.reviewNote]);
|
||||
|
||||
const submit = () => {
|
||||
if (!document) return;
|
||||
requestChange.mutate(
|
||||
{ companyId, fileId: document.id, note: note.trim() },
|
||||
{ onSuccess: onClose },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={document !== null}
|
||||
onClose={onClose}
|
||||
title="Request a change"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="orange" variant="light" icon={<FilePen size={18} />}>
|
||||
The customer is notified and sees this note verbatim. This role cannot
|
||||
be approved until they upload a corrected document.
|
||||
</Alert>
|
||||
<Text size="sm" c="dimmed">
|
||||
Document: <strong>{document?.name}</strong>
|
||||
</Text>
|
||||
<Textarea
|
||||
label="What needs correcting?"
|
||||
placeholder="e.g. The trade license scan is cut off — please re-upload the full page."
|
||||
autosize
|
||||
minRows={3}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onClose}
|
||||
disabled={requestChange.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="orange"
|
||||
loading={requestChange.isPending}
|
||||
disabled={note.trim().length === 0}
|
||||
onClick={submit}
|
||||
>
|
||||
Request change
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,10 @@ export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export {
|
||||
RequestDocumentChangeModal,
|
||||
type RequestDocumentChangeModalProps,
|
||||
} from "./RequestDocumentChangeModal";
|
||||
export {
|
||||
default as ResetPasswordAction,
|
||||
type ResetPasswordActionProps,
|
||||
|
||||
@@ -82,6 +82,8 @@ export const URL_CONSTANTS = {
|
||||
`/companies/change-requests/${id}/approve`,
|
||||
CHANGE_REQUEST_REJECT: (id: string) =>
|
||||
`/companies/change-requests/${id}/reject`,
|
||||
DOCUMENT_REQUEST_CHANGE: (fileId: string) =>
|
||||
`/companies/documents/${fileId}/request-change`,
|
||||
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
||||
`/bookings/by-company/${id}/customer-view`,
|
||||
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
|
||||
|
||||
@@ -27,11 +27,12 @@ import {
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
FilePen,
|
||||
Paperclip,
|
||||
Receipt,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
ProfileChips,
|
||||
ProfileStatusBadge,
|
||||
ProfileTypeBadge,
|
||||
RequestDocumentChangeModal,
|
||||
ResetPasswordAction,
|
||||
TableCard,
|
||||
formatBytes,
|
||||
@@ -179,6 +181,10 @@ export default function CustomerDetailPage() {
|
||||
const stillOnboarding = company ? isOnboardingDraft(company) : false;
|
||||
const canReview = company ? hasSubmittedOnboarding(company) : true;
|
||||
|
||||
/** Document the reviewer is asking the customer to correct; null = closed. */
|
||||
const [changeRequestDoc, setChangeRequestDoc] =
|
||||
useState<CustomerDocument | null>(null);
|
||||
|
||||
const profileColumns: ColumnDef<CompanyProfile>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -355,14 +361,31 @@ export default function CustomerDetailPage() {
|
||||
{
|
||||
id: "name",
|
||||
header: "Document",
|
||||
cell: ({ row }) => (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<FileText size={16} className="shrink-0 text-edr-muted" />
|
||||
<Text size="sm" c="edr-text" truncate>
|
||||
{row.original.name}
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const doc = row.original;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<FileText size={16} className="shrink-0 text-edr-muted" />
|
||||
<Text size="sm" c="edr-text" truncate>
|
||||
{doc.name}
|
||||
</Text>
|
||||
{doc.reviewStatus === "change_requested" && (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
Change requested
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{/* The note is the whole point of the request — show it inline so a
|
||||
second reviewer sees what was already asked for. */}
|
||||
{doc.reviewStatus === "change_requested" && doc.reviewNote && (
|
||||
<Text size="xs" c="dimmed" pl={24}>
|
||||
{doc.reviewNote}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
@@ -423,11 +446,29 @@ export default function CustomerDetailPage() {
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
{canReview && (
|
||||
<ActionIcon
|
||||
component="button"
|
||||
type="button"
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
aria-label="Request change"
|
||||
title={
|
||||
row.original.reviewStatus === "change_requested"
|
||||
? "Update the requested change"
|
||||
: "Request a change from the customer"
|
||||
}
|
||||
data-stop-row-click
|
||||
onClick={() => setChangeRequestDoc(row.original)}
|
||||
>
|
||||
<FilePen size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[view],
|
||||
[view, canReview],
|
||||
);
|
||||
|
||||
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
||||
@@ -1063,6 +1104,12 @@ export default function CustomerDetailPage() {
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<RequestDocumentChangeModal
|
||||
document={changeRequestDoc}
|
||||
companyId={company.id}
|
||||
onClose={() => setChangeRequestDoc(null)}
|
||||
/>
|
||||
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FilePen,
|
||||
Hourglass,
|
||||
Mail,
|
||||
Phone,
|
||||
@@ -31,7 +32,6 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
CompanyStatusBadge,
|
||||
CompanyTypeBadge,
|
||||
ProfileChips,
|
||||
formatDate,
|
||||
} from "@/components/customers";
|
||||
@@ -52,14 +52,30 @@ import {
|
||||
* wizard's first click and would otherwise pad the review queue. Those drafts
|
||||
* get their own view instead of disappearing, so staff can still chase them.
|
||||
*/
|
||||
type CustomerView = "all" | "pending" | "onboarding" | "active";
|
||||
type CustomerView =
|
||||
| "all"
|
||||
| "pending"
|
||||
| "pendingChanges"
|
||||
| "onboarding"
|
||||
| "active";
|
||||
|
||||
/**
|
||||
* "Pending changes" is deliberately not folded into "Pending approval". A
|
||||
* customer who edits their profile after being approved stays `status = active`,
|
||||
* so the pending filter can never match them — their resubmission would only
|
||||
* ever be visible by opening their detail page. This view is that queue.
|
||||
*/
|
||||
const VIEW_FILTERS: Record<
|
||||
CustomerView,
|
||||
{ status?: CompanyStatus; onboardingCompleted?: boolean }
|
||||
{
|
||||
status?: CompanyStatus;
|
||||
onboardingCompleted?: boolean;
|
||||
hasPendingChangeRequest?: boolean;
|
||||
}
|
||||
> = {
|
||||
all: {},
|
||||
pending: { status: "pending", onboardingCompleted: true },
|
||||
pendingChanges: { hasPendingChangeRequest: true },
|
||||
onboarding: { onboardingCompleted: false },
|
||||
active: { status: "active" },
|
||||
};
|
||||
@@ -94,7 +110,9 @@ export default function CustomersPage() {
|
||||
};
|
||||
}, [pagination.pageIndex, pagination.pageSize, debouncedQuery, view, sort]);
|
||||
|
||||
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
|
||||
const { data: stats } = useQuery(
|
||||
api.customers.stats.queryOptions({ input: {} }),
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||
api.customers.list.queryOptions({ input: { filter } }),
|
||||
@@ -127,7 +145,6 @@ export default function CustomersPage() {
|
||||
<Text fw={600} c="edr-text" truncate>
|
||||
{c.name}
|
||||
</Text>
|
||||
<CompanyTypeBadge type={c.type} />
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
TIN {c.tin}
|
||||
@@ -141,7 +158,9 @@ export default function CustomersPage() {
|
||||
{
|
||||
id: "profiles",
|
||||
header: "Profiles",
|
||||
cell: ({ row }) => <ProfileChips profiles={row.original.companyProfiles} />,
|
||||
cell: ({ row }) => (
|
||||
<ProfileChips profiles={row.original.companyProfiles} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
@@ -247,9 +266,30 @@ export default function CustomersPage() {
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
{ label: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" },
|
||||
{ label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" },
|
||||
{ label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" },
|
||||
{
|
||||
label: "Companies",
|
||||
value: stats?.total ?? "—",
|
||||
icon: Users,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Active",
|
||||
value: stats?.active ?? "—",
|
||||
icon: CheckCircle2,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Pending",
|
||||
value: stats?.pending ?? "—",
|
||||
icon: Clock,
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Pending changes",
|
||||
value: stats?.pendingChanges ?? "—",
|
||||
icon: FilePen,
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Onboarding",
|
||||
value: stats?.onboarding ?? "—",
|
||||
@@ -301,6 +341,7 @@ export default function CustomersPage() {
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Pending approval", value: "pending" },
|
||||
{ label: "Pending changes", value: "pendingChanges" },
|
||||
{ label: "Onboarding", value: "onboarding" },
|
||||
{ label: "Active", value: "active" },
|
||||
]}
|
||||
@@ -327,39 +368,39 @@ export default function CustomersPage() {
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={980}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
|
||||
emptyMessage={
|
||||
debouncedQuery
|
||||
? "No companies match your search."
|
||||
: "No companies yet."
|
||||
}
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
|
||||
emptyMessage={
|
||||
debouncedQuery
|
||||
? "No companies match your search."
|
||||
: "No companies yet."
|
||||
}
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: "Failed to load customers.",
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
@@ -2645,6 +2645,25 @@ export const api = {
|
||||
],
|
||||
),
|
||||
|
||||
/**
|
||||
* Ask the customer to correct one document. Invalidates the documents list
|
||||
* and the company itself, since an open request blocks role approval.
|
||||
*/
|
||||
requestDocumentChange: endpoint<
|
||||
{ companyId: string; fileId: string; note: string },
|
||||
CustomerDocument
|
||||
>(
|
||||
"customers",
|
||||
"requestDocumentChange",
|
||||
({ fileId, note }) =>
|
||||
customersService.requestDocumentChange(fileId, note),
|
||||
undefined,
|
||||
({ companyId }) => [
|
||||
QUERY_KEYS.CUSTOMERS.documents(companyId),
|
||||
QUERY_KEYS.CUSTOMERS.byId(companyId),
|
||||
],
|
||||
),
|
||||
|
||||
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
|
||||
"customers",
|
||||
"setCompanyStatus",
|
||||
|
||||
@@ -164,4 +164,21 @@ export const customersService = {
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask the customer to correct one uploaded document. Narrower than rejecting
|
||||
* the whole role: the customer keeps their other documents and only re-uploads
|
||||
* this one, but the role cannot be approved until they do.
|
||||
*/
|
||||
requestDocumentChange(
|
||||
fileId: string,
|
||||
note: string,
|
||||
): Promise<CustomerDocument> {
|
||||
return apiClient
|
||||
.post<CustomerDocument>(
|
||||
URL_CONSTANTS.COMPANIES.DOCUMENT_REQUEST_CHANGE(fileId),
|
||||
{ note },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -202,6 +202,11 @@ export interface CompanyListFilter {
|
||||
status?: CompanyStatus;
|
||||
/** `true` = submitted applications only; `false` = drafts only; omit for both. */
|
||||
onboardingCompleted?: boolean;
|
||||
/**
|
||||
* `true` = only customers with an open profile change request. They are
|
||||
* already `active`, so `status` alone can never surface them.
|
||||
*/
|
||||
hasPendingChangeRequest?: boolean;
|
||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
@@ -222,6 +227,8 @@ export interface CompanyStats {
|
||||
onboarding: number;
|
||||
suspended: number;
|
||||
blacklisted: number;
|
||||
/** Approved customers whose submitted profile edits are awaiting review. */
|
||||
pendingChanges: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
@@ -264,6 +271,15 @@ export interface CustomerDocument {
|
||||
size: number;
|
||||
uploadedAt: string;
|
||||
url?: string | null;
|
||||
/**
|
||||
* Reviewer verdict. `change_requested` means the customer has been asked to
|
||||
* re-upload a corrected version and the role cannot be approved until they do;
|
||||
* `null` means nobody has reviewed this document.
|
||||
*/
|
||||
reviewStatus?: "change_requested" | "approved" | null;
|
||||
/** The reviewer's reason, shown to the customer verbatim. */
|
||||
reviewNote?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
}
|
||||
|
||||
export type CustomerPaymentStatus =
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@edr/ui-common";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
@@ -174,6 +176,17 @@ export default function TabDocuments({
|
||||
|
||||
const licenseProfiles = profile.companyProfiles;
|
||||
|
||||
// Company documents render through SmartFileInput, which is keyed by field and
|
||||
// has no per-file review slot. Surfacing the outstanding corrections as one
|
||||
// banner keeps the reviewer's notes visible without reshaping that component.
|
||||
const changeRequested = useMemo(
|
||||
() =>
|
||||
(docsQuery.data ?? []).filter(
|
||||
(d) => d.reviewStatus === "change_requested",
|
||||
),
|
||||
[docsQuery.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card padding="lg">
|
||||
@@ -185,6 +198,30 @@ export default function TabDocuments({
|
||||
Upload and manage required business documents
|
||||
</Text>
|
||||
|
||||
{changeRequested.length > 0 && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
mb="lg"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="Some documents need correcting"
|
||||
>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm">
|
||||
Re-upload the documents below. Your account cannot be approved
|
||||
until they are corrected.
|
||||
</Text>
|
||||
{changeRequested.map((d) => (
|
||||
<Text key={d.id} size="sm">
|
||||
<strong>{d.name}</strong>
|
||||
{d.reviewNote ? ` — ${d.reviewNote}` : ""}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{docSettingQuery.isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader2 size={24} className="animate-spin" />
|
||||
@@ -459,22 +496,48 @@ function ProfileLicenseRow({
|
||||
{formatBytes(f.size)}
|
||||
</Text>
|
||||
)}
|
||||
{/* Without the reason the badge is unactionable — the
|
||||
customer would not know what to change. */}
|
||||
{f.reviewStatus === "change_requested" && f.reviewNote && (
|
||||
<Text size="xs" c="red" mt={2}>
|
||||
{f.reviewNote}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{badge && (
|
||||
{/* A reviewer's correction request outranks the staged-file
|
||||
badge: it is the one state the customer must act on. */}
|
||||
{f.reviewStatus === "change_requested" ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
leftSection={<Clock size={11} />}
|
||||
leftSection={<AlertTriangle size={11} />}
|
||||
style={{
|
||||
backgroundColor: badge.bg,
|
||||
color: badge.fg,
|
||||
backgroundColor:
|
||||
"var(--mantine-color-edr-red-soft-0)",
|
||||
color: "var(--mantine-color-edr-red-0)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{badge.label}
|
||||
Change requested
|
||||
</Badge>
|
||||
) : (
|
||||
badge && (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
leftSection={<Clock size={11} />}
|
||||
style={{
|
||||
backgroundColor: badge.bg,
|
||||
color: badge.fg,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
|
||||
<Tooltip label="Replace" withArrow>
|
||||
|
||||
@@ -24,6 +24,14 @@ export interface LicenseFile {
|
||||
mimeType: string;
|
||||
/** `live` = approved; `pending_add`/`pending_remove` = awaiting backoffice review. */
|
||||
status: LicenseFileStatus;
|
||||
/**
|
||||
* A reviewer's verdict on this specific document. `change_requested` means the
|
||||
* customer must upload a corrected version before the role can be approved —
|
||||
* orthogonal to `status`, which tracks the staged add/remove workflow.
|
||||
*/
|
||||
reviewStatus?: "change_requested" | "approved" | null;
|
||||
/** The reviewer's reason, shown to the customer verbatim. */
|
||||
reviewNote?: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalProfileResponse {
|
||||
@@ -121,6 +129,13 @@ export interface CompanyDocument {
|
||||
size: number;
|
||||
uploadedAt: string;
|
||||
url: string;
|
||||
/**
|
||||
* `change_requested` means a reviewer has asked for a corrected version of
|
||||
* this document; the role cannot be approved until it is re-uploaded.
|
||||
*/
|
||||
reviewStatus?: "change_requested" | "approved" | null;
|
||||
/** The reviewer's reason, shown to the customer verbatim. */
|
||||
reviewNote?: string | null;
|
||||
}
|
||||
|
||||
/** A single onboarding document field, as resolved and described by the backend. */
|
||||
|
||||
Reference in New Issue
Block a user