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:
Nathnael
2026-07-21 07:34:40 +00:00
parent 1cfc0f0fa8
commit fd5aedcec7
22 changed files with 948 additions and 60 deletions

View File

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

View File

@@ -13,6 +13,10 @@ export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
export {
RequestDocumentChangeModal,
type RequestDocumentChangeModalProps,
} from "./RequestDocumentChangeModal";
export {
default as ResetPasswordAction,
type ResetPasswordActionProps,

View File

@@ -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) =>

View File

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

View File

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

View File

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

View File

@@ -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);
},
};

View File

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