diff --git a/src/app/api/properties/route.ts b/src/app/api/properties/route.ts index c0fd5139..dd79ee91 100644 --- a/src/app/api/properties/route.ts +++ b/src/app/api/properties/route.ts @@ -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); -} \ No newline at end of file +} diff --git a/src/app/db/schema/property.ts b/src/app/db/schema/property.ts index 90bfea71..3d2dd56c 100644 --- a/src/app/db/schema/property.ts +++ b/src/app/db/schema/property.ts @@ -368,6 +368,19 @@ export interface PropertyWithRelations extends Record { 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< diff --git a/src/app/portfolio/[slug]/(portfolio)/layout.tsx b/src/app/portfolio/[slug]/(portfolio)/layout.tsx index f997ae62..a17bddc6 100644 --- a/src/app/portfolio/[slug]/(portfolio)/layout.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/layout.tsx @@ -22,13 +22,13 @@ export default async function PortfolioLayout(props: { return (
-

+

{portfolioName}

-
+
diff --git a/src/app/portfolio/[slug]/(portfolio)/page.tsx b/src/app/portfolio/[slug]/(portfolio)/page.tsx index 024928b3..4f9b0cde 100644 --- a/src/app/portfolio/[slug]/(portfolio)/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/page.tsx @@ -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 ( - <> - + <> + ); -} \ No newline at end of file +} diff --git a/src/app/portfolio/[slug]/components/PropertyFilters.tsx b/src/app/portfolio/[slug]/components/PropertyFilters.tsx index 361faf21..d7cb3130 100644 --- a/src/app/portfolio/[slug]/components/PropertyFilters.tsx +++ b/src/app/portfolio/[slug]/components/PropertyFilters.tsx @@ -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(""); - const [expectedEpc, setExpectedEpc] = - useState(""); + const [open, setOpen] = useState(false); + const [dropdownStyle, setDropdownStyle] = useState({}); + const ref = useRef(null); + const buttonRef = useRef(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 ( -
-
- {/* Address */} -
- - setAddress(e.target.value)} - /> -
- - {/* Postcode */} -
- - setPostcode(e.target.value)} - /> -
- - {/* Current EPC */} -
- - handleFieldChange(e.target.value as FilterField)} + > + {FIELD_OPTIONS.map((f) => ( + + ))} + +
+ + {/* Operator */} +
+ + +
+ + {/* Value */} +
+ + + {isEpcField(field) && ( + + )} + + {field === "epcExpiryDate" && operator === "date_preset" && ( + -
+ )} - {/* Expected EPC */} -
- - -
+ {field === "epcExpiryDate" && operator !== "date_preset" && ( + setDateValue(e.target.value)} + /> + )} +
- {/* Actions */} -
- - -
+ {/* Actions */} +
+ + +
+
+ ); +} + +/* ----------------------------------------------------------------------- + Condition Row +------------------------------------------------------------------------ */ +function ConditionRow({ + condition, + onRemove, +}: { + condition: PropertyFilter; + onRemove: () => void; +}) { + return ( +
+
+ {conditionLabel(condition)} +
+ +
+ ); +} + +/* ----------------------------------------------------------------------- + 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); + const [showForm, setShowForm] = useState(false); + const [addToGroupId, setAddToGroupId] = useState(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 ( +
+ {/* Group list */} + {draft.map((group, groupIdx) => ( +
+ {/* AND divider between groups */} + {groupIdx > 0 && ( +
+
+ + and + +
+
+ )} + +
+ {group.conditions.map((condition, condIdx) => ( +
+ {/* OR label between conditions in same group */} + {condIdx > 0 && ( +
+
+ + or + +
+
+ )} + removeCondition(group.id, condition.id)} + /> +
+ ))} + + {/* Add OR condition to this group */} + {!(showForm && addToGroupId === group.id) && ( + + )} + + {showForm && addToGroupId === group.id && ( + { setShowForm(false); setAddToGroupId(null); }} + /> + )} +
+
+ ))} + + {/* Add new filter group form */} + {showForm && addToGroupId === null ? ( + { setShowForm(false); setAddToGroupId(null); }} + /> + ) : ( + + )} + + {/* Apply / Clear */} +
+ +
); } -// #Test git with khalimsdsadsaasdsasdfdsertrsadfsdsadfdssdfds diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 03e42cf5..62e0b19f 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -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() {

- Hover over “New Property” to start adding properties + Hover over "New Property" to start adding properties to your portfolio.

