Add /report — read-only activity history view
Mirrors the form's IA and Field Almanac aesthetic; same auth (cid/cs
checksum) so the org owner who can fill the form can also view its
history.
New routes:
- GET /api/report — verifies checksum, resolves the org via the
Primary Contact relationship, fires Contact.get + Activity.get +
OptionValue.get in parallel. For every form field with a civiField,
walks all the org's Check-in (organizing) activities and collects
every non-empty value into a sorted-DESC history list. Returns
ReportPayload (orgName, currentStage, activities, fieldHistory,
options). Has stub-mode payload for env-less local dev.
- /report — page entry; same layout shell (SiteHeader + SiteFooter,
3xl page width). Eyebrow "Activity report - Co-op organizing".
ReportView component:
- ReportContextHeader: large org name, progress dots + uppercase
"Current stage" eyebrow + the Civi option *label* on its own line
at display-font xl/2xl leaf-800 (matches the form's header). Below
it a 3-up stat band: total check-ins, fields tracked, date span.
- One accordion card per stage section, in stage-rank order. Only
sections that have at least one field-with-entries render — past,
current, or "future-with-data" all welcome; truly empty stages stay
hidden so the page is calm.
- Same journey rail (md+) and mobile stem (md-) with past =
check-filled-leaf, current = filled-leaf-with-ring, future = dashed
hollow ring; solid leaf line vs dashed muted between markers.
- Within each card: divide-y rows. Field label and help on the left,
most-recent value on the right in display-font lg leaf-800, dated
beneath with an "{N} earlier entries" disclosure that expands a
small vertical timeline (date on left, value on right).
- FormattedValue handles currency (Intl), percent, number (tabular
nums), date (long, timezone-safe for YYYY-MM-DD), boolean (Yes/No),
select/readonly (resolved via option group), multiselect (handles
array or delimited string), file (filename), text-like (as-is).
- Loading / empty / error states match the form's treatments.
types/form.ts: new FieldHistoryEntry, ActivitySummary, ReportPayload.
The fieldHistory map keys by FieldConfig.name and only includes fields
that have at least one non-empty entry.
This commit is contained in:
247
app/api/report/route.ts
Normal file
247
app/api/report/route.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/report?cid=<cid>&cs=<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<Record<number, SelectOption[]>> {
|
||||||
|
if (optionGroupIds.length === 0) return {};
|
||||||
|
const res = await civi<OptionValueRow>("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<number, SelectOption[]> = {};
|
||||||
|
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<Record<string, unknown> & { 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<string, FieldHistoryEntry[]> = {};
|
||||||
|
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);
|
||||||
|
}
|
||||||
77
app/report/page.tsx
Normal file
77
app/report/page.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href="#main"
|
||||||
|
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-paper focus:px-3 focus:py-2 focus:text-ink focus:shadow"
|
||||||
|
>
|
||||||
|
Skip to content
|
||||||
|
</a>
|
||||||
|
<SiteHeader />
|
||||||
|
<main id="main" className="flex-1">
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6 sm:py-14">
|
||||||
|
<ReportIntro />
|
||||||
|
{!cid || !cs ? (
|
||||||
|
<MissingLinkParams />
|
||||||
|
) : (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ReportView config={formConfig} cid={cid} cs={cs} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportIntro() {
|
||||||
|
return (
|
||||||
|
<header className="mb-10 max-w-2xl">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] text-leaf-700">
|
||||||
|
Activity report · Co-op organizing
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 font-display text-[40px] font-normal leading-[1.05] tracking-tight text-ink sm:text-[52px]">
|
||||||
|
What we know so far
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-prose text-[17px] leading-relaxed text-ink-soft">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 h-px bg-rule" />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MissingLinkParams() {
|
||||||
|
return (
|
||||||
|
<div role="alert" className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7">
|
||||||
|
<h2 className="font-display text-xl font-medium text-clay-700">
|
||||||
|
This link is missing required information.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 leading-relaxed text-ink-soft">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
608
components/ReportView.tsx
Normal file
608
components/ReportView.tsx
Normal file
@@ -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<string, number> = {
|
||||||
|
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<LoadState>({ 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 <LoadingState />;
|
||||||
|
if (load.kind === "error") return <ErrorState message={load.message} />;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<ReportContextHeader
|
||||||
|
orgName={data.orgName}
|
||||||
|
currentStageLabel={currentStageLabel}
|
||||||
|
currentRank={currentRank}
|
||||||
|
totalActivities={totalActivities}
|
||||||
|
totalFieldsTracked={totalFieldsTracked}
|
||||||
|
dateRange={dateRange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{sectionsToRender.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<ol className="relative space-y-5 md:pl-12">
|
||||||
|
{sectionsToRender.map((entry, i) => {
|
||||||
|
const { section, pathwayState, fieldsWithHistory } = entry;
|
||||||
|
const nextState =
|
||||||
|
i < sectionsToRender.length - 1 ? sectionsToRender[i + 1].pathwayState : undefined;
|
||||||
|
return (
|
||||||
|
<li key={section.id} className="relative list-none">
|
||||||
|
<MobileStem show={i > 0} state={pathwayState} />
|
||||||
|
<RailMarker rank={section.rank} state={pathwayState} nextState={nextState} />
|
||||||
|
<ReportSection
|
||||||
|
section={section}
|
||||||
|
fields={fieldsWithHistory}
|
||||||
|
history={data.fieldHistory}
|
||||||
|
options={data.options ?? {}}
|
||||||
|
isCurrent={pathwayState === "current"}
|
||||||
|
defaultOpen={pathwayState === "current" || section.rank === 0}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<header className="rounded-lg border border-rule bg-paper-2/40 px-6 py-5 sm:px-7 sm:py-6">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] text-ink-mute">Report for</p>
|
||||||
|
<h1 className="mt-1 font-display text-3xl font-medium leading-tight tracking-tight text-ink sm:text-[34px]">
|
||||||
|
{orgName}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<StageProgress currentRank={currentRank} />
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.16em] font-medium text-ink-mute">
|
||||||
|
Current stage
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 font-display text-xl sm:text-2xl font-medium leading-tight tracking-tight text-leaf-800">
|
||||||
|
{currentStageLabel || "—"}
|
||||||
|
</p>
|
||||||
|
<dl className="mt-5 grid grid-cols-3 gap-3 border-t border-rule-soft pt-4 text-sm sm:gap-6">
|
||||||
|
<Stat label="Check-ins" value={String(totalActivities)} />
|
||||||
|
<Stat label="Fields tracked" value={String(totalFieldsTracked)} />
|
||||||
|
<Stat
|
||||||
|
label="Span"
|
||||||
|
value={dateRange ? `${formatShortDate(dateRange.from)} - ${formatShortDate(dateRange.to)}` : "—"}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-[10px] uppercase tracking-[0.14em] font-medium text-ink-mute">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-0.5 font-display text-base font-medium text-ink tabular-nums sm:text-lg">
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StageProgress({ currentRank }: { currentRank: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5" role="img" aria-label={`Stage ${currentRank} of 5`}>
|
||||||
|
{[0, 1, 2, 3, 4, 5].map((r) => (
|
||||||
|
<span
|
||||||
|
key={r}
|
||||||
|
className={
|
||||||
|
"h-1.5 rounded-full transition-all " +
|
||||||
|
(r < currentRank
|
||||||
|
? "w-2.5 bg-leaf-300"
|
||||||
|
: r === currentRank
|
||||||
|
? "w-6 bg-leaf-700"
|
||||||
|
: "w-2.5 bg-rule-soft")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportSection({
|
||||||
|
section,
|
||||||
|
fields,
|
||||||
|
history,
|
||||||
|
options,
|
||||||
|
isCurrent,
|
||||||
|
defaultOpen,
|
||||||
|
}: {
|
||||||
|
section: StageSectionConfig;
|
||||||
|
fields: FieldConfig[];
|
||||||
|
history: Record<string, FieldHistoryEntry[]>;
|
||||||
|
options: Record<number, SelectOption[]>;
|
||||||
|
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 (
|
||||||
|
<section
|
||||||
|
aria-labelledby={headingId}
|
||||||
|
className={"overflow-hidden rounded-lg transition " + cardClass}
|
||||||
|
>
|
||||||
|
<h2 id={headingId} className="m-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={panelId}
|
||||||
|
className={
|
||||||
|
"group relative flex w-full items-center gap-4 px-5 py-4 text-left transition-colors sm:px-6 " +
|
||||||
|
(isCurrent ? "bg-leaf-50/60" : "hover:bg-paper-2/60")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StageRankMark rank={section.rank} isCurrent={isCurrent} />
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="block font-display text-lg font-medium leading-tight tracking-tight text-ink sm:text-xl">
|
||||||
|
{section.label}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 flex items-center gap-3 text-xs text-ink-mute">
|
||||||
|
{isCurrent && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-clay-200 bg-clay-100/70 px-2 py-0.5 font-medium text-clay-700 uppercase tracking-[0.08em]">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-clay-600" aria-hidden />
|
||||||
|
Current stage
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{fields.length} {fields.length === 1 ? "field" : "fields"} with entries
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Chevron open={open} />
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id={panelId}
|
||||||
|
role="region"
|
||||||
|
aria-labelledby={headingId}
|
||||||
|
hidden={!open}
|
||||||
|
className="border-t border-rule-soft"
|
||||||
|
>
|
||||||
|
<div className="divide-y divide-rule-soft">
|
||||||
|
{fields.map((f) => (
|
||||||
|
<FieldHistoryRow
|
||||||
|
key={f.name}
|
||||||
|
field={f}
|
||||||
|
entries={history[f.name] ?? []}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
|
||||||
|
return (
|
||||||
|
<span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">
|
||||||
|
<StageIcon
|
||||||
|
rank={rank}
|
||||||
|
className={
|
||||||
|
"absolute inset-0 h-12 w-12 transition-opacity " +
|
||||||
|
(isCurrent ? "text-leaf-700 opacity-100" : "text-leaf-600 opacity-50")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"relative z-10 inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums shadow-sm " +
|
||||||
|
(isCurrent ? "bg-leaf-700 text-paper" : "bg-paper text-ink-soft border border-rule")
|
||||||
|
}
|
||||||
|
style={{ marginLeft: 28, marginTop: 28 }}
|
||||||
|
>
|
||||||
|
{rank}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chevron({ open }: { open: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className={"h-5 w-5 flex-shrink-0 text-ink-mute transition-transform duration-300 " + (open ? "rotate-180" : "")}
|
||||||
|
>
|
||||||
|
<path d="M5 8 L10 13 L15 8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldHistoryRow({
|
||||||
|
field,
|
||||||
|
entries,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
field: FieldConfig;
|
||||||
|
entries: FieldHistoryEntry[];
|
||||||
|
options: Record<number, SelectOption[]>;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const latest = entries[0];
|
||||||
|
const priorEntries = entries.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-4 sm:px-7">
|
||||||
|
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-6">
|
||||||
|
<div className="min-w-0 sm:max-w-[16rem]">
|
||||||
|
<p className="text-sm font-medium text-ink">{field.label}</p>
|
||||||
|
{field.help && (
|
||||||
|
<p className="mt-0.5 text-xs leading-relaxed text-ink-mute">{field.help}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 text-left sm:text-right">
|
||||||
|
<p className="font-display text-lg font-medium leading-snug text-leaf-800 tabular-nums">
|
||||||
|
<FormattedValue value={latest.value} field={field} options={options} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] uppercase tracking-[0.1em] text-ink-mute">
|
||||||
|
as of {formatShortDate(latest.date)}
|
||||||
|
{priorEntries.length > 0 && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
className="font-medium normal-case tracking-normal text-leaf-700 hover:text-leaf-800 hover:underline focus:outline-none focus-visible:underline"
|
||||||
|
>
|
||||||
|
{expanded ? "Hide" : `${priorEntries.length} earlier ${priorEntries.length === 1 ? "entry" : "entries"}`}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && priorEntries.length > 0 && (
|
||||||
|
<ol className="mt-3 space-y-1.5 border-l-2 border-rule-soft pl-4 sm:ml-auto sm:max-w-[24rem]">
|
||||||
|
{priorEntries.map((e) => (
|
||||||
|
<li
|
||||||
|
key={e.activityId}
|
||||||
|
className="flex items-baseline justify-between gap-4 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||||||
|
{formatShortDate(e.date)}
|
||||||
|
</span>
|
||||||
|
<span className="text-right text-ink-soft tabular-nums">
|
||||||
|
<FormattedValue value={e.value} field={field} options={options} />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<number, SelectOption[]>;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none hidden md:block absolute -left-9 top-0 bottom-0 w-7"
|
||||||
|
>
|
||||||
|
{nextState && (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"absolute left-1/2 -translate-x-1/2 top-11 -bottom-11 " +
|
||||||
|
(nextState === "future"
|
||||||
|
? "w-0 border-l border-dashed border-rule"
|
||||||
|
: "w-px bg-leaf-500/70")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MarkerCircle rank={rank} state={state} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkerCircle({ rank, state }: { rank: number; state: PathwayState }) {
|
||||||
|
if (state === "past") {
|
||||||
|
return (
|
||||||
|
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-5 w-5 items-center justify-center rounded-full bg-leaf-700 text-paper shadow-sm">
|
||||||
|
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className="h-2.5 w-2.5">
|
||||||
|
<path d="M3 6.5l2 2 4-5" />
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only">Stage {rank} (past)</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === "current") {
|
||||||
|
return (
|
||||||
|
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-6 w-6 items-center justify-center rounded-full bg-leaf-700 text-paper text-[10px] font-semibold shadow-md ring-2 ring-leaf-100">
|
||||||
|
{rank}
|
||||||
|
<span className="sr-only">Stage {rank} (current)</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-rule bg-paper text-ink-mute">
|
||||||
|
<span className="text-[9px] font-medium tabular-nums">{rank}</span>
|
||||||
|
<span className="sr-only">Stage {rank} (no entries)</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileStem({ show, state }: { show: boolean; state: PathwayState }) {
|
||||||
|
if (!show) return null;
|
||||||
|
return (
|
||||||
|
<div aria-hidden className="md:hidden -mt-4 mb-1.5 flex justify-center">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"block h-4 " +
|
||||||
|
(state === "future"
|
||||||
|
? "w-0 border-l border-dashed border-rule"
|
||||||
|
: "w-px bg-leaf-500/70")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingState() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-rule bg-paper px-6 py-12 text-center">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="mx-auto mb-4 h-7 w-7 animate-spin rounded-full border-[1.5px] border-rule border-t-leaf-700"
|
||||||
|
/>
|
||||||
|
<p className="font-display text-base text-ink-soft italic">Loading your activity report…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed border-rule bg-paper-2/30 px-6 py-10 text-center">
|
||||||
|
<p className="font-display text-lg text-ink-soft">No entries on file yet.</p>
|
||||||
|
<p className="mt-2 text-sm text-ink-mute">
|
||||||
|
Your co-op's first check-in will appear here once it's submitted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div role="alert" className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7">
|
||||||
|
<h2 className="font-display text-xl font-medium text-clay-700">
|
||||||
|
We couldn't open your report.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-relaxed text-ink-soft">{message}</p>
|
||||||
|
<p className="mt-4 text-sm text-ink-soft">
|
||||||
|
If this keeps happening, please contact your engagement coordinator.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -197,3 +197,49 @@ export interface SubmitPayload {
|
|||||||
cs: string;
|
cs: string;
|
||||||
values: Record<string, unknown>;
|
values: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, FieldHistoryEntry[]>;
|
||||||
|
options?: Record<number, SelectOption[]>;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user