mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
improving filter abilities
This commit is contained in:
parent
59d6bf2151
commit
c70a34f174
11 changed files with 1180 additions and 506 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)} m²` : "—"}
|
||||
</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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue