added filtering + inspections to front end

This commit is contained in:
Khalim Conn-Kowlessar 2025-10-28 11:22:05 +00:00
parent 95c2d8f3d7
commit b6353cb2d2
11 changed files with 4557 additions and 84 deletions

View file

@ -8,11 +8,13 @@ import {
HoverCardTrigger,
} from "@/app/shadcn_components/ui/hover-card";
type ExtendedStatus = (typeof PortfolioStatus)[number] | "ECO4" | "GBIS";
export default function StatusBadge({
status,
isProperty = false,
}: {
status: (typeof PortfolioStatus)[number];
status: ExtendedStatus;
isProperty?: boolean;
}) {
const statusConfig = statusColor[status];
@ -45,7 +47,7 @@ export default function StatusBadge({
}
const statusColor: {
[key in (typeof PortfolioStatus)[number]]: {
[key in ExtendedStatus]: {
class: string;
text: string;
hoverText: string;
@ -59,8 +61,9 @@ const statusColor: {
propertyHoverText: "This property is currently in scoping",
},
assessment: {
class: "bg-emerald-400 hover:bg-emerald-500 truncate text-overflow: ellipsis",
text: "Non-invasive Assessment",
class:
"bg-emerald-400 hover:bg-emerald-500 truncate text-overflow: ellipsis",
text: "Remote Assessment",
hoverText: "This portfolio is currently in the assessment stage",
propertyHoverText: "This property is currently in the assessment stage",
},
@ -114,4 +117,16 @@ const statusColor: {
hoverText: "The works in this portfolio has been completed and need review",
propertyHoverText: "The works on this property have been completed",
},
ECO4: {
class: "bg-brandblue hover:bg-hoverblue",
text: "ECO4",
hoverText: "This property is funded under the ECO4 scheme",
propertyHoverText: "This property is funded under the ECO4 scheme",
},
GBIS: {
class: "bg-brandmidblue hover:bg-hoverblue",
text: "GBIS",
hoverText: "This property is funded under the GBIS scheme",
propertyHoverText: "This property is funded under the GBIS scheme",
},
};

View file

@ -0,0 +1,35 @@
CREATE TYPE "public"."inspection_archetype_2" AS ENUM('detached', 'mid-terrace', 'enclosed mid-terrace', 'end-terrace', 'enclosed end-terrace', 'semi-detached');--> statement-breakpoint
CREATE TYPE "public"."inspection_archetype" AS ENUM('Bungalow', 'Flat', 'Maisonette', 'House', 'non-domestic');--> statement-breakpoint
CREATE TYPE "public"."inspection_borescoped" AS ENUM('yes', 'no', 'refused');--> statement-breakpoint
CREATE TYPE "public"."inspections_access_issues" AS ENUM('see notes', 'damp issues', 'foliage on walls', 'bushes against wall', 'trees around/anove property', 'high rise block flats/maisonettes', 'conservatory', 'lean-to', 'garage', 'extension', 'decking', 'shed against wall');--> statement-breakpoint
CREATE TYPE "public"."inspections_cladding" AS ENUM('none', 'cladded with “sufficient space to fill the wall”', 'cladded with “insufficient space to fill the wall”');--> statement-breakpoint
CREATE TYPE "public"."inspections_insulation_material" AS ENUM('empty 50-90', 'empty 100+', 'empty 30-40', 'empty less than 30', 'loose fibre/wool', 'eps/celo/king', 'fibre batts - with cavity', 'fibre batts - no cavity', 'loose bead', 'glued bead', 'formaldehyde', 'bubble wrap', 'poly chunks');--> statement-breakpoint
CREATE TYPE "public"."inspections_rendered" AS ENUM('no render', 'rendered with “insufficient” space between dpc and render', 'rendered with “sufficient” space between dpc and render');--> statement-breakpoint
CREATE TYPE "public"."inspections_roof_orientation" AS ENUM('north', 'east', 'south', 'west', 'north-east', 'north-west', 'south-east', 'south-west', 'n/s split', 'e/w split', 'ne/sw split', 'nw/se split', 'flat roof', 'no roof', 'roof too small', 'already has solar pv');--> statement-breakpoint
CREATE TYPE "public"."inspections_tile_hung" AS ENUM('yes', 'no', 'first floor flats are tile hung');--> statement-breakpoint
CREATE TYPE "public"."inspections_wall_construction" AS ENUM('cavity', 'solid', 'system built', 'timber framed', 'steel framed', 're-walled cavity', 'mansard pre-fab', 'mansard ewi', 'mansard re-walled');--> statement-breakpoint
CREATE TYPE "public"."inspections_wall_insulation" AS ENUM('empty cavity', 'filled at build', 'partial', 'retro drilled', 'ewi', 'iwi', 'solid non-cavity', 'system built', 'timber framed', 'steel framed');--> statement-breakpoint
CREATE TYPE "public"."plan_type" AS ENUM('solar_eco4', 'solar_hhrsh_eco4', 'empty_cavity_eco', 'partial_cavity_eco', 'extraction_eco');--> statement-breakpoint
CREATE TABLE "inspections" (
"id" bigserial PRIMARY KEY NOT NULL,
"property_id" bigint NOT NULL,
"archetype" "inspection_archetype",
"archetype_2" "inspection_archetype_2",
"wall_construction" "inspections_wall_construction",
"insulation" "inspections_wall_insulation",
"insulation_material" "inspections_insulation_material",
"borescoped" "inspection_borescoped",
"roof_orientation" "inspections_roof_orientation",
"tile_hung" "inspections_tile_hung",
"rendered" "inspections_rendered",
"cladding" "inspections_cladding",
"access_issues" "inspections_access_issues",
"notes" text,
"created_at" timestamp NOT NULL,
"surveyor_name" text,
"uploaded_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "property" ADD COLUMN "landlord_property_id" text;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "plan_type" "plan_type";--> statement-breakpoint
ALTER TABLE "inspections" ADD CONSTRAINT "inspections_property_id_property_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."property"("id") ON DELETE no action ON UPDATE no action;

File diff suppressed because it is too large Load diff

View file

@ -855,6 +855,13 @@
"when": 1761218186670,
"tag": "0121_chunky_tony_stark",
"breakpoints": true
},
{
"idx": 122,
"version": "7",
"when": 1761587998488,
"tag": "0122_yielding_morlocks",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,190 @@
import {
bigserial,
text,
timestamp,
pgTable,
pgEnum,
bigint,
} from "drizzle-orm/pg-core";
import { property } from "./property";
const inspection_archetypes: [string, ...string[]] = [
"Bungalow",
"Flat",
"Maisonette",
"House",
"non-domestic",
];
export const inspectionArchetypeEnum = pgEnum(
"inspection_archetype",
inspection_archetypes
);
const inspection_archetypes_2: [string, ...string[]] = [
"detached",
"mid-terrace",
"enclosed mid-terrace",
"end-terrace",
"enclosed end-terrace",
"semi-detached",
];
export const inspectionArchetype2Enum = pgEnum(
"inspection_archetype_2",
inspection_archetypes_2
);
const inspections_wall_constructions: [string, ...string[]] = [
"cavity",
"solid",
"system built",
"timber framed",
"steel framed",
"re-walled cavity",
"mansard pre-fab",
"mansard ewi",
"mansard re-walled",
];
export const inspectionsWallConstructionEnum = pgEnum(
"inspections_wall_construction",
inspections_wall_constructions
);
const inspections_wall_insulation: [string, ...string[]] = [
"empty cavity",
"filled at build",
"partial",
"retro drilled",
"ewi",
"iwi",
"solid non-cavity",
"system built",
"timber framed",
"steel framed",
];
export const inspectionsWallInsulationEnum = pgEnum(
"inspections_wall_insulation",
inspections_wall_insulation
);
const inspectionsInsulationMaterial: [string, ...string[]] = [
"empty 50-90",
"empty 100+",
"empty 30-40",
"empty less than 30",
"loose fibre/wool",
"eps/celo/king",
"fibre batts - with cavity",
"fibre batts - no cavity",
"loose bead",
"glued bead",
"formaldehyde",
"bubble wrap",
"poly chunks",
];
export const inspectionsInsulationMaterialEnum = pgEnum(
"inspections_insulation_material",
inspectionsInsulationMaterial
);
const inspectionBorescoped: [string, ...string[]] = ["yes", "no", "refused"];
export const inspectionBorescopedEnum = pgEnum(
"inspection_borescoped",
inspectionBorescoped
);
const inspectionsRoofOrientations: [string, ...string[]] = [
"north",
"east",
"south",
"west",
"north-east",
"north-west",
"south-east",
"south-west",
"n/s split",
"e/w split",
"ne/sw split",
"nw/se split",
"flat roof",
"no roof",
"roof too small",
"already has solar pv",
];
export const inspectionsRoofOrientationEnum = pgEnum(
"inspections_roof_orientation",
inspectionsRoofOrientations
);
const inspectionsTileHung: [string, ...string[]] = [
"yes",
"no",
"first floor flats are tile hung",
];
export const inspectionsTileHungEnum = pgEnum(
"inspections_tile_hung",
inspectionsTileHung
);
const renderedOptions: [string, ...string[]] = [
"no render",
"rendered with “insufficient” space between dpc and render",
"rendered with “sufficient” space between dpc and render",
];
export const inspectionsRenderedEnum = pgEnum(
"inspections_rendered",
renderedOptions
);
const claddingOptions: [string, ...string[]] = [
"none",
"cladded with “sufficient space to fill the wall”",
"cladded with “insufficient space to fill the wall”",
];
export const inspectionsCladdingEnum = pgEnum(
"inspections_cladding",
claddingOptions
);
const access_issuesOptions: [string, ...string[]] = [
"see notes",
"damp issues",
"foliage on walls",
"bushes against wall",
"trees around/anove property",
"high rise block flats/maisonettes",
"conservatory",
"lean-to",
"garage",
"extension",
"decking",
"shed against wall",
];
export const inspectionsAccessIssuesEnum = pgEnum(
"inspections_access_issues",
access_issuesOptions
);
export const inspections = pgTable("inspections", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
propertyId: bigint("property_id", { mode: "bigint" })
.notNull()
.references(() => property.id),
// TODO Revise
archetype: inspectionArchetypeEnum("archetype"),
archetype2: inspectionArchetype2Enum("archetype_2"),
wallConstruction: inspectionsWallConstructionEnum("wall_construction"),
insulation: inspectionsWallInsulationEnum("insulation"),
insulationMaterial: inspectionsInsulationMaterialEnum("insulation_material"),
borescoped: inspectionBorescopedEnum("borescoped"),
roofOrientation: inspectionsRoofOrientationEnum("roof_orientation"),
tileHung: inspectionsTileHungEnum("tile_hung"),
rendered: inspectionsRenderedEnum("rendered"),
cladding: inspectionsCladdingEnum("cladding"),
access_issues: inspectionsAccessIssuesEnum("access_issues"),
notes: text("notes"),
createdAt: timestamp("created_at").notNull(),
surveyorName: text("surveyor_name"),
uploadedAt: timestamp("uploaded_at").defaultNow().notNull(),
});

View file

@ -97,6 +97,7 @@ export const property = pgTable("property", {
.references(() => portfolio.id),
creationStatus: propertyCreationStatusEnum("creation_status").notNull(),
uprn: bigint("uprn", { mode: "bigint" }),
landlordPropertyId: text("landlord_property_id"), // Optional ID used by landlords
buildingReferenceNumber: bigint("building_reference_number", {
mode: "bigint",
}),
@ -268,6 +269,13 @@ export interface PropertyWithRelations {
cost?: number | null;
currentEpcRating: string | null;
currentSapPoints: number | null;
plans: {
id: bigint;
isDefault?: boolean;
fundingPackage?: {
scheme: string | null;
} | null;
}[];
}
export type NonIntrusiveSurveyNotes = InferModel<

View file

@ -65,6 +65,16 @@ export const recommendationMaterials = pgTable("recommendation_materials", {
estimatedCost: real("estimated_cost").notNull(),
});
// We create a plan type, for common plan types that we produce for clients
const PlanType: [string, ...string[]] = [
"solar_eco4",
"solar_hhrsh_eco4",
"empty_cavity_eco",
"partial_cavity_eco",
"extraction_eco",
];
export const planTypeEnum = pgEnum("plan_type", PlanType);
export const plan = pgTable("plan", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
name: text("name"),
@ -82,6 +92,7 @@ export const plan = pgTable("plan", {
valuationIncreaseLowerBound: real("valuation_increase_lower_bound"),
valuationIncreaseUpperBound: real("valuation_increase_upper_bound"),
valuationIncreaseAverage: real("valuation_increase_average"),
planType: planTypeEnum("plan_type"), // This may be null for custom plans, outside of our common plan types
});
export const planRecommendations = pgTable("plan_recommendations", {
@ -272,5 +283,5 @@ export const MeasureKeyEnum = z.enum([
...Object.keys(measuresDisplayLabels),
] as [
MeasureKey, // Force at least one measure key
...MeasureKey[]
...MeasureKey[],
]);

View file

@ -62,10 +62,19 @@ export const recommendationMaterialsRelations = relations(
// create a one to many relation to map a plan to the details in the underlying recommendation
// create a many to many map from a plan to a recommendation
// A recommendation can be in multiple plans and therefore we have a many to many relationship between
// plan and recommendations. This relationship is facilitated by the planRecommdnations table
// plan and recommendations. This relationship is facilitated by the planRecommendations table.
// We also need to be able to get from a plan to its property
export const planRelations = relations(plan, ({ many }) => ({
export const planRelations = relations(plan, ({ one, many }) => ({
property: one(property, {
fields: [plan.propertyId],
references: [property.id],
}),
planRecommendations: many(planRecommendations),
fundingPackage: one(fundingPackage, {
fields: [plan.id],
references: [fundingPackage.planId],
}),
}));
export const planRecommendationsRelations = relations(
@ -83,6 +92,7 @@ export const planRecommendationsRelations = relations(
);
// one to one relationship between property and propertyTargets, and also to the recommendations table
// We also relate a property to it's many plans, from which we can take the default
export const propertyRelations = relations(property, ({ one, many }) => ({
target: one(propertyTargets, {
fields: [property.id],
@ -93,6 +103,7 @@ export const propertyRelations = relations(property, ({ one, many }) => ({
fields: [property.id],
references: [propertyDetailsEpc.propertyId],
}),
plans: many(plan),
}));
// We have a many to many relationship between users and portfolios
@ -159,15 +170,25 @@ export const energyAssessmentDocumentsRelations = relations(
// Relation from a funding package to funding package measures
// Define a relation from a EnergyAssessmentDocument to EnergyAssessmentScenario. This is a many to one
// funding package links to multiple funding package measures
export const fundingPackageRelations = relations(fundingPackage, ({ many }) => ({
fundingPackageMeasures: many(fundingPackageMeasures),
}));
// funding package links to multiple funding package measures. We also link to a plan
export const fundingPackageRelations = relations(
fundingPackage,
({ one, many }) => ({
plan: one(plan, {
fields: [fundingPackage.planId],
references: [plan.id],
}),
fundingPackageMeasures: many(fundingPackageMeasures),
})
);
// funding package measures belong to a funding package
export const fundingPackageMeasuresRelations = relations(fundingPackageMeasures, ({ one }) => ({
fundingPackage: one(fundingPackage, {
fields: [fundingPackageMeasures.fundingPackageId],
references: [fundingPackage.id],
}),
}));
export const fundingPackageMeasuresRelations = relations(
fundingPackageMeasures,
({ one }) => ({
fundingPackage: one(fundingPackage, {
fields: [fundingPackageMeasures.fundingPackageId],
references: [fundingPackage.id],
}),
})
);

View file

@ -24,12 +24,12 @@ function EmptyPropertyState() {
);
}
export default async function Page(
props: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined | number }>;
}
) {
export default async function Page(props: {
params: Promise<{ slug: string }>;
searchParams: Promise<{
[key: string]: string | string[] | undefined | number;
}>;
}) {
const params = await props.params;
// This page is served from the server so we can make calls to the database

View file

@ -22,7 +22,6 @@ import {
PropertyWithRelations,
} from "@/app/db/schema/property";
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
@ -41,13 +40,11 @@ const EpcLetterBubble = ({ letter }: { letter: string }) => {
);
};
export function DataTableFilterHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
@ -66,10 +63,11 @@ export function DataTableFilterHeader<TData, TValue>({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{PortfolioStatus.map((status) => (
{[...PortfolioStatus, "ECO4", "GBIS"].map((status) => (
<DropdownMenuItem
key={status}
onClick={() => {
console.log("status filter:", status);
column.setFilterValue(status);
}}
>
@ -130,10 +128,22 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
},
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">
{status && <StatusBadge status={String(status)} isProperty={true} />}
{effectiveStatus && (
<StatusBadge status={String(effectiveStatus)} isProperty={true} />
)}
</div>
);
},

View file

@ -114,7 +114,6 @@ export interface DataItem {
scenarios: Scenario[];
}
export async function getOverviewPortfolioData(
portfolioId: string
): Promise<DataItem[]> {
@ -313,7 +312,11 @@ export async function getOverviewPortfolioData(
{ scenarioName: "Today", data: "" },
{
scenarioName: portfolioName || "Default",
data: "£" + formatNumber((data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1)),
data:
"£" +
formatNumber(
(data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1)
),
},
],
},
@ -333,7 +336,11 @@ export async function getOverviewPortfolioData(
{ scenarioName: "Today", data: "" },
{
scenarioName: portfolioName || "Default",
data: "£" + formatNumber((data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1)),
data:
"£" +
formatNumber(
(data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1)
),
},
],
},
@ -410,56 +417,6 @@ export async function getProperties(
limit: number = 1000,
offset: number = 0
): Promise<PropertyWithRelations[]> {
// When pulling in the associated recommendations, we need to pull in the associated plan and take recommendations that are associated to the
// default plans
// 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: eq(recommendation.default, true),
// with: {
// planRecommendations: {
// columns: {
// planId: true,
// },
// with: {
// plan: {
// columns: {
// id: true,
// isDefault: true,
// },
// where: eq(plan.isDefault, true),
// },
// },
// },
// },
// },
// },
// });
// 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,
@ -501,10 +458,37 @@ export async function getProperties(
)
),
},
plans: {
columns: {
id: true,
},
where: eq(plan.isDefault, true),
// Associate the funding information
with: {
fundingPackage: {
columns: {
scheme: true,
},
},
},
},
},
});
return data;
// 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;
return {
...p,
status: fundingScheme ? fundingScheme.toUpperCase() : p.status,
};
});
return updated;
}
interface UnaggregatedPortfolioPlanRecommendation {
@ -545,7 +529,8 @@ function aggregateRecommendations(
};
} else {
// if quantity is null previously, set to 0
grouped[key].quantity = (grouped[key].quantity ?? 0) + (item.quantity ?? 0);
grouped[key].quantity =
(grouped[key].quantity ?? 0) + (item.quantity ?? 0);
grouped[key].estimatedCost += item.estimatedCost;
if (item.propertyId) {
@ -560,7 +545,9 @@ function aggregateRecommendations(
// Round the results to 2 decimal places, compute uniquePropertyCount, and count unique measureTypes
for (const key in grouped) {
grouped[key].quantity = parseFloat(grouped[key].quantity?.toFixed(2) || "0.00");
grouped[key].quantity = parseFloat(
grouped[key].quantity?.toFixed(2) || "0.00"
);
grouped[key].estimatedCost = parseFloat(
grouped[key].estimatedCost.toFixed(2)
);