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

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