feat(reports-p7): cover-page brand picker (admin-only)

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 17:18:00 +02:00
parent d32e557e56
commit 8998f68c0f
2 changed files with 70 additions and 4 deletions

View File

@@ -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<string>('');
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 &ldquo;needs date range&rdquo; only render when both dates are set.
</p>
</div>
{canPickBrand ? (
<div className="space-y-1">
<Label htmlFor="export-brand">
Cover-page brand{' '}
<span className="text-xs font-normal text-muted-foreground">
(admin-only, defaults to the active port)
</span>
</Label>
<Select
value={coverBrandPortId || 'default'}
onValueChange={(v) => setCoverBrandPortId(v === 'default' ? '' : v)}
>
<SelectTrigger id="export-brand" className="max-w-md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Use active port brand</SelectItem>
{ports
.filter((p) => p.id !== currentPortId)
.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Swaps the cover-page logo and port name to the picked brand. The data inside stays
from the active port.
</p>
</div>
) : null}
<div className="space-y-1">
<Label>Output format</Label>
<div className="flex gap-2">

View File

@@ -202,11 +202,26 @@ export async function renderReportRun(reportRunId: string): Promise<ReportRun> {
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<string, unknown>) ?? {};
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<string, unknown>) ?? {};
const ctx: RenderCtx = { portName: brandPort.name, logoBuffer: logo.buffer ?? null };
const data = await renderer.fetchData(run.portId, params);