From 8998f68c0f3d61b9fcd078a708b69b13d3b6183d Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 25 May 2026 17:18:00 +0200 Subject: [PATCH] feat(reports-p7): cover-page brand picker (admin-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DashboardReportBuilder grows an optional Cover-page brand picker surfaced only when can('admin', 'manage_settings') AND the user has access to >1 port. Pulls ports from PortContext; default option is "Use active port brand", remaining options are the other ports the user can reach. Choice persists in config.coverBrandPortId; threaded through preview, download (/reports/generate), and queue (/reports/runs) payloads. - render-report.service.ts: when run.config.coverBrandPortId resolves to an accessible port, the cover-page logo + portName come from THAT port's brand kit. Falls back to the source port silently when the override port is missing or stale. Source-port DATA stays — only the cover branding swaps. Useful for cross-port leadership decks. Verified: tsc clean, 1493/1493 vitest. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../builders/dashboard-report-builder.tsx | 53 ++++++++++++++++++- src/lib/services/report-render.service.ts | 21 ++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/components/reports/builders/dashboard-report-builder.tsx b/src/components/reports/builders/dashboard-report-builder.tsx index e344b5b0..751b1265 100644 --- a/src/components/reports/builders/dashboard-report-builder.tsx +++ b/src/components/reports/builders/dashboard-report-builder.tsx @@ -11,6 +11,15 @@ import { DatePicker } from '@/components/ui/date-picker'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { usePermissions } from '@/hooks/use-permissions'; +import { usePortContext } from '@/providers/port-provider'; import { PDF_DASHBOARD_WIDGETS, PDF_DASHBOARD_CATEGORY_LABELS, @@ -60,10 +69,17 @@ export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Pro const [dateFrom, setDateFrom] = useState(initialFrom ?? toIsoLocal(last30)); const [dateTo, setDateTo] = useState(initialTo ?? toIsoLocal(today)); const [outputFormat, setOutputFormat] = useState<'pdf' | 'csv'>('pdf'); + const [coverBrandPortId, setCoverBrandPortId] = useState(''); const [loading, setLoading] = useState(false); const [enqueuing, setEnqueuing] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); + // P7: cover-brand swap — admin-only. The renderer falls back to the + // active port's brand kit when this is empty or invalid. + const { can } = usePermissions(); + const { ports, currentPortId } = usePortContext(); + const canPickBrand = can('admin', 'manage_settings') && ports.length > 1; + const previewPayload = useMemo( () => ({ title: title.trim() || 'Report', @@ -71,11 +87,12 @@ export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Pro kind: 'dashboard' as const, widgetIds: selected, ...(subtitle.trim() ? { subtitle: subtitle.trim() } : {}), + ...(coverBrandPortId ? { coverBrandPortId } : {}), ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}), }, }), - [title, subtitle, selected, dateFrom, dateTo], + [title, subtitle, selected, coverBrandPortId, dateFrom, dateTo], ); function toggle(id: PdfDashboardWidgetId) { @@ -105,6 +122,7 @@ export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Pro kind: 'dashboard', widgetIds: selected, ...(subtitle.trim() ? { subtitle: subtitle.trim() } : {}), + ...(coverBrandPortId ? { coverBrandPortId } : {}), ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}), }, @@ -147,6 +165,7 @@ export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Pro widgetIds: selected, title: title.trim() || 'Report', ...(subtitle.trim() ? { subtitle: subtitle.trim() } : {}), + ...(coverBrandPortId ? { coverBrandPortId } : {}), ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}), }, @@ -266,6 +285,38 @@ export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Pro Sections marked “needs date range” only render when both dates are set.

+ {canPickBrand ? ( +
+ + +

+ Swaps the cover-page logo and port name to the picked brand. The data inside stays + from the active port. +

+
+ ) : null}
diff --git a/src/lib/services/report-render.service.ts b/src/lib/services/report-render.service.ts index ac204126..f7d458c6 100644 --- a/src/lib/services/report-render.service.ts +++ b/src/lib/services/report-render.service.ts @@ -202,11 +202,26 @@ export async function renderReportRun(reportRunId: string): Promise { throw new Error(`Cannot render report ${run.id}: port ${run.portId} not found`); } - const logo = await resolvePortLogo(run.portId).catch(() => ({ + // P7: optional cover-brand swap. When config.coverBrandPortId points + // at a port the rep has access to, the cover-page logo + port name + // come from THAT port's brand kit instead of the report's source + // port. Useful for cross-port leadership decks; falls back to the + // source port when the override port is missing / inaccessible. + const params = (run.config as Record) ?? {}; + const overrideBrandPortId = + typeof params.coverBrandPortId === 'string' && params.coverBrandPortId.length > 0 + ? params.coverBrandPortId + : null; + const brandPortId = overrideBrandPortId ?? run.portId; + const brandPort = + overrideBrandPortId === null + ? port + : ((await db.query.ports.findFirst({ where: eq(ports.id, brandPortId) })) ?? port); + + const logo = await resolvePortLogo(brandPort.id).catch(() => ({ buffer: null as Buffer | null, })); - const ctx: RenderCtx = { portName: port.name, logoBuffer: logo.buffer ?? null }; - const params = (run.config as Record) ?? {}; + const ctx: RenderCtx = { portName: brandPort.name, logoBuffer: logo.buffer ?? null }; const data = await renderer.fetchData(run.portId, params);