From c9bfb0ce9c3849719d48b6770471174cab9ae0e2 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 5 Jan 2026 17:30:58 +0000 Subject: [PATCH] added db filtereing --- .vscode/settings.json | 26 +++ src/app/api/properties/route.ts | 25 +++ src/app/portfolio/[slug]/(portfolio)/page.tsx | 43 +---- .../(portfolio)/your-projects/plan/page.tsx | 2 +- .../[slug]/components/PropertyFilters.tsx | 151 +++++++++++++++ .../[slug]/components/PropertyTable.tsx | 109 +++++++++++ .../portfolio/[slug]/components/dataTable.tsx | 137 ++++++++++++++ .../[slug]/components/propertyTable.tsx | 172 ------------------ .../[slug]/components/useProperties.ts | 30 +++ src/app/portfolio/[slug]/utils.ts | 62 +++++++ src/app/utils/epc.ts | 25 +++ src/app/utils/propertyFilters.ts | 9 +- 12 files changed, 576 insertions(+), 215 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/app/api/properties/route.ts create mode 100644 src/app/portfolio/[slug]/components/PropertyFilters.tsx create mode 100644 src/app/portfolio/[slug]/components/PropertyTable.tsx create mode 100644 src/app/portfolio/[slug]/components/dataTable.tsx delete mode 100644 src/app/portfolio/[slug]/components/propertyTable.tsx create mode 100644 src/app/portfolio/[slug]/components/useProperties.ts create mode 100644 src/app/utils/epc.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..ecce03ee --- /dev/null +++ b/.vscode/settings.json @@ -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": { + "": false, + "": false, + "": false, + "": false, + "": false, + "": false, + "": false, + "": false, + "": false, + "": false, + "": false + }, + +} \ No newline at end of file diff --git a/src/app/api/properties/route.ts b/src/app/api/properties/route.ts new file mode 100644 index 00000000..68c5fb7f --- /dev/null +++ b/src/app/api/properties/route.ts @@ -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); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/page.tsx b/src/app/portfolio/[slug]/(portfolio)/page.tsx index ac6a8293..fc2134b8 100644 --- a/src/app/portfolio/[slug]/(portfolio)/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/page.tsx @@ -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 ( -
-
-

- Hover over "New Property" to start adding properties to your - Portfolio - -

-
-
- ); -} - 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 ( - <> -
-
-
- {properties.length === 0 ? ( - - ) : ( - - )} -
-
-
+ <> + ); -} +} \ No newline at end of file diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/plan/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/plan/page.tsx index f0ee8686..2f47a080 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/plan/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/plan/page.tsx @@ -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: { diff --git a/src/app/portfolio/[slug]/components/PropertyFilters.tsx b/src/app/portfolio/[slug]/components/PropertyFilters.tsx new file mode 100644 index 00000000..d99ea8fa --- /dev/null +++ b/src/app/portfolio/[slug]/components/PropertyFilters.tsx @@ -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(""); + const [expectedEpc, setExpectedEpc] = + useState(""); + + /* ---------------------------------------- + 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 ( +
+ {/* Address */} + setAddress(e.target.value)} + /> + + {/* Postcode */} + setPostcode(e.target.value)} + /> + + {/* Current EPC */} + + + {/* Expected EPC */} + + + + + +
+ ); +} diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx new file mode 100644 index 00000000..472082cb --- /dev/null +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -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 ( +
+
+

+ Hover over "New Property" to start adding properties to your + Portfolio + +

+
+
+ ); +} + +export default function PropertyTable({ portfolioId }: { portfolioId: string }) { + const [filters, setFilters] = useState({ + 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 ( +
+
+
+ + + {isLoading ? ( +
Loading properties…
+ ) : isError || data.length === 0 ? ( + + ) : ( + <> + {isFetching && ( +
Updating…
+ )} + + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/portfolio/[slug]/components/dataTable.tsx b/src/app/portfolio/[slug]/components/dataTable.tsx new file mode 100644 index 00000000..0e63aac5 --- /dev/null +++ b/src/app/portfolio/[slug]/components/dataTable.tsx @@ -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 = (row, columnId, value, addMeta) => { + const itemRank = rankItem(String(row.getValue(columnId) ?? ""), value); + addMeta?.({ itemRank }); + return itemRank.passed; +}; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export default function DataTable>({ + data, + columns, +}: DataTableProps) { + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = + useState([]); + 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 ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+ +
+ +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/components/propertyTable.tsx b/src/app/portfolio/[slug]/components/propertyTable.tsx deleted file mode 100644 index c15ff005..00000000 --- a/src/app/portfolio/[slug]/components/propertyTable.tsx +++ /dev/null @@ -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 = (row, columnId, value, addMeta) => { - const itemRank = rankItem(String(row.getValue(columnId) ?? ""), value); - addMeta?.({ itemRank }); - return itemRank.passed; -}; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; -} - -function fetchData(offset: number): TData[] { - // placeholder function for fetching - const data: TData[] = []; - return data; -} - -export default function DataTable>({ - data, - columns, -}: DataTableProps) { - const [sorting, setSorting] = useState([]); - const [tableData, setTableData] = useState(() => [...data]); - const [offset, setOffset] = useState(0); - const [currentPageIndex, setCurrentPageIndex] = useState(0); - const [columnFilters, setColumnFilters] = React.useState( - [] - ); - const [globalFilter, setGlobalFilter] = React.useState(""); - - // add page change handlers for DataTablePagination - const loadPaginatedData = () => { - const newData = fetchData(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 ( - <> -
-
- setGlobalFilter(event.target.value)} - className="w-64" - /> - {globalFilter && ( - - )} -
-
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - - No results. - - - )} - -
-
- -
-
- - ); -} diff --git a/src/app/portfolio/[slug]/components/useProperties.ts b/src/app/portfolio/[slug]/components/useProperties.ts new file mode 100644 index 00000000..38e3ae64 --- /dev/null +++ b/src/app/portfolio/[slug]/components/useProperties.ts @@ -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({ + 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, + }); +} diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index 3cdd5af7..38745893 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -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 `)})` diff --git a/src/app/utils/epc.ts b/src/app/utils/epc.ts new file mode 100644 index 00000000..6091fd84 --- /dev/null +++ b/src/app/utils/epc.ts @@ -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, +}; diff --git a/src/app/utils/propertyFilters.ts b/src/app/utils/propertyFilters.ts index 75442233..11e29e4f 100644 --- a/src/app/utils/propertyFilters.ts +++ b/src/app/utils/propertyFilters.ts @@ -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;