From d099efa1fa88a854bed088463f6559bc9214d567 Mon Sep 17 00:00:00 2001 From: Scott Carlton Date: Fri, 30 Oct 2026 09:25:31 -0600 Subject: [PATCH] feat(reports): SCO-151 export any report as a PDF Reports could only be exported as CSV. Export PDF builds the document on the server from the same filtered rows as the page: title, filter and date, the SCO-150 bar chart, then the table with its totals. The report loader moved into lib/server/reports/load-report.ts so the page and the PDF share one scoping path. Part of SCO-151 Co-Authored-By: Claude Opus 5.5 --- docs/brd/permissions-implementation-map.md | 3 +- src/lib/server/report-pdf.test.ts | 78 +++ src/lib/server/report-pdf.ts | 182 +++++++ src/lib/server/reports/load-report.ts | 536 +++++++++++++++++++ src/lib/utils/report-columns.test.ts | 84 +++ src/lib/utils/report-columns.ts | 243 +++++++++ src/routes/api/reports/[slug]/pdf/+server.ts | 50 ++ src/routes/reports/[slug]/+page.server.ts | 532 +----------------- src/routes/reports/[slug]/+page.svelte | 10 + tests/rls/admin-bypass.test.ts | 2 +- 10 files changed, 1189 insertions(+), 531 deletions(-) create mode 100644 src/lib/server/report-pdf.test.ts create mode 100644 src/lib/server/report-pdf.ts create mode 100644 src/lib/server/reports/load-report.ts create mode 100644 src/lib/utils/report-columns.test.ts create mode 100644 src/lib/utils/report-columns.ts create mode 100644 src/routes/api/reports/[slug]/pdf/+server.ts diff --git a/docs/brd/permissions-implementation-map.md b/docs/brd/permissions-implementation-map.md index e8139776..31a08aeb 100644 --- a/docs/brd/permissions-implementation-map.md +++ b/docs/brd/permissions-implementation-map.md @@ -269,7 +269,7 @@ For each route with a `+page.server.ts`, the tables queried and whether it needs | `/insight` | `brands`, `products`, `accounts`, `orders`, `season_deliveries`, `show_dates`, `show_visits`, `appointments`, `commission_overrides`, `insight_actions`, `organization_members`, `order_lines` | Own-org | Analytics are org-scoped | | `/products` | `brands`, `products`, `seasons` | RLS handles visibility | Shows own + federated products | | `/reports` | _(none)_ | Static | Report index | -| `/reports/[slug]` | `orders`, `organization_members`, `member_brand_commissions`, `show_dates`, `show_visits`, `appointments` | Own-org `.eq('organization_id', org.id)` | Reports are org-scoped | +| `/reports/[slug]` | `orders`, `organization_members`, `member_brand_commissions`, `show_dates`, `show_visits`, `appointments` | Own-org `.eq('organization_id', org.id)` | Reports are org-scoped. Load lives in `loadReport()`, shared with `/api/reports/[slug]/pdf` | | `/seasons` | _(redirects or static)_ | -- | | | `/shows` | _(redirects or static)_ | -- | | | `/shop` | `brands`, `products` | Buyer RLS (`get_buyer_brand_ids`) | Buyer portal | @@ -385,6 +385,7 @@ For each route with a `+page.server.ts`, the tables queried and whether it needs | `/api/orders/[id]/lines` | PATCH | `orders`, `federated_order_links`, `order_lines` | Own-org OR active `federated_order_links` via `loadOrderForOrg`; role + status gate | BOA can edit lines on a federated order. Canonical implementation of the order-authorization rule. | | `/api/returns/[id]/transition` | POST | `return_authorizations`, `return_lines`, `product_variants` | **Issuing org only**, via `loadIssuingOrgReturn` | Approve, decline, receive, cancel. Brand-only (admin/owner/member); reps and buyers can only request. Decline requires a reason. Receive goes through `receive_return()` as the signed-in user so restock and transition are atomic. `ra_number` is assigned by the approval trigger, never here. | | `/api/returns/credit-memos/export` | GET | `return_authorizations`, `accounts`, `brands`, `invoices` (applied) | **Own-org** `.eq('organization_id', currentOrgId)`, via the RLS client; brand admin/owner/member | CSV of issued credit memos for reconciliation. Own-org, not federated: a rep or buyer who can read a memo is reading the brand's books, not exporting their own. Free-text cells are formula-neutralised. | +| `/api/reports/[slug]/pdf` | GET | Same as `/reports/[slug]` | Own-org, via the shared `loadReport()` (`src/lib/server/reports/load-report.ts`) | Signed-in with an org; same access as the report page, which already offers CSV. Rows, brand scope and sales scope come from the loader the page uses, so the PDF can never show more than the page (SCO-151). | | `/api/returns/[id]/credit-memo/pdf` | GET | `return_authorizations`, `return_lines`, `invoices` (applied), `accounts` | RLS (`locals.supabase`): whoever can read the return; guest excluded | 404 for a return the caller cannot see and for one with no memo yet, so a memo's existence does not leak. Bill-to from the credited invoice's frozen snapshot, else the account. | | `/api/returns/[id]/credit-memo/send` | POST | `return_authorizations`, `return_lines`, `accounts`, `account_locations` | **Issuing org only**, via `loadIssuingOrgReturn`; admin/owner/member | Emails an issued memo. Recipient must be a contact already on the account (`allowedRecipients`), never free text. 409 before the memo is issued. Logged against `related_type = 'credit_memo'`. | | `/api/invoices/[id]/pdf` | GET | `invoices`, `invoice_lines` | Issuing org, OR `order_org_id` with `status <> 'draft'`, via `loadInvoiceForOrg` | Serves drafts to the issuing brand org only. A rep or buyer gets 404 for a draft, identical to not-found, so draft existence does not leak. | diff --git a/src/lib/server/report-pdf.test.ts b/src/lib/server/report-pdf.test.ts new file mode 100644 index 00000000..73c70dc6 --- /dev/null +++ b/src/lib/server/report-pdf.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { PDFDocument } from 'pdf-lib'; +import { generateReportPdf } from './report-pdf.js'; +import { buildReportChart } from '$lib/utils/report-chart.js'; +import { reportTable } from '$lib/utils/report-columns.js'; + +/** + * Smoke coverage, in the spirit of invoice-pdf.test.ts: no visual diffing, + * but the failures a layout change causes (a throw on a missing field, a crash + * paginating, an empty document) all surface here. + */ + +const salesRows = (n: number) => + Array.from({ length: n }, (_, i) => ({ + name: `Account ${i + 1}`, + orders: i + 1, + revenue: (n - i) * 100 + })); + +async function pages(bytes: Uint8Array) { + return (await PDFDocument.load(bytes)).getPageCount(); +} + +describe('generateReportPdf', () => { + it('renders a chart and a table with totals', async () => { + const rows = salesRows(14); + const bytes = await generateReportPdf({ + title: 'Sales by Account', + orgName: 'Denise Org', + filterLabel: 'Year 2026', + generatedAt: new Date('2026-10-30'), + chart: buildReportChart('sales-by-account', undefined, rows), + table: reportTable('sales-by-account', undefined)!, + rows + }); + expect(await pages(bytes)).toBe(1); + }); + + it('paginates a long table', async () => { + const rows = salesRows(120); + const bytes = await generateReportPdf({ + title: 'Sales by Account', + orgName: null, + filterLabel: 'Year 2026', + generatedAt: new Date(), + chart: buildReportChart('sales-by-account', undefined, rows), + table: reportTable('sales-by-account', undefined)!, + rows + }); + expect(await pages(bytes)).toBeGreaterThan(1); + }); + + it('renders an empty report without a chart', async () => { + const bytes = await generateReportPdf({ + title: 'Order Pipeline', + orgName: 'Acme', + filterLabel: 'Current pipeline', + generatedAt: new Date(), + chart: null, + table: reportTable('pipeline', undefined)!, + rows: [] + }); + expect(await pages(bytes)).toBe(1); + }); + + it('survives missing fields on the widest table', async () => { + const bytes = await generateReportPdf({ + title: 'Account Penetration', + orgName: 'Acme', + filterLabel: 'Year 2026', + generatedAt: new Date(), + chart: null, + table: reportTable('account-penetration', 'brand')!, + rows: [{ businessName: 'A very long account name that has to be truncated in its column' }] + }); + expect(await pages(bytes)).toBe(1); + }); +}); diff --git a/src/lib/server/report-pdf.ts b/src/lib/server/report-pdf.ts new file mode 100644 index 00000000..b13e0cfc --- /dev/null +++ b/src/lib/server/report-pdf.ts @@ -0,0 +1,182 @@ +import type { PDFFont, rgb } from 'pdf-lib'; +import { createPdfCanvas, type PdfCanvas } from './pdf-layout.js'; +import type { ReportChart } from '$lib/utils/report-chart.js'; +import type { ReportTable } from '$lib/utils/report-columns.js'; + +/** + * A report as a PDF (SCO-151): title block, the SCO-150 bar chart, then the + * table. Built from the same rows, chart and columns the page renders, so the + * document is the page on paper, filter included. + * + * Uses the shared canvas in `pdf-layout.ts`, the same page setup and type scale + * as order, invoice and credit memo PDFs. + */ + +export interface ReportPdfInput { + title: string; + orgName: string | null; + /** The filter the rows were loaded with, e.g. "Year 2026". */ + filterLabel: string; + generatedAt: Date; + chart: ReportChart | null; + table: ReportTable; + rows: Record[]; +} + +const currency = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0 +}); + +/** Bars that carry a printed value, matching the chart on the page. */ +const LABELLED = 3; + +export async function generateReportPdf(input: ReportPdfInput): Promise { + const c = await createPdfCanvas(); + const { gray } = c.colors; + + // --- Title block --- + c.text(input.title, c.margin, c.y, { font: c.bold, size: 20 }); + if (input.orgName) c.rightText(input.orgName, c.y, { size: 11, color: gray }); + c.y -= 18; + + const generated = input.generatedAt.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + c.text(input.filterLabel, c.margin, c.y, { size: 10, color: gray }); + c.rightText(`Generated ${generated}`, c.y, { size: 10, color: gray }); + c.y -= 10; + c.divider(); + + if (input.chart) drawChart(c, input.chart); + + if (input.rows.length === 0) { + c.text('No data for this period.', c.margin, c.y, { size: 10, color: gray }); + } else { + drawTable(c, input.table, input.rows); + } + + return c.save(); +} + +function drawChart(c: PdfCanvas, chart: ReportChart): void { + const rowHeight = 15; + const labelWidth = 150; + const valueWidth = 70; + const barX = c.margin + labelWidth + 8; + const barMaxWidth = c.contentWidth - labelWidth - 8 - valueWidth - 8; + const barHeight = 7; + + const format = (v: number) => + chart.format === 'percent' ? `${Math.round(v)}%` : currency.format(v); + const max = chart.format === 'percent' ? 100 : Math.max(...chart.bars.map((b) => b.value), 0); + const labelled = new Set( + chart.bars + .map((b, i) => ({ v: b.value, i })) + .sort((a, b) => b.v - a.v) + .slice(0, LABELLED) + .map((x) => x.i) + ); + + c.ensureSpace(24 + rowHeight * Math.min(chart.bars.length, 4)); + c.text(chart.title, c.margin, c.y, { font: c.bold, size: 11 }); + c.y -= 18; + + chart.bars.forEach((bar, i) => { + c.ensureSpace(rowHeight + 20); + const color = bar.isOther ? c.colors.gray : c.colors.black; + const label = c.truncate(bar.label, labelWidth, c.regular, 9); + c.text(label, c.margin, c.y - 9, { size: 9, color: bar.isOther ? c.colors.gray : undefined }); + + // Baseline, then the bar. A tiny nonzero value keeps a sliver, as on screen. + c.rect(barX, c.y - rowHeight + 2, 0.75, rowHeight - 2, c.colors.lightGray); + if (max > 0 && bar.value > 0) { + const w = Math.max((Math.min(bar.value, max) / max) * barMaxWidth, 1); + c.rect(barX, c.y - 11, w, barHeight, color); + } + + if (labelled.has(i)) { + c.rightText(format(bar.value), c.y - 9, { size: 9, color: c.colors.gray }); + } + c.y -= rowHeight; + }); + + c.y -= 12; +} + +function drawTable(c: PdfCanvas, table: ReportTable, rows: Record[]): void { + const colGap = 5; + const rowHeight = 18; + const headerHeight = 22; + const pad = 5; + + const totalWeight = table.columns.reduce((s, col) => s + col.weight, 0); + const usable = c.contentWidth - pad * 2 - colGap * (table.columns.length - 1); + const widths = table.columns.map((col) => (col.weight / totalWeight) * usable); + + const drawCells = ( + cells: string[], + yText: number, + options: { font: PDFFont; size: number; color?: ReturnType } + ) => { + let x = c.margin + pad; + table.columns.forEach((col, i) => { + const display = c.truncate(cells[i] ?? '', widths[i], options.font, options.size); + const textX = + col.align === 'right' + ? x + widths[i] - options.font.widthOfTextAtSize(display, options.size) + : x; + c.text(display, textX, yText, { + font: options.font, + size: options.size, + color: options.color + }); + x += widths[i] + colGap; + }); + }; + + const drawHeader = () => { + c.rect(c.margin, c.y - headerHeight + 4, c.contentWidth, headerHeight, c.colors.headerBg); + drawCells( + table.columns.map((col) => col.label), + c.y - headerHeight + 11, + { font: c.bold, size: 8, color: c.colors.white } + ); + c.y -= headerHeight + 2; + }; + + c.ensureSpace(headerHeight + rowHeight * Math.min(rows.length, 3) + 40); + drawHeader(); + + rows.forEach((row, i) => { + // A new page repeats the header, so a long table stays readable. + const before = c.y; + c.ensureSpace(rowHeight + 30); + if (c.y > before) drawHeader(); + + if (i % 2 === 1) { + c.rect(c.margin, c.y - rowHeight + 6, c.contentWidth, rowHeight, c.colors.lightGray); + } + drawCells( + table.columns.map((col) => col.value(row)), + c.y - rowHeight + 10, + { font: c.regular, size: 9 } + ); + c.y -= rowHeight; + }); + + if (table.hasTotals) { + c.ensureSpace(rowHeight + 10); + c.rect(c.margin, c.y + 4, c.contentWidth, 1, c.colors.gray); + drawCells( + table.columns.map((col) => col.total?.(rows) ?? ''), + c.y - rowHeight + 10, + { font: c.bold, size: 9 } + ); + c.y -= rowHeight; + } +} diff --git a/src/lib/server/reports/load-report.ts b/src/lib/server/reports/load-report.ts new file mode 100644 index 00000000..43bbe4e4 --- /dev/null +++ b/src/lib/server/reports/load-report.ts @@ -0,0 +1,536 @@ +import { error } from '@sveltejs/kit'; +import type { OrgType } from '$lib/types/database'; +import { supabaseAdmin } from '$lib/server/supabase.js'; +import { loadManagerScope } from '$lib/server/scoping.js'; +import { getNxBlsrBrandOrgIds, isNxBlsr } from '$lib/server/nx-blsr'; + +const repReportTitles: Record = { + 'sales-by-brand': 'Sales by Brand', + 'sales-by-account': 'Sales by Account', + 'sales-by-territory': 'Sales by Territory', + 'sales-by-rep': 'Sales by Rep', + commission: 'Commission Report', + pipeline: 'Order Pipeline', + 'season-comparison': 'Season Comparison', + 'show-performance': 'Show Performance' +}; + +const brandReportTitles: Record = { + 'sales-by-rep': 'Sales by Rep', + 'product-performance': 'Product Performance', + 'territory-coverage': 'Territory Coverage', + 'account-penetration': 'Account Penetration', + 'season-sell-through': 'Season Sell-Through', + pipeline: 'Order Pipeline' +}; + +function titleFor(orgType: OrgType, slug: string, nxBlsr: boolean): string | null { + // Nx-BLSR users see the brand-side report set unioned across their + // brand-org memberships. Same titles a single brand-org BLSR would see. + if (nxBlsr || orgType === 'brand') return brandReportTitles[slug] ?? null; + return repReportTitles[slug] ?? null; +} + +/** + * Every report's rows for the signed-in viewer, scoped exactly as the report + * page shows them. Shared by the page, its PDF export, and share snapshots + * (SCO-151), so all three can never disagree about what the viewer may see. + * + * Moved verbatim from the page's load; `report` is the route slug. + */ +export async function loadReport(locals: App.Locals, report: string, url: URL) { + const { supabase, organization, allMemberships } = locals; + + // Nx-BLSR scoping: sales-role member of 2+ brand-orgs. orgScope unions + // every brand-org they belong to; existing single-org callers see + // `[orgId]` so behavior is a strict superset. + const brandOrgIds = getNxBlsrBrandOrgIds(allMemberships); + const nxBlsr = isNxBlsr(brandOrgIds); + + const title = titleFor(locals.orgType, report, nxBlsr); + if (!title) throw error(404, 'Report not found'); + if (!organization) return { report, title, year: new Date().getFullYear(), rows: [] }; + + const orgId = organization.id; + const orgScope = nxBlsr ? brandOrgIds : [orgId]; + const year = parseInt(url.searchParams.get('year') ?? '') || new Date().getFullYear(); + const brandScope = locals.brandScope as string[] | null; + const isSales = locals.membership?.role === 'sales'; + + const userId = locals.user?.id; + const salesScope = + isSales && userId + ? await loadManagerScope(supabaseAdmin, userId, locals.membership?.id ?? null) + : null; + + // Precompute the set of org accounts this sales user covers via territory + // (plus untagged accounts, so orgs that haven't tagged don't strand sales). + let salesAccountIds: string[] | null = null; + if (salesScope?.hasTerritoryScope) { + const territoryIds = salesScope.visibleTerritoryIds.join(','); + const { data: rows } = await supabaseAdmin + .from('accounts') + .select('id') + .eq('organization_id', orgId) + .or(`territory_id.in.(${territoryIds}),territory_id.is.null`); + salesAccountIds = (rows ?? []).map((a: { id: string }) => a.id); + } + + // Helper: apply brand scope filter to any query with a brand_id column + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function scopeByBrand(query: any) { + return brandScope && brandScope.length > 0 ? query.in('brand_id', brandScope) : query; + } + + // Helper: scope sales users to their own records + managed reports' records + // + orders on accounts they cover via territory (including untagged). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function scopeByRep(query: any) { + if (!salesScope) return query; + if (salesAccountIds && salesAccountIds.length > 0) { + const createdByClause = salesScope.createdByScope.join(','); + return query.or( + `created_by.in.(${createdByClause}),account_id.in.(${salesAccountIds.join(',')})` + ); + } + return query.in('created_by', salesScope.createdByScope); + } + + switch (report) { + case 'sales-by-brand': { + const { data: orders } = await scopeByRep( + scopeByBrand( + supabase + .from('orders') + .select('brand_id, total_amount, brands(name)') + .eq('organization_id', orgId) + .eq('order_year', year) + .neq('status', 'cancelled') + ) + ); + + const brands = new Map(); + for (const o of orders ?? []) { + const brandsJoin = o.brands as { name?: string } | { name?: string }[] | null; + const name = + (Array.isArray(brandsJoin) ? brandsJoin[0]?.name : brandsJoin?.name) ?? 'Unknown'; + const existing = brands.get(o.brand_id); + if (existing) { + existing.orders++; + existing.revenue += Number(o.total_amount); + } else brands.set(o.brand_id, { name, orders: 1, revenue: Number(o.total_amount) }); + } + return { + report, + title, + year, + rows: Array.from(brands.values()).sort((a, b) => b.revenue - a.revenue) + }; + } + + case 'sales-by-account': { + const { data: orders } = await scopeByRep( + scopeByBrand( + supabase + .from('orders') + .select('account_id, total_amount, accounts(business_name)') + .eq('organization_id', orgId) + .eq('order_year', year) + .neq('status', 'cancelled') + ) + ); + + const accounts = new Map(); + for (const o of orders ?? []) { + const accountsJoin = o.accounts as + | { business_name?: string } + | { business_name?: string }[] + | null; + const name = + (Array.isArray(accountsJoin) + ? accountsJoin[0]?.business_name + : accountsJoin?.business_name) ?? 'Unknown'; + const existing = accounts.get(o.account_id); + if (existing) { + existing.orders++; + existing.revenue += Number(o.total_amount); + } else accounts.set(o.account_id, { name, orders: 1, revenue: Number(o.total_amount) }); + } + return { + report, + title, + year, + rows: Array.from(accounts.values()).sort((a, b) => b.revenue - a.revenue) + }; + } + + case 'sales-by-territory': { + const { data: orders } = await scopeByRep( + scopeByBrand( + supabase + .from('orders') + .select('account_id, total_amount, accounts(business_name, territories(name))') + .eq('organization_id', orgId) + .eq('order_year', year) + .neq('status', 'cancelled') + ) + ); + + const territories = new Map< + string, + { name: string; orders: number; revenue: number; accounts: Set } + >(); + for (const o of orders ?? []) { + const accountsJoin = o.accounts as + | { territories?: { name?: string } | { name?: string }[] | null } + | { territories?: { name?: string } | { name?: string }[] | null }[] + | null; + const acct = Array.isArray(accountsJoin) ? accountsJoin[0] : accountsJoin; + const terrJoin = acct?.territories; + const terr = (Array.isArray(terrJoin) ? terrJoin[0]?.name : terrJoin?.name) ?? 'Unassigned'; + const existing = territories.get(terr); + if (existing) { + existing.orders++; + existing.revenue += Number(o.total_amount); + existing.accounts.add(o.account_id); + } else + territories.set(terr, { + name: terr, + orders: 1, + revenue: Number(o.total_amount), + accounts: new Set([o.account_id]) + }); + } + return { + report, + title, + year, + rows: Array.from(territories.values()) + .map((t) => ({ + name: t.name, + orders: t.orders, + revenue: t.revenue, + accountCount: t.accounts.size + })) + .sort((a, b) => b.revenue - a.revenue) + }; + } + + case 'sales-by-rep': { + if (locals.orgType === 'brand' || nxBlsr) { + const { loadSalesByRep } = await import('$lib/server/reports/brand/salesByRep'); + const rows = await loadSalesByRep(supabase, orgScope, year); + return { report, title, year, rows, variant: 'brand' as const }; + } + const { data: orders } = await scopeByRep( + scopeByBrand( + supabase + .from('orders') + .select('created_by, total_amount') + .eq('organization_id', orgId) + .eq('order_year', year) + .neq('status', 'cancelled') + ) + ); + + const { data: members } = await supabase + .from('organization_members') + .select('profile_id, profiles!organization_members_profile_id_fkey(display_name)') + .eq('organization_id', orgId); + + type MemberRow = { + profile_id: string; + profiles?: { display_name?: string } | { display_name?: string }[] | null; + }; + const pickDisplayName = ( + profiles: { display_name?: string } | { display_name?: string }[] | null | undefined + ): string => + (Array.isArray(profiles) ? profiles[0]?.display_name : profiles?.display_name) ?? 'Unknown'; + const nameMap = new Map( + ((members ?? []) as MemberRow[]).map((m) => [m.profile_id, pickDisplayName(m.profiles)]) + ); + const reps = new Map(); + for (const o of orders ?? []) { + const name = nameMap.get(o.created_by) ?? 'Unknown'; + const existing = reps.get(o.created_by); + if (existing) { + existing.orders++; + existing.revenue += Number(o.total_amount); + } else reps.set(o.created_by, { name, orders: 1, revenue: Number(o.total_amount) }); + } + return { + report, + title, + year, + rows: Array.from(reps.values()).sort((a, b) => b.revenue - a.revenue) + }; + } + + case 'commission': { + const { data: orders } = await scopeByRep( + scopeByBrand( + supabase + .from('orders') + .select( + 'id, total_amount, shipped_amount, brand_id, created_by, brands(name, commission_rate), accounts(business_name)' + ) + .eq('organization_id', orgId) + .eq('order_year', year) + .neq('status', 'cancelled') + ) + ); + + const { data: memberCommissions } = await supabase + .from('member_brand_commissions') + .select('member_id, brand_id, rate') + .eq('organization_id', orgId); + + const { data: members } = await supabase + .from('organization_members') + .select('id, profile_id, profiles!organization_members_profile_id_fkey(display_name)') + .eq('organization_id', orgId); + + type CommMemberRow = { + id: string; + profile_id: string; + profiles?: { display_name?: string } | { display_name?: string }[] | null; + }; + const pickName = ( + profiles: { display_name?: string } | { display_name?: string }[] | null | undefined + ): string => + (Array.isArray(profiles) ? profiles[0]?.display_name : profiles?.display_name) ?? 'Unknown'; + const profileToMember = new Map( + ((members ?? []) as CommMemberRow[]).map((m) => [m.profile_id, m.id]) + ); + const memberToName = new Map( + ((members ?? []) as CommMemberRow[]).map((m) => [m.id, pickName(m.profiles)]) + ); + const commMap = new Map( + ( + (memberCommissions ?? []) as Array<{ member_id: string; brand_id: string; rate: number }> + ).map((mc) => [`${mc.member_id}-${mc.brand_id}`, mc.rate]) + ); + + type CommOrderRow = { + id: string; + total_amount: number; + shipped_amount?: number | null; + brand_id: string; + created_by: string; + brands?: + | { name?: string; commission_rate?: number } + | { name?: string; commission_rate?: number }[] + | null; + accounts?: { business_name?: string } | { business_name?: string }[] | null; + }; + const rows = ((orders ?? []) as CommOrderRow[]) + .map((o) => { + const amount = Number(o.shipped_amount ?? o.total_amount); + const brandObj = Array.isArray(o.brands) ? o.brands[0] : o.brands; + const acctObj = Array.isArray(o.accounts) ? o.accounts[0] : o.accounts; + const brandRate = brandObj?.commission_rate ?? 0; + const memberId = profileToMember.get(o.created_by); + const repRate = memberId ? (commMap.get(`${memberId}-${o.brand_id}`) ?? 0) : 0; + return { + brand: brandObj?.name ?? 'Unknown', + account: acctObj?.business_name ?? 'Unknown', + orderAmount: amount, + brandRate, + brandCommission: (amount * brandRate) / 100, + rep: memberToName.get(memberId ?? '') ?? 'Unknown', + repRate, + repCommission: (amount * repRate) / 100 + }; + }) + .sort((a, b) => b.orderAmount - a.orderAmount); + + return { report, title, year, rows }; + } + + case 'pipeline': { + if (locals.orgType === 'brand' || nxBlsr) { + const { loadOrderPipeline } = await import('$lib/server/reports/brand/orderPipeline'); + const rows = await loadOrderPipeline(supabase, orgScope); + return { report, title, year, rows, variant: 'brand' as const }; + } + const { data: orders } = await scopeByRep( + scopeByBrand( + supabase + .from('orders') + .select('status, total_amount') + .eq('organization_id', orgId) + .neq('status', 'cancelled') + ) + ); + + const statuses = new Map(); + for (const o of orders ?? []) { + const existing = statuses.get(o.status); + if (existing) { + existing.count++; + existing.total_amount += Number(o.total_amount); + } else + statuses.set(o.status, { + status: o.status, + count: 1, + total_amount: Number(o.total_amount) + }); + } + return { report, title, year, rows: Array.from(statuses.values()) }; + } + + case 'season-comparison': { + const { data: orders } = await scopeByRep( + scopeByBrand( + supabase + .from('orders') + .select('total_amount, order_year, season_id, seasons(name)') + .eq('organization_id', orgId) + .neq('status', 'cancelled') + ) + ); + + const seasons = new Map< + string, + { name: string; year: number; orders: number; revenue: number } + >(); + for (const o of orders ?? []) { + const key = `${o.season_id}-${o.order_year}`; + const seasonsJoin = o.seasons as { name?: string } | { name?: string }[] | null; + const name = + (Array.isArray(seasonsJoin) ? seasonsJoin[0]?.name : seasonsJoin?.name) ?? 'Unknown'; + const existing = seasons.get(key); + if (existing) { + existing.orders++; + existing.revenue += Number(o.total_amount); + } else + seasons.set(key, { + name: `${name} ${o.order_year ?? ''}`.trim(), + year: o.order_year ?? 0, + orders: 1, + revenue: Number(o.total_amount) + }); + } + return { + report, + title, + year, + rows: Array.from(seasons.values()).sort((a, b) => b.year - a.year || b.revenue - a.revenue) + }; + } + + case 'show-performance': { + const { data: showDates } = await supabase + .from('show_dates') + .select('id, year, month, city, state, shows(name)') + .eq('organization_id', orgId) + .order('year') + .order('month'); + + type ShowDateRow = { + id: string; + year: number; + month: number; + city: string | null; + state: string | null; + shows?: { name?: string } | { name?: string }[] | null; + }; + const showDateRows = (showDates ?? []) as ShowDateRow[]; + const dateIds = showDateRows.map((sd) => sd.id); + if (dateIds.length === 0) return { report, title, year, rows: [] }; + + const [ordersRes, visitsRes, apptsRes] = await Promise.all([ + scopeByRep( + scopeByBrand( + supabase + .from('orders') + .select('show_date_id, total_amount') + .eq('organization_id', orgId) + .in('show_date_id', dateIds) + .neq('status', 'cancelled') + ) + ), + supabase + .from('show_visits') + .select('show_date_id, is_new_account') + .in('show_date_id', dateIds), + supabase.from('appointments').select('show_date_id').in('show_date_id', dateIds) + ]); + + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' + ]; + const ordersData = (ordersRes.data ?? []) as Array<{ + show_date_id: string; + total_amount: number; + }>; + const visitsData = (visitsRes.data ?? []) as Array<{ + show_date_id: string; + is_new_account?: boolean; + }>; + const apptsData = (apptsRes.data ?? []) as Array<{ show_date_id: string }>; + const rows = showDateRows.map((sd) => { + const showName = Array.isArray(sd.shows) + ? (sd.shows[0]?.name ?? 'Unknown') + : (sd.shows?.name ?? 'Unknown'); + const sdOrders = ordersData.filter((o) => o.show_date_id === sd.id); + const sdVisits = visitsData.filter((v) => v.show_date_id === sd.id); + const sdAppts = apptsData.filter((a) => a.show_date_id === sd.id); + return { + show: `${showName} — ${months[sd.month - 1]} ${sd.year}`, + location: [sd.city, sd.state].filter(Boolean).join(', '), + orders: sdOrders.length, + revenue: sdOrders.reduce((sum, o) => sum + Number(o.total_amount), 0), + visits: sdVisits.length, + newAccounts: sdVisits.filter((v) => v.is_new_account).length, + appointments: sdAppts.length + }; + }); + return { report, title, year, rows }; + } + + case 'product-performance': { + const { loadProductPerformance } = + await import('$lib/server/reports/brand/productPerformance'); + const daysBack = parseInt(url.searchParams.get('days') ?? '') || 90; + const rows = await loadProductPerformance(supabase, orgScope, daysBack); + return { report, title, year, rows, daysBack }; + } + + case 'territory-coverage': { + if (locals.orgType !== 'brand' && !nxBlsr) throw error(404, 'Report not found'); + const { loadTerritoryCoverage } = await import('$lib/server/reports/brand/territoryCoverage'); + const rows = await loadTerritoryCoverage(supabase, orgScope, year); + return { report, title, year, rows, variant: 'brand' as const }; + } + + case 'account-penetration': { + if (locals.orgType !== 'brand' && !nxBlsr) throw error(404, 'Report not found'); + const { loadAccountPenetration } = + await import('$lib/server/reports/brand/accountPenetration'); + const rows = await loadAccountPenetration(supabase, orgScope, year); + return { report, title, year, rows, variant: 'brand' as const }; + } + + case 'season-sell-through': { + if (locals.orgType !== 'brand' && !nxBlsr) throw error(404, 'Report not found'); + const { loadSeasonSellThrough } = await import('$lib/server/reports/brand/seasonSellThrough'); + const rows = await loadSeasonSellThrough(supabase, orgScope, year); + return { report, title, year, rows, variant: 'brand' as const }; + } + + default: + throw error(404, 'Report not found'); + } +} diff --git a/src/lib/utils/report-columns.test.ts b/src/lib/utils/report-columns.test.ts new file mode 100644 index 00000000..0d4bbfbd --- /dev/null +++ b/src/lib/utils/report-columns.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { reportFilterLabel, reportTable } from './report-columns'; + +const labels = (report: string, variant?: string) => + reportTable(report, variant)?.columns.map((c) => c.label); + +describe('reportTable', () => { + it('names the first column after the grouping', () => { + expect(labels('sales-by-brand')?.[0]).toBe('Brand'); + expect(labels('sales-by-account')?.[0]).toBe('Account'); + expect(labels('sales-by-rep')?.[0]).toBe('Rep'); + }); + + it('uses the brand table for the brand variant of sales-by-rep', () => { + expect(labels('sales-by-rep', 'brand')).toEqual([ + 'Rep', + 'Agency', + 'Orders', + 'Revenue', + 'Avg Order Value', + 'Last Order' + ]); + }); + + it('formats money, averages, and totals like the page', () => { + const table = reportTable('sales-by-brand', undefined)!; + const rows = [ + { name: 'A', orders: 2, revenue: 1000 }, + { name: 'B', orders: 0, revenue: 0 } + ]; + const [name, orders, avg, revenue] = table.columns; + expect(avg.value(rows[0])).toBe('$500'); + expect(avg.value(rows[1])).toBe('$0'); + expect(revenue.value(rows[0])).toBe('$1,000'); + expect(table.hasTotals).toBe(true); + expect(name.total?.(rows)).toBe('Total'); + expect(orders.total?.(rows)).toBe('2'); + expect(revenue.total?.(rows)).toBe('$1,000'); + expect(avg.total).toBeUndefined(); + }); + + it('marks in-house agencies and inactive seasons in text', () => { + const agency = reportTable('territory-coverage', 'brand')!.columns[0]; + expect(agency.value({ agencyName: 'Acme', source: 'in_house' })).toBe('Acme (In-house)'); + const season = reportTable('season-sell-through', 'brand')!.columns[0]; + expect(season.value({ seasonName: 'SS26', isActive: false })).toBe('SS26 (Inactive)'); + }); + + it('writes None for a missing last order date', () => { + const last = reportTable('account-penetration', 'brand')!.columns[6]; + expect(last.value({ lastOrderDate: null })).toBe('None'); + }); + + it('capitalizes pipeline statuses', () => { + expect(reportTable('pipeline', undefined)!.columns[0].value({ status: 'submitted' })).toBe( + 'Submitted' + ); + }); + + it('right-aligns every numeric column', () => { + const table = reportTable('commission', undefined)!; + const aligned = Object.fromEntries(table.columns.map((c) => [c.label, c.align])); + expect(aligned).toMatchObject({ Brand: 'left', Amount: 'right', 'Rep Comm.': 'right' }); + }); + + it.each(['territory-coverage', 'account-penetration', 'season-sell-through'])( + 'has no table for the brand-only %s without the brand variant', + (report) => { + expect(reportTable(report, undefined)).toBeNull(); + } + ); + + it('returns null for an unknown report', () => { + expect(reportTable('nope', undefined)).toBeNull(); + }); +}); + +describe('reportFilterLabel', () => { + it('describes each filter the way the page sets it', () => { + expect(reportFilterLabel('sales-by-brand', 2026)).toBe('Year 2026'); + expect(reportFilterLabel('product-performance', 2026, 30)).toBe('Last 30 days'); + expect(reportFilterLabel('pipeline', 2026)).toBe('Current pipeline'); + }); +}); diff --git a/src/lib/utils/report-columns.ts b/src/lib/utils/report-columns.ts new file mode 100644 index 00000000..f6fa2420 --- /dev/null +++ b/src/lib/utils/report-columns.ts @@ -0,0 +1,243 @@ +/** + * The columns of each report's table, as data, for documents that cannot reuse + * the Svelte markup: the PDF export now, share snapshots next (SCO-151). + * + * Mirrors the tables in `routes/reports/[slug]/+page.svelte` column for column, + * including which reports carry a totals row. When a table there changes, its + * entry here changes with it. + */ + +export type ReportColumnAlign = 'left' | 'right'; + +type Row = Record; + +export interface ReportColumn { + label: string; + align: ReportColumnAlign; + /** Share of the table width, relative to the other columns. */ + weight: number; + value: (row: Row) => string; + /** The column's cell in the totals row; omitted cells are blank. */ + total?: (rows: Row[]) => string; +} + +export interface ReportTable { + columns: ReportColumn[]; + /** Whether the page renders a totals row for this report. */ + hasTotals: boolean; +} + +const currency = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0 +}); + +const num = (v: unknown): number => { + const n = Number(v ?? 0); + return Number.isFinite(n) ? n : 0; +}; +const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v)); +const money = (v: unknown) => currency.format(num(v)); +const sum = (rows: Row[], key: string) => rows.reduce((s, r) => s + num(r[key]), 0); +const sumMoney = (key: string) => (rows: Row[]) => currency.format(sum(rows, key)); +const sumCount = (key: string) => (rows: Row[]) => String(sum(rows, key)); + +function date(v: unknown): string { + if (typeof v !== 'string' || !v) return 'None'; + return new Date(v).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); +} + +const capitalize = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s); + +/** "Agency" with the in-house badge the page shows, as text. */ +const agency = (r: Row) => `${text(r.agencyName)}${r.source === 'in_house' ? ' (In-house)' : ''}`; + +const col = ( + label: string, + value: ReportColumn['value'], + opts: Partial> = {} +): ReportColumn => ({ + label, + value, + align: opts.align ?? 'left', + weight: opts.weight ?? 1, + ...opts +}); +const right = ( + label: string, + value: ReportColumn['value'], + total?: ReportColumn['total'] +): ReportColumn => col(label, value, { align: 'right', total }); +const avgOrder = (r: Row) => money(num(r.orders) > 0 ? num(r.revenue) / num(r.orders) : 0); + +export function reportTable(report: string, variant: string | undefined): ReportTable | null { + const brand = variant === 'brand'; + switch (report) { + case 'sales-by-brand': + case 'sales-by-account': + case 'sales-by-rep': + if (report === 'sales-by-rep' && brand) { + return { + hasTotals: true, + columns: [ + col('Rep', (r) => text(r.repName), { weight: 2, total: () => 'Total' }), + col('Agency', agency, { weight: 2 }), + right('Orders', (r) => text(r.orders), sumCount('orders')), + right('Revenue', (r) => money(r.revenue), sumMoney('revenue')), + right('Avg Order Value', (r) => money(r.avgOrderValue)), + col('Last Order', (r) => date(r.lastOrderDate), { weight: 1.2 }) + ] + }; + } + return { + hasTotals: true, + columns: [ + col( + report === 'sales-by-brand' + ? 'Brand' + : report === 'sales-by-account' + ? 'Account' + : 'Rep', + (r) => text(r.name), + { weight: 3, total: () => 'Total' } + ), + right('Orders', (r) => text(r.orders), sumCount('orders')), + right('Avg Order', avgOrder), + right('Revenue', (r) => money(r.revenue), sumMoney('revenue')) + ] + }; + case 'sales-by-territory': + return { + hasTotals: false, + columns: [ + col('Territory', (r) => text(r.name), { weight: 3 }), + right('Accounts', (r) => text(r.accountCount)), + right('Orders', (r) => text(r.orders)), + right('Revenue', (r) => money(r.revenue)) + ] + }; + case 'commission': + return { + hasTotals: true, + columns: [ + col('Brand', (r) => text(r.brand), { weight: 1.5, total: () => 'Total' }), + col('Account', (r) => text(r.account), { weight: 1.5 }), + right('Amount', (r) => money(r.orderAmount), sumMoney('orderAmount')), + right('Brand %', (r) => `${text(r.brandRate)}%`), + right('Brand Comm.', (r) => money(r.brandCommission), sumMoney('brandCommission')), + col('Rep', (r) => text(r.rep), { weight: 1.2 }), + right('Rep %', (r) => `${text(r.repRate)}%`), + right('Rep Comm.', (r) => money(r.repCommission), sumMoney('repCommission')) + ] + }; + case 'pipeline': + return { + hasTotals: false, + columns: [ + col('Status', (r) => capitalize(text(r.status)), { weight: 2 }), + right('Orders', (r) => text(r.count)), + right('Total Amount', (r) => money(r.total_amount)) + ] + }; + case 'season-comparison': + return { + hasTotals: false, + columns: [ + col('Season', (r) => text(r.name), { weight: 3 }), + right('Orders', (r) => text(r.orders)), + right('Avg Order', avgOrder), + right('Revenue', (r) => money(r.revenue)) + ] + }; + case 'show-performance': + return { + hasTotals: false, + columns: [ + col('Show', (r) => text(r.show), { weight: 2 }), + col('Location', (r) => text(r.location), { weight: 1.5 }), + right('Appts', (r) => text(r.appointments)), + right('Visits', (r) => text(r.visits)), + right('New Accts', (r) => text(r.newAccounts)), + right('Orders', (r) => text(r.orders)), + right('Revenue', (r) => money(r.revenue)) + ] + }; + case 'product-performance': + return { + hasTotals: true, + columns: [ + col('Style #', (r) => text(r.styleNumber)), + col('Product', (r) => text(r.productName), { weight: 2, total: () => 'Total' }), + right('Units', (r) => text(r.unitsOrdered), sumCount('unitsOrdered')), + right('Revenue', (r) => money(r.revenue), sumMoney('revenue')), + right('# Accounts', (r) => text(r.accounts), sumCount('accounts')), + right('Velocity', (r) => num(r.velocityScore).toFixed(1)), + col('Trend', (r) => text(r.trend || 'flat')) + ] + }; + case 'territory-coverage': + if (!brand) return null; + return { + hasTotals: true, + columns: [ + col('Agency', agency, { weight: 2, total: () => 'Total' }), + col('Territory', (r) => text(r.territoryName), { weight: 2 }), + right('Accounts', (r) => text(r.accounts), sumCount('accounts')), + right('Orders', (r) => text(r.orders), sumCount('orders')), + right('Revenue', (r) => money(r.revenue), sumMoney('revenue')) + ] + }; + case 'account-penetration': + if (!brand) return null; + return { + hasTotals: true, + columns: [ + col('Account', (r) => text(r.businessName), { weight: 2, total: () => 'Total' }), + col('Agency', agency, { weight: 1.5 }), + right('Orders', (r) => text(r.currentOrders ?? 0), sumCount('currentOrders')), + right('Revenue', (r) => money(r.currentRevenue), sumMoney('currentRevenue')), + right('Prior Year', (r) => money(r.priorRevenue), sumMoney('priorRevenue')), + col('Trend', (r) => text(r.trend || 'flat'), { weight: 0.8 }), + col('Last Order', (r) => date(r.lastOrderDate), { weight: 1.2 }), + col('Status', (r) => (r.status === 'dormant' ? 'Dormant' : 'Active'), { weight: 0.8 }) + ] + }; + case 'season-sell-through': + if (!brand) return null; + return { + hasTotals: true, + columns: [ + col( + 'Season', + (r) => `${text(r.seasonName)}${r.isActive === false ? ' (Inactive)' : ''}`, + { + weight: 2, + total: () => 'Total' + } + ), + right('Catalog', (r) => text(r.productsInSeason ?? 0), sumCount('productsInSeason')), + right('Sold', (r) => text(r.productsOrdered ?? 0), sumCount('productsOrdered')), + right('Sell-through', (r) => `${text(r.sellThroughPct ?? 0)}%`), + right('Units', (r) => text(r.totalUnits ?? 0), sumCount('totalUnits')), + right('Orders', (r) => text(r.orders), sumCount('orders')), + right('Accounts', (r) => text(r.accounts ?? 0)), + right('Revenue', (r) => money(r.totalRevenue), sumMoney('totalRevenue')) + ] + }; + default: + return null; + } +} + +/** The filter the rows were loaded with, as the line under a document title. */ +export function reportFilterLabel(report: string, year: number, daysBack?: number): string { + if (report === 'product-performance') return `Last ${daysBack ?? 90} days`; + if (report === 'pipeline') return 'Current pipeline'; + return `Year ${year}`; +} diff --git a/src/routes/api/reports/[slug]/pdf/+server.ts b/src/routes/api/reports/[slug]/pdf/+server.ts new file mode 100644 index 00000000..addd0f9c --- /dev/null +++ b/src/routes/api/reports/[slug]/pdf/+server.ts @@ -0,0 +1,50 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { loadReport } from '$lib/server/reports/load-report.js'; +import { generateReportPdf } from '$lib/server/report-pdf.js'; +import { buildReportChart } from '$lib/utils/report-chart.js'; +import { reportFilterLabel, reportTable } from '$lib/utils/report-columns.js'; + +/** + * The report page as a PDF (SCO-151). + * + * Same access as the page: `loadReport` scopes rows to the signed-in viewer + * exactly as the page does, and anyone who can open a report can already + * download it as CSV. The query string carries the page's filter (`year` or + * `days`), so the document matches what was on screen. + */ +export const GET: RequestHandler = async ({ locals, params, url }) => { + if (!locals.session || !locals.user || !locals.organization) { + throw error(401, 'Unauthorized'); + } + + const data = (await loadReport(locals, params.slug, url)) as { + report: string; + title: string; + year: number; + rows: Record[]; + variant?: string; + daysBack?: number; + }; + + const table = reportTable(data.report, data.variant); + if (!table) throw error(404, 'Report not found'); + + const pdfBytes = await generateReportPdf({ + title: data.title, + orgName: locals.organization.name ?? null, + filterLabel: reportFilterLabel(data.report, data.year, data.daysBack), + generatedAt: new Date(), + chart: buildReportChart(data.report, data.variant, data.rows), + table, + rows: data.rows + }); + + const suffix = data.report === 'product-performance' ? `${data.daysBack ?? 90}d` : data.year; + return new Response(pdfBytes as Uint8Array & BlobPart, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${data.report}-${suffix}.pdf"` + } + }); +}; diff --git a/src/routes/reports/[slug]/+page.server.ts b/src/routes/reports/[slug]/+page.server.ts index 9845cba8..501d775f 100644 --- a/src/routes/reports/[slug]/+page.server.ts +++ b/src/routes/reports/[slug]/+page.server.ts @@ -1,531 +1,5 @@ -import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import type { OrgType } from '$lib/types/database'; -import { supabaseAdmin } from '$lib/server/supabase.js'; -import { loadManagerScope } from '$lib/server/scoping.js'; -import { getNxBlsrBrandOrgIds, isNxBlsr } from '$lib/server/nx-blsr'; +import { loadReport } from '$lib/server/reports/load-report.js'; -const repReportTitles: Record = { - 'sales-by-brand': 'Sales by Brand', - 'sales-by-account': 'Sales by Account', - 'sales-by-territory': 'Sales by Territory', - 'sales-by-rep': 'Sales by Rep', - commission: 'Commission Report', - pipeline: 'Order Pipeline', - 'season-comparison': 'Season Comparison', - 'show-performance': 'Show Performance' -}; - -const brandReportTitles: Record = { - 'sales-by-rep': 'Sales by Rep', - 'product-performance': 'Product Performance', - 'territory-coverage': 'Territory Coverage', - 'account-penetration': 'Account Penetration', - 'season-sell-through': 'Season Sell-Through', - pipeline: 'Order Pipeline' -}; - -function titleFor(orgType: OrgType, slug: string, nxBlsr: boolean): string | null { - // Nx-BLSR users see the brand-side report set unioned across their - // brand-org memberships. Same titles a single brand-org BLSR would see. - if (nxBlsr || orgType === 'brand') return brandReportTitles[slug] ?? null; - return repReportTitles[slug] ?? null; -} - -export const load: PageServerLoad = async ({ locals, params, url }) => { - const { supabase, organization, allMemberships } = locals; - const report = params.slug; - - // Nx-BLSR scoping: sales-role member of 2+ brand-orgs. orgScope unions - // every brand-org they belong to; existing single-org callers see - // `[orgId]` so behavior is a strict superset. - const brandOrgIds = getNxBlsrBrandOrgIds(allMemberships); - const nxBlsr = isNxBlsr(brandOrgIds); - - const title = titleFor(locals.orgType, report, nxBlsr); - if (!title) throw error(404, 'Report not found'); - if (!organization) return { report, title, year: new Date().getFullYear(), rows: [] }; - - const orgId = organization.id; - const orgScope = nxBlsr ? brandOrgIds : [orgId]; - const year = parseInt(url.searchParams.get('year') ?? '') || new Date().getFullYear(); - const brandScope = locals.brandScope as string[] | null; - const isSales = locals.membership?.role === 'sales'; - - const userId = locals.user?.id; - const salesScope = - isSales && userId - ? await loadManagerScope(supabaseAdmin, userId, locals.membership?.id ?? null) - : null; - - // Precompute the set of org accounts this sales user covers via territory - // (plus untagged accounts, so orgs that haven't tagged don't strand sales). - let salesAccountIds: string[] | null = null; - if (salesScope?.hasTerritoryScope) { - const territoryIds = salesScope.visibleTerritoryIds.join(','); - const { data: rows } = await supabaseAdmin - .from('accounts') - .select('id') - .eq('organization_id', orgId) - .or(`territory_id.in.(${territoryIds}),territory_id.is.null`); - salesAccountIds = (rows ?? []).map((a: { id: string }) => a.id); - } - - // Helper: apply brand scope filter to any query with a brand_id column - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function scopeByBrand(query: any) { - return brandScope && brandScope.length > 0 ? query.in('brand_id', brandScope) : query; - } - - // Helper: scope sales users to their own records + managed reports' records - // + orders on accounts they cover via territory (including untagged). - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function scopeByRep(query: any) { - if (!salesScope) return query; - if (salesAccountIds && salesAccountIds.length > 0) { - const createdByClause = salesScope.createdByScope.join(','); - return query.or( - `created_by.in.(${createdByClause}),account_id.in.(${salesAccountIds.join(',')})` - ); - } - return query.in('created_by', salesScope.createdByScope); - } - - switch (report) { - case 'sales-by-brand': { - const { data: orders } = await scopeByRep( - scopeByBrand( - supabase - .from('orders') - .select('brand_id, total_amount, brands(name)') - .eq('organization_id', orgId) - .eq('order_year', year) - .neq('status', 'cancelled') - ) - ); - - const brands = new Map(); - for (const o of orders ?? []) { - const brandsJoin = o.brands as { name?: string } | { name?: string }[] | null; - const name = - (Array.isArray(brandsJoin) ? brandsJoin[0]?.name : brandsJoin?.name) ?? 'Unknown'; - const existing = brands.get(o.brand_id); - if (existing) { - existing.orders++; - existing.revenue += Number(o.total_amount); - } else brands.set(o.brand_id, { name, orders: 1, revenue: Number(o.total_amount) }); - } - return { - report, - title, - year, - rows: Array.from(brands.values()).sort((a, b) => b.revenue - a.revenue) - }; - } - - case 'sales-by-account': { - const { data: orders } = await scopeByRep( - scopeByBrand( - supabase - .from('orders') - .select('account_id, total_amount, accounts(business_name)') - .eq('organization_id', orgId) - .eq('order_year', year) - .neq('status', 'cancelled') - ) - ); - - const accounts = new Map(); - for (const o of orders ?? []) { - const accountsJoin = o.accounts as - | { business_name?: string } - | { business_name?: string }[] - | null; - const name = - (Array.isArray(accountsJoin) - ? accountsJoin[0]?.business_name - : accountsJoin?.business_name) ?? 'Unknown'; - const existing = accounts.get(o.account_id); - if (existing) { - existing.orders++; - existing.revenue += Number(o.total_amount); - } else accounts.set(o.account_id, { name, orders: 1, revenue: Number(o.total_amount) }); - } - return { - report, - title, - year, - rows: Array.from(accounts.values()).sort((a, b) => b.revenue - a.revenue) - }; - } - - case 'sales-by-territory': { - const { data: orders } = await scopeByRep( - scopeByBrand( - supabase - .from('orders') - .select('account_id, total_amount, accounts(business_name, territories(name))') - .eq('organization_id', orgId) - .eq('order_year', year) - .neq('status', 'cancelled') - ) - ); - - const territories = new Map< - string, - { name: string; orders: number; revenue: number; accounts: Set } - >(); - for (const o of orders ?? []) { - const accountsJoin = o.accounts as - | { territories?: { name?: string } | { name?: string }[] | null } - | { territories?: { name?: string } | { name?: string }[] | null }[] - | null; - const acct = Array.isArray(accountsJoin) ? accountsJoin[0] : accountsJoin; - const terrJoin = acct?.territories; - const terr = (Array.isArray(terrJoin) ? terrJoin[0]?.name : terrJoin?.name) ?? 'Unassigned'; - const existing = territories.get(terr); - if (existing) { - existing.orders++; - existing.revenue += Number(o.total_amount); - existing.accounts.add(o.account_id); - } else - territories.set(terr, { - name: terr, - orders: 1, - revenue: Number(o.total_amount), - accounts: new Set([o.account_id]) - }); - } - return { - report, - title, - year, - rows: Array.from(territories.values()) - .map((t) => ({ - name: t.name, - orders: t.orders, - revenue: t.revenue, - accountCount: t.accounts.size - })) - .sort((a, b) => b.revenue - a.revenue) - }; - } - - case 'sales-by-rep': { - if (locals.orgType === 'brand' || nxBlsr) { - const { loadSalesByRep } = await import('$lib/server/reports/brand/salesByRep'); - const rows = await loadSalesByRep(supabase, orgScope, year); - return { report, title, year, rows, variant: 'brand' as const }; - } - const { data: orders } = await scopeByRep( - scopeByBrand( - supabase - .from('orders') - .select('created_by, total_amount') - .eq('organization_id', orgId) - .eq('order_year', year) - .neq('status', 'cancelled') - ) - ); - - const { data: members } = await supabase - .from('organization_members') - .select('profile_id, profiles!organization_members_profile_id_fkey(display_name)') - .eq('organization_id', orgId); - - type MemberRow = { - profile_id: string; - profiles?: { display_name?: string } | { display_name?: string }[] | null; - }; - const pickDisplayName = ( - profiles: { display_name?: string } | { display_name?: string }[] | null | undefined - ): string => - (Array.isArray(profiles) ? profiles[0]?.display_name : profiles?.display_name) ?? 'Unknown'; - const nameMap = new Map( - ((members ?? []) as MemberRow[]).map((m) => [m.profile_id, pickDisplayName(m.profiles)]) - ); - const reps = new Map(); - for (const o of orders ?? []) { - const name = nameMap.get(o.created_by) ?? 'Unknown'; - const existing = reps.get(o.created_by); - if (existing) { - existing.orders++; - existing.revenue += Number(o.total_amount); - } else reps.set(o.created_by, { name, orders: 1, revenue: Number(o.total_amount) }); - } - return { - report, - title, - year, - rows: Array.from(reps.values()).sort((a, b) => b.revenue - a.revenue) - }; - } - - case 'commission': { - const { data: orders } = await scopeByRep( - scopeByBrand( - supabase - .from('orders') - .select( - 'id, total_amount, shipped_amount, brand_id, created_by, brands(name, commission_rate), accounts(business_name)' - ) - .eq('organization_id', orgId) - .eq('order_year', year) - .neq('status', 'cancelled') - ) - ); - - const { data: memberCommissions } = await supabase - .from('member_brand_commissions') - .select('member_id, brand_id, rate') - .eq('organization_id', orgId); - - const { data: members } = await supabase - .from('organization_members') - .select('id, profile_id, profiles!organization_members_profile_id_fkey(display_name)') - .eq('organization_id', orgId); - - type CommMemberRow = { - id: string; - profile_id: string; - profiles?: { display_name?: string } | { display_name?: string }[] | null; - }; - const pickName = ( - profiles: { display_name?: string } | { display_name?: string }[] | null | undefined - ): string => - (Array.isArray(profiles) ? profiles[0]?.display_name : profiles?.display_name) ?? 'Unknown'; - const profileToMember = new Map( - ((members ?? []) as CommMemberRow[]).map((m) => [m.profile_id, m.id]) - ); - const memberToName = new Map( - ((members ?? []) as CommMemberRow[]).map((m) => [m.id, pickName(m.profiles)]) - ); - const commMap = new Map( - ( - (memberCommissions ?? []) as Array<{ member_id: string; brand_id: string; rate: number }> - ).map((mc) => [`${mc.member_id}-${mc.brand_id}`, mc.rate]) - ); - - type CommOrderRow = { - id: string; - total_amount: number; - shipped_amount?: number | null; - brand_id: string; - created_by: string; - brands?: - | { name?: string; commission_rate?: number } - | { name?: string; commission_rate?: number }[] - | null; - accounts?: { business_name?: string } | { business_name?: string }[] | null; - }; - const rows = ((orders ?? []) as CommOrderRow[]) - .map((o) => { - const amount = Number(o.shipped_amount ?? o.total_amount); - const brandObj = Array.isArray(o.brands) ? o.brands[0] : o.brands; - const acctObj = Array.isArray(o.accounts) ? o.accounts[0] : o.accounts; - const brandRate = brandObj?.commission_rate ?? 0; - const memberId = profileToMember.get(o.created_by); - const repRate = memberId ? (commMap.get(`${memberId}-${o.brand_id}`) ?? 0) : 0; - return { - brand: brandObj?.name ?? 'Unknown', - account: acctObj?.business_name ?? 'Unknown', - orderAmount: amount, - brandRate, - brandCommission: (amount * brandRate) / 100, - rep: memberToName.get(memberId ?? '') ?? 'Unknown', - repRate, - repCommission: (amount * repRate) / 100 - }; - }) - .sort((a, b) => b.orderAmount - a.orderAmount); - - return { report, title, year, rows }; - } - - case 'pipeline': { - if (locals.orgType === 'brand' || nxBlsr) { - const { loadOrderPipeline } = await import('$lib/server/reports/brand/orderPipeline'); - const rows = await loadOrderPipeline(supabase, orgScope); - return { report, title, year, rows, variant: 'brand' as const }; - } - const { data: orders } = await scopeByRep( - scopeByBrand( - supabase - .from('orders') - .select('status, total_amount') - .eq('organization_id', orgId) - .neq('status', 'cancelled') - ) - ); - - const statuses = new Map(); - for (const o of orders ?? []) { - const existing = statuses.get(o.status); - if (existing) { - existing.count++; - existing.total_amount += Number(o.total_amount); - } else - statuses.set(o.status, { - status: o.status, - count: 1, - total_amount: Number(o.total_amount) - }); - } - return { report, title, year, rows: Array.from(statuses.values()) }; - } - - case 'season-comparison': { - const { data: orders } = await scopeByRep( - scopeByBrand( - supabase - .from('orders') - .select('total_amount, order_year, season_id, seasons(name)') - .eq('organization_id', orgId) - .neq('status', 'cancelled') - ) - ); - - const seasons = new Map< - string, - { name: string; year: number; orders: number; revenue: number } - >(); - for (const o of orders ?? []) { - const key = `${o.season_id}-${o.order_year}`; - const seasonsJoin = o.seasons as { name?: string } | { name?: string }[] | null; - const name = - (Array.isArray(seasonsJoin) ? seasonsJoin[0]?.name : seasonsJoin?.name) ?? 'Unknown'; - const existing = seasons.get(key); - if (existing) { - existing.orders++; - existing.revenue += Number(o.total_amount); - } else - seasons.set(key, { - name: `${name} ${o.order_year ?? ''}`.trim(), - year: o.order_year ?? 0, - orders: 1, - revenue: Number(o.total_amount) - }); - } - return { - report, - title, - year, - rows: Array.from(seasons.values()).sort((a, b) => b.year - a.year || b.revenue - a.revenue) - }; - } - - case 'show-performance': { - const { data: showDates } = await supabase - .from('show_dates') - .select('id, year, month, city, state, shows(name)') - .eq('organization_id', orgId) - .order('year') - .order('month'); - - type ShowDateRow = { - id: string; - year: number; - month: number; - city: string | null; - state: string | null; - shows?: { name?: string } | { name?: string }[] | null; - }; - const showDateRows = (showDates ?? []) as ShowDateRow[]; - const dateIds = showDateRows.map((sd) => sd.id); - if (dateIds.length === 0) return { report, title, year, rows: [] }; - - const [ordersRes, visitsRes, apptsRes] = await Promise.all([ - scopeByRep( - scopeByBrand( - supabase - .from('orders') - .select('show_date_id, total_amount') - .eq('organization_id', orgId) - .in('show_date_id', dateIds) - .neq('status', 'cancelled') - ) - ), - supabase - .from('show_visits') - .select('show_date_id, is_new_account') - .in('show_date_id', dateIds), - supabase.from('appointments').select('show_date_id').in('show_date_id', dateIds) - ]); - - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec' - ]; - const ordersData = (ordersRes.data ?? []) as Array<{ - show_date_id: string; - total_amount: number; - }>; - const visitsData = (visitsRes.data ?? []) as Array<{ - show_date_id: string; - is_new_account?: boolean; - }>; - const apptsData = (apptsRes.data ?? []) as Array<{ show_date_id: string }>; - const rows = showDateRows.map((sd) => { - const showName = Array.isArray(sd.shows) - ? (sd.shows[0]?.name ?? 'Unknown') - : (sd.shows?.name ?? 'Unknown'); - const sdOrders = ordersData.filter((o) => o.show_date_id === sd.id); - const sdVisits = visitsData.filter((v) => v.show_date_id === sd.id); - const sdAppts = apptsData.filter((a) => a.show_date_id === sd.id); - return { - show: `${showName} — ${months[sd.month - 1]} ${sd.year}`, - location: [sd.city, sd.state].filter(Boolean).join(', '), - orders: sdOrders.length, - revenue: sdOrders.reduce((sum, o) => sum + Number(o.total_amount), 0), - visits: sdVisits.length, - newAccounts: sdVisits.filter((v) => v.is_new_account).length, - appointments: sdAppts.length - }; - }); - return { report, title, year, rows }; - } - - case 'product-performance': { - const { loadProductPerformance } = - await import('$lib/server/reports/brand/productPerformance'); - const daysBack = parseInt(url.searchParams.get('days') ?? '') || 90; - const rows = await loadProductPerformance(supabase, orgScope, daysBack); - return { report, title, year, rows, daysBack }; - } - - case 'territory-coverage': { - if (locals.orgType !== 'brand' && !nxBlsr) throw error(404, 'Report not found'); - const { loadTerritoryCoverage } = await import('$lib/server/reports/brand/territoryCoverage'); - const rows = await loadTerritoryCoverage(supabase, orgScope, year); - return { report, title, year, rows, variant: 'brand' as const }; - } - - case 'account-penetration': { - if (locals.orgType !== 'brand' && !nxBlsr) throw error(404, 'Report not found'); - const { loadAccountPenetration } = - await import('$lib/server/reports/brand/accountPenetration'); - const rows = await loadAccountPenetration(supabase, orgScope, year); - return { report, title, year, rows, variant: 'brand' as const }; - } - - case 'season-sell-through': { - if (locals.orgType !== 'brand' && !nxBlsr) throw error(404, 'Report not found'); - const { loadSeasonSellThrough } = await import('$lib/server/reports/brand/seasonSellThrough'); - const rows = await loadSeasonSellThrough(supabase, orgScope, year); - return { report, title, year, rows, variant: 'brand' as const }; - } - - default: - throw error(404, 'Report not found'); - } -}; +export const load: PageServerLoad = ({ locals, params, url }) => + loadReport(locals, params.slug, url); diff --git a/src/routes/reports/[slug]/+page.svelte b/src/routes/reports/[slug]/+page.svelte index 4e68801b..b2417797 100644 --- a/src/routes/reports/[slug]/+page.svelte +++ b/src/routes/reports/[slug]/+page.svelte @@ -88,6 +88,15 @@ goto(resolve(`/reports/${report}?days=${days}`), { replaceState: true }); } + // The PDF is built on the server from the same filter as the page. + const pdfHref = $derived( + report === 'product-performance' + ? `/api/reports/${report}/pdf?days=${data.daysBack ?? 90}` + : report === 'pipeline' + ? `/api/reports/${report}/pdf` + : `/api/reports/${report}/pdf?year=${year}` + ); + function exportReport() { if (!rows.length) return; downloadCSV(rows, `${report}-${year}.csv`); @@ -152,6 +161,7 @@ {/if} {#if rows.length > 0} + {/if} diff --git a/tests/rls/admin-bypass.test.ts b/tests/rls/admin-bypass.test.ts index 2e21ceb8..b09dfb8a 100644 --- a/tests/rls/admin-bypass.test.ts +++ b/tests/rls/admin-bypass.test.ts @@ -73,6 +73,7 @@ const KNOWN_BYPASS_FILES: string[] = [ 'src/lib/server/queries/expenses.ts', 'src/lib/server/queries/invoices.ts', 'src/lib/server/queries/scope.ts', + 'src/lib/server/reports/load-report.ts', 'src/lib/server/retailers.ts', 'src/lib/server/returns/apply-transition.ts', 'src/lib/server/returns/authorize-return.ts', @@ -213,7 +214,6 @@ const KNOWN_BYPASS_FILES: string[] = [ 'src/routes/organization/taxes/+page.server.ts', 'src/routes/products/[productId]/+page.server.ts', 'src/routes/products/new/+page.server.ts', - 'src/routes/reports/[slug]/+page.server.ts', 'src/routes/returns/new/+page.server.ts', 'src/routes/returns/new/lines/+server.ts', 'src/routes/settings/email-intake/+page.server.ts',