mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
improving visuals of the property table
This commit is contained in:
parent
08af8e3bf1
commit
41c068c419
7 changed files with 707 additions and 145 deletions
|
|
@ -1,7 +1,9 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getProperties } from "@/app/portfolio/[slug]/utils";
|
||||
import { getProperties, getPropertiesCount } from "@/app/portfolio/[slug]/utils";
|
||||
import { FilterGroups } from "@/app/utils/propertyFilters";
|
||||
|
||||
const PAGE_LIMIT = 1000;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
|
||||
|
|
@ -15,6 +17,10 @@ export async function POST(req: NextRequest) {
|
|||
);
|
||||
}
|
||||
|
||||
const properties = await getProperties(portfolioId, 1000, 0, filterGroups);
|
||||
return NextResponse.json(properties);
|
||||
const [data, total] = await Promise.all([
|
||||
getProperties(portfolioId, PAGE_LIMIT, 0, filterGroups),
|
||||
getPropertiesCount(portfolioId, filterGroups),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ data, total });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ import {
|
|||
FilterField,
|
||||
FilterOperator,
|
||||
DatePreset,
|
||||
EnumOption,
|
||||
PROPERTY_TYPE_OPTIONS,
|
||||
BUILT_FORM_OPTIONS,
|
||||
TENURE_OPTIONS,
|
||||
YEAR_BUILT_OPTIONS,
|
||||
MAINFUEL_OPTIONS,
|
||||
} from "@/app/utils/propertyFilters";
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
|
|
@ -19,33 +25,58 @@ const EPC_LETTERS = ["A", "B", "C", "D", "E", "F", "G"] as const;
|
|||
type EpcLetter = (typeof EPC_LETTERS)[number];
|
||||
|
||||
const FIELD_OPTIONS: { value: FilterField; label: string }[] = [
|
||||
{ value: "currentEpc", label: "Current EPC" },
|
||||
{ value: "lodgedEpc", label: "Lodged EPC" },
|
||||
{ value: "expectedEpc", label: "Expected EPC" },
|
||||
{ value: "currentEpc", label: "Current EPC" },
|
||||
{ value: "lodgedEpc", label: "Lodged EPC" },
|
||||
{ value: "expectedEpc", label: "Expected EPC" },
|
||||
{ value: "epcExpiryDate", label: "EPC Expiry Date" },
|
||||
{ value: "propertyType", label: "Property Type" },
|
||||
{ value: "builtForm", label: "Built Form" },
|
||||
{ value: "tenure", label: "Tenure" },
|
||||
{ value: "yearBuilt", label: "Year Built" },
|
||||
{ value: "floorArea", label: "Floor Area (m²)" },
|
||||
{ value: "co2Emissions", label: "CO₂ Emissions (kg/m²/yr)" },
|
||||
{ value: "mainfuel", label: "Main Fuel" },
|
||||
];
|
||||
|
||||
const EPC_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [
|
||||
{ value: "epc_less_than", label: "is worse than" },
|
||||
{ value: "equals", label: "equals" },
|
||||
{ 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" },
|
||||
{ value: "epc_one_of", label: "is one of" },
|
||||
];
|
||||
|
||||
const DATE_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [
|
||||
{ value: "date_before", label: "is before" },
|
||||
{ value: "date_after", label: "is after" },
|
||||
{ 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: "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" },
|
||||
{ value: "expires_within_2_years",label: "Expiring within 2 years" },
|
||||
];
|
||||
|
||||
const ENUM_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [
|
||||
{ value: "enum_one_of", label: "is one of" },
|
||||
];
|
||||
|
||||
const NUM_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [
|
||||
{ value: "num_gte", label: "≥ (at least)" },
|
||||
{ value: "num_lte", label: "≤ (at most)" },
|
||||
{ value: "num_equals", label: "= (equals)" },
|
||||
];
|
||||
|
||||
const ENUM_FIELD_OPTIONS: Record<string, EnumOption[]> = {
|
||||
propertyType: PROPERTY_TYPE_OPTIONS,
|
||||
builtForm: BUILT_FORM_OPTIONS,
|
||||
tenure: TENURE_OPTIONS,
|
||||
yearBuilt: YEAR_BUILT_OPTIONS,
|
||||
mainfuel: MAINFUEL_OPTIONS,
|
||||
};
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Helpers
|
||||
------------------------------------------------------------------------ */
|
||||
|
|
@ -53,12 +84,26 @@ function isEpcField(field: FilterField) {
|
|||
return field === "currentEpc" || field === "lodgedEpc" || field === "expectedEpc";
|
||||
}
|
||||
|
||||
function isEnumField(field: FilterField): boolean {
|
||||
return field in ENUM_FIELD_OPTIONS;
|
||||
}
|
||||
|
||||
function isNumericField(field: FilterField) {
|
||||
return field === "floorArea" || field === "co2Emissions";
|
||||
}
|
||||
|
||||
function operatorsForField(field: FilterField): { value: FilterOperator; label: string }[] {
|
||||
if (isEpcField(field)) return EPC_OPERATOR_OPTIONS;
|
||||
if (field === "epcExpiryDate") return DATE_OPERATOR_OPTIONS;
|
||||
if (isEnumField(field)) return ENUM_OPERATOR_OPTIONS;
|
||||
if (isNumericField(field)) return NUM_OPERATOR_OPTIONS;
|
||||
return [];
|
||||
}
|
||||
|
||||
function defaultOperatorForField(field: FilterField): FilterOperator {
|
||||
return operatorsForField(field)[0]?.value ?? "equals";
|
||||
}
|
||||
|
||||
function conditionLabel(condition: PropertyFilter): string {
|
||||
const fieldLabel = FIELD_OPTIONS.find((f) => f.value === condition.field)?.label ?? condition.field;
|
||||
|
||||
|
|
@ -80,6 +125,20 @@ function conditionLabel(condition: PropertyFilter): string {
|
|||
return `${fieldLabel} ${opLabel} ${condition.value}`;
|
||||
}
|
||||
|
||||
if (isEnumField(condition.field) && condition.operator === "enum_one_of") {
|
||||
try {
|
||||
const labels: string[] = JSON.parse(condition.value);
|
||||
return `${fieldLabel} is one of: ${labels.join(", ")}`;
|
||||
} catch {
|
||||
return `${fieldLabel} is one of: ${condition.value}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNumericField(condition.field)) {
|
||||
const opLabel = NUM_OPERATOR_OPTIONS.find((o) => o.value === condition.operator)?.label ?? condition.operator;
|
||||
return `${fieldLabel} ${opLabel} ${condition.value}`;
|
||||
}
|
||||
|
||||
return `${fieldLabel} ${condition.operator} ${condition.value}`;
|
||||
}
|
||||
|
||||
|
|
@ -208,6 +267,133 @@ function EpcDropdown({
|
|||
);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Enum Multi-Select Dropdown
|
||||
------------------------------------------------------------------------ */
|
||||
function EnumMultiDropdown({
|
||||
options,
|
||||
selectedLabels,
|
||||
onChange,
|
||||
}: {
|
||||
options: EnumOption[];
|
||||
selectedLabels: string[];
|
||||
onChange: (labels: string[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
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: Math.max(rect.width, 220),
|
||||
zIndex: 9999,
|
||||
maxHeight: 280,
|
||||
overflowY: "auto",
|
||||
});
|
||||
}
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
|
||||
function toggle(label: string) {
|
||||
onChange(
|
||||
selectedLabels.includes(label)
|
||||
? selectedLabels.filter((l) => l !== label)
|
||||
: [...selectedLabels, label]
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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] text-left">
|
||||
{selectedLabels.length === 0 ? (
|
||||
<span className="text-gray-400">Select options…</span>
|
||||
) : (
|
||||
<span className="text-gray-700 truncate">{selectedLabels.join(", ")}</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">
|
||||
{options.map((opt) => {
|
||||
const isSelected = selectedLabels.includes(opt.label);
|
||||
return (
|
||||
<button
|
||||
key={opt.label}
|
||||
type="button"
|
||||
onClick={() => toggle(opt.label)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-sm transition hover:bg-gray-50 text-left ${isSelected ? "bg-gray-50" : ""}`}
|
||||
>
|
||||
<span className="flex-1 text-gray-700 leading-tight">{opt.label}</span>
|
||||
<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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Number Filter Input
|
||||
------------------------------------------------------------------------ */
|
||||
function NumberFilterInput({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
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"
|
||||
placeholder="Enter value…"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Add Filter Form
|
||||
------------------------------------------------------------------------ */
|
||||
|
|
@ -223,13 +409,16 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
|
|||
const [epcSelected, setEpcSelected] = useState<string[]>([]);
|
||||
const [dateValue, setDateValue] = useState("");
|
||||
const [preset, setPreset] = useState<DatePreset>("expired");
|
||||
const [enumSelected, setEnumSelected] = useState<string[]>([]);
|
||||
const [numValue, setNumValue] = useState("");
|
||||
|
||||
function handleFieldChange(newField: FilterField) {
|
||||
setField(newField);
|
||||
const ops = operatorsForField(newField);
|
||||
setOperator(ops[0]?.value ?? "equals");
|
||||
setOperator(defaultOperatorForField(newField));
|
||||
setEpcSelected([]);
|
||||
setDateValue("");
|
||||
setEnumSelected([]);
|
||||
setNumValue("");
|
||||
}
|
||||
|
||||
function buildValue(): string {
|
||||
|
|
@ -239,12 +428,17 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
|
|||
if (field === "epcExpiryDate") {
|
||||
return operator === "date_preset" ? preset : dateValue;
|
||||
}
|
||||
if (isEnumField(field)) {
|
||||
return enumSelected.length > 0 ? JSON.stringify(enumSelected) : "";
|
||||
}
|
||||
if (isNumericField(field)) {
|
||||
return numValue;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function canConfirm(): boolean {
|
||||
const value = buildValue();
|
||||
return value.length > 0;
|
||||
return buildValue().length > 0;
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
|
|
@ -262,6 +456,8 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
|
|||
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";
|
||||
|
||||
const enumOptions = isEnumField(field) ? ENUM_FIELD_OPTIONS[field] : [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3 bg-gray-50 rounded-md border border-gray-200">
|
||||
{/* Field */}
|
||||
|
|
@ -324,6 +520,18 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
|
|||
onChange={(e) => setDateValue(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isEnumField(field) && (
|
||||
<EnumMultiDropdown
|
||||
options={enumOptions}
|
||||
selectedLabels={enumSelected}
|
||||
onChange={setEnumSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isNumericField(field) && (
|
||||
<NumberFilterInput value={numValue} onChange={setNumValue} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
|
@ -387,28 +595,28 @@ export default function PropertyFilters({
|
|||
}) {
|
||||
// Draft state — only applied when user clicks Apply
|
||||
const [draft, setDraft] = useState<FilterGroups>(filterGroups);
|
||||
// "and" = new group, "or:<groupId>" = OR into existing group, null = hidden
|
||||
const [addMode, setAddMode] = useState<"and" | `or:${string}` | null>(null);
|
||||
// "or" = new OR group, "and:<groupId>" = AND condition into existing group, null = hidden
|
||||
const [addMode, setAddMode] = useState<"or" | `and:${string}` | null>(null);
|
||||
|
||||
function openAndFilter() {
|
||||
setAddMode("and");
|
||||
function openOrGroup() {
|
||||
setAddMode("or");
|
||||
}
|
||||
|
||||
function openOrFilter(groupId: string) {
|
||||
setAddMode(`or:${groupId}`);
|
||||
function openAndCondition(groupId: string) {
|
||||
setAddMode(`and:${groupId}`);
|
||||
}
|
||||
|
||||
function handleConfirm(groupId: string | null, condition: PropertyFilter) {
|
||||
setDraft((prev) => {
|
||||
if (groupId === null) {
|
||||
// New AND group
|
||||
// New OR group
|
||||
const newGroup: FilterGroup = {
|
||||
id: crypto.randomUUID(),
|
||||
conditions: [condition],
|
||||
};
|
||||
return [...prev, newGroup];
|
||||
} else {
|
||||
// Add OR-condition to existing group
|
||||
// Add AND-condition to existing group
|
||||
return prev.map((g) =>
|
||||
g.id === groupId
|
||||
? { ...g, conditions: [...g.conditions, condition] }
|
||||
|
|
@ -447,31 +655,31 @@ export default function PropertyFilters({
|
|||
<div className="flex flex-col gap-3 p-3">
|
||||
{/* Group list */}
|
||||
{draft.map((group, groupIdx) => {
|
||||
const isOrTarget = addMode === `or:${group.id}`;
|
||||
const isAndTarget = addMode === `and:${group.id}`;
|
||||
return (
|
||||
<div key={group.id}>
|
||||
{/* AND divider between groups */}
|
||||
{/* OR divider between groups */}
|
||||
{groupIdx > 0 && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex-1 border-t border-gray-300" />
|
||||
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-wide bg-white px-1.5 py-0.5 rounded border border-gray-300">
|
||||
AND
|
||||
<div className="flex-1 border-t border-blue-200" />
|
||||
<span className="text-[10px] font-bold text-blue-500 uppercase tracking-wide bg-blue-50 px-1.5 py-0.5 rounded border border-blue-200">
|
||||
OR
|
||||
</span>
|
||||
<div className="flex-1 border-t border-gray-300" />
|
||||
<div className="flex-1 border-t border-blue-200" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5 bg-white border border-gray-200 rounded-md p-2">
|
||||
{group.conditions.map((condition, condIdx) => (
|
||||
<div key={condition.id}>
|
||||
{/* OR label between conditions in same group */}
|
||||
{/* AND 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-blue-200" />
|
||||
<span className="text-[10px] font-bold text-blue-500 uppercase tracking-wide bg-blue-50 px-1.5 py-0.5 rounded border border-blue-200">
|
||||
OR
|
||||
<div className="flex-1 border-t border-dashed border-gray-300" />
|
||||
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-wide bg-white px-1.5 py-0.5 rounded border border-gray-300">
|
||||
AND
|
||||
</span>
|
||||
<div className="flex-1 border-t border-dashed border-blue-200" />
|
||||
<div className="flex-1 border-t border-dashed border-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
<ConditionRow
|
||||
|
|
@ -481,8 +689,8 @@ export default function PropertyFilters({
|
|||
</div>
|
||||
))}
|
||||
|
||||
{/* OR form inline */}
|
||||
{isOrTarget ? (
|
||||
{/* AND form inline */}
|
||||
{isAndTarget ? (
|
||||
<AddFilterForm
|
||||
targetGroupId={group.id}
|
||||
onConfirm={handleConfirm}
|
||||
|
|
@ -491,11 +699,11 @@ export default function PropertyFilters({
|
|||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openOrFilter(group.id)}
|
||||
className="self-start mt-1 flex items-center gap-1 text-[11px] font-semibold text-blue-500 hover:text-blue-700 bg-blue-50 hover:bg-blue-100 border border-blue-200 rounded px-2 py-0.5 transition"
|
||||
onClick={() => openAndCondition(group.id)}
|
||||
className="self-start mt-1 flex items-center gap-1 text-[11px] font-semibold text-gray-500 hover:text-gray-700 bg-white hover:bg-gray-50 border border-gray-300 rounded px-2 py-0.5 transition"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
OR
|
||||
AND
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -503,8 +711,8 @@ export default function PropertyFilters({
|
|||
);
|
||||
})}
|
||||
|
||||
{/* AND: add new group form or button */}
|
||||
{addMode === "and" ? (
|
||||
{/* OR: add new group form or button */}
|
||||
{addMode === "or" ? (
|
||||
<AddFilterForm
|
||||
targetGroupId={null}
|
||||
onConfirm={handleConfirm}
|
||||
|
|
@ -514,25 +722,25 @@ export default function PropertyFilters({
|
|||
<div className={hasGroups ? "flex items-center gap-2" : undefined}>
|
||||
{hasGroups && (
|
||||
<>
|
||||
<div className="flex-1 border-t border-gray-300" />
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wide px-1">
|
||||
then
|
||||
<div className="flex-1 border-t border-blue-200" />
|
||||
<span className="text-[10px] font-bold text-blue-400 uppercase tracking-wide px-1">
|
||||
or
|
||||
</span>
|
||||
<div className="flex-1 border-t border-gray-300" />
|
||||
<div className="flex-1 border-t border-blue-200" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAndFilter}
|
||||
onClick={openOrGroup}
|
||||
className={[
|
||||
"flex items-center gap-1 text-sm font-semibold transition",
|
||||
hasGroups
|
||||
? "shrink-0 text-gray-600 hover:text-gray-900 bg-white border border-gray-300 rounded px-2 py-0.5 text-[11px] hover:border-gray-400"
|
||||
? "shrink-0 text-blue-500 hover:text-blue-700 bg-blue-50 border border-blue-200 rounded px-2 py-0.5 text-[11px] hover:border-blue-400"
|
||||
: "w-full justify-center text-gray-600 hover:text-gray-900 border border-dashed border-gray-300 rounded-md px-3 py-2 hover:border-gray-400",
|
||||
].join(" ")}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{hasGroups ? "AND" : "Add filter"}
|
||||
{hasGroups ? "OR" : "Add filter"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,14 +6,17 @@ import DataTable from "./dataTable";
|
|||
import PropertyFilters from "./PropertyFilters";
|
||||
import { FilterGroups } from "@/app/utils/propertyFilters";
|
||||
import {
|
||||
HomeIcon,
|
||||
FunnelIcon,
|
||||
ChevronDownIcon,
|
||||
XMarkIcon,
|
||||
ArrowDownTrayIcon,
|
||||
ViewColumnsIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { HomeIcon } from "@heroicons/react/24/outline";
|
||||
import { sapToEpc } from "@/app/utils";
|
||||
import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
|
||||
import { PropertyWithRelations } from "@/app/db/schema/property";
|
||||
import { TENURE_OPTIONS, MAINFUEL_OPTIONS, EnumOption } from "@/app/utils/propertyFilters";
|
||||
import {
|
||||
OPTIONAL_COLUMN_IDS,
|
||||
OPTIONAL_COLUMN_LABELS,
|
||||
|
|
@ -39,6 +42,76 @@ import {
|
|||
} from "@/app/shadcn_components/ui/dialog";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
|
||||
/* ----------------------------------------
|
||||
Export helpers
|
||||
----------------------------------------- */
|
||||
const EXPORT_LIMIT = 1000;
|
||||
|
||||
function resolveEnumLabel(options: EnumOption[], dbValue: string | null | undefined): string {
|
||||
if (dbValue == null) return "";
|
||||
const opt = options.find((o) => o.dbValues.includes(dbValue));
|
||||
return opt?.label ?? dbValue;
|
||||
}
|
||||
|
||||
function exportToCsv(data: PropertyWithRelations[]) {
|
||||
const headers = [
|
||||
"Address", "Postcode", "Property Ref",
|
||||
"Current EPC", "Lodged EPC", "Expected EPC",
|
||||
"EPC Expiry", "EPC Expired",
|
||||
"Plan Cost (£)",
|
||||
"Property Type", "Built Form", "Tenure", "Year Built",
|
||||
"Floor Area (m²)", "CO₂ Emissions (kg/m²/yr)", "Main Fuel",
|
||||
];
|
||||
|
||||
const rows = data.map((p) => {
|
||||
const lodgedLetter = p.originalSapPoints ? sapToEpc(p.originalSapPoints) ?? "" : "";
|
||||
const expectedSap = (p.currentSapPoints ?? 0) + (p.totalRecommendationSapPoints ?? 0);
|
||||
const expectedLetter = expectedSap > 0 ? sapToEpc(expectedSap) ?? "" : "";
|
||||
|
||||
let expiryStr = "";
|
||||
if (p.epcLodgementDate) {
|
||||
const expiry = new Date(p.epcLodgementDate);
|
||||
expiry.setFullYear(expiry.getFullYear() + 10);
|
||||
expiryStr = expiry.toLocaleDateString("en-GB");
|
||||
}
|
||||
|
||||
return [
|
||||
p.address ?? "",
|
||||
p.postcode ?? "",
|
||||
p.landlordPropertyId ?? "",
|
||||
p.currentEpcRating ?? "",
|
||||
lodgedLetter,
|
||||
expectedLetter,
|
||||
expiryStr,
|
||||
p.epcIsExpired ? "Yes" : "No",
|
||||
p.totalRecommendationCost ? p.totalRecommendationCost.toFixed(2) : "",
|
||||
p.propertyType ?? "",
|
||||
p.builtForm ?? "",
|
||||
resolveEnumLabel(TENURE_OPTIONS, p.tenure),
|
||||
p.yearBuilt ?? "",
|
||||
p.totalFloorArea != null ? p.totalFloorArea.toFixed(1) : "",
|
||||
p.co2Emissions != null ? p.co2Emissions.toFixed(1) : "",
|
||||
resolveEnumLabel(MAINFUEL_OPTIONS, p.mainfuel),
|
||||
];
|
||||
});
|
||||
|
||||
const csv = [headers, ...rows]
|
||||
.map((row) =>
|
||||
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "properties.csv";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/* ----------------------------------------
|
||||
Empty portfolio state
|
||||
----------------------------------------- */
|
||||
|
|
@ -281,7 +354,7 @@ export default function PropertyTable({
|
|||
const hasActiveFilters = allFilterGroups.length > 0;
|
||||
|
||||
const {
|
||||
data = [],
|
||||
data: filteredResponse,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
|
|
@ -290,13 +363,18 @@ export default function PropertyTable({
|
|||
filterGroups: allFilterGroups,
|
||||
});
|
||||
|
||||
const data = filteredResponse?.data ?? [];
|
||||
const filteredTotal = filteredResponse?.total ?? 0;
|
||||
|
||||
// Second query for total (no filters) — React Query dedupes when filters are empty
|
||||
const { data: allData = [] } = useProperties({
|
||||
const { data: allResponse } = useProperties({
|
||||
portfolioId,
|
||||
filterGroups: [],
|
||||
});
|
||||
const totalCount = allData.length;
|
||||
const filteredCount = data.length;
|
||||
const totalCount = allResponse?.total ?? 0;
|
||||
|
||||
const DISPLAY_LIMIT = 1000;
|
||||
const isAtDisplayLimit = data.length >= DISPLAY_LIMIT && filteredTotal > DISPLAY_LIMIT;
|
||||
|
||||
/* ----------------------------------------
|
||||
Delete preview state
|
||||
|
|
@ -323,7 +401,7 @@ export default function PropertyTable({
|
|||
<span className="text-xs text-slate-500">
|
||||
Showing{" "}
|
||||
<span className="font-bold text-primary">
|
||||
{filteredCount.toLocaleString()}
|
||||
{filteredTotal.toLocaleString()}
|
||||
</span>{" "}
|
||||
of{" "}
|
||||
<span className="font-bold text-primary">
|
||||
|
|
@ -401,11 +479,27 @@ export default function PropertyTable({
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Export (stub) */}
|
||||
<button className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-primary hover:bg-slate-200 transition">
|
||||
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
|
||||
Export
|
||||
</button>
|
||||
{/* Export */}
|
||||
{filteredTotal > EXPORT_LIMIT ? (
|
||||
<div title={`Export is available for up to ${EXPORT_LIMIT.toLocaleString()} properties. Refine your filters to enable it.`}>
|
||||
<button
|
||||
disabled
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-slate-400 cursor-not-allowed opacity-60"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => exportToCsv(data)}
|
||||
disabled={isLoading || data.length === 0}
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-primary hover:bg-slate-200 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
|
||||
Export
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -460,6 +554,19 @@ export default function PropertyTable({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Display-limit notice */}
|
||||
{isAtDisplayLimit && (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
<span>
|
||||
Your filters match{" "}
|
||||
<span className="font-semibold">{filteredTotal.toLocaleString()}</span>{" "}
|
||||
properties — only the first{" "}
|
||||
<span className="font-semibold">{DISPLAY_LIMIT.toLocaleString()}</span>{" "}
|
||||
are shown. Refine your filters to narrow results.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body: sidebar + table */}
|
||||
<div className="flex items-start">
|
||||
{/* Collapsible filter sidebar */}
|
||||
|
|
|
|||
|
|
@ -11,21 +11,55 @@ import {
|
|||
} from "@/app/shadcn_components/ui/dropdown-menu";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
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 { PropertyWithRelations } from "@/app/db/schema/property";
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
EnumOption,
|
||||
TENURE_OPTIONS,
|
||||
MAINFUEL_OPTIONS,
|
||||
} from "@/app/utils/propertyFilters";
|
||||
|
||||
interface DataTableColumnHeaderProps<
|
||||
TData,
|
||||
TValue,
|
||||
> extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>;
|
||||
title: string;
|
||||
/* -----------------------------------------------------------------------
|
||||
Helpers
|
||||
------------------------------------------------------------------------ */
|
||||
function resolveEnumLabel(
|
||||
options: EnumOption[],
|
||||
dbValue: string | null | undefined
|
||||
): string | null {
|
||||
if (dbValue == null) return null;
|
||||
const opt = options.find((o) => o.dbValues.includes(dbValue));
|
||||
return opt?.label ?? dbValue;
|
||||
}
|
||||
|
||||
function tenureBadgeClass(label: string): string {
|
||||
if (label.toLowerCase().includes("owner")) return "bg-blue-50 text-blue-700";
|
||||
if (label.toLowerCase().includes("private")) return "bg-violet-50 text-violet-700";
|
||||
if (label.toLowerCase().includes("social")) return "bg-emerald-50 text-emerald-700";
|
||||
return "bg-slate-100 text-slate-500";
|
||||
}
|
||||
|
||||
function Pill({
|
||||
children,
|
||||
className = "bg-slate-100 text-slate-600",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wide whitespace-nowrap ${className}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
EPC letter bubble
|
||||
------------------------------------------------------------------------ */
|
||||
const EpcLetterBubble = ({ letter }: { letter: string }) => {
|
||||
if (!letter) return <div className="w-6 h-6" />;
|
||||
return (
|
||||
|
|
@ -39,6 +73,15 @@ const EpcLetterBubble = ({ letter }: { letter: string }) => {
|
|||
);
|
||||
};
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Column header with dropdown filter
|
||||
------------------------------------------------------------------------ */
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function DataTableFilterHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
|
|
@ -67,7 +110,6 @@ export function DataTableFilterHeader<TData, TValue>({
|
|||
<FunnelIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="max-h-80 overflow-y-auto min-w-[10rem]"
|
||||
|
|
@ -106,6 +148,9 @@ export function DataTableFilterHeader<TData, TValue>({
|
|||
);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Column metadata
|
||||
------------------------------------------------------------------------ */
|
||||
export const OPTIONAL_COLUMN_IDS = [
|
||||
"propertyType",
|
||||
"builtForm",
|
||||
|
|
@ -130,6 +175,9 @@ const OPTIONAL_COLUMN_LABELS: Record<OptionalColumnId, string> = {
|
|||
|
||||
export { OPTIONAL_COLUMN_LABELS };
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Core columns
|
||||
------------------------------------------------------------------------ */
|
||||
const coreColumns: ColumnDef<PropertyWithRelations>[] = [
|
||||
{
|
||||
accessorKey: "address",
|
||||
|
|
@ -145,42 +193,25 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
|
|||
),
|
||||
cell: ({ row }) => {
|
||||
const address = String(row.getValue("address"));
|
||||
const postcode = row.original.postcode;
|
||||
const propertyId = row.original.id;
|
||||
const portfolioId = row.original.portfolioId;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<HomeIcon className="h-8 w-8 text-brandmidblue" />
|
||||
<div className="flex flex-col">
|
||||
<a
|
||||
href={`${portfolioId}/building-passport/${propertyId}`}
|
||||
className="font-medium underline text-gray-800 cursor-pointer"
|
||||
>
|
||||
{address}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<a
|
||||
href={`${portfolioId}/building-passport/${propertyId}`}
|
||||
className="text-xs font-bold text-primary hover:text-secondary transition-colors truncate"
|
||||
>
|
||||
{address}
|
||||
</a>
|
||||
{postcode && (
|
||||
<span className="text-[10px] text-slate-400">{postcode}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "postcode",
|
||||
enableGlobalFilter: true,
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Postcode
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="text-gray-700 font-medium flex justify-center">
|
||||
{row.original.postcode}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "landlordPropertyId",
|
||||
header: () => <div className="text-xs font-medium">Property Ref</div>,
|
||||
|
|
@ -193,7 +224,7 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
|
|||
{
|
||||
accessorKey: "currentEpc",
|
||||
header: () => (
|
||||
<div className="flex justify-center text-xs">Current EPC Performance</div>
|
||||
<div className="flex justify-center text-xs">Current EPC</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="text-gray-700 font-medium flex justify-center">
|
||||
|
|
@ -204,7 +235,7 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
|
|||
{
|
||||
accessorKey: "originalSapPoints",
|
||||
header: () => (
|
||||
<div className="flex justify-center text-xs">Lodged EPC Rating</div>
|
||||
<div className="flex justify-center text-xs">Lodged EPC</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const originalSap = row.original.originalSapPoints;
|
||||
|
|
@ -251,43 +282,56 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
|
|||
const expired = row.original.epcIsExpired;
|
||||
|
||||
if (!dateStr) {
|
||||
return <div className="text-center text-gray-400 text-xs">—</div>;
|
||||
return <div className="text-center text-slate-400 text-xs">—</div>;
|
||||
}
|
||||
|
||||
const formatted = new Date(dateStr).toLocaleDateString("en-GB", {
|
||||
day: "2-digit",
|
||||
const lodgementDate = new Date(dateStr);
|
||||
const expiryDate = new Date(lodgementDate);
|
||||
expiryDate.setFullYear(expiryDate.getFullYear() + 10);
|
||||
|
||||
const formatted = expiryDate.toLocaleDateString("en-GB", {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
if (expired) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] font-bold text-red-600 bg-red-50 px-1.5 py-0.5 rounded w-max uppercase tracking-wide">
|
||||
Expired
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">{formatted}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-semibold text-primary">{formatted}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "cost",
|
||||
header: () => <div className="flex justify-center">Cost</div>,
|
||||
header: () => <div className="flex justify-end text-xs">Plan 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>;
|
||||
return <div className="font-medium flex justify-end" />;
|
||||
}
|
||||
|
||||
const formatted = cost ? "£" + formatNumber(cost) : "";
|
||||
|
||||
return (
|
||||
<div className="text-gray-700 font-medium flex justify-center">
|
||||
{formatted}
|
||||
<div className="text-right">
|
||||
{cost ? (
|
||||
<span className="text-xs font-bold text-primary">
|
||||
£{formatNumber(cost)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-300 italic">No cost</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
@ -334,30 +378,37 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
|
|||
},
|
||||
];
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Optional columns
|
||||
------------------------------------------------------------------------ */
|
||||
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>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const val = row.original.propertyType;
|
||||
return val ? <Pill>{val}</Pill> : <span className="text-slate-300 text-xs">—</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const val = row.original.builtForm;
|
||||
return val ? <Pill>{val}</Pill> : <span className="text-slate-300 text-xs">—</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tenure",
|
||||
accessorKey: "tenure",
|
||||
header: () => <div className="text-xs">Tenure</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm text-gray-700">{row.original.tenure ?? "—"}</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const label = resolveEnumLabel(TENURE_OPTIONS, row.original.tenure);
|
||||
if (!label) return <span className="text-slate-300 text-xs">—</span>;
|
||||
return <Pill className={tenureBadgeClass(label)}>{label}</Pill>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "yearBuilt",
|
||||
|
|
@ -388,7 +439,7 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
|
|||
const val = row.original.co2Emissions;
|
||||
return (
|
||||
<div className="text-sm text-gray-700">
|
||||
{val != null ? `${val.toFixed(1)} t/yr` : "—"}
|
||||
{val != null ? `${val.toFixed(1)} kg/m²/yr` : "—"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
@ -397,9 +448,10 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
|
|||
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>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const label = resolveEnumLabel(MAINFUEL_OPTIONS, row.original.mainfuel);
|
||||
return label ? <Pill>{label}</Pill> : <span className="text-slate-300 text-xs">—</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,13 @@ interface Params {
|
|||
filterGroups: FilterGroups;
|
||||
}
|
||||
|
||||
export interface PropertiesResponse {
|
||||
data: PropertyWithRelations[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function useProperties({ portfolioId, filterGroups }: Params) {
|
||||
return useQuery<PropertyWithRelations[]>({
|
||||
return useQuery<PropertiesResponse>({
|
||||
queryKey: ["properties", portfolioId, filterGroups],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/properties", {
|
||||
|
|
|
|||
|
|
@ -19,9 +19,26 @@ import {
|
|||
ScenarioSelect,
|
||||
} from "@/app/db/schema/recommendations";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { FilterGroups, PropertyFilter } from "@/app/utils/propertyFilters";
|
||||
import {
|
||||
FilterGroups,
|
||||
PropertyFilter,
|
||||
PROPERTY_TYPE_OPTIONS,
|
||||
BUILT_FORM_OPTIONS,
|
||||
TENURE_OPTIONS,
|
||||
YEAR_BUILT_OPTIONS,
|
||||
MAINFUEL_OPTIONS,
|
||||
EnumOption,
|
||||
} from "@/app/utils/propertyFilters";
|
||||
import { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/app/utils/epc";
|
||||
|
||||
const ENUM_FIELD_DB_OPTIONS: Record<string, EnumOption[]> = {
|
||||
propertyType: PROPERTY_TYPE_OPTIONS,
|
||||
builtForm: BUILT_FORM_OPTIONS,
|
||||
tenure: TENURE_OPTIONS,
|
||||
yearBuilt: YEAR_BUILT_OPTIONS,
|
||||
mainfuel: MAINFUEL_OPTIONS,
|
||||
};
|
||||
|
||||
export interface PortfolioSettingsType {
|
||||
name: string;
|
||||
budget: number | null;
|
||||
|
|
@ -539,18 +556,85 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case "propertyType":
|
||||
case "builtForm":
|
||||
case "tenure":
|
||||
case "yearBuilt":
|
||||
case "mainfuel": {
|
||||
if (filter.operator !== "enum_one_of") return null;
|
||||
|
||||
let selectedLabels: string[];
|
||||
try {
|
||||
selectedLabels = JSON.parse(filter.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (selectedLabels.length === 0) return null;
|
||||
|
||||
const options = ENUM_FIELD_DB_OPTIONS[filter.field];
|
||||
const colMap: Record<string, ReturnType<typeof sql>> = {
|
||||
propertyType: sql`p.property_type`,
|
||||
builtForm: sql`p.built_form`,
|
||||
tenure: sql`p.tenure`,
|
||||
yearBuilt: sql`p.year_built`,
|
||||
mainfuel: sql`epc.mainfuel`,
|
||||
};
|
||||
const col = colMap[filter.field];
|
||||
|
||||
// Flatten all dbValues for selected labels
|
||||
const allDbValues: string[] = [];
|
||||
let includeNull = false;
|
||||
|
||||
for (const label of selectedLabels) {
|
||||
const opt = options.find((o) => o.label === label);
|
||||
if (!opt) continue;
|
||||
for (const v of opt.dbValues) {
|
||||
if (v === "__null__") {
|
||||
includeNull = true;
|
||||
} else {
|
||||
allDbValues.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parts: ReturnType<typeof sql>[] = [];
|
||||
if (includeNull) {
|
||||
parts.push(sql`${col} IS NULL`);
|
||||
}
|
||||
if (allDbValues.length > 0) {
|
||||
// Build IN clause with each value as a separate param
|
||||
const placeholders = allDbValues.map((v) => sql`${v}`);
|
||||
parts.push(sql`${col} IN (${sql.join(placeholders, sql`, `)})`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
if (parts.length === 1) return parts[0];
|
||||
return sql`(${sql.join(parts, sql` OR `)})`;
|
||||
}
|
||||
|
||||
case "floorArea": {
|
||||
const n = parseFloat(filter.value);
|
||||
if (isNaN(n)) return null;
|
||||
if (filter.operator === "num_gte") return sql`epc.total_floor_area >= ${n}`;
|
||||
if (filter.operator === "num_lte") return sql`epc.total_floor_area <= ${n}`;
|
||||
if (filter.operator === "num_equals") return sql`epc.total_floor_area = ${n}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
case "co2Emissions": {
|
||||
const n = parseFloat(filter.value);
|
||||
if (isNaN(n)) return null;
|
||||
if (filter.operator === "num_gte") return sql`epc.co2_emissions >= ${n}`;
|
||||
if (filter.operator === "num_lte") return sql`epc.co2_emissions <= ${n}`;
|
||||
if (filter.operator === "num_equals") return sql`epc.co2_emissions = ${n}`;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getProperties(
|
||||
portfolioId: string,
|
||||
limit: number = 1000,
|
||||
offset: number = 0,
|
||||
filterGroups: FilterGroups = []
|
||||
): Promise<PropertyWithRelations[]> {
|
||||
// We need to perform the query like this because the nested query is not supported in the ORM right now
|
||||
|
||||
function buildWhereClause(filterGroups: FilterGroups): ReturnType<typeof sql> {
|
||||
const groupFragments: ReturnType<typeof sql>[] = [];
|
||||
|
||||
for (const group of filterGroups) {
|
||||
|
|
@ -563,14 +647,42 @@ export async function getProperties(
|
|||
if (condFragments.length === 1) {
|
||||
groupFragments.push(condFragments[0]);
|
||||
} else {
|
||||
groupFragments.push(sql`(${sql.join(condFragments, sql` OR `)})`);
|
||||
groupFragments.push(sql`(${sql.join(condFragments, sql` AND `)})`);
|
||||
}
|
||||
}
|
||||
|
||||
const combinedWhere =
|
||||
groupFragments.length > 0
|
||||
? sql`AND (${sql.join(groupFragments, sql` AND `)})`
|
||||
: sql``;
|
||||
return groupFragments.length > 0
|
||||
? sql`AND (${sql.join(groupFragments, sql` OR `)})`
|
||||
: sql``;
|
||||
}
|
||||
|
||||
export async function getPropertiesCount(
|
||||
portfolioId: string,
|
||||
filterGroups: FilterGroups = []
|
||||
): Promise<number> {
|
||||
const combinedWhere = buildWhereClause(filterGroups);
|
||||
|
||||
const result = await db.execute<{ count: string }>(sql`
|
||||
SELECT COUNT(DISTINCT p.id)::int AS count
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc epc ON epc.property_id = p.id
|
||||
LEFT JOIN plan pl ON pl.property_id = p.id AND pl.is_default = true
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
${combinedWhere}
|
||||
`);
|
||||
|
||||
return parseInt(result.rows[0]?.count ?? "0", 10);
|
||||
}
|
||||
|
||||
export async function getProperties(
|
||||
portfolioId: string,
|
||||
limit: number = 1000,
|
||||
offset: number = 0,
|
||||
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 combinedWhere = buildWhereClause(filterGroups);
|
||||
|
||||
const result =
|
||||
await db.execute<PropertyWithRelations>(sql<PropertyWithRelations>`
|
||||
|
|
|
|||
|
|
@ -5,7 +5,14 @@ export type FilterField =
|
|||
| "expectedEpc"
|
||||
| "lodgedEpc"
|
||||
| "epcExpiryDate"
|
||||
| "propertyRef";
|
||||
| "propertyRef"
|
||||
| "propertyType"
|
||||
| "builtForm"
|
||||
| "tenure"
|
||||
| "yearBuilt"
|
||||
| "floorArea"
|
||||
| "co2Emissions"
|
||||
| "mainfuel";
|
||||
|
||||
export type FilterOperator =
|
||||
| "contains"
|
||||
|
|
@ -19,7 +26,11 @@ export type FilterOperator =
|
|||
| "date_before"
|
||||
| "date_after"
|
||||
| "date_equals"
|
||||
| "date_preset";
|
||||
| "date_preset"
|
||||
| "enum_one_of"
|
||||
| "num_gte"
|
||||
| "num_lte"
|
||||
| "num_equals";
|
||||
|
||||
export type DatePreset =
|
||||
| "expired"
|
||||
|
|
@ -40,3 +51,64 @@ export interface FilterGroup {
|
|||
}
|
||||
|
||||
export type FilterGroups = FilterGroup[];
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Enum option definitions for categorical filter fields
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export interface EnumOption {
|
||||
/** User-facing display label */
|
||||
label: string;
|
||||
/** Actual DB values to match. Use ["__null__"] to match NULL. */
|
||||
dbValues: string[];
|
||||
}
|
||||
|
||||
export const PROPERTY_TYPE_OPTIONS: EnumOption[] = [
|
||||
{ label: "House", dbValues: ["House"] },
|
||||
{ label: "Flat", dbValues: ["Flat"] },
|
||||
{ label: "Bungalow", dbValues: ["Bungalow"] },
|
||||
{ label: "Maisonette", dbValues: ["Maisonette"] },
|
||||
];
|
||||
|
||||
export const BUILT_FORM_OPTIONS: EnumOption[] = [
|
||||
{ label: "Detached", dbValues: ["Detached"] },
|
||||
{ label: "Semi-Detached", dbValues: ["Semi-Detached"] },
|
||||
{ label: "End-Terrace", dbValues: ["End-Terrace"] },
|
||||
{ label: "Mid-Terrace", dbValues: ["Mid-Terrace"] },
|
||||
{ label: "Enclosed End-Terrace", dbValues: ["Enclosed End-Terrace"] },
|
||||
{ label: "Enclosed Mid-Terrace", dbValues: ["Enclosed Mid-Terrace"] },
|
||||
{ label: "Not Recorded", dbValues: ["Not Recorded"] },
|
||||
];
|
||||
|
||||
export const TENURE_OPTIONS: EnumOption[] = [
|
||||
{ label: "Owner-occupied", dbValues: ["Owner-occupied", "owner-occupied"] },
|
||||
{ label: "Rented (Private)", dbValues: ["Rented (private)", "rental (private)", "rented (private)"] },
|
||||
{ label: "Rented (Social)", dbValues: ["Rented (social)", "rental (social)"] },
|
||||
{ label: "Not Defined", dbValues: ["Not defined - use in the case of a new dwelling for which the intended tenure in not known. It is not to be used for an existing dwelling"] },
|
||||
{ label: "Unknown", dbValues: ["unknown"] },
|
||||
{ label: "Not Recorded", dbValues: ["__null__"] },
|
||||
];
|
||||
|
||||
export const YEAR_BUILT_OPTIONS: EnumOption[] = [
|
||||
"1900","1930","1950","1967","1976","1983","1991","1996",
|
||||
"2003","2007","2008","2009","2010","2011","2012","2013",
|
||||
"2014","2015","2016","2017","2018","2019","2020","2021",
|
||||
"2022","2023","2024","2025",
|
||||
].map((y) => ({ label: y, dbValues: [y] }));
|
||||
|
||||
export const MAINFUEL_OPTIONS: EnumOption[] = [
|
||||
{ label: "Mains Gas", dbValues: ["Gas mains gas", "Mains gas community", "Mains gas not community", "Mains gas this is for backwards compatibility only and should not be used"] },
|
||||
{ label: "LPG", dbValues: ["Bottled lpg", "Lpg community", "Lpg not community", "Lpg this is for backwards compatibility only and should not be used"] },
|
||||
{ label: "Oil", dbValues: ["Oil heating oil", "Oil community", "Oil not community", "Oil this is for backwards compatibility only and should not be used"] },
|
||||
{ label: "Electricity", dbValues: ["Electricity electricity unspecified tariff", "Electricity community", "Electricity not community", "Electricity this is for backwards compatibility only and should not be used"] },
|
||||
{ label: "Biomass", dbValues: ["Bulk wood pellets", "Wood chips", "Wood logs", "Biomass community", "Biomass this is for backwards compatibility only and should not be used"] },
|
||||
{ label: "Coal", dbValues: ["Anthracite", "House coal not community", "House coal this is for backwards compatibility only and should not be used", "Smokeless coal", "Coal community"] },
|
||||
{ label: "Dual Fuel (Mineral + Wood)", dbValues: ["Dual fuel mineral wood"] },
|
||||
{ label: "Biogas", dbValues: ["Biogas not community"] },
|
||||
{ label: "Biodiesel", dbValues: ["Heat from boilers using biodiesel from any biomass source community"] },
|
||||
{ label: "B30d (Biodiesel blend)", dbValues: ["B30d community"] },
|
||||
{ label: "B30k (Biodiesel blend)", dbValues: ["B30k not community"] },
|
||||
{ label: "Community Heat Network", dbValues: ["From heat network data community"] },
|
||||
{ label: "No Heating System", dbValues: ["To be used only when there is no heatinghotwater system", "To be used only when there is no heatinghotwater system or data is from a community network"] },
|
||||
{ label: "Unknown / No Data", dbValues: ["UNKNOWN", "NO DATA!"] },
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue