mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Merge branch 'main' into feature/abri_table
This commit is contained in:
commit
8016cc6f26
13 changed files with 4436 additions and 158 deletions
23
package-lock.json
generated
23
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
1
src/app/db/migrations/0123_cloudy_gambit.sql
Normal file
1
src/app/db/migrations/0123_cloudy_gambit.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TYPE "public"."type" ADD VALUE 'secondary_glazing' BEFORE 'trickle_vent';
|
||||||
4190
src/app/db/migrations/meta/0123_snapshot.json
Normal file
4190
src/app/db/migrations/meta/0123_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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<
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue