diff --git a/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts new file mode 100644 index 000000000..e4a3465d0 --- /dev/null +++ b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts @@ -0,0 +1,49 @@ +import { DataSource } from "typeorm"; + +/** + * `iam.users.name` is a localized object ({ en, am, … }), not a string — a + * plain `String(name)` there yields "[object Object]" in an audit trail. + */ +export interface IamUserRow { + name?: Record | string | null; + username?: string | null; + email?: string | null; +} + +/** Best display name for a user row: English label → any locale → login → email. */ +export function pickUserName(user: IamUserRow): string | null { + const { name } = user; + if (typeof name === "string" && name.trim()) return name.trim(); + if (name && typeof name === "object") { + const localized = + name.en ?? + Object.values(name).find((v) => typeof v === "string" && v.trim()); + if (localized?.trim()) return localized.trim(); + } + return user.username?.trim() || user.email?.trim() || null; +} + +/** + * Display names for a set of IAM user ids — one query for the whole set. + * `iam.users` is owned by the auth system and has no entity here, so it is read + * directly. A miss is not an error: the caller still holds the id and can fall + * back to it. + */ +export async function resolveIamUserNames( + dataSource: DataSource, + userIds: (string | null | undefined)[], +): Promise> { + const resolved = new Map(); + const ids = [...new Set(userIds.filter((id): id is string => Boolean(id)))]; + if (ids.length === 0) return resolved; + + const rows = (await dataSource.query( + `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, + [ids], + )) as Array; + for (const row of rows) { + const name = pickUserName(row); + if (name) resolved.set(row.id, name); + } + return resolved; +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 98b30f006..bf8184833 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,6 +7,7 @@ import { ForbiddenException, } from "@nestjs/common"; import { DataSource, EntityManager } from "typeorm"; +import { resolveIamUserNames } from "../../common/utils/iam-user-name.util"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; @@ -1141,16 +1142,55 @@ export class CompaniesService { return new ProfileResponseDto(profile, live, request); } - /** List a company's change requests, newest first (backoffice review). */ + /** + * List a company's change requests, newest first (backoffice review). Actor + * ids are resolved to display names here — the history screen has to say who + * asked for a change and who sent it back, not print two uuids. + */ async listChangeRequests(companyId: string): Promise { await this.findCompanyById(companyId); - return this.changeRequestRepo.findByCompanyId(companyId); + const requests = await this.changeRequestRepo.findByCompanyId(companyId); + const names = await this.resolveActorNames( + requests.flatMap((r) => [r.submittedBy, r.reviewedBy]), + ); + for (const request of requests) { + request.submittedByName = request.submittedBy + ? (names.get(request.submittedBy) ?? null) + : null; + request.reviewedByName = request.reviewedBy + ? (names.get(request.reviewedBy) ?? null) + : null; + } + return requests; } /** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */ async listCompanyRevisions(companyId: string): Promise { await this.findCompanyById(companyId); - return this.revisionRepo.findByCompanyId(companyId); + const revisions = await this.revisionRepo.findByCompanyId(companyId); + const names = await this.resolveActorNames(revisions.map((r) => r.actorId)); + for (const revision of revisions) { + revision.actorName = revision.actorId + ? (names.get(revision.actorId) ?? null) + : null; + } + return revisions; + } + + /** + * Display names for actor ids, one query for the whole list. A lookup failure + * degrades the history to ids rather than failing the request — the entry is + * still worth showing without the name. + */ + private async resolveActorNames( + actorIds: (string | null | undefined)[], + ): Promise> { + try { + return await resolveIamUserNames(this.dataSource, actorIds); + } catch (err) { + this.logger.warn(`Could not resolve actor names: ${String(err)}`); + return new Map(); + } } /** @@ -1519,6 +1559,7 @@ export class CompaniesService { } await this.discardLicenseChanges(request); await this.discardDocumentChanges(request); + await this.notifyChangeRequestReturned(request, "rejected", note, reviewerId); return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.Rejected, @@ -1556,6 +1597,12 @@ export class CompaniesService { `Change request ${id} is already ${request.status}`, ); } + await this.notifyChangeRequestReturned( + request, + "changes_requested", + note, + reviewerId, + ); return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.ChangesRequested, @@ -1566,6 +1613,35 @@ export class CompaniesService { ); } + /** + * Tell the customer desk a change request came back unapproved. Best-effort: + * a missing company or an unresolvable reviewer name must not fail the + * reviewer's decision, which is already the point of the try/catch. + */ + private async notifyChangeRequestReturned( + request: CompanyChangeRequest, + outcome: "rejected" | "changes_requested", + note: string, + reviewerId?: string, + ): Promise { + try { + const company = await this.companiesRepo.findById(request.companyId); + if (!company) return; + const names = await this.resolveActorNames([reviewerId]); + this.companyNotifier.changeRequestReturned( + company, + request.id, + outcome, + note, + reviewerId ? (names.get(reviewerId) ?? null) : null, + ); + } catch (err) { + this.logger.warn( + `Could not notify the customer desk about ${request.id}: ${String(err)}`, + ); + } + } + async deleteCompany(id: string): Promise { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 6bcd21f08..d3a556f50 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -244,6 +244,35 @@ export class CompanyNotifierService { ); } + /** + * A reviewer did NOT approve a customer's profile changes — they rejected it + * or sent it back for correction. The customer desk (Marketing included, via + * the `customers:get_notification` key) owns the follow-up with the customer, + * so the decision has to reach their inbox; without this it was silent, and + * only visible to whoever happened to reopen the customer's History tab. + */ + changeRequestReturned( + company: Company, + changeRequestId: string, + outcome: "rejected" | "changes_requested", + note: string, + reviewerName?: string | null, + ): void { + const rejected = outcome === "rejected"; + const by = reviewerName?.trim() ? ` by ${reviewerName.trim()}` : ""; + this.logger.log(`CHANGE_REQUEST_${outcome.toUpperCase()} — ${company.id}`); + this.notifyStaff( + company, + rejected + ? "Customer profile changes rejected" + : "Customer profile changes sent back for correction", + `${company.name}'s profile changes were ` + + `${rejected ? "rejected" : "sent back for correction"}${by}. ` + + `Reason: ${note}`, + { changeRequestId, outcome, note, reviewerName: reviewerName ?? null }, + ); + } + // ── Customer-facing: a specific document needs correcting ────────────────── /** diff --git a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts index 4a931dae3..2a4f65708 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts @@ -23,8 +23,12 @@ export class ChangeRequestResponseDto { documentChanges: DocumentChangeIntent[]; note: string | null; submittedBy: string | null; + /** Who filed the request, for the history screen (null when unresolvable). */ + submittedByName: string | null; submittedAt: Date | null; reviewedBy: string | null; + /** Who approved / rejected / sent it back. */ + reviewedByName: string | null; reviewedAt: Date | null; createdAt: Date; updatedAt: Date; @@ -39,8 +43,10 @@ export class ChangeRequestResponseDto { this.documentChanges = req.documents?.documentChanges ?? []; this.note = req.note ?? null; this.submittedBy = req.submittedBy ?? null; + this.submittedByName = req.submittedByName ?? null; this.submittedAt = req.submittedAt ?? null; this.reviewedBy = req.reviewedBy ?? null; + this.reviewedByName = req.reviewedByName ?? null; this.reviewedAt = req.reviewedAt ?? null; this.createdAt = req.createdAt; this.updatedAt = req.updatedAt; diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts index c93c7387c..198a44e06 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts @@ -8,6 +8,8 @@ export class CompanyRevisionResponseDto { id: string; companyId: string; actorId: string | null; + /** Who made the edit, for the history screen (null when unresolvable). */ + actorName: string | null; summary: string; changes: CompanyRevisionChange[]; createdAt: Date; @@ -16,6 +18,7 @@ export class CompanyRevisionResponseDto { this.id = revision.id; this.companyId = revision.companyId; this.actorId = revision.actorId ?? null; + this.actorName = revision.actorName ?? null; this.summary = revision.summary; this.changes = revision.changes ?? []; this.createdAt = revision.createdAt; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index 2ba39ecad..02b49f849 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -110,4 +110,12 @@ export class CompanyChangeRequest extends BaseEntity { @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) reviewedAt?: Date | null; + + /** + * Display names for {@link submittedBy} / {@link reviewedBy}, resolved from + * `iam.users` on read. Not columns — the history screen has to name the + * person who asked for the change, and an opaque uuid does not. + */ + submittedByName?: string | null; + reviewedByName?: string | null; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts index 222a8364f..533f08d7a 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts @@ -43,4 +43,10 @@ export class CompanyRevision extends BaseEntity { @Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` }) changes!: CompanyRevisionChange[]; + + /** + * Display name for {@link actorId}, resolved from `iam.users` on read. Not a + * column — history has to name who made the edit, and a uuid does not. + */ + actorName?: string | null; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts index e38f737c6..1f1469182 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; +import { resolveIamUserNames } from '../../common/utils/iam-user-name.util'; import { ContractDocumentChange, diffSnapshots, @@ -20,28 +21,6 @@ export interface RecordRevisionInput { stepId?: string | null; } -/** - * `iam.users.name` is a localized object ({ en, am, … }), not a string — a - * plain `String(name)` there yields "[object Object]" in the audit trail. - */ -interface IamUserRow { - name?: Record | string | null; - username?: string | null; - email?: string | null; -} - -/** Best display name for a user row: English label → any locale → login → email. */ -function pickUserName(user: IamUserRow): string | null { - const { name } = user; - if (typeof name === 'string' && name.trim()) return name.trim(); - if (name && typeof name === 'object') { - const localized = - name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim()); - if (localized?.trim()) return localized.trim(); - } - return user.username?.trim() || user.email?.trim() || null; -} - /** Pre-computed changes (contract fields), rather than a document diff. */ export interface RecordChangesInput { contractId: string; @@ -120,23 +99,12 @@ export class ContractDocumentHistoryService { private async resolveActorNames( actorIds: string[], ): Promise> { - const resolved = new Map(); - const ids = [...new Set(actorIds.filter(Boolean))]; - if (ids.length === 0) return resolved; - try { - const rows = (await this.dataSource.query( - `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, - [ids], - )) as Array; - for (const row of rows) { - const name = pickUserName(row); - if (name) resolved.set(row.id, name); - } + return await resolveIamUserNames(this.dataSource, actorIds); } catch (err) { this.logger.warn(`Could not resolve actor names: ${String(err)}`); + return new Map(); } - return resolved; } /** Revision history for a contract, newest first. */ diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index adbd22584..3fde18648 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -2484,6 +2484,12 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.suspend, FREIGHT_PERMS.contracts.editDocument, ...BOOKING_DESK_NOTIFICATION_KEYS, + // Marketing follows up with the customer when a reviewer sends profile + // changes back, so they sit on the customer desk: read-only on the customer + // record (no verify/deactivate — the decision stays with the chief) plus the + // desk key the change-request pings are addressed to. + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.customers.getNotification, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index 65c4f25e3..7f4cf30ce 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -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} @@ -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 }) { - - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx index cd421d9bd..8469e4031 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx @@ -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 = { 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 }) { {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 ( @@ -224,10 +254,33 @@ export function CompanyTimeline({ company }: { company: Company }) { + {/* 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) && ( + + {requestedLine && ( + + {requestedLine} + + )} + {decidedLine && ( + + {decidedLine} + + )} + + )} + {entry.note && ( - Note: {entry.note} + + {entry.kind === "changes_requested" + ? "What was asked for:" + : "Note:"} + {" "} + {entry.note} )} @@ -293,7 +346,6 @@ export function CompanyTimeline({ company }: { company: Company }) { ); })} - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index b395cc710..40185f990 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -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)} > @@ -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[] = 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) } > @@ -564,7 +553,7 @@ export default function CustomerDetailPage() { ), }, ], - [view, canRequestDocChange], + [canRequestDocChange], ); const paymentColumns: ColumnDef[] = 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) } > @@ -1280,6 +1265,23 @@ export default function CustomerDetailPage() { {/* DOCUMENTS */} + {/* 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. */} + + + + - 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} ); } diff --git a/apps/edr-freight-web/backoffice/src/services/files.service.ts b/apps/edr-freight-web/backoffice/src/services/files.service.ts index 038b5a86d..c86a3beb4 100644 --- a/apps/edr-freight-web/backoffice/src/services/files.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/files.service.ts @@ -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 ``: + * - `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 * (/