@@ -79,6 +35,20 @@ function EmptyPropertyState() { ); } +/* ---------------------------------------- + Loading overlay +----------------------------------------- */ +function LoadingOverlay() { + return ( +
+
+
+ Updating… +
+
+ ); +} + /* ---------------------------------------- Main table ----------------------------------------- */ @@ -87,27 +57,76 @@ export default function PropertyTable({ }: { portfolioId: string; }) { - const router = useRouter(); + const [sidebarOpen, setSidebarOpen] = useState(true); - const [filters, setFilters] = useState({ - 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([]); + + const debounceRef = useRef | 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(null); - const [deleteLoading, setDeleteLoading] = useState(false); + const [previewLoading] = useState(false); + const [previewError] = useState(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 ( -
-
-
- +
+ {/* Quick filters row */} +
+ - {isFetching && ( -
-
-
- )} +
+ + { + setQuickAddress(e.target.value); + handleQuickFilter("address", e.target.value); + }} + /> +
- {hasActiveFilters && !isFetching && ( -
- Filters applied ({parsedFilters.length}) -
- )} +
+ + { + setQuickPostcode(e.target.value); + handleQuickFilter("postcode", e.target.value); + }} + /> +
+ +
+ + { + setQuickPropertyRef(e.target.value); + handleQuickFilter("propertyRef", e.target.value); + }} + /> +
+ + {hasActiveFilters && ( + + )} +
+ + {/* Body: sidebar + table */} +
+ {/* Collapsible filter sidebar */} +
+
+

+ Filters +

+ +
+
+ + {/* Table area */} +
+ {isFetching && !isLoading && } {isLoading ? (
Loading properties…
@@ -147,14 +241,7 @@ export default function PropertyTable({

No properties match your filters.

- {/* ---------------------------------------- - Delete preview modal - ----------------------------------------- */} + {/* Delete preview modal */} { if (!open) { setDeletePropertyId(null); setDeletePreview(null); - setPreviewError(null); } }} > @@ -195,15 +279,6 @@ export default function PropertyTable({ - {previewLoading && ( -
-
- - Calculating deletion impact… - -
- )} - {previewError && (

{previewError}

)} @@ -231,14 +306,6 @@ export default function PropertyTable({ > Cancel - - {/* */}
diff --git a/src/app/portfolio/[slug]/components/dataTable.tsx b/src/app/portfolio/[slug]/components/dataTable.tsx index bdc81daf..be90d39a 100644 --- a/src/app/portfolio/[slug]/components/dataTable.tsx +++ b/src/app/portfolio/[slug]/components/dataTable.tsx @@ -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>({ const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [globalFilter, setGlobalFilter] = useState(""); - - // ✅ REQUIRED pagination state (fixes TS error) const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 7, }); + const [columnVisibility, setColumnVisibility] = useState( + () => { + 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>({ onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, onPaginationChange: setPagination, + onColumnVisibilityChange: setColumnVisibility, globalFilterFn: fuzzyFilter, @@ -78,6 +101,7 @@ export default function DataTable>({ columnFilters, globalFilter, pagination, + columnVisibility, }, meta: { @@ -86,13 +110,54 @@ export default function DataTable>({ }); return ( -
+
+ {/* Edit Columns toolbar */} +
+ + + + + + + Optional Columns + + + {OPTIONAL_COLUMN_IDS.map((colId) => { + const col = table.getColumn(colId); + if (!col) return null; + return ( + { + e.preventDefault(); + col.toggleVisibility(); + }} + > + + + {OPTIONAL_COLUMN_LABELS[colId]} + + + ); + })} + + +
+ {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( - + {header.isPlaceholder ? null : flexRender( diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index b776849c..300e5524 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -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
; return (
({ ); } -export const columns: ColumnDef[] = [ +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 = { + 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[] = [ { accessorKey: "address", enableGlobalFilter: true, - header: ({ column }) => { - return ( - - ); - }, + header: ({ column }) => ( + + ), cell: ({ row }) => { const address = String(row.getValue("address")); const propertyId = row.original.id; @@ -161,90 +182,53 @@ export const columns: ColumnDef[] = [ ), }, { - accessorKey: "status", - // header: () =>
Status
, - header: ({ column }) => { - return ( -
- ( - - )} - /> -
- ); - }, - cell: ({ row }) => { - const status = row.getValue("status") ?? ""; - - return ( -
- {status && } -
- ); - }, - }, - { - accessorKey: "fundingScheme", - header: ({ column }) => { - return ( -
- ( - // handle status being null or undefined - - )} - /> -
- ); - }, - 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 ( -
- {fundingScheme && fundingScheme !== "none" && ( - - )} -
- ); - }, + accessorKey: "landlordPropertyId", + header: () =>
Property Ref
, + cell: ({ row }) => ( +
+ {row.original.landlordPropertyId ?? "—"} +
+ ), }, { accessorKey: "currentEpc", - header: () =>
Current EPC Rating
, + header: () => ( +
Current EPC Performance
+ ), + cell: ({ row }) => ( +
+ +
+ ), + }, + { + accessorKey: "originalSapPoints", + header: () => ( +
Lodged EPC Rating
+ ), cell: ({ row }) => { + const originalSap = row.original.originalSapPoints; + const letter = originalSap ? sapToEpc(originalSap) : null; return (
- {} +
); }, }, { accessorKey: "targetEpc", - header: () =>
Expected EPC
, + header: () => ( +
Expected EPC
+ ), 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 (
- {} +
); } @@ -252,7 +236,38 @@ export const columns: ColumnDef[] = [ return (
- {} + +
+ ); + }, + }, + { + accessorKey: "epcLodgementDate", + header: () => ( +
EPC Expiry
+ ), + cell: ({ row }) => { + const dateStr = row.original.epcLodgementDate; + const expired = row.original.epcIsExpired; + + if (!dateStr) { + return
; + } + + const formatted = new Date(dateStr).toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }); + + return ( +
+ + {formatted} + + {expired && ( + (Exp) + )}
); }, @@ -262,8 +277,8 @@ export const columns: ColumnDef[] = [ header: () =>
Cost
, cell: ({ row }) => { const cost = row.original.totalRecommendationCost; - const creationStatus = row.original.creationStatus; + if (creationStatus === "LOADING") { return
; } @@ -279,51 +294,116 @@ export const columns: ColumnDef[] = [ }, { 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 ( -
- Loading... -
+
Loading...
); } return ( - <> -
- - - - - - Actions - navigator.clipboard.writeText(payment.id)} - className="text-gray-700 cursor-pointer" - > - - Building Passport - - - - - Settings - - - - - -
- +
+ + + + + + Actions + + + Building Passport + + + + Settings + + + + +
); }, }, ]; + +const optionalColumns: ColumnDef[] = [ + { + id: "propertyType", + accessorKey: "propertyType", + header: () =>
Property Type
, + cell: ({ row }) => ( +
{row.original.propertyType ?? "—"}
+ ), + }, + { + id: "builtForm", + accessorKey: "builtForm", + header: () =>
Built Form
, + cell: ({ row }) => ( +
{row.original.builtForm ?? "—"}
+ ), + }, + { + id: "tenure", + accessorKey: "tenure", + header: () =>
Tenure
, + cell: ({ row }) => ( +
{row.original.tenure ?? "—"}
+ ), + }, + { + id: "yearBuilt", + accessorKey: "yearBuilt", + header: () =>
Year Built
, + cell: ({ row }) => ( +
{row.original.yearBuilt ?? "—"}
+ ), + }, + { + id: "totalFloorArea", + accessorKey: "totalFloorArea", + header: () =>
Floor Area (m²)
, + cell: ({ row }) => { + const val = row.original.totalFloorArea; + return ( +
+ {val != null ? `${val.toFixed(1)} m²` : "—"} +
+ ); + }, + }, + { + id: "co2Emissions", + accessorKey: "co2Emissions", + header: () =>
CO₂ Emissions
, + cell: ({ row }) => { + const val = row.original.co2Emissions; + return ( +
+ {val != null ? `${val.toFixed(1)} t/yr` : "—"} +
+ ); + }, + }, + { + id: "mainfuel", + accessorKey: "mainfuel", + header: () =>
Main Fuel
, + cell: ({ row }) => ( +
{row.original.mainfuel ?? "—"}
+ ), + }, +]; + +export const columns: ColumnDef[] = [ + ...coreColumns, + ...optionalColumns, +]; diff --git a/src/app/portfolio/[slug]/components/useProperties.ts b/src/app/portfolio/[slug]/components/useProperties.ts index b0dca4d1..aab39141 100644 --- a/src/app/portfolio/[slug]/components/useProperties.ts +++ b/src/app/portfolio/[slug]/components/useProperties.ts @@ -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({ - 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, }), }); diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index cd7abf44..60d7e5a6 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -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, + operator: PropertyFilter["operator"], + value: string +): ReturnType | 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 => x !== null); + if (ranges.length === 0) return null; + return sql`(${sql.join(ranges, sql` OR `)})`; + } + + return null; +} + +function buildConditionSql(filter: PropertyFilter): ReturnType | 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 { // 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[] = []; - 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 => 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}; `); diff --git a/src/app/utils/propertyFilters.ts b/src/app/utils/propertyFilters.ts index 11e29e4f..16f67985 100644 --- a/src/app/utils/propertyFilters.ts +++ b/src/app/utils/propertyFilters.ts @@ -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; -} \ No newline at end of file +} + +export interface FilterGroup { + id: string; + conditions: PropertyFilter[]; +} + +export type FilterGroups = FilterGroup[];