diff --git a/app/api/report/route.ts b/app/api/report/route.ts new file mode 100644 index 0000000..0301f54 --- /dev/null +++ b/app/api/report/route.ts @@ -0,0 +1,247 @@ +/** + * GET /api/report?cid=&cs= + * + * Verifies the checksum, resolves the org from the contact via the + * Primary Contact relationship, then walks every Check-in (organizing) + * activity for the org and assembles a per-field history of non-empty + * values plus a summary list of the activities themselves. + * + * Returns ReportPayload (see types/form.ts). + * + * STUB MODE: if CiviCRM env vars are unset, returns synthesized history + * shaped from the same stub used by /api/data so the report UI is + * exercisable without a live CRM. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { civi, verifyChecksum } from "@/lib/civicrm"; +import { + allFields, + optionGroupIds, + ACTIVITY_TYPE_NAME, + ACTIVITY_STAGE_FIELD, + FORM_CONTACT_RELATIONSHIP, +} from "@/config/form"; +import type { + ReportPayload, + SelectOption, + FieldHistoryEntry, + ActivitySummary, +} from "@/types/form"; + +function isStubMode(): boolean { + return !( + process.env.CIVI_BASE_URL && + process.env.CIVI_API_KEY && + process.env.CIVI_SITE_KEY + ); +} + +const STUB_PAYLOAD: ReportPayload = (() => { + const today = new Date(); + const daysAgo = (n: number) => + new Date(today.getTime() - n * 24 * 3600 * 1000).toISOString(); + return { + orgName: "Sample Co-op (stub)", + currentStage: "Organizing", + activities: [ + { id: 9012, date: daysAgo(3), subject: "Co-op Check-in (form submission)" }, + { id: 9008, date: daysAgo(34), subject: "Co-op Check-in (form submission)" }, + { id: 9001, date: daysAgo(62), stage: "Organizing", subject: "Stage transition (staff)" }, + { id: 8995, date: daysAgo(95), subject: "Co-op Check-in (form submission)" }, + { id: 8980, date: daysAgo(180), stage: "Inquiry", subject: "Initial check-in (staff)" }, + ], + fieldHistory: { + Peer_Group_Participation: [ + { activityId: 9012, date: daysAgo(3), value: "Yes" }, + { activityId: 9008, date: daysAgo(34), value: "Considering" }, + { activityId: 8995, date: daysAgo(95), value: "No" }, + ], + Members__current_: [ + { activityId: 9012, date: daysAgo(3), value: 124 }, + { activityId: 9008, date: daysAgo(34), value: 109 }, + { activityId: 8995, date: daysAgo(95), value: 87 }, + ], + Member_Goal_for_current_Stage: [ + { activityId: 9008, date: daysAgo(34), value: 200 }, + ], + Internal_Startup_Assessment: [ + { activityId: 9012, date: daysAgo(3), value: "Strong" }, + { activityId: 8995, date: daysAgo(95), value: "Moderate" }, + ], + }, + options: { + 140: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }, { value: "Considering", label: "Considering" }], + 132: [{ value: "Strong", label: "Strong" }, { value: "Moderate", label: "Moderate" }, { value: "Needs Work", label: "Needs work" }], + 75: [ + { value: "Inquiry", label: "Inquiry" }, + { value: "Organizing", label: "Stage 1 — Convene & Prepare" }, + { value: "Feasibility", label: "Stage 2 — Grow & Plan" }, + { value: "Business feasibility", label: "Stage 3 — Connect & Gather" }, + { value: "Store Implementation", label: "Stage 4 — Excite & Build" }, + { value: "Stabilize newly opened co-op", label: "Stage 5 — Fulfill & Stabilize" }, + ], + }, + }; +})(); + +interface OptionValueRow { + value: string; + label: string; + option_group_id: number; + is_active: boolean; +} + +async function fetchOptionGroups(): Promise> { + if (optionGroupIds.length === 0) return {}; + const res = await civi("OptionValue", "get", { + select: ["value", "label", "option_group_id", "is_active"], + where: [ + ["option_group_id", "IN", optionGroupIds], + ["is_active", "=", true], + ], + orderBy: { weight: "ASC" }, + limit: 500, + }); + const out: Record = {}; + for (const row of res.values ?? []) { + if (!out[row.option_group_id]) out[row.option_group_id] = []; + out[row.option_group_id].push({ value: row.value, label: row.label }); + } + return out; +} + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const cid = url.searchParams.get("cid"); + const cs = url.searchParams.get("cs"); + + if (!cid || !cs) { + return NextResponse.json({ error: "Missing cid or cs parameter." }, { status: 400 }); + } + + if (isStubMode()) { + return NextResponse.json(STUB_PAYLOAD); + } + + const ok = await verifyChecksum(cid, cs); + if (!ok) { + return NextResponse.json( + { error: "Link is invalid or has expired. Please request a fresh one." }, + { status: 401 }, + ); + } + + // Resolve org (mirror of /api/data). + const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", { + select: ["contact_id_b"], + where: [ + ["contact_id_a", "=", Number(cid)], + ["relationship_type_id.name_a_b", "=", FORM_CONTACT_RELATIONSHIP], + ["is_active", "=", true], + ], + limit: 2, + }); + const orgs = relRes.values ?? []; + if (orgs.length === 0) { + return NextResponse.json( + { error: `No active "${FORM_CONTACT_RELATIONSHIP}" relationship found for your contact.` }, + { status: 404 }, + ); + } + if (orgs.length > 1) { + return NextResponse.json( + { error: `Your contact has multiple active "${FORM_CONTACT_RELATIONSHIP}" relationships; staff must resolve before this link will work.` }, + { status: 409 }, + ); + } + const orgId = orgs[0].contact_id_b; + + // Org name + activity walk + option groups, in parallel. + const civiFieldNames = Array.from( + new Set(allFields.map((f) => f.civiField).filter((f): f is string => !!f)), + ); + const select = [ + "id", + "activity_date_time", + "subject", + ACTIVITY_STAGE_FIELD, + ...civiFieldNames, + ]; + + const [orgRes, activityRes, options] = await Promise.all([ + civi<{ id: number; display_name: string }>("Contact", "get", { + select: ["id", "display_name"], + where: [["id", "=", orgId]], + }), + civi & { id: number; activity_date_time: string }>( + "Activity", + "get", + { + select, + where: [ + ["activity_type_id:name", "=", ACTIVITY_TYPE_NAME], + ["target_contact_id", "=", orgId], + ], + orderBy: { activity_date_time: "DESC", id: "DESC" }, + limit: 500, + }, + ), + fetchOptionGroups(), + ]); + + const org = orgRes.values?.[0]; + if (!org) { + return NextResponse.json({ error: "Organization not found." }, { status: 404 }); + } + + const rows = activityRes.values ?? []; + + // Activity summaries — ordered DESC already. + const activities: ActivitySummary[] = rows.map((r) => ({ + id: r.id, + date: r.activity_date_time, + stage: (r[ACTIVITY_STAGE_FIELD] as string | null | undefined) ?? null, + subject: (r.subject as string | null | undefined) ?? null, + })); + + // Derive current stage from most recent stage-bearing activity. + const stageRow = rows.find( + (r) => { + const v = r[ACTIVITY_STAGE_FIELD]; + return typeof v === "string" && v.length > 0; + }, + ); + const currentStage = + (typeof stageRow?.[ACTIVITY_STAGE_FIELD] === "string" + ? (stageRow[ACTIVITY_STAGE_FIELD] as string) + : "") || "Inquiry"; + + // Per-field history. For every form-side field that has a civiField, + // walk activities and collect non-empty values. + const fieldHistory: Record = {}; + for (const f of allFields) { + if (!f.civiField) continue; + if (f.type === "readonly") continue; // readonly displays don't have history + const entries: FieldHistoryEntry[] = []; + for (const row of rows) { + const v = row[f.civiField]; + if (v === null || v === undefined || v === "") continue; + entries.push({ + activityId: row.id, + date: row.activity_date_time, + value: v, + }); + } + if (entries.length > 0) fieldHistory[f.name] = entries; + } + + const payload: ReportPayload = { + orgName: org.display_name, + currentStage, + activities, + fieldHistory, + options, + }; + return NextResponse.json(payload); +} diff --git a/app/report/page.tsx b/app/report/page.tsx new file mode 100644 index 0000000..b2ab203 --- /dev/null +++ b/app/report/page.tsx @@ -0,0 +1,77 @@ +import { Suspense } from "react"; +import { ReportView } from "@/components/ReportView"; +import { SiteHeader, SiteFooter } from "@/components/SiteChrome"; +import { formConfig } from "@/config/form"; + +interface PageProps { + searchParams: Promise<{ cid?: string; cs?: string }>; +} + +export const metadata = { + title: "Activity report — Food Co-op Initiative", + robots: { index: false, follow: false }, +}; + +export default async function ReportPage({ searchParams }: PageProps) { + const { cid, cs } = await searchParams; + + return ( + <> + + Skip to content + + +
+
+ + {!cid || !cs ? ( + + ) : ( + + + + )} +
+
+ + + ); +} + +function ReportIntro() { + return ( +
+

+ Activity report · Co-op organizing +

+

+ What we know so far +

+

+ A read-only summary of every value your co-op has shared through past + check-ins, grouped by stage. The most recent entry sits at the top of + each row; expand a row to see how a number or note has changed over + time. +

+
+
+ ); +} + +function MissingLinkParams() { + return ( +
+

+ This link is missing required information. +

+

+ Open the report using the personalized link from your email. If you no + longer have it, please contact your engagement coordinator and ask for + a fresh link. +

+
+ ); +} diff --git a/components/ReportView.tsx b/components/ReportView.tsx new file mode 100644 index 0000000..50ec593 --- /dev/null +++ b/components/ReportView.tsx @@ -0,0 +1,608 @@ +"use client"; + +import { useEffect, useId, useMemo, useState } from "react"; +import type { + FieldConfig, + FieldHistoryEntry, + FormConfig, + ReportPayload, + SelectOption, + StageSectionConfig, +} from "@/types/form"; +import { STAGE_OPTION_GROUP_ID } from "@/config/form"; +import { StageIcon } from "./StageIcon"; + +interface ReportViewProps { + config: FormConfig; + cid: string; + cs: string; +} + +type LoadState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; data: ReportPayload }; + +type PathwayState = "past" | "current" | "future"; + +const STAGE_RANK: Record = { + Inquiry: 0, + Organizing: 1, + Feasibility: 2, + "Business feasibility": 3, + "Store Implementation": 4, + "Stabilize newly opened co-op": 5, +}; + +export function ReportView({ config, cid, cs }: ReportViewProps) { + const [load, setLoad] = useState({ kind: "loading" }); + + useEffect(() => { + let cancelled = false; + async function go() { + try { + const url = new URL("/api/report", window.location.origin); + url.searchParams.set("cid", cid); + url.searchParams.set("cs", cs); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (!res.ok) { + const text = await res.text(); + let message = `Could not load report (HTTP ${res.status}).`; + try { + const j = JSON.parse(text) as { error?: string }; + if (j.error) message = j.error; + } catch { /* default */ } + if (!cancelled) setLoad({ kind: "error", message }); + return; + } + const data: ReportPayload = await res.json(); + if (!cancelled) setLoad({ kind: "ready", data }); + } catch (e) { + if (!cancelled) { + setLoad({ + kind: "error", + message: e instanceof Error ? e.message : "Unexpected error loading report.", + }); + } + } + } + void go(); + return () => { + cancelled = true; + }; + }, [cid, cs]); + + const sectionsToRender = useMemo(() => { + if (load.kind !== "ready") return []; + const { currentStage, fieldHistory } = load.data; + const currentRank = STAGE_RANK[currentStage] ?? 0; + return config.sections + .map((section) => { + const fieldsWithHistory = section.fields.filter( + (f) => fieldHistory[f.name] && fieldHistory[f.name].length > 0, + ); + const pathwayState: PathwayState = + section.rank < currentRank ? "past" : section.rank === currentRank ? "current" : "future"; + return { section, fieldsWithHistory, pathwayState }; + }) + .filter((e) => e.fieldsWithHistory.length > 0); + }, [load, config.sections]); + + if (load.kind === "loading") return ; + if (load.kind === "error") return ; + + const { data } = load; + const stageOpts = data.options?.[STAGE_OPTION_GROUP_ID] ?? []; + const currentStageLabel = + stageOpts.find((o) => o.value === data.currentStage)?.label ?? data.currentStage; + const currentRank = STAGE_RANK[data.currentStage] ?? 0; + + const dateRange = computeDateRange(data.activities.map((a) => a.date)); + const totalActivities = data.activities.length; + const totalFieldsTracked = Object.keys(data.fieldHistory).length; + + return ( +
+ + + {sectionsToRender.length === 0 ? ( + + ) : ( +
    + {sectionsToRender.map((entry, i) => { + const { section, pathwayState, fieldsWithHistory } = entry; + const nextState = + i < sectionsToRender.length - 1 ? sectionsToRender[i + 1].pathwayState : undefined; + return ( +
  1. + 0} state={pathwayState} /> + + +
  2. + ); + })} +
+ )} +
+ ); +} + +function ReportContextHeader({ + orgName, + currentStageLabel, + currentRank, + totalActivities, + totalFieldsTracked, + dateRange, +}: { + orgName: string; + currentStageLabel: string; + currentRank: number; + totalActivities: number; + totalFieldsTracked: number; + dateRange: { from: string; to: string } | null; +}) { + return ( +
+

Report for

+

+ {orgName} +

+
+ + + Current stage + +
+

+ {currentStageLabel || "—"} +

+
+ + + +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +function StageProgress({ currentRank }: { currentRank: number }) { + return ( +
+ {[0, 1, 2, 3, 4, 5].map((r) => ( + + ))} +
+ ); +} + +function ReportSection({ + section, + fields, + history, + options, + isCurrent, + defaultOpen, +}: { + section: StageSectionConfig; + fields: FieldConfig[]; + history: Record; + options: Record; + isCurrent: boolean; + defaultOpen: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + const headingId = useId(); + const panelId = useId(); + + const cardClass = isCurrent + ? "border-2 border-leaf-600 shadow-[0_1px_0_0_rgba(0,0,0,0.04),0_8px_24px_-12px_rgba(60,80,40,0.18)] bg-white/95" + : "border border-rule bg-white/95 shadow-sm"; + + return ( +
+

+ +

+ + +
+ ); +} + +function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) { + return ( + + + + {rank} + + + ); +} + +function Chevron({ open }: { open: boolean }) { + return ( + + + + ); +} + +function FieldHistoryRow({ + field, + entries, + options, +}: { + field: FieldConfig; + entries: FieldHistoryEntry[]; + options: Record; +}) { + const [expanded, setExpanded] = useState(false); + const latest = entries[0]; + const priorEntries = entries.slice(1); + + return ( +
+
+
+

{field.label}

+ {field.help && ( +

{field.help}

+ )} +
+
+

+ +

+

+ as of {formatShortDate(latest.date)} + {priorEntries.length > 0 && ( + <> + {" · "} + + + )} +

+
+
+ + {expanded && priorEntries.length > 0 && ( +
    + {priorEntries.map((e) => ( +
  1. + + {formatShortDate(e.date)} + + + + +
  2. + ))} +
+ )} +
+ ); +} + +const currencyFmt = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, +}); + +const numberFmt = new Intl.NumberFormat("en-US"); + +function FormattedValue({ + value, + field, + options, +}: { + value: unknown; + field: FieldConfig; + options: Record; +}) { + if (value === null || value === undefined || value === "") return <>—; + + const opts: SelectOption[] | undefined = field.optionGroupId + ? options[field.optionGroupId] + : field.options; + + switch (field.type) { + case "currency": { + const n = typeof value === "number" ? value : Number(value); + return <>{Number.isFinite(n) ? currencyFmt.format(n) : String(value)}; + } + case "percent": { + const n = typeof value === "number" ? value : Number(value); + return <>{Number.isFinite(n) ? `${n}%` : String(value)}; + } + case "number": { + const n = typeof value === "number" ? value : Number(value); + return <>{Number.isFinite(n) ? numberFmt.format(n) : String(value)}; + } + case "date": + return <>{formatLongDate(String(value))}; + case "boolean": + return <>{value ? "Yes" : "No"}; + case "select": + case "readonly": { + const v = String(value); + const found = opts?.find((o) => o.value === v); + return <>{found?.label ?? v}; + } + case "multiselect": { + let parts: string[]; + if (Array.isArray(value)) { + parts = value.map(String); + } else { + parts = String(value).split(/[|,]/).map((s) => s.trim()).filter(Boolean); + } + const labels = parts.map((p) => opts?.find((o) => o.value === p)?.label ?? p); + return <>{labels.join(", ")}; + } + case "file": + return <>{String(value)}; + case "textarea": + case "text": + case "email": + case "phone": + default: + return <>{String(value)}; + } +} + +function formatShortDate(iso: string): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); +} + +function formatLongDate(iso: string): string { + if (!iso) return "—"; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + const d = m + ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) + : new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }); +} + +function computeDateRange(dates: string[]): { from: string; to: string } | null { + if (dates.length === 0) return null; + const times = dates + .map((d) => new Date(d).getTime()) + .filter((t) => Number.isFinite(t)); + if (times.length === 0) return null; + const min = new Date(Math.min(...times)).toISOString(); + const max = new Date(Math.max(...times)).toISOString(); + return { from: min, to: max }; +} + +function RailMarker({ + rank, + state, + nextState, +}: { + rank: number; + state: PathwayState; + nextState?: PathwayState; +}) { + return ( +
+ {nextState && ( + + )} + +
+ ); +} + +function MarkerCircle({ rank, state }: { rank: number; state: PathwayState }) { + if (state === "past") { + return ( + + + + + Stage {rank} (past) + + ); + } + if (state === "current") { + return ( + + {rank} + Stage {rank} (current) + + ); + } + return ( + + {rank} + Stage {rank} (no entries) + + ); +} + +function MobileStem({ show, state }: { show: boolean; state: PathwayState }) { + if (!show) return null; + return ( +
+ +
+ ); +} + +function LoadingState() { + return ( +
+
+

Loading your activity report…

+
+ ); +} + +function EmptyState() { + return ( +
+

No entries on file yet.

+

+ Your co-op's first check-in will appear here once it's submitted. +

+
+ ); +} + +function ErrorState({ message }: { message: string }) { + return ( +
+

+ We couldn't open your report. +

+

{message}

+

+ If this keeps happening, please contact your engagement coordinator. +

+
+ ); +} diff --git a/types/form.ts b/types/form.ts index 1d2d932..96c77c2 100644 --- a/types/form.ts +++ b/types/form.ts @@ -197,3 +197,49 @@ export interface SubmitPayload { cs: string; values: Record; } + +/** + * One historical entry for a field. The report walks all Check-in + * (organizing) activities for an org and collects every non-empty value + * each field has ever held, alongside the activity it came from. + */ +export interface FieldHistoryEntry { + activityId: number; + /** ISO timestamp of activity_date_time. */ + date: string; + value: unknown; +} + +/** + * A trimmed view of one activity, used to head the report and to label + * staff stage-change activities distinctly from form-driven submissions. + */ +export interface ActivitySummary { + id: number; + date: string; + /** Stage value carried on this activity (non-empty → staff transition). */ + stage?: string | null; + subject?: string | null; +} + +/** + * The shape returned by /api/report. Activities ordered DESC; each + * fieldHistory entry list is also DESC. + */ +export interface ReportPayload { + orgName: string; + /** Current Framework Stage as text value (e.g. "Organizing"). */ + currentStage: string; + /** + * Activity summary list, ordered DESC by activity_date_time. + * Includes both staff stage-change and form-driven submission activities. + */ + activities: ActivitySummary[]; + /** + * Per-field history of non-empty values, keyed by FieldConfig.name. Each + * list is sorted DESC by activity_date_time. Fields that have never been + * filled in are absent from this map. + */ + fieldHistory: Record; + options?: Record; +}