Wire real CiviCRM DEV custom fields and dynamic option fetching

This commit is contained in:
Joel Brock
2026-05-09 20:42:35 -07:00
parent 54555c74d2
commit c58a49be6d
7 changed files with 663 additions and 213 deletions

View File

@@ -2,36 +2,60 @@
* GET /api/data?cid=<cid>&cs=<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<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");
@@ -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);
}

View File

@@ -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<string, unknown> = {
"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 });
}

View File

@@ -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 ?? {}}
/>
);
})}

View File

@@ -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<number, SelectOption[]>;
}
/**
@@ -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({
<p className="text-sm italic text-stone-500">No fields are visible at this stage.</p>
) : (
<div className="grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-2">
{visibleFields.map((f) => (
<div
key={f.name}
className={f.type === "textarea" || f.type === "boolean" ? "md:col-span-2" : ""}
>
<FieldRenderer
field={f}
register={register}
errors={errors}
readonlyValue={formValues[f.name]}
/>
</div>
))}
{visibleFields.map((f) => {
const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined;
const wide =
f.type === "textarea" ||
f.type === "boolean" ||
f.type === "multiselect";
return (
<div key={f.name} className={wide ? "md:col-span-2" : ""}>
<FieldRenderer
field={f}
register={register}
errors={errors}
readonlyValue={formValues[f.name]}
resolvedOptions={resolvedOptions}
/>
</div>
);
})}
</div>
)}
</div>

View File

@@ -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 (
<div className="space-y-1">
<Label id={id} field={field} />
@@ -116,7 +134,7 @@ export function FieldRenderer({ field, register, errors, readonlyValue }: FieldR
<option value="" disabled>
Select
</option>
{(field.options ?? []).map((o) => (
{effectiveOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
@@ -128,6 +146,45 @@ export function FieldRenderer({ field, register, errors, readonlyValue }: FieldR
);
}
// ── Multiselect (rendered as a checkbox group) ─────────────────────────
if (field.type === "multiselect") {
return (
<fieldset
aria-describedby={describedBy}
aria-invalid={errorMsg ? true : undefined}
className="space-y-2"
>
<legend className="block text-sm font-medium text-stone-800">
{field.label}
{field.required && <RequiredMark />}
</legend>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{effectiveOptions.map((o, idx) => {
const optId = `${id}-${idx}`;
return (
<label
key={o.value}
htmlFor={optId}
className="flex items-start gap-2 rounded border border-stone-200 px-3 py-2 hover:bg-stone-50 cursor-pointer"
>
<input
id={optId}
type="checkbox"
value={o.value}
{...register(field.name)}
className="mt-0.5 h-4 w-4 rounded border-stone-400 text-leaf-700 focus:ring-2 focus:ring-leaf-500/40"
/>
<span className="text-sm text-stone-800">{o.label}</span>
</label>
);
})}
</div>
{field.help && <Help id={helpId!}>{field.help}</Help>}
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</fieldset>
);
}
// ── File ────────────────────────────────────────────────────────────────
if (field.type === "file") {
return (

View File

@@ -1,14 +1,19 @@
/**
* Form definition for the Org Engagement Check-in form.
*
* Fields and stages are sourced from the spec at
* docs/superpowers/specs/2026-05-08-civi-webform-design.md and the
* concrete field list provided 2026-05-09.
* Field references and types are sourced from the live CiviCRM DEV
* (`client.crm.fci.coop`) inventory pulled 2026-05-09 via APIv4
* `CustomField.get` filtered to the six custom groups bound to the
* `Check-in (organizing)` activity type:
* - Check_in_data__organizing_ (Stage 0)
* - Stage_1, Stage_2, Stage_3, Stage_4, Stage_5
*
* The `civiField` property on each field is currently a placeholder
* (`custom_<machine_name>`). Replace these with the actual CiviCRM custom
* field references (`custom_<id>` or `<group_name>.<field_name>`) once the
* field IDs are known. See README.md.
* `civiField` uses APIv4's `<group_name>.<field_name>` syntax.
*
* Fields with `optionGroupId` set have their `options` populated at runtime
* via `/api/data` — the server fetches OptionValue rows for those option
* groups and embeds them in the FormDataPayload. The hardcoded `options`
* arrays here are placeholder fallbacks if the live fetch fails.
*/
import type { FieldConfig, FormConfig, StageSectionConfig, VisibilityRule } from "@/types/form";
@@ -29,6 +34,15 @@ const visibleAtOrAfter = (...stages: string[]): VisibilityRule => ({
values: stages,
});
// CiviCRM custom group machine names per the APIv4 inventory.
const G0 = "Check_in_data__organizing_";
const G1 = "Stage_1";
const G2 = "Stage_2";
const G3 = "Stage_3";
const G4 = "Stage_4";
const G5 = "Stage_5";
// Stage 0 — Check-in (always visible)
const stage0: StageSectionConfig = {
rank: 0,
id: "stage_0",
@@ -36,175 +50,473 @@ const stage0: StageSectionConfig = {
intro:
"Core check-in fields. These are visible at every stage and capture the data we follow over time across the lifecycle of the co-op.",
fields: [
{ name: "current_stage", label: "Framework Stage", type: "readonly", civiField: "Food_Co_op_Organizing.Stage" },
{ name: "stage_0_peer_group_participation", label: "Peer Group Participation", type: "text" },
{ name: "stage_0_internal_startup_assessment", label: "Internal Startup Assessment", type: "textarea" },
{ name: "stage_0_internal_startup_assessment_date", label: "Internal Startup Assessment Date", type: "date" },
{ name: "stage_0_member_goal", label: "Member Goal for current Stage", type: "number" },
{ name: "stage_0_members_current", label: "Members (current)", type: "number" },
{ name: "stage_0_total_members_at_opening", label: "Total members at opening", type: "number" },
{ name: "stage_0_volunteers_helping_in_store", label: "Volunteers Helping In Store", type: "number" },
{ name: "stage_0_work_from_members", label: "Work from Members", type: "text", help: "Hours, dollars, or descriptive — your choice." },
{ name: "stage_0_total_square_ft", label: "Total square ft", type: "number" },
{ name: "stage_0_retail_sq_ft", label: "Retail sq ft", type: "number" },
{ name: "stage_0_latest_sources_uses_doc", label: "Latest Sources and Uses doc", type: "file" },
{ name: "stage_0_latest_pro_forma_doc", label: "Latest Pro Forma doc", type: "file" },
{ name: "stage_0_projected_y1_sales", label: "Projected Year 1 Sales", type: "currency" },
{ name: "stage_0_projected_y2_sales", label: "Projected Year 2 Sales", type: "currency" },
{ name: "stage_0_projected_y3_sales", label: "Projected Year 3 Sales", type: "currency" },
{ name: "stage_0_projected_sales_established", label: "Projected sales once established", type: "currency" },
{ name: "stage_0_projected_ftes", label: "Projected FTEs", type: "number", step: 0.1 },
{ name: "stage_0_actual_fte", label: "Actual FTE", type: "number", step: 0.1 },
{ name: "stage_0_total_cost_of_project", label: "Total cost of project", type: "currency" },
{ name: "stage_0_member_equity_total_needed", label: "Member equity total needed", type: "currency" },
{ name: "stage_0_member_equity_raised", label: "Member equity raised", type: "currency" },
{ name: "stage_0_member_loans_total_needed", label: "Member loans total needed", type: "currency" },
{ name: "stage_0_member_loans_raised", label: "Member loans raised", type: "currency" },
{ name: "stage_0_member_preferred_shares_total_needed", label: "Member preferred shares total needed", type: "currency" },
{ name: "stage_0_member_preferred_shares_raised", label: "Member preferred shares raised", type: "currency" },
{ name: "stage_0_bank_debt_total_needed", label: "Bank debt total needed", type: "currency" },
{ name: "stage_0_bank_debt_raised", label: "Bank debt raised", type: "currency" },
{ name: "stage_0_grant_donations_needed", label: "Grants / Donations Needed", type: "currency" },
{ name: "stage_0_grants_donations_raised", label: "Grants / Donations Raised", type: "currency" },
{ name: "stage_0_other_sources_total_needed", label: "Other sources total needed", type: "currency" },
{ name: "stage_0_other_sources_raised", label: "Other sources raised", type: "currency" },
{ name: "stage_0_date_closed_folded", label: "Date Closed / Folded", type: "date" },
].map((f) => ({ ...f, civiField: f.civiField ?? `custom_${f.name}` })) as FieldConfig[],
// current_stage is a special UI-only field — it shows the org's stage.
// It is NOT written back to the activity by the submit endpoint.
{
name: "current_stage",
label: "Framework Stage",
type: "readonly",
civiField: "Food_Co_op_Organizing.Stage",
},
{
name: "stage_at_submission",
label: "This check-in's stage",
type: "readonly",
civiField: `${G0}.Stage`,
help: "Set automatically to the org's current Framework Stage when you submit.",
},
{
name: "Peer_Group_Participation",
label: "Peer Group Participation",
type: "select",
civiField: `${G0}.Peer_Group_Participation`,
optionGroupId: 140,
help: "Is this co-op currently participating in Peer Learning Groups?",
},
{
name: "Internal_Startup_Assessment",
label: "Internal Startup Assessment",
type: "select",
civiField: `${G0}.Internal_Startup_Assessment`,
optionGroupId: 132,
},
{
name: "Internal_Startup_Assessment_Date",
label: "Internal Startup Assessment Date",
type: "date",
civiField: `${G0}.Internal_Startup_Assessment_Date`,
help: "Date of the latest Internal Startup Assessment rating.",
},
{
name: "Member_Goal_for_current_Stage",
label: "Member Goal for current Stage",
type: "number",
civiField: `${G0}.Member_Goal_for_current_Stage`,
help: "What is your member / owner goal for your current co-op development Stage?",
},
{
name: "Members__current_",
label: "Members (current)",
type: "number",
civiField: `${G0}.Members__current_`,
help: "How many paid members does the co-op currently have? Include partially paid (e.g. installment plans).",
},
{
name: "Total_members_at_opening",
label: "Total members at opening",
type: "number",
civiField: `${G0}.Total_members_at_opening`,
},
{
name: "Volunteers_Helping_In_Store",
label: "Volunteers Helping In Store",
type: "select",
civiField: `${G0}.Volunteers_Helping_In_Store`,
optionGroupId: 141,
help: "Does this co-op use volunteers for day-to-day operations? (Not occasional events.)",
},
{
name: "Work_from_Members",
label: "Work from Members",
type: "select",
civiField: `${G0}.Work_from_Members`,
optionGroupId: 142,
help: "Does this co-op require work from members (e.g. annual hours)?",
},
{
name: "Total_square_ft",
label: "Total square ft",
type: "number",
civiField: `${G0}.Total_square_ft`,
step: 1,
},
{
name: "Retail_sq_ft",
label: "Retail sq ft",
type: "number",
civiField: `${G0}.Retail_sq_ft`,
step: 1,
},
{
name: "Latest_Sources_and_Uses_doc",
label: "Latest Sources and Uses doc",
type: "file",
civiField: `${G0}.Latest_Sources_and_Uses_doc`,
},
{
name: "Latest_Pro_Forma_doc",
label: "Latest Pro Forma doc",
type: "file",
civiField: `${G0}.Latest_Pro_Forma_doc`,
},
{ name: "Projected_Year_1_Sales", label: "Projected Year 1 Sales", type: "currency", civiField: `${G0}.Projected_Year_1_Sales` },
{ name: "Projected_Year_2_Sales", label: "Projected Year 2 Sales", type: "currency", civiField: `${G0}.Projected_Year_2_Sales` },
{ name: "Projected_Year_3_Sales", label: "Projected Year 3 Sales", type: "currency", civiField: `${G0}.Projected_Year_3_Sales` },
{
name: "Projected_sales_at_maturity",
label: "Projected sales once established",
type: "currency",
civiField: `${G0}.Projected_sales_at_maturity`,
},
{
name: "FTEs",
label: "Projected FTEs",
type: "number",
civiField: `${G0}.FTEs`,
step: 0.1,
help: "Number of full-time equivalent (FTE) employees planned to work at the store.",
},
{
name: "Actual_FTE",
label: "Actual FTE",
type: "number",
civiField: `${G0}.Actual_FTE`,
step: 0.1,
},
{
name: "Total_cost_of_project",
label: "Total cost of project",
type: "currency",
civiField: `${G0}.Total_cost_of_project`,
},
{ name: "Member_equity_total", label: "Member equity total needed", type: "currency", civiField: `${G0}.Member_equity_total` },
{ name: "Member_equity_raised", label: "Member equity raised", type: "currency", civiField: `${G0}.Member_equity_raised` },
{ name: "Member_loans_total", label: "Member loans total needed", type: "currency", civiField: `${G0}.Member_loans_total` },
{ name: "Member_loans_raised", label: "Member loans raised", type: "currency", civiField: `${G0}.Member_loans_raised` },
{
name: "Member_preferred_shares_total",
label: "Member preferred shares total needed",
type: "currency",
civiField: `${G0}.Member_preferred_shares_total`,
},
{
name: "Member_preferred_shares_raised",
label: "Member preferred shares raised",
type: "currency",
civiField: `${G0}.Member_preferred_shares_raised`,
},
{ name: "Bank_debt_total", label: "Bank debt total needed", type: "currency", civiField: `${G0}.Bank_debt_total` },
{ name: "Bank_debt_raised", label: "Bank debt raised", type: "currency", civiField: `${G0}.Bank_debt_raised` },
{ name: "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: "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: "Date_Closed_Folded", label: "Date Closed / Folded", type: "date", civiField: `${G0}.Date_Closed_Folded` },
] as FieldConfig[],
};
// Stage 1 — Convene & Prepare (visible from Organizing onward)
const stage1: StageSectionConfig = {
rank: 1,
id: "stage_1",
label: "Stage 1 — Convene & Prepare",
label: "Stage 1 — Convene and Prepare",
visibleWhen: visibleAtOrAfter(S.Organizing, S.Feasibility, S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
fields: [
{ name: "stage_1_preliminary_market_assessment", label: "Preliminary Market Assessment", type: "textarea" },
{ name: "stage_1_preliminary_market_assessment_upload", label: "Preliminary Market Assessment — Upload", type: "file" },
{ name: "stage_1_preliminary_sources_uses", label: "Preliminary Sources And Uses", type: "textarea" },
{ name: "stage_1_preliminary_sources_uses_upload", label: "Preliminary Sources and Uses — Upload", type: "file" },
{ name: "stage_1_vision", label: "Vision", type: "textarea" },
{ name: "stage_1_vision_upload", label: "Vision — Upload", type: "file" },
{ name: "stage_1_business_concept", label: "Business Concept", type: "textarea" },
{ name: "stage_1_business_concept_upload", label: "Business Concept — Upload", type: "file" },
].map((f) => ({ ...f, civiField: `custom_${f.name}` })) as FieldConfig[],
{
name: "Preliminary_Market_Assessment",
label: "Preliminary Market Assessment",
type: "date",
civiField: `${G1}.Preliminary_Market_Assessment`,
help: "What date was your Preliminary Market Assessment completed?",
},
{
name: "Preliminary_Market_Assessment_Upload",
label: "Preliminary Market Assessment — Upload",
type: "file",
civiField: `${G1}.Preliminary_Market_Assessment_Upload`,
},
{
name: "Preliminary_Sources_Uses",
label: "Preliminary Sources And Uses",
type: "date",
civiField: `${G1}.Preliminary_Sources_Uses`,
help: "When was your Preliminary Sources & Uses completed?",
},
{
name: "Preliminary_Sources_Uses_Upload",
label: "Preliminary Sources and Uses — Upload",
type: "file",
civiField: `${G1}.Preliminary_Sources_Uses_Upload`,
},
{
name: "Vision",
label: "Vision",
type: "date",
civiField: `${G1}.Vision`,
help: "What date was your vision document completed?",
},
{ name: "Vision_Upload", label: "Vision — Upload", type: "file", civiField: `${G1}.Vision_Upload` },
{
name: "Business_Concept",
label: "Business Concept",
type: "date",
civiField: `${G1}.Business_Concept`,
help: "When was your business concept finalized?",
},
{
name: "Business_Concept_Upload",
label: "Business Concept — Upload",
type: "file",
civiField: `${G1}.Business_Concept_Upload`,
},
] as FieldConfig[],
};
// Stage 2 — Feasibility
const stage2: StageSectionConfig = {
rank: 2,
id: "stage_2",
label: "Stage 2 — Feasibility",
visibleWhen: visibleAtOrAfter(S.Feasibility, S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
fields: [
{ name: "stage_2_market_study_date", label: "Market Study Date", type: "date" },
{ name: "stage_2_market_study_upload", label: "Market Study — Upload", type: "file" },
{ name: "stage_2_pro_forma_date", label: "Pro Forma — date completed", type: "date" },
{ name: "stage_2_pro_forma_upload", label: "Pro Forma — Upload", type: "file" },
{ name: "Market_Study_Date", label: "Market Study Date", type: "date", civiField: `${G2}.Market_Study_Date` },
{ name: "Market_Study_Upload", label: "Market Study — Upload", type: "file", civiField: `${G2}.Market_Study_Upload` },
{ name: "Pro_Forma_date_completed", label: "Pro Forma — date completed", type: "date", civiField: `${G2}.Pro_Forma_date_completed` },
{ name: "Pro_Forma_Upload", label: "Pro Forma — Upload", type: "file", civiField: `${G2}.Pro_Forma_Upload` },
{
name: "stage_2_pro_forma_viability",
name: "Pro_Forma_Viability",
label: "Pro Forma Viability",
type: "select",
options: [
{ value: "viable", label: "Viable" },
{ value: "marginal", label: "Marginal" },
{ value: "not_viable", label: "Not viable" },
{ value: "tbd", label: "Not yet determined" },
],
civiField: `${G2}.Pro_Forma_Viability`,
optionGroupId: 133,
help: "Rate the viability of the project.",
},
{ name: "stage_2_business_plan", label: "Business Plan", type: "textarea" },
{ name: "stage_2_business_plan_upload", label: "Business Plan — Upload", type: "file" },
{ name: "stage_2_board_self_assessment", label: "Board Self Assessment", type: "textarea" },
{ name: "stage_2_board_self_assessment_upload", label: "Board Self Assessment — Upload", type: "file" },
{ name: "stage_2_governance_system", label: "Governance System Used", type: "text" },
].map((f) => ({ ...f, civiField: `custom_${f.name}` })) as FieldConfig[],
{
name: "Business_Plan",
label: "Business Plan",
type: "date",
civiField: `${G2}.Business_Plan`,
help: "What date was your most recent business plan completed?",
},
{ name: "Business_Plan_Upload", label: "Business Plan — Upload", type: "file", civiField: `${G2}.Business_Plan_Upload` },
{
name: "Board_Self_Assessment",
label: "Board Self Assessment",
type: "date",
civiField: `${G2}.Board_Self_Assessment`,
help: "When was your last Board Self Assessment completed?",
},
{
name: "Board_Self_Assessment_Upload",
label: "Board Self Assessment — Upload",
type: "file",
civiField: `${G2}.Board_Self_Assessment_Upload`,
},
{
name: "Governance_System_Used",
label: "Governance System Used",
type: "text",
civiField: `${G2}.Governance_System_Used`,
help: "What governance system does your board use, or how do you make decisions?",
},
] as FieldConfig[],
};
// Stage 3 — Connect & Gather
const stage3: StageSectionConfig = {
rank: 3,
id: "stage_3",
label: "Stage 3 — Connect & Gather",
label: "Stage 3 — Connect and Gather",
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
fields: [
{ name: "stage_3_site_loi_date", label: "Site: Letter of Intent Date", type: "date" },
{ name: "stage_3_site_loi_upload", label: "Site: Letter of Intent — Upload", type: "file" },
{ name: "stage_3_own_building_property", label: "Own the building / property", type: "boolean" },
{ name: "stage_3_capital_campaign_owner_participation_pct", label: "Capital Campaign: Owner Participation %", type: "percent" },
{ name: "stage_3_capital_campaign_avg_owner_investment", label: "Capital Campaign: Average Owner Investment", type: "currency" },
{ name: "stage_3_capital_stack", label: "Capital Stack", type: "textarea" },
{ name: "stage_3_project_manager_hire_date", label: "Project Manager — Date of Hire", type: "date" },
{ name: "stage_3_store_design_plan_completion", label: "Store Design — Plan Completion", type: "date" },
{ name: "stage_3_store_designer", label: "Store Designer", type: "text" },
{ name: "stage_3_ncg_member", label: "NCG Member", type: "boolean" },
{ name: "stage_3_ncg_corridor", label: "NCG Corridor", type: "text" },
{ name: "stage_3_infra_member", label: "INFRA Member", type: "boolean" },
{ name: "stage_3_other_distributors", label: "Other Distributors", type: "text" },
].map((f) => ({ ...f, civiField: `custom_${f.name}` })) as FieldConfig[],
{
name: "Site_Letter_of_Intent_Date",
label: "Site: Letter of Intent Date",
type: "date",
civiField: `${G3}.Site_Letter_of_Intent_Date`,
},
{
name: "Site_Letter_of_Intent_Upload",
label: "Site: Letter of Intent — Upload",
type: "file",
civiField: `${G3}.Site_Letter_of_Intent_Upload`,
},
{
name: "Own_the_building_property",
label: "Own the building / property",
type: "select",
civiField: `${G3}.Own_the_building_property`,
optionGroupId: 139,
help: "Will the co-op own the building or property?",
},
{
name: "Capital_Campaign_Owner_Participation_",
label: "Capital Campaign: Owner Participation %",
type: "percent",
civiField: `${G3}.Capital_Campaign_Owner_Participation_`,
},
{
name: "Capital_Campaign_Average_Owner_Investment",
label: "Capital Campaign: Average Owner Investment",
type: "currency",
civiField: `${G3}.Capital_Campaign_Average_Owner_Investment`,
},
{
name: "Capital_Stack",
label: "Capital Stack",
type: "multiselect",
civiField: `${G3}.Capital_Stack`,
optionGroupId: 134,
help: "Select all the types of funding that are part of your Sources.",
},
{
name: "Project_Manager_Date_of_Hire",
label: "Project Manager — Date of Hire",
type: "date",
civiField: `${G3}.Project_Manager_Date_of_Hire`,
},
{
name: "Store_Design_Plan_Completion",
label: "Store Design — Plan Completion",
type: "date",
civiField: `${G3}.Store_Design_Plan_Completion`,
},
{ name: "Store_Designer", label: "Store Designer", type: "text", civiField: `${G3}.Store_Designer` },
{ name: "NCG_Member", label: "NCG Member", type: "select", civiField: `${G3}.NCG_Member`, optionGroupId: 136 },
{ name: "NCG_Corridor", label: "NCG Corridor", type: "select", civiField: `${G3}.NCG_Corridor`, optionGroupId: 143 },
{ name: "INFRA_Member", label: "INFRA Member", type: "select", civiField: `${G3}.INFRA_Member`, optionGroupId: 137 },
{
name: "Other_Distributors",
label: "Other Distributors",
type: "select",
civiField: `${G3}.Other_Distributors`,
optionGroupId: 138,
help: "Are you using or considering one of these other distributors as your primary?",
},
] as FieldConfig[],
};
// Stage 4 — Excite & Build
const stage4: StageSectionConfig = {
rank: 4,
id: "stage_4",
label: "Stage 4 — Excite & Build",
label: "Stage 4 — Excite and Build",
visibleWhen: visibleAtOrAfter(S.StoreImplementation, S.Stabilize),
fields: [
{ name: "stage_4_projected_opening_date", label: "Projected Opening Date", type: "date" },
{ name: "stage_4_construction_completion_date", label: "Construction Completion Date", type: "date" },
{ name: "stage_4_mission_transition_plan_date", label: "Mission Transition Plan — Date", type: "date" },
{ name: "stage_4_mission_transition_plan_upload", label: "Mission Transition Plan — Upload", type: "file" },
{ name: "stage_4_gm_name", label: "General Manager — Name", type: "text" },
{ name: "stage_4_gm_phone", label: "General Manager Phone", type: "phone" },
{ name: "stage_4_gm_email", label: "General Manager Email", type: "email" },
{ name: "stage_4_gm_hire_date", label: "General Manager — Date of Hire", type: "date" },
{ name: "stage_4_gm_background", label: "General Manager Background", type: "textarea" },
{ name: "stage_4_gm_support_training", label: "GM Support and Training", type: "textarea" },
{ name: "stage_4_gm_support_team", label: "GM Support Team", type: "textarea" },
].map((f) => ({ ...f, civiField: `custom_${f.name}` })) as FieldConfig[],
{ name: "Projected_Opening_Date", label: "Projected Opening Date", type: "date", civiField: `${G4}.Projected_Opening_Date` },
{
name: "Construction_Completion_Date",
label: "Construction Completion Date",
type: "date",
civiField: `${G4}.Construction_Completion_Date`,
},
{
name: "Missions_Transition_Plan_Date",
label: "Mission Transition Plan — Date",
type: "date",
civiField: `${G4}.Missions_Transition_Plan_Date`,
},
{
name: "Mission_Transition_Plan_Upload",
label: "Mission Transition Plan — Upload",
type: "file",
civiField: `${G4}.Mission_Transition_Plan_Upload`,
},
{ name: "General_Manager_Name", label: "General Manager — Name", type: "text", civiField: `${G4}.General_Manager_Name` },
{ name: "General_Manager_Phone", label: "General Manager Phone", type: "phone", civiField: `${G4}.General_Manager_Phone` },
{ name: "GM_Email", label: "General Manager Email", type: "email", civiField: `${G4}.GM_Email` },
{
name: "General_Manager_Date_of_Hire",
label: "General Manager — Date of Hire",
type: "date",
civiField: `${G4}.General_Manager_Date_of_Hire`,
},
{
name: "General_Manager_Background",
label: "General Manager Background",
type: "select",
civiField: `${G4}.General_Manager_Background`,
optionGroupId: 135,
help: "What professional background does your General Manager have?",
},
{
name: "GM_Support_Training",
label: "GM Support and Training",
type: "text",
civiField: `${G4}.GM_Support_Training`,
help: "What training and/or onboarding support is being provided to your GM?",
},
{
name: "GM_Support_Team",
label: "GM Support Team",
type: "text",
civiField: `${G4}.GM_Support_Team`,
help: "Who is providing support and training to your GM?",
},
] as FieldConfig[],
};
// Stage 5 has 12 monthly + 4 quarterly trackers across multiple metrics; build programmatically.
const months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
const quarters = [1, 2, 3, 4];
// Stage 5 — Fulfill & Stabilize. Field machine names have historical
// inconsistencies; we list each one explicitly rather than building
// programmatically to keep the mapping auditable.
const STAGE_5_FIELDS: FieldConfig[] = [
{ name: "Date_Opened", label: "Date Opened", type: "date", civiField: `${G5}.Date_Opened` },
{ name: "Y1_Actual_Sales", label: "Y1 Actual Sales", type: "currency", civiField: `${G5}.Y1_Actual_Sales` },
// Y1 Monthly Sales Targets — note the irregular machine names!
{ name: "Y1_Monthly_Sales_Targets", label: "Y1 Monthly Sales Target: M1", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Targets` },
{ name: "Y1_Monthly_Sales_Targets_M2", label: "Y1 Monthly Sales Target: M2", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Targets_M2` },
{ name: "Y1_Monthly_Sales_Target_M2", label: "Y1 Monthly Sales Target: M3", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Target_M2`, help: "(Civi field name says M2 but represents M3.)" },
{ name: "Y1_Monthly_Sales_Target_M4", label: "Y1 Monthly Sales Target: M4", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Target_M4` },
{ name: "Y1_Monthly_Sales_Target_M5", label: "Y1 Monthly Sales Target: M5", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Target_M5` },
{ name: "Y1_Monthly_Sales_Target_M6", label: "Y1 Monthly Sales Target: M6", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Target_M6` },
{ name: "Y1_Monthly_Sales_Target_M7", label: "Y1 Monthly Sales Target: M7", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Target_M7` },
{ name: "Y1_Monthly_Sales_Target_M8", label: "Y1 Monthly Sales Target: M8", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Target_M8` },
{ name: "Y1_Monthly_Sales_Target_M9", label: "Y1 Monthly Sales Target: M9", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Target_M9` },
{ name: "Y1_Monthly_Sales_Target_M10", label: "Y1 Monthly Sales Target: M10", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Target_M10` },
{ name: "Y1_Monthly_Sales_Target_M11", label: "Y1 Monthly Sales Target: M11", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Target_M11` },
{ name: "Y1_Monthly_Sales_Target_M12", label: "Y1 Monthly Sales Target: M12", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Target_M12` },
// Y1 Actual Sales (M1..M12)
...[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((m) => ({
name: `Y1_M${m}_Actual_Sales`,
label: `Y1 M${m} Actual Sales`,
type: "currency" as const,
civiField: `${G5}.Y1_M${m}_Actual_Sales`,
})),
// Y1 Transactions (M1..M12)
...[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((m) => ({
name: `Y1_M${m}_Transactions`,
label: `Y1 M${m} Transactions`,
type: "number" as const,
civiField: `${G5}.Y1_M${m}_Transactions`,
})),
// Y1 Quarterly Labor (Q1..Q4) — stored as % of sales
...[1, 2, 3, 4].map((q) => ({
name: `Y1_Q${q}_Labor`,
label: `Y1 Q${q} Labor`,
type: "percent" as const,
civiField: `${G5}.Y1_Q${q}_Labor`,
help: `Labor, as a percent of sales for Y1 Q${q}.`,
})),
// Y1 Quarterly Margin (Q1..Q4)
...[1, 2, 3, 4].map((q) => ({
name: `Y1_Q${q}_Margin`,
label: `Y1 Q${q} Margin`,
type: "percent" as const,
civiField: `${G5}.Y1_Q${q}_Margin`,
help: `Margin, as a percent of sales, for Y1 Q${q}.`,
})),
// Y1 Quarterly Member Sales % (Q1..Q4) — note the trailing underscore in Civi machine names!
...[1, 2, 3, 4].map((q) => ({
name: `Y1_Q${q}_Member_Sales_`,
label: `Y1 Q${q} Member Sales %`,
type: "percent" as const,
civiField: `${G5}.Y1_Q${q}_Member_Sales_`,
help: `Percentage of sales to member-owners in Y1 Q${q}.`,
})),
];
const stage5: StageSectionConfig = {
rank: 5,
id: "stage_5",
label: "Stage 5 — Fulfill & Stabilize",
label: "Stage 5 — Fulfill and Stabilize",
visibleWhen: visibleAtOrAfter(S.Stabilize),
fields: [
{ name: "stage_5_date_opened", label: "Date Opened", type: "date" as const },
{ name: "stage_5_y1_actual_sales", label: "Y1 Actual Sales", type: "currency" as const },
...months.map((m) => ({
name: `stage_5_y1_target_m${m}`,
label: `Y1 Monthly Sales Target: M${m}`,
type: "currency" as const,
})),
...months.map((m) => ({
name: `stage_5_y1_actual_m${m}`,
label: `Y1 M${m} Actual Sales`,
type: "currency" as const,
})),
...months.map((m) => ({
name: `stage_5_y1_transactions_m${m}`,
label: `Y1 M${m} Transactions`,
type: "number" as const,
})),
...quarters.map((q) => ({
name: `stage_5_y1_q${q}_labor`,
label: `Y1 Q${q} Labor`,
type: "number" as const,
step: 0.1,
help: "FTEs",
})),
...quarters.map((q) => ({
name: `stage_5_y1_q${q}_margin`,
label: `Y1 Q${q} Margin`,
type: "percent" as const,
})),
...quarters.map((q) => ({
name: `stage_5_y1_q${q}_member_sales_pct`,
label: `Y1 Q${q} Member Sales %`,
type: "percent" as const,
})),
].map((f) => ({ ...f, civiField: `custom_${f.name}` })) as FieldConfig[],
fields: STAGE_5_FIELDS,
};
export const formConfig: FormConfig = {
@@ -218,3 +530,18 @@ export const formConfig: FormConfig = {
/** Convenience: flatten all field configs across all sections for lookup by name. */
export const allFields = formConfig.sections.flatMap((s) => s.fields);
/** Activity type the form writes its submissions as. */
export const ACTIVITY_TYPE_NAME = "Check-in (organizing)";
/** Stage-snapshot field on the activity (so the form's stage_at_submission writes here). */
export const ACTIVITY_STAGE_FIELD = `${G0}.Stage`;
/** All distinct option_group_ids referenced by fields in this form. */
export const optionGroupIds = Array.from(
new Set(
allFields
.map((f) => (f as FieldConfig).optionGroupId)
.filter((id): id is number => typeof id === "number"),
),
);

View File

@@ -78,13 +78,17 @@ export interface FieldConfig {
/** Visibility rule. If absent, field is always visible (within its visible section). */
visibleWhen?: VisibilityRule;
/**
* The CiviCRM custom-field reference, if this maps to a custom field on the
* Org Engagement Submission activity. Format: `custom_<id>` or
* `custom_<group_name>.<field_name>` depending on APIv4 conventions.
* The CiviCRM custom-field reference. APIv4 format: `<group_name>.<field_name>`.
* Leave undefined for fields that don't write back to Civi (e.g. transient
* UI helpers).
*/
civiField?: string;
/**
* If this field's options come from a CiviCRM option group, set its ID here.
* `/api/data` will fetch the option-group values and embed them in
* FormDataPayload.options so the renderer can use real CRM-defined choices.
*/
optionGroupId?: number;
}
export interface StageSectionConfig {
@@ -134,6 +138,12 @@ export interface FormDataPayload {
* Fields with no prior value are simply absent from this map.
*/
prefill: Record<string, unknown>;
/**
* Option-group values fetched at request time, keyed by option_group_id.
* Each entry is a list of {value, label}. The form merges these into
* field configs whose `optionGroupId` matches.
*/
options?: Record<number, SelectOption[]>;
}
/**