feat(portfolio): address impeccable critique — no-plans state, contrast, focus

Design pass on the portfolio table (critique: 27/40; P0 was the unmodelled state).

- onboard: a mixed-safe "not modelled yet" nudge above the table — shown whenever
  any loaded property lacks a plan (so a part-modelled portfolio still gets it),
  wired to Run modelling. Turns a wall of empty cells into a next step without
  hiding real data for the rows that ARE modelled.
- harden: one empty-cell vocabulary (EmptyCell) replacing the four divergent
  renderings (blank div, slate-300 "—", italic "No cost", "Unknown"); AA-legible
  and aria-labelled. Empty EPC bubble is now a labelled placeholder ring, not an
  invisible div. Export failures surface an inline error instead of dying silently.
- a11y: the quick-filter clear is no longer a <span role=button> nested inside a
  <button> — pill is a container with sibling open/clear buttons.
- distill: exactly one navy CTA at a time — Add properties leads an empty
  portfolio, Run modelling leads a populated one; the other demotes to neutral.
- colorize: black tokens in PropertyFilters + the Edit-Columns checkboxes now use
  the brand navy/brandmidblue scale (was raw black, drifting off the toolbar).
- cleanup: removed the unreachable delete-property modal (no DELETE endpoint,
  onDeleteProperty never called). Real property deletion is its own slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-13 21:50:27 +00:00
parent 5872e7c335
commit 46421279fb
4 changed files with 236 additions and 127 deletions

View file

