Compare commits
2
Commits
2e1244aa47
...
229ef51537
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
229ef51537 | ||
|
|
bd859ce906 |
@@ -19,6 +19,7 @@ function sectionForField(
|
|||||||
import { evaluate } from "@/lib/conditional";
|
import { evaluate } from "@/lib/conditional";
|
||||||
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
|
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
|
||||||
import { StageSection } from "./StageSection";
|
import { StageSection } from "./StageSection";
|
||||||
|
import { FieldRenderer } from "./fields/FieldRenderer";
|
||||||
import { loadDraft, saveDraft, clearDraft } from "@/lib/draft";
|
import { loadDraft, saveDraft, clearDraft } from "@/lib/draft";
|
||||||
|
|
||||||
interface EngagementFormProps {
|
interface EngagementFormProps {
|
||||||
@@ -241,6 +242,26 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
|
|
||||||
const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
|
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.
|
||||||
|
// The submitter-info section (rank: -1) lives outside the stage pathway —
|
||||||
|
// it renders directly above the section list (see JSX below), not inside
|
||||||
|
// the rail/marker accordion model used for Stage 0..5. Filter it out here
|
||||||
|
// so its rank doesn't pollute the past/current/future calculation.
|
||||||
|
const submitterSection = useMemo(
|
||||||
|
() => config.sections.find((s) => s.id === "submitter_info"),
|
||||||
|
[config.sections],
|
||||||
|
);
|
||||||
|
const stageSections = useMemo(
|
||||||
|
() => config.sections.filter((s) => s.id !== "submitter_info"),
|
||||||
|
[config.sections],
|
||||||
|
);
|
||||||
|
|
||||||
// Section pathway state — past / current / future is determined entirely
|
// Section pathway state — past / current / future is determined entirely
|
||||||
// by stage rank vs the org's current rank. Sections never hide; future
|
// 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.
|
// stages render in a locked treatment so users can preview what's ahead.
|
||||||
@@ -250,12 +271,12 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
// stage's data never gets accidentally written.
|
// stage's data never gets accidentally written.
|
||||||
const sectionsToRender = useMemo(
|
const sectionsToRender = useMemo(
|
||||||
() =>
|
() =>
|
||||||
config.sections.map((s) => {
|
stageSections.map((s) => {
|
||||||
const pathwayState: PathwayState =
|
const pathwayState: PathwayState =
|
||||||
s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "future";
|
s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "future";
|
||||||
return { section: s, pathwayState, locked: pathwayState === "future" };
|
return { section: s, pathwayState, locked: pathwayState === "future" };
|
||||||
}),
|
}),
|
||||||
[config.sections, currentRank],
|
[stageSections, currentRank],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track which sections the user has manually opened/closed so we can
|
// Track which sections the user has manually opened/closed so we can
|
||||||
@@ -398,6 +419,38 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
}} />
|
}} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{submitterSection && (
|
||||||
|
<section
|
||||||
|
aria-labelledby="submitter-info-heading"
|
||||||
|
className="rounded-2xl border border-rule bg-paper p-4 sm:p-5"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id="submitter-info-heading"
|
||||||
|
className="font-display text-lg font-medium text-ink"
|
||||||
|
>
|
||||||
|
{submitterSection.label}
|
||||||
|
</h2>
|
||||||
|
{submitterSection.intro && (
|
||||||
|
<p className="mt-1 text-sm text-ink-soft">{submitterSection.intro}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
{submitterSection.fields.map((f) => (
|
||||||
|
<FieldRenderer
|
||||||
|
key={f.name}
|
||||||
|
field={f}
|
||||||
|
register={register}
|
||||||
|
setValue={setValue}
|
||||||
|
control={control}
|
||||||
|
errors={errors}
|
||||||
|
cid={cid}
|
||||||
|
cs={cs}
|
||||||
|
onUploadStateChange={handleUploadStateChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<ol className="relative space-y-5 md:pl-12">
|
<ol className="relative space-y-5 md:pl-12">
|
||||||
{sectionsToRender.map((entry, i) => {
|
{sectionsToRender.map((entry, i) => {
|
||||||
const { section, pathwayState, locked } = entry;
|
const { section, pathwayState, locked } = entry;
|
||||||
|
|||||||
+132
-59
@@ -121,6 +121,16 @@ export function StaffReportView({
|
|||||||
const membersField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_ACTUAL_NAME);
|
const membersField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_ACTUAL_NAME);
|
||||||
const goalField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_GOAL_NAME);
|
const goalField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_GOAL_NAME);
|
||||||
|
|
||||||
|
// Most recent Survey_completed_by / _email values from the activity history.
|
||||||
|
// Activities are returned newest-first by /api/staff/report, so the first
|
||||||
|
// non-empty history entry on each field is "most recent."
|
||||||
|
const submitterName = pickLatestText(
|
||||||
|
checkInSection?.fields.find((f) => f.descriptor.name === "Survey_completed_by"),
|
||||||
|
);
|
||||||
|
const submitterEmail = pickLatestText(
|
||||||
|
checkInSection?.fields.find((f) => f.descriptor.name === "Survey_completed_by_email"),
|
||||||
|
);
|
||||||
|
|
||||||
const stageLabel = data.currentStage
|
const stageLabel = data.currentStage
|
||||||
? data.options[STAGE_OPTION_GROUP_ID]?.find((o) => o.value === data.currentStage)?.label
|
? data.options[STAGE_OPTION_GROUP_ID]?.find((o) => o.value === data.currentStage)?.label
|
||||||
?? data.currentStage
|
?? data.currentStage
|
||||||
@@ -149,6 +159,25 @@ export function StaffReportView({
|
|||||||
/>
|
/>
|
||||||
<Stat label="Org id" value={<code className="font-mono">{data.orgId}</code>} />
|
<Stat label="Org id" value={<code className="font-mono">{data.orgId}</code>} />
|
||||||
</dl>
|
</dl>
|
||||||
|
{(submitterName || submitterEmail) && (
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.16em] text-ink-mute">
|
||||||
|
Most recent submitter
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-ink">{submitterName ?? "—"}</span>
|
||||||
|
{submitterEmail && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<a
|
||||||
|
href={`mailto:${submitterEmail}`}
|
||||||
|
className="text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-leaf-700"
|
||||||
|
>
|
||||||
|
{submitterEmail}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="h-px bg-rule" />
|
<div className="h-px bg-rule" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -251,13 +280,20 @@ function StaffSection({
|
|||||||
const empty = section.fields.filter((f) => f.history.length === 0);
|
const empty = section.fields.filter((f) => f.history.length === 0);
|
||||||
const [showEmpty, setShowEmpty] = useState(false);
|
const [showEmpty, setShowEmpty] = useState(false);
|
||||||
|
|
||||||
// Stage 5 Y1 matrix: pull Labor / Margin / Member_Sales quarterly fields
|
// Stage 5 Y1 matrices: pull quarterly (Y1_Q*) and monthly (Y1_M*) field
|
||||||
// out into a single tabular display matching the form's matrix layout.
|
// series out into compact tabular displays that mirror the form's matrix
|
||||||
|
// layout. Anything not consumed by a matrix falls through to the regular
|
||||||
|
// per-field list below.
|
||||||
const isStage5 = section.groupName === "Stage_5";
|
const isStage5 = section.groupName === "Stage_5";
|
||||||
const matrixFields = isStage5 ? collectY1MatrixFields(filled) : null;
|
const quarterlyMatrix = isStage5 ? collectY1MatrixByPeriod(filled, "Q") : null;
|
||||||
|
const monthlyMatrix = isStage5 ? collectY1MatrixByPeriod(filled, "M") : null;
|
||||||
|
const matrixUsedNames = new Set<string>([
|
||||||
|
...(quarterlyMatrix?.usedNames ?? []),
|
||||||
|
...(monthlyMatrix?.usedNames ?? []),
|
||||||
|
]);
|
||||||
const filledOutsideMatrix =
|
const filledOutsideMatrix =
|
||||||
matrixFields
|
matrixUsedNames.size > 0
|
||||||
? filled.filter((f) => !matrixFields.usedNames.has(f.descriptor.name))
|
? filled.filter((f) => !matrixUsedNames.has(f.descriptor.name))
|
||||||
: filled;
|
: filled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -277,12 +313,11 @@ function StaffSection({
|
|||||||
{filled.length} with data
|
{filled.length} with data
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{matrixFields ? (
|
{quarterlyMatrix ? (
|
||||||
<Y1MatrixTable
|
<Y1MatrixTable data={quarterlyMatrix} options={options} />
|
||||||
rows={matrixFields.rows}
|
) : null}
|
||||||
quarters={matrixFields.quarters}
|
{monthlyMatrix ? (
|
||||||
options={options}
|
<Y1MatrixTable data={monthlyMatrix} options={options} />
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{filledOutsideMatrix.length > 0 ? (
|
{filledOutsideMatrix.length > 0 ? (
|
||||||
@@ -349,41 +384,58 @@ function CompactFieldRow({
|
|||||||
const earlier = field.history.slice(1);
|
const earlier = field.history.slice(1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="px-3 py-2">
|
<li className="px-3 py-3">
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||||
<span className="text-[13px] font-medium text-ink">{field.descriptor.label}</span>
|
<span className="text-[13px] font-medium text-ink">{field.descriptor.label}</span>
|
||||||
<div className="text-[13px] text-ink-soft">
|
<div className="flex flex-col items-end gap-0.5 text-right">
|
||||||
<FieldValue field={field} entry={latest} options={options} civiBaseUrl={civiBaseUrl} />
|
<span className="font-display text-xl font-medium leading-snug text-leaf-800 tabular-nums">
|
||||||
|
<FieldValue
|
||||||
|
field={field}
|
||||||
|
entry={latest}
|
||||||
|
options={options}
|
||||||
|
civiBaseUrl={civiBaseUrl}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
{latest.date ? (
|
{latest.date ? (
|
||||||
<span className="ml-2 text-[11px] text-ink-soft">
|
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||||||
· {formatShortDate(latest.date)}
|
as of {formatShortDate(latest.date)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{earlier.length > 0 ? (
|
{earlier.length > 0 ? (
|
||||||
<div className="mt-1">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="text-[11px] uppercase tracking-[0.14em] text-leaf-700 hover:underline"
|
aria-expanded={open}
|
||||||
|
className="text-[10px] font-medium text-leaf-700 hover:text-leaf-800 hover:underline focus:outline-none focus-visible:underline"
|
||||||
>
|
>
|
||||||
{open ? "Hide" : `${earlier.length} earlier ${earlier.length === 1 ? "entry" : "entries"}`}
|
{open
|
||||||
|
? "Hide earlier entries"
|
||||||
|
: `${earlier.length} earlier ${earlier.length === 1 ? "entry" : "entries"}`}
|
||||||
</button>
|
</button>
|
||||||
{open ? (
|
) : null}
|
||||||
<ul className="mt-1 space-y-1 border-l border-rule pl-3">
|
</div>
|
||||||
|
</div>
|
||||||
|
{open && earlier.length > 0 ? (
|
||||||
|
<ol className="mt-2 ml-auto max-w-[24rem] space-y-1 border-l border-rule pl-3">
|
||||||
{earlier.map((e, i) => (
|
{earlier.map((e, i) => (
|
||||||
<li
|
<li
|
||||||
key={`${e.activityId}-${e.date}-${i}`}
|
key={`${e.activityId}-${e.date}-${i}`}
|
||||||
className="flex items-baseline justify-between gap-x-4 text-[12px] text-ink-soft"
|
className="flex items-baseline justify-between gap-x-4"
|
||||||
>
|
>
|
||||||
<FieldValue field={field} entry={e} options={options} civiBaseUrl={civiBaseUrl} />
|
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||||||
<span className="text-[11px] text-ink-soft">{formatShortDate(e.date)}</span>
|
{formatShortDate(e.date)}
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-base font-medium text-ink-soft tabular-nums">
|
||||||
|
<FieldValue
|
||||||
|
field={field}
|
||||||
|
entry={e}
|
||||||
|
options={options}
|
||||||
|
civiBaseUrl={civiBaseUrl}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ol>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -427,6 +479,21 @@ function FieldValue({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the most recent non-empty string value from a field's history, or
|
||||||
|
* undefined if the field is missing or every entry is empty. Used to surface
|
||||||
|
* the latest submitter name / email at the top of the report.
|
||||||
|
*/
|
||||||
|
function pickLatestText(field: StaffReportField | undefined): string | undefined {
|
||||||
|
if (!field) return undefined;
|
||||||
|
for (const e of field.history) {
|
||||||
|
if (e.value === null || e.value === undefined) continue;
|
||||||
|
const s = String(e.value).trim();
|
||||||
|
if (s) return s;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function fieldConfigFor(f: StaffReportField): FieldConfig {
|
function fieldConfigFor(f: StaffReportField): FieldConfig {
|
||||||
return {
|
return {
|
||||||
name: f.descriptor.name,
|
name: f.descriptor.name,
|
||||||
@@ -461,41 +528,47 @@ function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stage 5 Y1 matrix: detect fields whose names match Y1_Q<n>_<metric> and
|
* Stage 5 Y1 matrix: detect fields whose names match Y1_<P><n>_<metric> for a
|
||||||
* group them into a read-only table mirroring the form's matrix layout.
|
* given period letter (Q for quarterly, M for monthly) and group them into a
|
||||||
* The metric set is whatever's actually present in the data so a Civi
|
* read-only table mirroring the form's matrix layout. The metric set is
|
||||||
* schema addition (e.g. Y1_Q*_Labor_Hours) appears automatically.
|
* whatever's actually present in the data, so a Civi schema addition (e.g.
|
||||||
|
* Y1_M*_Labor_Hours) appears automatically.
|
||||||
*/
|
*/
|
||||||
interface Y1MatrixRow {
|
interface Y1MatrixRow {
|
||||||
metric: string; // e.g. "Labor" | "Margin" | "Member_Sales_"
|
metric: string; // e.g. "Labor" | "Margin" | "Member_Sales_"
|
||||||
label: string; // human label from the first field's descriptor (sans Y1_Q<n>_ prefix)
|
label: string; // human label from the first field's descriptor (sans Y1_<P><n>_ prefix)
|
||||||
byQuarter: Map<number, StaffReportField>;
|
byPeriod: Map<number, StaffReportField>;
|
||||||
}
|
}
|
||||||
interface Y1MatrixData {
|
interface Y1MatrixData {
|
||||||
rows: Y1MatrixRow[];
|
rows: Y1MatrixRow[];
|
||||||
quarters: number[];
|
periods: number[];
|
||||||
|
periodLetter: "Q" | "M";
|
||||||
usedNames: Set<string>;
|
usedNames: Set<string>;
|
||||||
}
|
}
|
||||||
function collectY1MatrixFields(filled: StaffReportField[]): Y1MatrixData | null {
|
function collectY1MatrixByPeriod(
|
||||||
const re = /^Y1_Q(\d+)_(.+)$/;
|
filled: StaffReportField[],
|
||||||
|
periodLetter: "Q" | "M",
|
||||||
|
): Y1MatrixData | null {
|
||||||
|
const nameRe = new RegExp(`^Y1_${periodLetter}(\\d+)_(.+)$`);
|
||||||
|
const labelStripRe = new RegExp(`^Y1\\s*${periodLetter}\\d+\\s*`, "i");
|
||||||
const used = new Set<string>();
|
const used = new Set<string>();
|
||||||
const byMetric = new Map<string, Map<number, StaffReportField>>();
|
const byMetric = new Map<string, Map<number, StaffReportField>>();
|
||||||
const quartersSet = new Set<number>();
|
const periodsSet = new Set<number>();
|
||||||
const metricLabel = new Map<string, string>();
|
const metricLabel = new Map<string, string>();
|
||||||
|
|
||||||
for (const f of filled) {
|
for (const f of filled) {
|
||||||
const m = re.exec(f.descriptor.name);
|
const m = nameRe.exec(f.descriptor.name);
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
const quarter = Number(m[1]);
|
const period = Number(m[1]);
|
||||||
const metric = m[2];
|
const metric = m[2];
|
||||||
used.add(f.descriptor.name);
|
used.add(f.descriptor.name);
|
||||||
quartersSet.add(quarter);
|
periodsSet.add(period);
|
||||||
if (!byMetric.has(metric)) byMetric.set(metric, new Map());
|
if (!byMetric.has(metric)) byMetric.set(metric, new Map());
|
||||||
byMetric.get(metric)!.set(quarter, f);
|
byMetric.get(metric)!.set(period, f);
|
||||||
if (!metricLabel.has(metric)) {
|
if (!metricLabel.has(metric)) {
|
||||||
// Strip "Y1 Q<n> " prefix variants from the label if present.
|
// Strip the "Y1 Q<n> " / "Y1 M<n> " prefix from the label if present.
|
||||||
const cleaned = f.descriptor.label
|
const cleaned = f.descriptor.label
|
||||||
.replace(/^Y1\s*Q\d+\s*/i, "")
|
.replace(labelStripRe, "")
|
||||||
.replace(/_/g, " ")
|
.replace(/_/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
metricLabel.set(metric, cleaned || metric.replace(/_/g, " "));
|
metricLabel.set(metric, cleaned || metric.replace(/_/g, " "));
|
||||||
@@ -503,35 +576,35 @@ function collectY1MatrixFields(filled: StaffReportField[]): Y1MatrixData | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (byMetric.size === 0) return null;
|
if (byMetric.size === 0) return null;
|
||||||
const quarters = Array.from(quartersSet).sort((a, b) => a - b);
|
const periods = Array.from(periodsSet).sort((a, b) => a - b);
|
||||||
const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byQuarter]) => ({
|
const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byPeriod]) => ({
|
||||||
metric,
|
metric,
|
||||||
label: metricLabel.get(metric) ?? metric,
|
label: metricLabel.get(metric) ?? metric,
|
||||||
byQuarter,
|
byPeriod,
|
||||||
}));
|
}));
|
||||||
return { rows, quarters, usedNames: used };
|
return { rows, periods, periodLetter, usedNames: used };
|
||||||
}
|
}
|
||||||
|
|
||||||
function Y1MatrixTable({
|
function Y1MatrixTable({
|
||||||
rows,
|
data,
|
||||||
quarters,
|
|
||||||
options,
|
options,
|
||||||
}: {
|
}: {
|
||||||
rows: Y1MatrixRow[];
|
data: Y1MatrixData;
|
||||||
quarters: number[];
|
|
||||||
options: Record<number, SelectOption[]>;
|
options: Record<number, SelectOption[]>;
|
||||||
}) {
|
}) {
|
||||||
|
const { rows, periods, periodLetter } = data;
|
||||||
|
const cadence = periodLetter === "Q" ? "quarterly" : "monthly";
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto rounded-md border border-rule bg-paper">
|
<div className="overflow-x-auto rounded-md border border-rule bg-paper">
|
||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
<caption className="px-3 pt-2 text-left text-[11px] uppercase tracking-[0.16em] text-ink-soft">
|
<caption className="px-3 pt-2 text-left text-[11px] uppercase tracking-[0.16em] text-ink-soft">
|
||||||
Year 1 quarterly · latest values
|
Year 1 {cadence} · latest values
|
||||||
</caption>
|
</caption>
|
||||||
<thead className="text-left text-[11px] uppercase tracking-[0.14em] text-ink-soft">
|
<thead className="text-left text-[11px] uppercase tracking-[0.14em] text-ink-soft">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 font-medium">Metric</th>
|
<th className="px-3 py-2 font-medium">Metric</th>
|
||||||
{quarters.map((q) => (
|
{periods.map((p) => (
|
||||||
<th key={q} className="px-3 py-2 font-medium">Q{q}</th>
|
<th key={p} className="px-3 py-2 font-medium">{periodLetter}{p}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -539,11 +612,11 @@ function Y1MatrixTable({
|
|||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.metric}>
|
<tr key={row.metric}>
|
||||||
<td className="px-3 py-2 text-[13px] text-ink">{row.label}</td>
|
<td className="px-3 py-2 text-[13px] text-ink">{row.label}</td>
|
||||||
{quarters.map((q) => {
|
{periods.map((p) => {
|
||||||
const f = row.byQuarter.get(q);
|
const f = row.byPeriod.get(p);
|
||||||
const latest = f?.history[0];
|
const latest = f?.history[0];
|
||||||
return (
|
return (
|
||||||
<td key={q} className="px-3 py-2 text-[13px] text-ink-soft tabular-nums">
|
<td key={p} className="px-3 py-2 text-[13px] text-ink-soft tabular-nums">
|
||||||
{f && latest ? (
|
{f && latest ? (
|
||||||
<FormattedValue
|
<FormattedValue
|
||||||
value={latest.value}
|
value={latest.value}
|
||||||
|
|||||||
+31
-1
@@ -54,6 +54,36 @@ const G5 = "Stage_5";
|
|||||||
// route through Contact.update on submit and Contact.get at form load.
|
// route through Contact.update on submit and Contact.get at form load.
|
||||||
const G_ORG = "Food_Co_op_Organizing";
|
const G_ORG = "Food_Co_op_Organizing";
|
||||||
|
|
||||||
|
// Submitter information — captured on every check-in. Rendered above the
|
||||||
|
// stage pathway (see EngagementForm) so it's not threaded into the past /
|
||||||
|
// current / future stage rail. Both fields are required on every submission.
|
||||||
|
// rank: -1 keeps the section out of the rank-based pathway computation;
|
||||||
|
// EngagementForm filters this section out of `sectionsToRender` and renders
|
||||||
|
// it directly via FieldRenderer above the stage list.
|
||||||
|
const submitterInfo: StageSectionConfig = {
|
||||||
|
rank: -1,
|
||||||
|
id: "submitter_info",
|
||||||
|
label: "Survey submitter",
|
||||||
|
intro:
|
||||||
|
"Who's filling this out? We log this with each submission so we can follow up if needed.",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "Survey_completed_by",
|
||||||
|
label: "Survey completed by",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
civiField: `${G0}.Survey_completed_by`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Survey_completed_by_email",
|
||||||
|
label: "Survey completed by — email",
|
||||||
|
type: "email",
|
||||||
|
required: true,
|
||||||
|
civiField: `${G0}.Survey_completed_by_email`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
// Stage 0 — Survey (always visible)
|
// Stage 0 — Survey (always visible)
|
||||||
const stage0: StageSectionConfig = {
|
const stage0: StageSectionConfig = {
|
||||||
rank: 0,
|
rank: 0,
|
||||||
@@ -753,7 +783,7 @@ export const formConfig: FormConfig = {
|
|||||||
subtitle:
|
subtitle:
|
||||||
"Thank you for updating your co-op information. The questions you'll see depend on where you are in the Framework.",
|
"Thank you for updating your co-op information. The questions you'll see depend on where you are in the Framework.",
|
||||||
stageField: "current_stage",
|
stageField: "current_stage",
|
||||||
sections: [stage0, stage1, stage2, stage3, stage4, stage5],
|
sections: [submitterInfo, stage0, stage1, stage2, stage3, stage4, stage5],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Convenience: flatten all field configs across all sections for lookup by name. */
|
/** Convenience: flatten all field configs across all sections for lookup by name. */
|
||||||
|
|||||||
Reference in New Issue
Block a user