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:
+93
-10
@@ -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">
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user