@ -0,0 +1,78 @@
---
target: portfolio page (property table + toolbar + no-plans state)
total_score: 27
p0_count: 1
p1_count: 1
timestamp: 2026-07-13T21-29-24Z
slug: rc-app-portfolio-slug-components-propertytable-tsx
---
Method: dual-agent (A: ad10a436 design-review · B: a517f2c1 detector+evidence)
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 3 | Good results count + spinner overlay, but first-load is bare "Loading properties…" (no skeleton) and a no-plans portfolio gives zero signal that modelling is the missing step. |
| 2 | Match System / Real World | 3 | "Plan Cost / No cost", "Expected EPC" read well; but blank EPC bubbles + "—" Ref communicate nothing about *why* they're empty. |
| 3 | User Control and Freedom | 3 | Clear/Clear-all on filters + selection; no undo on optimistic tag assign, no cancel-export. |
| 4 | Consistency and Standards | 2 | Two colour systems: toolbar uses brandblue/primary; filter sidebar + Edit-Columns checkboxes use raw black/bg-black/accent-black. |
| 5 | Error Prevention | 3 | Delete has a preview modal, export gated >6000 — but the delete modal has Cancel and no wired Confirm (dead-end). |
| 6 | Recognition Rather Than Recall | 3 | Columns labelled, Current EPC has a "?" tooltip; but Current/Lodged/Expected are three identical blank bubbles with no legend when empty. |
| 7 | Flexibility and Efficiency | 3 | Column visibility, quick filters, filter builder, export — strong; undercut by a 7-row page size on a desk tool. |
| 8 | Aesthetic and Minimalist Design | 2 | The no-plans grid is a wall of "—", blank bubbles, italic "No cost"; 6 top-level toolbar actions + two dark CTAs + shouty micro-labels. |
| 9 | Error Recovery | 3 | load-more + export fail silently; tag-assign error only shows inline in the popover footer. |
| 10 | Help and Documentation | 2 | One tooltip (Current EPC); no inline guidance connecting an unmodelled portfolio to "Run modelling". |
| **Total** | | **27/40** | **Acceptable — solid plumbing, dragged down by the empty state + consistency** |
## Anti-Patterns Verdict
**LLM assessment:** Not egregious slop — the table is competently built and restrained — but three tells betray a generated-template lineage: tiny uppercase black micro-labels everywhere (`RESULTS:` font-black uppercase, `CURATE SELECTION` at 9px), `rounded-xl` + `shadow-sm` cards the brief names as an anti-reference, and em-dash-as-content filling 56 of 9 columns. Bones are institutional; finish is generic-admin.
**Deterministic scan:** detect.mjs exit 0 — **zero findings**. No gradient text, decorative gradients, side-stripe borders, glassmorphism, over-rounded radii (only rounded-lg/xl/full), heavy shadows (only shadow-sm), or arbitrary z-index. The mechanical slop tells are genuinely absent — the problems here are IA/hierarchy/contrast that a static scanner can't see, which is exactly where the design review earns its keep.
**Visual overlays:** none — browser automation unavailable (arm64 container can't run the bundled x86 Chromium under qemu). Fallback: static grep evidence corroborated the review (8 placeholder "—" cells in propertyTableColumns.tsx: 256/336/442/451/459/470/483/494; text-slate-300 at 384/442/451/494/524; 6 toolbar controls at 602/617/658/686/693/742; no dedicated no-plans empty-state component).
## Overall Impression
The interaction plumbing is genuinely good (optimistic tag writes, filter-scoped selection, provenance signalling). What lets it down is a single unaddressed truth: **the default state of every brand-new portfolio — properties added, nothing modelled — is the one state with zero design investment.** A first-time housing-association user lands on 185 rows where Ref is "—", three EPC columns are blank, Expiry is "—", and every Plan Cost is a faint italic "No cost". The read isn't "fresh start", it's "this tool is broken / my import failed" — the worst possible first impression for a product whose promise is *trustworthy numbers*. The single biggest opportunity is to design the unmodelled state as a first-class surface, not a fallthrough.
## What's Working
1. **The Current-EPC "?" tooltip** — exactly right for the institutional register: explains a load-bearing figure inline without cluttering the header. A pattern to extend, not remove.
2. **The Predicted pill + provenance model** (amber "Predicted", ADR-0002) — genuine trust-signalling that tells analysts which EPCs to trust less; precisely the "trustworthy numbers" value the brand claims.
3. **Optimistic tag writes across every cached page + filter-scoped selection surviving pagination** — Linear/Stripe-class plumbing, invisible when it works.
## Priority Issues
**[P0] The no-plans / unmodelled state has no design at all.** `EmptyPropertyState` only fires when there are literally zero rows; a portfolio with 185 unmodelled properties falls straight through to the full table with 56 empty columns. This is the *default* state of every new portfolio and the exact case flagged as "terrible". **Fix:** detect "has properties, zero plans/EPC" and render a purpose-built view — collapse/hide the three EPC + Expiry + Plan Cost columns, show Address/Ref/Tags at full density, and surface one reassuring banner ("185 properties added — run modelling to generate EPC ratings and retrofit costs →") wired to the existing Run-modelling route. *Command: `onboard`*
**[P1] Empty cells fail contrast and read as breakage.** `text-slate-300 italic` "No cost" and slate-300 em-dashes / "Unknown" are below WCAG AA on white and make populated data indistinguishable from missing data at a glance. Four different "empty" renderings coexist (blank `w-5 h-5` div, "—", italic "No cost", "Unknown"). **Fix:** raise empty text to slate-400/500 and pick ONE absence vocabulary. *Command: `harden`*
**[P2] Six top-level toolbar actions with two competing dark CTAs.** Filters, Edit Columns, Export, Tags▾, Add properties▾, Run modelling in one undifferentiated row; Add properties and Run modelling are both navy `bg-primary` — no primary focus, and the brief says navy belongs "in small doses". **Fix:** one dark CTA at a time (Add properties when empty → Run modelling once populated), fold Export/Edit Columns/Tags into a quieter secondary group. *Command: `distill`*
**[P3] Colour-system drift between toolbar and filter sidebar.** Toolbar uses brandblue/primary; `PropertyFilters.tsx` and the Edit-Columns checkboxes use raw black/bg-black/accent-black — two systems on one page. **Fix:** replace every black token with the brand navy/brandblue scale. *Command: `colorize`*
**[P3] No first-load skeleton; silent failures.** First load is a lone "Loading properties…" string; load-more and export swallow errors (`// silently ignore`). For a data-trust product a silent export failure is a credibility risk. **Fix:** skeleton rows + a toast/inline error on export and load-more failure. *Command: `polish`*
## Persona Red Flags
**Alex (power user):** `pageSize: 7` on a large desk screen means paging 27× through 185 homes; lazy load-more only triggers on reaching the last page — no jump-to-end or density control. Wants 50100 rows + persistent EPC/cost sort.
**Sam (accessibility):** slate-300 "No cost"/"Unknown" fail AA outright; the quick-filter clear control is a `<span role="button" tabIndex=0>` nested *inside* a `<button>` (invalid interactive nesting, unpredictable for SR/keyboard); the empty EpcLetterBubble is an unlabelled `<div className="w-5 h-5" />` with no "no data" text.
**Riley (edge/empty-state stress):** the 185-no-plans grid is the headline failure — renders as a wall of placeholders with no explanation. Compounded by the delete modal's Cancel-with-no-Confirm dead-end (and `deletePreview` never populated, so the impact list is always empty), and load-more failing silently leaving a partial list.
## Minor Observations
- Give the empty EpcLetterBubble an aria-hidden "—" so the column isn't three invisible gaps.
- `RESULTS:` in font-black uppercase is louder than the numbers it introduces — invert the weight.
- Export button sits in `bg-slate-100` while neighbours are `bg-white` — reads as disabled when it's active.
- The floating chat launcher (third-party) overlaps the right-edge Actions/⋯ column and can occlude the row menu.
- Delete modal `DialogDescription` promises "Review the impact below" but `deletePreview` is never fetched in this file — always empty (engineering bug).
- Heavy useEffect/useMemo usage contradicts the repo CLAUDE.md React guidance (for the engineering review, not design).
## Questions to Consider
1. If the default state of every new portfolio is "properties added, nothing modelled", why is that the state with zero design investment — shouldn't the unmodelled view be the primary design target?
2. Three EPC columns collapse to three identical blank bubbles with no data — should Lodged/Expected be progressive-disclosure behind the modelled state?
3. Two navy buttons say "both of these are the most important thing" — which ONE action is the product betting a first-time user should take, and does the layout make that bet visible?

View file

@ -248,7 +248,7 @@ function EpcDropdown({
ref={buttonRef}
type="button"
onClick={openDropdown}
className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-black/10 transition"
className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-brandblue/20 transition"
>
<span className="flex items-center gap-1 flex-wrap min-h-[1.25rem]">
{selected.length === 0 ? (
@ -287,7 +287,7 @@ function EpcDropdown({
{multi ? (
<span
className={`ml-auto w-4 h-4 rounded border flex items-center justify-center shrink-0 transition ${
isSelected ? "bg-black border-black" : "border-gray-300"
isSelected ? "bg-brandmidblue border-brandmidblue" : "border-gray-300"
}`}
>
{isSelected && <Check className="h-2.5 w-2.5 text-white" />}
@ -372,7 +372,7 @@ function EnumMultiDropdown({
ref={buttonRef}
type="button"
onClick={openDropdown}
className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-black/10 transition"
className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-brandblue/20 transition"
>
<span className="flex items-center gap-1 flex-wrap min-h-[1.25rem] text-left">
{selectedLabels.length === 0 ? (
@ -398,7 +398,7 @@ function EnumMultiDropdown({
<span className="flex-1 text-gray-700 leading-tight">{opt.label}</span>
<span
className={`ml-auto w-4 h-4 rounded border flex items-center justify-center shrink-0 transition ${
isSelected ? "bg-black border-black" : "border-gray-300"
isSelected ? "bg-brandmidblue border-brandmidblue" : "border-gray-300"
}`}
>
{isSelected && <Check className="h-2.5 w-2.5 text-white" />}
@ -489,7 +489,7 @@ function TagMultiDropdown({
ref={buttonRef}
type="button"
onClick={openDropdown}
className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-black/10 transition"
className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-brandblue/20 transition"
>
<span className="flex items-center gap-1 flex-wrap min-h-[1.25rem] text-left">
{selected.length === 0 ? (
@ -525,7 +525,7 @@ function TagMultiDropdown({
<span className="flex-1 text-gray-700 leading-tight truncate">{opt.label}</span>
<span
className={`ml-auto w-4 h-4 rounded border flex items-center justify-center shrink-0 transition ${
isSelected ? "bg-black border-black" : "border-gray-300"
isSelected ? "bg-brandmidblue border-brandmidblue" : "border-gray-300"
}`}
>
{isSelected && <Check className="h-2.5 w-2.5 text-white" />}
@ -552,7 +552,7 @@ function NumberFilterInput({
return (
<input
type="number"
className="w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-black/10"
className="w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brandblue/20"
placeholder="Enter value…"
value={value}
onChange={(e) => onChange(e.target.value)}
@ -627,7 +627,7 @@ function AddFilterForm({ targetGroupId, tags, onConfirm, onCancel }: AddFilterFo
}
const selectClass =
"w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-black/10";
"w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brandblue/20";
const enumOptions = isEnumField(field) ? ENUM_FIELD_OPTIONS[field] : [];
@ -688,7 +688,7 @@ function AddFilterForm({ targetGroupId, tags, onConfirm, onCancel }: AddFilterFo
{field === "epcExpiryDate" && operator !== "date_preset" && (
<input
type="date"
className="w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-black/10"
className="w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brandblue/20"
value={dateValue}
onChange={(e) => setDateValue(e.target.value)}
/>
@ -721,7 +721,7 @@ function AddFilterForm({ targetGroupId, tags, onConfirm, onCancel }: AddFilterFo
type="button"
onClick={handleConfirm}
disabled={!canConfirm()}
className="flex-1 h-8 rounded-md bg-black text-xs font-medium text-white hover:bg-black/90 transition disabled:opacity-40 disabled:cursor-not-allowed"
className="flex-1 h-8 rounded-md bg-primary text-xs font-medium text-white hover:opacity-90 transition disabled:opacity-40 disabled:cursor-not-allowed"
>
Add
</button>
@ -939,7 +939,7 @@ export default function PropertyFilters({
<button
type="button"
onClick={apply}
className="flex-1 h-9 rounded-md bg-black text-sm font-medium text-white hover:bg-black/90 transition"
className="flex-1 h-9 rounded-md bg-primary text-sm font-medium text-white hover:opacity-90 transition"
>
Apply
</button>

View file

@ -47,21 +47,20 @@ import {
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import { Button } from "@/app/shadcn_components/ui/button";
/* ----------------------------------------
Export helpers
----------------------------------------- */
const EXPORT_LIMIT = 6000;
// Exactly one navy CTA is primary at a time (impeccable distill): Add properties
// leads an empty portfolio, Run modelling leads a populated one; the other demotes
// to the neutral toolbar-button style so the toolbar has a single focal action.
const PRIMARY_CTA =
"flex items-center gap-1.5 h-8 px-3 rounded-lg bg-primary text-white text-xs font-semibold hover:opacity-90 transition";
const SECONDARY_CTA =
"flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-white text-primary text-xs font-semibold hover:bg-slate-50 transition";
function resolveEnumLabel(
options: EnumOption[],
dbValue: string | null | undefined,
@ -237,43 +236,44 @@ function QuickFilterDropdown({
return (
<div ref={containerRef} className="relative">
<button
onClick={onOpen}
{/* Pill is a container, not a button the open trigger and the clear
control are sibling buttons so no interactive element is nested. */}
<div
className={[
"flex items-center gap-1.5 h-8 px-3 rounded-lg border text-xs font-semibold transition shrink-0",
"flex items-center h-8 px-3 rounded-lg border text-xs font-semibold transition shrink-0",
isActive
? "border-brandblue bg-brandblue text-white"
: "border-slate-200 bg-white text-primary hover:bg-slate-50",
].join(" ")}
>
<span>{label}</span>
{isActive ? (
<>
<button
type="button"
onClick={onOpen}
className="flex min-w-0 items-center gap-1.5 text-left"
>
<span>{label}</span>
{isActive ? (
<span className="opacity-75 max-w-[120px] truncate">
: {committedValue}
</span>
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onClear();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.stopPropagation();
onClear();
}
}}
className="ml-0.5 rounded-full hover:opacity-75"
>
<XMarkIcon className="h-3.5 w-3.5" />
</span>
</>
) : (
<ChevronDownIcon className="h-3.5 w-3.5 opacity-50" />
) : (
<ChevronDownIcon className="h-3.5 w-3.5 opacity-50" />
)}
</button>
{isActive && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClear();
}}
aria-label={`Clear ${label} filter`}
className="ml-1 rounded-full hover:opacity-75"
>
<XMarkIcon className="h-3.5 w-3.5" />
</button>
)}
</button>
</div>
{isOpen && (
<div className="absolute left-0 top-full mt-1 z-30 bg-white border border-slate-200 rounded-lg shadow-md p-2 flex gap-1.5 items-center">
@ -422,6 +422,7 @@ export default function PropertyTable({
filterGroups: [],
});
const totalCount = allResponse?.total ?? 0;
const [modelNudgeDismissed, setModelNudgeDismissed] = useState(false);
const DISPLAY_LIMIT = 250;
const LOAD_MORE_SIZE = 100;
@ -448,6 +449,11 @@ export default function PropertyTable({
[queryData, extraRows],
);
// Properties with no default plan yet — the "not modelled" set. Counted from the
// loaded rows (cheap; avoids a hook per project convention): exact for a
// fully-loaded portfolio, a floor when more rows lazy-load beyond view.
const unmodelledInView = tableData.filter((p) => p.planId == null).length;
// Controlled pagination (lifted from DataTable so we can detect the last page)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
@ -519,9 +525,11 @@ export default function PropertyTable({
// Export fetches its own batch (up to EXPORT_LIMIT) for the current filters,
// independent of the on-screen page — the display fetch is deliberately small.
const [isExporting, setIsExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const handleExport = useCallback(async () => {
if (isExporting) return;
setIsExporting(true);
setExportError(null);
try {
const res = await fetch("/api/properties", {
method: "POST",
@ -533,24 +541,17 @@ export default function PropertyTable({
offset: 0,
}),
});
if (!res.ok) return;
if (!res.ok) throw new Error();
const json: { data: PropertyWithRelations[] } = await res.json();
exportToCsv(json.data);
} catch {
// Don't fail silently — a data-trust product must say when an export dies.
setExportError("Export failed — please try again.");
} finally {
setIsExporting(false);
}
}, [isExporting, portfolioId, allFilterGroups]);
/* ----------------------------------------
Delete preview state
----------------------------------------- */
const [deletePropertyId, setDeletePropertyId] = useState<number | null>(null);
const [deletePreview, setDeletePreview] = useState<
{ table: string; count: number }[] | null
>(null);
const [previewLoading] = useState(false);
const [previewError] = useState<string | null>(null);
return (
<div className="py-4 mx-4">
{/* Action bar */}
@ -586,6 +587,18 @@ export default function PropertyTable({
</button>
</span>
)}
{exportError && (
<span className="inline-flex items-center gap-1.5 rounded-md border border-red-200 bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
{exportError}
<button
onClick={() => setExportError(null)}
aria-label="Dismiss"
className="text-red-400 hover:text-red-700"
>
<XMarkIcon className="h-3 w-3" />
</button>
</span>
)}
{hasActiveFilters && (
<button
onClick={clearAll}
@ -644,7 +657,7 @@ export default function PropertyTable({
type="checkbox"
checked={isVisible}
readOnly
className="h-3.5 w-3.5 accent-black"
className="h-3.5 w-3.5 accent-brandmidblue"
/>
<span className="text-xs">
{OPTIONAL_COLUMN_LABELS[colId]}
@ -689,10 +702,10 @@ export default function PropertyTable({
onRemove={() => setTagMode("remove")}
/>
{/* Add properties */}
{/* Add properties — primary CTA only while the portfolio is empty */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1.5 h-8 px-3 rounded-lg bg-primary text-white text-xs font-semibold hover:opacity-90 transition">
<button className={totalCount > 0 ? SECONDARY_CTA : PRIMARY_CTA}>
<PlusIcon className="h-3.5 w-3.5" />
Add properties
<ChevronDownIcon className="h-3.5 w-3.5 opacity-70" />
@ -738,10 +751,10 @@ export default function PropertyTable({
portfolioId={portfolioId}
/>
{/* Run modelling */}
{/* Run modelling — primary CTA once there are properties to model */}
<button
onClick={() => router.push(`/portfolio/${portfolioId}/modelling/run`)}
className="flex items-center gap-1.5 h-8 px-3 rounded-lg bg-primary text-white text-xs font-semibold hover:opacity-90 transition"
className={totalCount > 0 ? PRIMARY_CTA : SECONDARY_CTA}
>
<PlayIcon className="h-3.5 w-3.5" />
Run modelling
@ -821,6 +834,49 @@ export default function PropertyTable({
onOpenChange={setTagUploadOpen}
/>
{/* Not-modelled nudge turns a wall of empty cells into a next step.
Mixed-safe: shown whenever any loaded property lacks a plan, so a
part-modelled portfolio still gets the prompt for the rest. */}
{!isLoading &&
totalCount > 0 &&
unmodelledInView > 0 &&
!modelNudgeDismissed && (
<div className="mb-3 flex flex-wrap items-center gap-3 rounded-xl border border-brandmidblue/25 bg-brandlightblue/40 px-4 py-3">
<div className="flex min-w-0 flex-1 items-start gap-3">
<PlayIcon className="mt-0.5 h-5 w-5 shrink-0 text-brandmidblue" />
<div className="min-w-0">
<p className="text-sm font-semibold text-brandblue">
{unmodelledInView === totalCount
? "None of these properties have been modelled yet"
: `${unmodelledInView.toLocaleString()} ${unmodelledInView === 1 ? "property hasn't" : "properties haven't"} been modelled yet`}
</p>
<p className="text-xs text-slate-600">
Run modelling to generate Expected EPC ratings and retrofit
costs for them.
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() =>
router.push(`/portfolio/${portfolioId}/modelling/run`)
}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-white transition hover:opacity-90"
>
<PlayIcon className="h-3.5 w-3.5" />
Run modelling
</button>
<button
onClick={() => setModelNudgeDismissed(true)}
aria-label="Dismiss"
className="rounded-md p-1.5 text-slate-400 transition hover:text-brandblue"
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
)}
{/* Display-limit notice */}
{isAtDisplayLimit && hasMore && (
<div className="mb-3 flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-500">
@ -890,7 +946,6 @@ export default function PropertyTable({
<DataTable
data={tableData}
columns={columns}
onDeleteProperty={(id) => setDeletePropertyId(id)}
columnVisibility={columnVisibility}
onColumnVisibilityChange={
setColumnVisibility as (
@ -911,57 +966,6 @@ export default function PropertyTable({
)}
</div>
</div>
{/* Delete preview modal */}
<Dialog
open={deletePropertyId !== null}
onOpenChange={(open) => {
if (!open) {
setDeletePropertyId(null);
setDeletePreview(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete property?</DialogTitle>
<DialogDescription>
{previewLoading
? "Calculating what will be deleted…"
: "This action is permanent. Review the impact below."}
</DialogDescription>
</DialogHeader>
{previewError && (
<p className="text-sm text-red-600">{previewError}</p>
)}
{deletePreview && !previewLoading && (
<ul className="space-y-1 text-sm">
{deletePreview.map((row) => (
<li key={row.table} className="flex justify-between">
<span className="capitalize">
{row.table.replace(/_/g, " ")}
</span>
<span className="font-medium">{row.count}</span>
</li>
))}
</ul>
)}
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
setDeletePropertyId(null);
setDeletePreview(null);
}}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -53,8 +53,29 @@ function Pill({
/* -----------------------------------------------------------------------
EPC letter bubble
------------------------------------------------------------------------ */
/**
* The one treatment for an absent value across the table (ADR-0013 impeccable
* pass): a single quiet, AA-legible em-dash with an accessible label replacing
* the four different empty renderings (blank div, slate-300 "—", italic
* "No cost", "Unknown") the critique flagged.
*/
const EmptyCell = ({ label = "No data" }: { label?: string }) => (
<span className="text-slate-400" title={label} aria-label={label}>
</span>
);
const EpcLetterBubble = ({ letter }: { letter: string }) => {
if (!letter) return <div className="w-5 h-5" />;
if (!letter)
return (
<div
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-slate-200 text-[11px] font-semibold text-slate-400"
title="No rating yet"
aria-label="No rating yet"
>
</div>
);
return (
<div
className={`inline-flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold text-white ${getEpcColorClass(letter)}`}
@ -253,7 +274,7 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
header: () => <div className="text-xs font-medium">Property Ref</div>,
cell: ({ row }) => (
<div className="text-gray-700 text-sm">
{row.original.landlordPropertyId ?? "—"}
{row.original.landlordPropertyId ?? <EmptyCell />}
</div>
),
},
@ -333,7 +354,11 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
const expired = row.original.epcIsExpired;
if (!dateStr) {
return <div className="text-center text-slate-400 text-xs"></div>;
return (
<div className="flex justify-center">
<EmptyCell label="No expiry date" />
</div>
);
}
const lodgementDate = new Date(dateStr);
@ -375,13 +400,13 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
}
return (
<div className="text-right">
<div className="flex justify-end text-right">
{cost ? (
<span className="text-xs font-bold text-primary">
£{formatNumber(cost)}
</span>
) : (
<span className="text-[10px] text-slate-300 italic">No cost</span>
<EmptyCell label="Not modelled yet" />
)}
</div>
);
@ -439,7 +464,7 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
header: () => <div className="text-xs">Property Type</div>,
cell: ({ row }) => {
const val = row.original.propertyType;
return val ? <Pill>{val}</Pill> : <span className="text-slate-300 text-xs"></span>;
return val ? <Pill>{val}</Pill> : <EmptyCell />;
},
},
{
@ -448,7 +473,7 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
header: () => <div className="text-xs">Built Form</div>,
cell: ({ row }) => {
const val = row.original.builtForm;
return val ? <Pill>{val}</Pill> : <span className="text-slate-300 text-xs"></span>;
return val ? <Pill>{val}</Pill> : <EmptyCell />;
},
},
{
@ -456,7 +481,9 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
accessorKey: "yearBuilt",
header: () => <div className="text-xs">Construction Age</div>,
cell: ({ row }) => (
<div className="text-sm text-gray-700">{row.original.yearBuilt ?? "—"}</div>
<div className="text-sm text-gray-700">
{row.original.yearBuilt ?? <EmptyCell />}
</div>
),
},
{
@ -467,7 +494,7 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
const val = row.original.totalFloorArea;
return (
<div className="text-sm text-gray-700">
{val != null ? `${val.toFixed(1)}` : "—"}
{val != null ? `${val.toFixed(1)}` : <EmptyCell />}
</div>
);
},
@ -480,7 +507,7 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
const val = row.original.co2Emissions;
return (
<div className="text-sm text-gray-700">
{val != null ? `${val.toFixed(1)} kg/m²/yr` : "—"}
{val != null ? `${val.toFixed(1)} kg/m²/yr` : <EmptyCell />}
</div>
);
},
@ -491,7 +518,7 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
header: () => <div className="text-xs">Main Fuel</div>,
cell: ({ row }) => {
const label = resolveEnumLabel(MAINFUEL_OPTIONS, row.original.mainfuel);
return label ? <Pill>{label}</Pill> : <span className="text-slate-300 text-xs"></span>;
return label ? <Pill>{label}</Pill> : <EmptyCell />;
},
},
{
@ -521,7 +548,7 @@ function DescriptorCell({ value }: { value: string | null }) {
<div
className={cn(
"text-sm max-w-[220px] truncate",
val === "Unknown" ? "text-slate-300" : "text-gray-700",
val === "Unknown" ? "text-slate-500" : "text-gray-700",
)}
title={val}
>