Compare commits
10 Commits
0d84b9654b
...
dbd607b829
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbd607b829 | ||
|
|
899dad2323 | ||
|
|
ba88eb0165 | ||
|
|
04e69ca04c | ||
|
|
74a85d38fe | ||
|
|
b4e80517a7 | ||
|
|
18b7a67fa1 | ||
|
|
762605f04b | ||
|
|
9103ccaf9d | ||
|
|
a804650f65 |
266
README.md
266
README.md
@@ -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
|
A tokenized, mobile-friendly web form that lets external co-op contacts
|
||||||
organization's tracking data on CiviCRM, sidestepping the limitations of
|
update their organization's tracking data on CiviCRM, plus a read-only
|
||||||
Webform CiviCRM's admin UI.
|
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
|
## How it works
|
||||||
`../docs/superpowers/specs/2026-05-08-civi-webform-design.md`.
|
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
```
|
When the link is opened, the app verifies the checksum against CiviCRM,
|
||||||
https://check-in.fci.coop/?cid=<contactId>&cs=<checksum>
|
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.
|
**Stage authority.** The current stage is derived from the most recent
|
||||||
2. Resolves the org from the contact's **Primary Contact** relationship (Individual → Organization). Configurable via `FORM_CONTACT_RELATIONSHIP` in `config/form.ts`.
|
"Check-in (organizing)" activity whose Stage field is set. Staff own
|
||||||
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`.
|
stage transitions by manually setting Stage on a check-in activity they
|
||||||
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).
|
create; the form itself never writes Stage, so org self-submissions
|
||||||
5. Renders the form with stage-conditional sections — Stage 0 (Inquiry / core check-in fields, ~32 of them) is always visible; Stages 1–5 each appear when `current_stage` is in their visibility set.
|
can't override a staff transition. Orgs with no stage-bearing activity
|
||||||
6. On submit, creates a **new immutable** `Org Engagement Submission` activity with the visible-field values plus a `stage_at_submission` audit field.
|
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
|
## Visual & UX details
|
||||||
- Tailwind CSS 4 (CSS-based `@theme` config)
|
|
||||||
- `react-hook-form` for state + validation
|
|
||||||
- `axios` (only used in earlier scaffolding; this version uses native `fetch`)
|
|
||||||
|
|
||||||
## Project structure
|
- **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
|
||||||
app/
|
CSS gradients.
|
||||||
layout.tsx Root layout, fonts (Inter + Source Serif 4)
|
- **Journey rail** runs down the left side on desktop, with a marker
|
||||||
page.tsx Public form entry; reads cid+cs from URL
|
per stage and a "Now" pill at the current stage. Past stages are
|
||||||
globals.css Tailwind 4 + theme tokens (leaf + stone palettes)
|
filled with a checkmark, future stages are dashed and locked. A small
|
||||||
api/
|
inter-card stem stands in for the rail on mobile.
|
||||||
data/route.ts GET form data + per-field-most-recent prefill
|
- **Draft auto-save** to `localStorage` (30-day TTL) so partial answers
|
||||||
submit/route.ts POST submission → creates new Civi activity
|
survive page refreshes; a "Draft restored" banner appears on return.
|
||||||
|
- **Successful submit** lands on a destination screen instead of a
|
||||||
components/
|
reloaded form, preventing accidental duplicate submits from
|
||||||
EngagementForm.tsx Top-level orchestrator
|
back-button or autofill replay.
|
||||||
StageSection.tsx Collapsible accordion card per stage section
|
- **Currency live preview**, date min/max bounds (1900–2100), and
|
||||||
SiteChrome.tsx Header + footer chrome (logo placeholder included)
|
thousands-separator formatting on numeric fields.
|
||||||
fields/
|
- **Hand-drawn stage icons** for each of the six stages; FCI brand logo
|
||||||
FieldRenderer.tsx One renderer for all 12 field types
|
in the header.
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
- All inputs have proper `<label htmlFor>` associations.
|
- Real `<label htmlFor>` on every control; help text and inline errors
|
||||||
- Help text and error messages are linked via `aria-describedby`.
|
linked via `aria-describedby`.
|
||||||
- Errors set `aria-invalid` and use `role="alert"` for assistive tech.
|
- Required fields use `aria-required`, a visible `*` with
|
||||||
- Required fields use `aria-required` plus a visible `*` (with
|
`aria-label="required"`, and typed error messages.
|
||||||
`aria-label="required"`).
|
- Failed-validation submit auto-expands the section containing the
|
||||||
- Sections use semantic `<section>` + `<h2>` + `aria-controls` accordion pattern.
|
first error, scrolls it into view, and focuses the field.
|
||||||
- Keyboard-only flow works end-to-end.
|
- Accordion sections follow the disclosure pattern: `<button>` headers
|
||||||
- Color contrast meets WCAG AA against the leaf + stone palette (verified
|
with `aria-expanded` + `aria-controls`, `role="region"` panels.
|
||||||
visually; an audit pass is recommended before launch).
|
- All animations honour `prefers-reduced-motion`.
|
||||||
- A skip-to-content link is present for keyboard users.
|
- 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
|
The app runs in **stub mode** when any of the CiviCRM variables below
|
||||||
(`lib/conditional.ts`) supports:
|
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`
|
| Variable | Required? | Purpose |
|
||||||
- Composition via `{all: [...]}` (AND) and `{any: [...]}` (OR)
|
|---|---|---|
|
||||||
|
| `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'
|
Copy `.env.example` to `.env.local` for local development.
|
||||||
values are stripped from the submitted payload, so users never accidentally
|
|
||||||
write data they couldn't see.
|
|
||||||
|
|
||||||
## 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
|
npm install
|
||||||
follow-on project.
|
npm run dev
|
||||||
- **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.
|
Open `http://localhost:3000/?cid=1&cs=anything` — any non-empty `cs`
|
||||||
- **CSRF protection on the submit endpoint.** Checksum gating is the
|
works while in stub mode. The report is at `/report?cid=1&cs=anything`.
|
||||||
current trust mechanism. Add CSRF tokens if you put this behind a
|
|
||||||
long-lived session.
|
## Deploy
|
||||||
- **i18n.** Copy is English-only.
|
|
||||||
|
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 M1–M12 / Q1–Q4 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.
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
* GET /api/data?cid=<cid>&cs=<cs>
|
* GET /api/data?cid=<cid>&cs=<cs>
|
||||||
*
|
*
|
||||||
* Verifies the checksum, resolves the org from the contact via the
|
* Verifies the checksum, resolves the org from the contact via the
|
||||||
* Primary Contact relationship, reads the org's Framework Stage
|
* Primary Contact relationship, derives the org's current Framework Stage
|
||||||
* (`Food_Co_op_Organizing.Stage` on the Organization contact), and walks
|
* from the most recent `Check-in (organizing)` activity whose Stage custom
|
||||||
* past `Check-in (organizing)` activities for per-field most-recent prefill.
|
* 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
|
* Also fetches the OptionValue rows for any option_group_ids referenced by
|
||||||
* the form (so radio/select/multiselect fields render with real CRM-defined
|
* the form (so radio/select/multiselect fields render with real CRM-defined
|
||||||
@@ -19,9 +21,21 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { civi, verifyChecksum } from "@/lib/civicrm";
|
import { civi, verifyChecksum } from "@/lib/civicrm";
|
||||||
import { loadPrefill } from "@/lib/prefill";
|
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";
|
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 = {
|
const STUB_PAYLOAD: FormDataPayload = {
|
||||||
orgName: "Sample Co-op (stub)",
|
orgName: "Sample Co-op (stub)",
|
||||||
currentStage: "Organizing",
|
currentStage: "Organizing",
|
||||||
@@ -144,31 +158,41 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
const orgId = orgs[0].contact_id_b;
|
const orgId = orgs[0].contact_id_b;
|
||||||
|
|
||||||
// Fetch org name + Framework Stage (org-side, on the organization contact).
|
// Fire org-name lookup, stage-bearing-activity lookup, prefill walk, and
|
||||||
const orgRes = await civi<{
|
// option-group fetch in parallel — they're independent.
|
||||||
id: number;
|
const [orgRes, stageActivityRes, { values: prefill }, options] = await Promise.all([
|
||||||
display_name: string;
|
civi<{ id: number; display_name: string }>("Contact", "get", {
|
||||||
"Food_Co_op_Organizing.Stage": string | null;
|
select: ["id", "display_name"],
|
||||||
}>("Contact", "get", {
|
where: [["id", "=", orgId]],
|
||||||
select: ["id", "display_name", "Food_Co_op_Organizing.Stage"],
|
}),
|
||||||
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
|
||||||
const org = orgRes.values?.[0];
|
// equal activity_date_time is id DESC.
|
||||||
if (!org) {
|
civi<{ id: number; [key: string]: unknown }>("Activity", "get", {
|
||||||
return NextResponse.json({ error: "Organization not found." }, { status: 404 });
|
select: ["id", ACTIVITY_STAGE_FIELD],
|
||||||
}
|
where: [
|
||||||
const currentStage = org["Food_Co_op_Organizing.Stage"] ?? "";
|
["activity_type_id:name", "=", ACTIVITY_TYPE_NAME],
|
||||||
|
["target_contact_id", "=", orgId],
|
||||||
// Per-field-most-recent prefill across past Check-in activities, plus
|
[ACTIVITY_STAGE_FIELD, "IS NOT EMPTY"],
|
||||||
// option-group fetch — kicked off in parallel.
|
],
|
||||||
const [{ values: prefill }, options] = await Promise.all([
|
orderBy: { activity_date_time: "DESC", id: "DESC" },
|
||||||
|
limit: 1,
|
||||||
|
}),
|
||||||
loadPrefill(orgId, allFields, ACTIVITY_TYPE_NAME),
|
loadPrefill(orgId, allFields, ACTIVITY_TYPE_NAME),
|
||||||
fetchOptionGroups(),
|
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 = {
|
const payload: FormDataPayload = {
|
||||||
orgName: org.display_name,
|
orgName: org.display_name,
|
||||||
currentStage: typeof currentStage === "string" ? currentStage : "",
|
currentStage,
|
||||||
prefill,
|
prefill,
|
||||||
options,
|
options,
|
||||||
};
|
};
|
||||||
|
|||||||
247
app/api/report/route.ts
Normal file
247
app/api/report/route.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/report?cid=<cid>&cs=<cs>
|
||||||
|
*
|
||||||
|
* Verifies the checksum, resolves the org from the contact via the
|
||||||
|
* Primary Contact relationship, then walks every Check-in (organizing)
|
||||||
|
* activity for the org and assembles a per-field history of non-empty
|
||||||
|
* values plus a summary list of the activities themselves.
|
||||||
|
*
|
||||||
|
* Returns ReportPayload (see types/form.ts).
|
||||||
|
*
|
||||||
|
* STUB MODE: if CiviCRM env vars are unset, returns synthesized history
|
||||||
|
* shaped from the same stub used by /api/data so the report UI is
|
||||||
|
* exercisable without a live CRM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { civi, verifyChecksum } from "@/lib/civicrm";
|
||||||
|
import {
|
||||||
|
allFields,
|
||||||
|
optionGroupIds,
|
||||||
|
ACTIVITY_TYPE_NAME,
|
||||||
|
ACTIVITY_STAGE_FIELD,
|
||||||
|
FORM_CONTACT_RELATIONSHIP,
|
||||||
|
} from "@/config/form";
|
||||||
|
import type {
|
||||||
|
ReportPayload,
|
||||||
|
SelectOption,
|
||||||
|
FieldHistoryEntry,
|
||||||
|
ActivitySummary,
|
||||||
|
} from "@/types/form";
|
||||||
|
|
||||||
|
function isStubMode(): boolean {
|
||||||
|
return !(
|
||||||
|
process.env.CIVI_BASE_URL &&
|
||||||
|
process.env.CIVI_API_KEY &&
|
||||||
|
process.env.CIVI_SITE_KEY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STUB_PAYLOAD: ReportPayload = (() => {
|
||||||
|
const today = new Date();
|
||||||
|
const daysAgo = (n: number) =>
|
||||||
|
new Date(today.getTime() - n * 24 * 3600 * 1000).toISOString();
|
||||||
|
return {
|
||||||
|
orgName: "Sample Co-op (stub)",
|
||||||
|
currentStage: "Organizing",
|
||||||
|
activities: [
|
||||||
|
{ id: 9012, date: daysAgo(3), subject: "Co-op Check-in (form submission)" },
|
||||||
|
{ id: 9008, date: daysAgo(34), subject: "Co-op Check-in (form submission)" },
|
||||||
|
{ id: 9001, date: daysAgo(62), stage: "Organizing", subject: "Stage transition (staff)" },
|
||||||
|
{ id: 8995, date: daysAgo(95), subject: "Co-op Check-in (form submission)" },
|
||||||
|
{ id: 8980, date: daysAgo(180), stage: "Inquiry", subject: "Initial check-in (staff)" },
|
||||||
|
],
|
||||||
|
fieldHistory: {
|
||||||
|
Peer_Group_Participation: [
|
||||||
|
{ activityId: 9012, date: daysAgo(3), value: "Yes" },
|
||||||
|
{ activityId: 9008, date: daysAgo(34), value: "Considering" },
|
||||||
|
{ activityId: 8995, date: daysAgo(95), value: "No" },
|
||||||
|
],
|
||||||
|
Members__current_: [
|
||||||
|
{ activityId: 9012, date: daysAgo(3), value: 124 },
|
||||||
|
{ activityId: 9008, date: daysAgo(34), value: 109 },
|
||||||
|
{ activityId: 8995, date: daysAgo(95), value: 87 },
|
||||||
|
],
|
||||||
|
Member_Goal_for_current_Stage: [
|
||||||
|
{ activityId: 9008, date: daysAgo(34), value: 200 },
|
||||||
|
],
|
||||||
|
Internal_Startup_Assessment: [
|
||||||
|
{ activityId: 9012, date: daysAgo(3), value: "Strong" },
|
||||||
|
{ activityId: 8995, date: daysAgo(95), value: "Moderate" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
140: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }, { value: "Considering", label: "Considering" }],
|
||||||
|
132: [{ value: "Strong", label: "Strong" }, { value: "Moderate", label: "Moderate" }, { value: "Needs Work", label: "Needs work" }],
|
||||||
|
75: [
|
||||||
|
{ value: "Inquiry", label: "Inquiry" },
|
||||||
|
{ value: "Organizing", label: "Stage 1 — Convene & Prepare" },
|
||||||
|
{ value: "Feasibility", label: "Stage 2 — Grow & Plan" },
|
||||||
|
{ value: "Business feasibility", label: "Stage 3 — Connect & Gather" },
|
||||||
|
{ value: "Store Implementation", label: "Stage 4 — Excite & Build" },
|
||||||
|
{ value: "Stabilize newly opened co-op", label: "Stage 5 — Fulfill & Stabilize" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
interface OptionValueRow {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
option_group_id: number;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOptionGroups(): Promise<Record<number, SelectOption[]>> {
|
||||||
|
if (optionGroupIds.length === 0) return {};
|
||||||
|
const res = await civi<OptionValueRow>("OptionValue", "get", {
|
||||||
|
select: ["value", "label", "option_group_id", "is_active"],
|
||||||
|
where: [
|
||||||
|
["option_group_id", "IN", optionGroupIds],
|
||||||
|
["is_active", "=", true],
|
||||||
|
],
|
||||||
|
orderBy: { weight: "ASC" },
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
const out: Record<number, SelectOption[]> = {};
|
||||||
|
for (const row of res.values ?? []) {
|
||||||
|
if (!out[row.option_group_id]) out[row.option_group_id] = [];
|
||||||
|
out[row.option_group_id].push({ value: row.value, label: row.label });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const cid = url.searchParams.get("cid");
|
||||||
|
const cs = url.searchParams.get("cs");
|
||||||
|
|
||||||
|
if (!cid || !cs) {
|
||||||
|
return NextResponse.json({ error: "Missing cid or cs parameter." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStubMode()) {
|
||||||
|
return NextResponse.json(STUB_PAYLOAD);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await verifyChecksum(cid, cs);
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Link is invalid or has expired. Please request a fresh one." },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve org (mirror of /api/data).
|
||||||
|
const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", {
|
||||||
|
select: ["contact_id_b"],
|
||||||
|
where: [
|
||||||
|
["contact_id_a", "=", Number(cid)],
|
||||||
|
["relationship_type_id.name_a_b", "=", FORM_CONTACT_RELATIONSHIP],
|
||||||
|
["is_active", "=", true],
|
||||||
|
],
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
const orgs = relRes.values ?? [];
|
||||||
|
if (orgs.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `No active "${FORM_CONTACT_RELATIONSHIP}" relationship found for your contact.` },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (orgs.length > 1) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Your contact has multiple active "${FORM_CONTACT_RELATIONSHIP}" relationships; staff must resolve before this link will work.` },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const orgId = orgs[0].contact_id_b;
|
||||||
|
|
||||||
|
// Org name + activity walk + option groups, in parallel.
|
||||||
|
const civiFieldNames = Array.from(
|
||||||
|
new Set(allFields.map((f) => f.civiField).filter((f): f is string => !!f)),
|
||||||
|
);
|
||||||
|
const select = [
|
||||||
|
"id",
|
||||||
|
"activity_date_time",
|
||||||
|
"subject",
|
||||||
|
ACTIVITY_STAGE_FIELD,
|
||||||
|
...civiFieldNames,
|
||||||
|
];
|
||||||
|
|
||||||
|
const [orgRes, activityRes, options] = await Promise.all([
|
||||||
|
civi<{ id: number; display_name: string }>("Contact", "get", {
|
||||||
|
select: ["id", "display_name"],
|
||||||
|
where: [["id", "=", orgId]],
|
||||||
|
}),
|
||||||
|
civi<Record<string, unknown> & { id: number; activity_date_time: string }>(
|
||||||
|
"Activity",
|
||||||
|
"get",
|
||||||
|
{
|
||||||
|
select,
|
||||||
|
where: [
|
||||||
|
["activity_type_id:name", "=", ACTIVITY_TYPE_NAME],
|
||||||
|
["target_contact_id", "=", orgId],
|
||||||
|
],
|
||||||
|
orderBy: { activity_date_time: "DESC", id: "DESC" },
|
||||||
|
limit: 500,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
fetchOptionGroups(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const org = orgRes.values?.[0];
|
||||||
|
if (!org) {
|
||||||
|
return NextResponse.json({ error: "Organization not found." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = activityRes.values ?? [];
|
||||||
|
|
||||||
|
// Activity summaries — ordered DESC already.
|
||||||
|
const activities: ActivitySummary[] = rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
date: r.activity_date_time,
|
||||||
|
stage: (r[ACTIVITY_STAGE_FIELD] as string | null | undefined) ?? null,
|
||||||
|
subject: (r.subject as string | null | undefined) ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Derive current stage from most recent stage-bearing activity.
|
||||||
|
const stageRow = rows.find(
|
||||||
|
(r) => {
|
||||||
|
const v = r[ACTIVITY_STAGE_FIELD];
|
||||||
|
return typeof v === "string" && v.length > 0;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const currentStage =
|
||||||
|
(typeof stageRow?.[ACTIVITY_STAGE_FIELD] === "string"
|
||||||
|
? (stageRow[ACTIVITY_STAGE_FIELD] as string)
|
||||||
|
: "") || "Inquiry";
|
||||||
|
|
||||||
|
// Per-field history. For every form-side field that has a civiField,
|
||||||
|
// walk activities and collect non-empty values.
|
||||||
|
const fieldHistory: Record<string, FieldHistoryEntry[]> = {};
|
||||||
|
for (const f of allFields) {
|
||||||
|
if (!f.civiField) continue;
|
||||||
|
if (f.type === "readonly") continue; // readonly displays don't have history
|
||||||
|
const entries: FieldHistoryEntry[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const v = row[f.civiField];
|
||||||
|
if (v === null || v === undefined || v === "") continue;
|
||||||
|
entries.push({
|
||||||
|
activityId: row.id,
|
||||||
|
date: row.activity_date_time,
|
||||||
|
value: v,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (entries.length > 0) fieldHistory[f.name] = entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: ReportPayload = {
|
||||||
|
orgName: org.display_name,
|
||||||
|
currentStage,
|
||||||
|
activities,
|
||||||
|
fieldHistory,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
return NextResponse.json(payload);
|
||||||
|
}
|
||||||
@@ -5,16 +5,17 @@
|
|||||||
*
|
*
|
||||||
* Verifies checksum, resolves org from cid (mirror of /api/data), then
|
* Verifies checksum, resolves org from cid (mirror of /api/data), then
|
||||||
* creates a new `Check-in (organizing)` activity. The activity's own Stage
|
* creates a new `Check-in (organizing)` activity. The activity's own Stage
|
||||||
* field (`Check_in_data__organizing_.Stage`) is auto-populated from the
|
* field is intentionally left null: the form is not the authority on stage.
|
||||||
* org's current `Food_Co_op_Organizing.Stage` so each check-in carries a
|
* Staff set Stage on their own check-in activities to mark transitions, and
|
||||||
* snapshot of where the org was at submission time.
|
* /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.
|
* STUB MODE: if CiviCRM env vars are unset, returns success without writing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { civi, verifyChecksum } from "@/lib/civicrm";
|
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 type { SubmitPayload } from "@/types/form";
|
||||||
import { appEnv, redact } from "@/lib/env";
|
import { appEnv, redact } from "@/lib/env";
|
||||||
import { rateLimit, clientIp } from "@/lib/rate-limit";
|
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;
|
const orgId = orgs[0].contact_id_b;
|
||||||
|
|
||||||
// Read the org's current Framework Stage so we can snapshot it on the activity.
|
// Build the activity record. The Stage custom field is deliberately NOT
|
||||||
const orgRes = await civi<{ "Food_Co_op_Organizing.Stage": string | null }>(
|
// set here — staff own stage transitions on their own activities.
|
||||||
"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.
|
|
||||||
const activityRecord: Record<string, unknown> = {
|
const activityRecord: Record<string, unknown> = {
|
||||||
"activity_type_id:name": ACTIVITY_TYPE_NAME,
|
"activity_type_id:name": ACTIVITY_TYPE_NAME,
|
||||||
"status_id:name": "Completed",
|
"status_id:name": "Completed",
|
||||||
target_contact_id: orgId,
|
target_contact_id: orgId,
|
||||||
source_contact_id: Number(cid),
|
source_contact_id: Number(cid),
|
||||||
subject: "Co-op Check-in (form submission)",
|
subject: "Co-op Check-in (form submission)",
|
||||||
[ACTIVITY_STAGE_FIELD]: stageAtSubmission,
|
|
||||||
};
|
};
|
||||||
for (const [name, value] of Object.entries(values)) {
|
for (const [name, value] of Object.entries(values)) {
|
||||||
const field = FIELD_BY_NAME.get(name);
|
const field = FIELD_BY_NAME.get(name);
|
||||||
if (!field || !field.civiField) continue;
|
if (!field || !field.civiField) continue;
|
||||||
if (field.type === "readonly") continue; // never write read-only fields
|
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;
|
activityRecord[field.civiField] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
/* A small cohort of utility classes — rule lines, rough underlines, etc. */
|
||||||
.rule-soft {
|
.rule-soft {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
|
|||||||
77
app/report/page.tsx
Normal file
77
app/report/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { ReportView } from "@/components/ReportView";
|
||||||
|
import { SiteHeader, SiteFooter } from "@/components/SiteChrome";
|
||||||
|
import { formConfig } from "@/config/form";
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
searchParams: Promise<{ cid?: string; cs?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Activity report — Food Co-op Initiative",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ReportPage({ searchParams }: PageProps) {
|
||||||
|
const { cid, cs } = await searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href="#main"
|
||||||
|
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-paper focus:px-3 focus:py-2 focus:text-ink focus:shadow"
|
||||||
|
>
|
||||||
|
Skip to content
|
||||||
|
</a>
|
||||||
|
<SiteHeader />
|
||||||
|
<main id="main" className="flex-1">
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6 sm:py-14">
|
||||||
|
<ReportIntro />
|
||||||
|
{!cid || !cs ? (
|
||||||
|
<MissingLinkParams />
|
||||||
|
) : (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ReportView config={formConfig} cid={cid} cs={cs} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportIntro() {
|
||||||
|
return (
|
||||||
|
<header className="mb-10 max-w-2xl">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] text-leaf-700">
|
||||||
|
Activity report · Co-op organizing
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 font-display text-[40px] font-normal leading-[1.05] tracking-tight text-ink sm:text-[52px]">
|
||||||
|
What we know so far
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-prose text-[17px] leading-relaxed text-ink-soft">
|
||||||
|
A read-only summary of every value your co-op has shared through past
|
||||||
|
check-ins, grouped by stage. The most recent entry sits at the top of
|
||||||
|
each row; expand a row to see how a number or note has changed over
|
||||||
|
time.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 h-px bg-rule" />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MissingLinkParams() {
|
||||||
|
return (
|
||||||
|
<div role="alert" className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7">
|
||||||
|
<h2 className="font-display text-xl font-medium text-clay-700">
|
||||||
|
This link is missing required information.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 leading-relaxed text-ink-soft">
|
||||||
|
Open the report using the personalized link from your email. If you no
|
||||||
|
longer have it, please contact your engagement coordinator and ask for
|
||||||
|
a fresh link.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ function sectionForField(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
import { evaluate } from "@/lib/conditional";
|
import { evaluate } from "@/lib/conditional";
|
||||||
|
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
|
||||||
import { StageSection } from "./StageSection";
|
import { StageSection } from "./StageSection";
|
||||||
import { loadDraft, saveDraft, clearDraft } from "@/lib/draft";
|
import { loadDraft, saveDraft, clearDraft } from "@/lib/draft";
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ type SubmitStatus =
|
|||||||
| { kind: "success" }
|
| { kind: "success" }
|
||||||
| { kind: "error"; message: string };
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
type PathwayState = "past" | "current" | "future";
|
||||||
|
|
||||||
const STAGE_RANK: Record<string, number> = {
|
const STAGE_RANK: Record<string, number> = {
|
||||||
Inquiry: 0,
|
Inquiry: 0,
|
||||||
Organizing: 1,
|
Organizing: 1,
|
||||||
@@ -74,13 +77,11 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
|
|
||||||
// State map passed to the conditional engine and to FieldRenderer for
|
// State map passed to the conditional engine and to FieldRenderer for
|
||||||
// readonly display values. Only fields referenced by visibility rules or
|
// readonly display values. Only fields referenced by visibility rules or
|
||||||
// by readonly displays need to be here. Both readonly fields in this form
|
// by readonly displays need to be here — currently just current_stage,
|
||||||
// (current_stage, stage_at_submission) display the org's current stage.
|
// which gates every Stage 1–5 visibleWhen rule and is shown as a readonly
|
||||||
|
// field in the Stage 0 header.
|
||||||
const evalState = useMemo(
|
const evalState = useMemo(
|
||||||
() => ({
|
() => ({ current_stage: currentStageValue }),
|
||||||
current_stage: currentStageValue,
|
|
||||||
stage_at_submission: currentStageValue,
|
|
||||||
}),
|
|
||||||
[currentStageValue],
|
[currentStageValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -157,18 +158,25 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
};
|
};
|
||||||
}, [load.kind, cid, watch]);
|
}, [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(
|
const sectionsToRender = useMemo(
|
||||||
() =>
|
() =>
|
||||||
config.sections.map((s) => ({
|
config.sections.map((s) => {
|
||||||
section: s,
|
const pathwayState: PathwayState =
|
||||||
sectionVisible: evaluate(s.visibleWhen, evalState),
|
s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "future";
|
||||||
})),
|
return { section: s, pathwayState, locked: pathwayState === "future" };
|
||||||
[config.sections, evalState],
|
}),
|
||||||
|
[config.sections, currentRank],
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
|
|
||||||
|
|
||||||
// Track which sections the user has manually opened/closed so we can
|
// Track which sections the user has manually opened/closed so we can
|
||||||
// programmatically re-open them when validation surfaces an error within.
|
// programmatically re-open them when validation surfaces an error within.
|
||||||
// Used by the onInvalid handler below.
|
// 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 === "loading") return <LoadingState />;
|
||||||
if (load.kind === "error") return <ErrorState message={load.message} />;
|
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>) => {
|
const onSubmit = async (values: Record<string, unknown>) => {
|
||||||
setSubmitState({ kind: "submitting" });
|
setSubmitState({ kind: "submitting" });
|
||||||
try {
|
try {
|
||||||
@@ -261,7 +284,7 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-5" noValidate>
|
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-5" noValidate>
|
||||||
<SubmissionContextHeader
|
<SubmissionContextHeader
|
||||||
orgName={load.data.orgName}
|
orgName={load.data.orgName}
|
||||||
currentStage={load.data.currentStage}
|
currentStageLabel={resolveStageLabel(load.data.currentStage, load.data.options)}
|
||||||
currentRank={currentRank}
|
currentRank={currentRank}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -275,18 +298,30 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
}} />
|
}} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ol className="space-y-5">
|
<ol className="relative space-y-5 md:pl-12">
|
||||||
{sectionsToRender.map(({ section, sectionVisible }) => {
|
{sectionsToRender.map((entry, i) => {
|
||||||
if (!sectionVisible) return null;
|
const { section, pathwayState, locked } = entry;
|
||||||
|
const nextState =
|
||||||
|
i < sectionsToRender.length - 1
|
||||||
|
? sectionsToRender[i + 1].pathwayState
|
||||||
|
: undefined;
|
||||||
return (
|
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
|
<StageSection
|
||||||
section={section}
|
section={section}
|
||||||
register={register}
|
register={register}
|
||||||
|
control={control}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
formValues={evalState}
|
formValues={evalState}
|
||||||
isCurrent={section.rank === currentRank}
|
isCurrent={pathwayState === "current"}
|
||||||
defaultOpen={section.rank === currentRank || section.rank === 0}
|
locked={locked}
|
||||||
|
defaultOpen={pathwayState === "current" || section.rank === 0}
|
||||||
options={load.data.options ?? {}}
|
options={load.data.options ?? {}}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
@@ -305,11 +340,11 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
|
|
||||||
function SubmissionContextHeader({
|
function SubmissionContextHeader({
|
||||||
orgName,
|
orgName,
|
||||||
currentStage,
|
currentStageLabel,
|
||||||
currentRank,
|
currentRank,
|
||||||
}: {
|
}: {
|
||||||
orgName: string;
|
orgName: string;
|
||||||
currentStage: string;
|
currentStageLabel: string;
|
||||||
currentRank: number;
|
currentRank: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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]">
|
<h1 className="mt-1 font-display text-3xl font-medium leading-tight tracking-tight text-ink sm:text-[34px]">
|
||||||
{orgName}
|
{orgName}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mt-3 flex items-center gap-3">
|
<div className="mt-4 flex items-center gap-3">
|
||||||
<StageProgress currentRank={currentRank} />
|
<StageProgress currentRank={currentRank} />
|
||||||
<span className="text-sm text-ink-soft">
|
<span className="text-[10px] uppercase tracking-[0.16em] font-medium text-ink-mute">
|
||||||
<span className="text-ink-mute">Stage</span>{" "}
|
Current stage
|
||||||
<span className="font-medium text-leaf-800">{currentStage || "—"}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</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
|
* Six small dots representing the six stages, with the current rank filled
|
||||||
* and earlier ranks marked as visited. Quietly conveys progression without
|
* and earlier ranks marked as visited. Quietly conveys progression without
|
||||||
@@ -392,7 +444,10 @@ function SubmitBar({
|
|||||||
draftSavedAt: string | null;
|
draftSavedAt: string | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<div className="flex flex-col gap-0.5">
|
||||||
<SubmitFeedback state={state} />
|
<SubmitFeedback state={state} />
|
||||||
{state.kind === "idle" && (
|
{state.kind === "idle" && (
|
||||||
@@ -429,12 +484,6 @@ function SubmitFeedback({ state }: { state: SubmitStatus }) {
|
|||||||
Saving your check-in…
|
Saving your check-in…
|
||||||
</p>
|
</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")
|
if (state.kind === "error")
|
||||||
return (
|
return (
|
||||||
<p className="text-sm font-medium text-clay-700" role="alert">
|
<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'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's safe to close this window.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ErrorState({ message }: { message: string }) {
|
function ErrorState({ message }: { message: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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 {
|
function formatRelative(iso: string): string {
|
||||||
const then = new Date(iso).getTime();
|
const then = new Date(iso).getTime();
|
||||||
if (Number.isNaN(then)) return iso;
|
if (Number.isNaN(then)) return iso;
|
||||||
|
|||||||
608
components/ReportView.tsx
Normal file
608
components/ReportView.tsx
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useMemo, useState } from "react";
|
||||||
|
import type {
|
||||||
|
FieldConfig,
|
||||||
|
FieldHistoryEntry,
|
||||||
|
FormConfig,
|
||||||
|
ReportPayload,
|
||||||
|
SelectOption,
|
||||||
|
StageSectionConfig,
|
||||||
|
} from "@/types/form";
|
||||||
|
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
|
||||||
|
import { StageIcon } from "./StageIcon";
|
||||||
|
|
||||||
|
interface ReportViewProps {
|
||||||
|
config: FormConfig;
|
||||||
|
cid: string;
|
||||||
|
cs: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadState =
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "error"; message: string }
|
||||||
|
| { kind: "ready"; data: ReportPayload };
|
||||||
|
|
||||||
|
type PathwayState = "past" | "current" | "future";
|
||||||
|
|
||||||
|
const STAGE_RANK: Record<string, number> = {
|
||||||
|
Inquiry: 0,
|
||||||
|
Organizing: 1,
|
||||||
|
Feasibility: 2,
|
||||||
|
"Business feasibility": 3,
|
||||||
|
"Store Implementation": 4,
|
||||||
|
"Stabilize newly opened co-op": 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReportView({ config, cid, cs }: ReportViewProps) {
|
||||||
|
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function go() {
|
||||||
|
try {
|
||||||
|
const url = new URL("/api/report", window.location.origin);
|
||||||
|
url.searchParams.set("cid", cid);
|
||||||
|
url.searchParams.set("cs", cs);
|
||||||
|
const res = await fetch(url.toString(), { cache: "no-store" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
let message = `Could not load report (HTTP ${res.status}).`;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text) as { error?: string };
|
||||||
|
if (j.error) message = j.error;
|
||||||
|
} catch { /* default */ }
|
||||||
|
if (!cancelled) setLoad({ kind: "error", message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data: ReportPayload = await res.json();
|
||||||
|
if (!cancelled) setLoad({ kind: "ready", data });
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoad({
|
||||||
|
kind: "error",
|
||||||
|
message: e instanceof Error ? e.message : "Unexpected error loading report.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void go();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [cid, cs]);
|
||||||
|
|
||||||
|
const sectionsToRender = useMemo(() => {
|
||||||
|
if (load.kind !== "ready") return [];
|
||||||
|
const { currentStage, fieldHistory } = load.data;
|
||||||
|
const currentRank = STAGE_RANK[currentStage] ?? 0;
|
||||||
|
return config.sections
|
||||||
|
.map((section) => {
|
||||||
|
const fieldsWithHistory = section.fields.filter(
|
||||||
|
(f) => fieldHistory[f.name] && fieldHistory[f.name].length > 0,
|
||||||
|
);
|
||||||
|
const pathwayState: PathwayState =
|
||||||
|
section.rank < currentRank ? "past" : section.rank === currentRank ? "current" : "future";
|
||||||
|
return { section, fieldsWithHistory, pathwayState };
|
||||||
|
})
|
||||||
|
.filter((e) => e.fieldsWithHistory.length > 0);
|
||||||
|
}, [load, config.sections]);
|
||||||
|
|
||||||
|
if (load.kind === "loading") return <LoadingState />;
|
||||||
|
if (load.kind === "error") return <ErrorState message={load.message} />;
|
||||||
|
|
||||||
|
const { data } = load;
|
||||||
|
const stageOpts = data.options?.[STAGE_OPTION_GROUP_ID] ?? [];
|
||||||
|
const currentStageLabel =
|
||||||
|
stageOpts.find((o) => o.value === data.currentStage)?.label ?? data.currentStage;
|
||||||
|
const currentRank = STAGE_RANK[data.currentStage] ?? 0;
|
||||||
|
|
||||||
|
const dateRange = computeDateRange(data.activities.map((a) => a.date));
|
||||||
|
const totalActivities = data.activities.length;
|
||||||
|
const totalFieldsTracked = Object.keys(data.fieldHistory).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<ReportContextHeader
|
||||||
|
orgName={data.orgName}
|
||||||
|
currentStageLabel={currentStageLabel}
|
||||||
|
currentRank={currentRank}
|
||||||
|
totalActivities={totalActivities}
|
||||||
|
totalFieldsTracked={totalFieldsTracked}
|
||||||
|
dateRange={dateRange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{sectionsToRender.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<ol className="relative space-y-5 md:pl-12">
|
||||||
|
{sectionsToRender.map((entry, i) => {
|
||||||
|
const { section, pathwayState, fieldsWithHistory } = entry;
|
||||||
|
const nextState =
|
||||||
|
i < sectionsToRender.length - 1 ? sectionsToRender[i + 1].pathwayState : undefined;
|
||||||
|
return (
|
||||||
|
<li key={section.id} className="relative list-none">
|
||||||
|
<MobileStem show={i > 0} state={pathwayState} />
|
||||||
|
<RailMarker rank={section.rank} state={pathwayState} nextState={nextState} />
|
||||||
|
<ReportSection
|
||||||
|
section={section}
|
||||||
|
fields={fieldsWithHistory}
|
||||||
|
history={data.fieldHistory}
|
||||||
|
options={data.options ?? {}}
|
||||||
|
isCurrent={pathwayState === "current"}
|
||||||
|
defaultOpen={pathwayState === "current" || section.rank === 0}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportContextHeader({
|
||||||
|
orgName,
|
||||||
|
currentStageLabel,
|
||||||
|
currentRank,
|
||||||
|
totalActivities,
|
||||||
|
totalFieldsTracked,
|
||||||
|
dateRange,
|
||||||
|
}: {
|
||||||
|
orgName: string;
|
||||||
|
currentStageLabel: string;
|
||||||
|
currentRank: number;
|
||||||
|
totalActivities: number;
|
||||||
|
totalFieldsTracked: number;
|
||||||
|
dateRange: { from: string; to: string } | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="rounded-lg border border-rule bg-paper-2/40 px-6 py-5 sm:px-7 sm:py-6">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] text-ink-mute">Report for</p>
|
||||||
|
<h1 className="mt-1 font-display text-3xl font-medium leading-tight tracking-tight text-ink sm:text-[34px]">
|
||||||
|
{orgName}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<StageProgress currentRank={currentRank} />
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.16em] font-medium text-ink-mute">
|
||||||
|
Current stage
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 font-display text-xl sm:text-2xl font-medium leading-tight tracking-tight text-leaf-800">
|
||||||
|
{currentStageLabel || "—"}
|
||||||
|
</p>
|
||||||
|
<dl className="mt-5 grid grid-cols-3 gap-3 border-t border-rule-soft pt-4 text-sm sm:gap-6">
|
||||||
|
<Stat label="Check-ins" value={String(totalActivities)} />
|
||||||
|
<Stat label="Fields tracked" value={String(totalFieldsTracked)} />
|
||||||
|
<Stat
|
||||||
|
label="Span"
|
||||||
|
value={dateRange ? `${formatShortDate(dateRange.from)} - ${formatShortDate(dateRange.to)}` : "—"}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-[10px] uppercase tracking-[0.14em] font-medium text-ink-mute">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-0.5 font-display text-base font-medium text-ink tabular-nums sm:text-lg">
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StageProgress({ currentRank }: { currentRank: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5" role="img" aria-label={`Stage ${currentRank} of 5`}>
|
||||||
|
{[0, 1, 2, 3, 4, 5].map((r) => (
|
||||||
|
<span
|
||||||
|
key={r}
|
||||||
|
className={
|
||||||
|
"h-1.5 rounded-full transition-all " +
|
||||||
|
(r < currentRank
|
||||||
|
? "w-2.5 bg-leaf-300"
|
||||||
|
: r === currentRank
|
||||||
|
? "w-6 bg-leaf-700"
|
||||||
|
: "w-2.5 bg-rule-soft")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportSection({
|
||||||
|
section,
|
||||||
|
fields,
|
||||||
|
history,
|
||||||
|
options,
|
||||||
|
isCurrent,
|
||||||
|
defaultOpen,
|
||||||
|
}: {
|
||||||
|
section: StageSectionConfig;
|
||||||
|
fields: FieldConfig[];
|
||||||
|
history: Record<string, FieldHistoryEntry[]>;
|
||||||
|
options: Record<number, SelectOption[]>;
|
||||||
|
isCurrent: boolean;
|
||||||
|
defaultOpen: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const headingId = useId();
|
||||||
|
const panelId = useId();
|
||||||
|
|
||||||
|
const cardClass = isCurrent
|
||||||
|
? "border-2 border-leaf-600 shadow-[0_1px_0_0_rgba(0,0,0,0.04),0_8px_24px_-12px_rgba(60,80,40,0.18)] bg-white/95"
|
||||||
|
: "border border-rule bg-white/95 shadow-sm";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby={headingId}
|
||||||
|
className={"overflow-hidden rounded-lg transition " + cardClass}
|
||||||
|
>
|
||||||
|
<h2 id={headingId} className="m-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={panelId}
|
||||||
|
className={
|
||||||
|
"group relative flex w-full items-center gap-4 px-5 py-4 text-left transition-colors sm:px-6 " +
|
||||||
|
(isCurrent ? "bg-leaf-50/60" : "hover:bg-paper-2/60")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StageRankMark rank={section.rank} isCurrent={isCurrent} />
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="block font-display text-lg font-medium leading-tight tracking-tight text-ink sm:text-xl">
|
||||||
|
{section.label}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 flex items-center gap-3 text-xs text-ink-mute">
|
||||||
|
{isCurrent && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-clay-200 bg-clay-100/70 px-2 py-0.5 font-medium text-clay-700 uppercase tracking-[0.08em]">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-clay-600" aria-hidden />
|
||||||
|
Current stage
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{fields.length} {fields.length === 1 ? "field" : "fields"} with entries
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Chevron open={open} />
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id={panelId}
|
||||||
|
role="region"
|
||||||
|
aria-labelledby={headingId}
|
||||||
|
hidden={!open}
|
||||||
|
className="border-t border-rule-soft"
|
||||||
|
>
|
||||||
|
<div className="divide-y divide-rule-soft">
|
||||||
|
{fields.map((f) => (
|
||||||
|
<FieldHistoryRow
|
||||||
|
key={f.name}
|
||||||
|
field={f}
|
||||||
|
entries={history[f.name] ?? []}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
|
||||||
|
return (
|
||||||
|
<span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">
|
||||||
|
<StageIcon
|
||||||
|
rank={rank}
|
||||||
|
className={
|
||||||
|
"absolute inset-0 h-12 w-12 transition-opacity " +
|
||||||
|
(isCurrent ? "text-leaf-700 opacity-100" : "text-leaf-600 opacity-50")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"relative z-10 inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums shadow-sm " +
|
||||||
|
(isCurrent ? "bg-leaf-700 text-paper" : "bg-paper text-ink-soft border border-rule")
|
||||||
|
}
|
||||||
|
style={{ marginLeft: 28, marginTop: 28 }}
|
||||||
|
>
|
||||||
|
{rank}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chevron({ open }: { open: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className={"h-5 w-5 flex-shrink-0 text-ink-mute transition-transform duration-300 " + (open ? "rotate-180" : "")}
|
||||||
|
>
|
||||||
|
<path d="M5 8 L10 13 L15 8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldHistoryRow({
|
||||||
|
field,
|
||||||
|
entries,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
field: FieldConfig;
|
||||||
|
entries: FieldHistoryEntry[];
|
||||||
|
options: Record<number, SelectOption[]>;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const latest = entries[0];
|
||||||
|
const priorEntries = entries.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-4 sm:px-7">
|
||||||
|
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-6">
|
||||||
|
<div className="min-w-0 sm:max-w-[16rem]">
|
||||||
|
<p className="text-sm font-medium text-ink">{field.label}</p>
|
||||||
|
{field.help && (
|
||||||
|
<p className="mt-0.5 text-xs leading-relaxed text-ink-mute">{field.help}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 text-left sm:text-right">
|
||||||
|
<p className="font-display text-lg font-medium leading-snug text-leaf-800 tabular-nums">
|
||||||
|
<FormattedValue value={latest.value} field={field} options={options} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] uppercase tracking-[0.1em] text-ink-mute">
|
||||||
|
as of {formatShortDate(latest.date)}
|
||||||
|
{priorEntries.length > 0 && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
className="font-medium normal-case tracking-normal text-leaf-700 hover:text-leaf-800 hover:underline focus:outline-none focus-visible:underline"
|
||||||
|
>
|
||||||
|
{expanded ? "Hide" : `${priorEntries.length} earlier ${priorEntries.length === 1 ? "entry" : "entries"}`}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && priorEntries.length > 0 && (
|
||||||
|
<ol className="mt-3 space-y-1.5 border-l-2 border-rule-soft pl-4 sm:ml-auto sm:max-w-[24rem]">
|
||||||
|
{priorEntries.map((e) => (
|
||||||
|
<li
|
||||||
|
key={e.activityId}
|
||||||
|
className="flex items-baseline justify-between gap-4 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||||||
|
{formatShortDate(e.date)}
|
||||||
|
</span>
|
||||||
|
<span className="text-right text-ink-soft tabular-nums">
|
||||||
|
<FormattedValue value={e.value} field={field} options={options} />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencyFmt = new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const numberFmt = new Intl.NumberFormat("en-US");
|
||||||
|
|
||||||
|
function FormattedValue({
|
||||||
|
value,
|
||||||
|
field,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
value: unknown;
|
||||||
|
field: FieldConfig;
|
||||||
|
options: Record<number, SelectOption[]>;
|
||||||
|
}) {
|
||||||
|
if (value === null || value === undefined || value === "") return <>—</>;
|
||||||
|
|
||||||
|
const opts: SelectOption[] | undefined = field.optionGroupId
|
||||||
|
? options[field.optionGroupId]
|
||||||
|
: field.options;
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case "currency": {
|
||||||
|
const n = typeof value === "number" ? value : Number(value);
|
||||||
|
return <>{Number.isFinite(n) ? currencyFmt.format(n) : String(value)}</>;
|
||||||
|
}
|
||||||
|
case "percent": {
|
||||||
|
const n = typeof value === "number" ? value : Number(value);
|
||||||
|
return <>{Number.isFinite(n) ? `${n}%` : String(value)}</>;
|
||||||
|
}
|
||||||
|
case "number": {
|
||||||
|
const n = typeof value === "number" ? value : Number(value);
|
||||||
|
return <>{Number.isFinite(n) ? numberFmt.format(n) : String(value)}</>;
|
||||||
|
}
|
||||||
|
case "date":
|
||||||
|
return <>{formatLongDate(String(value))}</>;
|
||||||
|
case "boolean":
|
||||||
|
return <>{value ? "Yes" : "No"}</>;
|
||||||
|
case "select":
|
||||||
|
case "readonly": {
|
||||||
|
const v = String(value);
|
||||||
|
const found = opts?.find((o) => o.value === v);
|
||||||
|
return <>{found?.label ?? v}</>;
|
||||||
|
}
|
||||||
|
case "multiselect": {
|
||||||
|
let parts: string[];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
parts = value.map(String);
|
||||||
|
} else {
|
||||||
|
parts = String(value).split(/[|,]/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
const labels = parts.map((p) => opts?.find((o) => o.value === p)?.label ?? p);
|
||||||
|
return <>{labels.join(", ")}</>;
|
||||||
|
}
|
||||||
|
case "file":
|
||||||
|
return <>{String(value)}</>;
|
||||||
|
case "textarea":
|
||||||
|
case "text":
|
||||||
|
case "email":
|
||||||
|
case "phone":
|
||||||
|
default:
|
||||||
|
return <>{String(value)}</>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortDate(iso: string): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLongDate(iso: string): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||||
|
const d = m
|
||||||
|
? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
||||||
|
: new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDateRange(dates: string[]): { from: string; to: string } | null {
|
||||||
|
if (dates.length === 0) return null;
|
||||||
|
const times = dates
|
||||||
|
.map((d) => new Date(d).getTime())
|
||||||
|
.filter((t) => Number.isFinite(t));
|
||||||
|
if (times.length === 0) return null;
|
||||||
|
const min = new Date(Math.min(...times)).toISOString();
|
||||||
|
const max = new Date(Math.max(...times)).toISOString();
|
||||||
|
return { from: min, to: max };
|
||||||
|
}
|
||||||
|
|
||||||
|
function RailMarker({
|
||||||
|
rank,
|
||||||
|
state,
|
||||||
|
nextState,
|
||||||
|
}: {
|
||||||
|
rank: number;
|
||||||
|
state: PathwayState;
|
||||||
|
nextState?: PathwayState;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none hidden md:block absolute -left-9 top-0 bottom-0 w-7"
|
||||||
|
>
|
||||||
|
{nextState && (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"absolute left-1/2 -translate-x-1/2 top-11 -bottom-11 " +
|
||||||
|
(nextState === "future"
|
||||||
|
? "w-0 border-l border-dashed border-rule"
|
||||||
|
: "w-px bg-leaf-500/70")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MarkerCircle rank={rank} state={state} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkerCircle({ rank, state }: { rank: number; state: PathwayState }) {
|
||||||
|
if (state === "past") {
|
||||||
|
return (
|
||||||
|
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-5 w-5 items-center justify-center rounded-full bg-leaf-700 text-paper shadow-sm">
|
||||||
|
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className="h-2.5 w-2.5">
|
||||||
|
<path d="M3 6.5l2 2 4-5" />
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only">Stage {rank} (past)</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === "current") {
|
||||||
|
return (
|
||||||
|
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-6 w-6 items-center justify-center rounded-full bg-leaf-700 text-paper text-[10px] font-semibold shadow-md ring-2 ring-leaf-100">
|
||||||
|
{rank}
|
||||||
|
<span className="sr-only">Stage {rank} (current)</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-rule bg-paper text-ink-mute">
|
||||||
|
<span className="text-[9px] font-medium tabular-nums">{rank}</span>
|
||||||
|
<span className="sr-only">Stage {rank} (no entries)</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileStem({ show, state }: { show: boolean; state: PathwayState }) {
|
||||||
|
if (!show) return null;
|
||||||
|
return (
|
||||||
|
<div aria-hidden className="md:hidden -mt-4 mb-1.5 flex justify-center">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"block h-4 " +
|
||||||
|
(state === "future"
|
||||||
|
? "w-0 border-l border-dashed border-rule"
|
||||||
|
: "w-px bg-leaf-500/70")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingState() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-rule bg-paper px-6 py-12 text-center">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="mx-auto mb-4 h-7 w-7 animate-spin rounded-full border-[1.5px] border-rule border-t-leaf-700"
|
||||||
|
/>
|
||||||
|
<p className="font-display text-base text-ink-soft italic">Loading your activity report…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed border-rule bg-paper-2/30 px-6 py-10 text-center">
|
||||||
|
<p className="font-display text-lg text-ink-soft">No entries on file yet.</p>
|
||||||
|
<p className="mt-2 text-sm text-ink-mute">
|
||||||
|
Your co-op's first check-in will appear here once it's submitted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div role="alert" className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7">
|
||||||
|
<h2 className="font-display text-xl font-medium text-clay-700">
|
||||||
|
We couldn't open your report.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-relaxed text-ink-soft">{message}</p>
|
||||||
|
<p className="mt-4 text-sm text-ink-soft">
|
||||||
|
If this keeps happening, please contact your engagement coordinator.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export function SiteHeader() {
|
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">
|
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-5 sm:px-6">
|
||||||
<Link
|
<Link
|
||||||
href="https://fci.coop"
|
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"
|
aria-label="Food Co-op Initiative — fci.coop"
|
||||||
>
|
>
|
||||||
<Logo />
|
<Image
|
||||||
<div className="flex flex-col leading-none">
|
src="/fci-logo.png"
|
||||||
<span className="font-display text-base font-medium text-ink tracking-tight">
|
alt="Food Co-op Initiative"
|
||||||
Food Co-op Initiative
|
width={130}
|
||||||
</span>
|
height={105}
|
||||||
<span className="mt-1 text-[11px] uppercase tracking-[0.18em] text-ink-mute">
|
priority
|
||||||
Co-op Check-in
|
className="h-12 w-auto sm:h-14"
|
||||||
</span>
|
/>
|
||||||
</div>
|
<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>
|
</Link>
|
||||||
<nav aria-label="Primary">
|
<nav aria-label="Primary">
|
||||||
<Link
|
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect, useId, useMemo, useState } from "react";
|
import { useEffect, useId, useMemo, useState } from "react";
|
||||||
import type { StageSectionConfig, SelectOption } from "@/types/form";
|
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 { FieldRenderer } from "./fields/FieldRenderer";
|
||||||
import { MatrixGroup } from "./MatrixGroup";
|
import { MatrixGroup } from "./MatrixGroup";
|
||||||
import { StageIcon } from "./StageIcon";
|
import { StageIcon } from "./StageIcon";
|
||||||
@@ -11,11 +16,18 @@ import { evaluate } from "@/lib/conditional";
|
|||||||
interface StageSectionProps {
|
interface StageSectionProps {
|
||||||
section: StageSectionConfig;
|
section: StageSectionConfig;
|
||||||
register: UseFormRegister<FieldValues>;
|
register: UseFormRegister<FieldValues>;
|
||||||
|
control: Control<FieldValues>;
|
||||||
errors: FieldErrors;
|
errors: FieldErrors;
|
||||||
/** Live form values, used to evaluate per-field visibility rules. */
|
/** Live form values, used to evaluate per-field visibility rules. */
|
||||||
formValues: Record<string, unknown>;
|
formValues: Record<string, unknown>;
|
||||||
/** Whether this is the section that matches the current org stage. */
|
/** Whether this is the section that matches the current org stage. */
|
||||||
isCurrent: boolean;
|
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. */
|
/** Whether the section starts open. */
|
||||||
defaultOpen: boolean;
|
defaultOpen: boolean;
|
||||||
/** Option groups fetched from CiviCRM, keyed by option_group_id. */
|
/** Option groups fetched from CiviCRM, keyed by option_group_id. */
|
||||||
@@ -36,9 +48,11 @@ interface StageSectionProps {
|
|||||||
export function StageSection({
|
export function StageSection({
|
||||||
section,
|
section,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
errors,
|
errors,
|
||||||
formValues,
|
formValues,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
|
locked,
|
||||||
defaultOpen,
|
defaultOpen,
|
||||||
options,
|
options,
|
||||||
}: StageSectionProps) {
|
}: StageSectionProps) {
|
||||||
@@ -76,15 +90,22 @@ export function StageSection({
|
|||||||
0,
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-labelledby={headingId}
|
aria-labelledby={headingId}
|
||||||
className={
|
className={"overflow-hidden rounded-lg transition " + cardClass}
|
||||||
"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")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<h2 id={headingId} className="m-0">
|
<h2 id={headingId} className="m-0">
|
||||||
<button
|
<button
|
||||||
@@ -94,12 +115,17 @@ export function StageSection({
|
|||||||
aria-controls={panelId}
|
aria-controls={panelId}
|
||||||
className={
|
className={
|
||||||
"group relative flex w-full items-center gap-4 px-5 py-4 text-left transition-colors sm:px-6 " +
|
"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="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}
|
{section.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 flex items-center gap-3 text-xs text-ink-mute">
|
<span className="mt-1 flex items-center gap-3 text-xs text-ink-mute">
|
||||||
@@ -109,6 +135,12 @@ export function StageSection({
|
|||||||
Current stage
|
Current stage
|
||||||
</span>
|
</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">
|
<span className="tabular-nums">
|
||||||
{fieldCount} {fieldCount === 1 ? "field" : "fields"}
|
{fieldCount} {fieldCount === 1 ? "field" : "fields"}
|
||||||
</span>
|
</span>
|
||||||
@@ -123,9 +155,17 @@ export function StageSection({
|
|||||||
role="region"
|
role="region"
|
||||||
aria-labelledby={headingId}
|
aria-labelledby={headingId}
|
||||||
hidden={!open}
|
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 && (
|
{section.intro && (
|
||||||
<p className="mb-6 max-w-prose text-[15px] leading-relaxed text-ink-soft">
|
<p className="mb-6 max-w-prose text-[15px] leading-relaxed text-ink-soft">
|
||||||
{section.intro}
|
{section.intro}
|
||||||
@@ -153,6 +193,7 @@ export function StageSection({
|
|||||||
<FieldRenderer
|
<FieldRenderer
|
||||||
field={f}
|
field={f}
|
||||||
register={register}
|
register={register}
|
||||||
|
control={control}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
readonlyValue={formValues[f.name]}
|
readonlyValue={formValues[f.name]}
|
||||||
resolvedOptions={resolvedOptions}
|
resolvedOptions={resolvedOptions}
|
||||||
@@ -162,7 +203,7 @@ export function StageSection({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -173,20 +214,35 @@ export function StageSection({
|
|||||||
* icon offset behind it. When current, the ring is leaf-green and the
|
* icon offset behind it. When current, the ring is leaf-green and the
|
||||||
* number is white-on-green; otherwise it's quiet.
|
* 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 (
|
return (
|
||||||
<span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">
|
<span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">
|
||||||
<StageIcon
|
<StageIcon
|
||||||
rank={rank}
|
rank={rank}
|
||||||
className={
|
className={"absolute inset-0 h-12 w-12 transition-opacity " + iconClass}
|
||||||
"absolute inset-0 h-12 w-12 transition-opacity " +
|
|
||||||
(isCurrent ? "text-leaf-700 opacity-100" : "text-leaf-600 opacity-50")
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
"relative z-10 inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums shadow-sm " +
|
"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 }}
|
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're visible
|
||||||
|
now so you can preview the framework you'll be working through.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Chevron({ open }: { open: boolean }) {
|
function Chevron({ open }: { open: boolean }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useWatch } from "react-hook-form";
|
||||||
import type { FieldConfig, SelectOption } from "@/types/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 {
|
interface FieldRendererProps {
|
||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
register: UseFormRegister<FieldValues>;
|
register: UseFormRegister<FieldValues>;
|
||||||
errors: FieldErrors;
|
errors: FieldErrors;
|
||||||
|
/** Form control — required for currency live-preview formatting. */
|
||||||
|
control: Control<FieldValues>;
|
||||||
/** For readonly display fields, the value to render. */
|
/** For readonly display fields, the value to render. */
|
||||||
readonlyValue?: unknown;
|
readonlyValue?: unknown;
|
||||||
/**
|
/**
|
||||||
@@ -17,6 +25,9 @@ interface FieldRendererProps {
|
|||||||
resolvedOptions?: SelectOption[];
|
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
|
* Renders a single field appropriate to its `type`. All inputs share a common
|
||||||
* accessibility scaffold: a real <label htmlFor>, aria-describedby pointing
|
* accessibility scaffold: a real <label htmlFor>, aria-describedby pointing
|
||||||
@@ -34,6 +45,7 @@ export function FieldRenderer({
|
|||||||
field,
|
field,
|
||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
|
control,
|
||||||
readonlyValue,
|
readonlyValue,
|
||||||
resolvedOptions,
|
resolvedOptions,
|
||||||
}: FieldRendererProps) {
|
}: FieldRendererProps) {
|
||||||
@@ -263,17 +275,43 @@ export function FieldRenderer({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{field.type === "currency" && (
|
||||||
|
<CurrencyPreview control={control} name={field.name} />
|
||||||
|
)}
|
||||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||||
</div>
|
</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 =
|
const inputType =
|
||||||
field.type === "email" ? "email" :
|
field.type === "email" ? "email" :
|
||||||
field.type === "phone" ? "tel" :
|
field.type === "phone" ? "tel" :
|
||||||
field.type === "date" ? "date" :
|
|
||||||
"text";
|
"text";
|
||||||
|
|
||||||
return (
|
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 }) {
|
function Label({ id, field }: { id: string; field: FieldConfig }) {
|
||||||
return (
|
return (
|
||||||
<label htmlFor={id} className="block text-sm font-medium text-ink">
|
<label htmlFor={id} className="block text-sm font-medium text-ink">
|
||||||
|
|||||||
276
config/form.ts
276
config/form.ts
@@ -34,6 +34,13 @@ const visibleAtOrAfter = (...stages: string[]): VisibilityRule => ({
|
|||||||
values: stages,
|
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.
|
// CiviCRM custom group machine names per the APIv4 inventory.
|
||||||
const G0 = "Check_in_data__organizing_";
|
const G0 = "Check_in_data__organizing_";
|
||||||
const G1 = "Stage_1";
|
const G1 = "Stage_1";
|
||||||
@@ -50,20 +57,17 @@ const stage0: StageSectionConfig = {
|
|||||||
intro:
|
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.",
|
"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: [
|
fields: [
|
||||||
// current_stage is a special UI-only field — it shows the org's stage.
|
// current_stage is a UI-only readonly field. Its value is the Civi
|
||||||
// It is NOT written back to the activity by the submit endpoint.
|
// 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",
|
name: "current_stage",
|
||||||
label: "Framework Stage",
|
label: "Current stage",
|
||||||
type: "readonly",
|
type: "readonly",
|
||||||
civiField: "Food_Co_op_Organizing.Stage",
|
optionGroupId: STAGE_OPTION_GROUP_ID,
|
||||||
},
|
|
||||||
{
|
|
||||||
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.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Peer_Group_Participation",
|
name: "Peer_Group_Participation",
|
||||||
@@ -72,6 +76,7 @@ const stage0: StageSectionConfig = {
|
|||||||
civiField: `${G0}.Peer_Group_Participation`,
|
civiField: `${G0}.Peer_Group_Participation`,
|
||||||
optionGroupId: 140,
|
optionGroupId: 140,
|
||||||
help: "Is this co-op currently participating in Peer Learning Groups?",
|
help: "Is this co-op currently participating in Peer Learning Groups?",
|
||||||
|
visibleWhen: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Internal_Startup_Assessment",
|
name: "Internal_Startup_Assessment",
|
||||||
@@ -79,6 +84,7 @@ const stage0: StageSectionConfig = {
|
|||||||
type: "select",
|
type: "select",
|
||||||
civiField: `${G0}.Internal_Startup_Assessment`,
|
civiField: `${G0}.Internal_Startup_Assessment`,
|
||||||
optionGroupId: 132,
|
optionGroupId: 132,
|
||||||
|
visibleWhen: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Internal_Startup_Assessment_Date",
|
name: "Internal_Startup_Assessment_Date",
|
||||||
@@ -86,6 +92,7 @@ const stage0: StageSectionConfig = {
|
|||||||
type: "date",
|
type: "date",
|
||||||
civiField: `${G0}.Internal_Startup_Assessment_Date`,
|
civiField: `${G0}.Internal_Startup_Assessment_Date`,
|
||||||
help: "Date of the latest Internal Startup Assessment rating.",
|
help: "Date of the latest Internal Startup Assessment rating.",
|
||||||
|
visibleWhen: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Member_Goal_for_current_Stage",
|
name: "Member_Goal_for_current_Stage",
|
||||||
@@ -106,29 +113,31 @@ const stage0: StageSectionConfig = {
|
|||||||
label: "Total members at opening",
|
label: "Total members at opening",
|
||||||
type: "number",
|
type: "number",
|
||||||
civiField: `${G0}.Total_members_at_opening`,
|
civiField: `${G0}.Total_members_at_opening`,
|
||||||
|
visibleWhen: visibleAtOrAfter(S.Stabilize),
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
name: "Volunteers_Helping_In_Store",
|
// name: "Volunteers_Helping_In_Store",
|
||||||
label: "Volunteers Helping In Store",
|
// label: "Volunteers Helping In Store",
|
||||||
type: "select",
|
// type: "select",
|
||||||
civiField: `${G0}.Volunteers_Helping_In_Store`,
|
// civiField: `${G0}.Volunteers_Helping_In_Store`,
|
||||||
optionGroupId: 141,
|
// optionGroupId: 141,
|
||||||
help: "Does this co-op use volunteers for day-to-day operations? (Not occasional events.)",
|
// help: "Does this co-op use volunteers for day-to-day operations? (Not occasional events.)",
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
name: "Work_from_Members",
|
// name: "Work_from_Members",
|
||||||
label: "Work from Members",
|
// label: "Work from Members",
|
||||||
type: "select",
|
// type: "select",
|
||||||
civiField: `${G0}.Work_from_Members`,
|
// civiField: `${G0}.Work_from_Members`,
|
||||||
optionGroupId: 142,
|
// optionGroupId: 142,
|
||||||
help: "Does this co-op require work from members (e.g. annual hours)?",
|
// help: "Does this co-op require work from members (e.g. annual hours)?",
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
name: "Total_square_ft",
|
name: "Total_square_ft",
|
||||||
label: "Total square ft",
|
label: "Total square ft",
|
||||||
type: "number",
|
type: "number",
|
||||||
civiField: `${G0}.Total_square_ft`,
|
civiField: `${G0}.Total_square_ft`,
|
||||||
step: 1,
|
step: 1,
|
||||||
|
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Retail_sq_ft",
|
name: "Retail_sq_ft",
|
||||||
@@ -136,32 +145,34 @@ const stage0: StageSectionConfig = {
|
|||||||
type: "number",
|
type: "number",
|
||||||
civiField: `${G0}.Retail_sq_ft`,
|
civiField: `${G0}.Retail_sq_ft`,
|
||||||
step: 1,
|
step: 1,
|
||||||
|
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
name: "Latest_Sources_and_Uses_doc",
|
// name: "Latest_Sources_and_Uses_doc",
|
||||||
label: "Latest Sources and Uses doc",
|
// label: "Latest Sources and Uses doc",
|
||||||
type: "file",
|
// type: "file",
|
||||||
civiField: `${G0}.Latest_Sources_and_Uses_doc`,
|
// civiField: `${G0}.Latest_Sources_and_Uses_doc`,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
name: "Latest_Pro_Forma_doc",
|
// name: "Latest_Pro_Forma_doc",
|
||||||
label: "Latest Pro Forma doc",
|
// label: "Latest Pro Forma doc",
|
||||||
type: "file",
|
// type: "file",
|
||||||
civiField: `${G0}.Latest_Pro_Forma_doc`,
|
// 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_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_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_Year_3_Sales", label: "Projected Year 3 Sales", type: "currency", civiField: `${G0}.Projected_Year_3_Sales` },
|
||||||
{
|
// {
|
||||||
name: "Projected_sales_at_maturity",
|
// name: "Projected_sales_at_maturity",
|
||||||
label: "Projected sales once established",
|
// label: "Projected sales once established",
|
||||||
type: "currency",
|
// type: "currency",
|
||||||
civiField: `${G0}.Projected_sales_at_maturity`,
|
// civiField: `${G0}.Projected_sales_at_maturity`,
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
name: "FTEs",
|
name: "FTEs",
|
||||||
label: "Projected FTEs",
|
label: "Projected FTEs",
|
||||||
type: "number",
|
type: "number",
|
||||||
|
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
||||||
civiField: `${G0}.FTEs`,
|
civiField: `${G0}.FTEs`,
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
help: "Number of full-time equivalent (FTE) employees planned to work at the store.",
|
help: "Number of full-time equivalent (FTE) employees planned to work at the store.",
|
||||||
@@ -172,35 +183,60 @@ const stage0: StageSectionConfig = {
|
|||||||
type: "number",
|
type: "number",
|
||||||
civiField: `${G0}.Actual_FTE`,
|
civiField: `${G0}.Actual_FTE`,
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
|
visibleWhen: visibleAtOrAfter(S.Stabilize),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Total_cost_of_project",
|
name: "Total_cost_of_project",
|
||||||
label: "Total cost of project",
|
label: "Total cost of project",
|
||||||
type: "currency",
|
type: "currency",
|
||||||
|
visibleWhen: visibleAtOrAfter(S.Stabilize),
|
||||||
civiField: `${G0}.Total_cost_of_project`,
|
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_equity_total",
|
||||||
{ name: "Member_loans_total", label: "Member loans total needed", type: "currency", civiField: `${G0}.Member_loans_total` },
|
label: "Member equity total needed",
|
||||||
{ name: "Member_loans_raised", label: "Member loans raised", type: "currency", civiField: `${G0}.Member_loans_raised` },
|
type: "currency",
|
||||||
{
|
civiField: `${G0}.Member_equity_total`
|
||||||
name: "Member_preferred_shares_total",
|
},
|
||||||
label: "Member preferred shares total needed",
|
{
|
||||||
type: "currency",
|
name: "Member_equity_raised",
|
||||||
civiField: `${G0}.Member_preferred_shares_total`,
|
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",
|
name: "Member_preferred_shares_raised",
|
||||||
label: "Member preferred shares raised",
|
label: "Member preferred shares raised",
|
||||||
type: "currency",
|
type: "currency",
|
||||||
|
visibleWhen: visibleAtOrAfter(S.Stabilize),
|
||||||
civiField: `${G0}.Member_preferred_shares_raised`,
|
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_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: "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_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` },
|
{ name: "Date_Closed_Folded", label: "Date Closed / Folded", type: "date", civiField: `${G0}.Date_Closed_Folded` },
|
||||||
] as FieldConfig[],
|
] as FieldConfig[],
|
||||||
};
|
};
|
||||||
@@ -209,13 +245,14 @@ const stage0: StageSectionConfig = {
|
|||||||
const stage1: StageSectionConfig = {
|
const stage1: StageSectionConfig = {
|
||||||
rank: 1,
|
rank: 1,
|
||||||
id: "stage_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),
|
visibleWhen: visibleAtOrAfter(S.Organizing, S.Feasibility, S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: "Preliminary_Market_Assessment",
|
name: "Preliminary_Market_Assessment",
|
||||||
label: "Preliminary Market Assessment",
|
label: "Preliminary Market Assessment",
|
||||||
type: "date",
|
type: "date",
|
||||||
|
required: true,
|
||||||
civiField: `${G1}.Preliminary_Market_Assessment`,
|
civiField: `${G1}.Preliminary_Market_Assessment`,
|
||||||
help: "What date was your Preliminary Market Assessment completed?",
|
help: "What date was your Preliminary Market Assessment completed?",
|
||||||
},
|
},
|
||||||
@@ -266,7 +303,7 @@ const stage1: StageSectionConfig = {
|
|||||||
const stage2: StageSectionConfig = {
|
const stage2: StageSectionConfig = {
|
||||||
rank: 2,
|
rank: 2,
|
||||||
id: "stage_2",
|
id: "stage_2",
|
||||||
label: "Stage 2 — Feasibility",
|
label: "Stage 2 — Grow & Plan",
|
||||||
visibleWhen: visibleAtOrAfter(S.Feasibility, S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
visibleWhen: visibleAtOrAfter(S.Feasibility, S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
||||||
fields: [
|
fields: [
|
||||||
{ name: "Market_Study_Date", label: "Market Study Date", type: "date", civiField: `${G2}.Market_Study_Date` },
|
{ 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 = {
|
const stage3: StageSectionConfig = {
|
||||||
rank: 3,
|
rank: 3,
|
||||||
id: "stage_3",
|
id: "stage_3",
|
||||||
label: "Stage 3 — Connect and Gather",
|
label: "Stage 3 — Connect & Gather",
|
||||||
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
@@ -390,7 +427,7 @@ const stage3: StageSectionConfig = {
|
|||||||
const stage4: StageSectionConfig = {
|
const stage4: StageSectionConfig = {
|
||||||
rank: 4,
|
rank: 4,
|
||||||
id: "stage_4",
|
id: "stage_4",
|
||||||
label: "Stage 4 — Excite and Build",
|
label: "Stage 4 — Excite & Build",
|
||||||
visibleWhen: visibleAtOrAfter(S.StoreImplementation, S.Stabilize),
|
visibleWhen: visibleAtOrAfter(S.StoreImplementation, S.Stabilize),
|
||||||
fields: [
|
fields: [
|
||||||
{ name: "Projected_Opening_Date", label: "Projected Opening Date", type: "date", civiField: `${G4}.Projected_Opening_Date` },
|
{ 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
|
// Stage 5 — Fulfill & Stabilize. Field machine names have historical
|
||||||
// inconsistencies; we list each one explicitly rather than building
|
// inconsistencies; we list each one explicitly rather than building
|
||||||
// programmatically to keep the mapping auditable.
|
// programmatically to keep the mapping auditable.
|
||||||
|
/*
|
||||||
const STAGE_5_FIELDS: FieldConfig[] = [
|
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!
|
// 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", 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` },
|
{ 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_M11",
|
||||||
"Y1_Monthly_Sales_Target_M12",
|
"Y1_Monthly_Sales_Target_M12",
|
||||||
];
|
];
|
||||||
|
*/
|
||||||
const stage5: StageSectionConfig = {
|
const stage5: StageSectionConfig = {
|
||||||
rank: 5,
|
rank: 5,
|
||||||
id: "stage_5",
|
id: "stage_5",
|
||||||
label: "Stage 5 — Fulfill and Stabilize",
|
label: "Stage 5 — Fulfill & Stabilize",
|
||||||
visibleWhen: visibleAtOrAfter(S.Stabilize),
|
visibleWhen: visibleAtOrAfter(S.Stabilize),
|
||||||
fields: STAGE_5_FIELDS,
|
fields: [
|
||||||
matrixGroups: [
|
{ 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` }
|
||||||
id: "y1_monthly_metrics",
|
] as FieldConfig[],
|
||||||
label: "Year 1 — Monthly Tracking",
|
// STAGE_5_FIELDS,
|
||||||
intro: "Targets and actuals by month. Leave blank for months you don't yet have data for.",
|
// matrixGroups: [
|
||||||
cols: monthCols,
|
// {
|
||||||
rows: [
|
// id: "y1_monthly_metrics",
|
||||||
{ label: "Sales Target", type: "currency", fields: Y1_TARGET_FIELDS },
|
// label: "Year 1 — Monthly Tracking",
|
||||||
{
|
// intro: "Targets and actuals by month. Leave blank for months you don't yet have data for.",
|
||||||
label: "Actual Sales",
|
// cols: monthCols,
|
||||||
type: "currency",
|
// rows: [
|
||||||
fields: months.map((m) => `Y1_M${m}_Actual_Sales`),
|
// { label: "Sales Target", type: "currency", fields: Y1_TARGET_FIELDS },
|
||||||
},
|
// {
|
||||||
{
|
// label: "Actual Sales",
|
||||||
label: "Transactions",
|
// type: "currency",
|
||||||
type: "number",
|
// fields: months.map((m) => `Y1_M${m}_Actual_Sales`),
|
||||||
fields: months.map((m) => `Y1_M${m}_Transactions`),
|
// },
|
||||||
},
|
// {
|
||||||
],
|
// 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: [
|
// id: "y1_quarterly_metrics",
|
||||||
{
|
// label: "Year 1 — Quarterly Tracking",
|
||||||
label: "Labor",
|
// intro: "Quarterly operating ratios.",
|
||||||
type: "percent",
|
// cols: quarterCols,
|
||||||
fields: quarters.map((q) => `Y1_Q${q}_Labor`),
|
// rows: [
|
||||||
},
|
// {
|
||||||
{
|
// label: "Labor",
|
||||||
label: "Margin",
|
// type: "percent",
|
||||||
type: "percent",
|
// fields: quarters.map((q) => `Y1_Q${q}_Labor`),
|
||||||
fields: quarters.map((q) => `Y1_Q${q}_Margin`),
|
// },
|
||||||
},
|
// {
|
||||||
{
|
// label: "Margin",
|
||||||
label: "Member Sales",
|
// type: "percent",
|
||||||
type: "percent",
|
// fields: quarters.map((q) => `Y1_Q${q}_Margin`),
|
||||||
fields: quarters.map((q) => `Y1_Q${q}_Member_Sales_`),
|
// },
|
||||||
},
|
// {
|
||||||
],
|
// label: "Member Sales",
|
||||||
},
|
// type: "percent",
|
||||||
],
|
// fields: quarters.map((q) => `Y1_Q${q}_Member_Sales_`),
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formConfig: FormConfig = {
|
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. */
|
/** Activity type the form writes its submissions as. */
|
||||||
export const ACTIVITY_TYPE_NAME = "Check-in (organizing)";
|
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`;
|
export const ACTIVITY_STAGE_FIELD = `${G0}.Stage`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
119
package-lock.json
generated
119
package-lock.json
generated
@@ -8,7 +8,6 @@
|
|||||||
"name": "next-app",
|
"name": "next-app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.16.0",
|
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
@@ -2405,12 +2404,6 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
@@ -2437,17 +2430,6 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -2558,6 +2540,7 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -2657,18 +2640,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -2827,15 +2798,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -2863,6 +2825,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
@@ -2974,6 +2937,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -2983,6 +2947,7 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -3020,6 +2985,7 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -3032,6 +2998,7 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -3628,26 +3595,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -3664,26 +3611,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -3744,6 +3676,7 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -3768,6 +3701,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
@@ -3855,6 +3789,7 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -3926,6 +3861,7 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -3938,6 +3874,7 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
@@ -3953,6 +3890,7 @@
|
|||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -4935,6 +4873,7 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4964,27 +4903,6 @@
|
|||||||
"node": ">=8.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": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -5474,15 +5392,6 @@
|
|||||||
"react-is": "^16.13.1"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.16.0",
|
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
|||||||
BIN
public/fci-logo.png
Normal file
BIN
public/fci-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -69,9 +69,13 @@ export interface FieldConfig {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/** For select / multiselect. */
|
/** For select / multiselect. */
|
||||||
options?: SelectOption[];
|
options?: SelectOption[];
|
||||||
/** For number / currency / percent — min/max/step constraints. */
|
/**
|
||||||
min?: number;
|
* Min/max bound. For numeric fields, a number; for date fields, an ISO
|
||||||
max?: number;
|
* 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;
|
step?: number;
|
||||||
/** For text / textarea — max length. */
|
/** For text / textarea — max length. */
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
@@ -193,3 +197,49 @@ export interface SubmitPayload {
|
|||||||
cs: string;
|
cs: string;
|
||||||
values: Record<string, unknown>;
|
values: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One historical entry for a field. The report walks all Check-in
|
||||||
|
* (organizing) activities for an org and collects every non-empty value
|
||||||
|
* each field has ever held, alongside the activity it came from.
|
||||||
|
*/
|
||||||
|
export interface FieldHistoryEntry {
|
||||||
|
activityId: number;
|
||||||
|
/** ISO timestamp of activity_date_time. */
|
||||||
|
date: string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A trimmed view of one activity, used to head the report and to label
|
||||||
|
* staff stage-change activities distinctly from form-driven submissions.
|
||||||
|
*/
|
||||||
|
export interface ActivitySummary {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
/** Stage value carried on this activity (non-empty → staff transition). */
|
||||||
|
stage?: string | null;
|
||||||
|
subject?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shape returned by /api/report. Activities ordered DESC; each
|
||||||
|
* fieldHistory entry list is also DESC.
|
||||||
|
*/
|
||||||
|
export interface ReportPayload {
|
||||||
|
orgName: string;
|
||||||
|
/** Current Framework Stage as text value (e.g. "Organizing"). */
|
||||||
|
currentStage: string;
|
||||||
|
/**
|
||||||
|
* Activity summary list, ordered DESC by activity_date_time.
|
||||||
|
* Includes both staff stage-change and form-driven submission activities.
|
||||||
|
*/
|
||||||
|
activities: ActivitySummary[];
|
||||||
|
/**
|
||||||
|
* Per-field history of non-empty values, keyed by FieldConfig.name. Each
|
||||||
|
* list is sorted DESC by activity_date_time. Fields that have never been
|
||||||
|
* filled in are absent from this map.
|
||||||
|
*/
|
||||||
|
fieldHistory: Record<string, FieldHistoryEntry[]>;
|
||||||
|
options?: Record<number, SelectOption[]>;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user