"use client"; import { useEffect, useState } from "react"; import type { FieldConfig, FieldHistoryEntry, SelectOption, StageSectionConfig, StaffReportField, StaffReportPayload, StaffReportSection, } from "@/types/form"; import { MembershipChart, MEMBERS_ACTUAL_NAME, MEMBERS_GOAL_NAME } from "./report/MembershipChart"; import { DateTimeline } from "./report/DateTimeline"; import { FormattedValue, formatShortDate, formatLongDate, computeDateRange, } from "./report/FieldHistory"; 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 = | { kind: "loading" } | { kind: "error"; message: string } | { kind: "ready"; data: StaffReportPayload }; const STAGE_OPTION_GROUP_ID = 75; export function StaffReportView({ org, authKey, civiBaseUrl }: StaffReportViewProps) { const [load, setLoad] = useState({ kind: "loading" }); useEffect(() => { let alive = true; (async () => { try { const res = await fetch( `/api/staff/report?org=${encodeURIComponent(String(org))}&key=${encodeURIComponent(authKey)}`, { cache: "no-store" }, ); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; if (alive) setLoad({ kind: "error", message: body.error ?? `Request failed (${res.status})`, }); return; } const data = (await res.json()) as StaffReportPayload; if (alive) setLoad({ kind: "ready", data }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (alive) setLoad({ kind: "error", message: msg }); } })(); return () => { alive = false; }; }, [org, authKey]); if (load.kind === "loading") return ; if (load.kind === "error") return ; const { data } = load; if (data.sections.length === 0 && data.activities.length === 0) return ; 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[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

{data.orgName}

{data.orgId}} />
0} /> {membersField && membersField.history.length > 0 ? ( ) : null} {data.sections.map((section) => ( ))}
); } function Stat({ label, value }: { label: string; value: React.ReactNode }) { return (
{label}
{value}
); } /** 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, civiBaseUrl, }: { section: StaffReportSection; options: Record; 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.fields.length} field{section.fields.length === 1 ? "" : "s"} ·{" "} {filled.length} with data

{matrixFields ? ( ) : null} {filledOutsideMatrix.length > 0 ? (
    {filledOutsideMatrix.map((f) => ( ))}
) : null} {empty.length > 0 ? (
{showEmpty ? (
    {empty.map((f) => (
  • {f.descriptor.label}
  • ))}
) : null}
) : null}
); } /** * 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, label: f.descriptor.label, type: renderToFieldType(f.descriptor.render), optionGroupId: f.descriptor.optionGroupId, }; } function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldConfig["type"] { switch (r) { case "currency": return "currency"; case "date": case "datetime": return "date"; case "select": return "select"; case "multiselect": return "multiselect"; case "file": return "file"; case "longtext": return "textarea"; case "boolean": return "boolean"; case "number": return "number"; default: return "text"; } } /** * 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 Y1MatrixTable({ rows, quarters, options, }: { rows: Y1MatrixRow[]; quarters: number[]; options: Record; }) { return (
    {quarters.map((q) => ( ))} {rows.map((row) => ( {quarters.map((q) => { const f = row.byQuarter.get(q); const latest = f?.history[0]; return ( ); })} ))}
    Year 1 quarterly · latest values
    MetricQ{q}
    {row.label} {f && latest ? ( ) : ( "—" )}
    ); } function ActivityTable({ activities, options, }: { activities: StaffReportPayload["activities"]; options: Record; }) { if (activities.length === 0) { return (

    All submissions

    No submissions recorded for this organization yet.

    ); } const stageOptions = options[STAGE_OPTION_GROUP_ID] ?? []; const stageLabel = (v: string | null | undefined) => v ? stageOptions.find((o) => o.value === v)?.label ?? v : "—"; return (

    All submissions

    {activities.map((a) => ( ))}
    Date Stage snapshot Subject Submitted by Activity id
    {formatShortDate(a.date)} {stageLabel(a.stage ?? null)} {a.subject ?? "—"} {a.submittedBy ?? "—"} {a.id}
    ); } function StaffDateTimeline({ data }: { data: StaffReportPayload }) { const fieldHistory: Record = {}; const fieldConfigs: FieldConfig[] = []; for (const section of data.sections) { if (section.groupKind !== "activity") continue; for (const f of section.fields) { if (f.descriptor.render !== "date" && f.descriptor.render !== "datetime") continue; if (f.history.length === 0) continue; fieldHistory[f.descriptor.name] = f.history; fieldConfigs.push(fieldConfigFor(f)); } } if (fieldConfigs.length === 0) return null; const sections: StageSectionConfig[] = [ { rank: 0, id: "all", label: "All dated events", fields: fieldConfigs }, ]; return ; } // formatLongDate retained for symmetry with other report views; unused here // now that file rows are compact links. void formatLongDate;