From 46421279fba005d3ffa43a9edc9cc5aaf7a59bf3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 13 Jul 2026 21:50:27 +0000 Subject: [PATCH] =?UTF-8?q?feat(portfolio):=20address=20impeccable=20criti?= =?UTF-8?q?que=20=E2=80=94=20no-plans=20state,=20contrast,=20focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 nested inside a @@ -939,7 +939,7 @@ export default function PropertyFilters({ diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 8c8b86c9..a64be71d 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -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 (
- + {isActive && ( + )} - +
{isOpen && (
@@ -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({ 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(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(null); - const [deletePreview, setDeletePreview] = useState< - { table: string; count: number }[] | null - >(null); - const [previewLoading] = useState(false); - const [previewError] = useState(null); - return (
{/* Action bar */} @@ -586,6 +587,18 @@ export default function PropertyTable({ )} + {exportError && ( + + {exportError} + + + )} {hasActiveFilters && ( + +
+
+ )} + {/* Display-limit notice */} {isAtDisplayLimit && hasMore && (
@@ -890,7 +946,6 @@ export default function PropertyTable({ setDeletePropertyId(id)} columnVisibility={columnVisibility} onColumnVisibilityChange={ setColumnVisibility as ( @@ -911,57 +966,6 @@ export default function PropertyTable({ )}
- - {/* Delete preview modal */} - { - if (!open) { - setDeletePropertyId(null); - setDeletePreview(null); - } - }} - > - - - Delete property? - - {previewLoading - ? "Calculating what will be deleted…" - : "This action is permanent. Review the impact below."} - - - - {previewError && ( -

{previewError}

- )} - - {deletePreview && !previewLoading && ( -
    - {deletePreview.map((row) => ( -
  • - - {row.table.replace(/_/g, " ")} - - {row.count} -
  • - ))} -
- )} - - - - -
-
); } diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index 3142be4a..3307ca92 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -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 }) => ( + + — + +); + const EpcLetterBubble = ({ letter }: { letter: string }) => { - if (!letter) return
; + if (!letter) + return ( +
+ – +
+ ); return (
[] = [ header: () =>
Property Ref
, cell: ({ row }) => (
- {row.original.landlordPropertyId ?? "—"} + {row.original.landlordPropertyId ?? }
), }, @@ -333,7 +354,11 @@ const coreColumns: ColumnDef[] = [ const expired = row.original.epcIsExpired; if (!dateStr) { - return
; + return ( +
+ +
+ ); } const lodgementDate = new Date(dateStr); @@ -375,13 +400,13 @@ const coreColumns: ColumnDef[] = [ } return ( -
+
{cost ? ( £{formatNumber(cost)} ) : ( - No cost + )}
); @@ -439,7 +464,7 @@ const optionalColumns: ColumnDef[] = [ header: () =>
Property Type
, cell: ({ row }) => { const val = row.original.propertyType; - return val ? {val} : ; + return val ? {val} : ; }, }, { @@ -448,7 +473,7 @@ const optionalColumns: ColumnDef[] = [ header: () =>
Built Form
, cell: ({ row }) => { const val = row.original.builtForm; - return val ? {val} : ; + return val ? {val} : ; }, }, { @@ -456,7 +481,9 @@ const optionalColumns: ColumnDef[] = [ accessorKey: "yearBuilt", header: () =>
Construction Age
, cell: ({ row }) => ( -
{row.original.yearBuilt ?? "—"}
+
+ {row.original.yearBuilt ?? } +
), }, { @@ -467,7 +494,7 @@ const optionalColumns: ColumnDef[] = [ const val = row.original.totalFloorArea; return (
- {val != null ? `${val.toFixed(1)} m²` : "—"} + {val != null ? `${val.toFixed(1)} m²` : }
); }, @@ -480,7 +507,7 @@ const optionalColumns: ColumnDef[] = [ const val = row.original.co2Emissions; return (
- {val != null ? `${val.toFixed(1)} kg/m²/yr` : "—"} + {val != null ? `${val.toFixed(1)} kg/m²/yr` : }
); }, @@ -491,7 +518,7 @@ const optionalColumns: ColumnDef[] = [ header: () =>
Main Fuel
, cell: ({ row }) => { const label = resolveEnumLabel(MAINFUEL_OPTIONS, row.original.mainfuel); - return label ? {label} : ; + return label ? {label} : ; }, }, { @@ -521,7 +548,7 @@ function DescriptorCell({ value }: { value: string | null }) {