feat(condition): aspect drill-down drawer + clear clickable affordance

Add the element→aspect drill-down agreed via the prototype. Every
component (worklist card, horizon/healthy row, roadmap lane, assessment
chip) is now a button that opens a right-side drawer showing that
element's real aspect_condition rows — aspect, value, install year,
renewal, comments — under a header + renewal hero + lifespan bar.

Make clickability obvious: a chevron affordance on each row that nudges
and darkens on hover, plus hover lift/border on the cards and a "Select
a component to view its aspects" hint by the toggle.

Drawer uses the app's existing vaul dependency (direction="right") so
focus-trap / Esc / scroll-lock come for free — no useEffect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-14 17:51:37 +00:00
parent 14f47ef45e
commit 8a08a38ab6

View file

@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { Drawer as Vaul } from "vaul";
import {
HomeModernIcon,
BuildingOffice2Icon,
@ -13,8 +14,14 @@ import {
ChevronRightIcon,
Squares2X2Icon,
CalendarDaysIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import { elementLabel, renewalUrgency, type RenewalUrgency } from "@/lib/condition/labels";
import {
elementLabel,
aspectLabel,
renewalUrgency,
type RenewalUrgency,
} from "@/lib/condition/labels";
import {
CONDITION_CATEGORIES,
categoryKeyOf,
@ -46,7 +53,6 @@ function earliestInstallYear(aspects: AspectInput[]): number | null {
const years = aspects.map((a) => yearOf(a.installDate)).filter((y): y is number => y !== null);
return years.length ? Math.min(...years) : null;
}
// Headline value for a component: prefer material, else a rating/condition aspect.
function primaryValue(aspects: AspectInput[]): string | null {
const mat = aspects.find((a) => a.aspectType === "material" && a.value)?.value;
if (mat) return mat;
@ -56,6 +62,10 @@ function primaryValue(aspects: AspectInput[]): string | null {
}
return aspects.find((a) => a.value)?.value ?? null;
}
function categoryLabelOf(elementType: string): string {
const key = categoryKeyOf(elementType);
return CONDITION_CATEGORIES.find((c) => c.key === key)?.label ?? "Component";
}
function ElementIcon({ elementType, className }: { elementType: string; className?: string }) {
const t = elementType;
@ -120,7 +130,7 @@ function SectionLabel({ children, count }: { children: React.ReactNode; count?:
}
// ── Priority worklist ─────────────────────────────────────────────────────────
function Worklist({ rows, currentYear }: { rows: Row[]; currentYear: number }) {
function Worklist({ rows, currentYear, onSelect }: { rows: Row[]; currentYear: number; onSelect: (r: Row) => void }) {
const comps = rows.filter((r) => !r.isAssessment);
const assess = rows.filter((r) => r.isAssessment);
const byYear = (a: Row, b: Row) => (a.el.renewalYear ?? Infinity) - (b.el.renewalYear ?? Infinity);
@ -128,6 +138,7 @@ function Worklist({ rows, currentYear }: { rows: Row[]; currentYear: number }) {
const soon = comps.filter((r) => r.urgency === "soon").sort(byYear);
const ok = comps.filter((r) => r.urgency === "ok").sort(byYear);
const attn = [...overdue, ...soon];
const key = (r: Row) => `${r.el.elementType}__${r.el.elementInstance}`;
return (
<div>
@ -140,9 +151,10 @@ function Worklist({ rows, currentYear }: { rows: Row[]; currentYear: number }) {
const span = row.installed != null && row.el.renewalYear != null ? row.el.renewalYear - row.installed : 0;
const pct = span > 0 ? Math.max(0, Math.min(100, ((currentYear - row.installed!) / span) * 100)) : 0;
return (
<div
key={`${row.el.elementType}__${row.el.elementInstance}`}
className={`grid grid-cols-[auto_1fr_auto] items-center gap-4 rounded-2xl border bg-white p-4 shadow-sm ${u.border}`}
<button
key={key(row)}
onClick={() => onSelect(row)}
className={`group grid w-full grid-cols-[auto_1fr_auto_auto] items-center gap-4 rounded-2xl border bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:shadow-md ${u.border}`}
>
<div className={`grid h-11 w-11 place-items-center rounded-xl ${u.medallion}`}>
<ElementIcon elementType={row.el.elementType} className="h-5 w-5" />
@ -167,7 +179,8 @@ function Worklist({ rows, currentYear }: { rows: Row[]; currentYear: number }) {
{row.urgency === "overdue" ? "Replace" : "Plan"}
</div>
</div>
</div>
<ChevronRightIcon className="h-5 w-5 text-gray-300 transition-all group-hover:translate-x-0.5 group-hover:text-brandblue" />
</button>
);
})}
</div>
@ -179,16 +192,20 @@ function Worklist({ rows, currentYear }: { rows: Row[]; currentYear: number }) {
<SectionLabel count={`next ${Math.min(6, ok.length)}`}>On the horizon</SectionLabel>
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{ok.slice(0, 6).map((row) => (
<div
key={`${row.el.elementType}__${row.el.elementInstance}`}
className="flex items-center justify-between gap-3 rounded-xl border border-gray-100 bg-white px-4 py-3"
<button
key={key(row)}
onClick={() => onSelect(row)}
className="group flex items-center justify-between gap-2 rounded-xl border border-gray-100 bg-white px-4 py-3 text-left transition-colors hover:border-brandblue/30 hover:bg-gray-50"
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-brandblue">{elementLabel(row.el.elementType)}</p>
{row.value && <p className="truncate text-[11px] text-gray-400">{row.value}</p>}
{row.value && <p className="truncate text-[11px] text-gray-500">{row.value}</p>}
</div>
<span className="shrink-0 text-sm font-bold tabular-nums text-emerald-700">{row.el.renewalYear}</span>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span className="text-sm font-bold tabular-nums text-emerald-700">{row.el.renewalYear}</span>
<ChevronRightIcon className="h-4 w-4 text-gray-300 transition-all group-hover:translate-x-0.5 group-hover:text-brandblue" />
</div>
</button>
))}
</div>
@ -202,13 +219,17 @@ function Worklist({ rows, currentYear }: { rows: Row[]; currentYear: number }) {
</summary>
<div className="grid grid-cols-1 border-t border-gray-100 sm:grid-cols-2">
{ok.map((row) => (
<div
key={`${row.el.elementType}__${row.el.elementInstance}`}
className="flex items-center justify-between border-t border-gray-50 px-5 py-2.5 text-[13px] first:border-t-0 sm:[&:nth-child(2)]:border-t-0"
<button
key={key(row)}
onClick={() => onSelect(row)}
className="group flex items-center justify-between border-t border-gray-50 px-5 py-2.5 text-left text-[13px] transition-colors first:border-t-0 hover:bg-gray-50 sm:[&:nth-child(2)]:border-t-0"
>
<span className="truncate text-gray-700">{elementLabel(row.el.elementType)}</span>
<span className="shrink-0 font-semibold tabular-nums text-gray-500">{row.el.renewalYear}</span>
</div>
<span className="flex shrink-0 items-center gap-1.5">
<span className="font-semibold tabular-nums text-gray-500">{row.el.renewalYear}</span>
<ChevronRightIcon className="h-3.5 w-3.5 text-gray-200 transition-all group-hover:translate-x-0.5 group-hover:text-brandblue" />
</span>
</button>
))}
</div>
</details>
@ -224,13 +245,15 @@ function Worklist({ rows, currentYear }: { rows: Row[]; currentYear: number }) {
{assess.map((row) => {
const flag = /category\s*[12]/i.test(row.value ?? "");
return (
<span
key={`${row.el.elementType}__${row.el.elementInstance}`}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs ${flag ? "border-amber-200 bg-amber-50" : "border-gray-100 bg-white"}`}
<button
key={key(row)}
onClick={() => onSelect(row)}
className={`group inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors ${flag ? "border-amber-200 bg-amber-50 hover:border-amber-300" : "border-gray-100 bg-white hover:border-brandblue/30"}`}
>
<span className="font-semibold text-gray-700">{elementLabel(row.el.elementType)}</span>
<span className={flag ? "font-bold text-amber-700" : "text-gray-500"}>{row.value ?? "—"}</span>
</span>
<ChevronRightIcon className="h-3.5 w-3.5 text-gray-300 transition-all group-hover:translate-x-0.5 group-hover:text-brandblue" />
</button>
);
})}
</div>
@ -245,7 +268,7 @@ const LO = 2015;
const HI = 2060;
const xOf = (y: number) => ((Math.min(Math.max(y, LO), HI) - LO) / (HI - LO)) * 100;
function Roadmap({ rows, currentYear }: { rows: Row[]; currentYear: number }) {
function Roadmap({ rows, currentYear, onSelect }: { rows: Row[]; currentYear: number; onSelect: (r: Row) => void }) {
const dated = rows.filter((r) => !r.isAssessment && r.el.renewalYear !== null);
const assessCount = rows.length - dated.length;
const nowX = xOf(currentYear);
@ -253,12 +276,11 @@ function Roadmap({ rows, currentYear }: { rows: Row[]; currentYear: number }) {
const groups = CONDITION_CATEGORIES.map((c) => ({
category: c,
rows: dated.filter((r) => categoryKeyOf(r.el.elementType) === c.key).sort((a, b) => (a.el.renewalYear! - b.el.renewalYear!)),
rows: dated.filter((r) => categoryKeyOf(r.el.elementType) === c.key).sort((a, b) => a.el.renewalYear! - b.el.renewalYear!),
})).filter((g) => g.rows.length > 0);
return (
<div className="overflow-hidden rounded-2xl border border-gray-100 bg-white pb-4 shadow-sm">
{/* Axis */}
<div className="sticky top-0 z-10 grid grid-cols-[130px_1fr] items-center border-b border-gray-100 bg-white/95 px-4 pb-1.5 pt-3 backdrop-blur sm:grid-cols-[160px_1fr]">
<div />
<div className="relative h-4">
@ -284,31 +306,33 @@ function Roadmap({ rows, currentYear }: { rows: Row[]; currentYear: number }) {
const x0 = row.installed != null ? xOf(row.installed) : Math.max(0, xOf(row.el.renewalYear!) - 3);
const x1 = xOf(row.el.renewalYear!);
return (
<div
<button
key={`${row.el.elementType}__${row.el.elementInstance}`}
className="grid grid-cols-[130px_1fr] items-center px-4 py-0.5 transition-colors hover:bg-gray-50 sm:grid-cols-[160px_1fr]"
onClick={() => onSelect(row)}
className="group grid w-full grid-cols-[130px_1fr] items-center px-4 py-0.5 text-left transition-colors hover:bg-gray-50 sm:grid-cols-[160px_1fr]"
>
<div className="truncate pr-2 text-xs font-semibold text-brandblue" title={`${elementLabel(row.el.elementType)}${row.value ? `${row.value}` : ""}`}>
{elementLabel(row.el.elementType)}
<div className="flex items-center gap-1 truncate pr-2 text-xs font-semibold text-brandblue" title={`${elementLabel(row.el.elementType)}${row.value ? `${row.value}` : ""}`}>
<ChevronRightIcon className="h-3 w-3 shrink-0 text-gray-300 opacity-0 transition-opacity group-hover:opacity-100" />
<span className="truncate">{elementLabel(row.el.elementType)}</span>
</div>
<div className="relative h-5">
<div className="absolute -top-1 bottom-[-4px] w-px bg-gray-200" style={{ left: `${nowX}%` }} />
<div className={`absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full ${u.bar} opacity-70`} style={{ left: `${x0}%`, width: `${Math.max(1, x1 - x0)}%` }} />
<div className={`absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white ${u.due}`} style={{ left: `${x1}%` }} title={`Renew ${row.el.renewalYear}`} />
<div className={`absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white ${u.due}`} style={{ left: `${x1}%` }} />
{x1 < 86 && (
<span className="absolute top-1/2 -translate-y-1/2 pl-2 text-[10px] font-bold tabular-nums text-gray-500" style={{ left: `${x1}%` }}>
{row.el.renewalYear}
</span>
)}
</div>
</div>
</button>
);
})}
</div>
))}
{assessCount > 0 && (
<p className="mt-3 border-t border-gray-100 px-4 pt-3 text-xs text-gray-400">
<p className="mt-3 border-t border-gray-100 px-4 pt-3 text-xs text-gray-500">
{assessCount} health &amp; safety assessment{assessCount === 1 ? "" : "s"} not shown they carry a rating, not a renewal date.
</p>
)}
@ -316,9 +340,106 @@ function Roadmap({ rows, currentYear }: { rows: Row[]; currentYear: number }) {
);
}
// ── Shell: summary + view toggle ──────────────────────────────────────────────
// ── Aspect drill-down drawer ──────────────────────────────────────────────────
function AspectDrawer({ row, currentYear, onClose }: { row: Row; currentYear: number; onClose: () => void }) {
const el = row.el;
const u = U[row.urgency];
const span = row.installed != null && el.renewalYear != null ? el.renewalYear - row.installed : 0;
const pct = span > 0 ? Math.max(0, Math.min(100, ((currentYear - row.installed!) / span) * 100)) : 0;
return (
<div className="flex h-full flex-col">
<div className="relative border-b border-gray-100 px-6 pb-5 pt-6">
<button
onClick={onClose}
aria-label="Close"
className="absolute right-4 top-4 grid h-8 w-8 place-items-center rounded-lg border border-gray-200 text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-800"
>
<XMarkIcon className="h-4 w-4" />
</button>
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">{categoryLabelOf(el.elementType)}</p>
<div className="mt-2 flex items-center gap-3">
<div className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl ${u.medallion}`}>
<ElementIcon elementType={el.elementType} className="h-5 w-5" />
</div>
<Vaul.Title className="font-manrope text-xl font-bold text-brandblue">
{elementLabel(el.elementType)}
{el.elementInstance > 1 && <span className="ml-1 font-medium text-gray-300">#{el.elementInstance}</span>}
</Vaul.Title>
</div>
<Vaul.Description className="mt-2 text-xs text-gray-500">
{el.source} · surveyed {el.date}
</Vaul.Description>
</div>
{!row.isAssessment && (
<div className="border-b border-gray-100 px-6 py-4">
<div className="flex items-end justify-between gap-4">
<p className="text-xs text-gray-500">{statusLine(row, currentYear)}</p>
<span className={`font-manrope text-3xl font-black tabular-nums leading-none ${u.year}`}>{el.renewalYear ?? "—"}</span>
</div>
{span > 0 && (
<div className="mt-3">
<div className="relative h-1.5 overflow-hidden rounded-full bg-gray-100">
<div className={`absolute inset-y-0 left-0 rounded-full ${u.bar}`} style={{ width: `${pct}%` }} />
</div>
<div className="mt-1.5 flex justify-between text-[11px] font-medium text-gray-500">
<span>Installed {row.installed}</span>
<span className="tabular-nums">{span}-yr life</span>
<span>Renew {el.renewalYear}</span>
</div>
</div>
)}
</div>
)}
<div className="flex-1 overflow-y-auto">
<p className="px-6 pb-2 pt-4 text-[10px] font-bold uppercase tracking-widest text-gray-500">
Aspects · {el.aspects.length}
</p>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[10px] font-semibold uppercase tracking-wide text-gray-500">
<th className="px-6 py-2 font-semibold">Aspect</th>
<th className="px-3 py-2 font-semibold">Value</th>
<th className="px-3 py-2 font-semibold">Inst.</th>
<th className="px-6 py-2 font-semibold">Renew</th>
</tr>
</thead>
<tbody>
{el.aspects.map((a, i) => {
const au = urgencyOf(a.renewalYear, currentYear);
return (
<tr key={i} className="border-t border-gray-50 align-top">
<td className="whitespace-nowrap px-6 py-3 font-medium text-gray-500">{aspectLabel(a.aspectType)}</td>
<td className="px-3 py-3 font-semibold text-brandblue">
{a.value ?? <span className="font-normal text-gray-300"></span>}
{a.comments && <div className="mt-0.5 text-xs font-normal text-gray-500">{a.comments}</div>}
</td>
<td className="px-3 py-3 tabular-nums text-gray-500">
{yearOf(a.installDate) ?? <span className="text-gray-300"></span>}
</td>
<td className="px-6 py-3 tabular-nums">
{a.renewalYear === null ? (
<span className="text-gray-300"></span>
) : (
<span className={`font-semibold ${U[au].year}`}>{a.renewalYear}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// ── Shell: summary + view toggle + drawer ─────────────────────────────────────
export function ConditionView({ elements, currentYear }: { elements: ResolvedElement[]; currentYear: number }) {
const [view, setView] = useState<"worklist" | "roadmap">("worklist");
const [selected, setSelected] = useState<Row | null>(null);
const rows = elements.map((el) => toRow(el, currentYear));
const comps = rows.filter((r) => !r.isAssessment);
@ -335,12 +456,12 @@ export function ConditionView({ elements, currentYear }: { elements: ResolvedEle
? { n: counts.soon, label: `component${counts.soon === 1 ? "" : "s"} due soon`, tone: U.soon.tone }
: { n: counts.ok, label: "in good condition", tone: U.ok.tone };
const tab = (key: "worklist" | "roadmap", label: string, Icon: React.ElementType) => (
const tab = (k: "worklist" | "roadmap", label: string, Icon: React.ElementType) => (
<button
onClick={() => setView(key)}
aria-pressed={view === key}
onClick={() => setView(k)}
aria-pressed={view === k}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-semibold transition-colors ${
view === key ? "bg-white text-brandblue shadow-sm" : "text-gray-500 hover:text-gray-800"
view === k ? "bg-white text-brandblue shadow-sm" : "text-gray-500 hover:text-gray-800"
}`}
>
<Icon className="h-4 w-4" />
@ -374,15 +495,29 @@ export function ConditionView({ elements, currentYear }: { elements: ResolvedEle
</div>
</div>
{/* View toggle */}
<div className="flex justify-end">
{/* Hint + view toggle */}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-gray-500">Select a component to view its aspects</p>
<div className="inline-flex rounded-xl border border-gray-200 bg-gray-50 p-1">
{tab("worklist", "Priority", Squares2X2Icon)}
{tab("roadmap", "Timeline", CalendarDaysIcon)}
</div>
</div>
{view === "worklist" ? <Worklist rows={rows} currentYear={currentYear} /> : <Roadmap rows={rows} currentYear={currentYear} />}
{view === "worklist" ? (
<Worklist rows={rows} currentYear={currentYear} onSelect={setSelected} />
) : (
<Roadmap rows={rows} currentYear={currentYear} onSelect={setSelected} />
)}
<Vaul.Root open={selected !== null} onOpenChange={(open) => !open && setSelected(null)} direction="right">
<Vaul.Portal>
<Vaul.Overlay className="fixed inset-0 z-50 bg-black/40" />
<Vaul.Content className="fixed inset-y-0 right-0 z-50 flex w-[460px] max-w-[100vw] flex-col bg-white outline-none">
{selected && <AspectDrawer row={selected} currentYear={currentYear} onClose={() => setSelected(null)} />}
</Vaul.Content>
</Vaul.Portal>
</Vaul.Root>
</div>
);
}