mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 23:35:42 +00:00
feat(freight): non-terminal change-request review + unified customer timeline
Backoffice can now "Request changes" on a pending settings change request without rejecting it outright: a new ChangesRequested status keeps the row open so the customer's next edit appends into the same request instead of starting a fresh cycle, and the reviewer's note persists across that round instead of being cleared on resubmit. Version History and Review History (previously two separate, differently-shaped lists) are merged into one chronological timeline under a new History tab, including document changes shown as a real previous-vs-current diff (both files openable). Bug fixes surfaced while wiring this up: - Replacing a single-file document slot left the old file live alongside the new one instead of retiring it (customer settings + onboarding uploads). - The "previous" file in a document diff 404'd once superseded — the preview route now also matches soft-deleted records. - A document replace was recorded twice in the timeline (once at upload, once again at change-request approval).
This commit is contained in:
@@ -2,7 +2,6 @@ import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
@@ -27,11 +26,11 @@ import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { fetchViewableFile } from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||
import type { Company } from "@/types/customer";
|
||||
import { formatDate, humanize } from "./format";
|
||||
|
||||
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
companyName: "Company name",
|
||||
companyEmail: "Company email",
|
||||
companyPhone: "Company phone",
|
||||
@@ -69,7 +68,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Best-effort current value on the live company for a proposed field key. */
|
||||
function currentValue(company: Company, key: string): string {
|
||||
export function currentValue(company: Company, key: string): string {
|
||||
const c = company as unknown as Record<string, unknown>;
|
||||
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
|
||||
const map: Record<string, unknown> = {
|
||||
@@ -160,7 +159,7 @@ function FaydaIdentityDiff({
|
||||
);
|
||||
}
|
||||
|
||||
function DiffRow({
|
||||
export function DiffRow({
|
||||
label,
|
||||
from,
|
||||
to,
|
||||
@@ -201,8 +200,9 @@ function DiffRow({
|
||||
|
||||
/**
|
||||
* Backoffice review surface for a customer's staged profile edits. Shows the
|
||||
* pending change request as a proposed-vs-current diff with Approve / Reject
|
||||
* (with note) actions, plus a short history of past decisions.
|
||||
* pending change request as a proposed-vs-current diff with Approve / Reject /
|
||||
* Request changes actions. Past decisions live in the History tab's unified
|
||||
* timeline (see {@link CompanyTimeline}), not here.
|
||||
*/
|
||||
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const { user } = useAuth();
|
||||
@@ -216,16 +216,21 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const reject = useMutation(
|
||||
api.customers.rejectChangeRequest.mutationOptions(),
|
||||
);
|
||||
const requestChanges = useMutation(
|
||||
api.customers.requestChangeRequestChanges.mutationOptions(),
|
||||
);
|
||||
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [rejectId, setRejectId] = useState<string | null>(null);
|
||||
const [actionTarget, setActionTarget] = useState<{
|
||||
id: string;
|
||||
kind: "reject" | "request-changes";
|
||||
} | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const requests = query.data ?? [];
|
||||
const pending = requests.find((r) => r.status === "pending");
|
||||
const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
|
||||
|
||||
if (!pending && history.length === 0) return null;
|
||||
if (!pending) return null;
|
||||
|
||||
const proposedKeys = pending
|
||||
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
|
||||
@@ -237,13 +242,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const licenseChanges = pending?.licenseChanges ?? [];
|
||||
const documentChanges = pending?.documentChanges ?? [];
|
||||
|
||||
const confirmReject = () => {
|
||||
if (!rejectId) return;
|
||||
reject.mutate(
|
||||
{ id: rejectId, note: note.trim() },
|
||||
const confirmAction = () => {
|
||||
if (!actionTarget) return;
|
||||
const mutation = actionTarget.kind === "reject" ? reject : requestChanges;
|
||||
mutation.mutate(
|
||||
{ id: actionTarget.id, note: note.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRejectId(null);
|
||||
setActionTarget(null);
|
||||
setNote("");
|
||||
},
|
||||
},
|
||||
@@ -270,6 +276,18 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{pending.note && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
>
|
||||
Changes were requested on an earlier round of this same
|
||||
submission: <strong>{pending.note}</strong> — check whether
|
||||
this resubmission actually addresses it before approving.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{proposedKeys.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{proposedKeys.map((key) => (
|
||||
@@ -418,12 +436,22 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setRejectId(pending.id);
|
||||
setActionTarget({ id: pending.id, kind: "reject" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() => {
|
||||
setActionTarget({ id: pending.id, kind: "request-changes" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={approve.isPending}
|
||||
@@ -437,51 +465,31 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{history.length > 0 && (
|
||||
<Card withBorder>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} c="edr-text">
|
||||
Review history
|
||||
</Text>
|
||||
{history.map((r: CompanyChangeRequest) => (
|
||||
<Group key={r.id} gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Badge
|
||||
color={r.status === "approved" ? "edr-green" : "red"}
|
||||
variant="light"
|
||||
radius="md"
|
||||
tt="capitalize"
|
||||
>
|
||||
{r.status}
|
||||
</Badge>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" c="edr-text">
|
||||
{formatDate(r.reviewedAt ?? r.updatedAt)}
|
||||
</Text>
|
||||
{r.note && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Note: {r.note}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={rejectId !== null}
|
||||
onClose={() => setRejectId(null)}
|
||||
title="Reject changes"
|
||||
opened={actionTarget !== null}
|
||||
onClose={() => setActionTarget(null)}
|
||||
title={
|
||||
actionTarget?.kind === "reject" ? "Reject changes" : "Request changes"
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="red" variant="light" icon={<AlertTriangle size={18} />}>
|
||||
The customer will see this note and can amend and resubmit.
|
||||
<Alert
|
||||
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
>
|
||||
{actionTarget?.kind === "reject"
|
||||
? "The customer will see this note and can amend and resubmit."
|
||||
: "The customer will see this note and can keep editing this same request — no need to start over."}
|
||||
</Alert>
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
label={
|
||||
actionTarget?.kind === "reject"
|
||||
? "Reason for rejection"
|
||||
: "What needs to change"
|
||||
}
|
||||
placeholder="e.g. The company address doesn't match the trade license."
|
||||
autosize
|
||||
minRows={3}
|
||||
@@ -492,18 +500,20 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setRejectId(null)}
|
||||
disabled={reject.isPending}
|
||||
onClick={() => setActionTarget(null)}
|
||||
disabled={reject.isPending || requestChanges.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={reject.isPending}
|
||||
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
||||
loading={reject.isPending || requestChanges.isPending}
|
||||
disabled={note.trim().length === 0}
|
||||
onClick={confirmReject}
|
||||
onClick={confirmAction}
|
||||
>
|
||||
Reject changes
|
||||
{actionTarget?.kind === "reject"
|
||||
? "Reject changes"
|
||||
: "Request changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
Reference in New Issue
Block a user