added db filtereing

This commit is contained in:
Jun-te Kim 2026-01-05 17:30:58 +00:00
parent 7a7c379749
commit c9bfb0ce9c
12 changed files with 576 additions and 215 deletions

26
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,26 @@
{
// Hot reload setting that needs to be in user settings
// "jupyter.runStartupCommands": [
// "%load_ext autoreload", "%autoreload 2"
// ]
// --- VIM SETTINGS ---
// "vim.useSystemClipboard": true,
"vim.enableNeovim": false,
// Allow VSCode native keybindings to override Vim when needed
"vim.handleKeys": {
"<C-p>": false,
"<C-P>": false,
"<C-S-p>": false,
"<C-c>": false,
"<C-v>": false,
"<C-S-v>": false,
"<C-S-e>": false,
"<C-b>": false,
"<C-j>": false,
"<C-S-c>": false,
"<C-k>": false
},
}

View file

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

View file

@ -1,27 +1,13 @@
import { HomeIcon } from "@heroicons/react/24/outline";
import { getPortfolio, getPortfolioPerformance, getProperties } from "../utils";
import DataTable from "@/app/portfolio/[slug]/components/propertyTable";
import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
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";
// We enfore caching of data for 60 seconds
export const revalidate = 60;
function EmptyPropertyState() {
return (
<div className="flex justify-center h-1/2">
<div className="bg-white rounded-lg w-full">
<p className="text-center text-gray-400 pt-6">
Hover over &quot;New Property&quot; to start adding properties to your
Portfolio
<HomeIcon className="h-20 w-20 mx-auto mt-4 text-gray-200" />
</p>
</div>
</div>
);
}
export default async function Page(props: {
params: Promise<{ slug: string }>;
searchParams: Promise<{
@ -71,26 +57,9 @@ export default async function Page(props: {
];
}
const properties: PropertyWithRelations[] = await getProperties(
portfolioId,
1000,
0,
[]
);
return (
<>
<div className="flex justify-center">
<div className="grid grid-cols-11 w-full max-w-8xl">
<div className="col-span-11 bg-white">
{properties.length === 0 ? (
<EmptyPropertyState />
) : (
<DataTable data={properties} columns={columns} />
)}
</div>
</div>
</div>
<>
<PropertyTable portfolioId={portfolioId}/>
</>
);
}
}

View file

@ -1,6 +1,6 @@
import { ProjectProposal, DashboardSummary } from "./ProjectProposal";
import { getPlansWithTotals } from "./utils";
import DataTable from "@/app/portfolio/[slug]/components/propertyTable";
import DataTable from "@/app/portfolio/[slug]/components/dataTable";
import { planColumns } from "./ProposalColumns";
export default async function ProjectProposalPage(props: {

View file

@ -0,0 +1,151 @@
"use client";
import { useState, useEffect } from "react";
export type PropertyFilterValues = {
address: string;
postcode: string;
current_epc_at_most: "" | "C" | "D" | "E" | "F" | "G";
expected_epc_at_least: "" | "A" | "B" | "C" | "D";
};
const EPC_ORDER = ["A", "B", "C", "D", "E", "F", "G"] as const;
const epcIndex = (epc: string) =>
EPC_ORDER.indexOf(epc as (typeof EPC_ORDER)[number]);
export default function PropertyFilters({
onApply,
}: {
onApply: (filters: PropertyFilterValues) => 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"]>("");
/* ----------------------------------------
Auto-fix invalid combinations
----------------------------------------- */
useEffect(() => {
if (currentEpc && expectedEpc) {
// expected must be BETTER than current
if (epcIndex(expectedEpc) >= epcIndex(currentEpc)) {
setExpectedEpc("");
}
}
}, [currentEpc]);
useEffect(() => {
if (currentEpc && expectedEpc) {
if (epcIndex(expectedEpc) >= epcIndex(currentEpc)) {
setCurrentEpc("");
}
}
}, [expectedEpc]);
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: "",
});
}
return (
<div className="flex gap-4 bg-gray-50 p-4 rounded-lg items-end">
{/* Address */}
<input
className="border rounded px-3 py-2"
placeholder="Address contains…"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
{/* Postcode */}
<input
className="border rounded px-3 py-2"
placeholder="Postcode"
value={postcode}
onChange={(e) => setPostcode(e.target.value)}
/>
{/* Current EPC */}
<select
className="border rounded px-3 py-2"
value={currentEpc}
onChange={(e) =>
setCurrentEpc(e.target.value as any)
}
>
<option value="">Current EPC is</option>
{["C", "D", "E", "F", "G"].map((epc) => (
<option
key={epc}
value={epc}
disabled={
expectedEpc &&
epcIndex(epc) <= epcIndex(expectedEpc)
}
>
{epc} or worse
</option>
))}
</select>
{/* Expected EPC */}
<select
className="border rounded px-3 py-2"
value={expectedEpc}
onChange={(e) =>
setExpectedEpc(e.target.value as any)
}
>
<option value="">Expected EPC at least</option>
{["A", "B", "C", "D"].map((epc) => (
<option
key={epc}
value={epc}
disabled={
currentEpc &&
epcIndex(epc) >= epcIndex(currentEpc)
}
>
{epc} or better
</option>
))}
</select>
<button
onClick={apply}
className="bg-black text-white px-4 py-2 rounded"
>
Apply
</button>
<button
onClick={clear}
className="text-gray-500 px-2"
>
Clear
</button>
</div>
);
}

View file

@ -0,0 +1,109 @@
"use client";
import { useState } 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 { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
import { useMemo } from "react";
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.toUpperCase(),
});
}
console.log(parsed)
return parsed;
}
function EmptyPropertyState() {
return (
<div className="flex justify-center h-1/2">
<div className="bg-white rounded-lg w-full">
<p className="text-center text-gray-400 pt-6">
Hover over &quot;New Property&quot; to start adding properties to your
Portfolio
<HomeIcon className="h-20 w-20 mx-auto mt-4 text-gray-200" />
</p>
</div>
</div>
);
}
export default function PropertyTable({ portfolioId }: { portfolioId: string }) {
const [filters, setFilters] = useState<PropertyFilterValues>({
address: "",
postcode: "",
current_epc_at_most: "",
expected_epc_at_least: "",
});
const parsedFilters = useMemo(
() => parsePropertyFilters(filters),
[filters]
);
const { data = [], isLoading, isFetching, isError } = useProperties({
portfolioId,
filters: parsedFilters,
});
return (
<div className="flex justify-center">
<div className="grid grid-cols-11 w-full max-w-8xl">
<div className="col-span-11 bg-white">
<PropertyFilters onApply={setFilters} />
{isLoading ? (
<div className="p-6 text-gray-400">Loading properties</div>
) : isError || data.length === 0 ? (
<EmptyPropertyState />
) : (
<>
{isFetching && (
<div className="text-sm text-gray-400 px-4">Updating</div>
)}
<DataTable data={data} columns={columns} />
</>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,137 @@
"use client";
import {
ColumnDef,
ColumnFiltersState,
SortingState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
FilterFn,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { useState } from "react";
import { DataTablePagination } from "./propertyTablePagination";
import { rankItem } from "@tanstack/match-sorter-utils";
/* ----------------------------------------
Optional fuzzy global filter
----------------------------------------- */
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
const itemRank = rankItem(String(row.getValue(columnId) ?? ""), value);
addMeta?.({ itemRank });
return itemRank.passed;
};
interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[];
data: TData[];
}
export default function DataTable<TData extends Record<string, any>>({
data,
columns,
}: DataTableProps<TData>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] =
useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [currentPageIndex, setCurrentPageIndex] = useState(0);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: fuzzyFilter,
state: {
sorting,
columnFilters,
globalFilter,
pagination: {
pageIndex: currentPageIndex,
pageSize: 7,
},
},
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="h-14 py-2">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="mb-2">
<DataTablePagination
table={table}
currentPageIndex={currentPageIndex}
setCurrentPageIndex={setCurrentPageIndex}
/>
</div>
</div>
);
}

View file

@ -1,172 +0,0 @@
"use client";
import {
ColumnDef,
ColumnFiltersState,
SortingState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { useState } from "react";
import { DataTablePagination } from "./propertyTablePagination";
import React from "react";
import { Input } from "@/app/shadcn_components/ui/input";
import { rankItem } from "@tanstack/match-sorter-utils";
import { FilterFn } from "@tanstack/react-table";
// Optional: Fuzzy global filter
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
const itemRank = rankItem(String(row.getValue(columnId) ?? ""), value);
addMeta?.({ itemRank });
return itemRank.passed;
};
interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[];
data: TData[];
}
function fetchData<TData>(offset: number): TData[] {
// placeholder function for fetching
const data: TData[] = [];
return data;
}
export default function DataTable<TData extends Record<string, any>>({
data,
columns,
}: DataTableProps<TData>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [tableData, setTableData] = useState(() => [...data]);
const [offset, setOffset] = useState(0);
const [currentPageIndex, setCurrentPageIndex] = useState(0);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
);
const [globalFilter, setGlobalFilter] = React.useState("");
// add page change handlers for DataTablePagination
const loadPaginatedData = () => {
const newData = fetchData<TData>(offset);
if (newData) {
setTableData([...tableData, ...newData]);
setOffset(offset + 1);
return true;
}
return false;
};
const table = useReactTable({
data: tableData,
columns,
getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: fuzzyFilter,
state: {
sorting,
pagination: { pageIndex: currentPageIndex, pageSize: 7 },
columnFilters,
globalFilter,
},
});
return (
<>
<div className="flex items-center py-2">
<div className="flex items-center py-2">
<Input
placeholder="Search address or postcode..."
value={globalFilter ?? ""}
onChange={(event) => setGlobalFilter(event.target.value)}
className="w-64"
/>
{globalFilter && (
<button
onClick={() => setGlobalFilter("")}
className="ml-4 text-gray-500 hover:text-gray-800"
title="Clear search"
>
</button>
)}
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} className="h-14 py-2">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="mb-2">
<DataTablePagination
table={table}
loadPaginatedData={loadPaginatedData}
currentPageIndex={currentPageIndex}
setCurrentPageIndex={setCurrentPageIndex}
/>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,30 @@
import { useQuery } from "@tanstack/react-query";
import { PropertyWithRelations } from "@/app/db/schema/property";
import { PropertyFilter } from "@/app/utils/propertyFilters";
interface Params {
portfolioId: string;
filters: PropertyFilter[];
}
export function useProperties({ portfolioId, filters }: Params) {
return useQuery<PropertyWithRelations[]>({
queryKey: ["properties", portfolioId, filters],
queryFn: async () => {
const res = await fetch("/api/properties", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
portfolioId,
filters,
}),
});
if (!res.ok) throw new Error("Failed to fetch properties");
return res.json();
},
keepPreviousData: true,
});
}

