From 41c068c419191e9b0c4f93045d20fcc908ea7c97 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 13 Apr 2026 13:49:34 +0000 Subject: [PATCH] improving visuals of the property table --- src/app/api/properties/route.ts | 12 +- .../[slug]/components/PropertyFilters.tsx | 302 +++++++++++++++--- .../[slug]/components/PropertyTable.tsx | 129 +++++++- .../components/propertyTableColumns.tsx | 186 +++++++---- .../[slug]/components/useProperties.ts | 7 +- src/app/portfolio/[slug]/utils.ts | 140 +++++++- src/app/utils/propertyFilters.ts | 76 ++++- 7 files changed, 707 insertions(+), 145 deletions(-) diff --git a/src/app/api/properties/route.ts b/src/app/api/properties/route.ts index dd79ee9..6a52bb7 100644 --- a/src/app/api/properties/route.ts +++ b/src/app/api/properties/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; -import { getProperties } from "@/app/portfolio/[slug]/utils"; +import { getProperties, getPropertiesCount } from "@/app/portfolio/[slug]/utils"; import { FilterGroups } from "@/app/utils/propertyFilters"; +const PAGE_LIMIT = 1000; + export async function POST(req: NextRequest) { const body = await req.json(); @@ -15,6 +17,10 @@ export async function POST(req: NextRequest) { ); } - const properties = await getProperties(portfolioId, 1000, 0, filterGroups); - return NextResponse.json(properties); + const [data, total] = await Promise.all([ + getProperties(portfolioId, PAGE_LIMIT, 0, filterGroups), + getPropertiesCount(portfolioId, filterGroups), + ]); + + return NextResponse.json({ data, total }); } diff --git a/src/app/portfolio/[slug]/components/PropertyFilters.tsx b/src/app/portfolio/[slug]/components/PropertyFilters.tsx index b295f4f..35de20e 100644 --- a/src/app/portfolio/[slug]/components/PropertyFilters.tsx +++ b/src/app/portfolio/[slug]/components/PropertyFilters.tsx @@ -10,6 +10,12 @@ import { FilterField, FilterOperator, DatePreset, + EnumOption, + PROPERTY_TYPE_OPTIONS, + BUILT_FORM_OPTIONS, + TENURE_OPTIONS, + YEAR_BUILT_OPTIONS, + MAINFUEL_OPTIONS, } from "@/app/utils/propertyFilters"; /* ----------------------------------------------------------------------- @@ -19,33 +25,58 @@ const EPC_LETTERS = ["A", "B", "C", "D", "E", "F", "G"] as const; type EpcLetter = (typeof EPC_LETTERS)[number]; const FIELD_OPTIONS: { value: FilterField; label: string }[] = [ - { value: "currentEpc", label: "Current EPC" }, - { value: "lodgedEpc", label: "Lodged EPC" }, - { value: "expectedEpc", label: "Expected EPC" }, + { value: "currentEpc", label: "Current EPC" }, + { value: "lodgedEpc", label: "Lodged EPC" }, + { value: "expectedEpc", label: "Expected EPC" }, { value: "epcExpiryDate", label: "EPC Expiry Date" }, + { value: "propertyType", label: "Property Type" }, + { value: "builtForm", label: "Built Form" }, + { value: "tenure", label: "Tenure" }, + { value: "yearBuilt", label: "Year Built" }, + { value: "floorArea", label: "Floor Area (m²)" }, + { value: "co2Emissions", label: "CO₂ Emissions (kg/m²/yr)" }, + { value: "mainfuel", label: "Main Fuel" }, ]; const EPC_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [ - { value: "epc_less_than", label: "is worse than" }, - { value: "equals", label: "equals" }, + { value: "epc_less_than", label: "is worse than" }, + { value: "equals", label: "equals" }, { value: "epc_greater_than", label: "is better than" }, - { value: "epc_one_of", label: "is one of" }, + { value: "epc_one_of", label: "is one of" }, ]; const DATE_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [ { value: "date_before", label: "is before" }, - { value: "date_after", label: "is after" }, + { value: "date_after", label: "is after" }, { value: "date_equals", label: "is on" }, { value: "date_preset", label: "preset" }, ]; const DATE_PRESET_OPTIONS: { value: DatePreset; label: string }[] = [ - { value: "expired", label: "Already expired" }, - { value: "expires_this_year", label: "Expiring this year" }, + { value: "expired", label: "Already expired" }, + { value: "expires_this_year", label: "Expiring this year" }, { value: "expires_within_1_year", label: "Expiring within 1 year" }, - { value: "expires_within_2_years", label: "Expiring within 2 years" }, + { value: "expires_within_2_years",label: "Expiring within 2 years" }, ]; +const ENUM_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [ + { value: "enum_one_of", label: "is one of" }, +]; + +const NUM_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [ + { value: "num_gte", label: "≥ (at least)" }, + { value: "num_lte", label: "≤ (at most)" }, + { value: "num_equals", label: "= (equals)" }, +]; + +const ENUM_FIELD_OPTIONS: Record = { + propertyType: PROPERTY_TYPE_OPTIONS, + builtForm: BUILT_FORM_OPTIONS, + tenure: TENURE_OPTIONS, + yearBuilt: YEAR_BUILT_OPTIONS, + mainfuel: MAINFUEL_OPTIONS, +}; + /* ----------------------------------------------------------------------- Helpers ------------------------------------------------------------------------ */ @@ -53,12 +84,26 @@ function isEpcField(field: FilterField) { return field === "currentEpc" || field === "lodgedEpc" || field === "expectedEpc"; } +function isEnumField(field: FilterField): boolean { + return field in ENUM_FIELD_OPTIONS; +} + +function isNumericField(field: FilterField) { + return field === "floorArea" || field === "co2Emissions"; +} + function operatorsForField(field: FilterField): { value: FilterOperator; label: string }[] { if (isEpcField(field)) return EPC_OPERATOR_OPTIONS; if (field === "epcExpiryDate") return DATE_OPERATOR_OPTIONS; + if (isEnumField(field)) return ENUM_OPERATOR_OPTIONS; + if (isNumericField(field)) return NUM_OPERATOR_OPTIONS; return []; } +function defaultOperatorForField(field: FilterField): FilterOperator { + return operatorsForField(field)[0]?.value ?? "equals"; +} + function conditionLabel(condition: PropertyFilter): string { const fieldLabel = FIELD_OPTIONS.find((f) => f.value === condition.field)?.label ?? condition.field; @@ -80,6 +125,20 @@ function conditionLabel(condition: PropertyFilter): string { return `${fieldLabel} ${opLabel} ${condition.value}`; } + if (isEnumField(condition.field) && condition.operator === "enum_one_of") { + try { + const labels: string[] = JSON.parse(condition.value); + return `${fieldLabel} is one of: ${labels.join(", ")}`; + } catch { + return `${fieldLabel} is one of: ${condition.value}`; + } + } + + if (isNumericField(condition.field)) { + const opLabel = NUM_OPERATOR_OPTIONS.find((o) => o.value === condition.operator)?.label ?? condition.operator; + return `${fieldLabel} ${opLabel} ${condition.value}`; + } + return `${fieldLabel} ${condition.operator} ${condition.value}`; } @@ -208,6 +267,133 @@ function EpcDropdown({ ); } +/* ----------------------------------------------------------------------- + Enum Multi-Select Dropdown +------------------------------------------------------------------------ */ +function EnumMultiDropdown({ + options, + selectedLabels, + onChange, +}: { + options: EnumOption[]; + selectedLabels: string[]; + onChange: (labels: string[]) => void; +}) { + const [open, setOpen] = useState(false); + const [dropdownStyle, setDropdownStyle] = useState({}); + const ref = useRef(null); + const buttonRef = useRef(null); + + useEffect(() => { + function handleOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + function handleScroll() { + setOpen(false); + } + if (open) { + document.addEventListener("mousedown", handleOutside); + window.addEventListener("scroll", handleScroll, true); + } + return () => { + document.removeEventListener("mousedown", handleOutside); + window.removeEventListener("scroll", handleScroll, true); + }; + }, [open]); + + function openDropdown() { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setDropdownStyle({ + position: "fixed", + top: rect.bottom + 4, + left: rect.left, + width: Math.max(rect.width, 220), + zIndex: 9999, + maxHeight: 280, + overflowY: "auto", + }); + } + setOpen((o) => !o); + } + + function toggle(label: string) { + onChange( + selectedLabels.includes(label) + ? selectedLabels.filter((l) => l !== label) + : [...selectedLabels, label] + ); + } + + return ( +
+ + + {open && ( +
+ {options.map((opt) => { + const isSelected = selectedLabels.includes(opt.label); + return ( + + ); + })} +
+ )} +
+ ); +} + +/* ----------------------------------------------------------------------- + Number Filter Input +------------------------------------------------------------------------ */ +function NumberFilterInput({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + return ( + onChange(e.target.value)} + /> + ); +} + /* ----------------------------------------------------------------------- Add Filter Form ------------------------------------------------------------------------ */ @@ -223,13 +409,16 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp const [epcSelected, setEpcSelected] = useState([]); const [dateValue, setDateValue] = useState(""); const [preset, setPreset] = useState("expired"); + const [enumSelected, setEnumSelected] = useState([]); + const [numValue, setNumValue] = useState(""); function handleFieldChange(newField: FilterField) { setField(newField); - const ops = operatorsForField(newField); - setOperator(ops[0]?.value ?? "equals"); + setOperator(defaultOperatorForField(newField)); setEpcSelected([]); setDateValue(""); + setEnumSelected([]); + setNumValue(""); } function buildValue(): string { @@ -239,12 +428,17 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp if (field === "epcExpiryDate") { return operator === "date_preset" ? preset : dateValue; } + if (isEnumField(field)) { + return enumSelected.length > 0 ? JSON.stringify(enumSelected) : ""; + } + if (isNumericField(field)) { + return numValue; + } return ""; } function canConfirm(): boolean { - const value = buildValue(); - return value.length > 0; + return buildValue().length > 0; } function handleConfirm() { @@ -262,6 +456,8 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp 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"; + const enumOptions = isEnumField(field) ? ENUM_FIELD_OPTIONS[field] : []; + return (
{/* Field */} @@ -324,6 +520,18 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp onChange={(e) => setDateValue(e.target.value)} /> )} + + {isEnumField(field) && ( + + )} + + {isNumericField(field) && ( + + )}
{/* Actions */} @@ -387,28 +595,28 @@ export default function PropertyFilters({ }) { // Draft state — only applied when user clicks Apply const [draft, setDraft] = useState(filterGroups); - // "and" = new group, "or:" = OR into existing group, null = hidden - const [addMode, setAddMode] = useState<"and" | `or:${string}` | null>(null); + // "or" = new OR group, "and:" = AND condition into existing group, null = hidden + const [addMode, setAddMode] = useState<"or" | `and:${string}` | null>(null); - function openAndFilter() { - setAddMode("and"); + function openOrGroup() { + setAddMode("or"); } - function openOrFilter(groupId: string) { - setAddMode(`or:${groupId}`); + function openAndCondition(groupId: string) { + setAddMode(`and:${groupId}`); } function handleConfirm(groupId: string | null, condition: PropertyFilter) { setDraft((prev) => { if (groupId === null) { - // New AND group + // New OR group const newGroup: FilterGroup = { id: crypto.randomUUID(), conditions: [condition], }; return [...prev, newGroup]; } else { - // Add OR-condition to existing group + // Add AND-condition to existing group return prev.map((g) => g.id === groupId ? { ...g, conditions: [...g.conditions, condition] } @@ -447,31 +655,31 @@ export default function PropertyFilters({
{/* Group list */} {draft.map((group, groupIdx) => { - const isOrTarget = addMode === `or:${group.id}`; + const isAndTarget = addMode === `and:${group.id}`; return (
- {/* AND divider between groups */} + {/* OR divider between groups */} {groupIdx > 0 && (
-
- - AND +
+ + OR -
+
)}
{group.conditions.map((condition, condIdx) => (
- {/* OR label between conditions in same group */} + {/* AND label between conditions in same group */} {condIdx > 0 && (
-
- - OR +
+ + AND -
+
)} ))} - {/* OR form inline */} - {isOrTarget ? ( + {/* AND form inline */} + {isAndTarget ? ( openOrFilter(group.id)} - className="self-start mt-1 flex items-center gap-1 text-[11px] font-semibold text-blue-500 hover:text-blue-700 bg-blue-50 hover:bg-blue-100 border border-blue-200 rounded px-2 py-0.5 transition" + onClick={() => openAndCondition(group.id)} + className="self-start mt-1 flex items-center gap-1 text-[11px] font-semibold text-gray-500 hover:text-gray-700 bg-white hover:bg-gray-50 border border-gray-300 rounded px-2 py-0.5 transition" > - OR + AND )}
@@ -503,8 +711,8 @@ export default function PropertyFilters({ ); })} - {/* AND: add new group form or button */} - {addMode === "and" ? ( + {/* OR: add new group form or button */} + {addMode === "or" ? ( {hasGroups && ( <> -
- - then +
+ + or -
+
)}
)} diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index ae191c1..2c1bc75 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -6,14 +6,17 @@ import DataTable from "./dataTable"; import PropertyFilters from "./PropertyFilters"; import { FilterGroups } from "@/app/utils/propertyFilters"; import { - HomeIcon, FunnelIcon, ChevronDownIcon, XMarkIcon, ArrowDownTrayIcon, ViewColumnsIcon, } from "@heroicons/react/24/outline"; +import { HomeIcon } from "@heroicons/react/24/outline"; +import { sapToEpc } from "@/app/utils"; import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns"; +import { PropertyWithRelations } from "@/app/db/schema/property"; +import { TENURE_OPTIONS, MAINFUEL_OPTIONS, EnumOption } from "@/app/utils/propertyFilters"; import { OPTIONAL_COLUMN_IDS, OPTIONAL_COLUMN_LABELS, @@ -39,6 +42,76 @@ import { } from "@/app/shadcn_components/ui/dialog"; import { Button } from "@/app/shadcn_components/ui/button"; +/* ---------------------------------------- + Export helpers +----------------------------------------- */ +const EXPORT_LIMIT = 1000; + +function resolveEnumLabel(options: EnumOption[], dbValue: string | null | undefined): string { + if (dbValue == null) return ""; + const opt = options.find((o) => o.dbValues.includes(dbValue)); + return opt?.label ?? dbValue; +} + +function exportToCsv(data: PropertyWithRelations[]) { + const headers = [ + "Address", "Postcode", "Property Ref", + "Current EPC", "Lodged EPC", "Expected EPC", + "EPC Expiry", "EPC Expired", + "Plan Cost (£)", + "Property Type", "Built Form", "Tenure", "Year Built", + "Floor Area (m²)", "CO₂ Emissions (kg/m²/yr)", "Main Fuel", + ]; + + const rows = data.map((p) => { + const lodgedLetter = p.originalSapPoints ? sapToEpc(p.originalSapPoints) ?? "" : ""; + const expectedSap = (p.currentSapPoints ?? 0) + (p.totalRecommendationSapPoints ?? 0); + const expectedLetter = expectedSap > 0 ? sapToEpc(expectedSap) ?? "" : ""; + + let expiryStr = ""; + if (p.epcLodgementDate) { + const expiry = new Date(p.epcLodgementDate); + expiry.setFullYear(expiry.getFullYear() + 10); + expiryStr = expiry.toLocaleDateString("en-GB"); + } + + return [ + p.address ?? "", + p.postcode ?? "", + p.landlordPropertyId ?? "", + p.currentEpcRating ?? "", + lodgedLetter, + expectedLetter, + expiryStr, + p.epcIsExpired ? "Yes" : "No", + p.totalRecommendationCost ? p.totalRecommendationCost.toFixed(2) : "", + p.propertyType ?? "", + p.builtForm ?? "", + resolveEnumLabel(TENURE_OPTIONS, p.tenure), + p.yearBuilt ?? "", + p.totalFloorArea != null ? p.totalFloorArea.toFixed(1) : "", + p.co2Emissions != null ? p.co2Emissions.toFixed(1) : "", + resolveEnumLabel(MAINFUEL_OPTIONS, p.mainfuel), + ]; + }); + + const csv = [headers, ...rows] + .map((row) => + row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",") + ) + .join("\n"); + + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "properties.csv"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + /* ---------------------------------------- Empty portfolio state ----------------------------------------- */ @@ -281,7 +354,7 @@ export default function PropertyTable({ const hasActiveFilters = allFilterGroups.length > 0; const { - data = [], + data: filteredResponse, isLoading, isFetching, isError, @@ -290,13 +363,18 @@ export default function PropertyTable({ filterGroups: allFilterGroups, }); + const data = filteredResponse?.data ?? []; + const filteredTotal = filteredResponse?.total ?? 0; + // Second query for total (no filters) — React Query dedupes when filters are empty - const { data: allData = [] } = useProperties({ + const { data: allResponse } = useProperties({ portfolioId, filterGroups: [], }); - const totalCount = allData.length; - const filteredCount = data.length; + const totalCount = allResponse?.total ?? 0; + + const DISPLAY_LIMIT = 1000; + const isAtDisplayLimit = data.length >= DISPLAY_LIMIT && filteredTotal > DISPLAY_LIMIT; /* ---------------------------------------- Delete preview state @@ -323,7 +401,7 @@ export default function PropertyTable({ Showing{" "} - {filteredCount.toLocaleString()} + {filteredTotal.toLocaleString()} {" "} of{" "} @@ -401,11 +479,27 @@ export default function PropertyTable({ - {/* Export (stub) */} - + {/* Export */} + {filteredTotal > EXPORT_LIMIT ? ( +
+ +
+ ) : ( + + )}
@@ -460,6 +554,19 @@ export default function PropertyTable({ />
+ {/* Display-limit notice */} + {isAtDisplayLimit && ( +
+ + Your filters match{" "} + {filteredTotal.toLocaleString()}{" "} + properties — only the first{" "} + {DISPLAY_LIMIT.toLocaleString()}{" "} + are shown. Refine your filters to narrow results. + +
+ )} + {/* Body: sidebar + table */}
{/* Collapsible filter sidebar */} diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index 300e552..6f9efb5 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -11,21 +11,55 @@ import { } from "@/app/shadcn_components/ui/dropdown-menu"; import { Button } from "@/app/shadcn_components/ui/button"; import { ArrowUpDown, MoreHorizontal } from "lucide-react"; -import { HomeIcon } from "@heroicons/react/20/solid"; import { FunnelIcon } from "@heroicons/react/24/outline"; import { formatNumber, getEpcColorClass, sapToEpc } from "@/app/utils"; import { cn } from "@/lib/utils"; import { PropertyWithRelations } from "@/app/db/schema/property"; import { X } from "lucide-react"; +import { + EnumOption, + TENURE_OPTIONS, + MAINFUEL_OPTIONS, +} from "@/app/utils/propertyFilters"; -interface DataTableColumnHeaderProps< - TData, - TValue, -> extends React.HTMLAttributes { - column: Column; - title: string; +/* ----------------------------------------------------------------------- + Helpers +------------------------------------------------------------------------ */ +function resolveEnumLabel( + options: EnumOption[], + dbValue: string | null | undefined +): string | null { + if (dbValue == null) return null; + const opt = options.find((o) => o.dbValues.includes(dbValue)); + return opt?.label ?? dbValue; } +function tenureBadgeClass(label: string): string { + if (label.toLowerCase().includes("owner")) return "bg-blue-50 text-blue-700"; + if (label.toLowerCase().includes("private")) return "bg-violet-50 text-violet-700"; + if (label.toLowerCase().includes("social")) return "bg-emerald-50 text-emerald-700"; + return "bg-slate-100 text-slate-500"; +} + +function Pill({ + children, + className = "bg-slate-100 text-slate-600", +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + +/* ----------------------------------------------------------------------- + EPC letter bubble +------------------------------------------------------------------------ */ const EpcLetterBubble = ({ letter }: { letter: string }) => { if (!letter) return
; return ( @@ -39,6 +73,15 @@ const EpcLetterBubble = ({ letter }: { letter: string }) => { ); }; +/* ----------------------------------------------------------------------- + Column header with dropdown filter +------------------------------------------------------------------------ */ +interface DataTableColumnHeaderProps + extends React.HTMLAttributes { + column: Column; + title: string; +} + export function DataTableFilterHeader({ column, title, @@ -67,7 +110,6 @@ export function DataTableFilterHeader({ - ({ ); } +/* ----------------------------------------------------------------------- + Column metadata +------------------------------------------------------------------------ */ export const OPTIONAL_COLUMN_IDS = [ "propertyType", "builtForm", @@ -130,6 +175,9 @@ const OPTIONAL_COLUMN_LABELS: Record = { export { OPTIONAL_COLUMN_LABELS }; +/* ----------------------------------------------------------------------- + Core columns +------------------------------------------------------------------------ */ const coreColumns: ColumnDef[] = [ { accessorKey: "address", @@ -145,42 +193,25 @@ const coreColumns: ColumnDef[] = [ ), cell: ({ row }) => { const address = String(row.getValue("address")); + const postcode = row.original.postcode; const propertyId = row.original.id; const portfolioId = row.original.portfolioId; return ( -
- - +
+ + {address} + + {postcode && ( + {postcode} + )}
); }, }, - { - accessorKey: "postcode", - enableGlobalFilter: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
- {row.original.postcode} -
- ), - }, { accessorKey: "landlordPropertyId", header: () =>
Property Ref
, @@ -193,7 +224,7 @@ const coreColumns: ColumnDef[] = [ { accessorKey: "currentEpc", header: () => ( -
Current EPC Performance
+
Current EPC
), cell: ({ row }) => (
@@ -204,7 +235,7 @@ const coreColumns: ColumnDef[] = [ { accessorKey: "originalSapPoints", header: () => ( -
Lodged EPC Rating
+
Lodged EPC
), cell: ({ row }) => { const originalSap = row.original.originalSapPoints; @@ -251,43 +282,56 @@ const coreColumns: ColumnDef[] = [ const expired = row.original.epcIsExpired; if (!dateStr) { - return
; + return
; } - const formatted = new Date(dateStr).toLocaleDateString("en-GB", { - day: "2-digit", + const lodgementDate = new Date(dateStr); + const expiryDate = new Date(lodgementDate); + expiryDate.setFullYear(expiryDate.getFullYear() + 10); + + const formatted = expiryDate.toLocaleDateString("en-GB", { month: "short", year: "numeric", }); + if (expired) { + return ( +
+ + Expired + + {formatted} +
+ ); + } + return ( -
- - {formatted} - - {expired && ( - (Exp) - )} +
+ {formatted}
); }, }, { accessorKey: "cost", - header: () =>
Cost
, + header: () =>
Plan Cost
, cell: ({ row }) => { const cost = row.original.totalRecommendationCost; const creationStatus = row.original.creationStatus; if (creationStatus === "LOADING") { - return
; + return
; } - const formatted = cost ? "£" + formatNumber(cost) : ""; - return ( -
- {formatted} +
+ {cost ? ( + + £{formatNumber(cost)} + + ) : ( + No cost + )}
); }, @@ -334,30 +378,37 @@ const coreColumns: ColumnDef[] = [ }, ]; +/* ----------------------------------------------------------------------- + Optional columns +------------------------------------------------------------------------ */ const optionalColumns: ColumnDef[] = [ { id: "propertyType", accessorKey: "propertyType", header: () =>
Property Type
, - cell: ({ row }) => ( -
{row.original.propertyType ?? "—"}
- ), + cell: ({ row }) => { + const val = row.original.propertyType; + return val ? {val} : ; + }, }, { id: "builtForm", accessorKey: "builtForm", header: () =>
Built Form
, - cell: ({ row }) => ( -
{row.original.builtForm ?? "—"}
- ), + cell: ({ row }) => { + const val = row.original.builtForm; + return val ? {val} : ; + }, }, { id: "tenure", accessorKey: "tenure", header: () =>
Tenure
, - cell: ({ row }) => ( -
{row.original.tenure ?? "—"}
- ), + cell: ({ row }) => { + const label = resolveEnumLabel(TENURE_OPTIONS, row.original.tenure); + if (!label) return ; + return {label}; + }, }, { id: "yearBuilt", @@ -388,7 +439,7 @@ const optionalColumns: ColumnDef[] = [ const val = row.original.co2Emissions; return (
- {val != null ? `${val.toFixed(1)} t/yr` : "—"} + {val != null ? `${val.toFixed(1)} kg/m²/yr` : "—"}
); }, @@ -397,9 +448,10 @@ const optionalColumns: ColumnDef[] = [ id: "mainfuel", accessorKey: "mainfuel", header: () =>
Main Fuel
, - cell: ({ row }) => ( -
{row.original.mainfuel ?? "—"}
- ), + cell: ({ row }) => { + const label = resolveEnumLabel(MAINFUEL_OPTIONS, row.original.mainfuel); + return label ? {label} : ; + }, }, ]; diff --git a/src/app/portfolio/[slug]/components/useProperties.ts b/src/app/portfolio/[slug]/components/useProperties.ts index aab3914..f33bc88 100644 --- a/src/app/portfolio/[slug]/components/useProperties.ts +++ b/src/app/portfolio/[slug]/components/useProperties.ts @@ -7,8 +7,13 @@ interface Params { filterGroups: FilterGroups; } +export interface PropertiesResponse { + data: PropertyWithRelations[]; + total: number; +} + export function useProperties({ portfolioId, filterGroups }: Params) { - return useQuery({ + return useQuery({ queryKey: ["properties", portfolioId, filterGroups], queryFn: async () => { const res = await fetch("/api/properties", { diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index 60d7e5a..8d7ff89 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -19,9 +19,26 @@ import { ScenarioSelect, } from "@/app/db/schema/recommendations"; import { sql } from "drizzle-orm"; -import { FilterGroups, PropertyFilter } from "@/app/utils/propertyFilters"; +import { + FilterGroups, + PropertyFilter, + PROPERTY_TYPE_OPTIONS, + BUILT_FORM_OPTIONS, + TENURE_OPTIONS, + YEAR_BUILT_OPTIONS, + MAINFUEL_OPTIONS, + EnumOption, +} from "@/app/utils/propertyFilters"; import { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/app/utils/epc"; +const ENUM_FIELD_DB_OPTIONS: Record = { + propertyType: PROPERTY_TYPE_OPTIONS, + builtForm: BUILT_FORM_OPTIONS, + tenure: TENURE_OPTIONS, + yearBuilt: YEAR_BUILT_OPTIONS, + mainfuel: MAINFUEL_OPTIONS, +}; + export interface PortfolioSettingsType { name: string; budget: number | null; @@ -539,18 +556,85 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul } return null; } + + case "propertyType": + case "builtForm": + case "tenure": + case "yearBuilt": + case "mainfuel": { + if (filter.operator !== "enum_one_of") return null; + + let selectedLabels: string[]; + try { + selectedLabels = JSON.parse(filter.value); + } catch { + return null; + } + if (selectedLabels.length === 0) return null; + + const options = ENUM_FIELD_DB_OPTIONS[filter.field]; + const colMap: Record> = { + propertyType: sql`p.property_type`, + builtForm: sql`p.built_form`, + tenure: sql`p.tenure`, + yearBuilt: sql`p.year_built`, + mainfuel: sql`epc.mainfuel`, + }; + const col = colMap[filter.field]; + + // Flatten all dbValues for selected labels + const allDbValues: string[] = []; + let includeNull = false; + + for (const label of selectedLabels) { + const opt = options.find((o) => o.label === label); + if (!opt) continue; + for (const v of opt.dbValues) { + if (v === "__null__") { + includeNull = true; + } else { + allDbValues.push(v); + } + } + } + + const parts: ReturnType[] = []; + if (includeNull) { + parts.push(sql`${col} IS NULL`); + } + if (allDbValues.length > 0) { + // Build IN clause with each value as a separate param + const placeholders = allDbValues.map((v) => sql`${v}`); + parts.push(sql`${col} IN (${sql.join(placeholders, sql`, `)})`); + } + + if (parts.length === 0) return null; + if (parts.length === 1) return parts[0]; + return sql`(${sql.join(parts, sql` OR `)})`; + } + + case "floorArea": { + const n = parseFloat(filter.value); + if (isNaN(n)) return null; + if (filter.operator === "num_gte") return sql`epc.total_floor_area >= ${n}`; + if (filter.operator === "num_lte") return sql`epc.total_floor_area <= ${n}`; + if (filter.operator === "num_equals") return sql`epc.total_floor_area = ${n}`; + return null; + } + + case "co2Emissions": { + const n = parseFloat(filter.value); + if (isNaN(n)) return null; + if (filter.operator === "num_gte") return sql`epc.co2_emissions >= ${n}`; + if (filter.operator === "num_lte") return sql`epc.co2_emissions <= ${n}`; + if (filter.operator === "num_equals") return sql`epc.co2_emissions = ${n}`; + return null; + } } return null; } -export async function getProperties( - portfolioId: string, - limit: number = 1000, - offset: number = 0, - filterGroups: FilterGroups = [] -): Promise { - // We need to perform the query like this because the nested query is not supported in the ORM right now - +function buildWhereClause(filterGroups: FilterGroups): ReturnType { const groupFragments: ReturnType[] = []; for (const group of filterGroups) { @@ -563,14 +647,42 @@ export async function getProperties( if (condFragments.length === 1) { groupFragments.push(condFragments[0]); } else { - groupFragments.push(sql`(${sql.join(condFragments, sql` OR `)})`); + groupFragments.push(sql`(${sql.join(condFragments, sql` AND `)})`); } } - const combinedWhere = - groupFragments.length > 0 - ? sql`AND (${sql.join(groupFragments, sql` AND `)})` - : sql``; + return groupFragments.length > 0 + ? sql`AND (${sql.join(groupFragments, sql` OR `)})` + : sql``; +} + +export async function getPropertiesCount( + portfolioId: string, + filterGroups: FilterGroups = [] +): Promise { + const combinedWhere = buildWhereClause(filterGroups); + + const result = await db.execute<{ count: string }>(sql` + SELECT COUNT(DISTINCT p.id)::int AS count + FROM property p + LEFT JOIN property_details_epc epc ON epc.property_id = p.id + LEFT JOIN plan pl ON pl.property_id = p.id AND pl.is_default = true + WHERE p.portfolio_id = ${portfolioId} + ${combinedWhere} + `); + + return parseInt(result.rows[0]?.count ?? "0", 10); +} + +export async function getProperties( + portfolioId: string, + limit: number = 1000, + offset: number = 0, + filterGroups: FilterGroups = [] +): Promise { + // We need to perform the query like this because the nested query is not supported in the ORM right now + + const combinedWhere = buildWhereClause(filterGroups); const result = await db.execute(sql` diff --git a/src/app/utils/propertyFilters.ts b/src/app/utils/propertyFilters.ts index 16f6798..e67309a 100644 --- a/src/app/utils/propertyFilters.ts +++ b/src/app/utils/propertyFilters.ts @@ -5,7 +5,14 @@ export type FilterField = | "expectedEpc" | "lodgedEpc" | "epcExpiryDate" - | "propertyRef"; + | "propertyRef" + | "propertyType" + | "builtForm" + | "tenure" + | "yearBuilt" + | "floorArea" + | "co2Emissions" + | "mainfuel"; export type FilterOperator = | "contains" @@ -19,7 +26,11 @@ export type FilterOperator = | "date_before" | "date_after" | "date_equals" - | "date_preset"; + | "date_preset" + | "enum_one_of" + | "num_gte" + | "num_lte" + | "num_equals"; export type DatePreset = | "expired" @@ -40,3 +51,64 @@ export interface FilterGroup { } export type FilterGroups = FilterGroup[]; + +/* ----------------------------------------------------------------------- + Enum option definitions for categorical filter fields +------------------------------------------------------------------------ */ + +export interface EnumOption { + /** User-facing display label */ + label: string; + /** Actual DB values to match. Use ["__null__"] to match NULL. */ + dbValues: string[]; +} + +export const PROPERTY_TYPE_OPTIONS: EnumOption[] = [ + { label: "House", dbValues: ["House"] }, + { label: "Flat", dbValues: ["Flat"] }, + { label: "Bungalow", dbValues: ["Bungalow"] }, + { label: "Maisonette", dbValues: ["Maisonette"] }, +]; + +export const BUILT_FORM_OPTIONS: EnumOption[] = [ + { label: "Detached", dbValues: ["Detached"] }, + { label: "Semi-Detached", dbValues: ["Semi-Detached"] }, + { label: "End-Terrace", dbValues: ["End-Terrace"] }, + { label: "Mid-Terrace", dbValues: ["Mid-Terrace"] }, + { label: "Enclosed End-Terrace", dbValues: ["Enclosed End-Terrace"] }, + { label: "Enclosed Mid-Terrace", dbValues: ["Enclosed Mid-Terrace"] }, + { label: "Not Recorded", dbValues: ["Not Recorded"] }, +]; + +export const TENURE_OPTIONS: EnumOption[] = [ + { label: "Owner-occupied", dbValues: ["Owner-occupied", "owner-occupied"] }, + { label: "Rented (Private)", dbValues: ["Rented (private)", "rental (private)", "rented (private)"] }, + { label: "Rented (Social)", dbValues: ["Rented (social)", "rental (social)"] }, + { label: "Not Defined", dbValues: ["Not defined - use in the case of a new dwelling for which the intended tenure in not known. It is not to be used for an existing dwelling"] }, + { label: "Unknown", dbValues: ["unknown"] }, + { label: "Not Recorded", dbValues: ["__null__"] }, +]; + +export const YEAR_BUILT_OPTIONS: EnumOption[] = [ + "1900","1930","1950","1967","1976","1983","1991","1996", + "2003","2007","2008","2009","2010","2011","2012","2013", + "2014","2015","2016","2017","2018","2019","2020","2021", + "2022","2023","2024","2025", +].map((y) => ({ label: y, dbValues: [y] })); + +export const MAINFUEL_OPTIONS: EnumOption[] = [ + { label: "Mains Gas", dbValues: ["Gas mains gas", "Mains gas community", "Mains gas not community", "Mains gas this is for backwards compatibility only and should not be used"] }, + { label: "LPG", dbValues: ["Bottled lpg", "Lpg community", "Lpg not community", "Lpg this is for backwards compatibility only and should not be used"] }, + { label: "Oil", dbValues: ["Oil heating oil", "Oil community", "Oil not community", "Oil this is for backwards compatibility only and should not be used"] }, + { label: "Electricity", dbValues: ["Electricity electricity unspecified tariff", "Electricity community", "Electricity not community", "Electricity this is for backwards compatibility only and should not be used"] }, + { label: "Biomass", dbValues: ["Bulk wood pellets", "Wood chips", "Wood logs", "Biomass community", "Biomass this is for backwards compatibility only and should not be used"] }, + { label: "Coal", dbValues: ["Anthracite", "House coal not community", "House coal this is for backwards compatibility only and should not be used", "Smokeless coal", "Coal community"] }, + { label: "Dual Fuel (Mineral + Wood)", dbValues: ["Dual fuel mineral wood"] }, + { label: "Biogas", dbValues: ["Biogas not community"] }, + { label: "Biodiesel", dbValues: ["Heat from boilers using biodiesel from any biomass source community"] }, + { label: "B30d (Biodiesel blend)", dbValues: ["B30d community"] }, + { label: "B30k (Biodiesel blend)", dbValues: ["B30k not community"] }, + { label: "Community Heat Network", dbValues: ["From heat network data community"] }, + { label: "No Heating System", dbValues: ["To be used only when there is no heatinghotwater system", "To be used only when there is no heatinghotwater system or data is from a community network"] }, + { label: "Unknown / No Data", dbValues: ["UNKNOWN", "NO DATA!"] }, +];