improving filter abilities

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-11 15:40:06 +00:00
parent 59d6bf2151
commit c70a34f174
11 changed files with 1180 additions and 506 deletions

View file

@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { getProperties } from "@/app/portfolio/[slug]/utils";
import { PropertyFilter } from "@/app/utils/propertyFilters";
import { FilterGroups } from "@/app/utils/propertyFilters";
export async function POST(req: NextRequest) {
const body = await req.json();
const portfolioId = body.portfolioId;
const filters: PropertyFilter[] = body.filters ?? [];
const filterGroups: FilterGroups = body.filters ?? [];
if (!portfolioId) {
return NextResponse.json(
@ -14,12 +14,7 @@ export async function POST(req: NextRequest) {
{ status: 400 }
);
}
console.log("filters", filters);
const properties = await getProperties(
portfolioId,
1000,
0,
filters
);
const properties = await getProperties(portfolioId, 1000, 0, filterGroups);
return NextResponse.json(properties);
}
}

View file

@ -368,6 +368,19 @@ export interface PropertyWithRelations extends Record<string, unknown> {
fundingScheme: string | null;
totalRecommendationSapPoints: number | null;
totalRecommendationCost: number | null;
// New fields
landlordPropertyId: string | null;
originalSapPoints: number | null;
epcLodgementDate: string | null;
epcIsExpired: boolean | null;
// Optional columns (hidden by default)
propertyType: string | null;
builtForm: string | null;
tenure: string | null;
yearBuilt: string | null;
totalFloorArea: number | null;
co2Emissions: number | null;
mainfuel: string | null;
}
export type NonIntrusiveSurveyNotes = InferModel<

View file

