diff --git a/app/api/staff/file/route.ts b/app/api/staff/file/route.ts deleted file mode 100644 index 7c45a03..0000000 --- a/app/api/staff/file/route.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * GET /api/staff/file?id=&key= - * - * Streams an attachment from CiviCRM to the caller. The Civi API user's - * credentials never leave the server. Auth is the same shared - * STAFF_REPORT_KEY used by /api/staff/report. - * - * In stub mode, returns a tiny placeholder PNG so the UI's preview path - * is exercisable in dev. - */ - -import { NextRequest, NextResponse } from "next/server"; -import { isStaffKeyValid } from "@/lib/staff-auth"; -import { civi } from "@/lib/civicrm"; - -function isCiviStubMode(): boolean { - return !( - process.env.CIVI_BASE_URL && - process.env.CIVI_API_KEY && - process.env.CIVI_SITE_KEY - ); -} - -// 1x1 transparent PNG, base64-encoded — used as a stub attachment so the -// UI's image preview path renders something in dev. -const STUB_PNG_B64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; - -export async function GET(req: NextRequest) { - const url = new URL(req.url); - const key = url.searchParams.get("key"); - const idStr = url.searchParams.get("id"); - - if (!isStaffKeyValid(key)) { - return new NextResponse("Not found", { status: 404 }); - } - - const id = Number(idStr); - if (!idStr || !Number.isFinite(id) || id <= 0) { - return new NextResponse("Bad request", { status: 400 }); - } - - if (isCiviStubMode()) { - const bytes = Buffer.from(STUB_PNG_B64, "base64"); - return new NextResponse(bytes, { - status: 200, - headers: { - "content-type": "image/png", - "content-disposition": `inline; filename="stub-${id}.png"`, - "cache-control": "private, max-age=60", - }, - }); - } - - try { - // Look up the attachment URL and metadata. - const meta = await civi<{ id: number; url: string; mime_type: string; name: string }>( - "Attachment", - "get", - { - select: ["id", "url", "mime_type", "name"], - where: [["id", "=", id]], - }, - ); - const row = meta.values?.[0]; - if (!row?.url) { - return new NextResponse("Not found", { status: 404 }); - } - - // SSRF guard: only follow URLs whose origin matches CIVI_BASE_URL. Civi - // returns absolute URLs for attachments; if a compromised Civi (or DB row - // tamper) ever set this to an attacker-controlled host, the basic-auth - // creds attached below would leak. Validating the origin closes that. - const civiOrigin = new URL(process.env.CIVI_BASE_URL!).origin; - let upstreamUrl: URL; - try { - upstreamUrl = new URL(row.url); - } catch { - console.error(`[staff/file] malformed civi url id=${id}`); - return new NextResponse("Upstream error", { status: 502 }); - } - if (upstreamUrl.origin !== civiOrigin) { - console.error( - `[staff/file] refused cross-origin upstream id=${id} origin=${upstreamUrl.origin}`, - ); - return new NextResponse("Upstream error", { status: 502 }); - } - - const headers: Record = {}; - if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) { - const creds = Buffer.from( - `${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`, - ).toString("base64"); - headers["Authorization"] = `Basic ${creds}`; - } - - const upstream = await fetch(upstreamUrl, { headers, redirect: "manual" }); - if (!upstream.ok || !upstream.body) { - console.error( - `[staff/file] upstream fetch failed: id=${id} status=${upstream.status}`, - ); - return new NextResponse("Upstream error", { status: 502 }); - } - - // XSS guard: only allow a fixed allowlist of MIME types to render inline - // (browsers execute scripts inside SVGs and HTML, and will sniff some - // ambiguous types). Everything else is forced to attachment with a - // neutralised content-type. nosniff blocks MIME sniffing entirely. - const SAFE_INLINE = new Set([ - "image/png", - "image/jpeg", - "image/gif", - "image/webp", - "application/pdf", - ]); - const safeName = (row.name || `file-${id}`).replace(/[\r\n"]/g, ""); - const declaredMime = row.mime_type || upstream.headers.get("content-type") || "application/octet-stream"; - const isInline = SAFE_INLINE.has(declaredMime); - const servedMime = isInline ? declaredMime : "application/octet-stream"; - return new NextResponse(upstream.body, { - status: 200, - headers: { - "content-type": servedMime, - "content-disposition": `${isInline ? "inline" : "attachment"}; filename="${safeName}"`, - "cache-control": "private, max-age=60", - "x-content-type-options": "nosniff", - "content-security-policy": "default-src 'none'; sandbox; style-src 'unsafe-inline'", - }, - }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - console.error(`[staff/file] fetch threw: ${msg}`); - return new NextResponse("Upstream error", { status: 502 }); - } -} diff --git a/app/staff/report/page.tsx b/app/staff/report/page.tsx index 88d6af9..029ceef 100644 --- a/app/staff/report/page.tsx +++ b/app/staff/report/page.tsx @@ -23,6 +23,9 @@ export default async function StaffReportPage({ searchParams }: PageProps) { const orgId = Number(org); const orgValid = !!org && Number.isFinite(orgId) && orgId > 0; + // CIVI_BASE_URL flows from server config to client only as a base for + // outbound file links. No secret material is exposed. + const civiBaseUrl = process.env.CIVI_BASE_URL ?? ""; return ( <> @@ -37,7 +40,7 @@ export default async function StaffReportPage({ searchParams }: PageProps) {
{orgValid ? ( - + ) : ( diff --git a/components/StaffReportView.tsx b/components/StaffReportView.tsx index 8443853..a256fac 100644 --- a/components/StaffReportView.tsx +++ b/components/StaffReportView.tsx @@ -13,7 +13,7 @@ import type { import { MembershipChart, MEMBERS_ACTUAL_NAME, MEMBERS_GOAL_NAME } from "./report/MembershipChart"; import { DateTimeline } from "./report/DateTimeline"; import { - FieldHistoryRow, + FormattedValue, formatShortDate, formatLongDate, computeDateRange, @@ -23,6 +23,8 @@ import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates"; interface StaffReportViewProps { org: number; authKey: string; + /** CIVI_BASE_URL, used to build outbound file links. */ + civiBaseUrl: string; } type LoadState = @@ -30,7 +32,9 @@ type LoadState = | { kind: "error"; message: string } | { kind: "ready"; data: StaffReportPayload }; -export function StaffReportView({ org, authKey }: StaffReportViewProps) { +const STAGE_OPTION_GROUP_ID = 75; + +export function StaffReportView({ org, authKey, civiBaseUrl }: StaffReportViewProps) { const [load, setLoad] = useState({ kind: "loading" }); useEffect(() => { @@ -67,19 +71,18 @@ export function StaffReportView({ org, authKey }: StaffReportViewProps) { const { data } = load; if (data.sections.length === 0 && data.activities.length === 0) return ; - // Pull the membership + goal series out wherever they live (Check_in_data__organizing_). const checkInSection = data.sections.find((s) => s.groupName === "Check_in_data__organizing_"); const membersField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_ACTUAL_NAME); const goalField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_GOAL_NAME); - const stageLabel = - data.currentStage - ? data.options[75]?.find((o) => o.value === data.currentStage)?.label ?? data.currentStage - : "—"; + const stageLabel = data.currentStage + ? data.options[STAGE_OPTION_GROUP_ID]?.find((o) => o.value === data.currentStage)?.label + ?? data.currentStage + : "—"; const dateRange = computeDateRange(data.activities.map((a) => a.date)); return ( -
+

Staff report · Internal use only @@ -103,18 +106,24 @@ export function StaffReportView({ org, authKey }: StaffReportViewProps) {

+ 0} /> + {membersField && membersField.history.length > 0 ? ( ) : null} - {/* Build a synthetic "sections + fieldHistory" view the DateTimeline understands. */} {data.sections.map((section) => ( - + ))} @@ -122,10 +131,6 @@ export function StaffReportView({ org, authKey }: StaffReportViewProps) { ); } -function historyOnly(f: StaffReportField): FieldHistoryEntry[] { - return f.history; -} - function Stat({ label, value }: { label: string; value: React.ReactNode }) { return (
@@ -135,58 +140,229 @@ function Stat({ label, value }: { label: string; value: React.ReactNode }) { ); } +/** Sticky horizontal anchor strip — one chip per section + Submissions. */ +function SectionAnchorNav({ + sections, + hasActivities, +}: { + sections: StaffReportSection[]; + hasActivities: boolean; +}) { + const items = sections.map((s) => ({ + href: `#section-${s.groupName}`, + label: s.groupKind === "org" ? "Org profile" : s.groupTitle, + })); + if (hasActivities) items.push({ href: "#section-submissions", label: "Submissions" }); + return ( + + ); +} + function StaffSection({ section, options, - authKey, + civiBaseUrl, }: { section: StaffReportSection; options: Record; - authKey: string; + civiBaseUrl: string; }) { const filled = section.fields.filter((f) => f.history.length > 0); const empty = section.fields.filter((f) => f.history.length === 0); + const [showEmpty, setShowEmpty] = useState(false); + + // Stage 5 Y1 matrix: pull Labor / Margin / Member_Sales quarterly fields + // out into a single tabular display matching the form's matrix layout. + const isStage5 = section.groupName === "Stage_5"; + const matrixFields = isStage5 ? collectY1MatrixFields(filled) : null; + const filledOutsideMatrix = + matrixFields + ? filled.filter((f) => !matrixFields.usedNames.has(f.descriptor.name)) + : filled; return ( -
-

{section.groupTitle}

+
+

+ {section.groupTitle} +

{section.fields.length} field{section.fields.length === 1 ? "" : "s"} ·{" "} {filled.length} with data

-
    - {filled.map((f) => ( -
  • - {f.descriptor.render === "file" ? ( - - ) : ( - - )} -
  • - ))} - {empty.map((f) => ( -
  • + ) : null} + + {filledOutsideMatrix.length > 0 ? ( +
      + {filledOutsideMatrix.map((f) => ( + + ))} +
    + ) : null} + + {empty.length > 0 ? ( +
    +
  • - ))} -
+ {empty.length} empty field{empty.length === 1 ? "" : "s"} + {showEmpty ? "▾" : "▸"} + + {showEmpty ? ( +
    + {empty.map((f) => ( +
  • + {f.descriptor.label} + +
  • + ))} +
+ ) : null} +
+ ) : null} ); } /** - * Adapt a StaffReportField to the FieldConfig shape FieldHistoryRow / FormattedValue - * expect. The renderer only reads `name`, `label`, `type`, `optionGroupId`. + * Compact, latest-only row. If the field has multiple history entries, a + * muted "N earlier entries" toggle reveals the rest inline. + * + * File-typed values render as an outbound link to CiviCRM rather than a + * proxied download — staff are already logged into Civi when they arrive + * here, and the server doesn't need to broker bytes. */ +function CompactFieldRow({ + field, + options, + civiBaseUrl, +}: { + field: StaffReportField; + options: Record; + civiBaseUrl: string; +}) { + const [open, setOpen] = useState(false); + const latest = field.history[0]; + const earlier = field.history.slice(1); + + return ( +
  • +
    + {field.descriptor.label} +
    + + {latest.date ? ( + + · {formatShortDate(latest.date)} + + ) : null} +
    +
    + {earlier.length > 0 ? ( +
    + + {open ? ( +
      + {earlier.map((e, i) => ( +
    • + + {formatShortDate(e.date)} +
    • + ))} +
    + ) : null} +
    + ) : null} +
  • + ); +} + +function FieldValue({ + field, + entry, + options, + civiBaseUrl, +}: { + field: StaffReportField; + entry: FieldHistoryEntry; + options: Record; + civiBaseUrl: string; +}) { + if (field.descriptor.render === "file") { + const v = entry.value as { id?: number | string; file_name?: string } | null; + if (!v || v.id === undefined) return ; + const id = String(v.id); + const name = v.file_name ?? `file-${id}`; + // Civi serves uploaded files at /civicrm/file?reset=1&id=. + // The staff member is already authenticated to Civi (they came from + // there); the browser sends their session cookie automatically. + const href = civiBaseUrl + ? `${civiBaseUrl}/civicrm/file?reset=1&id=${encodeURIComponent(id)}` + : "#"; + return ( + + {name} + + ); + } + return ( + + ); +} + function fieldConfigFor(f: StaffReportField): FieldConfig { return { name: f.descriptor.name, @@ -201,7 +377,6 @@ function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldCo case "currency": return "currency"; case "date": - return "date"; case "datetime": return "date"; case "select": @@ -221,101 +396,107 @@ function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldCo } } -function FileFieldRow({ field, authKey }: { field: StaffReportField; authKey: string }) { - return ( -
    -

    {field.descriptor.label}

    -
      - {field.history.map((entry) => ( - - ))} -
    -
    - ); +/** + * Stage 5 Y1 matrix: detect fields whose names match Y1_Q_ and + * group them into a read-only table mirroring the form's matrix layout. + * The metric set is whatever's actually present in the data so a Civi + * schema addition (e.g. Y1_Q*_Labor_Hours) appears automatically. + */ +interface Y1MatrixRow { + metric: string; // e.g. "Labor" | "Margin" | "Member_Sales_" + label: string; // human label from the first field's descriptor (sans Y1_Q_ prefix) + byQuarter: Map; +} +interface Y1MatrixData { + rows: Y1MatrixRow[]; + quarters: number[]; + usedNames: Set; +} +function collectY1MatrixFields(filled: StaffReportField[]): Y1MatrixData | null { + const re = /^Y1_Q(\d+)_(.+)$/; + const used = new Set(); + const byMetric = new Map>(); + const quartersSet = new Set(); + const metricLabel = new Map(); + + for (const f of filled) { + const m = re.exec(f.descriptor.name); + if (!m) continue; + const quarter = Number(m[1]); + const metric = m[2]; + used.add(f.descriptor.name); + quartersSet.add(quarter); + if (!byMetric.has(metric)) byMetric.set(metric, new Map()); + byMetric.get(metric)!.set(quarter, f); + if (!metricLabel.has(metric)) { + // Strip "Y1 Q " prefix variants from the label if present. + const cleaned = f.descriptor.label + .replace(/^Y1\s*Q\d+\s*/i, "") + .replace(/_/g, " ") + .trim(); + metricLabel.set(metric, cleaned || metric.replace(/_/g, " ")); + } + } + + if (byMetric.size === 0) return null; + const quarters = Array.from(quartersSet).sort((a, b) => a - b); + const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byQuarter]) => ({ + metric, + label: metricLabel.get(metric) ?? metric, + byQuarter, + })); + return { rows, quarters, usedNames: used }; } -function FilePreviewItem({ - entry, - authKey, +function Y1MatrixTable({ + rows, + quarters, + options, }: { - entry: FieldHistoryEntry; - authKey: string; + rows: Y1MatrixRow[]; + quarters: number[]; + options: Record; }) { - const v = entry.value as { id?: number | string; file_name?: string } | null; - if (!v || v.id === undefined) return null; - const id = String(v.id); - const name = v.file_name ?? `file-${id}`; - const href = `/api/staff/file?id=${encodeURIComponent(id)}&key=${encodeURIComponent(authKey)}`; - const ext = (name.split(".").pop() ?? "").toLowerCase(); - // SVG omitted on purpose — the proxy forces SVG to download (XSS hardening), - // so an inline here would just show a broken thumbnail. - const isImage = ["png", "jpg", "jpeg", "gif", "webp"].includes(ext); - const isPdf = ext === "pdf"; - return ( -
  • - {isImage ? ( - - {/* eslint-disable-next-line @next/next/no-img-element */} - {name} - - ) : null} -
    - - {name} - - {entry.date ? ( -

    submitted {formatLongDate(entry.date)}

    - ) : null} - {isPdf ? : null} -
    -
  • - ); -} - -function PdfPreviewButton({ href, name }: { href: string; name: string }) { - const [open, setOpen] = useState(false); - return ( - <> - - {open ? ( -
    setOpen(false)} - > -
    e.stopPropagation()} - > - -