Compare commits
2
Commits
2e1244aa47
...
229ef51537
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
229ef51537 | ||
|
|
bd859ce906 |
@@ -19,6 +19,7 @@ function sectionForField(
|
||||
import { evaluate } from "@/lib/conditional";
|
||||
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
|
||||
import { StageSection } from "./StageSection";
|
||||
import { FieldRenderer } from "./fields/FieldRenderer";
|
||||
import { loadDraft, saveDraft, clearDraft } from "@/lib/draft";
|
||||
|
||||
interface EngagementFormProps {
|
||||
@@ -241,6 +242,26 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
||||
|
||||
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
|
||||
// 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.
|
||||
@@ -250,12 +271,12 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
||||
// stage's data never gets accidentally written.
|
||||
const sectionsToRender = useMemo(
|
||||
() =>
|
||||
config.sections.map((s) => {
|
||||
stageSections.map((s) => {
|
||||
const pathwayState: PathwayState =
|
||||
s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "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
|
||||
@@ -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">
|
||||
{sectionsToRender.map((entry, i) => {
|
||||
const { section, pathwayState, locked } = entry;
|
||||
|
||||
+142
-69
@@ -121,6 +121,16 @@ export function StaffReportView({
|
||||
const membersField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_ACTUAL_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
|
||||
? data.options[STAGE_OPTION_GROUP_ID]?.find((o) => o.value === data.currentStage)?.label
|
||||
?? data.currentStage
|
||||
@@ -149,6 +159,25 @@ export function StaffReportView({
|
||||
/>
|
||||
<Stat label="Org id" value={<code className="font-mono">{data.orgId}</code>} />
|
||||
</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" />
|
||||
</header>
|
||||
|
||||
@@ -251,13 +280,20 @@ function StaffSection({
|
||||
const empty = section.fields.filter((f) => f.history.length === 0);
|
||||
const [showEmpty, setShowEmpty] = useState(false);
|
||||
|
||||
// Stage 5 Y1 matrix: pull Labor / Margin / Member_Sales quarterly fields
|
||||
// out into a single tabular display matching the form's matrix layout.
|
||||
// Stage 5 Y1 matrices: pull quarterly (Y1_Q*) and monthly (Y1_M*) field
|
||||
// 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 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 =
|
||||
matrixFields
|
||||
? filled.filter((f) => !matrixFields.usedNames.has(f.descriptor.name))
|
||||
matrixUsedNames.size > 0
|
||||
? filled.filter((f) => !matrixUsedNames.has(f.descriptor.name))
|
||||
: filled;
|
||||
|
||||
return (
|
||||
@@ -277,12 +313,11 @@ function StaffSection({
|
||||
{filled.length} with data
|
||||
</p>
|
||||
|
||||
{matrixFields ? (
|
||||
<Y1MatrixTable
|
||||
rows={matrixFields.rows}
|
||||
quarters={matrixFields.quarters}
|
||||
options={options}
|
||||
/>
|
||||
{quarterlyMatrix ? (
|
||||
<Y1MatrixTable data={quarterlyMatrix} options={options} />
|
||||
) : null}
|
||||
{monthlyMatrix ? (
|
||||
<Y1MatrixTable data={monthlyMatrix} options={options} />
|
||||
) : null}
|
||||
|
||||
{filledOutsideMatrix.length > 0 ? (
|
||||
@@ -349,41 +384,58 @@ function CompactFieldRow({
|
||||
const earlier = field.history.slice(1);
|
||||
|
||||
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">
|
||||
<span className="text-[13px] font-medium text-ink">{field.descriptor.label}</span>
|
||||
<div className="text-[13px] text-ink-soft">
|
||||
<FieldValue field={field} entry={latest} options={options} civiBaseUrl={civiBaseUrl} />
|
||||
<div className="flex flex-col items-end gap-0.5 text-right">
|
||||
<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 ? (
|
||||
<span className="ml-2 text-[11px] text-ink-soft">
|
||||
· {formatShortDate(latest.date)}
|
||||
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||||
as of {formatShortDate(latest.date)}
|
||||
</span>
|
||||
) : null}
|
||||
{earlier.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
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 entries"
|
||||
: `${earlier.length} earlier ${earlier.length === 1 ? "entry" : "entries"}`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{earlier.length > 0 ? (
|
||||
<div className="mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="text-[11px] uppercase tracking-[0.14em] text-leaf-700 hover:underline"
|
||||
>
|
||||
{open ? "Hide" : `${earlier.length} earlier ${earlier.length === 1 ? "entry" : "entries"}`}
|
||||
</button>
|
||||
{open ? (
|
||||
<ul className="mt-1 space-y-1 border-l border-rule pl-3">
|
||||
{earlier.map((e, i) => (
|
||||
<li
|
||||
key={`${e.activityId}-${e.date}-${i}`}
|
||||
className="flex items-baseline justify-between gap-x-4 text-[12px] text-ink-soft"
|
||||
>
|
||||
<FieldValue field={field} entry={e} options={options} civiBaseUrl={civiBaseUrl} />
|
||||
<span className="text-[11px] text-ink-soft">{formatShortDate(e.date)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</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) => (
|
||||
<li
|
||||
key={`${e.activityId}-${e.date}-${i}`}
|
||||
className="flex items-baseline justify-between gap-x-4"
|
||||
>
|
||||
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||||
{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>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
</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 {
|
||||
return {
|
||||
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
|
||||
* group them into a read-only table mirroring the form's matrix layout.
|
||||
* The metric set is whatever's actually present in the data so a Civi
|
||||
* schema addition (e.g. Y1_Q*_Labor_Hours) appears automatically.
|
||||
* Stage 5 Y1 matrix: detect fields whose names match Y1_<P><n>_<metric> for a
|
||||
* given period letter (Q for quarterly, M for monthly) and group them into a
|
||||
* read-only table mirroring the form's matrix layout. The metric set is
|
||||
* whatever's actually present in the data, so a Civi schema addition (e.g.
|
||||
* Y1_M*_Labor_Hours) appears automatically.
|
||||
*/
|
||||
interface Y1MatrixRow {
|
||||
metric: string; // e.g. "Labor" | "Margin" | "Member_Sales_"
|
||||
label: string; // human label from the first field's descriptor (sans Y1_Q<n>_ prefix)
|
||||
byQuarter: Map<number, StaffReportField>;
|
||||
label: string; // human label from the first field's descriptor (sans Y1_<P><n>_ prefix)
|
||||
byPeriod: Map<number, StaffReportField>;
|
||||
}
|
||||
interface Y1MatrixData {
|
||||
rows: Y1MatrixRow[];
|
||||
quarters: number[];
|
||||
periods: number[];
|
||||
periodLetter: "Q" | "M";
|
||||
usedNames: Set<string>;
|
||||
}
|
||||
function collectY1MatrixFields(filled: StaffReportField[]): Y1MatrixData | null {
|
||||
const re = /^Y1_Q(\d+)_(.+)$/;
|
||||
function collectY1MatrixByPeriod(
|
||||
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 byMetric = new Map<string, Map<number, StaffReportField>>();
|
||||
const quartersSet = new Set<number>();
|
||||
const periodsSet = new Set<number>();
|
||||
const metricLabel = new Map<string, string>();
|
||||
|
||||
for (const f of filled) {
|
||||
const m = re.exec(f.descriptor.name);
|
||||
const m = nameRe.exec(f.descriptor.name);
|
||||
if (!m) continue;
|
||||
const quarter = Number(m[1]);
|
||||
const period = Number(m[1]);
|
||||
const metric = m[2];
|
||||
used.add(f.descriptor.name);
|
||||
quartersSet.add(quarter);
|
||||
periodsSet.add(period);
|
||||
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)) {
|
||||
// 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
|
||||
.replace(/^Y1\s*Q\d+\s*/i, "")
|
||||
.replace(labelStripRe, "")
|
||||
.replace(/_/g, " ")
|
||||
.trim();
|
||||
metricLabel.set(metric, cleaned || metric.replace(/_/g, " "));
|
||||
@@ -503,35 +576,35 @@ function collectY1MatrixFields(filled: StaffReportField[]): Y1MatrixData | null
|
||||
}
|
||||
|
||||
if (byMetric.size === 0) return null;
|
||||
const quarters = Array.from(quartersSet).sort((a, b) => a - b);
|
||||
const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byQuarter]) => ({
|
||||
const periods = Array.from(periodsSet).sort((a, b) => a - b);
|
||||
const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byPeriod]) => ({
|
||||
metric,
|
||||
label: metricLabel.get(metric) ?? metric,
|
||||
byQuarter,
|
||||
byPeriod,
|
||||
}));
|
||||
return { rows, quarters, usedNames: used };
|
||||
return { rows, periods, periodLetter, usedNames: used };
|
||||
}
|
||||
|
||||
function Y1MatrixTable({
|
||||
rows,
|
||||
quarters,
|
||||
data,
|
||||
options,
|
||||
}: {
|
||||
rows: Y1MatrixRow[];
|
||||
quarters: number[];
|
||||
data: Y1MatrixData;
|
||||
options: Record<number, SelectOption[]>;
|
||||
}) {
|
||||
const { rows, periods, periodLetter } = data;
|
||||
const cadence = periodLetter === "Q" ? "quarterly" : "monthly";
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-rule bg-paper">
|
||||
<table className="min-w-full text-sm">
|
||||
<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>
|
||||
<thead className="text-left text-[11px] uppercase tracking-[0.14em] text-ink-soft">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">Metric</th>
|
||||
{quarters.map((q) => (
|
||||
<th key={q} className="px-3 py-2 font-medium">Q{q}</th>
|
||||
{periods.map((p) => (
|
||||
<th key={p} className="px-3 py-2 font-medium">{periodLetter}{p}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -539,11 +612,11 @@ function Y1MatrixTable({
|
||||
{rows.map((row) => (
|
||||
<tr key={row.metric}>
|
||||
<td className="px-3 py-2 text-[13px] text-ink">{row.label}</td>
|
||||
{quarters.map((q) => {
|
||||
const f = row.byQuarter.get(q);
|
||||
{periods.map((p) => {
|
||||
const f = row.byPeriod.get(p);
|
||||
const latest = f?.history[0];
|
||||
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 ? (
|
||||
<FormattedValue
|
||||
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.
|
||||
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)
|
||||
const stage0: StageSectionConfig = {
|
||||
rank: 0,
|
||||
@@ -753,7 +783,7 @@ export const formConfig: FormConfig = {
|
||||
subtitle:
|
||||
"Thank you for updating your co-op information. The questions you'll see depend on where you are in the Framework.",
|
||||
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. */
|
||||
|
||||
Reference in New Issue
Block a user