mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
5872e7c335
commit
46421279fb
4 changed files with 236 additions and 127 deletions
|
|
@ -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 5–6 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 5–6 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 50–100 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?
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)} m²` : "—"}
|
||||
{val != null ? `${val.toFixed(1)} m²` : <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}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue