feat(customers): notify marketing on returned changes, name actors in history

Three gaps on the backoffice customer detail page:

- Rejecting a change request or sending it back for correction notified
  nobody. Adds CompanyNotifierService.changeRequestReturned, which pings
  the customer desk with the reviewer, the outcome and the note. Marketing
  joins that desk via customers:view + customers:get_notification in the
  role preset — grants still come from the IAM UI, the preset only sets
  the default for new environments.
- submitted_by / reviewed_by / actor_id were stored but never resolved, so
  the History tab could say what changed but never who asked or who sent
  it back. Resolves them through a shared iam-user-name util (deduped from
  the private copy in contract-document-history.service) and renders
  "Requested by" / "Sent back to marketing by" lines. The
  changes_requested badge is relabelled to match the workflow.
- "View" opened an in-page modal one document at a time. Adds
  openFileInNewTab, which opens the tab inside the click gesture and fills
  it once the authenticated fetch resolves, and an "Open all" button that
  loops over the documents table so every file lands in its own tab.
This commit is contained in:
Nathnael
2026-08-17 12:38:06 +00:00
parent 339996d27f
commit 7143ba1040
14 changed files with 335 additions and 89 deletions

View File

@@ -20,11 +20,10 @@ import {
FileX2,
} from "lucide-react";
import { useState } from "react";
import { useFileViewer } from "@edr/ui-common";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { fetchViewableFile } from "@/services/files.service";
import { openFileInNewTab } from "@/services/files.service";
import { api } from "@/services/api";
import type { Company } from "@/types/customer";
import { formatDate, humanize } from "./format";
@@ -227,7 +226,6 @@ export function ChangeRequestReview({ company }: { company: Company }) {
api.customers.requestChangeRequestChanges.mutationOptions(),
);
const { view, viewer } = useFileViewer();
const [actionTarget, setActionTarget] = useState<{
id: string;
kind: "reject" | "request-changes";
@@ -350,10 +348,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button"
size="sm"
onClick={() =>
void fetchViewableFile(
openFileInNewTab(
c.fileId,
c.fileName ?? humanize(c.code),
).then(view)
)
}
style={{
textDecoration:
@@ -382,12 +380,7 @@ export function ChangeRequestReview({ company }: { company: Company }) {
component="button"
type="button"
size="sm"
onClick={() =>
void fetchViewableFile(
fileId,
`Document ${i + 1}`,
).then(view)
}
onClick={() => openFileInNewTab(fileId, `Document ${i + 1}`)}
>
Document {i + 1}
</Anchor>
@@ -421,10 +414,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button"
size="sm"
onClick={() =>
void fetchViewableFile(
openFileInNewTab(
c.fileId,
c.fileName ?? "License document",
).then(view)
)
}
style={{
textDecoration:
@@ -532,8 +525,6 @@ export function ChangeRequestReview({ company }: { company: Company }) {
</Group>
</Stack>
</Modal>
{viewer}
</>
);
}

View File

@@ -1,9 +1,8 @@
import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { FilePlus2, FileX2, History } from "lucide-react";
import { useFileViewer } from "@edr/ui-common";
import { fetchViewableFile } from "@/services/files.service";
import { openFileInNewTab } from "@/services/files.service";
import { api } from "@/services/api";
import type {
Company,
@@ -36,6 +35,12 @@ interface TimelineEntry {
at: string;
note?: string | null;
summary?: string;
/** Who filed the change (the customer, or staff editing during onboarding). */
requestedBy?: string | null;
/** When they filed it — the "asked" half of the ask/decide pair below. */
requestedAt?: string | null;
/** Who decided (approved / rejected / sent it back to marketing). */
decidedBy?: string | null;
fieldDiffs: FieldDiff[];
docDiffs: DocDiff[];
}
@@ -43,10 +48,18 @@ interface TimelineEntry {
const KIND_BADGE: Record<TimelineEntry["kind"], { label: string; color: string }> = {
approved: { label: "Approved", color: "edr-green" },
rejected: { label: "Rejected", color: "red" },
changes_requested: { label: "Changes requested", color: "yellow" },
// Sending a request back is what "reverted to marketing" means here: the
// request stays open and marketing owns the follow-up with the customer.
changes_requested: { label: "Sent back to marketing", color: "yellow" },
revision: { label: "Recorded", color: "blue" },
};
/** "Requested by X" / "Reviewed by X", with the id-less case reading sanely. */
function actorLine(verb: string, who?: string | null, when?: string | null) {
if (!who && !when) return null;
return `${verb}${who ? ` by ${who}` : ""}${when ? ` · ${formatDate(when)}` : ""}`;
}
/**
* Pair adjacent remove-then-add intents into one before/after doc diff — a
* "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together
@@ -132,6 +145,9 @@ function fromChangeRequest(
kind: r.status as TimelineEntry["kind"],
at: r.reviewedAt ?? r.updatedAt,
note: r.note,
requestedBy: r.submittedByName,
requestedAt: r.submittedAt ?? r.createdAt,
decidedBy: r.reviewedByName,
fieldDiffs,
docDiffs,
};
@@ -156,6 +172,7 @@ function fromRevision(rev: CompanyRevision): TimelineEntry {
kind: "revision",
at: rev.createdAt,
summary: rev.summary,
requestedBy: rev.actorName,
fieldDiffs,
docDiffs,
};
@@ -170,7 +187,6 @@ function fromRevision(rev: CompanyRevision): TimelineEntry {
* single answer instead of two places to check.
*/
export function CompanyTimeline({ company }: { company: Company }) {
const { view, viewer } = useFileViewer();
const changeRequestsQuery = useQuery(
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
);
@@ -186,7 +202,7 @@ export function CompanyTimeline({ company }: { company: Company }) {
].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
const openFile = (file: { id: string; name: string }) =>
void fetchViewableFile(file.id, file.name).then(view);
openFileInNewTab(file.id, file.name);
if (entries.length === 0) {
return (
@@ -205,6 +221,20 @@ export function CompanyTimeline({ company }: { company: Company }) {
<Stack gap="md">
{entries.map((entry) => {
const badge = KIND_BADGE[entry.kind];
const requestedLine = actorLine(
entry.kind === "revision" ? "Edited" : "Requested",
entry.requestedBy,
entry.requestedAt,
);
const decidedLine = actorLine(
entry.kind === "changes_requested"
? "Sent back to marketing"
: entry.kind === "rejected"
? "Rejected"
: "Approved",
entry.decidedBy,
entry.kind === "revision" ? null : entry.at,
);
return (
<Card key={entry.id} withBorder>
<Stack gap="sm">
@@ -224,10 +254,33 @@ export function CompanyTimeline({ company }: { company: Company }) {
</Text>
</Group>
{/* Who asked, and who decided. Without this the feed said what
changed and when, but never named a person — the first thing
anyone auditing a returned request needs. */}
{(requestedLine || decidedLine) && (
<Stack gap={2}>
{requestedLine && (
<Text size="xs" c="dimmed">
{requestedLine}
</Text>
)}
{decidedLine && (
<Text size="xs" c="dimmed">
{decidedLine}
</Text>
)}
</Stack>
)}
{entry.note && (
<Alert color="yellow" variant="light">
<Text size="sm">
<strong>Note:</strong> {entry.note}
<strong>
{entry.kind === "changes_requested"
? "What was asked for:"
: "Note:"}
</strong>{" "}
{entry.note}
</Text>
</Alert>
)}
@@ -293,7 +346,6 @@ export function CompanyTimeline({ company }: { company: Company }) {
</Card>
);
})}
{viewer}
</Stack>
);
}