Merge branch 'main' into feature/abri_table

This commit is contained in:
Jun-te Kim 2025-10-31 12:53:17 +00:00
commit 8016cc6f26
13 changed files with 4436 additions and 158 deletions

23
package-lock.json generated
View file

@ -28,6 +28,7 @@
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.0.7",
"@remixicon/react": "^4.2.0",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-query": "^4.29.12",
"@tanstack/react-table": "^8.9.3",
"@tremor/react": "^3.18.7",
@ -5549,6 +5550,22 @@
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1"
}
},
"node_modules/@tanstack/match-sorter-utils": {
"version": "8.19.4",
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz",
"integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==",
"license": "MIT",
"dependencies": {
"remove-accents": "0.5.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-core": {
"version": "4.40.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.40.0.tgz",
@ -12985,6 +13002,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/remove-accents": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==",
"license": "MIT"
},
"node_modules/request-progress": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz",

View file

@ -34,6 +34,7 @@
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.0.7",
"@remixicon/react": "^4.2.0",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-query": "^4.29.12",
"@tanstack/react-table": "^8.9.3",
"@tremor/react": "^3.18.7",

View file

@ -8,7 +8,11 @@ import {
HoverCardTrigger,
} from "@/app/shadcn_components/ui/hover-card";
type ExtendedStatus = (typeof PortfolioStatus)[number] | "ECO4" | "GBIS";
type ExtendedStatus =
| (typeof PortfolioStatus)[number]
| "ECO4"
| "GBIS"
| "NONE";
export default function StatusBadge({
status,
@ -18,6 +22,7 @@ export default function StatusBadge({
isProperty?: boolean;
}) {
const statusConfig = statusColor[status];
console.log("status", status, statusConfig);
return (
<HoverCard>
@ -129,4 +134,10 @@ const statusColor: {
hoverText: "This property is funded under the GBIS scheme",
propertyHoverText: "This property is funded under the GBIS scheme",
},
NONE: {
class: "bg-gray-400 hover:bg-gray-400",
text: "No Funding",
hoverText: "This property has no funding scheme applied",
propertyHoverText: "This property has no funding scheme applied",
},
};

View file

@ -0,0 +1 @@
ALTER TYPE "public"."type" ADD VALUE 'secondary_glazing' BEFORE 'trickle_vent';

File diff suppressed because it is too large Load diff

View file

@ -862,6 +862,13 @@
"when": 1761587998488,
"tag": "0122_yielding_morlocks",
"breakpoints": true
},
{
"idx": 123,
"version": "7",
"when": 1761660543815,
"tag": "0123_cloudy_gambit",
"breakpoints": true
}
]
}

View file

@ -42,6 +42,7 @@ export const MaterialType: [string, ...string[]] = [
"flat_roof_waterproofing",
// Windows
"windows_glazing",
"secondary_glazing",
// vents
"trickle_vent",
"door_undercut",

View file

@ -257,25 +257,20 @@ export interface PropertyToRecommendation {
sapPoints?: number | null;
}
export interface PropertyWithRelations {
status: string | null;
id: bigint;
portfolioId: bigint;
creationStatus: string;
export interface PropertyWithRelations extends Record<string, unknown> {
id: number | string | bigint;
portfolioId: number | string | bigint;
address: string | null;
postcode: string | null;
target: { epc?: string | null; heatDemand?: number | null };
recommendations: PropertyToRecommendation[];
cost?: number | null;
status: string | null;
creationStatus: string | null;
currentEpcRating: string | null;
currentSapPoints: number | null;
plans: {
id: bigint;
isDefault?: boolean;
fundingPackage?: {
scheme: string | null;
} | null;
}[];
targetEpc: string | null;
planId: number | null;
fundingScheme: string | null;
totalRecommendationSapPoints: number | null;
totalRecommendationCost: number | null;
}
export type NonIntrusiveSurveyNotes = InferModel<

View file

@ -8,6 +8,7 @@ import { cache } from "react";
import { Inter } from "next/font/google";
import { Toaster } from "@/app/shadcn_components/ui/toaster";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { X } from "lucide-react";
// If loading a variable font, you don't need to specify the font weight
const inter = Inter({

View file

@ -73,11 +73,14 @@ export default async function Page(props: {
];
}
// Time how long this takes
console.time("getProperties3");
const properties: PropertyWithRelations[] = await getProperties(
portfolioId,
1000,
0
);
console.timeEnd("getProperties3");
return (
<>

View file

@ -25,6 +25,15 @@ import { DataTablePagination } from "./propertyTablePagination";
import React from "react";
import { Input } from "@/app/shadcn_components/ui/input";
import { PropertyWithRelations } from "@/app/db/schema/property";
import { rankItem } from "@tanstack/match-sorter-utils";
import { FilterFn } from "@tanstack/react-table";
// Optional: Fuzzy global filter
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
const itemRank = rankItem(String(row.getValue(columnId) ?? ""), value);
addMeta?.({ itemRank });
return itemRank.passed;
};
interface DataTableProps<TData, TValue> {
columns: ColumnDef<PropertyWithRelations>[];
@ -49,6 +58,7 @@ export default function DataTable<TData, TValue>({
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
);
const [globalFilter, setGlobalFilter] = React.useState("");
// add page change handlers for DataTablePagination
const loadPaginatedData = () => {
@ -72,24 +82,36 @@ export default function DataTable<TData, TValue>({
getPaginationRowModel: getPaginationRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: fuzzyFilter,
state: {
sorting,
pagination: { pageIndex: currentPageIndex, pageSize: 7 },
columnFilters,
globalFilter,
},
});
return (
<>
<div className="flex items-center py-4">
<Input
placeholder="Filter address"
value={(table.getColumn("address")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("address")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
<div className="flex items-center py-2">
<div className="flex items-center py-2">
<Input
placeholder="Search address or postcode..."
value={globalFilter ?? ""}
onChange={(event) => setGlobalFilter(event.target.value)}
className="w-64"
/>
{globalFilter && (
<button
onClick={() => setGlobalFilter("")}
className="ml-4 text-gray-500 hover:text-gray-800"
title="Clear search"
>
</button>
)}
</div>
</div>
<div className="rounded-md border">
<Table>
@ -98,7 +120,7 @@ export default function DataTable<TData, TValue>({
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} className="h-14 py-4">
<TableHead key={header.id} className="h-14 py-2">
{header.isPlaceholder
? null
: flexRender(

View file

@ -17,10 +17,8 @@ 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 {
PropertyToRecommendation,
PropertyWithRelations,
} from "@/app/db/schema/property";
import { PropertyWithRelations } from "@/app/db/schema/property";
import { X } from "lucide-react";
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
@ -44,38 +42,65 @@ export function DataTableFilterHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
options,
renderOption,
}: DataTableColumnHeaderProps<TData, TValue> & {
options: string[];
renderOption?: (opt: string) => React.ReactNode;
}) {
const currentValue = column.getFilterValue() as string | undefined;
return (
<div className={cn("flex items-center space-x-2", className)}>
<div className={cn("flex items-center space-x-1", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
variant={currentValue ? "secondary" : "ghost"}
size="sm"
className="-ml-3 h-8 data-[state=open]:bg-accent"
className={cn(
"-ml-2 h-8",
currentValue && "text-accent-foreground bg-accent"
)}
>
<span>{title}</span>
<FunnelIcon className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{[...PortfolioStatus, "ECO4", "GBIS"].map((status) => (
<DropdownMenuContent
align="start"
className="max-h-80 overflow-y-auto min-w-[10rem]"
>
{options.map((opt) => (
<DropdownMenuItem
key={status}
onClick={() => {
console.log("status filter:", status);
column.setFilterValue(status);
}}
key={opt}
onClick={() =>
column.setFilterValue(currentValue === opt ? undefined : opt)
}
className={cn(
"cursor-pointer flex items-center gap-2 px-2 py-1.5",
currentValue === opt && "bg-accent"
)}
>
{<StatusBadge status={status} isProperty={false} />}
{renderOption ? renderOption(opt) : opt}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{currentValue && (
<Button
variant="ghost"
className={cn(
"h-6 w-6 text-gray-500 hover:text-gray-800 transition-opacity duration-150",
currentValue ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={() => column.setFilterValue(undefined)}
title="Clear filter"
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
);
}
@ -83,6 +108,7 @@ export function DataTableFilterHeader<TData, TValue>({
export const columns: ColumnDef<PropertyWithRelations>[] = [
{
accessorKey: "address",
enableGlobalFilter: true,
header: ({ column }) => {
return (
<Button
@ -96,7 +122,6 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
},
cell: ({ row }) => {
const address = String(row.getValue("address"));
const postcode = String(row.original.postcode);
const propertyId = row.original.id;
const portfolioId = row.original.portfolioId;
@ -110,39 +135,84 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
>
{address}
</a>
<a className="text-sm text-gray-500">{postcode}</a>
</div>
</div>
);
},
},
{
accessorKey: "postcode",
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: "status",
// header: () => <div className="flex justify-center">Status</div>,
header: ({ column }) => {
return (
<div className="flex justify-center">
<DataTableFilterHeader column={column} title="Status" />
<DataTableFilterHeader
column={column}
title="Status"
options={PortfolioStatus}
renderOption={(status) => (
<StatusBadge status={status} isProperty={false} />
)}
/>
</div>
);
},
cell: ({ row }) => {
const status = row.getValue("status") ?? "";
const plans = row.original.plans || [];
// Check if any plan has an ECO4 or GBIS funding package
const fundingScheme = plans.find((p) => {
const scheme = p?.fundingPackage?.scheme;
return scheme && ["ECO4", "GBIS"].includes(scheme.toUpperCase());
})?.fundingPackage?.scheme;
const effectiveStatus = fundingScheme
? fundingScheme.toUpperCase()
: status;
return (
<div className="flex justify-center">
{effectiveStatus && (
<StatusBadge status={String(effectiveStatus)} isProperty={true} />
{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>
);
@ -163,27 +233,11 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
accessorKey: "targetEpc",
header: () => <div className="flex justify-center">Expected EPC</div>,
cell: ({ row }) => {
const recommendations = row.original.recommendations;
const currentSapPoints = row.original.currentSapPoints || 0;
const currentSapPoints = row.original.currentSapPoints;
const expectedSapPoints = row.original.totalRecommendationSapPoints || 0;
const expectedapPoints = recommendations.reduce(
(acc: number, rec: PropertyToRecommendation) => {
if (rec.sapPoints === null || rec.sapPoints === undefined) {
return acc;
}
return acc + rec.sapPoints;
},
0
);
if (currentSapPoints === null || currentSapPoints === undefined) {
return (
<div className="text-gray-700 font-medium flex justify-center">
{""}
</div>
);
}
const expectedEpc = sapToEpc(currentSapPoints + expectedapPoints);
const expectedEpc = sapToEpc(currentSapPoints + expectedSapPoints);
return (
<div className="text-gray-700 font-medium flex justify-center">
@ -196,17 +250,7 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
accessorKey: "cost",
header: () => <div className="flex justify-center">Cost</div>,
cell: ({ row }) => {
const recommendations = row.original.recommendations;
const cost = recommendations.reduce(
(acc: number, rec: PropertyToRecommendation) => {
if (rec.estimatedCost === null || rec.estimatedCost === undefined) {
return acc;
}
return acc + rec.estimatedCost;
},
0
);
const cost = row.original.totalRecommendationCost;
const creationStatus = row.original.creationStatus;
if (creationStatus === "LOADING") {

View file

@ -18,6 +18,7 @@ import {
scenario,
ScenarioSelect,
} from "@/app/db/schema/recommendations";
import { sql } from "drizzle-orm";
export interface PortfolioSettingsType {
name: string;
@ -418,77 +419,55 @@ export async function getProperties(
offset: number = 0
): Promise<PropertyWithRelations[]> {
// We need to perform the query like this because the nested query is not supported in the ORM right now
const data: PropertyWithRelations[] = await db.query.property.findMany({
limit: limit,
offset: offset,
columns: {
id: true,
portfolioId: true,
address: true,
postcode: true,
status: true,
creationStatus: true,
currentEpcRating: true,
currentSapPoints: true,
},
where: eq(property.portfolioId, BigInt(portfolioId)),
with: {
target: {
columns: {
epc: true,
},
},
recommendations: {
columns: {
id: true,
estimatedCost: true,
sapPoints: true,
},
where: and(
eq(recommendation.default, true),
inArray(
recommendation.id,
db
.select({
recommendationId: planRecommendations.recommendationId,
})
.from(planRecommendations)
.innerJoin(plan, eq(plan.id, planRecommendations.planId))
.where(eq(plan.isDefault, true))
)
),
},
plans: {
columns: {
id: true,
},
where: eq(plan.isDefault, true),
// Associate the funding information
with: {
fundingPackage: {
columns: {
scheme: true,
},
},
},
},
},
});
// override status to reflect ECO4/GBIS if present
const updated = data.map((p) => {
const fundingScheme = p.plans.find((pl) => {
const scheme = pl?.fundingPackage?.scheme;
return scheme && ["ECO4", "GBIS"].includes(scheme.toUpperCase());
})?.fundingPackage?.scheme;
const result =
await db.execute<PropertyWithRelations>(sql<PropertyWithRelations>`
SELECT
p.id AS id,
p.portfolio_id AS "portfolioId",
p.address AS address,
p.postcode AS postcode,
p.status AS status,
p.creation_status AS "creationStatus",
p.current_epc_rating AS "currentEpcRating",
p.current_sap_points AS "currentSapPoints",
t.epc AS "targetEpc",
pl.id AS "planId",
fp.scheme AS "fundingScheme",
COALESCE(SUM(r.sap_points), 0) AS "totalRecommendationSapPoints",
COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost"
FROM property p
LEFT JOIN property_targets t
ON t.property_id = p.id
LEFT JOIN plan pl
ON pl.property_id = p.id
AND pl.is_default = true
LEFT JOIN funding_package fp
ON fp.plan_id = pl.id
LEFT JOIN plan_recommendations pr
ON pr.plan_id = pl.id
LEFT JOIN recommendation r
ON r.id = pr.recommendation_id
AND r.default = true
WHERE p.portfolio_id = ${portfolioId}
GROUP BY
p.id,
p.portfolio_id,
p.address,
p.postcode,
p.status,
p.creation_status,
p.current_epc_rating,
p.current_sap_points,
t.epc,
pl.id,
fp.scheme
LIMIT ${limit} OFFSET ${offset};
`);
return {
...p,
status: fundingScheme ? fundingScheme.toUpperCase() : p.status,
};
});
const data: PropertyWithRelations[] = result.rows;
return updated;
return data;
}
interface UnaggregatedPortfolioPlanRecommendation {