View file

@ -20,6 +20,7 @@ import {
} from "@/app/db/schema/recommendations";
import { sql } from "drizzle-orm";
import { PropertyFilter } from "@/app/utils/propertyFilters";
import { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/app/utils/epc";
export interface PortfolioSettingsType {
name: string;
@ -424,6 +425,67 @@ export async function getProperties(
const whereClauses: any[] = [];
for (const filter of filters) {
switch (filter.field) {
case "address":
if (filter.operator === "contains") {
whereClauses.push(
sql`p.address ILIKE ${"%" + filter.value + "%"}`
);
}
break;
case "postcode":
if (filter.operator === "starts_with") {
whereClauses.push(
sql`p.postcode ILIKE ${filter.value + "%"}`
);
}
break;
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": {
if (filter.operator === "epc_at_least") {
whereClauses.push(sql`
CASE t.epc::text
WHEN 'A' THEN 7
WHEN 'B' THEN 6
WHEN 'C' THEN 5
WHEN 'D' THEN 4
WHEN 'E' THEN 3
WHEN 'F' THEN 2
WHEN 'G' THEN 1
END
>=
CASE ${filter.value}
WHEN 'A' THEN 7
WHEN 'B' THEN 6
WHEN 'C' THEN 5
WHEN 'D' THEN 4
WHEN 'E' THEN 3
WHEN 'F' THEN 2
WHEN 'G' THEN 1
END
`);
}
break;
}
}
}
const combinedWhere =
whereClauses.length > 0
? sql`AND (${sql.join(whereClauses, sql` AND `)})`

25
src/app/utils/epc.ts Normal file
View file

@ -0,0 +1,25 @@
export const EPC_TO_SAP_MIN: Record<
"A" | "B" | "C" | "D" | "E" | "F" | "G",
number
> = {
A: 92,
B: 81,
C: 69,
D: 55,
E: 39,
F: 21,
G: 0,
};
export const EPC_TO_SAP_MAX: Record<
"A" | "B" | "C" | "D" | "E" | "F" | "G",
number
> = {
A: 100,
B: 91,
C: 80,
D: 68,
E: 54,
F: 38,
G: 20,
};

View file

@ -1,16 +1,15 @@
export type FilterField =
| "address"
| "postcode"
| "status"
| "currentEpcRating"
| "currentSapPoints";
| "currentEpc"
| "expectedEpc";
export type FilterOperator =
| "contains"
| "starts_with"
| "equals"
| "gte"
| "lte";
| "epc_at_least"
| "epc_at_most";
export interface PropertyFilter {
field: FilterField;