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

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