improving visuals of the property table

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-13 13:49:34 +00:00
parent 08af8e3bf1
commit 41c068c419
7 changed files with 707 additions and 145 deletions

View file

@ -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 });
}

View file

@ -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<string, EnumOption[]> = {
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<React.CSSProperties>({});
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(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 (
<div className="relative" ref={ref}>
<button
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"
>
<span className="flex items-center gap-1 flex-wrap min-h-[1.25rem] text-left">
{selectedLabels.length === 0 ? (
<span className="text-gray-400">Select options</span>
) : (
<span className="text-gray-700 truncate">{selectedLabels.join(", ")}</span>
)}
</span>
<ChevronDown className={`h-4 w-4 text-gray-400 shrink-0 ml-1 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div style={dropdownStyle} className="rounded-md border border-gray-200 bg-white shadow-lg py-1">
{options.map((opt) => {
const isSelected = selectedLabels.includes(opt.label);
return (
<button
key={opt.label}
type="button"
onClick={() => toggle(opt.label)}
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-sm transition hover:bg-gray-50 text-left ${isSelected ? "bg-gray-50" : ""}`}
>
<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 && <Check className="h-2.5 w-2.5 text-white" />}
</span>
</button>
);
})}
</div>
)}
</div>
);
}
/* -----------------------------------------------------------------------
Number Filter Input
------------------------------------------------------------------------ */
function NumberFilterInput({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
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"
placeholder="Enter value…"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
/* -----------------------------------------------------------------------
Add Filter Form
------------------------------------------------------------------------ */
@ -223,13 +409,16 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
const [epcSelected, setEpcSelected] = useState<string[]>([]);
const [dateValue, setDateValue] = useState("");
const [preset, setPreset] = useState<DatePreset>("expired");
const [enumSelected, setEnumSelected] = useState<string[]>([]);
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 (
<div className="flex flex-col gap-2 p-3 bg-gray-50 rounded-md border border-gray-200">
{/* Field */}
@ -324,6 +520,18 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
onChange={(e) => setDateValue(e.target.value)}
/>
)}
{isEnumField(field) && (
<EnumMultiDropdown
options={enumOptions}
selectedLabels={enumSelected}
onChange={setEnumSelected}
/>
)}
{isNumericField(field) && (
<NumberFilterInput value={numValue} onChange={setNumValue} />
)}
</div>
{/* Actions */}
@ -387,28 +595,28 @@ export default function PropertyFilters({
}) {
// Draft state — only applied when user clicks Apply
const [draft, setDraft] = useState<FilterGroups>(filterGroups);
// "and" = new group, "or:<groupId>" = OR into existing group, null = hidden
const [addMode, setAddMode] = useState<"and" | `or:${string}` | null>(null);
// "or" = new OR group, "and:<groupId>" = 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({
<div className="flex flex-col gap-3 p-3">
{/* Group list */}
{draft.map((group, groupIdx) => {
const isOrTarget = addMode === `or:${group.id}`;
const isAndTarget = addMode === `and:${group.id}`;
return (
<div key={group.id}>
{/* AND divider between groups */}
{/* OR divider between groups */}
{groupIdx > 0 && (
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 border-t border-gray-300" />
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-wide bg-white px-1.5 py-0.5 rounded border border-gray-300">
AND
<div className="flex-1 border-t border-blue-200" />
<span className="text-[10px] font-bold text-blue-500 uppercase tracking-wide bg-blue-50 px-1.5 py-0.5 rounded border border-blue-200">
OR
</span>
<div className="flex-1 border-t border-gray-300" />
<div className="flex-1 border-t border-blue-200" />
</div>
)}
<div className="flex flex-col gap-1.5 bg-white border border-gray-200 rounded-md p-2">
{group.conditions.map((condition, condIdx) => (
<div key={condition.id}>
{/* OR label between conditions in same group */}
{/* AND label between conditions in same group */}
{condIdx > 0 && (
<div className="flex items-center gap-2 my-1">
<div className="flex-1 border-t border-dashed border-blue-200" />
<span className="text-[10px] font-bold text-blue-500 uppercase tracking-wide bg-blue-50 px-1.5 py-0.5 rounded border border-blue-200">
OR
<div className="flex-1 border-t border-dashed border-gray-300" />
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-wide bg-white px-1.5 py-0.5 rounded border border-gray-300">
AND
</span>
<div className="flex-1 border-t border-dashed border-blue-200" />
<div className="flex-1 border-t border-dashed border-gray-300" />
</div>
)}
<ConditionRow
@ -481,8 +689,8 @@ export default function PropertyFilters({
</div>
))}
{/* OR form inline */}
{isOrTarget ? (
{/* AND form inline */}
{isAndTarget ? (
<AddFilterForm
targetGroupId={group.id}
onConfirm={handleConfirm}
@ -491,11 +699,11 @@ export default function PropertyFilters({
) : (
<button
type="button"
onClick={() => 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"
>
<Plus className="h-3 w-3" />
OR
AND
</button>
)}
</div>
@ -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" ? (
<AddFilterForm
targetGroupId={null}
onConfirm={handleConfirm}
@ -514,25 +722,25 @@ export default function PropertyFilters({
<div className={hasGroups ? "flex items-center gap-2" : undefined}>
{hasGroups && (
<>
<div className="flex-1 border-t border-gray-300" />
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wide px-1">
then
<div className="flex-1 border-t border-blue-200" />
<span className="text-[10px] font-bold text-blue-400 uppercase tracking-wide px-1">
or
</span>
<div className="flex-1 border-t border-gray-300" />
<div className="flex-1 border-t border-blue-200" />
</>
)}
<button
type="button"
onClick={openAndFilter}
onClick={openOrGroup}
className={[
"flex items-center gap-1 text-sm font-semibold transition",
hasGroups
? "shrink-0 text-gray-600 hover:text-gray-900 bg-white border border-gray-300 rounded px-2 py-0.5 text-[11px] hover:border-gray-400"
? "shrink-0 text-blue-500 hover:text-blue-700 bg-blue-50 border border-blue-200 rounded px-2 py-0.5 text-[11px] hover:border-blue-400"
: "w-full justify-center text-gray-600 hover:text-gray-900 border border-dashed border-gray-300 rounded-md px-3 py-2 hover:border-gray-400",
].join(" ")}
>
<Plus className="h-3.5 w-3.5" />
{hasGroups ? "AND" : "Add filter"}
{hasGroups ? "OR" : "Add filter"}
</button>
</div>
)}

View file

@ -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({
<span className="text-xs text-slate-500">
Showing{" "}
<span className="font-bold text-primary">
{filteredCount.toLocaleString()}
{filteredTotal.toLocaleString()}
</span>{" "}
of{" "}
<span className="font-bold text-primary">
@ -401,11 +479,27 @@ export default function PropertyTable({
</DropdownMenuContent>
</DropdownMenu>
{/* Export (stub) */}
<button className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-primary hover:bg-slate-200 transition">
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
Export
</button>
{/* Export */}
{filteredTotal > EXPORT_LIMIT ? (
<div title={`Export is available for up to ${EXPORT_LIMIT.toLocaleString()} properties. Refine your filters to enable it.`}>
<button
disabled
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-slate-400 cursor-not-allowed opacity-60"
>
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
Export
</button>
</div>
) : (
<button
onClick={() => exportToCsv(data)}
disabled={isLoading || data.length === 0}
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-primary hover:bg-slate-200 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
Export
</button>
)}
</div>
</div>
@ -460,6 +554,19 @@ export default function PropertyTable({
/>
</div>
{/* Display-limit notice */}
{isAtDisplayLimit && (
<div className="mb-3 flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
<span>
Your filters match{" "}
<span className="font-semibold">{filteredTotal.toLocaleString()}</span>{" "}
properties only the first{" "}
<span className="font-semibold">{DISPLAY_LIMIT.toLocaleString()}</span>{" "}
are shown. Refine your filters to narrow results.
</span>
</div>
)}
{/* Body: sidebar + table */}
<div className="flex items-start">
{/* Collapsible filter sidebar */}

View file

@ -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<HTMLDivElement> {
column: Column<TData, TValue>;
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 (
<span
className={`inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wide whitespace-nowrap ${className}`}
>
{children}
</span>
);
}
/* -----------------------------------------------------------------------
EPC letter bubble
------------------------------------------------------------------------ */
const EpcLetterBubble = ({ letter }: { letter: string }) => {
if (!letter) return <div className="w-6 h-6" />;
return (
@ -39,6 +73,15 @@ const EpcLetterBubble = ({ letter }: { letter: string }) => {
);
};
/* -----------------------------------------------------------------------
Column header with dropdown filter
------------------------------------------------------------------------ */
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
}
export function DataTableFilterHeader<TData, TValue>({
column,
title,
@ -67,7 +110,6 @@ export function DataTableFilterHeader<TData, TValue>({
<FunnelIcon className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-80 overflow-y-auto min-w-[10rem]"
@ -106,6 +148,9 @@ export function DataTableFilterHeader<TData, TValue>({
);
}
/* -----------------------------------------------------------------------
Column metadata
------------------------------------------------------------------------ */
export const OPTIONAL_COLUMN_IDS = [
"propertyType",
"builtForm",
@ -130,6 +175,9 @@ const OPTIONAL_COLUMN_LABELS: Record<OptionalColumnId, string> = {
export { OPTIONAL_COLUMN_LABELS };
/* -----------------------------------------------------------------------
Core columns
------------------------------------------------------------------------ */
const coreColumns: ColumnDef<PropertyWithRelations>[] = [
{
accessorKey: "address",
@ -145,42 +193,25 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
),
cell: ({ row }) => {
const address = String(row.getValue("address"));
const postcode = row.original.postcode;
const propertyId = row.original.id;
const portfolioId = row.original.portfolioId;
return (
<div className="flex items-center space-x-2">
<HomeIcon className="h-8 w-8 text-brandmidblue" />
<div className="flex flex-col">
<a
href={`${portfolioId}/building-passport/${propertyId}`}
className="font-medium underline text-gray-800 cursor-pointer"
>
{address}
</a>
</div>
<div className="flex flex-col gap-0.5">
<a
href={`${portfolioId}/building-passport/${propertyId}`}
className="text-xs font-bold text-primary hover:text-secondary transition-colors truncate"
>
{address}
</a>
{postcode && (
<span className="text-[10px] text-slate-400">{postcode}</span>
)}
</div>
);
},
},
{
accessorKey: "postcode",
enableGlobalFilter: true,
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Postcode
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<div className="text-gray-700 font-medium flex justify-center">
{row.original.postcode}
</div>
),
},
{
accessorKey: "landlordPropertyId",
header: () => <div className="text-xs font-medium">Property Ref</div>,
@ -193,7 +224,7 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
{
accessorKey: "currentEpc",
header: () => (
<div className="flex justify-center text-xs">Current EPC Performance</div>
<div className="flex justify-center text-xs">Current EPC</div>
),
cell: ({ row }) => (
<div className="text-gray-700 font-medium flex justify-center">
@ -204,7 +235,7 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
{
accessorKey: "originalSapPoints",
header: () => (
<div className="flex justify-center text-xs">Lodged EPC Rating</div>
<div className="flex justify-center text-xs">Lodged EPC</div>
),
cell: ({ row }) => {
const originalSap = row.original.originalSapPoints;
@ -251,43 +282,56 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
const expired = row.original.epcIsExpired;
if (!dateStr) {
return <div className="text-center text-gray-400 text-xs"></div>;
return <div className="text-center text-slate-400 text-xs"></div>;
}
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 (
<div className="flex flex-col gap-0.5">
<span className="text-[9px] font-bold text-red-600 bg-red-50 px-1.5 py-0.5 rounded w-max uppercase tracking-wide">
Expired
</span>
<span className="text-[10px] text-slate-400">{formatted}</span>
</div>
);
}
return (
<div className="text-center text-xs">
<span className={expired ? "text-red-600 font-medium" : "text-gray-700"}>
{formatted}
</span>
{expired && (
<span className="ml-1 text-red-500 font-semibold">(Exp)</span>
)}
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold text-primary">{formatted}</span>
</div>
);
},
},
{
accessorKey: "cost",
header: () => <div className="flex justify-center">Cost</div>,
header: () => <div className="flex justify-end text-xs">Plan Cost</div>,
cell: ({ row }) => {
const cost = row.original.totalRecommendationCost;
const creationStatus = row.original.creationStatus;
if (creationStatus === "LOADING") {
return <div className="font-medium flex justify-center"></div>;
return <div className="font-medium flex justify-end" />;
}
const formatted = cost ? "£" + formatNumber(cost) : "";
return (
<div className="text-gray-700 font-medium flex justify-center">
{formatted}
<div className="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>
)}
</div>
);
},
@ -334,30 +378,37 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
},
];
/* -----------------------------------------------------------------------
Optional columns
------------------------------------------------------------------------ */
const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
{
id: "propertyType",
accessorKey: "propertyType",
header: () => <div className="text-xs">Property Type</div>,
cell: ({ row }) => (
<div className="text-sm text-gray-700">{row.original.propertyType ?? "—"}</div>
),
cell: ({ row }) => {
const val = row.original.propertyType;
return val ? <Pill>{val}</Pill> : <span className="text-slate-300 text-xs"></span>;
},
},
{
id: "builtForm",
accessorKey: "builtForm",
header: () => <div className="text-xs">Built Form</div>,
cell: ({ row }) => (
<div className="text-sm text-gray-700">{row.original.builtForm ?? "—"}</div>
),
cell: ({ row }) => {
const val = row.original.builtForm;
return val ? <Pill>{val}</Pill> : <span className="text-slate-300 text-xs"></span>;
},
},
{
id: "tenure",
accessorKey: "tenure",
header: () => <div className="text-xs">Tenure</div>,
cell: ({ row }) => (
<div className="text-sm text-gray-700">{row.original.tenure ?? "—"}</div>
),
cell: ({ row }) => {
const label = resolveEnumLabel(TENURE_OPTIONS, row.original.tenure);
if (!label) return <span className="text-slate-300 text-xs"></span>;
return <Pill className={tenureBadgeClass(label)}>{label}</Pill>;
},
},
{
id: "yearBuilt",
@ -388,7 +439,7 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
const val = row.original.co2Emissions;
return (
<div className="text-sm text-gray-700">
{val != null ? `${val.toFixed(1)} t/yr` : "—"}
{val != null ? `${val.toFixed(1)} kg/m²/yr` : "—"}
</div>
);
},
@ -397,9 +448,10 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
id: "mainfuel",
accessorKey: "mainfuel",
header: () => <div className="text-xs">Main Fuel</div>,
cell: ({ row }) => (
<div className="text-sm text-gray-700">{row.original.mainfuel ?? "—"}</div>
),
cell: ({ row }) => {
const label = resolveEnumLabel(MAINFUEL_OPTIONS, row.original.mainfuel);
return label ? <Pill>{label}</Pill> : <span className="text-slate-300 text-xs"></span>;
},
},
];

View file

@ -7,8 +7,13 @@ interface Params {
filterGroups: FilterGroups;
}
export interface PropertiesResponse {
data: PropertyWithRelations[];
total: number;
}
export function useProperties({ portfolioId, filterGroups }: Params) {
return useQuery<PropertyWithRelations[]>({
return useQuery<PropertiesResponse>({
queryKey: ["properties", portfolioId, filterGroups],
queryFn: async () => {
const res = await fetch("/api/properties", {

View file

@ -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<string, EnumOption[]> = {
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<typeof sql> | 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<string, ReturnType<typeof sql>> = {
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<typeof sql>[] = [];
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<PropertyWithRelations[]> {
// 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<typeof sql> {
const groupFragments: ReturnType<typeof sql>[] = [];
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<number> {
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<PropertyWithRelations[]> {
// 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<PropertyWithRelations>(sql<PropertyWithRelations>`

View file

@ -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!"] },
];