diff --git a/app/api/data/route.ts b/app/api/data/route.ts index d90ccab..e8699e1 100644 --- a/app/api/data/route.ts +++ b/app/api/data/route.ts @@ -2,36 +2,60 @@ * GET /api/data?cid=&cs= * * Verifies the checksum, resolves the org from the contact via the - * Primary Form Contact relationship, reads the org's Framework Stage, - * and walks past Org Engagement Submission activities for per-field - * most-recent prefill. + * Primary Form Contact relationship, reads the org's Framework Stage + * (`Food_Co_op_Organizing.Stage` on the Organization contact), and walks + * past `Check-in (organizing)` activities for per-field most-recent prefill. + * + * Also fetches the OptionValue rows for any option_group_ids referenced by + * the form (so radio/select/multiselect fields render with real CRM-defined + * options) and returns them in `payload.options`. * * Returns FormDataPayload (see types/form.ts). * - * STUB MODE: if CiviCRM env vars are unset, returns mock data so the UI - * is exercisable without a live CRM. The mock data uses the actual stage - * values and a couple of populated example fields so the form looks alive - * during development. + * STUB MODE: if CiviCRM env vars are unset, returns mock data + a small set + * of mock options so the UI is exercisable without a live CRM. */ import { NextRequest, NextResponse } from "next/server"; import { civi, verifyChecksum } from "@/lib/civicrm"; import { loadPrefill } from "@/lib/prefill"; -import { allFields } from "@/config/form"; -import type { FormDataPayload } from "@/types/form"; +import { allFields, optionGroupIds, ACTIVITY_TYPE_NAME } from "@/config/form"; +import type { FormDataPayload, SelectOption } from "@/types/form"; const STUB_PAYLOAD: FormDataPayload = { orgName: "Sample Co-op (stub)", currentStage: "Organizing", prefill: { - stage_0_peer_group_participation: "Peer cohort 4", - stage_0_members_current: 87, - stage_0_total_members_at_opening: null, - stage_0_projected_y1_sales: 2400000, - stage_0_projected_y2_sales: 2950000, - stage_0_total_cost_of_project: 4200000, - stage_1_vision: "A neighborhood-rooted co-op grocery prioritizing local farmers and equity in food access.", - stage_1_business_concept: "5,500 sq ft full-service co-op in a transit-adjacent storefront.", + Peer_Group_Participation: "Yes", + Members__current_: 87, + Total_members_at_opening: null, + Projected_Year_1_Sales: 2400000, + Projected_Year_2_Sales: 2950000, + Total_cost_of_project: 4200000, + Vision: "2024-09-15", + Business_Concept: "2024-12-02", + }, + 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" }], + 141: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }], + 142: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }], + 133: [{ value: "Viable", label: "Viable" }, { value: "Marginal", label: "Marginal" }, { value: "Not viable", label: "Not viable" }], + 134: [{ value: "Member equity", label: "Member equity" }, { value: "Member loans", label: "Member loans" }, { value: "Bank debt", label: "Bank debt" }, { value: "Grants", label: "Grants" }], + 139: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }], + 135: [{ value: "Co-op", label: "Co-op grocery" }, { value: "Conventional", label: "Conventional grocery" }, { value: "Other", label: "Other" }], + 136: [{ value: "Member", label: "Member" }, { value: "Considering", label: "Considering" }, { value: "Not a member", label: "Not a member" }], + 137: [{ value: "Member", label: "Member" }, { value: "Considering", label: "Considering" }, { value: "Not a member", label: "Not a member" }], + 138: [{ value: "UNFI", label: "UNFI" }, { value: "KeHE", label: "KeHE" }, { value: "Other", label: "Other" }, { value: "None", label: "None" }], + 143: [{ value: "Northeast", label: "Northeast" }, { value: "Mid-Atlantic", label: "Mid-Atlantic" }, { value: "Midwest", label: "Midwest" }, { value: "South", label: "South" }, { value: "West", label: "West" }], + 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" }, + ], }, }; @@ -43,6 +67,32 @@ function isStubMode(): boolean { ); } +interface OptionValueRow { + value: string; + label: string; + option_group_id: number; + is_active: boolean; +} + +async function fetchOptionGroups(): Promise> { + if (optionGroupIds.length === 0) return {}; + const res = await civi("OptionValue", "get", { + select: ["value", "label", "option_group_id", "is_active"], + where: [ + ["option_group_id", "IN", optionGroupIds], + ["is_active", "=", true], + ], + orderBy: { weight: "ASC" }, + limit: 500, + }); + const out: Record = {}; + for (const row of res.values ?? []) { + if (!out[row.option_group_id]) out[row.option_group_id] = []; + out[row.option_group_id].push({ value: row.value, label: row.label }); + } + return out; +} + export async function GET(req: NextRequest) { const url = new URL(req.url); const cid = url.searchParams.get("cid"); @@ -69,8 +119,7 @@ export async function GET(req: NextRequest) { } // Resolve the org from the contact's Primary Form Contact relationship. - // Requires that the relationship exists and is active. We fetch contact_id_b - // because the relationship is Individual (A) → Organization (B). + // The relationship is Individual (A) → Organization (B); fetch contact_id_b. const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", { select: ["contact_id_b"], where: [ @@ -95,30 +144,33 @@ export async function GET(req: NextRequest) { } const orgId = orgs[0].contact_id_b; - // Fetch org name + Framework Stage value. + // Fetch org name + Framework Stage (org-side, on the organization contact). const orgRes = await civi<{ id: number; display_name: string; "Food_Co_op_Organizing.Stage": string | null; - custom_1?: string | null; }>("Contact", "get", { - select: ["id", "display_name", "custom_1", "Food_Co_op_Organizing.Stage"], + select: ["id", "display_name", "Food_Co_op_Organizing.Stage"], where: [["id", "=", orgId]], }); const org = orgRes.values?.[0]; if (!org) { return NextResponse.json({ error: "Organization not found." }, { status: 404 }); } - const currentStage = - org["Food_Co_op_Organizing.Stage"] ?? org.custom_1 ?? ""; + const currentStage = org["Food_Co_op_Organizing.Stage"] ?? ""; - // Per-field-most-recent prefill across past submission activities. - const { values: prefill } = await loadPrefill(orgId, allFields); + // Per-field-most-recent prefill across past Check-in activities, plus + // option-group fetch — kicked off in parallel. + const [{ values: prefill }, options] = await Promise.all([ + loadPrefill(orgId, allFields, ACTIVITY_TYPE_NAME), + fetchOptionGroups(), + ]); const payload: FormDataPayload = { orgName: org.display_name, currentStage: typeof currentStage === "string" ? currentStage : "", prefill, + options, }; return NextResponse.json(payload); } diff --git a/app/api/submit/route.ts b/app/api/submit/route.ts index faf4822..7d101e0 100644 --- a/app/api/submit/route.ts +++ b/app/api/submit/route.ts @@ -3,18 +3,18 @@ * * Body: SubmitPayload { cid, cs, values }. * - * Verifies checksum, resolves org from cid (same as /api/data), then creates a - * new Org Engagement Submission activity with all visible-field values written - * to their custom-field bindings + stage_at_submission set to the org's - * current Framework Stage. + * Verifies checksum, resolves org from cid (mirror of /api/data), then + * creates a new `Check-in (organizing)` activity. The activity's own Stage + * field (`Check_in_data__organizing_.Stage`) is auto-populated from the + * org's current `Food_Co_op_Organizing.Stage` so each check-in carries a + * snapshot of where the org was at submission time. * - * STUB MODE: if CiviCRM env vars are unset, returns success without writing - * anywhere. Useful for UI dev. + * STUB MODE: if CiviCRM env vars are unset, returns success without writing. */ import { NextResponse } from "next/server"; import { civi, verifyChecksum } from "@/lib/civicrm"; -import { allFields } from "@/config/form"; +import { allFields, ACTIVITY_TYPE_NAME, ACTIVITY_STAGE_FIELD } from "@/config/form"; import type { SubmitPayload } from "@/types/form"; function isStubMode(): boolean { @@ -40,16 +40,13 @@ export async function POST(req: Request) { } if (isStubMode()) { - console.warn("[submit:STUB] would create Org Engagement Submission activity with values:", values); + console.warn("[submit:STUB] would create Check-in (organizing) activity with values:", values); return NextResponse.json({ ok: true, stub: true }); } const ok = await verifyChecksum(cid, cs); if (!ok) { - return NextResponse.json( - { error: "Link is invalid or has expired." }, - { status: 401 }, - ); + return NextResponse.json({ error: "Link is invalid or has expired." }, { status: 401 }); } // Resolve org via the relationship (mirror of /api/data). @@ -71,39 +68,37 @@ export async function POST(req: Request) { } const orgId = orgs[0].contact_id_b; - // Read the org's current Framework Stage so we can stamp `stage_at_submission`. - const orgRes = await civi<{ - "Food_Co_op_Organizing.Stage": string | null; - custom_1?: string | null; - }>("Contact", "get", { - select: ["custom_1", "Food_Co_op_Organizing.Stage"], - where: [["id", "=", orgId]], - }); - const stageAtSubmission = - orgRes.values?.[0]?.["Food_Co_op_Organizing.Stage"] ?? - orgRes.values?.[0]?.custom_1 ?? - null; + // Read the org's current Framework Stage so we can snapshot it on the activity. + const orgRes = await civi<{ "Food_Co_op_Organizing.Stage": string | null }>( + "Contact", + "get", + { + select: ["Food_Co_op_Organizing.Stage"], + where: [["id", "=", orgId]], + }, + ); + const stageAtSubmission = orgRes.values?.[0]?.["Food_Co_op_Organizing.Stage"] ?? null; // Build the activity record. Each form-side field name maps to its // configured `civiField` for the activity-side write. const activityRecord: Record = { - "activity_type_id:name": "Org Engagement Submission", + "activity_type_id:name": ACTIVITY_TYPE_NAME, "status_id:name": "Completed", target_contact_id: orgId, source_contact_id: Number(cid), + subject: "Co-op Check-in (form submission)", + [ACTIVITY_STAGE_FIELD]: stageAtSubmission, }; for (const [name, value] of Object.entries(values)) { const field = FIELD_BY_NAME.get(name); if (!field || !field.civiField) continue; if (field.type === "readonly") continue; // never write read-only fields + if (name === "current_stage") continue; // org-side, never written to activity + if (name === "stage_at_submission") continue; // handled above via ACTIVITY_STAGE_FIELD activityRecord[field.civiField] = value; } - // Stamp the stage-at-submission audit field. Convention: a field named - // `Submission_Audit.stage_at_submission` on the activity. Adjust to your - // actual machine name if different. - activityRecord["Submission_Audit.stage_at_submission"] = stageAtSubmission; - await civi("Activity", "create", { values: activityRecord }); + await civi("Activity", "create", { values: [activityRecord] }); return NextResponse.json({ ok: true }); } diff --git a/components/EngagementForm.tsx b/components/EngagementForm.tsx index a591f2b..7ef5b6e 100644 --- a/components/EngagementForm.tsx +++ b/components/EngagementForm.tsx @@ -162,6 +162,7 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) { formValues={formValues} isCurrent={section.rank === currentRank} defaultOpen={section.rank === currentRank || section.rank === 0} + options={load.data.options ?? {}} /> ); })} diff --git a/components/StageSection.tsx b/components/StageSection.tsx index 3c67045..9ee72ee 100644 --- a/components/StageSection.tsx +++ b/components/StageSection.tsx @@ -1,7 +1,7 @@ "use client"; import { useId, useState } from "react"; -import type { StageSectionConfig } from "@/types/form"; +import type { StageSectionConfig, SelectOption } from "@/types/form"; import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form"; import { FieldRenderer } from "./fields/FieldRenderer"; import { evaluate } from "@/lib/conditional"; @@ -16,6 +16,8 @@ interface StageSectionProps { isCurrent: boolean; /** Whether the section starts open. */ defaultOpen: boolean; + /** Option groups fetched from CiviCRM, keyed by option_group_id. */ + options: Record; } /** @@ -30,6 +32,7 @@ export function StageSection({ formValues, isCurrent, defaultOpen, + options, }: StageSectionProps) { const [open, setOpen] = useState(defaultOpen); const headingId = useId(); @@ -89,19 +92,24 @@ export function StageSection({

No fields are visible at this stage.

) : (
- {visibleFields.map((f) => ( -
- -
- ))} + {visibleFields.map((f) => { + const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined; + const wide = + f.type === "textarea" || + f.type === "boolean" || + f.type === "multiselect"; + return ( +
+ +
+ ); + })}
)} diff --git a/components/fields/FieldRenderer.tsx b/components/fields/FieldRenderer.tsx index 1100ab2..edebb7f 100644 --- a/components/fields/FieldRenderer.tsx +++ b/components/fields/FieldRenderer.tsx @@ -1,6 +1,6 @@ "use client"; -import type { FieldConfig } from "@/types/form"; +import type { FieldConfig, SelectOption } from "@/types/form"; import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form"; interface FieldRendererProps { @@ -9,6 +9,12 @@ interface FieldRendererProps { errors: FieldErrors; /** For readonly display fields, the value to render. */ readonlyValue?: unknown; + /** + * Options resolved at runtime (from /api/data). When `field.optionGroupId` + * is set, look up options here first; fall back to the field's hard-coded + * `options` array if absent. + */ + resolvedOptions?: SelectOption[]; } /** @@ -20,12 +26,19 @@ interface FieldRendererProps { * `step` and inputMode for mobile keyboards. We keep formatting light — the * server is the source of truth for normalization. */ -export function FieldRenderer({ field, register, errors, readonlyValue }: FieldRendererProps) { +export function FieldRenderer({ + field, + register, + errors, + readonlyValue, + resolvedOptions, +}: FieldRendererProps) { const id = `field-${field.name}`; const helpId = field.help ? `${id}-help` : undefined; const errorId = errors[field.name] ? `${id}-error` : undefined; const describedBy = [helpId, errorId].filter(Boolean).join(" ") || undefined; const errorMsg = errors[field.name]?.message as string | undefined; + const effectiveOptions = resolvedOptions ?? field.options ?? []; const baseInputClass = "w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-stone-900 " + @@ -36,7 +49,12 @@ export function FieldRenderer({ field, register, errors, readonlyValue }: FieldR // ── Readonly display field ────────────────────────────────────────────── if (field.type === "readonly") { - const display = readonlyValue == null || readonlyValue === "" ? "—" : String(readonlyValue); + // Look up the human label if this readonly references an option-group field. + const opt = effectiveOptions.find((o) => o.value === readonlyValue); + const display = + readonlyValue == null || readonlyValue === "" + ? "—" + : opt?.label ?? String(readonlyValue); return (