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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 {
|
import {
|
||||||
PDF_DASHBOARD_WIDGETS,
|
PDF_DASHBOARD_WIDGETS,
|
||||||
PDF_DASHBOARD_CATEGORY_LABELS,
|
PDF_DASHBOARD_CATEGORY_LABELS,
|
||||||
@@ -60,10 +69,17 @@ export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Pro
|
|||||||
const [dateFrom, setDateFrom] = useState(initialFrom ?? toIsoLocal(last30));
|
const [dateFrom, setDateFrom] = useState(initialFrom ?? toIsoLocal(last30));
|
||||||
const [dateTo, setDateTo] = useState(initialTo ?? toIsoLocal(today));
|
const [dateTo, setDateTo] = useState(initialTo ?? toIsoLocal(today));
|
||||||
const [outputFormat, setOutputFormat] = useState<'pdf' | 'csv'>('pdf');
|
const [outputFormat, setOutputFormat] = useState<'pdf' | 'csv'>('pdf');
|
||||||
|
const [coverBrandPortId, setCoverBrandPortId] = useState<string>('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [enqueuing, setEnqueuing] = useState(false);
|
const [enqueuing, setEnqueuing] = useState(false);
|
||||||
const [previewOpen, setPreviewOpen] = 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(
|
const previewPayload = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
title: title.trim() || 'Report',
|
title: title.trim() || 'Report',
|
||||||
@@ -71,11 +87,12 @@ export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Pro
|
|||||||
kind: 'dashboard' as const,
|
kind: 'dashboard' as const,
|
||||||
widgetIds: selected,
|
widgetIds: selected,
|
||||||
...(subtitle.trim() ? { subtitle: subtitle.trim() } : {}),
|
...(subtitle.trim() ? { subtitle: subtitle.trim() } : {}),
|
||||||
|
...(coverBrandPortId ? { coverBrandPortId } : {}),
|
||||||
...(dateFrom ? { dateFrom } : {}),
|
...(dateFrom ? { dateFrom } : {}),
|
||||||
...(dateTo ? { dateTo } : {}),
|
...(dateTo ? { dateTo } : {}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[title, subtitle, selected, dateFrom, dateTo],
|
[title, subtitle, selected, coverBrandPortId, dateFrom, dateTo],
|
||||||
);
|
);
|
||||||
|
|
||||||
function toggle(id: PdfDashboardWidgetId) {
|
function toggle(id: PdfDashboardWidgetId) {
|
||||||
@@ -105,6 +122,7 @@ export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Pro
|
|||||||
kind: 'dashboard',
|
kind: 'dashboard',
|
||||||
widgetIds: selected,
|
widgetIds: selected,
|
||||||
...(subtitle.trim() ? { subtitle: subtitle.trim() } : {}),
|
...(subtitle.trim() ? { subtitle: subtitle.trim() } : {}),
|
||||||
|
...(coverBrandPortId ? { coverBrandPortId } : {}),
|
||||||
...(dateFrom ? { dateFrom } : {}),
|
...(dateFrom ? { dateFrom } : {}),
|
||||||
...(dateTo ? { dateTo } : {}),
|
...(dateTo ? { dateTo } : {}),
|
||||||
},
|
},
|
||||||
@@ -147,6 +165,7 @@ export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Pro
|
|||||||
widgetIds: selected,
|
widgetIds: selected,
|
||||||
title: title.trim() || 'Report',
|
title: title.trim() || 'Report',
|
||||||
...(subtitle.trim() ? { subtitle: subtitle.trim() } : {}),
|
...(subtitle.trim() ? { subtitle: subtitle.trim() } : {}),
|
||||||
|
...(coverBrandPortId ? { coverBrandPortId } : {}),
|
||||||
...(dateFrom ? { dateFrom } : {}),
|
...(dateFrom ? { dateFrom } : {}),
|
||||||
...(dateTo ? { dateTo } : {}),
|
...(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.
|
Sections marked “needs date range” only render when both dates are set.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<Label>Output format</Label>
|
<Label>Output format</Label>
|
||||||
<div className="flex gap-2">
|
<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`);
|
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,
|
buffer: null as Buffer | null,
|
||||||
}));
|
}));
|
||||||
const ctx: RenderCtx = { portName: port.name, logoBuffer: logo.buffer ?? null };
|
const ctx: RenderCtx = { portName: brandPort.name, logoBuffer: logo.buffer ?? null };
|
||||||
const params = (run.config as Record<string, unknown>) ?? {};
|
|
||||||
|
|
||||||
const data = await renderer.fetchData(run.portId, params);
|
const data = await renderer.fetchData(run.portId, params);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user