Field groups: tighter style + apply to report

Form: dropped the boxed card treatment for FieldGroupCard in favor
of a leaf-tinted left rule + small uppercase mini-label. Eats only
~14px of horizontal space (border + pl-3/sm:pl-4) instead of
~32-40px for the previous bg-tinted card with px-4/sm:px-5 on both
sides, so the inner 2-col grid keeps more breathing room for the
fields themselves.

Report: same field-group concept now applies to ReportSection.
Grouped FieldHistoryRows render together inside a leaf-tinted left
rule with a small label above. Walk preserves the declared field
order — a group is emitted at its first member's position; the
other members are skipped when the loop later reaches them. Mixes
cleanly with the existing MembershipChart inline insertion and the
divide-y rhythm of standalone rows.
This commit is contained in:
Joel Brock
2026-05-21 16:51:00 -07:00
parent 8766de5ed0
commit 9bc4bbb732
2 changed files with 110 additions and 21 deletions
+93 -10
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Fragment, useEffect, useId, useMemo, useState } from "react"; import { useEffect, useId, useMemo, useState } from "react";
import type { import type {
FieldConfig, FieldConfig,
FieldHistoryEntry, FieldHistoryEntry,
@@ -1006,25 +1006,70 @@ function ReportSection({
const chartIdx = Math.max(memberIdx, goalIdx); const chartIdx = Math.max(memberIdx, goalIdx);
const membersFieldInSection = memberIdx >= 0 ? fields[memberIdx] : undefined; const membersFieldInSection = memberIdx >= 0 ? fields[memberIdx] : undefined;
const goalFieldInSection = goalIdx >= 0 ? fields[goalIdx] : undefined; const goalFieldInSection = goalIdx >= 0 ? fields[goalIdx] : undefined;
return fields.map((f, i) => (
<Fragment key={f.name}> // Build a map from field name → its group config (if any). A
// field referenced in multiple groups belongs to the first one.
const groupByField = new Map<string, { id: string; label?: string }>();
for (const g of section.fieldGroups ?? []) {
for (const fname of g.fields) {
if (!groupByField.has(fname)) {
groupByField.set(fname, { id: g.id, label: g.label });
}
}
}
// Walk fields in declared order; whenever we hit one that
// belongs to a group we haven't emitted yet, collect every
// visible field from that group (those with history) and emit
// them as one bordered cluster. Other group members get
// skipped when we encounter them later in the loop.
const fieldsWithHistorySet = new Set(fields.map((f) => f.name));
const emittedGroups = new Set<string>();
const out: React.ReactNode[] = [];
fields.forEach((f, i) => {
const grp = groupByField.get(f.name);
if (grp && !emittedGroups.has(grp.id)) {
emittedGroups.add(grp.id);
const sectionGroup = (section.fieldGroups ?? []).find((g) => g.id === grp.id)!;
const groupedFields = sectionGroup.fields
.map((name) => fields.find((ff) => ff.name === name))
.filter((ff): ff is FieldConfig => !!ff && fieldsWithHistorySet.has(ff.name));
if (groupedFields.length === 0) return;
out.push(
<FieldHistoryGroup
key={`group-${grp.id}`}
label={grp.label}
fields={groupedFields}
history={history}
options={options}
/>,
);
} else if (!grp) {
out.push(
<FieldHistoryRow <FieldHistoryRow
key={f.name}
field={f} field={f}
entries={history[f.name] ?? []} entries={history[f.name] ?? []}
options={options} options={options}
/> />,
{i === chartIdx && chartIdx >= 0 && ( );
<div className="bg-paper-2/20 px-5 py-5 sm:px-7 sm:py-6"> }
if (i === chartIdx && chartIdx >= 0) {
out.push(
<div key={`chart-${f.name}`} className="bg-paper-2/20 px-5 py-5 sm:px-7 sm:py-6">
<MembershipChart <MembershipChart
membersField={membersFieldInSection} membersField={membersFieldInSection}
goalField={goalFieldInSection} goalField={goalFieldInSection}
membersHistory={history[MEMBERS_ACTUAL_NAME]} membersHistory={history[MEMBERS_ACTUAL_NAME]}
goalHistory={history[MEMBERS_GOAL_NAME]} goalHistory={history[MEMBERS_GOAL_NAME]}
/> />
</div> </div>,
)} );
</Fragment> }
)); });
return out;
})()} })()}
</div> </div>
</div> </div>
@@ -1032,6 +1077,44 @@ function ReportSection({
); );
} }
/**
* Mirrors the form's FieldGroupCard: a leaf-tinted left rule + small
* uppercase mini-label, with the grouped history rows stacked beneath
* and separated by the same divide-y as the standalone rows. Eats only
* ~14px of horizontal space (vs ~40px for a fully-boxed treatment).
*/
function FieldHistoryGroup({
label,
fields,
history,
options,
}: {
label?: string;
fields: FieldConfig[];
history: Record<string, FieldHistoryEntry[]>;
options: Record<number, SelectOption[]>;
}) {
return (
<div className="border-l-2 border-leaf-300/60">
{label && (
<p className="px-5 pt-3 pb-1 pl-7 text-[10px] uppercase tracking-[0.1em] font-medium text-ink-soft sm:px-7 sm:pl-9">
{label}
</p>
)}
<div className="divide-y divide-rule-soft">
{fields.map((f) => (
<FieldHistoryRow
key={f.name}
field={f}
entries={history[f.name] ?? []}
options={options}
/>
))}
</div>
</div>
);
}
function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) { function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
return ( return (
<span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center"> <span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">
+13 -7
View File
@@ -348,9 +348,10 @@ function LockedBanner() {
/** /**
* Visual cluster of related fields inside a section (e.g. "Market Study" * Visual cluster of related fields inside a section (e.g. "Market Study"
* grouping its date + upload fields). Renders as a quiet bordered card * grouping its date + upload fields). Lightweight treatment: a leaf-tinted
* with an optional heading; the fields inside use the same two-column * left rule and an optional uppercase mini-label. No boxed background or
* grid rules as the standalone per-field grid above/below. * heavy padding — preserves the horizontal space the inner 2-col grid has
* to work with, while still signaling "these belong together."
*/ */
function FieldGroupCard({ function FieldGroupCard({
label, label,
@@ -372,21 +373,26 @@ function FieldGroupCard({
errors: FieldErrors; errors: FieldErrors;
}) { }) {
return ( return (
<div className="rounded-md border border-rule-soft bg-paper-2/30 px-4 py-4 sm:px-5 sm:py-5"> <div className="border-l-2 border-leaf-300/60 pl-3 sm:pl-4">
{label && ( {label && (
<h3 className="font-display text-sm font-medium uppercase tracking-[0.08em] text-ink-soft"> <h3 className="font-display text-[11px] font-medium uppercase tracking-[0.1em] text-ink-soft">
{label} {label}
</h3> </h3>
)} )}
{intro && ( {intro && (
<p className={"max-w-prose text-xs leading-relaxed text-ink-mute " + (label ? "mt-1" : "")}> <p
className={
"max-w-prose text-xs leading-relaxed text-ink-mute " +
(label ? "mt-0.5" : "")
}
>
{intro} {intro}
</p> </p>
)} )}
<div <div
className={ className={
"grid grid-cols-1 gap-x-7 gap-y-5 md:grid-cols-2 " + "grid grid-cols-1 gap-x-7 gap-y-5 md:grid-cols-2 " +
(label || intro ? "mt-3" : "") (label || intro ? "mt-2" : "")
} }
> >
{fields.map((f) => { {fields.map((f) => {