@ -22,13 +22,13 @@ export default async function PortfolioLayout(props: {
return (
<section>
<div className="flex justify-center">
<h1 className="text-3xl text-gray-700 font-bold mt-3 mb-4">
<h1 className="text-2xl text-gray-700 font-bold mt-1 mb-1">
{portfolioName}
</h1>
</div>
<div className="flex justify-center">
<div className="grid grid-cols-8 w-full max-w-8xl">
<div className="col-span-12 justify-center bg-gray-50 py-2 relative">
<div className="col-span-12 justify-center bg-gray-50 py-1 px-4 relative">
<Toolbar portfolioId={portfolioId} scenarios={scenarios} />
</div>
</div>

View file

@ -1,12 +1,5 @@
import { getPortfolio, getPortfolioPerformance, getProperties } from "../utils";
import DataTable from "@/app/portfolio/[slug]/components/dataTable";
import { PropertyWithRelations } from "@/app/db/schema/property";
import PropertyTable from "../components/PropertyTable";
import SummaryBox from "@/app/components/portfolio/SummaryBox";
import { Component } from "lucide-react";
// We enfore caching of data for 60 seconds
export const revalidate = 60;
export default async function Page(props: {
@ -16,51 +9,11 @@ export default async function Page(props: {
}>;
}) {
const params = await props.params;
// This page is served from the server so we can make calls to the database
const portfolioId = params.slug;
let portfolioPerformance = await getPortfolioPerformance(portfolioId);
let scenarios;
if (portfolioPerformance.length > 0) {
scenarios = portfolioPerformance.map((performance) => ({
id: BigInt(performance.id),
name: performance.name || "Default Scenario",
budget: performance.budget,
totalCost: performance.cost,
funding: performance.funding,
contingency: performance.contingency,
co2EquivalentSavings: performance.co2EquivalentSavings,
propertyValuationIncrease: performance.propertyValuationIncrease,
energySavings: performance.energySavings,
energyCostSavings: performance.energyCostSavings,
labourDays: performance.labourDays,
isDefault: performance.isDefault,
}));
} else {
const portfolio = await getPortfolio(portfolioId);
scenarios = [
{
id: BigInt(0),
name: "Default",
budget: portfolio.budget,
totalCost: portfolio.cost,
funding: 0,
contingency: 0,
co2EquivalentSavings: portfolio.co2EquivalentSavings,
propertyValuationIncrease: portfolio.propertyValuationIncrease,
energySavings: portfolio.energySavings,
energyCostSavings: portfolio.energyCostSavings,
labourDays: portfolio.labourDays,
isDefault: true,
},
];
}
return (
<>
<PropertyTable portfolioId={portfolioId}/>
<>
<PropertyTable portfolioId={portfolioId} />
</>
);
}
}

View file

@ -1,185 +1,545 @@
"use client";
import { useState } from "react";
import React, { useState, useRef, useEffect } from "react";
import { X, Plus, ChevronDown, Check } from "lucide-react";
import { getEpcColorClass } from "@/app/utils";
import {
FilterGroups,
FilterGroup,
PropertyFilter,
FilterField,
FilterOperator,
DatePreset,
} from "@/app/utils/propertyFilters";
export type PropertyFilterValues = {
address: string;
postcode: string;
current_epc_at_most: "" | "C" | "D" | "E" | "F" | "G";
expected_epc_at_least: "" | "A" | "B" | "C" | "D";
};
/* -----------------------------------------------------------------------
Constants
------------------------------------------------------------------------ */
const EPC_LETTERS = ["A", "B", "C", "D", "E", "F", "G"] as const;
type EpcLetter = (typeof EPC_LETTERS)[number];
const EPC_ORDER = ["A", "B", "C", "D", "E", "F", "G"] as const;
const FIELD_OPTIONS: { value: FilterField; label: string }[] = [
{ value: "currentEpc", label: "Current EPC" },
{ value: "lodgedEpc", label: "Lodged EPC" },
{ value: "expectedEpc", label: "Expected EPC" },
{ value: "epcExpiryDate", label: "EPC Expiry Date" },
];
const epcIndex = (epc: string) =>
EPC_ORDER.indexOf(epc as (typeof EPC_ORDER)[number]);
const EPC_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [
{ 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" },
];
export default function PropertyFilters({
onApply,
const DATE_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [
{ value: "date_before", label: "is before" },
{ 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: "expires_within_1_year", label: "Expiring within 1 year" },
{ value: "expires_within_2_years", label: "Expiring within 2 years" },
];
/* -----------------------------------------------------------------------
Helpers
------------------------------------------------------------------------ */
function isEpcField(field: FilterField) {
return field === "currentEpc" || field === "lodgedEpc" || field === "expectedEpc";
}
function operatorsForField(field: FilterField): { value: FilterOperator; label: string }[] {
if (isEpcField(field)) return EPC_OPERATOR_OPTIONS;
if (field === "epcExpiryDate") return DATE_OPERATOR_OPTIONS;
return [];
}
function conditionLabel(condition: PropertyFilter): string {
const fieldLabel = FIELD_OPTIONS.find((f) => f.value === condition.field)?.label ?? condition.field;
if (isEpcField(condition.field)) {
const opLabel = EPC_OPERATOR_OPTIONS.find((o) => o.value === condition.operator)?.label ?? condition.operator;
const value =
condition.operator === "epc_one_of"
? condition.value.split(",").join(", ")
: condition.value;
return `${fieldLabel} ${opLabel} ${value}`;
}
if (condition.field === "epcExpiryDate") {
if (condition.operator === "date_preset") {
const presetLabel = DATE_PRESET_OPTIONS.find((p) => p.value === condition.value)?.label ?? condition.value;
return `${fieldLabel}: ${presetLabel}`;
}
const opLabel = DATE_OPERATOR_OPTIONS.find((o) => o.value === condition.operator)?.label ?? condition.operator;
return `${fieldLabel} ${opLabel} ${condition.value}`;
}
return `${fieldLabel} ${condition.operator} ${condition.value}`;
}
/* -----------------------------------------------------------------------
EPC Dropdown (single + multi)
------------------------------------------------------------------------ */
function EpcDropdown({
multi,
selected,
onChange,
}: {
onApply: (filters: PropertyFilterValues) => void;
multi: boolean;
selected: string[];
onChange: (letters: string[]) => void;
}) {
const [address, setAddress] = useState("");
const [postcode, setPostcode] = useState("");
const [currentEpc, setCurrentEpc] =
useState<PropertyFilterValues["current_epc_at_most"]>("");
const [expectedEpc, setExpectedEpc] =
useState<PropertyFilterValues["expected_epc_at_least"]>("");
const [open, setOpen] = useState(false);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
/* ----------------------------------------
Change handlers (no useEffect)
----------------------------------------- */
function handleCurrentEpcChange(
value: PropertyFilterValues["current_epc_at_most"],
) {
setCurrentEpc(value);
if (value && expectedEpc && epcIndex(expectedEpc) >= epcIndex(value)) {
setExpectedEpc("");
useEffect(() => {
function handleOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
}
function handleExpectedEpcChange(
value: PropertyFilterValues["expected_epc_at_least"],
) {
setExpectedEpc(value);
if (value && currentEpc && epcIndex(value) >= epcIndex(currentEpc)) {
setCurrentEpc("");
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: rect.width,
zIndex: 9999,
});
}
setOpen((o) => !o);
}
function apply() {
onApply({
address,
postcode,
current_epc_at_most: currentEpc,
expected_epc_at_least: expectedEpc,
});
}
function clear() {
setAddress("");
setPostcode("");
setCurrentEpc("");
setExpectedEpc("");
onApply({
address: "",
postcode: "",
current_epc_at_most: "",
expected_epc_at_least: "",
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
apply();
function toggle(letter: string) {
if (multi) {
onChange(
selected.includes(letter)
? selected.filter((l) => l !== letter)
: [...selected, letter]
);
} else {
onChange(selected[0] === letter ? [] : [letter]);
setOpen(false);
}
}
return (
<div className="border-b bg-white">
<div className="grid grid-cols-12 gap-4 p-4" onKeyDown={handleKeyDown}>
{/* Address */}
<div className="col-span-4">
<label className="block text-xs font-medium text-gray-600 mb-1">
Address
</label>
<input
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-black/10"
placeholder="Contains…"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</div>
{/* Postcode */}
<div className="col-span-2">
<label className="block text-xs font-medium text-gray-600 mb-1">
Postcode
</label>
<input
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-black/10"
placeholder="e.g. E17"
value={postcode}
onChange={(e) => setPostcode(e.target.value)}
/>
</div>
{/* Current EPC */}
<div className="col-span-2">
<label className="block text-xs font-medium text-gray-600 mb-1">
Current EPC
</label>
<select
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm
bg-white focus:outline-none focus:ring-2 focus:ring-black/10"
value={currentEpc}
onChange={(e) => handleCurrentEpcChange(e.target.value as any)}
>
<option value="">Any</option>
{["C", "D", "E", "F", "G"].map((epc) => (
<option
key={epc}
value={epc}
disabled={
expectedEpc !== "" && epcIndex(epc) <= epcIndex(expectedEpc)
}
<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]">
{selected.length === 0 ? (
<span className="text-gray-400">Select rating</span>
) : (
selected.map((l) => (
<span
key={l}
className={`inline-flex items-center justify-center w-5 h-5 rounded-full text-[11px] font-bold text-white ${getEpcColorClass(l)}`}
>
{epc} or below
</option>
{l}
</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">
{EPC_LETTERS.map((l) => {
const isSelected = selected.includes(l);
return (
<button
key={l}
type="button"
onClick={() => toggle(l)}
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-sm transition hover:bg-gray-50 ${isSelected ? "bg-gray-50" : ""}`}
>
<span
className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold text-white shrink-0 ${getEpcColorClass(l)}`}
>
{l}
</span>
<span className="text-gray-700">Rating {l}</span>
{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 && <Check className="h-2.5 w-2.5 text-white" />}
</span>
) : (
isSelected && <Check className="ml-auto h-3.5 w-3.5 text-gray-600" />
)}
</button>
);
})}
</div>
)}
</div>
);
}
/* -----------------------------------------------------------------------
Add Filter Form
------------------------------------------------------------------------ */
interface AddFilterFormProps {
targetGroupId: string | null; // null = new group
onConfirm: (groupId: string | null, condition: PropertyFilter) => void;
onCancel: () => void;
}
function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProps) {
const [field, setField] = useState<FilterField>("currentEpc");
const [operator, setOperator] = useState<FilterOperator>("epc_less_than");
const [epcSelected, setEpcSelected] = useState<string[]>([]);
const [dateValue, setDateValue] = useState("");
const [preset, setPreset] = useState<DatePreset>("expired");
function handleFieldChange(newField: FilterField) {
setField(newField);
const ops = operatorsForField(newField);
setOperator(ops[0]?.value ?? "equals");
setEpcSelected([]);
setDateValue("");
}
function buildValue(): string {
if (isEpcField(field)) {
return operator === "epc_one_of" ? epcSelected.join(",") : epcSelected[0] ?? "";
}
if (field === "epcExpiryDate") {
return operator === "date_preset" ? preset : dateValue;
}
return "";
}
function canConfirm(): boolean {
const value = buildValue();
return value.length > 0;
}
function handleConfirm() {
const value = buildValue();
if (!value) return;
const condition: PropertyFilter = {
id: crypto.randomUUID(),
field,
operator,
value,
};
onConfirm(targetGroupId, condition);
}
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";
return (
<div className="flex flex-col gap-2 p-3 bg-gray-50 rounded-md border border-gray-200">
{/* Field */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-0.5">Field</label>
<select
className={selectClass}
value={field}
onChange={(e) => handleFieldChange(e.target.value as FilterField)}
>
{FIELD_OPTIONS.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
</div>
{/* Operator */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-0.5">Condition</label>
<select
className={selectClass}
value={operator}
onChange={(e) => setOperator(e.target.value as FilterOperator)}
>
{operatorsForField(field).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{/* Value */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Value</label>
{isEpcField(field) && (
<EpcDropdown
multi={operator === "epc_one_of"}
selected={epcSelected}
onChange={setEpcSelected}
/>
)}
{field === "epcExpiryDate" && operator === "date_preset" && (
<select
className={selectClass}
value={preset}
onChange={(e) => setPreset(e.target.value as DatePreset)}
>
{DATE_PRESET_OPTIONS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
)}
{/* Expected EPC */}
<div className="col-span-2">
<label className="block text-xs font-medium text-gray-600 mb-1">
Expected EPC
</label>
<select
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm
bg-white focus:outline-none focus:ring-2 focus:ring-black/10"
value={expectedEpc}
onChange={(e) => handleExpectedEpcChange(e.target.value as any)}
>
<option value="">Any</option>
{["A", "B", "C", "D"].map((epc) => (
<option
key={epc}
value={epc}
disabled={
expectedEpc !== "" && epcIndex(epc) <= epcIndex(expectedEpc)
}
>
{epc} or above
</option>
))}
</select>
</div>
{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"
value={dateValue}
onChange={(e) => setDateValue(e.target.value)}
/>
)}
</div>
{/* Actions */}
<div className="col-span-2 flex items-end gap-2">
<button
onClick={apply}
className="h-10 w-full rounded-md bg-black text-sm font-medium text-white
hover:bg-black/90 transition"
>
Apply
</button>
<button
onClick={clear}
className="h-10 px-3 text-sm text-gray-500 hover:text-gray-700"
>
Clear
</button>
</div>
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
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"
>
Add
</button>
<button
type="button"
onClick={onCancel}
className="h-8 px-3 text-xs text-gray-500 hover:text-gray-700"
>
Cancel
</button>
</div>
</div>
);
}
/* -----------------------------------------------------------------------
Condition Row
------------------------------------------------------------------------ */
function ConditionRow({
condition,
onRemove,
}: {
condition: PropertyFilter;
onRemove: () => void;
}) {
return (
<div className="flex items-start gap-1 group">
<div className="flex-1 text-xs text-gray-700 bg-white border border-gray-200 rounded px-2 py-1.5 leading-tight break-words min-w-0">
{conditionLabel(condition)}
</div>
<button
type="button"
onClick={onRemove}
className="shrink-0 mt-0.5 p-0.5 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition"
title="Remove condition"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}
/* -----------------------------------------------------------------------
Main Component
------------------------------------------------------------------------ */
export default function PropertyFilters({
filterGroups,
onChange,
}: {
filterGroups: FilterGroups;
onChange: (groups: FilterGroups) => void;
}) {
// Draft state — only applied when user clicks Apply
const [draft, setDraft] = useState<FilterGroups>(filterGroups);
const [showForm, setShowForm] = useState(false);
const [addToGroupId, setAddToGroupId] = useState<string | null>(null);
// Sync incoming filterGroups changes (e.g., external clear)
// We use a simple pattern: if parent clears, reset draft too
function openNewFilter() {
setAddToGroupId(null);
setShowForm(true);
}
function openOrFilter(groupId: string) {
setAddToGroupId(groupId);
setShowForm(true);
}
function handleConfirm(groupId: string | null, condition: PropertyFilter) {
setDraft((prev) => {
if (groupId === null) {
// New group
const newGroup: FilterGroup = {
id: crypto.randomUUID(),
conditions: [condition],
};
return [...prev, newGroup];
} else {
// Add OR-condition to existing group
return prev.map((g) =>
g.id === groupId
? { ...g, conditions: [...g.conditions, condition] }
: g
);
}
});
setShowForm(false);
setAddToGroupId(null);
}
function removeCondition(groupId: string, conditionId: string) {
setDraft((prev) => {
const updated = prev
.map((g) =>
g.id === groupId
? { ...g, conditions: g.conditions.filter((c) => c.id !== conditionId) }
: g
)
.filter((g) => g.conditions.length > 0);
return updated;
});
}
function apply() {
onChange(draft);
}
function clear() {
setDraft([]);
onChange([]);
}
return (
<div className="flex flex-col gap-3 p-3">
{/* Group list */}
{draft.map((group, groupIdx) => (
<div key={group.id}>
{/* AND divider between groups */}
{groupIdx > 0 && (
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 border-t border-gray-200" />
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide px-1">
and
</span>
<div className="flex-1 border-t border-gray-200" />
</div>
)}
<div className="flex flex-col gap-1.5">
{group.conditions.map((condition, condIdx) => (
<div key={condition.id}>
{/* OR 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-gray-200" />
<span className="text-[10px] font-semibold text-blue-400 uppercase tracking-wide px-1">
or
</span>
<div className="flex-1 border-t border-dashed border-gray-200" />
</div>
)}
<ConditionRow
condition={condition}
onRemove={() => removeCondition(group.id, condition.id)}
/>
</div>
))}
{/* Add OR condition to this group */}
{!(showForm && addToGroupId === group.id) && (
<button
type="button"
onClick={() => openOrFilter(group.id)}
className="self-start text-[11px] text-blue-500 hover:text-blue-700 flex items-center gap-0.5 mt-0.5"
>
<Plus className="h-3 w-3" />
or
</button>
)}
{showForm && addToGroupId === group.id && (
<AddFilterForm
targetGroupId={group.id}
onConfirm={handleConfirm}
onCancel={() => { setShowForm(false); setAddToGroupId(null); }}
/>
)}
</div>
</div>
))}
{/* Add new filter group form */}
{showForm && addToGroupId === null ? (
<AddFilterForm
targetGroupId={null}
onConfirm={handleConfirm}
onCancel={() => { setShowForm(false); setAddToGroupId(null); }}
/>
) : (
<button
type="button"
onClick={openNewFilter}
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 border border-dashed border-gray-300 rounded-md px-3 py-2 hover:border-gray-400 transition"
>
<Plus className="h-3.5 w-3.5" />
Add filter
</button>
)}
{/* Apply / Clear */}
<div className="flex gap-2 pt-1 border-t border-gray-100">
<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"
>
Apply
</button>
<button
type="button"
onClick={clear}
className="h-9 px-3 text-sm text-gray-500 hover:text-gray-700"
>
Clear
</button>
</div>
</div>
);
}
// #Test git with khalimsdsadsaasdsasdfdsertrsadfsdsadfdssdfds

View file

@ -1,12 +1,11 @@
"use client";
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useState, useMemo, useRef } from "react";
import { useProperties } from "./useProperties";
import DataTable from "./dataTable";
import PropertyFilters, { PropertyFilterValues } from "./PropertyFilters";
import { PropertyFilter } from "@/app/utils/propertyFilters";
import { HomeIcon } from "@heroicons/react/24/outline";
import PropertyFilters from "./PropertyFilters";
import { FilterGroups } from "@/app/utils/propertyFilters";
import { HomeIcon, FunnelIcon } from "@heroicons/react/24/outline";
import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
import {
@ -19,49 +18,6 @@ import {
} from "@/app/shadcn_components/ui/dialog";
import { Button } from "@/app/shadcn_components/ui/button";
/* ----------------------------------------
Filter parsing
----------------------------------------- */
export function parsePropertyFilters(
filters: PropertyFilterValues
): PropertyFilter[] {
const parsed: PropertyFilter[] = [];
if (filters.address) {
parsed.push({
field: "address",
operator: "contains",
value: filters.address,
});
}
if (filters.postcode) {
parsed.push({
field: "postcode",
operator: "starts_with",
value: filters.postcode,
});
}
if (filters.current_epc_at_most) {
parsed.push({
field: "currentEpc",
operator: "epc_at_most",
value: filters.current_epc_at_most,
});
}
if (filters.expected_epc_at_least) {
parsed.push({
field: "expectedEpc",
operator: "epc_at_least",
value: filters.expected_epc_at_least,
});
}
return parsed;
}
/* ----------------------------------------
Empty portfolio state
----------------------------------------- */
@ -71,7 +27,7 @@ function EmptyPropertyState() {
<div className="text-center text-gray-400">
<HomeIcon className="h-16 w-16 mx-auto mb-4 text-gray-200" />
<p>
Hover over <strong>New Property</strong> to start adding properties
Hover over <strong>"New Property"</strong> to start adding properties
to your portfolio.
</p>
</div>
@ -79,6 +35,20 @@ function EmptyPropertyState() {
);
}
/* ----------------------------------------
Loading overlay
----------------------------------------- */
function LoadingOverlay() {
return (
<div className="absolute inset-0 z-10 rounded-md bg-white/60 flex items-center justify-center pointer-events-none">
<div className="flex flex-col items-center gap-2">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
<span className="text-xs text-gray-500">Updating</span>
</div>
</div>
);
}
/* ----------------------------------------
Main table
----------------------------------------- */
@ -87,27 +57,76 @@ export default function PropertyTable({
}: {
portfolioId: string;
}) {
const router = useRouter();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [filters, setFilters] = useState<PropertyFilterValues>({
address: "",
postcode: "",
current_epc_at_most: "",
expected_epc_at_least: "",
});
// Quick filter display state (updates immediately)
const [quickAddress, setQuickAddress] = useState("");
const [quickPostcode, setQuickPostcode] = useState("");
const [quickPropertyRef, setQuickPropertyRef] = useState("");
const parsedFilters = useMemo(() => parsePropertyFilters(filters), [filters]);
const hasActiveFilters = parsedFilters.length > 0;
// Debounced quick filter values (drives the query)
const [debouncedAddress, setDebouncedAddress] = useState("");
const [debouncedPostcode, setDebouncedPostcode] = useState("");
const [debouncedPropertyRef, setDebouncedPropertyRef] = useState("");
// Advanced filter groups from the sidebar
const [filterGroups, setFilterGroups] = useState<FilterGroups>([]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
function handleQuickFilter(
field: "address" | "postcode" | "propertyRef",
value: string
) {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
if (field === "address") setDebouncedAddress(value);
if (field === "postcode") setDebouncedPostcode(value);
if (field === "propertyRef") setDebouncedPropertyRef(value);
}, 300);
}
function clearAll() {
if (debounceRef.current) clearTimeout(debounceRef.current);
setQuickAddress("");
setQuickPostcode("");
setQuickPropertyRef("");
setDebouncedAddress("");
setDebouncedPostcode("");
setDebouncedPropertyRef("");
setFilterGroups([]);
}
const allFilterGroups = useMemo((): FilterGroups => {
const quick: FilterGroups = [];
if (debouncedAddress)
quick.push({
id: "qa",
conditions: [{ id: "qa-c", field: "address", operator: "contains", value: debouncedAddress }],
});
if (debouncedPostcode)
quick.push({
id: "qp",
conditions: [{ id: "qp-c", field: "postcode", operator: "starts_with", value: debouncedPostcode }],
});
if (debouncedPropertyRef)
quick.push({
id: "qr",
conditions: [{ id: "qr-c", field: "propertyRef", operator: "contains", value: debouncedPropertyRef }],
});
return [...quick, ...filterGroups];
}, [debouncedAddress, debouncedPostcode, debouncedPropertyRef, filterGroups]);
const hasActiveFilters = allFilterGroups.length > 0;
const {
data = [],
isLoading,
isFetching,
isError,
refetch,
} = useProperties({
portfolioId,
filters: parsedFilters,
filterGroups: allFilterGroups,
});
/* ----------------------------------------
@ -117,27 +136,102 @@ export default function PropertyTable({
const [deletePreview, setDeletePreview] = useState<
{ table: string; count: number }[] | null
>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [previewLoading] = useState(false);
const [previewError] = useState<string | null>(null);
const inputClass =
"h-9 rounded-md border border-gray-300 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-black/10 bg-white";
return (
<div className="flex justify-center">
<div className="grid grid-cols-11 w-full max-w-8xl">
<div className="col-span-11 bg-white rounded-md border">
<PropertyFilters onApply={setFilters} />
<div className="px-4 py-3">
{/* Quick filters row */}
<div className="flex items-end gap-3 mb-3 flex-wrap">
<button
onClick={() => setSidebarOpen((o) => !o)}
className="flex items-center gap-1.5 h-9 px-3 rounded-md border border-gray-300 text-sm text-gray-600 hover:bg-gray-50 transition shrink-0"
title={sidebarOpen ? "Hide filters" : "Show filters"}
>
<FunnelIcon className="h-4 w-4" />
{sidebarOpen ? "Hide filters" : "Filters"}
</button>
{isFetching && (
<div className="h-1 w-full bg-gray-100 overflow-hidden">
<div className="h-full w-1/3 bg-black animate-[loading_1.2s_infinite]" />
</div>
)}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">Address</label>
<input
className={`${inputClass} w-52`}
placeholder="Contains…"
value={quickAddress}
onChange={(e) => {
setQuickAddress(e.target.value);
handleQuickFilter("address", e.target.value);
}}
/>
</div>
{hasActiveFilters && !isFetching && (
<div className="px-4 py-2 text-xs text-gray-500 border-b">
Filters applied ({parsedFilters.length})
</div>
)}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">Postcode</label>
<input
className={`${inputClass} w-32`}
placeholder="e.g. E17"
value={quickPostcode}
onChange={(e) => {
setQuickPostcode(e.target.value);
handleQuickFilter("postcode", e.target.value);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">
Property Ref
</label>
<input
className={`${inputClass} w-40`}
placeholder="Landlord ref…"
value={quickPropertyRef}
onChange={(e) => {
setQuickPropertyRef(e.target.value);
handleQuickFilter("propertyRef", e.target.value);
}}
/>
</div>
{hasActiveFilters && (
<button
onClick={clearAll}
className="self-end h-9 px-3 text-sm text-gray-500 hover:text-gray-700 underline"
>
Clear all
</button>
)}
</div>
{/* Body: sidebar + table */}
<div className="flex items-start gap-4">
{/* Collapsible filter sidebar */}
<div
className={[
"shrink-0 overflow-hidden transition-all duration-300 ease-in-out",
"bg-white rounded-md",
sidebarOpen
? "w-72 opacity-100 border border-gray-200"
: "w-0 opacity-0",
].join(" ")}
>
<div className="w-72">
<p className="px-3 pt-3 pb-0 text-xs font-semibold text-gray-500 uppercase tracking-wide">
Filters
</p>
<PropertyFilters
filterGroups={filterGroups}
onChange={setFilterGroups}
/>
</div>
</div>
{/* Table area */}
<div className="flex-1 min-w-0 bg-white rounded-md border border-gray-200 relative">
{isFetching && !isLoading && <LoadingOverlay />}
{isLoading ? (
<div className="p-6 text-gray-400">Loading properties</div>
@ -147,14 +241,7 @@ export default function PropertyTable({
<div className="p-10 text-center text-gray-500">
<p>No properties match your filters.</p>
<button
onClick={() =>
setFilters({
address: "",
postcode: "",
current_epc_at_most: "",
expected_epc_at_least: "",
})
}
onClick={clearAll}
className="mt-3 text-sm text-black underline"
>
Clear filters
@ -172,16 +259,13 @@ export default function PropertyTable({
</div>
</div>
{/* ----------------------------------------
Delete preview modal
----------------------------------------- */}
{/* Delete preview modal */}
<Dialog
open={deletePropertyId !== null}
onOpenChange={(open) => {
if (!open) {
setDeletePropertyId(null);
setDeletePreview(null);
setPreviewError(null);
}
}}
>
@ -195,15 +279,6 @@ export default function PropertyTable({
</DialogDescription>
</DialogHeader>
{previewLoading && (
<div className="flex items-center gap-3 py-6">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted border-t-foreground" />
<span className="text-sm text-muted-foreground">
Calculating deletion impact
</span>
</div>
)}
{previewError && (
<p className="text-sm text-red-600">{previewError}</p>
)}
@ -231,14 +306,6 @@ export default function PropertyTable({
>
Cancel
</Button>
{/* <Button
variant="destructive"
disabled={!deletePreview || previewLoading || deleteLoading}
onClick={handleConfirmDelete}
>
{deleteLoading ? "Deleting…" : "Confirm delete"}
</Button> */}
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -5,6 +5,7 @@ import {
ColumnFiltersState,
SortingState,
PaginationState,
VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
@ -23,9 +24,23 @@ import {
TableRow,
} from "@/app/shadcn_components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import { useState } from "react";
import { DataTablePagination } from "./propertyTablePagination";
import { rankItem } from "@tanstack/match-sorter-utils";
import { Button } from "@/app/shadcn_components/ui/button";
import {
OPTIONAL_COLUMN_IDS,
OPTIONAL_COLUMN_LABELS,
} from "./propertyTableColumns";
/* ----------------------------------------
Optional fuzzy global filter
@ -50,12 +65,19 @@ export default function DataTable<TData extends Record<string, any>>({
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
// ✅ REQUIRED pagination state (fixes TS error)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 7,
});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
() => {
const init: VisibilityState = {};
OPTIONAL_COLUMN_IDS.forEach((id) => {
init[id] = false;
});
return init;
}
);
const table = useReactTable({
data,
@ -70,6 +92,7 @@ export default function DataTable<TData extends Record<string, any>>({
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
onColumnVisibilityChange: setColumnVisibility,
globalFilterFn: fuzzyFilter,
@ -78,6 +101,7 @@ export default function DataTable<TData extends Record<string, any>>({
columnFilters,
globalFilter,
pagination,
columnVisibility,
},
meta: {
@ -86,13 +110,54 @@ export default function DataTable<TData extends Record<string, any>>({
});
return (
<div className="rounded-md border">
<div className="rounded-md">
{/* Edit Columns toolbar */}
<div className="flex justify-end px-4 py-2 border-b">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="text-xs gap-1.5">
Edit Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs">
Optional Columns
</DropdownMenuLabel>
<DropdownMenuSeparator />
{OPTIONAL_COLUMN_IDS.map((colId) => {
const col = table.getColumn(colId);
if (!col) return null;
return (
<DropdownMenuItem
key={colId}
className="flex items-center gap-2 cursor-pointer"
onSelect={(e) => {
e.preventDefault();
col.toggleVisibility();
}}
>
<input
type="checkbox"
checked={col.getIsVisible()}
readOnly
className="h-3.5 w-3.5 accent-black"
/>
<span className="text-xs">
{OPTIONAL_COLUMN_LABELS[colId]}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="h-14 py-2">
<TableHead key={header.id} className="h-12 py-2">
{header.isPlaceholder
? null
: flexRender(

View file

@ -11,12 +11,10 @@ import {
} from "@/app/shadcn_components/ui/dropdown-menu";
import { Button } from "@/app/shadcn_components/ui/button";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import StatusBadge from "@/app/components/StatusBadge";
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 { PortfolioStatus } from "@/app/db/schema/portfolio";
import { PropertyWithRelations } from "@/app/db/schema/property";
import { X } from "lucide-react";
@ -29,6 +27,7 @@ interface DataTableColumnHeaderProps<
}
const EpcLetterBubble = ({ letter }: { letter: string }) => {
if (!letter) return <div className="w-6 h-6" />;
return (
<div
className={`inline-flex items-center justify-center w-6 h-6 rounded-full ${getEpcColorClass(
@ -107,21 +106,43 @@ export function DataTableFilterHeader<TData, TValue>({
);
}
export const columns: ColumnDef<PropertyWithRelations>[] = [
export const OPTIONAL_COLUMN_IDS = [
"propertyType",
"builtForm",
"tenure",
"yearBuilt",
"totalFloorArea",
"co2Emissions",
"mainfuel",
] as const;
export type OptionalColumnId = (typeof OPTIONAL_COLUMN_IDS)[number];
const OPTIONAL_COLUMN_LABELS: Record<OptionalColumnId, string> = {
propertyType: "Property Type",
builtForm: "Built Form",
tenure: "Tenure",
yearBuilt: "Year Built",
totalFloorArea: "Floor Area (m²)",
co2Emissions: "CO₂ Emissions",
mainfuel: "Main Fuel",
};
export { OPTIONAL_COLUMN_LABELS };
const coreColumns: ColumnDef<PropertyWithRelations>[] = [
{
accessorKey: "address",
enableGlobalFilter: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const address = String(row.getValue("address"));
const propertyId = row.original.id;
@ -161,90 +182,53 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
),
},
{
accessorKey: "status",
// header: () => <div className="flex justify-center">Status</div>,
header: ({ column }) => {
return (
<div className="flex justify-center">
<DataTableFilterHeader
column={column}
title="Status"
options={PortfolioStatus}
renderOption={(status) => (
<StatusBadge status={status} isProperty={false} />
)}
/>
</div>
);
},
cell: ({ row }) => {
const status = row.getValue("status") ?? "";
return (
<div className="flex justify-center">
{status && <StatusBadge status={String(status)} isProperty={true} />}
</div>
);
},
},
{
accessorKey: "fundingScheme",
header: ({ column }) => {
return (
<div className="flex justify-center">
<DataTableFilterHeader
column={column}
title="Funding Scheme"
options={["ECO4", "GBIS", "NONE"]}
renderOption={(status) => (
// handle status being null or undefined
<StatusBadge status={status ?? "none"} isProperty={false} />
)}
/>
</div>
);
},
cell: ({ row }) => {
// if the funding scheme is "none" we display nothing
const fundingScheme = row.getValue("fundingScheme") || "";
// Check if any plan has an ECO4 or GBIS funding package
return (
<div className="flex justify-center">
{fundingScheme && fundingScheme !== "none" && (
<StatusBadge
status={String(fundingScheme).toUpperCase()}
isProperty={true}
/>
)}
</div>
);
},
accessorKey: "landlordPropertyId",
header: () => <div className="text-xs font-medium">Property Ref</div>,
cell: ({ row }) => (
<div className="text-gray-700 text-sm">
{row.original.landlordPropertyId ?? "—"}
</div>
),
},
{
accessorKey: "currentEpc",
header: () => <div className="flex justify-center">Current EPC Rating</div>,
header: () => (
<div className="flex justify-center text-xs">Current EPC Performance</div>
),
cell: ({ row }) => (
<div className="text-gray-700 font-medium flex justify-center">
<EpcLetterBubble letter={row.original.currentEpcRating || ""} />
</div>
),
},
{
accessorKey: "originalSapPoints",
header: () => (
<div className="flex justify-center text-xs">Lodged EPC Rating</div>
),
cell: ({ row }) => {
const originalSap = row.original.originalSapPoints;
const letter = originalSap ? sapToEpc(originalSap) : null;
return (
<div className="text-gray-700 font-medium flex justify-center">
{<EpcLetterBubble letter={row.original.currentEpcRating || ""} />}
<EpcLetterBubble letter={letter || ""} />
</div>
);
},
},
{
accessorKey: "targetEpc",
header: () => <div className="flex justify-center">Expected EPC</div>,
header: () => (
<div className="flex justify-center text-xs">Expected EPC</div>
),
cell: ({ row }) => {
const currentSapPoints = row.original.currentSapPoints || 0;
const expectedSapPoints = row.original.totalRecommendationSapPoints || 0;
// if currentSapPoints + expectedSapPoint is 0 expected EPC is ""
if (currentSapPoints + expectedSapPoints === 0) {
return (
<div className="text-gray-700 font-medium flex justify-center">
{<EpcLetterBubble letter={""} />}
<EpcLetterBubble letter="" />
</div>
);
}
@ -252,7 +236,38 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
return (
<div className="text-gray-700 font-medium flex justify-center">
{<EpcLetterBubble letter={expectedEpc || ""} />}
<EpcLetterBubble letter={expectedEpc || ""} />
</div>
);
},
},
{
accessorKey: "epcLodgementDate",
header: () => (
<div className="flex justify-center text-xs">EPC Expiry</div>
),
cell: ({ row }) => {
const dateStr = row.original.epcLodgementDate;
const expired = row.original.epcIsExpired;
if (!dateStr) {
return <div className="text-center text-gray-400 text-xs"></div>;
}
const formatted = new Date(dateStr).toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
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>
);
},
@ -262,8 +277,8 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
header: () => <div className="flex justify-center">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>;
}
@ -279,51 +294,116 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
},
{
id: "actions",
cell: ({ row, table }) => {
cell: ({ row }) => {
const property = row.original;
const propertyId = property.id;
const portfolioId = property.portfolioId;
const creationStatus = property.creationStatus;
if (creationStatus === "LOADING") {
return (
<div className="font-mediutext-gray-800m text-gray-700 flex justify-center">
Loading...
</div>
<div className="text-gray-700 flex justify-center">Loading...</div>
);
}
return (
<>
<div className="flex justify-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
// onClick={() => navigator.clipboard.writeText(payment.id)}
className="text-gray-700 cursor-pointer"
>
<a href={`${portfolioId}/building-passport/${propertyId}`}>
Building Passport
</a>
</DropdownMenuItem>
<DropdownMenuItem className="text-gray-700 cursor-pointer">
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
<div className="flex justify-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem className="text-gray-700 cursor-pointer">
<a href={`${portfolioId}/building-passport/${propertyId}`}>
Building Passport
</a>
</DropdownMenuItem>
<DropdownMenuItem className="text-gray-700 cursor-pointer">
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
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>
),
},
{
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>
),
},
{
id: "tenure",
accessorKey: "tenure",
header: () => <div className="text-xs">Tenure</div>,
cell: ({ row }) => (
<div className="text-sm text-gray-700">{row.original.tenure ?? "—"}</div>
),
},
{
id: "yearBuilt",
accessorKey: "yearBuilt",
header: () => <div className="text-xs">Year Built</div>,
cell: ({ row }) => (
<div className="text-sm text-gray-700">{row.original.yearBuilt ?? "—"}</div>
),
},
{
id: "totalFloorArea",
accessorKey: "totalFloorArea",
header: () => <div className="text-xs">Floor Area (m²)</div>,
cell: ({ row }) => {
const val = row.original.totalFloorArea;
return (
<div className="text-sm text-gray-700">
{val != null ? `${val.toFixed(1)}` : "—"}
</div>
);
},
},
{
id: "co2Emissions",
accessorKey: "co2Emissions",
header: () => <div className="text-xs">CO Emissions</div>,
cell: ({ row }) => {
const val = row.original.co2Emissions;
return (
<div className="text-sm text-gray-700">
{val != null ? `${val.toFixed(1)} t/yr` : "—"}
</div>
);
},
},
{
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>
),
},
];
export const columns: ColumnDef<PropertyWithRelations>[] = [
...coreColumns,
...optionalColumns,
];

View file

@ -1,15 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { PropertyWithRelations } from "@/app/db/schema/property";
import { PropertyFilter } from "@/app/utils/propertyFilters";
import { FilterGroups } from "@/app/utils/propertyFilters";
interface Params {
portfolioId: string;
filters: PropertyFilter[];
filterGroups: FilterGroups;
}
export function useProperties({ portfolioId, filters }: Params) {
export function useProperties({ portfolioId, filterGroups }: Params) {
return useQuery<PropertyWithRelations[]>({
queryKey: ["properties", portfolioId, filters],
queryKey: ["properties", portfolioId, filterGroups],
queryFn: async () => {
const res = await fetch("/api/properties", {
method: "POST",
@ -18,7 +18,7 @@ export function useProperties({ portfolioId, filters }: Params) {
},
body: JSON.stringify({
portfolioId,
filters,
filters: filterGroups,
}),
});

View file

@ -19,7 +19,7 @@ import {
ScenarioSelect,
} from "@/app/db/schema/recommendations";
import { sql } from "drizzle-orm";
import { PropertyFilter } from "@/app/utils/propertyFilters";
import { FilterGroups, PropertyFilter } from "@/app/utils/propertyFilters";
import { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/app/utils/epc";
export interface PortfolioSettingsType {
@ -415,68 +415,161 @@ export async function getNonDefaultPortfolioScenarios(
return scenarios;
}
type EpcLetter = "A" | "B" | "C" | "D" | "E" | "F" | "G";
const EPC_ORDER: EpcLetter[] = ["A", "B", "C", "D", "E", "F", "G"];
function buildEpcSapCondition(
col: ReturnType<typeof sql>,
operator: PropertyFilter["operator"],
value: string
): ReturnType<typeof sql> | null {
const letter = value as EpcLetter;
if (operator === "epc_at_most") {
const maxSap = EPC_TO_SAP_MAX[letter as keyof typeof EPC_TO_SAP_MAX];
if (maxSap === undefined) return null;
return sql`${col} <= ${maxSap}`;
}
if (operator === "epc_at_least") {
const minSap = EPC_TO_SAP_MIN[letter as keyof typeof EPC_TO_SAP_MIN];
if (minSap === undefined) return null;
return sql`${col} >= ${minSap}`;
}
if (operator === "equals") {
const minSap = EPC_TO_SAP_MIN[letter as keyof typeof EPC_TO_SAP_MIN];
const maxSap = EPC_TO_SAP_MAX[letter as keyof typeof EPC_TO_SAP_MAX];
if (minSap === undefined || maxSap === undefined) return null;
return sql`${col} BETWEEN ${minSap} AND ${maxSap}`;
}
if (operator === "epc_less_than") {
// Worse than the given letter — SAP below the band's minimum
const minSap = EPC_TO_SAP_MIN[letter as keyof typeof EPC_TO_SAP_MIN];
if (minSap === undefined) return null;
return sql`${col} < ${minSap}`;
}
if (operator === "epc_greater_than") {
// Better than the given letter — SAP above the band's maximum
const maxSap = EPC_TO_SAP_MAX[letter as keyof typeof EPC_TO_SAP_MAX];
if (maxSap === undefined) return null;
return sql`${col} > ${maxSap}`;
}
if (operator === "epc_one_of") {
const letters = value.split(",").map((l) => l.trim()) as EpcLetter[];
const ranges = letters
.map((l) => {
const minSap = EPC_TO_SAP_MIN[l as keyof typeof EPC_TO_SAP_MIN];
const maxSap = EPC_TO_SAP_MAX[l as keyof typeof EPC_TO_SAP_MAX];
if (minSap === undefined || maxSap === undefined) return null;
return sql`(${col} BETWEEN ${minSap} AND ${maxSap})`;
})
.filter((x): x is ReturnType<typeof sql> => x !== null);
if (ranges.length === 0) return null;
return sql`(${sql.join(ranges, sql` OR `)})`;
}
return null;
}
function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | null {
switch (filter.field) {
case "address":
if (filter.operator === "contains") {
return sql`p.address ILIKE ${"%" + filter.value + "%"}`;
}
return null;
case "postcode":
if (filter.operator === "starts_with") {
return sql`p.postcode ILIKE ${filter.value + "%"}`;
}
return null;
case "propertyRef":
if (filter.operator === "contains") {
return sql`p.landlord_property_id ILIKE ${"%" + filter.value + "%"}`;
}
return null;
case "currentEpc":
return buildEpcSapCondition(sql`p.current_sap_points`, filter.operator, filter.value);
case "lodgedEpc":
return buildEpcSapCondition(sql`p.original_sap_points`, filter.operator, filter.value);
case "expectedEpc": {
if (filter.operator === "epc_at_least") {
const minSap = EPC_TO_SAP_MIN[filter.value as keyof typeof EPC_TO_SAP_MIN];
if (minSap === undefined) return null;
return sql`pl.post_sap_points IS NOT NULL AND pl.post_sap_points >= ${minSap}`;
}
return null;
}
case "epcExpiryDate": {
const expiryExpr = sql`(epc.lodgement_date + INTERVAL '10 years')`;
const guard = sql`epc.lodgement_date IS NOT NULL`;
if (filter.operator === "date_before") {
return sql`${guard} AND ${expiryExpr} < ${filter.value}::date`;
}
if (filter.operator === "date_after") {
return sql`${guard} AND ${expiryExpr} > ${filter.value}::date`;
}
if (filter.operator === "date_equals") {
return sql`${guard} AND DATE(${expiryExpr}) = ${filter.value}::date`;
}
if (filter.operator === "date_preset") {
switch (filter.value) {
case "expired":
return sql`epc.is_expired = true`;
case "expires_this_year":
return sql`${guard} AND EXTRACT(YEAR FROM ${expiryExpr}) = EXTRACT(YEAR FROM CURRENT_DATE)`;
case "expires_within_1_year":
return sql`${guard} AND ${expiryExpr} BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '1 year')`;
case "expires_within_2_years":
return sql`${guard} AND ${expiryExpr} BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '2 years')`;
default:
return null;
}
}
return null;
}
}
return null;
}
export async function getProperties(
portfolioId: string,
limit: number = 1000,
offset: number = 0,
filters: PropertyFilter[] = []
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 whereClauses: any[] = [];
const groupFragments: ReturnType<typeof sql>[] = [];
for (const filter of filters) {
switch (filter.field) {
case "address":
if (filter.operator === "contains") {
whereClauses.push(
sql`p.address ILIKE ${"%" + filter.value + "%"}`
);
}
break;
for (const group of filterGroups) {
const condFragments = group.conditions
.map(buildConditionSql)
.filter((x): x is ReturnType<typeof sql> => x !== null);
case "postcode":
if (filter.operator === "starts_with") {
whereClauses.push(
sql`p.postcode ILIKE ${filter.value + "%"}`
);
}
break;
if (condFragments.length === 0) continue;
case "currentEpc": {
console.log("EPC at most", filter.value)
const maxSap =
EPC_TO_SAP_MAX[filter.value as keyof typeof EPC_TO_SAP_MAX];
if (maxSap === undefined) break;
if (filter.operator === "epc_at_most") {
whereClauses.push(
sql`p.current_sap_points <= ${maxSap}`
);
}
break;
}
case "expectedEpc": {
const minSap =
EPC_TO_SAP_MIN[filter.value as keyof typeof EPC_TO_SAP_MIN];
if (minSap === undefined) break;
if (filter.operator === "epc_at_least") {
whereClauses.push(
sql`
pl.post_sap_points IS NOT NULL
AND pl.post_sap_points >= ${minSap}
`
);
}
break;
}
if (condFragments.length === 1) {
groupFragments.push(condFragments[0]);
} else {
groupFragments.push(sql`(${sql.join(condFragments, sql` OR `)})`);
}
}
const combinedWhere =
whereClauses.length > 0
? sql`AND (${sql.join(whereClauses, sql` AND `)})`
groupFragments.length > 0
? sql`AND (${sql.join(groupFragments, sql` AND `)})`
: sql``;
const result =
@ -494,7 +587,18 @@ export async function getProperties(
pl.id AS "planId",
fp.scheme AS "fundingScheme",
COALESCE(SUM(r.sap_points), 0) AS "totalRecommendationSapPoints",
COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost"
COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost",
p.landlord_property_id AS "landlordPropertyId",
p.original_sap_points AS "originalSapPoints",
p.property_type AS "propertyType",
p.built_form AS "builtForm",
p.tenure AS tenure,
p.year_built AS "yearBuilt",
epc.lodgement_date::text AS "epcLodgementDate",
epc.is_expired AS "epcIsExpired",
epc.total_floor_area AS "totalFloorArea",
epc.co2_emissions AS "co2Emissions",
epc.mainfuel AS mainfuel
FROM property p
LEFT JOIN property_targets t
ON t.property_id = p.id
@ -508,7 +612,9 @@ export async function getProperties(
LEFT JOIN recommendation r
ON r.id = pr.recommendation_id
AND r.default = true
and r.already_installed = false
AND r.already_installed = false
LEFT JOIN property_details_epc epc
ON epc.property_id = p.id
WHERE p.portfolio_id = ${portfolioId}
${combinedWhere}
GROUP BY
@ -522,7 +628,18 @@ export async function getProperties(
p.current_sap_points,
t.epc,
pl.id,
fp.scheme
fp.scheme,
p.landlord_property_id,
p.original_sap_points,
p.property_type,
p.built_form,
p.tenure,
p.year_built,
epc.lodgement_date,
epc.is_expired,
epc.total_floor_area,
epc.co2_emissions,
epc.mainfuel
LIMIT ${limit} OFFSET ${offset};
`);

View file

@ -2,17 +2,41 @@ export type FilterField =
| "address"
| "postcode"
| "currentEpc"
| "expectedEpc";
| "expectedEpc"
| "lodgedEpc"
| "epcExpiryDate"
| "propertyRef";
export type FilterOperator =
| "contains"
| "starts_with"
| "equals"
| "epc_at_least"
| "epc_at_most";
| "epc_at_most"
| "epc_less_than"
| "epc_greater_than"
| "epc_one_of"
| "date_before"
| "date_after"
| "date_equals"
| "date_preset";
export type DatePreset =
| "expired"
| "expires_this_year"
| "expires_within_1_year"
| "expires_within_2_years";
export interface PropertyFilter {
id: string;
field: FilterField;
operator: FilterOperator;
value: string;
}
}
export interface FilterGroup {
id: string;
conditions: PropertyFilter[];
}
export type FilterGroups = FilterGroup[];