From 8a08a38ab63e5068775685a79cff4efef33b676e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 14 Jul 2026 17:51:37 +0000 Subject: [PATCH] feat(condition): aspect drill-down drawer + clear clickable affordance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../[propertyId]/condition/ConditionView.tsx | 215 ++++++++++++++---- 1 file changed, 175 insertions(+), 40 deletions(-) diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/condition/ConditionView.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/condition/ConditionView.tsx index 6a3904db..3ce8b99d 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/condition/ConditionView.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/condition/ConditionView.tsx @@ -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 (
@@ -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 ( -
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}`} >
@@ -167,7 +179,8 @@ function Worklist({ rows, currentYear }: { rows: Row[]; currentYear: number }) { {row.urgency === "overdue" ? "Replace" : "Plan"}
-
+ + ); })} @@ -179,16 +192,20 @@ function Worklist({ rows, currentYear }: { rows: Row[]; currentYear: number }) { On the horizon
{ok.slice(0, 6).map((row) => ( -
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" >

{elementLabel(row.el.elementType)}

- {row.value &&

{row.value}

} + {row.value &&

{row.value}

}
- {row.el.renewalYear} -
+
+ {row.el.renewalYear} + +
+ ))}
@@ -202,13 +219,17 @@ function Worklist({ rows, currentYear }: { rows: Row[]; currentYear: number }) {
{ok.map((row) => ( -
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" > {elementLabel(row.el.elementType)} - {row.el.renewalYear} -
+ + {row.el.renewalYear} + + + ))}
@@ -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 ( - 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"}`} > {elementLabel(row.el.elementType)} {row.value ?? "—"} - + + ); })} @@ -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 (
- {/* Axis */}
@@ -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 ( -
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]" > -
- {elementLabel(row.el.elementType)} +
+ + {elementLabel(row.el.elementType)}
-
+
{x1 < 86 && ( {row.el.renewalYear} )}
-
+ ); })}
))} {assessCount > 0 && ( -

+

{assessCount} health & safety assessment{assessCount === 1 ? "" : "s"} not shown — they carry a rating, not a renewal date.

)} @@ -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 ( +
+
+ +

{categoryLabelOf(el.elementType)}

+
+
+ +
+ + {elementLabel(el.elementType)} + {el.elementInstance > 1 && #{el.elementInstance}} + +
+ + {el.source} · surveyed {el.date} + +
+ + {!row.isAssessment && ( +
+
+

{statusLine(row, currentYear)}

+ {el.renewalYear ?? "—"} +
+ {span > 0 && ( +
+
+
+
+
+ Installed {row.installed} + {span}-yr life + Renew {el.renewalYear} +
+
+ )} +
+ )} + +
+

+ Aspects · {el.aspects.length} +

+ + + + + + + + + + + {el.aspects.map((a, i) => { + const au = urgencyOf(a.renewalYear, currentYear); + return ( + + + + + + + ); + })} + +
AspectValueInst.Renew
{aspectLabel(a.aspectType)} + {a.value ?? } + {a.comments &&
{a.comments}
} +
+ {yearOf(a.installDate) ?? } + + {a.renewalYear === null ? ( + + ) : ( + {a.renewalYear} + )} +
+
+
+ ); +} + +// ── 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(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) => (
- {/* View toggle */} -
+ {/* Hint + view toggle */} +
+

Select a component to view its aspects

{tab("worklist", "Priority", Squares2X2Icon)} {tab("roadmap", "Timeline", CalendarDaysIcon)}
- {view === "worklist" ? : } + {view === "worklist" ? ( + + ) : ( + + )} + + !open && setSelected(null)} direction="right"> + + + + {selected && setSelected(null)} />} + + +
); }