Compare commits

...

10 Commits

Author SHA1 Message Date
Joel Brock
dbd607b829 Prune scaffold cruft: unused Next.js template SVGs and axios dep
Removed five default Next.js scaffold SVGs from public/ that were
created by create-next-app and never referenced (file.svg, globe.svg,
next.svg, vercel.svg, window.svg). The actual brand mark
public/fci-logo.png is the only image the app uses.

Removed axios from dependencies — the app uses native fetch
everywhere, and axios hasn't been imported since the initial
scaffolding pass. Lockfile regenerated; build verified clean.
2026-05-13 13:36:21 -07:00
Joel Brock
899dad2323 README: refresh for current architecture, drop stale Framework Stage / mock-field notes
Trimmed the in-the-weeds tech tour (file tree, lib names, custom-field
inventory tutorial) and replaced with a brief functional overview, the
visual/UX highlights that survived the polish passes, the accessibility
features, an env-var table, and the minimum to run/deploy. Outdated
material removed: org-side Framework Stage as authority, 'Org Engagement
Submission' activity type, stage_at_submission write, placeholder
custom_<name> civiField refs, Inter+SourceSerif typography, leaf+stone
palette. Documents stage-from-activity, /report, locked future stages,
draft auto-save, success destination, HTTP-Basic-Auth proxy env vars,
HEALTH_TOKEN, and PREVIEW_ADMIN_TOKEN.
2026-05-13 13:19:11 -07:00
Joel Brock
ba88eb0165 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.
2026-05-13 12:33:26 -07:00
Joel Brock
04e69ca04c Header: show Civi stage label prominently; restore current_stage readonly in Stage 0
- New STAGE_OPTION_GROUP_ID constant (=75) added to config and consumed
  by both the in-card readonly and the header. Because the field carries
  optionGroupId, /api/data auto-includes the Stage option group in its
  parallel fetch (via the existing optionGroupIds derivation).
- SubmissionContextHeader: dropped the small inline 'Stage Organizing'
  line. Replaced with an uppercase 'Current stage' eyebrow next to the
  progress dots, followed by a display-font 20/24px leaf-toned label
  underneath. Resolves the stored option value ('Organizing') to its
  Civi option label ('Stage 1 — Convene & Prepare') via the new
  resolveStageLabel helper; falls back to the raw value if the option
  group hasn't loaded.
- Stage 0 'Check-in (organizing)' section: re-added a current_stage
  readonly field at the top. With optionGroupId set, FieldRenderer's
  readonly branch resolves the value to the Civi label for display.
2026-05-13 12:09:24 -07:00
Joel Brock
74a85d38fe Drop in-form Framework Stage field; align section labels with Civi
The current_stage readonly field is removed from Stage 0 — the activity-
derived stage value already displays in the SubmissionContextHeader, so
showing it again in-card was redundant. current_stage remains in form
state (RHF defaults from /api/data) because every Stage 1-5 visibleWhen
rule and the journey rail still key off it; it just isn't rendered.

Section labels for Stages 1-5 updated to match the CiviCRM Stage option
group labels exactly (ampersand instead of 'and'; Stage 2 corrected from
'Feasibility' to 'Grow & Plan'). Stage 0 keeps its 'Check-in (organizing)'
label since it represents the always-visible core fields, not the Inquiry
stage rank.

Also folded in: in-progress visibleWhen additions on several Stage 0
fields and commented-out field stubs from your working tree.
2026-05-13 12:04:22 -07:00
Joel Brock
b4e80517a7 Derive current stage from most-recent stage-bearing activity
Stage authority moves from Organization.Food_Co_op_Organizing.Stage to the
most recent Check-in (organizing) activity whose Stage custom field is set.
Staff create these activities manually to mark transitions; org-owner form
submissions no longer write the Stage field at all, so they cannot override
a staff-set transition.

- /api/data: removed the Contact.get for org-side Stage; added an
  Activity.get filtered to ACTIVITY_TYPE_NAME + ACTIVITY_STAGE_FIELD IS
  NOT EMPTY, ordered by activity_date_time DESC, id DESC, limit 1.
  Fallback when no such activity exists: Inquiry (rank 0). Org-name
  lookup, stage activity, prefill, and option-group fetch all run in
  parallel via Promise.all.
- /api/submit: removed the stageAtSubmission read + the
  [ACTIVITY_STAGE_FIELD] write on the activity record. The form's
  activities are stage-null by design now.
- config/form.ts: dropped the stage_at_submission readonly field (no
  longer being set or displayed). Kept ACTIVITY_STAGE_FIELD export — it's
  now used by /api/data to find stage-bearing activities. Updated the
  current_stage field comment to reflect the new source.
- components/EngagementForm.tsx: dropped stage_at_submission from
  evalState (no longer referenced by any visibility rule or readonly
  display).

Org.Food_Co_op_Organizing.Stage remains in CiviCRM for staff list views;
the middleware no longer reads or writes it. No backfill required —
orgs without a stage-bearing activity simply read as Inquiry.
2026-05-13 11:41:25 -07:00
Joel Brock
18b7a67fa1 Swap inline 'Sown' SVG for the real FCI brand logo
Drops the stylized SVG mark in favor of public/fci-logo.png (the official
Food Co-op Initiative logo, including its wordmark). Because the logo now
contains the 'Food Co-op Initiative' text, the duplicate display-font
wordmark is removed; the 'Co-op Check-in' eyebrow stays as a quiet hairline
divider to the right (sm+ only, hidden on mobile to keep the header tight).
2026-05-11 13:42:06 -07:00
Joel Brock
762605f04b Compact the journey rail to reclaim form width
Gutter shrinks from md:pl-20 (80px) to md:pl-12 (48px) — a 32px (40%) gain
back to the form column. Markers, pulse halo, and 'Now' pill scale down to
match so the rail still reads at a glance:

- Marker container: -left-14 w-12 -> -left-9 w-7
- Past/future markers: h-6 -> h-5
- Current marker: h-7 ring-4 -> h-6 ring-2; rank label 11px -> 10px
- 'Now' pill: 9px -> 8px; tracking and offset re-balanced for the shorter
  drop from the smaller marker bottom
- Rail-pulse keyframe shadow radius: 8px -> 6px to suit the smaller disc
2026-05-11 13:37:28 -07:00
Joel Brock
9103ccaf9d Locked future-stage cards + journey rail
Future stages now render as preview-only "look ahead" cards instead of being
hidden. A user at stage 2 can see headers and contents for stages 3, 4, 5,
but those sections are visibly locked and uneditable.

Locked-card treatment:
- Dashed-rule border, paper-2 fill, no shadow — visually quieter than
  active cards
- "Upcoming" pill in the header with a small lock glyph
- Muted stage rank mark (dashed badge, low-opacity icon)
- Panel content wrapped in fieldset[disabled] so every form control inside
  is natively non-interactive, with an opacity tweak for affordance
- "A look ahead" banner explaining that fields will become editable when
  the co-op reaches this stage

Section visibleWhen is still consulted on submit, so locked-stage values
never get written back to CiviCRM even if data is prefilled.

Journey rail:
- Vertical rail (md+) in a new left gutter; each card carries an aligned
  marker. Past stages = filled leaf circle with check; current = filled
  leaf disc with rank number, leaf-100 halo ring, and a subtle rail-pulse
  box-shadow animation (motion-safe). A "Now" pill sits beneath the
  current marker. Future stages = dashed hollow ring with lock glyph.
- Connector segments between markers are solid leaf when the next stage
  is past-or-current, dashed muted when future — so the transition from
  "traveled" to "ahead" reads at the right place in the journey.
- Mobile fallback: a small vertical stem in the gap between adjacent
  cards, styled the same way (solid vs dashed) so the progression cue
  still reads on narrow viewports.
2026-05-11 13:05:34 -07:00
Joel Brock
a804650f65 Audit short-term: currency preview, date bounds, success destination, safe-area
- H3: Live currency preview below currency inputs shows the value formatted
  with thousands separators (en-US, USD) using Intl.NumberFormat. Skipped
  inside matrix cells to keep the Y1 monthly table compact.
- M1: Date inputs now apply min/max bounds. Default window is 1900-01-01 to
  2100-12-31; per-field override via FieldConfig.min/max as ISO strings.
- H6: On successful submit, replace the form with a SuccessDestination card
  (large checkmark, org name, "Submit another" + "safe to close" affordance).
  Prevents accidental duplicate submits from back-button / autofill replay.
- M6: Sticky submit bar respects iOS safe-area-inset-bottom.

FieldRenderer now takes a control prop so the currency preview can subscribe
to its single field via useWatch without re-rendering the whole form.
2026-05-11 09:48:03 -07:00
21 changed files with 1751 additions and 536 deletions

