mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +00:00
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:
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Banknote,
|
||||
Contact,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FileSignature,
|
||||
FileText,
|
||||
@@ -69,7 +70,7 @@ import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
downloadBookingFile,
|
||||
fetchViewableFile,
|
||||
openFileInNewTab,
|
||||
} from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
@@ -81,12 +82,7 @@ import type {
|
||||
} from "@/types/customer";
|
||||
import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
DataTable,
|
||||
useFileViewer,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
|
||||
@@ -146,7 +142,6 @@ const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
|
||||
export default function CustomerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: company, isLoading } = useQuery(
|
||||
@@ -271,9 +266,7 @@ export default function CustomerDetailPage() {
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`View ${f.name}`}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(f.id, f.name).then(view)
|
||||
}
|
||||
onClick={() => openFileInNewTab(f.id, f.name)}
|
||||
>
|
||||
<Eye size={14} />
|
||||
</ActionIcon>
|
||||
@@ -282,9 +275,7 @@ export default function CustomerDetailPage() {
|
||||
type="button"
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(f.id, f.name).then(view)
|
||||
}
|
||||
onClick={() => openFileInNewTab(f.id, f.name)}
|
||||
style={{
|
||||
maxWidth: 170,
|
||||
textAlign: "left",
|
||||
@@ -339,7 +330,7 @@ export default function CustomerDetailPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[view, canReview],
|
||||
[canReview],
|
||||
);
|
||||
|
||||
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
|
||||
@@ -522,9 +513,7 @@ export default function CustomerDetailPage() {
|
||||
aria-label="View"
|
||||
data-stop-row-click
|
||||
onClick={() =>
|
||||
void fetchViewableFile(row.original.id, row.original.name).then(
|
||||
view,
|
||||
)
|
||||
openFileInNewTab(row.original.id, row.original.name)
|
||||
}
|
||||
>
|
||||
<Eye size={16} />
|
||||
@@ -564,7 +553,7 @@ export default function CustomerDetailPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[view, canRequestDocChange],
|
||||
[canRequestDocChange],
|
||||
);
|
||||
|
||||
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
||||
@@ -1163,9 +1152,7 @@ export default function CustomerDetailPage() {
|
||||
lineClamp={1}
|
||||
style={{ flex: 1, textAlign: "left" }}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.id, doc.name).then(
|
||||
view,
|
||||
)
|
||||
openFileInNewTab(doc.id, doc.name)
|
||||
}
|
||||
>
|
||||
{doc.name}
|
||||
@@ -1176,9 +1163,7 @@ export default function CustomerDetailPage() {
|
||||
color="gray"
|
||||
aria-label={`Preview ${doc.name}`}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.id, doc.name).then(
|
||||
view,
|
||||
)
|
||||
openFileInNewTab(doc.id, doc.name)
|
||||
}
|
||||
>
|
||||
<Eye size={15} />
|
||||
@@ -1280,6 +1265,23 @@ export default function CustomerDetailPage() {
|
||||
{/* DOCUMENTS */}
|
||||
<Tabs.Panel value="documents" pt="lg">
|
||||
<Stack gap="lg">
|
||||
{/* Reviewing a customer means reading every document, so offer the
|
||||
whole set at once — each opens in its own tab. The loop is
|
||||
synchronous inside the click handler on purpose: that is what
|
||||
keeps the browser treating all of them as user-initiated. */}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<ExternalLink size={16} />}
|
||||
disabled={documents.length === 0}
|
||||
onClick={() =>
|
||||
documents.forEach((d) => openFileInNewTab(d.id, d.name))
|
||||
}
|
||||
>
|
||||
Open all {documents.length > 0 && `(${documents.length})`}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<TableCard minWidth={760}>
|
||||
<DataTable
|
||||
columns={documentColumns}
|
||||
@@ -1316,9 +1318,7 @@ export default function CustomerDetailPage() {
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void fetchViewableFile(f.id, f.name).then(view)
|
||||
}
|
||||
onClick={() => openFileInNewTab(f.id, f.name)}
|
||||
size="xs"
|
||||
style={{
|
||||
textDecoration:
|
||||
@@ -1424,7 +1424,6 @@ export default function CustomerDetailPage() {
|
||||
onClose={() => setChangeRequestDoc(null)}
|
||||
/>
|
||||
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,53 @@ export async function downloadBookingFile(
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a stored file in its own browser tab.
|
||||
*
|
||||
* Two things make this less trivial than an `<a target="_blank">`:
|
||||
* - `GET /files/:id` is authenticated, so the bytes have to come through the
|
||||
* axios client and be handed over as a blob URL (same reason as
|
||||
* {@link fetchViewableFile}).
|
||||
* - The tab therefore has to be opened *synchronously*, inside the click
|
||||
* gesture, and filled once the download resolves — a `window.open()` after an
|
||||
* `await` is blocked as a popup. That also means a loop over several
|
||||
* documents opens one tab each, all within the same gesture.
|
||||
*
|
||||
* `noopener` is deliberately not passed: it makes `window.open` return null, and
|
||||
* the handle is what lets us navigate the tab. `opener` is nulled instead.
|
||||
*/
|
||||
export function openFileInNewTab(id: string, filename: string): void {
|
||||
const tab = window.open("", "_blank");
|
||||
if (tab) {
|
||||
tab.opener = null;
|
||||
tab.document.title = filename;
|
||||
if (tab.document.body) {
|
||||
tab.document.body.textContent = `Opening ${filename}…`;
|
||||
}
|
||||
}
|
||||
void filesService.download(id).then(
|
||||
(blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (tab) tab.location.replace(url);
|
||||
// Popup blocked — fall back to a save, so the click still does something.
|
||||
else {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
// Revoking immediately would cancel the tab's own load of the URL.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (tab?.document.body) {
|
||||
tab.document.body.textContent = `Could not open ${filename}.`;
|
||||
}
|
||||
console.error(`Failed to open file ${id}`, error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /files/:id is authenticated (global JwtGuard) — raw browser loads
|
||||
* (<img>/<iframe>/<a href>) carry no Bearer token and 401. Fetch the bytes
|
||||
|
||||
@@ -111,7 +111,11 @@ export interface CompanyChangeRequest {
|
||||
/** Staged company-document add/remove intents (e.g. the PoA letter). */
|
||||
documentChanges: DocumentChangeIntent[];
|
||||
note: string | null;
|
||||
/** Who filed the request — resolved from `iam.users`, null when unknown. */
|
||||
submittedByName: string | null;
|
||||
submittedAt: string | null;
|
||||
/** Who approved / rejected / sent it back. */
|
||||
reviewedByName: string | null;
|
||||
reviewedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -139,6 +143,8 @@ export interface CompanyRevision {
|
||||
id: string;
|
||||
companyId: string;
|
||||
actorId: string | null;
|
||||
/** Who made the edit — resolved from `iam.users`, null when unknown. */
|
||||
actorName: string | null;
|
||||
summary: string;
|
||||
changes: CompanyRevisionChange[];
|
||||
createdAt: string;
|
||||
|
||||
Reference in New Issue
Block a user