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-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@remixicon/react": "^4.2.0", "@remixicon/react": "^4.2.0",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-query": "^4.29.12", "@tanstack/react-query": "^4.29.12",
"@tanstack/react-table": "^8.9.3", "@tanstack/react-table": "^8.9.3",
"@tremor/react": "^3.18.7", "@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" "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": { "node_modules/@tanstack/query-core": {
"version": "4.40.0", "version": "4.40.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.40.0.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.40.0.tgz",
@ -12985,6 +13002,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/request-progress": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", "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-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@remixicon/react": "^4.2.0", "@remixicon/react": "^4.2.0",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-query": "^4.29.12", "@tanstack/react-query": "^4.29.12",
"@tanstack/react-table": "^8.9.3", "@tanstack/react-table": "^8.9.3",
"@tremor/react": "^3.18.7", "@tremor/react": "^3.18.7",

View file

@ -8,7 +8,11 @@ import {
HoverCardTrigger, HoverCardTrigger,
} from "@/app/shadcn_components/ui/hover-card"; } 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({ export default function StatusBadge({
status, status,
@ -18,6 +22,7 @@ export default function StatusBadge({
isProperty?: boolean; isProperty?: boolean;
}) { }) {
const statusConfig = statusColor[status]; const statusConfig = statusColor[status];
console.log("status", status, statusConfig);
return ( return (
<HoverCard> <HoverCard>
@ -129,4 +134,10 @@ const statusColor: {
hoverText: "This property is funded under the GBIS scheme", hoverText: "This property is funded under the GBIS scheme",
propertyHoverText: "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, "when": 1761587998488,
"tag": "0122_yielding_morlocks", "tag": "0122_yielding_morlocks",
"breakpoints": true "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", "flat_roof_waterproofing",
// Windows // Windows
"windows_glazing", "windows_glazing",
"secondary_glazing",
// vents // vents
"trickle_vent", "trickle_vent",
"door_undercut", "door_undercut",

View file

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

View file

@ -8,6 +8,7 @@ import { cache } from "react";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import { Toaster } from "@/app/shadcn_components/ui/toaster"; import { Toaster } from "@/app/shadcn_components/ui/toaster";
import { SpeedInsights } from "@vercel/speed-insights/next"; 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 // If loading a variable font, you don't need to specify the font weight
const inter = Inter({ 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( const properties: PropertyWithRelations[] = await getProperties(
portfolioId, portfolioId,
1000, 1000,
0 0
); );
console.timeEnd("getProperties3");
return ( return (
<> <>

View file

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

View file

@ -17,10 +17,8 @@ import { FunnelIcon } from "@heroicons/react/24/outline";
import { formatNumber, getEpcColorClass, sapToEpc } from "@/app/utils"; import { formatNumber, getEpcColorClass, sapToEpc } from "@/app/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PortfolioStatus } from "@/app/db/schema/portfolio"; import { PortfolioStatus } from "@/app/db/schema/portfolio";
import { import { PropertyWithRelations } from "@/app/db/schema/property";
PropertyToRecommendation, import { X } from "lucide-react";
PropertyWithRelations,
} from "@/app/db/schema/property";
interface DataTableColumnHeaderProps<TData, TValue> interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> { extends React.HTMLAttributes<HTMLDivElement> {
@ -44,38 +42,65 @@ export function DataTableFilterHeader<TData, TValue>({
column, column,
title, title,
className, className,
}: DataTableColumnHeaderProps<TData, TValue>) { options,
if (!column.getCanSort()) { renderOption,
return <div className={cn(className)}>{title}</div>; }: DataTableColumnHeaderProps<TData, TValue> & {
} options: string[];
renderOption?: (opt: string) => React.ReactNode;
}) {
const currentValue = column.getFilterValue() as string | undefined;
return ( return (
<div className={cn("flex items-center space-x-2", className)}> <div className={cn("flex items-center space-x-1", className)}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant={currentValue ? "secondary" : "ghost"}
size="sm" 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> <span>{title}</span>
<FunnelIcon className="ml-2 h-4 w-4" /> <FunnelIcon className="ml-2 h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </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 <DropdownMenuItem
key={status} key={opt}
onClick={() => { onClick={() =>
console.log("status filter:", status); column.setFilterValue(currentValue === opt ? undefined : opt)
column.setFilterValue(status); }
}} 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> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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> </div>
); );
} }
@ -83,6 +108,7 @@ export function DataTableFilterHeader<TData, TValue>({
export const columns: ColumnDef<PropertyWithRelations>[] = [ export const columns: ColumnDef<PropertyWithRelations>[] = [
{ {
accessorKey: "address", accessorKey: "address",
enableGlobalFilter: true,
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@ -96,7 +122,6 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
}, },
cell: ({ row }) => { cell: ({ row }) => {
const address = String(row.getValue("address")); const address = String(row.getValue("address"));
const postcode = String(row.original.postcode);
const propertyId = row.original.id; const propertyId = row.original.id;
const portfolioId = row.original.portfolioId; const portfolioId = row.original.portfolioId;
@ -110,39 +135,84 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
> >
{address} {address}
</a> </a>
<a className="text-sm text-gray-500">{postcode}</a>
</div> </div>
</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", accessorKey: "status",
// header: () => <div className="flex justify-center">Status</div>, // header: () => <div className="flex justify-center">Status</div>,
header: ({ column }) => { header: ({ column }) => {
return ( return (
<div className="flex justify-center"> <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> </div>
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
const status = row.getValue("status") ?? ""; 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 ( return (
<div className="flex justify-center"> <div className="flex justify-center">
{effectiveStatus && ( {status && <StatusBadge status={String(status)} isProperty={true} />}
<StatusBadge status={String(effectiveStatus)} 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> </div>
); );
@ -163,27 +233,11 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
accessorKey: "targetEpc", accessorKey: "targetEpc",
header: () => <div className="flex justify-center">Expected EPC</div>, header: () => <div className="flex justify-center">Expected EPC</div>,
cell: ({ row }) => { 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( const expectedEpc = sapToEpc(currentSapPoints + expectedSapPoints);
(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);
return ( return (
<div className="text-gray-700 font-medium flex justify-center"> <div className="text-gray-700 font-medium flex justify-center">
@ -196,17 +250,7 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
accessorKey: "cost", accessorKey: "cost",
header: () => <div className="flex justify-center">Cost</div>, header: () => <div className="flex justify-center">Cost</div>,
cell: ({ row }) => { cell: ({ row }) => {
const recommendations = row.original.recommendations; const cost = row.original.totalRecommendationCost;
const cost = recommendations.reduce(
(acc: number, rec: PropertyToRecommendation) => {
if (rec.estimatedCost === null || rec.estimatedCost === undefined) {
return acc;
}
return acc + rec.estimatedCost;
},
0
);
const creationStatus = row.original.creationStatus; const creationStatus = row.original.creationStatus;
if (creationStatus === "LOADING") { if (creationStatus === "LOADING") {

View file

@ -18,6 +18,7 @@ import {
scenario, scenario,
ScenarioSelect, ScenarioSelect,
} from "@/app/db/schema/recommendations"; } from "@/app/db/schema/recommendations";
import { sql } from "drizzle-orm";
export interface PortfolioSettingsType { export interface PortfolioSettingsType {
name: string; name: string;
@ -418,77 +419,55 @@ export async function getProperties(
offset: number = 0 offset: number = 0
): Promise<PropertyWithRelations[]> { ): Promise<PropertyWithRelations[]> {
// We need to perform the query like this because the nested query is not supported in the ORM right now // 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 result =
const updated = data.map((p) => { await db.execute<PropertyWithRelations>(sql<PropertyWithRelations>`
const fundingScheme = p.plans.find((pl) => { SELECT
const scheme = pl?.fundingPackage?.scheme; p.id AS id,
return scheme && ["ECO4", "GBIS"].includes(scheme.toUpperCase()); p.portfolio_id AS "portfolioId",
})?.fundingPackage?.scheme; 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 { const data: PropertyWithRelations[] = result.rows;
...p,
status: fundingScheme ? fundingScheme.toUpperCase() : p.status,
};
});
return updated; return data;
} }
interface UnaggregatedPortfolioPlanRecommendation { interface UnaggregatedPortfolioPlanRecommendation {