266
README.md
View File

@@ -1,168 +1,136 @@
# Co-op Check-in (WebForm-mw)
# Co-op Check-in
Standalone Next.js middleware that lets external co-op contacts update their
organization's tracking data on CiviCRM, sidestepping the limitations of
Webform CiviCRM's admin UI.
A tokenized, mobile-friendly web form that lets external co-op contacts
update their organization's tracking data on CiviCRM, plus a read-only
activity report showing every value the co-op has shared over time. Built
to wrap CiviCRM's existing custom-field schema without modifying it.
This app is the **v2** delivery path described in
`../docs/superpowers/specs/2026-05-08-civi-webform-design.md`.
## How it works
## What it does
Each co-op has a designated **Primary Contact** (the individual) linked
to the **Organization** record in CiviCRM. Staff send that contact a
personalized link generated against the contact's CiviCRM checksum:
A form-filler clicks a tokenized link in an email:
- `https://check-in.fci.coop/?cid=<contactId>&cs=<checksum>` — the form
- `https://check-in.fci.coop/report?cid=<contactId>&cs=<checksum>` — the report
```
https://check-in.fci.coop/?cid=<contactId>&cs=<checksum>
```
When the link is opened, the app verifies the checksum against CiviCRM,
resolves the organization through the Primary Contact relationship, and
loads the right view.
The app:
**Form.** Sections are organized by the co-op development stages
(Inquiry → Convene & Prepare → Grow & Plan → Connect & Gather → Excite &
Build → Fulfill & Stabilize). The org's current stage controls which
sections are editable; past and current stages are open for editing,
future stages render as previews with their fields locked so the co-op
can see the framework ahead. Fields prefill with each value's most
recent non-empty entry from past check-ins. On submit, a new
"Check-in (organizing)" activity is created; nothing is overwritten.
1. Verifies the checksum against CiviCRM.
2. Resolves the org from the contact's **Primary Contact** relationship (Individual → Organization). Configurable via `FORM_CONTACT_RELATIONSHIP` in `config/form.ts`.
3. Reads the org's **Framework Stage** (`Food_Co_op_Organizing.Stage`) — text-keyed values like `Inquiry`, `Organizing`, `Feasibility`, `Business feasibility`, `Store Implementation`, `Stabilize newly opened co-op`.
4. Walks the org's past `Org Engagement Submission` activities DESC and assembles per-field-most-recent prefill values (the exact behaviour the WCM admin UI cannot express).
5. Renders the form with stage-conditional sections — Stage 0 (Inquiry / core check-in fields, ~32 of them) is always visible; Stages 15 each appear when `current_stage` is in their visibility set.
6. On submit, creates a **new immutable** `Org Engagement Submission` activity with the visible-field values plus a `stage_at_submission` audit field.
**Stage authority.** The current stage is derived from the most recent
"Check-in (organizing)" activity whose Stage field is set. Staff own
stage transitions by manually setting Stage on a check-in activity they
create; the form itself never writes Stage, so org self-submissions
can't override a staff transition. Orgs with no stage-bearing activity
default to "Inquiry."
## Tech stack
**Report.** A read-only view of every field that has ever held a value,
grouped by stage. Each row shows the most recent value prominently; an
"N earlier entries" disclosure expands a chronological list of prior
values with their dates. Empty stages and untouched fields are hidden so
the page stays calm.
- Next.js 16 (App Router) + React 19 + TypeScript
- Tailwind CSS 4 (CSS-based `@theme` config)
- `react-hook-form` for state + validation
- `axios` (only used in earlier scaffolding; this version uses native `fetch`)
## Visual & UX details
## Project structure
```
app/
layout.tsx Root layout, fonts (Inter + Source Serif 4)
page.tsx Public form entry; reads cid+cs from URL
globals.css Tailwind 4 + theme tokens (leaf + stone palettes)
api/
data/route.ts GET form data + per-field-most-recent prefill
submit/route.ts POST submission → creates new Civi activity
components/
EngagementForm.tsx Top-level orchestrator
StageSection.tsx Collapsible accordion card per stage section
SiteChrome.tsx Header + footer chrome (logo placeholder included)
fields/
FieldRenderer.tsx One renderer for all 12 field types
config/
form.ts The 123-field form definition
lib/
civicrm.ts APIv4 client (Bearer-style auth)
conditional.ts Visibility rule engine
prefill.ts Per-field-most-recent walk
types/
form.ts FormConfig, FieldConfig, VisibilityRule, etc.
```
## Configuration
The app falls back to **STUB MODE** with hardcoded mock data when any of
these environment variables are missing. The UI renders fully and the
form is interactive, but no CiviCRM calls are made.
```bash
# .env.local
CIVI_BASE_URL=https://crm.fci.coop
CIVI_API_KEY=<a Civi user's API key>
CIVI_SITE_KEY=<the site_key from civicrm.settings.php>
```
The exact auth header strategy depends on which CiviCRM auth extension is
active. The default in `lib/civicrm.ts` uses the AuthX-style Bearer header
for the API key plus the legacy `X-Civi-Key` for the site key. If your
instance uses a different scheme (e.g. classic APIv3 `api_key`+`key` query
params), adjust `lib/civicrm.ts` accordingly.
### Custom-field references (CiviCRM APIv4)
Form fields write to CiviCRM custom fields via the `civiField` property.
The convention is APIv4's `<group_name>.<field_name>` format. For now,
`config/form.ts` uses placeholder `custom_<form_field_name>` references
for all but the Framework Stage. **Before going live, replace each
`civiField` value with the actual APIv4 reference** for that custom
field on the `Org Engagement Submission` activity type.
To inventory the actual field names, query APIv4 from the Civi UI:
```
/civicrm/api4#/explorer
→ CustomField.get
select: ["name", "label", "custom_group_id:name"]
where: [["custom_group_id:name", "IN", ["Stage_0_Inquiry", "Stage_1_*", ...]]]
```
## Running locally
```bash
npm install
npm run dev
# open http://localhost:3000/?cid=anything&cs=anything (any non-empty cs while in stub mode)
```
## Building
```bash
npm run build
```
## Deploying
The simplest deployment is Vercel. The app is a standard App Router project:
```bash
vercel
```
Set the three CiviCRM env vars in the Vercel project settings; the app
auto-leaves stub mode the moment all three are present.
For self-hosting, any Node 20+ environment supporting Next.js 16 will work:
```bash
npm run build && npm start
```
- **Field Almanac** aesthetic: warm cream paper, deep botanical green
ink, a sparing terracotta accent. Fraunces (display) and DM Sans
(body) at variable weights. Subtle paper-grain texture via layered
CSS gradients.
- **Journey rail** runs down the left side on desktop, with a marker
per stage and a "Now" pill at the current stage. Past stages are
filled with a checkmark, future stages are dashed and locked. A small
inter-card stem stands in for the rail on mobile.
- **Draft auto-save** to `localStorage` (30-day TTL) so partial answers
survive page refreshes; a "Draft restored" banner appears on return.
- **Successful submit** lands on a destination screen instead of a
reloaded form, preventing accidental duplicate submits from
back-button or autofill replay.
- **Currency live preview**, date min/max bounds (19002100), and
thousands-separator formatting on numeric fields.
- **Hand-drawn stage icons** for each of the six stages; FCI brand logo
in the header.
## Accessibility
- All inputs have proper `<label htmlFor>` associations.
- Help text and error messages are linked via `aria-describedby`.
- Errors set `aria-invalid` and use `role="alert"` for assistive tech.
- Required fields use `aria-required` plus a visible `*` (with
`aria-label="required"`).
- Sections use semantic `<section>` + `<h2>` + `aria-controls` accordion pattern.
- Keyboard-only flow works end-to-end.
- Color contrast meets WCAG AA against the leaf + stone palette (verified
visually; an audit pass is recommended before launch).
- A skip-to-content link is present for keyboard users.
- Real `<label htmlFor>` on every control; help text and inline errors
linked via `aria-describedby`.
- Required fields use `aria-required`, a visible `*` with
`aria-label="required"`, and typed error messages.
- Failed-validation submit auto-expands the section containing the
first error, scrolls it into view, and focuses the field.
- Accordion sections follow the disclosure pattern: `<button>` headers
with `aria-expanded` + `aria-controls`, `role="region"` panels.
- All animations honour `prefers-reduced-motion`.
- Skip-to-content link present for keyboard users.
- iOS safe-area inset respected on the sticky submit bar.
## Conditional logic
## Environment variables
Rules live alongside fields and sections in `config/form.ts`. The engine
(`lib/conditional.ts`) supports:
The app runs in **stub mode** when any of the CiviCRM variables below
are missing — the UI is fully exercisable against synthesized data with
no live CRM calls. Production startup will refuse to boot in stub mode.
- `equals`, `notEquals`, `in`, `notIn`, `isEmpty`, `isNotEmpty`
- Composition via `{all: [...]}` (AND) and `{any: [...]}` (OR)
| Variable | Required? | Purpose |
|---|---|---|
| `CIVI_BASE_URL` | yes | CiviCRM root, e.g. `https://crm.fci.coop` |
| `CIVI_API_KEY` | yes | API key of a Civi user with permission to read contacts/activities and create activities |
| `CIVI_SITE_KEY` | yes | The `site_key` from `civicrm.settings.php` |
| `CIVI_HTTP_AUTH_USER` | optional | Username, if CiviCRM sits behind HTTP Basic Auth at the webserver layer |
| `CIVI_HTTP_AUTH_PASS` | optional | Password for the above |
| `HEALTH_TOKEN` | optional | If set, `/api/health` requires `?token=…` in production |
| `PREVIEW_ADMIN_TOKEN` | optional | If set, `/api/preview-link` requires a matching `Authorization: Bearer …` header (admin convenience endpoint for minting form links) |
| `NODE_ENV` | — | Set to `production` for the production build |
Section visibility re-evaluates live on every form change. Hidden fields'
values are stripped from the submitted payload, so users never accidentally
write data they couldn't see.
Copy `.env.example` to `.env.local` for local development.
## What's not in scope for this build
## Run locally
- **Browser-based form builder.** Forms are configured by editing
`config/form.ts`. An admin UI for non-developers to build forms is a
follow-on project.
- **File upload to a storage backend.** File fields render correctly but
the Civi-side upload pipeline is a stub. Wire up to S3/R2 + CiviCRM's
`File` entity when ready.
- **CSRF protection on the submit endpoint.** Checksum gating is the
current trust mechanism. Add CSRF tokens if you put this behind a
long-lived session.
- **i18n.** Copy is English-only.
```
npm install
npm run dev
```
Open `http://localhost:3000/?cid=1&cs=anything` — any non-empty `cs`
works while in stub mode. The report is at `/report?cid=1&cs=anything`.
## Deploy
The repo ships with `render.yaml` for [Render](https://render.com) and a
detailed `DEPLOYMENT.md` covering Render specifically. The app is a
standard Next.js 16 App Router project and runs anywhere Node 20+ can
run `next start` — Vercel, AWS Amplify Hosting, App Runner, Fly,
self-hosted, etc.
Build + start:
```
npm run build
npm start
```
Set all three required CiviCRM variables in the host's secret store;
the app auto-leaves stub mode the moment they're all present.
A `/healthz` route returns a lightweight 200 for platform health checks;
`/api/health` returns a richer diagnostic payload (gated by
`HEALTH_TOKEN` in production).
## Editing the form
The form definition lives in `config/form.ts` — one TypeScript file
declaring sections, fields, types, visibility rules, and matrix groups
(used for compact M1M12 / Q1Q4 layouts in Stage 5). Each field
references its CiviCRM custom field via APIv4's `<group_name>.<field_name>`
syntax. Edit the file, restart `npm run dev`, you're done.
A browser-based form builder is out of scope for this version.

View File

@@ -2,9 +2,11 @@
* GET /api/data?cid=<cid>&cs=<cs>
*
* Verifies the checksum, resolves the org from the contact via the
* Primary Contact relationship, reads the org's Framework Stage
* (`Food_Co_op_Organizing.Stage` on the Organization contact), and walks
* past `Check-in (organizing)` activities for per-field most-recent prefill.
* Primary Contact relationship, derives the org's current Framework Stage
* from the most recent `Check-in (organizing)` activity whose Stage custom
* field is non-empty (staff set this manually to mark transitions; the
* form itself leaves it null), and walks past activities for per-field
* most-recent prefill.
*
* Also fetches the OptionValue rows for any option_group_ids referenced by
* the form (so radio/select/multiselect fields render with real CRM-defined
@@ -19,9 +21,21 @@
import { NextRequest, NextResponse } from "next/server";
import { civi, verifyChecksum } from "@/lib/civicrm";
import { loadPrefill } from "@/lib/prefill";
import { allFields, optionGroupIds, ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
import {
allFields,
optionGroupIds,
ACTIVITY_TYPE_NAME,
ACTIVITY_STAGE_FIELD,
FORM_CONTACT_RELATIONSHIP,
} from "@/config/form";
import type { FormDataPayload, SelectOption } from "@/types/form";
/**
* Fallback when an org has no stage-bearing check-in activity yet.
* Matches the entry stage in CiviCRM's Stage option group.
*/
const DEFAULT_STAGE = "Inquiry";
const STUB_PAYLOAD: FormDataPayload = {
orgName: "Sample Co-op (stub)",
currentStage: "Organizing",
@@ -144,31 +158,41 @@ export async function GET(req: NextRequest) {
}
const orgId = orgs[0].contact_id_b;
// Fetch org name + Framework Stage (org-side, on the organization contact).
const orgRes = await civi<{
id: number;
display_name: string;
"Food_Co_op_Organizing.Stage": string | null;
}>("Contact", "get", {
select: ["id", "display_name", "Food_Co_op_Organizing.Stage"],
where: [["id", "=", orgId]],
});
const org = orgRes.values?.[0];
if (!org) {
return NextResponse.json({ error: "Organization not found." }, { status: 404 });
}
const currentStage = org["Food_Co_op_Organizing.Stage"] ?? "";
// Per-field-most-recent prefill across past Check-in activities, plus
// option-group fetch — kicked off in parallel.
const [{ values: prefill }, options] = await Promise.all([
// Fire org-name lookup, stage-bearing-activity lookup, prefill walk, and
// option-group fetch in parallel — they're independent.
const [orgRes, stageActivityRes, { values: prefill }, options] = await Promise.all([
civi<{ id: number; display_name: string }>("Contact", "get", {
select: ["id", "display_name"],
where: [["id", "=", orgId]],
}),
// Most recent Check-in (organizing) activity whose Stage custom field
// is set. Staff own this field; the form never writes it. Tiebreaker on
// equal activity_date_time is id DESC.
civi<{ id: number; [key: string]: unknown }>("Activity", "get", {
select: ["id", ACTIVITY_STAGE_FIELD],
where: [
["activity_type_id:name", "=", ACTIVITY_TYPE_NAME],
["target_contact_id", "=", orgId],
[ACTIVITY_STAGE_FIELD, "IS NOT EMPTY"],
],
orderBy: { activity_date_time: "DESC", id: "DESC" },
limit: 1,
}),
loadPrefill(orgId, allFields, ACTIVITY_TYPE_NAME),
fetchOptionGroups(),
]);
const org = orgRes.values?.[0];
if (!org) {
return NextResponse.json({ error: "Organization not found." }, { status: 404 });
}
const stageRaw = stageActivityRes.values?.[0]?.[ACTIVITY_STAGE_FIELD];
const currentStage = typeof stageRaw === "string" && stageRaw ? stageRaw : DEFAULT_STAGE;
const payload: FormDataPayload = {
orgName: org.display_name,
currentStage: typeof currentStage === "string" ? currentStage : "",
currentStage,
prefill,
options,
};

247
app/api/report/route.ts Normal file
View 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);
}

View File

@@ -5,16 +5,17 @@
*
* Verifies checksum, resolves org from cid (mirror of /api/data), then
* creates a new `Check-in (organizing)` activity. The activity's own Stage
* field (`Check_in_data__organizing_.Stage`) is auto-populated from the
* org's current `Food_Co_op_Organizing.Stage` so each check-in carries a
* snapshot of where the org was at submission time.
* field is intentionally left null: the form is not the authority on stage.
* Staff set Stage on their own check-in activities to mark transitions, and
* /api/data derives the org's current stage from the most recent stage-
* bearing activity.
*
* STUB MODE: if CiviCRM env vars are unset, returns success without writing.
*/
import { NextResponse } from "next/server";
import { civi, verifyChecksum } from "@/lib/civicrm";
import { allFields, ACTIVITY_TYPE_NAME, ACTIVITY_STAGE_FIELD, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
import { allFields, ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
import type { SubmitPayload } from "@/types/form";
import { appEnv, redact } from "@/lib/env";
import { rateLimit, clientIp } from "@/lib/rate-limit";
@@ -102,33 +103,19 @@ async function runSubmit(cid: string, cs: string, values: Record<string, unknown
}
const orgId = orgs[0].contact_id_b;
// Read the org's current Framework Stage so we can snapshot it on the activity.
const orgRes = await civi<{ "Food_Co_op_Organizing.Stage": string | null }>(
"Contact",
"get",
{
select: ["Food_Co_op_Organizing.Stage"],
where: [["id", "=", orgId]],
},
);
const stageAtSubmission = orgRes.values?.[0]?.["Food_Co_op_Organizing.Stage"] ?? null;
// Build the activity record. Each form-side field name maps to its
// configured `civiField` for the activity-side write.
// Build the activity record. The Stage custom field is deliberately NOT
// set here — staff own stage transitions on their own activities.
const activityRecord: Record<string, unknown> = {
"activity_type_id:name": ACTIVITY_TYPE_NAME,
"status_id:name": "Completed",
target_contact_id: orgId,
source_contact_id: Number(cid),
subject: "Co-op Check-in (form submission)",
[ACTIVITY_STAGE_FIELD]: stageAtSubmission,
};
for (const [name, value] of Object.entries(values)) {
const field = FIELD_BY_NAME.get(name);
if (!field || !field.civiField) continue;
if (field.type === "readonly") continue; // never write read-only fields
if (name === "current_stage") continue; // org-side, never written to activity
if (name === "stage_at_submission") continue; // handled above via ACTIVITY_STAGE_FIELD
activityRecord[field.civiField] = value;
}

View File

@@ -142,6 +142,19 @@ input[type="date"]::-webkit-calendar-picker-indicator {
}
}
/* Journey rail — gentle "you are here" pulse on the current stage marker.
* Animates the ring shadow only; the marker disc stays at full opacity so
* the cue reads as a halo rather than a fade. Reduced-motion users get the
* static state thanks to the global override above. */
@keyframes rail-pulse {
0%, 100% {
box-shadow: 0 0 0 0 oklch(72% 0.14 135 / 0.55);
}
60% {
box-shadow: 0 0 0 6px oklch(72% 0.14 135 / 0);
}
}
/* A small cohort of utility classes — rule lines, rough underlines, etc. */
.rule-soft {
background: linear-gradient(

77
app/report/page.tsx Normal file
View 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>
);
}

View File

@@ -17,6 +17,7 @@ function sectionForField(
return undefined;
}
import { evaluate } from "@/lib/conditional";
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
import { StageSection } from "./StageSection";
import { loadDraft, saveDraft, clearDraft } from "@/lib/draft";
@@ -37,6 +38,8 @@ type SubmitStatus =
| { kind: "success" }
| { kind: "error"; message: string };
type PathwayState = "past" | "current" | "future";
const STAGE_RANK: Record<string, number> = {
Inquiry: 0,
Organizing: 1,
@@ -74,13 +77,11 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
// State map passed to the conditional engine and to FieldRenderer for
// readonly display values. Only fields referenced by visibility rules or
// by readonly displays need to be here. Both readonly fields in this form
// (current_stage, stage_at_submission) display the org's current stage.
// by readonly displays need to be here — currently just current_stage,
// which gates every Stage 15 visibleWhen rule and is shown as a readonly
// field in the Stage 0 header.
const evalState = useMemo(
() => ({
current_stage: currentStageValue,
stage_at_submission: currentStageValue,
}),
() => ({ current_stage: currentStageValue }),
[currentStageValue],
);
@@ -157,18 +158,25 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
};
}, [load.kind, cid, watch]);
// ── Section ordering ───────────────────────────────────────────────────
const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
// Section pathway state — past / current / future is determined entirely
// by stage rank vs the org's current rank. Sections never hide; future
// stages render in a locked treatment so users can preview what's ahead.
//
// Section-level visibleWhen is still consulted on submit to strip
// locked-stage values from the payload (see onSubmit below), so a future
// stage's data never gets accidentally written.
const sectionsToRender = useMemo(
() =>
config.sections.map((s) => ({
section: s,
sectionVisible: evaluate(s.visibleWhen, evalState),
})),
[config.sections, evalState],
config.sections.map((s) => {
const pathwayState: PathwayState =
s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "future";
return { section: s, pathwayState, locked: pathwayState === "future" };
}),
[config.sections, currentRank],
);
const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
// Track which sections the user has manually opened/closed so we can
// programmatically re-open them when validation surfaces an error within.
// Used by the onInvalid handler below.
@@ -182,6 +190,21 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
if (load.kind === "loading") return <LoadingState />;
if (load.kind === "error") return <ErrorState message={load.message} />;
// After a successful submit, replace the form entirely with a destination
// screen. This prevents accidental duplicate submits (back-button, double-
// click, browser autofill replay) and gives the user a clear endpoint.
if (submitState.kind === "success") {
return (
<SuccessDestination
orgName={load.data.orgName}
onAnother={() => {
setSubmitState({ kind: "idle" });
window.scrollTo({ top: 0, behavior: "smooth" });
}}
/>
);
}
const onSubmit = async (values: Record<string, unknown>) => {
setSubmitState({ kind: "submitting" });
try {
@@ -261,7 +284,7 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-5" noValidate>
<SubmissionContextHeader
orgName={load.data.orgName}
currentStage={load.data.currentStage}
currentStageLabel={resolveStageLabel(load.data.currentStage, load.data.options)}
currentRank={currentRank}
/>
@@ -275,18 +298,30 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
}} />
)}
<ol className="space-y-5">
{sectionsToRender.map(({ section, sectionVisible }) => {
if (!sectionVisible) return null;
<ol className="relative space-y-5 md:pl-12">
{sectionsToRender.map((entry, i) => {
const { section, pathwayState, locked } = entry;
const nextState =
i < sectionsToRender.length - 1
? sectionsToRender[i + 1].pathwayState
: undefined;
return (
<li key={section.id} className="list-none">
<li key={section.id} className="relative list-none">
<MobileStem show={i > 0} state={pathwayState} />
<RailMarker
rank={section.rank}
state={pathwayState}
nextState={nextState}
/>
<StageSection
section={section}
register={register}
control={control}
errors={errors}
formValues={evalState}
isCurrent={section.rank === currentRank}
defaultOpen={section.rank === currentRank || section.rank === 0}
isCurrent={pathwayState === "current"}
locked={locked}
defaultOpen={pathwayState === "current" || section.rank === 0}
options={load.data.options ?? {}}
/>
</li>
@@ -305,11 +340,11 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
function SubmissionContextHeader({
orgName,
currentStage,
currentStageLabel,
currentRank,
}: {
orgName: string;
currentStage: string;
currentStageLabel: string;
currentRank: number;
}) {
return (
@@ -318,17 +353,34 @@ function SubmissionContextHeader({
<h1 className="mt-1 font-display text-3xl font-medium leading-tight tracking-tight text-ink sm:text-[34px]">
{orgName}
</h1>
<div className="mt-3 flex items-center gap-3">
<div className="mt-4 flex items-center gap-3">
<StageProgress currentRank={currentRank} />
<span className="text-sm text-ink-soft">
<span className="text-ink-mute">Stage</span>{" "}
<span className="font-medium text-leaf-800">{currentStage || "—"}</span>
<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>
</header>
);
}
/**
* Resolve a Stage option-value (e.g. "Organizing") to its Civi option label
* (e.g. "Stage 1 — Convene & Prepare"). Falls back to the raw value if the
* option group hasn't loaded or the value isn't in the group.
*/
function resolveStageLabel(
value: string,
options: Record<number, { value: string; label: string }[]> | undefined,
): string {
if (!value) return "";
const group = options?.[STAGE_OPTION_GROUP_ID];
if (!group) return value;
return group.find((o) => o.value === value)?.label ?? value;
}
/**
* Six small dots representing the six stages, with the current rank filled
* and earlier ranks marked as visited. Quietly conveys progression without
@@ -392,7 +444,10 @@ function SubmitBar({
draftSavedAt: string | null;
}) {
return (
<div className="sticky bottom-3 z-20 flex flex-col gap-3 rounded-lg border border-rule bg-paper/95 px-5 py-4 shadow-[0_-4px_24px_-12px_rgba(60,80,40,0.2)] backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-6">
<div
className="sticky bottom-3 z-20 flex flex-col gap-3 rounded-lg border border-rule bg-paper/95 px-5 py-4 shadow-[0_-4px_24px_-12px_rgba(60,80,40,0.2)] backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-6"
style={{ marginBottom: "env(safe-area-inset-bottom)" }}
>
<div className="flex flex-col gap-0.5">
<SubmitFeedback state={state} />
{state.kind === "idle" && (
@@ -429,12 +484,6 @@ function SubmitFeedback({ state }: { state: SubmitStatus }) {
Saving your check-in
</p>
);
if (state.kind === "success")
return (
<p className="text-sm font-medium text-leaf-800" role="status">
Check-in saved. Thank you.
</p>
);
if (state.kind === "error")
return (
<p className="text-sm font-medium text-clay-700" role="alert">
@@ -471,6 +520,49 @@ function LoadingState() {
);
}
function SuccessDestination({
orgName,
onAnother,
}: {
orgName: string;
onAnother: () => void;
}) {
return (
<section
role="status"
aria-live="polite"
className="rounded-lg border-2 border-leaf-600 bg-leaf-50/40 px-6 py-10 text-center shadow-[0_1px_0_0_rgba(0,0,0,0.04),0_12px_32px_-16px_rgba(60,80,40,0.22)] sm:px-10 sm:py-12"
>
<div
aria-hidden
className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-leaf-700 text-paper shadow-sm"
>
<svg viewBox="0 0 24 24" className="h-7 w-7" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12l5 5 9-11" />
</svg>
</div>
<h2 className="mt-5 font-display text-2xl font-medium leading-tight tracking-tight text-ink sm:text-3xl">
Check-in saved
</h2>
<p className="mx-auto mt-3 max-w-prose text-[15px] leading-relaxed text-ink-soft">
Thank you. We&apos;ve recorded this check-in for{" "}
<span className="font-medium text-ink">{orgName}</span>. Your engagement coordinator will see
it on their next review.
</p>
<div className="mt-7 flex flex-col items-center justify-center gap-3 sm:flex-row">
<button
type="button"
onClick={onAnother}
className="inline-flex items-center justify-center rounded-md border border-rule bg-paper px-5 py-2 text-sm font-medium text-ink-soft transition hover:bg-paper-2/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40"
>
Submit another check-in
</button>
<p className="text-xs text-ink-mute">It&apos;s safe to close this window.</p>
</div>
</section>
);
}
function ErrorState({ message }: { message: string }) {
return (
<div
@@ -489,6 +581,99 @@ function ErrorState({ message }: { message: string }) {
);
}
/**
* Vertical journey rail marker, visible on md+ only. Sits in the OL's left
* gutter, aligned to the card header. Includes a downward connecting line
* toward the next marker; the line's style is determined by whether the
* NEXT stage is past/current (solid leaf) or future (dashed muted) — that
* way the transition from "traveled" to "ahead" happens right after the
* current marker, which reads correctly as "you've come this far."
*/
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} />
{state === "current" && (
<span className="absolute left-1/2 -translate-x-1/2 top-[52px] whitespace-nowrap rounded-full bg-leaf-700 px-1.5 py-[1px] text-[8px] font-medium uppercase tracking-[0.1em] text-paper shadow-sm">
Now
</span>
)}
</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} (completed)</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 motion-safe:animate-[rail-pulse_2.6s_ease-in-out_infinite]">
{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">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" className="h-2.5 w-2.5">
<rect x="2.5" y="5.5" width="7" height="5" rx="0.75" />
<path d="M4 5.5V4a2 2 0 0 1 4 0v1.5" />
</svg>
<span className="sr-only">Stage {rank} (upcoming)</span>
</span>
);
}
/**
* Mobile-only inter-card stem. Sits centered in the gap between adjacent
* stage cards. Solid leaf when arriving at a past-or-current stage; dashed
* muted when arriving at a future (locked) stage.
*/
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 formatRelative(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return iso;

608
components/ReportView.tsx Normal file
View 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&apos;s first check-in will appear here once it&apos;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&apos;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>
);
}

View File

@@ -1,3 +1,4 @@
import Image from "next/image";
import Link from "next/link";
export function SiteHeader() {
@@ -6,18 +7,20 @@ export function SiteHeader() {
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-5 sm:px-6">
<Link
href="https://fci.coop"
className="group flex items-center gap-3.5 focus:outline-none"
className="group flex items-center gap-4 focus:outline-none"
aria-label="Food Co-op Initiative — fci.coop"
>
<Logo />
<div className="flex flex-col leading-none">
<span className="font-display text-base font-medium text-ink tracking-tight">
Food Co-op Initiative
</span>
<span className="mt-1 text-[11px] uppercase tracking-[0.18em] text-ink-mute">
Co-op Check-in
</span>
</div>
<Image
src="/fci-logo.png"
alt="Food Co-op Initiative"
width={130}
height={105}
priority
className="h-12 w-auto sm:h-14"
/>
<span className="hidden text-[11px] uppercase tracking-[0.18em] text-ink-mute sm:inline-block sm:border-l sm:border-rule sm:pl-4">
Co-op Check-in
</span>
</Link>
<nav aria-label="Primary">
<Link
@@ -53,51 +56,3 @@ export function SiteFooter() {
);
}
/**
* "Sown" — a stylized geometric mark: a center point (the co-op) with six
* radiating sprouts (the six framework stages). Inscribed in a soft circle
* (the cooperative principle). Pure SVG, scales cleanly, no external asset.
*
* Drawn at 40px; can be sized via className.
*/
function Logo({ className = "" }: { className?: string }) {
return (
<svg
width="42"
height="42"
viewBox="0 0 42 42"
role="img"
aria-label="Food Co-op Initiative logo"
className={"text-leaf-700 " + className}
>
{/* Outer cooperative ring */}
<circle cx="21" cy="21" r="19" stroke="currentColor" strokeWidth="1.2" fill="none" />
<circle cx="21" cy="21" r="19" fill="currentColor" opacity="0.06" />
{/* Six sprouts radiating from center, one per stage */}
<g stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" fill="none">
{/* North */}
<path d="M21 21 V8" />
<path d="M21 12 q-2.5 -1 -3.5 -3 q3 -.5 3.5 3 z" fill="currentColor" opacity="0.7" />
{/* NE */}
<path d="M21 21 L30 12" />
<path d="M27.3 14.7 q.7 -2.6 -.5 -4.7 q2.5 1.5 .5 4.7 z" fill="currentColor" opacity="0.6" />
{/* SE */}
<path d="M21 21 L30 30" />
<path d="M27.3 27.3 q2.6 .7 4.7 -.5 q-1.5 2.5 -4.7 .5 z" fill="currentColor" opacity="0.5" />
{/* South */}
<path d="M21 21 V34" />
<path d="M21 30 q2.5 1 3.5 3 q-3 .5 -3.5 -3 z" fill="currentColor" opacity="0.4" />
{/* SW */}
<path d="M21 21 L12 30" />
<path d="M14.7 27.3 q-2.6 .7 -4.7 -.5 q1.5 2.5 4.7 .5 z" fill="currentColor" opacity="0.5" />
{/* NW */}
<path d="M21 21 L12 12" />
<path d="M14.7 14.7 q-.7 -2.6 .5 -4.7 q-2.5 1.5 -.5 4.7 z" fill="currentColor" opacity="0.6" />
</g>
{/* Center seed */}
<circle cx="21" cy="21" r="2.2" fill="currentColor" />
</svg>
);
}

View File

@@ -2,7 +2,12 @@
import { useEffect, useId, useMemo, useState } from "react";
import type { StageSectionConfig, SelectOption } from "@/types/form";
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form";
import type {
UseFormRegister,
FieldValues,
FieldErrors,
Control,
} from "react-hook-form";
import { FieldRenderer } from "./fields/FieldRenderer";
import { MatrixGroup } from "./MatrixGroup";
import { StageIcon } from "./StageIcon";
@@ -11,11 +16,18 @@ import { evaluate } from "@/lib/conditional";
interface StageSectionProps {
section: StageSectionConfig;
register: UseFormRegister<FieldValues>;
control: Control<FieldValues>;
errors: FieldErrors;
/** Live form values, used to evaluate per-field visibility rules. */
formValues: Record<string, unknown>;
/** Whether this is the section that matches the current org stage. */
isCurrent: boolean;
/**
* Whether this stage is ahead of the org's current stage. Locked sections
* still render so the user can see what's coming, but the fields inside
* are wrapped in a disabled fieldset and never written on submit.
*/
locked: boolean;
/** Whether the section starts open. */
defaultOpen: boolean;
/** Option groups fetched from CiviCRM, keyed by option_group_id. */
@@ -36,9 +48,11 @@ interface StageSectionProps {
export function StageSection({
section,
register,
control,
errors,
formValues,
isCurrent,
locked,
defaultOpen,
options,
}: StageSectionProps) {
@@ -76,15 +90,22 @@ export function StageSection({
0,
);
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"
: locked
? "border border-dashed border-rule bg-paper-2/30 shadow-none"
: "border border-rule bg-white/95 shadow-sm";
const buttonClass = isCurrent
? "bg-leaf-50/60"
: locked
? "hover:bg-paper-2/50"
: "hover:bg-paper-2/60";
return (
<section
aria-labelledby={headingId}
className={
"overflow-hidden rounded-lg bg-white/95 transition " +
(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)]"
: "border border-rule shadow-sm")
}
className={"overflow-hidden rounded-lg transition " + cardClass}
>
<h2 id={headingId} className="m-0">
<button
@@ -94,12 +115,17 @@ export function StageSection({
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")
buttonClass
}
>
<StageRankMark rank={section.rank} isCurrent={isCurrent} />
<StageRankMark rank={section.rank} isCurrent={isCurrent} locked={locked} />
<span className="flex-1 min-w-0">
<span className="block font-display text-lg font-medium text-ink leading-tight tracking-tight sm:text-xl">
<span
className={
"block font-display text-lg font-medium leading-tight tracking-tight sm:text-xl " +
(locked ? "text-ink-soft" : "text-ink")
}
>
{section.label}
</span>
<span className="mt-1 flex items-center gap-3 text-xs text-ink-mute">
@@ -109,6 +135,12 @@ export function StageSection({
Current stage
</span>
)}
{locked && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-rule bg-paper px-2 py-0.5 font-medium text-ink-mute uppercase tracking-[0.08em]">
<LockGlyph />
Upcoming
</span>
)}
<span className="tabular-nums">
{fieldCount} {fieldCount === 1 ? "field" : "fields"}
</span>
@@ -123,9 +155,17 @@ export function StageSection({
role="region"
aria-labelledby={headingId}
hidden={!open}
className="border-t border-rule-soft"
className={"border-t " + (locked ? "border-rule" : "border-rule-soft")}
>
<div className="px-5 py-6 sm:px-7 sm:py-7">
<fieldset
disabled={locked}
aria-disabled={locked || undefined}
className={
"px-5 py-6 sm:px-7 sm:py-7 " +
(locked ? "opacity-80" : "")
}
>
{locked && <LockedBanner />}
{section.intro && (
<p className="mb-6 max-w-prose text-[15px] leading-relaxed text-ink-soft">
{section.intro}
@@ -153,6 +193,7 @@ export function StageSection({
<FieldRenderer
field={f}
register={register}
control={control}
errors={errors}
readonlyValue={formValues[f.name]}
resolvedOptions={resolvedOptions}
@@ -162,7 +203,7 @@ export function StageSection({
})}
</div>
)}
</div>
</fieldset>
</div>
</section>
);
@@ -173,20 +214,35 @@ export function StageSection({
* icon offset behind it. When current, the ring is leaf-green and the
* number is white-on-green; otherwise it's quiet.
*/
function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
function StageRankMark({
rank,
isCurrent,
locked,
}: {
rank: number;
isCurrent: boolean;
locked: boolean;
}) {
const iconClass = isCurrent
? "text-leaf-700 opacity-100"
: locked
? "text-ink-mute opacity-30"
: "text-leaf-600 opacity-50";
const badgeClass = isCurrent
? "bg-leaf-700 text-paper"
: locked
? "bg-paper text-ink-mute border border-dashed border-rule"
: "bg-paper text-ink-soft border border-rule";
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")
}
className={"absolute inset-0 h-12 w-12 transition-opacity " + iconClass}
/>
<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")
badgeClass
}
style={{ marginLeft: 28, marginTop: 28 }}
>
@@ -196,6 +252,42 @@ function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }
);
}
function LockGlyph() {
return (
<svg
aria-hidden
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3"
>
<rect x="2.5" y="5.5" width="7" height="5" rx="0.75" />
<path d="M4 5.5V4a2 2 0 0 1 4 0v1.5" />
</svg>
);
}
function LockedBanner() {
return (
<div
role="note"
className="mb-5 flex items-start gap-3 rounded-md border border-dashed border-rule bg-paper px-4 py-3"
>
<span aria-hidden className="mt-0.5 text-ink-mute">
<LockGlyph />
</span>
<p className="text-xs leading-relaxed text-ink-soft">
<span className="font-medium text-ink-soft">A look ahead.</span>{" "}
These fields will become editable when your co-op reaches this stage. They&apos;re visible
now so you can preview the framework you&apos;ll be working through.
</p>
</div>
);
}
function Chevron({ open }: { open: boolean }) {
return (
<svg

View File

@@ -1,12 +1,20 @@
"use client";
import { useWatch } from "react-hook-form";
import type { FieldConfig, SelectOption } from "@/types/form";
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form";
import type {
UseFormRegister,
FieldValues,
FieldErrors,
Control,
} from "react-hook-form";
interface FieldRendererProps {
field: FieldConfig;
register: UseFormRegister<FieldValues>;
errors: FieldErrors;
/** Form control — required for currency live-preview formatting. */
control: Control<FieldValues>;
/** For readonly display fields, the value to render. */
readonlyValue?: unknown;
/**
@@ -17,6 +25,9 @@ interface FieldRendererProps {
resolvedOptions?: SelectOption[];
}
const DATE_MIN_DEFAULT = "1900-01-01";
const DATE_MAX_DEFAULT = "2100-12-31";
/**
* Renders a single field appropriate to its `type`. All inputs share a common
* accessibility scaffold: a real <label htmlFor>, aria-describedby pointing
@@ -34,6 +45,7 @@ export function FieldRenderer({
field,
register,
errors,
control,
readonlyValue,
resolvedOptions,
}: FieldRendererProps) {
@@ -263,17 +275,43 @@ export function FieldRenderer({
</span>
)}
</div>
{field.type === "currency" && (
<CurrencyPreview control={control} name={field.name} />
)}
{field.help && <Help id={helpId!}>{field.help}</Help>}
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</div>
);
}
// ── Default: text-like (text / email / phone / date) ────────────────────
// ── Date ────────────────────────────────────────────────────────────────
if (field.type === "date") {
const dateMin = typeof field.min === "string" ? field.min : DATE_MIN_DEFAULT;
const dateMax = typeof field.max === "string" ? field.max : DATE_MAX_DEFAULT;
return (
<div className="space-y-1">
<Label id={id} field={field} />
<input
id={id}
type="date"
aria-describedby={describedBy}
aria-invalid={errorMsg ? true : undefined}
aria-required={field.required || undefined}
min={dateMin}
max={dateMax}
{...register(field.name, { required: requiredOpt })}
className={baseInputClass}
/>
{field.help && <Help id={helpId!}>{field.help}</Help>}
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</div>
);
}
// ── Default: text-like (text / email / phone) ───────────────────────────
const inputType =
field.type === "email" ? "email" :
field.type === "phone" ? "tel" :
field.type === "date" ? "date" :
"text";
return (
@@ -301,6 +339,30 @@ export function FieldRenderer({
);
}
const currencyFmt = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
});
function CurrencyPreview({
control,
name,
}: {
control: Control<FieldValues>;
name: string;
}) {
const raw = useWatch({ control, name });
if (raw === undefined || raw === null || raw === "") return null;
const n = typeof raw === "number" ? raw : Number(raw);
if (!Number.isFinite(n)) return null;
return (
<p className="text-xs tabular-nums text-ink-mute" aria-live="polite">
{currencyFmt.format(n)}
</p>
);
}
function Label({ id, field }: { id: string; field: FieldConfig }) {
return (
<label htmlFor={id} className="block text-sm font-medium text-ink">

View File

@@ -34,6 +34,13 @@ const visibleAtOrAfter = (...stages: string[]): VisibilityRule => ({
values: stages,
});
/**
* Option group ID for the `Stage` option group on `client.crm.fci.coop`.
* Used to resolve the current stage value (e.g. "Organizing") to its Civi
* label (e.g. "Stage 1 — Convene & Prepare") for display.
*/
export const STAGE_OPTION_GROUP_ID = 75;
// CiviCRM custom group machine names per the APIv4 inventory.
const G0 = "Check_in_data__organizing_";
const G1 = "Stage_1";
@@ -50,20 +57,17 @@ const stage0: StageSectionConfig = {
intro:
"Core check-in fields. These are visible at every stage and capture the data we follow over time across the lifecycle of the co-op.",
fields: [
// current_stage is a special UI-only field — it shows the org's stage.
// It is NOT written back to the activity by the submit endpoint.
// current_stage is a UI-only readonly field. Its value is the Civi
// option *value* (e.g. "Organizing") sourced by /api/data from the
// most recent stage-bearing Check-in (organizing) activity. Setting
// `optionGroupId` makes the readonly renderer resolve the value to
// its Civi option *label* (e.g. "Stage 1 — Convene & Prepare") for
// display. The form never writes Stage back to the activity.
{
name: "current_stage",
label: "Framework Stage",
label: "Current stage",
type: "readonly",
civiField: "Food_Co_op_Organizing.Stage",
},
{
name: "stage_at_submission",
label: "This check-in's stage",
type: "readonly",
civiField: `${G0}.Stage`,
help: "Set automatically to the org's current Framework Stage when you submit.",
optionGroupId: STAGE_OPTION_GROUP_ID,
},
{
name: "Peer_Group_Participation",
@@ -72,6 +76,7 @@ const stage0: StageSectionConfig = {
civiField: `${G0}.Peer_Group_Participation`,
optionGroupId: 140,
help: "Is this co-op currently participating in Peer Learning Groups?",
visibleWhen: []
},
{
name: "Internal_Startup_Assessment",
@@ -79,6 +84,7 @@ const stage0: StageSectionConfig = {
type: "select",
civiField: `${G0}.Internal_Startup_Assessment`,
optionGroupId: 132,
visibleWhen: []
},
{
name: "Internal_Startup_Assessment_Date",
@@ -86,6 +92,7 @@ const stage0: StageSectionConfig = {
type: "date",
civiField: `${G0}.Internal_Startup_Assessment_Date`,
help: "Date of the latest Internal Startup Assessment rating.",
visibleWhen: []
},
{
name: "Member_Goal_for_current_Stage",
@@ -106,29 +113,31 @@ const stage0: StageSectionConfig = {
label: "Total members at opening",
type: "number",
civiField: `${G0}.Total_members_at_opening`,
visibleWhen: visibleAtOrAfter(S.Stabilize),
},
{
name: "Volunteers_Helping_In_Store",
label: "Volunteers Helping In Store",
type: "select",
civiField: `${G0}.Volunteers_Helping_In_Store`,
optionGroupId: 141,
help: "Does this co-op use volunteers for day-to-day operations? (Not occasional events.)",
},
{
name: "Work_from_Members",
label: "Work from Members",
type: "select",
civiField: `${G0}.Work_from_Members`,
optionGroupId: 142,
help: "Does this co-op require work from members (e.g. annual hours)?",
},
// {
// name: "Volunteers_Helping_In_Store",
// label: "Volunteers Helping In Store",
// type: "select",
// civiField: `${G0}.Volunteers_Helping_In_Store`,
// optionGroupId: 141,
// help: "Does this co-op use volunteers for day-to-day operations? (Not occasional events.)",
// },
// {
// name: "Work_from_Members",
// label: "Work from Members",
// type: "select",
// civiField: `${G0}.Work_from_Members`,
// optionGroupId: 142,
// help: "Does this co-op require work from members (e.g. annual hours)?",
// },
{
name: "Total_square_ft",
label: "Total square ft",
type: "number",
civiField: `${G0}.Total_square_ft`,
step: 1,
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
},
{
name: "Retail_sq_ft",
@@ -136,32 +145,34 @@ const stage0: StageSectionConfig = {
type: "number",
civiField: `${G0}.Retail_sq_ft`,
step: 1,
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
},
{
name: "Latest_Sources_and_Uses_doc",
label: "Latest Sources and Uses doc",
type: "file",
civiField: `${G0}.Latest_Sources_and_Uses_doc`,
},
{
name: "Latest_Pro_Forma_doc",
label: "Latest Pro Forma doc",
type: "file",
civiField: `${G0}.Latest_Pro_Forma_doc`,
},
{ name: "Projected_Year_1_Sales", label: "Projected Year 1 Sales", type: "currency", civiField: `${G0}.Projected_Year_1_Sales` },
{ name: "Projected_Year_2_Sales", label: "Projected Year 2 Sales", type: "currency", civiField: `${G0}.Projected_Year_2_Sales` },
{ name: "Projected_Year_3_Sales", label: "Projected Year 3 Sales", type: "currency", civiField: `${G0}.Projected_Year_3_Sales` },
{
name: "Projected_sales_at_maturity",
label: "Projected sales once established",
type: "currency",
civiField: `${G0}.Projected_sales_at_maturity`,
},
// {
// name: "Latest_Sources_and_Uses_doc",
// label: "Latest Sources and Uses doc",
// type: "file",
// civiField: `${G0}.Latest_Sources_and_Uses_doc`,
// },
// {
// name: "Latest_Pro_Forma_doc",
// label: "Latest Pro Forma doc",
// type: "file",
// civiField: `${G0}.Latest_Pro_Forma_doc`,
// },
// { name: "Projected_Year_1_Sales", label: "Projected Year 1 Sales", type: "currency", civiField: `${G0}.Projected_Year_1_Sales` },
// { name: "Projected_Year_2_Sales", label: "Projected Year 2 Sales", type: "currency", civiField: `${G0}.Projected_Year_2_Sales` },
// { name: "Projected_Year_3_Sales", label: "Projected Year 3 Sales", type: "currency", civiField: `${G0}.Projected_Year_3_Sales` },
// {
// name: "Projected_sales_at_maturity",
// label: "Projected sales once established",
// type: "currency",
// civiField: `${G0}.Projected_sales_at_maturity`,
// },
{
name: "FTEs",
label: "Projected FTEs",
type: "number",
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
civiField: `${G0}.FTEs`,
step: 0.1,
help: "Number of full-time equivalent (FTE) employees planned to work at the store.",
@@ -172,35 +183,60 @@ const stage0: StageSectionConfig = {
type: "number",
civiField: `${G0}.Actual_FTE`,
step: 0.1,
visibleWhen: visibleAtOrAfter(S.Stabilize),
},
{
name: "Total_cost_of_project",
label: "Total cost of project",
type: "currency",
visibleWhen: visibleAtOrAfter(S.Stabilize),
civiField: `${G0}.Total_cost_of_project`,
},
{ name: "Member_equity_total", label: "Member equity total needed", type: "currency", civiField: `${G0}.Member_equity_total` },
{ name: "Member_equity_raised", label: "Member equity raised", type: "currency", civiField: `${G0}.Member_equity_raised` },
{ name: "Member_loans_total", label: "Member loans total needed", type: "currency", civiField: `${G0}.Member_loans_total` },
{ name: "Member_loans_raised", label: "Member loans raised", type: "currency", civiField: `${G0}.Member_loans_raised` },
{
name: "Member_preferred_shares_total",
label: "Member preferred shares total needed",
type: "currency",
civiField: `${G0}.Member_preferred_shares_total`,
},
{
name: "Member_equity_total",
label: "Member equity total needed",
type: "currency",
civiField: `${G0}.Member_equity_total`
},
{
name: "Member_equity_raised",
label: "Member equity raised",
type: "currency",
visibleWhen: visibleAtOrAfter(S.Stabilize),
civiField: `${G0}.Member_equity_raised`
},
{
name: "Member_loans_total",
label: "Member loans total needed",
type: "currency",
civiField: `${G0}.Member_loans_total`
},
{
name: "Member_loans_raised",
label: "Member loans raised",
type: "currency",
visibleWhen: visibleAtOrAfter(S.Stabilize),
civiField: `${G0}.Member_loans_raised`
},
// {
// name: "Member_preferred_shares_total",
// label: "Member preferred shares total needed",
// type: "currency",
// civiField: `${G0}.Member_preferred_shares_total`,
// },
{
name: "Member_preferred_shares_raised",
label: "Member preferred shares raised",
type: "currency",
visibleWhen: visibleAtOrAfter(S.Stabilize),
civiField: `${G0}.Member_preferred_shares_raised`,
},
{ name: "Bank_debt_total", label: "Bank debt total needed", type: "currency", civiField: `${G0}.Bank_debt_total` },
{ name: "Bank_debt_raised", label: "Bank debt raised", type: "currency", civiField: `${G0}.Bank_debt_raised` },
{ name: "Bank_debt_raised", label: "Bank debt raised", type: "currency", visibleWhen: visibleAtOrAfter(S.Stabilize), civiField: `${G0}.Bank_debt_raised` },
{ name: "Grant_Donations_Needed", label: "Grants / Donations Needed", type: "currency", civiField: `${G0}.Grant_Donations_Needed` },
{ name: "Grants_Donations_Raised", label: "Grants / Donations Raised", type: "currency", civiField: `${G0}.Grants_Donations_Raised` },
{ name: "Grants_Donations_Raised", label: "Grants / Donations Raised", type: "currency", visibleWhen: visibleAtOrAfter(S.Stabilize), civiField: `${G0}.Grants_Donations_Raised` },
{ name: "Other_sources_total", label: "Other sources total needed", type: "currency", civiField: `${G0}.Other_sources_total` },
{ name: "Other_sources_raised", label: "Other sources raised", type: "currency", civiField: `${G0}.Other_sources_raised` },
{ name: "Other_sources_raised", label: "Other sources raised", type: "currency", visibleWhen: visibleAtOrAfter(S.Stabilize), civiField: `${G0}.Other_sources_raised` },
{ name: "Date_Closed_Folded", label: "Date Closed / Folded", type: "date", civiField: `${G0}.Date_Closed_Folded` },
] as FieldConfig[],
};
@@ -209,13 +245,14 @@ const stage0: StageSectionConfig = {
const stage1: StageSectionConfig = {
rank: 1,
id: "stage_1",
label: "Stage 1 — Convene and Prepare",
label: "Stage 1 — Convene & Prepare",
visibleWhen: visibleAtOrAfter(S.Organizing, S.Feasibility, S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
fields: [
{
name: "Preliminary_Market_Assessment",
label: "Preliminary Market Assessment",
type: "date",
required: true,
civiField: `${G1}.Preliminary_Market_Assessment`,
help: "What date was your Preliminary Market Assessment completed?",
},
@@ -266,7 +303,7 @@ const stage1: StageSectionConfig = {
const stage2: StageSectionConfig = {
rank: 2,
id: "stage_2",
label: "Stage 2 — Feasibility",
label: "Stage 2 — Grow & Plan",
visibleWhen: visibleAtOrAfter(S.Feasibility, S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
fields: [
{ name: "Market_Study_Date", label: "Market Study Date", type: "date", civiField: `${G2}.Market_Study_Date` },
@@ -316,7 +353,7 @@ const stage2: StageSectionConfig = {
const stage3: StageSectionConfig = {
rank: 3,
id: "stage_3",
label: "Stage 3 — Connect and Gather",
label: "Stage 3 — Connect & Gather",
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
fields: [
{
@@ -390,7 +427,7 @@ const stage3: StageSectionConfig = {
const stage4: StageSectionConfig = {
rank: 4,
id: "stage_4",
label: "Stage 4 — Excite and Build",
label: "Stage 4 — Excite & Build",
visibleWhen: visibleAtOrAfter(S.StoreImplementation, S.Stabilize),
fields: [
{ name: "Projected_Opening_Date", label: "Projected Opening Date", type: "date", civiField: `${G4}.Projected_Opening_Date` },
@@ -449,10 +486,8 @@ const stage4: StageSectionConfig = {
// Stage 5 — Fulfill & Stabilize. Field machine names have historical
// inconsistencies; we list each one explicitly rather than building
// programmatically to keep the mapping auditable.
/*
const STAGE_5_FIELDS: FieldConfig[] = [
{ name: "Date_Opened", label: "Date Opened", type: "date", civiField: `${G5}.Date_Opened` },
{ name: "Y1_Actual_Sales", label: "Y1 Actual Sales", type: "currency", civiField: `${G5}.Y1_Actual_Sales` },
// Y1 Monthly Sales Targets — note the irregular machine names!
{ name: "Y1_Monthly_Sales_Targets", label: "Y1 Monthly Sales Target: M1", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Targets` },
{ name: "Y1_Monthly_Sales_Targets_M2", label: "Y1 Monthly Sales Target: M2", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Targets_M2` },
@@ -532,57 +567,61 @@ const Y1_TARGET_FIELDS = [
"Y1_Monthly_Sales_Target_M11",
"Y1_Monthly_Sales_Target_M12",
];
*/
const stage5: StageSectionConfig = {
rank: 5,
id: "stage_5",
label: "Stage 5 — Fulfill and Stabilize",
label: "Stage 5 — Fulfill & Stabilize",
visibleWhen: visibleAtOrAfter(S.Stabilize),
fields: STAGE_5_FIELDS,
matrixGroups: [
{
id: "y1_monthly_metrics",
label: "Year 1 — Monthly Tracking",
intro: "Targets and actuals by month. Leave blank for months you don't yet have data for.",
cols: monthCols,
rows: [
{ label: "Sales Target", type: "currency", fields: Y1_TARGET_FIELDS },
{
label: "Actual Sales",
type: "currency",
fields: months.map((m) => `Y1_M${m}_Actual_Sales`),
},
{
label: "Transactions",
type: "number",
fields: months.map((m) => `Y1_M${m}_Transactions`),
},
],
},
{
id: "y1_quarterly_metrics",
label: "Year 1 — Quarterly Tracking",
intro: "Quarterly operating ratios.",
cols: quarterCols,
rows: [
{
label: "Labor",
type: "percent",
fields: quarters.map((q) => `Y1_Q${q}_Labor`),
},
{
label: "Margin",
type: "percent",
fields: quarters.map((q) => `Y1_Q${q}_Margin`),
},
{
label: "Member Sales",
type: "percent",
fields: quarters.map((q) => `Y1_Q${q}_Member_Sales_`),
},
],
},
],
fields: [
{ name: "Date_Opened", label: "Date Opened", type: "date", civiField: `${G5}.Date_Opened` },
{ name: "Y1_Actual_Sales", label: "Y1 Actual Sales", type: "currency", civiField: `${G5}.Y1_Actual_Sales` }
] as FieldConfig[],
// STAGE_5_FIELDS,
// matrixGroups: [
// {
// id: "y1_monthly_metrics",
// label: "Year 1 — Monthly Tracking",
// intro: "Targets and actuals by month. Leave blank for months you don't yet have data for.",
// cols: monthCols,
// rows: [
// { label: "Sales Target", type: "currency", fields: Y1_TARGET_FIELDS },
// {
// label: "Actual Sales",
// type: "currency",
// fields: months.map((m) => `Y1_M${m}_Actual_Sales`),
// },
// {
// label: "Transactions",
// type: "number",
// fields: months.map((m) => `Y1_M${m}_Transactions`),
// },
// ],
// },
// {
// id: "y1_quarterly_metrics",
// label: "Year 1 — Quarterly Tracking",
// intro: "Quarterly operating ratios.",
// cols: quarterCols,
// rows: [
// {
// label: "Labor",
// type: "percent",
// fields: quarters.map((q) => `Y1_Q${q}_Labor`),
// },
// {
// label: "Margin",
// type: "percent",
// fields: quarters.map((q) => `Y1_Q${q}_Margin`),
// },
// {
// label: "Member Sales",
// type: "percent",
// fields: quarters.map((q) => `Y1_Q${q}_Member_Sales_`),
// },
// ],
// },
// ],
};
export const formConfig: FormConfig = {
@@ -600,7 +639,12 @@ export const allFields = formConfig.sections.flatMap((s) => s.fields);
/** Activity type the form writes its submissions as. */
export const ACTIVITY_TYPE_NAME = "Check-in (organizing)";
/** Stage-snapshot field on the activity (so the form's stage_at_submission writes here). */
/**
* Stage-snapshot field on the activity. The form does NOT write this field;
* it's set manually by staff on dedicated stage-change check-in activities.
* /api/data uses this field to derive the org's current stage by finding
* the most recent activity where it is non-empty.
*/
export const ACTIVITY_STAGE_FIELD = `${G0}.Stage`;
/**

119
package-lock.json generated
View File

@@ -8,7 +8,6 @@
"name": "next-app",
"version": "0.1.0",
"dependencies": {
"axios": "^1.16.0",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
@@ -2405,12 +2404,6 @@
"node": ">= 0.4"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -2437,17 +2430,6 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -2558,6 +2540,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -2657,18 +2640,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2827,15 +2798,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2863,6 +2825,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -2974,6 +2937,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2983,6 +2947,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3020,6 +2985,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -3032,6 +2998,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3628,26 +3595,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -3664,26 +3611,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -3744,6 +3676,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -3768,6 +3701,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -3855,6 +3789,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3926,6 +3861,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3938,6 +3874,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -3953,6 +3890,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -4935,6 +4873,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4964,27 +4903,6 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -5474,15 +5392,6 @@
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@@ -9,7 +9,6 @@
"lint": "eslint"
},
"dependencies": {
"axios": "^1.16.0",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",

BIN
public/fci-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View File

@@ -69,9 +69,13 @@ export interface FieldConfig {
placeholder?: string;
/** For select / multiselect. */
options?: SelectOption[];
/** For number / currency / percent — min/max/step constraints. */
min?: number;
max?: number;
/**
* Min/max bound. For numeric fields, a number; for date fields, an ISO
* date string (YYYY-MM-DD). Date fields default to a wide sanity window
* (1900-01-01 to 2100-12-31) when not set.
*/
min?: number | string;
max?: number | string;
step?: number;
/** For text / textarea — max length. */
maxLength?: number;
@@ -193,3 +197,49 @@ export interface SubmitPayload {
cs: string;
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[]>;
}