mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +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,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 =
|
||||
|
||||
Reference in New Issue
Block a user