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:
@@ -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 “needs date range” 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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user