Merge remote-tracking branch 'origin/main' into feature/onbarding_of_addresses

This commit is contained in:
Jun-te Kim 2026-04-24 11:16:46 +00:00
commit 9f8ea85fed
27 changed files with 34502 additions and 195 deletions

View file

@ -5,22 +5,25 @@ import { uploadedFiles } from "@/app/db/schema/uploaded_files";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const dealIdParam = searchParams.get("dealId");
const uprnParam = searchParams.get("uprn");
const landlordPropertyIdParam = searchParams.get("landlordPropertyId");
if (!uprnParam && !landlordPropertyIdParam) {
if (!dealIdParam && !uprnParam && !landlordPropertyIdParam) {
return NextResponse.json(
{ error: "uprn or landlordPropertyId is required" },
{ error: "dealId, uprn, or landlordPropertyId is required" },
{ status: 400 },
);
}
try {
// Prefer UPRN — it's more selective and avoids an OR full-table scan.
// Only fall back to landlordPropertyId when no UPRN is available.
const condition = uprnParam
? eq(uploadedFiles.uprn, BigInt(uprnParam))
: eq(uploadedFiles.landlordPropertyId, landlordPropertyIdParam!);
// Prefer dealId — reliable even when UPRN is missing from the deal.
// Fall back to UPRN, then landlordPropertyId.
const condition = dealIdParam
? eq(uploadedFiles.hubsotDealId, dealIdParam)
: uprnParam
? eq(uploadedFiles.uprn, BigInt(uprnParam))
: eq(uploadedFiles.landlordPropertyId, landlordPropertyIdParam!);
const rows = await db
.select({

View file

@ -69,6 +69,8 @@ export async function GET(
const sid = BigInt(scenarioId);
const hideNonCompliant =
request.nextUrl.searchParams.get("hideNonCompliant") === "true";
const useOriginalBaseline =
request.nextUrl.searchParams.get("useOriginalBaseline") === "true";
/* ----------------------------------------------------------
Query 0 scenario definition
@ -129,7 +131,18 @@ export async function GET(
END
)::float AS total_sap_uplift
FROM latest_plans lp
JOIN property p ON p.id = lp.property_id;
JOIN property p ON p.id = lp.property_id
-- Conditional filter: only restrict by original_sap_points when the toggle is on
-- AND the scenario has an EPC target. Written as an OR chain so Postgres evaluates
-- it as a single WHERE clause avoiding the need to dynamically build the query
-- string in application code (which would require string concatenation and risks
-- SQL injection). The OR short-circuits left-to-right: if the first or second
-- condition is true, the third is never evaluated, so all rows pass through.
WHERE (
${useOriginalBaseline} = false -- toggle off include everything
OR ${minSap}::float IS NULL -- no EPC target nothing to filter on
OR p.original_sap_points < ${minSap}::float -- actual filter
);
`);
const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates;
@ -162,8 +175,14 @@ export async function GET(
COALESCE(fp.total_uplift, 0)
)::float AS total_funding
FROM latest_plans lp
JOIN property p ON p.id = lp.property_id
LEFT JOIN funding_package fp ON fp.plan_id = lp.id
WHERE lp.cost_of_works > 0;
WHERE lp.cost_of_works > 0
AND (
${useOriginalBaseline} = false
OR ${minSap}::float IS NULL
OR p.original_sap_points < ${minSap}::float
);
`);
const upgraded = upgradedResult.rows[0] as UpgradedAggregates;
@ -223,6 +242,11 @@ export async function GET(
AND plan.post_sap_points >= ${minSap}::float
)
)
AND (
${useOriginalBaseline} = false
OR ${minSap}::float IS NULL
OR p.original_sap_points < ${minSap}::float
)
ORDER BY created_at DESC
LIMIT 1
) lp ON true
@ -256,6 +280,11 @@ export async function GET(
AND plan.post_sap_points >= ${minSap}::float
)
)
AND (
${useOriginalBaseline} = false
OR ${minSap}::float IS NULL
OR p.original_sap_points < ${minSap}::float
)
ORDER BY created_at DESC
LIMIT 1
) lp ON true

View file

@ -7,6 +7,26 @@ import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { syncContractorDocUploadToHubSpot } from "@/app/lib/hubspot/dealSync";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
function computeMeasureProgress(
proposedMeasures: string[],
dealDocs: { fileType: string | null; measureName: string | null }[],
) {
const installDocs = dealDocs.filter((d) => d.fileType !== null && d.measureName !== null);
return proposedMeasures.map((measureName) => {
const required = getRequiredDocs(measureName);
const docsForMeasure = installDocs.filter((d) => d.measureName === measureName);
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
const uploadedCount = required.filter((r) => uploadedTypeSet.has(r)).length;
return {
measureName,
uploadedCount,
requiredCount: required.length,
isComplete: uploadedCount === required.length,
};
});
}
// POST — record a contractor install document in uploaded_files (fileType optional — can be classified later)
export async function POST(req: NextRequest) {
@ -85,6 +105,8 @@ export async function PATCH(req: NextRequest) {
measureName: z.string().optional(),
}),
),
hubspotDealId: z.string().optional(),
proposedMeasures: z.array(z.string()).optional(),
});
let body: z.infer<typeof bodySchema>;
@ -110,6 +132,17 @@ export async function PATCH(req: NextRequest) {
.where(eq(uploadedFiles.id, BigInt(update.id)));
}
// Sync per-measure progress to HubSpot after classification
if (body.hubspotDealId && body.proposedMeasures && body.proposedMeasures.length > 0) {
const dealDocs = await db
.select({ fileType: uploadedFiles.fileType, measureName: uploadedFiles.measureName })
.from(uploadedFiles)
.where(eq(uploadedFiles.hubsotDealId, body.hubspotDealId));
const measureProgress = computeMeasureProgress(body.proposedMeasures, dealDocs);
void syncContractorDocUploadToHubSpot({ hubspotDealId: body.hubspotDealId, measureProgress });
}
return NextResponse.json({ success: true });
} catch (err) {
console.error("PATCH /upload/contractor-install error:", err);

View file

@ -0,0 +1,3 @@
ALTER TABLE "property" ADD COLUMN "user_inputted_address" text;--> statement-breakpoint
ALTER TABLE "property" ADD COLUMN "user_inputted_postcode" text;--> statement-breakpoint
ALTER TABLE "property" ADD COLUMN "lexiscore" real;

View file

@ -0,0 +1,261 @@
CREATE TYPE "public"."energy_element_type" AS ENUM('roof', 'wall', 'floor', 'main_heating', 'window', 'lighting', 'hot_water', 'secondary_heating', 'main_heating_controls');--> statement-breakpoint
CREATE TABLE "epc_building_part" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_property_id" bigint NOT NULL,
"identifier" text NOT NULL,
"construction_age_band" text NOT NULL,
"wall_construction" text NOT NULL,
"wall_insulation_type" text NOT NULL,
"wall_thickness_measured" boolean NOT NULL,
"party_wall_construction" text NOT NULL,
"building_part_number" integer,
"wall_dry_lined" boolean,
"wall_thickness_mm" integer,
"wall_insulation_thickness" text,
"floor_heat_loss" integer,
"floor_insulation_thickness" text,
"flat_roof_insulation_thickness" text,
"floor_type" text,
"floor_construction_type" text,
"floor_insulation_type_str" text,
"floor_u_value_known" boolean,
"roof_construction" integer,
"roof_insulation_location" text,
"roof_insulation_thickness" text,
"room_in_roof_floor_area" real,
"room_in_roof_construction_age_band" text,
"alt_wall_1_area" real,
"alt_wall_1_dry_lined" text,
"alt_wall_1_construction" integer,
"alt_wall_1_insulation_type" integer,
"alt_wall_1_thickness_measured" text,
"alt_wall_1_insulation_thickness" text,
"alt_wall_2_area" real,
"alt_wall_2_dry_lined" text,
"alt_wall_2_construction" integer,
"alt_wall_2_insulation_type" integer,
"alt_wall_2_thickness_measured" text,
"alt_wall_2_insulation_thickness" text
);
--> statement-breakpoint
CREATE TABLE "epc_energy_element" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_property_id" bigint NOT NULL,
"element_type" "energy_element_type" NOT NULL,
"description" text NOT NULL,
"energy_efficiency_rating" integer NOT NULL,
"environmental_efficiency_rating" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE "epc_flat_details" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_property_id" bigint NOT NULL,
"level" integer NOT NULL,
"top_storey" text NOT NULL,
"flat_location" integer NOT NULL,
"heat_loss_corridor" integer NOT NULL,
"storey_count" integer,
"unheated_corridor_length_m" integer,
CONSTRAINT "epc_flat_details_epc_property_id_unique" UNIQUE("epc_property_id")
);
--> statement-breakpoint
CREATE TABLE "epc_floor_dimension" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_building_part_id" bigint NOT NULL,
"floor" integer,
"room_height_m" real NOT NULL,
"total_floor_area_m2" real NOT NULL,
"party_wall_length_m" real NOT NULL,
"heat_loss_perimeter_m" real NOT NULL,
"floor_insulation" integer,
"floor_construction" integer
);
--> statement-breakpoint
CREATE TABLE "epc_main_heating_detail" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_property_id" bigint NOT NULL,
"has_fghrs" boolean NOT NULL,
"main_fuel_type" text NOT NULL,
"heat_emitter_type" text NOT NULL,
"emitter_temperature" text NOT NULL,
"main_heating_control" text NOT NULL,
"fan_flue_present" boolean,
"boiler_flue_type" integer,
"boiler_ignition_type" integer,
"central_heating_pump_age" integer,
"central_heating_pump_age_str" text,
"main_heating_index_number" integer,
"sap_main_heating_code" integer,
"main_heating_number" integer,
"main_heating_category" integer,
"main_heating_fraction" integer,
"main_heating_data_source" integer,
"condensing" boolean,
"weather_compensator" boolean
);
--> statement-breakpoint
CREATE TABLE "epc_property" (
"id" bigserial PRIMARY KEY NOT NULL,
"property_id" bigint NOT NULL,
"portfolio_id" bigint NOT NULL,
"uprn" bigint,
"uprn_source" text,
"report_reference" text,
"report_type" text,
"assessment_type" text,
"sap_version" real,
"schema_type" text,
"schema_versions_original" text,
"status" text,
"calculation_software_version" text,
"address_line_1" text,
"address_line_2" text,
"post_town" text,
"postcode" text,
"region_code" text,
"country_code" text,
"language_code" text,
"dwelling_type" text NOT NULL,
"property_type" text,
"built_form" text,
"tenure" text NOT NULL,
"transaction_type" text NOT NULL,
"inspection_date" timestamp NOT NULL,
"completion_date" timestamp,
"registration_date" timestamp,
"total_floor_area_m2" real NOT NULL,
"measurement_type" integer,
"solar_water_heating" boolean NOT NULL,
"has_hot_water_cylinder" boolean NOT NULL,
"has_fixed_air_conditioning" boolean NOT NULL,
"has_conservatory" boolean,
"has_heated_separate_conservatory" boolean,
"conservatory_type" integer,
"door_count" integer NOT NULL,
"wet_rooms_count" integer NOT NULL,
"extensions_count" integer NOT NULL,
"heated_rooms_count" integer NOT NULL,
"open_chimneys_count" integer NOT NULL,
"habitable_rooms_count" integer NOT NULL,
"insulated_door_count" integer NOT NULL,
"cfl_fixed_lighting_bulbs_count" integer NOT NULL,
"led_fixed_lighting_bulbs_count" integer NOT NULL,
"incandescent_fixed_lighting_bulbs_count" integer NOT NULL,
"blocked_chimneys_count" integer,
"draughtproofed_door_count" integer,
"energy_rating_average" integer,
"low_energy_fixed_lighting_bulbs_count" integer,
"fixed_lighting_outlets_count" integer,
"low_energy_fixed_lighting_outlets_count" integer,
"number_of_storeys" integer,
"any_unheated_rooms" boolean,
"hydro" boolean,
"photovoltaic_array" boolean,
"waste_water_heat_recovery" text,
"pressure_test" integer,
"pressure_test_certificate_number" integer,
"percent_draughtproofed" integer,
"insulated_door_u_value" real,
"multiple_glazed_proportion" integer,
"windows_transmission_u_value" real,
"windows_transmission_data_source" integer,
"windows_transmission_solar_transmittance" real,
"energy_mains_gas" boolean NOT NULL,
"energy_meter_type" text NOT NULL,
"energy_pv_battery_count" integer NOT NULL,
"energy_wind_turbines_count" integer NOT NULL,
"energy_gas_smart_meter_present" boolean NOT NULL,
"energy_is_dwelling_export_capable" boolean NOT NULL,
"energy_wind_turbines_terrain_type" text NOT NULL,
"energy_electricity_smart_meter_present" boolean NOT NULL,
"energy_pv_connection" text,
"energy_pv_percent_roof_area" integer,
"energy_pv_battery_capacity" real,
"energy_wind_turbine_hub_height" real,
"energy_wind_turbine_rotor_diameter" real,
"heating_cylinder_size" text,
"heating_water_heating_code" integer,
"heating_water_heating_fuel" integer,
"heating_immersion_heating_type" text,
"heating_cylinder_insulation_type" text,
"heating_cylinder_thermostat" text,
"heating_secondary_fuel_type" integer,
"heating_secondary_heating_type" text,
"heating_cylinder_insulation_thickness_mm" integer,
"heating_wwhrs_index_number_1" integer,
"heating_wwhrs_index_number_2" integer,
"heating_shower_outlet_type" text,
"heating_shower_wwhrs" integer,
"ventilation_type" text,
"ventilation_draught_lobby" boolean,
"ventilation_pressure_test" text,
"ventilation_open_flues_count" integer,
"ventilation_closed_flues_count" integer,
"ventilation_boiler_flues_count" integer,
"ventilation_other_flues_count" integer,
"ventilation_extract_fans_count" integer,
"ventilation_passive_vents_count" integer,
"ventilation_flueless_gas_fires_count" integer,
"ventilation_in_pcdf_database" boolean,
"mechanical_ventilation" integer,
"mechanical_vent_duct_type" integer,
"mechanical_vent_duct_placement" integer,
"mechanical_vent_duct_insulation" integer,
"mechanical_ventilation_index_number" integer,
"mechanical_vent_measured_installation" text
);
--> statement-breakpoint
CREATE TABLE "epc_property_energy_performance" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_property_id" bigint NOT NULL,
"energy_rating_current" integer,
"energy_consumption_current" integer,
"environmental_impact_current" integer,
"heating_cost_current" real,
"lighting_cost_current" real,
"hot_water_cost_current" real,
"co2_emissions_current" real,
"co2_emissions_current_per_floor_area" integer,
"current_energy_efficiency_band" text,
"energy_rating_potential" real,
"energy_consumption_potential" integer,
"environmental_impact_potential" integer,
"heating_cost_potential" real,
"lighting_cost_potential" real,
"hot_water_cost_potential" real,
"co2_emissions_potential" real,
"potential_energy_efficiency_band" text,
CONSTRAINT "epc_property_energy_performance_epc_property_id_unique" UNIQUE("epc_property_id")
);
--> statement-breakpoint
CREATE TABLE "epc_window" (
"id" bigserial PRIMARY KEY NOT NULL,
"epc_property_id" bigint NOT NULL,
"pvc_frame" text NOT NULL,
"glazing_gap" text NOT NULL,
"orientation" text NOT NULL,
"window_type" text NOT NULL,
"glazing_type" text NOT NULL,
"window_width" real NOT NULL,
"window_height" real NOT NULL,
"draught_proofed" boolean NOT NULL,
"window_location" text NOT NULL,
"window_wall_type" text NOT NULL,
"permanent_shutters_present" boolean NOT NULL,
"frame_factor" real,
"permanent_shutters_insulated" text,
"transmission_u_value" real,
"transmission_data_source" integer,
"transmission_solar_transmittance" real
);
--> statement-breakpoint
ALTER TABLE "epc_building_part" ADD CONSTRAINT "epc_building_part_epc_property_id_epc_property_id_fk" FOREIGN KEY ("epc_property_id") REFERENCES "public"."epc_property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_energy_element" ADD CONSTRAINT "epc_energy_element_epc_property_id_epc_property_id_fk" FOREIGN KEY ("epc_property_id") REFERENCES "public"."epc_property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_flat_details" ADD CONSTRAINT "epc_flat_details_epc_property_id_epc_property_id_fk" FOREIGN KEY ("epc_property_id") REFERENCES "public"."epc_property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_floor_dimension" ADD CONSTRAINT "epc_floor_dimension_epc_building_part_id_epc_building_part_id_fk" FOREIGN KEY ("epc_building_part_id") REFERENCES "public"."epc_building_part"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_main_heating_detail" ADD CONSTRAINT "epc_main_heating_detail_epc_property_id_epc_property_id_fk" FOREIGN KEY ("epc_property_id") REFERENCES "public"."epc_property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_property" ADD CONSTRAINT "epc_property_property_id_property_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_property" ADD CONSTRAINT "epc_property_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_property_energy_performance" ADD CONSTRAINT "epc_property_energy_performance_epc_property_id_epc_property_id_fk" FOREIGN KEY ("epc_property_id") REFERENCES "public"."epc_property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "epc_window" ADD CONSTRAINT "epc_window_epc_property_id_epc_property_id_fk" FOREIGN KEY ("epc_property_id") REFERENCES "public"."epc_property"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "uq_epc_property_property_portfolio" ON "epc_property" USING btree ("property_id","portfolio_id");

View file

@ -0,0 +1,3 @@
ALTER TABLE "epc_property" ALTER COLUMN "address_line_1" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "epc_property" ALTER COLUMN "post_town" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "epc_property" ALTER COLUMN "postcode" SET NOT NULL;

View file

@ -0,0 +1,2 @@
ALTER TABLE "epc_property" ALTER COLUMN "property_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "epc_property" ALTER COLUMN "portfolio_id" DROP NOT NULL;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1282,6 +1282,34 @@
"when": 1776699608018,
"tag": "0182_messy_calypso",
"breakpoints": true
},
{
"idx": 183,
"version": "7",
"when": 1776947867497,
"tag": "0183_careless_darkhawk",
"breakpoints": true
},
{
"idx": 184,
"version": "7",
"when": 1776962910255,
"tag": "0184_tiny_annihilus",
"breakpoints": true
},
{
"idx": 185,
"version": "7",
"when": 1777026653433,
"tag": "0185_slimy_mindworm",
"breakpoints": true
},
{
"idx": 186,
"version": "7",
"when": 1777028605680,
"tag": "0186_equal_baron_zemo",
"breakpoints": true
}
]
}

View file

@ -114,6 +114,9 @@ export const property = pgTable(
status: propertyStatusEnum("status"),
address: text("address"),
postcode: text("postcode"),
userInputtedAddress: text("user_inputted_address"),
userInputtedPostcode: text("user_inputted_postcode"),
lexiscore: real("lexiscore"),
hasPreConditionReport: boolean("has_pre_condition_report"),
hasRecommendations: boolean("has_recommendations"),
@ -398,3 +401,371 @@ export interface NonIntrusiveSurveyData {
surveyor: string;
notes: { title: string; note: string }[];
}
// ─── Enums ────────────────────────────────────────────────────────────────────
export const energyElementTypeEnum = pgEnum("energy_element_type", [
"roof", "wall", "floor", "main_heating", "window",
"lighting", "hot_water", "secondary_heating", "main_heating_controls",
]);
// ─── epc_property ─────────────────────────────────────────────────────────────
export const epcProperty = pgTable(
"epc_property",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
propertyId: bigint("property_id", { mode: "bigint" })
// .notNull()
.references(() => property.id),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
// .notNull()
.references(() => portfolio.id),
// Identity / admin
uprn: bigint("uprn", { mode: "bigint" }),
uprnSource: text("uprn_source"),
reportReference: text("report_reference"),
reportType: text("report_type"),
assessmentType: text("assessment_type"),
sapVersion: real("sap_version"),
schemaType: text("schema_type"),
schemaVersionsOriginal: text("schema_versions_original"),
status: text("status"),
calculationSoftwareVersion: text("calculation_software_version"),
// Address
addressLine1: text("address_line_1").notNull(),
addressLine2: text("address_line_2"),
postTown: text("post_town").notNull(),
postcode: text("postcode").notNull(),
regionCode: text("region_code"),
countryCode: text("country_code"),
languageCode: text("language_code"),
// Property description
dwellingType: text("dwelling_type").notNull(),
propertyType: text("property_type"),
builtForm: text("built_form"),
tenure: text("tenure").notNull(),
transactionType: text("transaction_type").notNull(),
// Dates
inspectionDate: timestamp("inspection_date").notNull(),
completionDate: timestamp("completion_date"),
registrationDate: timestamp("registration_date"),
// Measurements
totalFloorAreaM2: real("total_floor_area_m2").notNull(),
measurementType: integer("measurement_type"),
// Flags
solarWaterHeating: boolean("solar_water_heating").notNull(),
hasHotWaterCylinder: boolean("has_hot_water_cylinder").notNull(),
hasFixedAirConditioning: boolean("has_fixed_air_conditioning").notNull(),
hasConservatory: boolean("has_conservatory"),
hasHeatedSeparateConservatory: boolean("has_heated_separate_conservatory"),
conservatoryType: integer("conservatory_type"),
// Counts
doorCount: integer("door_count").notNull(),
wetRoomsCount: integer("wet_rooms_count").notNull(),
extensionsCount: integer("extensions_count").notNull(),
heatedRoomsCount: integer("heated_rooms_count").notNull(),
openChimneysCount: integer("open_chimneys_count").notNull(),
habitableRoomsCount: integer("habitable_rooms_count").notNull(),
insulatedDoorCount: integer("insulated_door_count").notNull(),
cflFixedLightingBulbsCount: integer("cfl_fixed_lighting_bulbs_count").notNull(),
ledFixedLightingBulbsCount: integer("led_fixed_lighting_bulbs_count").notNull(),
incandescentFixedLightingBulbsCount: integer("incandescent_fixed_lighting_bulbs_count").notNull(),
blockedChimneysCount: integer("blocked_chimneys_count"),
draughtproofedDoorCount: integer("draughtproofed_door_count"),
energyRatingAverage: integer("energy_rating_average"),
lowEnergyFixedLightingBulbsCount: integer("low_energy_fixed_lighting_bulbs_count"),
fixedLightingOutletsCount: integer("fixed_lighting_outlets_count"),
lowEnergyFixedLightingOutletsCount: integer("low_energy_fixed_lighting_outlets_count"),
numberOfStoreys: integer("number_of_storeys"),
anyUnheatedRooms: boolean("any_unheated_rooms"),
// Misc
hydro: boolean("hydro"),
photovoltaicArray: boolean("photovoltaic_array"),
wasteWaterHeatRecovery: text("waste_water_heat_recovery"),
pressureTest: integer("pressure_test"),
pressureTestCertificateNumber: integer("pressure_test_certificate_number"),
percentDraughtproofed: integer("percent_draughtproofed"),
insulatedDoorUValue: real("insulated_door_u_value"),
multipleGlazedProportion: integer("multiple_glazed_proportion"),
windowsTransmissionUValue: real("windows_transmission_u_value"),
windowsTransmissionDataSource: integer("windows_transmission_data_source"),
windowsTransmissionSolarTransmittance: real("windows_transmission_solar_transmittance"),
// Energy source
energyMainsGas: boolean("energy_mains_gas").notNull(),
energyMeterType: text("energy_meter_type").notNull(),
energyPvBatteryCount: integer("energy_pv_battery_count").notNull(),
energyWindTurbinesCount: integer("energy_wind_turbines_count").notNull(),
energyGasSmartMeterPresent: boolean("energy_gas_smart_meter_present").notNull(),
energyIsDwellingExportCapable: boolean("energy_is_dwelling_export_capable").notNull(),
energyWindTurbinesTerrainType: text("energy_wind_turbines_terrain_type").notNull(),
energyElectricitySmartMeterPresent: boolean("energy_electricity_smart_meter_present").notNull(),
energyPvConnection: text("energy_pv_connection"),
energyPvPercentRoofArea: integer("energy_pv_percent_roof_area"),
energyPvBatteryCapacity: real("energy_pv_battery_capacity"),
energyWindTurbineHubHeight: real("energy_wind_turbine_hub_height"),
energyWindTurbineRotorDiameter: real("energy_wind_turbine_rotor_diameter"),
// Heating config
heatingCylinderSize: text("heating_cylinder_size"),
heatingWaterHeatingCode: integer("heating_water_heating_code"),
heatingWaterHeatingFuel: integer("heating_water_heating_fuel"),
heatingImmersionHeatingType: text("heating_immersion_heating_type"),
heatingCylinderInsulationType: text("heating_cylinder_insulation_type"),
heatingCylinderThermostat: text("heating_cylinder_thermostat"),
heatingSecondaryFuelType: integer("heating_secondary_fuel_type"),
heatingSecondaryHeatingType: text("heating_secondary_heating_type"),
heatingCylinderInsulationThicknessMm: integer("heating_cylinder_insulation_thickness_mm"),
heatingWwhrsIndexNumber1: integer("heating_wwhrs_index_number_1"),
heatingWwhrsIndexNumber2: integer("heating_wwhrs_index_number_2"),
heatingShowerOutletType: text("heating_shower_outlet_type"),
heatingShowerWwhrs: integer("heating_shower_wwhrs"),
// Ventilation
ventilationType: text("ventilation_type"),
ventilationDraughtLobby: boolean("ventilation_draught_lobby"),
ventilationPressureTest: text("ventilation_pressure_test"),
ventilationOpenFluesCount: integer("ventilation_open_flues_count"),
ventilationClosedFluesCount: integer("ventilation_closed_flues_count"),
ventilationBoilerFluesCount: integer("ventilation_boiler_flues_count"),
ventilationOtherFluesCount: integer("ventilation_other_flues_count"),
ventilationExtractFansCount: integer("ventilation_extract_fans_count"),
ventilationPassiveVentsCount: integer("ventilation_passive_vents_count"),
ventilationFluelessGasFiresCount: integer("ventilation_flueless_gas_fires_count"),
ventilationInPcdfDatabase: boolean("ventilation_in_pcdf_database"),
mechanicalVentilation: integer("mechanical_ventilation"),
mechanicalVentDuctType: integer("mechanical_vent_duct_type"),
mechanicalVentDuctPlacement: integer("mechanical_vent_duct_placement"),
mechanicalVentDuctInsulation: integer("mechanical_vent_duct_insulation"),
mechanicalVentilationIndexNumber: integer("mechanical_ventilation_index_number"),
mechanicalVentMeasuredInstallation: text("mechanical_vent_measured_installation"),
},
(table) => [
uniqueIndex("uq_epc_property_property_portfolio").on(
table.propertyId,
table.portfolioId,
),
],
);
// ─── epc_property_energy_performance ─────────────────────────────────────────
export const epcPropertyEnergyPerformance = pgTable(
"epc_property_energy_performance",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
epcPropertyId: bigint("epc_property_id", { mode: "bigint" })
.notNull()
.unique()
.references(() => epcProperty.id),
// Current
energyRatingCurrent: integer("energy_rating_current"),
energyConsumptionCurrent: integer("energy_consumption_current"),
environmentalImpactCurrent: integer("environmental_impact_current"),
heatingCostCurrent: real("heating_cost_current"),
lightingCostCurrent: real("lighting_cost_current"),
hotWaterCostCurrent: real("hot_water_cost_current"),
co2EmissionsCurrent: real("co2_emissions_current"),
co2EmissionsCurrentPerFloorArea: integer("co2_emissions_current_per_floor_area"),
currentEnergyEfficiencyBand: text("current_energy_efficiency_band"),
// Potential
energyRatingPotential: real("energy_rating_potential"),
energyConsumptionPotential: integer("energy_consumption_potential"),
environmentalImpactPotential: integer("environmental_impact_potential"),
heatingCostPotential: real("heating_cost_potential"),
lightingCostPotential: real("lighting_cost_potential"),
hotWaterCostPotential: real("hot_water_cost_potential"),
co2EmissionsPotential: real("co2_emissions_potential"),
potentialEnergyEfficiencyBand: text("potential_energy_efficiency_band"),
},
);
// ─── epc_flat_details ─────────────────────────────────────────────────────────
export const epcFlatDetails = pgTable(
"epc_flat_details",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
epcPropertyId: bigint("epc_property_id", { mode: "bigint" })
.notNull()
.unique()
.references(() => epcProperty.id),
level: integer("level").notNull(),
topStorey: text("top_storey").notNull(),
flatLocation: integer("flat_location").notNull(),
heatLossCorridor: integer("heat_loss_corridor").notNull(),
storeyCount: integer("storey_count"),
unheatedCorridorLengthM: integer("unheated_corridor_length_m"),
},
);
// ─── epc_main_heating_detail ──────────────────────────────────────────────────
export const epcMainHeatingDetail = pgTable(
"epc_main_heating_detail",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
epcPropertyId: bigint("epc_property_id", { mode: "bigint" })
.notNull()
.references(() => epcProperty.id),
hasFghrs: boolean("has_fghrs").notNull(),
mainFuelType: text("main_fuel_type").notNull(),
heatEmitterType: text("heat_emitter_type").notNull(),
emitterTemperature: text("emitter_temperature").notNull(),
mainHeatingControl: text("main_heating_control").notNull(),
fanFluePresent: boolean("fan_flue_present"),
boilerFlueType: integer("boiler_flue_type"),
boilerIgnitionType: integer("boiler_ignition_type"),
centralHeatingPumpAge: integer("central_heating_pump_age"),
centralHeatingPumpAgeStr: text("central_heating_pump_age_str"),
mainHeatingIndexNumber: integer("main_heating_index_number"),
sapMainHeatingCode: integer("sap_main_heating_code"),
mainHeatingNumber: integer("main_heating_number"),
mainHeatingCategory: integer("main_heating_category"),
mainHeatingFraction: integer("main_heating_fraction"),
mainHeatingDataSource: integer("main_heating_data_source"),
condensing: boolean("condensing"),
weatherCompensator: boolean("weather_compensator"),
},
);
// ─── epc_building_part ────────────────────────────────────────────────────────
export const epcBuildingPart = pgTable(
"epc_building_part",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
epcPropertyId: bigint("epc_property_id", { mode: "bigint" })
.notNull()
.references(() => epcProperty.id),
identifier: text("identifier").notNull(),
constructionAgeBand: text("construction_age_band").notNull(),
// Wall
wallConstruction: text("wall_construction").notNull(),
wallInsulationType: text("wall_insulation_type").notNull(),
wallThicknessMeasured: boolean("wall_thickness_measured").notNull(),
partyWallConstruction: text("party_wall_construction").notNull(),
buildingPartNumber: integer("building_part_number"),
wallDryLined: boolean("wall_dry_lined"),
wallThicknessMm: integer("wall_thickness_mm"),
wallInsulationThickness: text("wall_insulation_thickness"),
// age band source
// Floor
floorHeatLoss: integer("floor_heat_loss"),
floorInsulationThickness: text("floor_insulation_thickness"),
flatRoofInsulationThickness: text("flat_roof_insulation_thickness"),
floorType: text("floor_type"),
floorConstructionType: text("floor_construction_type"),
floorInsulationTypeStr: text("floor_insulation_type_str"),
floorUValueKnown: boolean("floor_u_value_known"),
// Roof
roofConstruction: integer("roof_construction"),
roofInsulationLocation: text("roof_insulation_location"),
roofInsulationThickness: text("roof_insulation_thickness"),
// Room in roof (inlined)
roomInRoofFloorArea: real("room_in_roof_floor_area"),
roomInRoofConstructionAgeBand: text("room_in_roof_construction_age_band"),
// Alternative wall 1 (inlined)
altWall1Area: real("alt_wall_1_area"),
altWall1DryLined: text("alt_wall_1_dry_lined"),
altWall1Construction: integer("alt_wall_1_construction"),
altWall1InsulationType: integer("alt_wall_1_insulation_type"),
altWall1ThicknessMeasured: text("alt_wall_1_thickness_measured"),
altWall1InsulationThickness: text("alt_wall_1_insulation_thickness"),
// Alternative wall 2 (inlined)
altWall2Area: real("alt_wall_2_area"),
altWall2DryLined: text("alt_wall_2_dry_lined"),
altWall2Construction: integer("alt_wall_2_construction"),
altWall2InsulationType: integer("alt_wall_2_insulation_type"),
altWall2ThicknessMeasured: text("alt_wall_2_thickness_measured"),
altWall2InsulationThickness: text("alt_wall_2_insulation_thickness"),
},
);
// ─── epc_floor_dimension ──────────────────────────────────────────────────────
export const epcFloorDimension = pgTable(
"epc_floor_dimension",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
epcBuildingPartId: bigint("epc_building_part_id", { mode: "bigint" })
.notNull()
.references(() => epcBuildingPart.id),
floor: integer("floor"),
roomHeightM: real("room_height_m").notNull(),
totalFloorAreaM2: real("total_floor_area_m2").notNull(),
partyWallLengthM: real("party_wall_length_m").notNull(),
heatLossPerimeterM: real("heat_loss_perimeter_m").notNull(),
floorInsulation: integer("floor_insulation"),
floorConstruction: integer("floor_construction"),
},
);
// ─── epc_window ───────────────────────────────────────────────────────────────
export const epcWindow = pgTable(
"epc_window",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
epcPropertyId: bigint("epc_property_id", { mode: "bigint" })
.notNull()
.references(() => epcProperty.id),
pvcFrame: text("pvc_frame").notNull(),
glazingGap: text("glazing_gap").notNull(),
orientation: text("orientation").notNull(),
windowType: text("window_type").notNull(),
glazingType: text("glazing_type").notNull(),
windowWidth: real("window_width").notNull(), // add unit?
windowHeight: real("window_height").notNull(), // add unit?
draughtProofed: boolean("draught_proofed").notNull(),
windowLocation: text("window_location").notNull(),
windowWallType: text("window_wall_type").notNull(),
permanentShuttersPresent: boolean("permanent_shutters_present").notNull(),
frameFactor: real("frame_factor"),
permanentShuttersInsulated: text("permanent_shutters_insulated"),
// Transmission details (inlined)
transmissionUValue: real("transmission_u_value"),
transmissionDataSource: integer("transmission_data_source"),
transmissionSolarTransmittance: real("transmission_solar_transmittance"),
},
);
// ─── epc_energy_element ───────────────────────────────────────────────────────
export const epcEnergyElement = pgTable(
"epc_energy_element",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
epcPropertyId: bigint("epc_property_id", { mode: "bigint" })
.notNull()
.references(() => epcProperty.id),
elementType: energyElementTypeEnum("element_type").notNull(),
description: text("description").notNull(),
energyEfficiencyRating: integer("energy_efficiency_rating").notNull(),
environmentalEfficiencyRating: integer("environmental_efficiency_rating").notNull(),
},
);

View file

@ -71,16 +71,43 @@ export async function syncRemovalRequestToHubSpot(params: {
}
}
type MeasureUploadProgress = {
measureName: string;
uploadedCount: number;
requiredCount: number;
isComplete: boolean;
};
export async function syncContractorDocUploadToHubSpot(params: {
hubspotDealId: string;
measureProgress?: MeasureUploadProgress[];
}): Promise<void> {
let log: string;
let uploadStatus: string;
if (params.measureProgress && params.measureProgress.length > 0) {
log = params.measureProgress
.map((m) => {
if (m.isComplete) return `${m.measureName}: Complete (${m.uploadedCount}/${m.requiredCount} docs)`;
if (m.uploadedCount > 0) return `${m.measureName}: In Progress (${m.uploadedCount}/${m.requiredCount} docs)`;
return `${m.measureName}: Not Started (0/${m.requiredCount} docs)`;
})
.join(" | ");
uploadStatus = params.measureProgress.every((m) => m.isComplete)
? "Upload Complete for All Measures"
: "Upload in progress";
} else {
log = "Documents available - uploaded by contractor";
uploadStatus = "Upload in progress";
}
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const client = getHubSpotClient();
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: {
contractor_document_upload_log: "Documents available - uploaded by contractor",
contractor_document_upload_log: log,
contractor_document_upload_status: uploadStatus,
},
});
return;

View file

@ -0,0 +1,81 @@
/**
* Measure Document Requirements
* Maps HubSpot measure names to the required installer document types (fileType enum values).
* Used to compute per-measure upload completion and guide contractors in the upload modal.
*/
// Required for every measure
const BASE_DOCS = [
"pre_photo",
"mid_photo",
"post_photo",
"pre_installation_building_inspection",
"claim_of_compliance",
"insurance_guarantee",
"workmanship_warranty",
] as const;
// MCS-accredited measures require MCS certification in addition to base docs
const MCS_EXTRA = ["mcs_compliance_certificate"] as const;
export const MEASURE_DOC_REQUIREMENTS: Record<string, string[]> = {
ASHP: [...BASE_DOCS, ...MCS_EXTRA, "commissioning_records"],
"Solar PV": [...BASE_DOCS, ...MCS_EXTRA, "g98_notification"],
DMevs: [
...BASE_DOCS,
"dmev_photos",
"anemometer_readings",
"commissioning_records",
"part_f_ventilation_document",
"door_undercut_photos",
"trickle_vent_photos",
"ventilation_assessment_checklist",
"minor_works_electrical_certificate",
],
"Loft insulation": [...BASE_DOCS, "loft_hatch_photo"],
// All remaining measures require BASE_DOCS only:
// CWI, EWI, IWI, "Flat roof", RIR, UFI, HW, Windows, "Ext. doors",
// TRVs, "Heating controls", "New boiler", HHRSH, Battery, LEL,
// "Listed building", "Removal 2nd heating", Others
};
/**
* Returns the required document types for a given measure name.
* Falls back to BASE_DOCS for any measure not explicitly listed.
*/
export function getRequiredDocs(measureName: string): string[] {
return MEASURE_DOC_REQUIREMENTS[measureName] ?? [...BASE_DOCS];
}
/**
* Human-readable label for a fileType enum value.
* Matches the labels used in ContractorUploadModal FILE_TYPE_OPTIONS.
*/
export const FILE_TYPE_LABELS: Record<string, string> = {
pre_photo: "Pre-Install Photos",
mid_photo: "Mid-Install Photos",
post_photo: "Post-Install Photos",
loft_hatch_photo: "Loft Hatch & Draft Excluder Photos",
dmev_photos: "DMEV Photos (Wetrooms)",
door_undercut_photos: "Door Undercut Photos",
trickle_vent_photos: "Trickle Vent Photos",
pre_installation_building_inspection: "PIBI / Tech Survey",
point_of_work_risk_assessment: "Point of Work Risk Assessment",
claim_of_compliance: "DOCC 2030 (Claim of Compliance)",
mcs_compliance_certificate: "MCS Compliance Certificate",
certificate_of_conformity: "Certificate of Conformity",
minor_works_electrical_certificate: "Minor Works Electrical Certificate",
trustmark_licence_numbers: "TrustMark Licence Numbers",
operative_competency: "Operative Competency",
ventilation_assessment_checklist: "Ventilation Assessment Checklist",
anemometer_readings: "Anemometer Readings",
commissioning_records: "Commissioning Records",
part_f_ventilation_document: "Approved Document Part F",
handover_pack: "Handover Pack",
workmanship_warranty: "Workmanship Warranty",
insurance_guarantee: "Insurance Backed Guarantee (IBG)",
g98_notification: "G98 / G99 Notification",
installer_qualifications: "Installer Qualifications",
installer_feedback: "Installer Feedback",
contractor_other: "Other",
};

View file

@ -39,13 +39,16 @@ async function fetchScenarioReport({
portfolioId,
scenarioId,
hideNonCompliant,
useOriginalBaseline,
}: {
portfolioId: number;
scenarioId: number | "default";
hideNonCompliant: boolean;
useOriginalBaseline: boolean;
}) {
const params = new URLSearchParams({
hideNonCompliant: String(hideNonCompliant),
useOriginalBaseline: String(useOriginalBaseline),
});
const path = `/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`;
@ -89,6 +92,8 @@ export function ReportingClientArea({
const [measuresOpen, setMeasuresOpen] = useState<boolean>(false);
const [appliedHideNonCompliant, setAppliedHideNonCompliant] =
useState<boolean>(false);
const [appliedUseOriginalBaseline, setAppliedUseOriginalBaseline] =
useState<boolean>(false);
const [showToast, setShowToast] = useState(false);
const drawerOpen = Boolean(selectedScenarioId);
@ -107,12 +112,14 @@ export function ReportingClientArea({
portfolioId,
selectedScenarioId,
appliedHideNonCompliant,
appliedUseOriginalBaseline,
],
queryFn: () =>
fetchScenarioReport({
portfolioId,
scenarioId: selectedScenarioId!,
hideNonCompliant: appliedHideNonCompliant,
useOriginalBaseline: appliedUseOriginalBaseline,
}),
enabled: selectedScenarioId !== null, // only run when scenario selected or default selected
keepPreviousData: true, // keep showing old data while loading new scenario or applying filter
@ -238,12 +245,14 @@ export function ReportingClientArea({
<ReportingFunctionalityButtons
hideNonCompliant={appliedHideNonCompliant}
useOriginalBaseline={appliedUseOriginalBaseline}
disabled={scenarioBusy}
canFilterNonCompliant={
selectedScenarioId !== null && selectedScenarioId !== "default"
}
onApply={async (value) => {
setAppliedHideNonCompliant(value);
onApply={async ({ hideNonCompliant, useOriginalBaseline }) => {
setAppliedHideNonCompliant(hideNonCompliant);
setAppliedUseOriginalBaseline(useOriginalBaseline);
}}
/>

View file

@ -13,33 +13,45 @@ export interface ReportingFunctionalityButtonsProps {
/** Currently applied value */
hideNonCompliant: boolean;
/** Currently applied value */
useOriginalBaseline: boolean;
/**
* Explicit user action.
* Parent decides what "apply" means (refetch, mutate, etc).
*/
onApply: (value: boolean) => Promise<void> | void;
onApply: (options: {
hideNonCompliant: boolean;
useOriginalBaseline: boolean;
}) => Promise<void> | void;
disabled?: boolean;
/* Whether hideNonCompliant filter is available */
/* Whether filters are available (only for specific non-default scenarios) */
canFilterNonCompliant?: boolean;
}
export function ReportingFunctionalityButtons({
hideNonCompliant,
useOriginalBaseline,
onApply,
disabled = false,
canFilterNonCompliant = true,
}: ReportingFunctionalityButtonsProps) {
const [draftHideNonCompliant, setDraftHideNonCompliant] =
useState<boolean>(hideNonCompliant);
const [draftUseOriginalBaseline, setDraftUseOriginalBaseline] =
useState<boolean>(useOriginalBaseline);
const [isApplying, setIsApplying] = useState(false);
async function handleApply() {
try {
setIsApplying(true);
await onApply(draftHideNonCompliant);
await onApply({
hideNonCompliant: draftHideNonCompliant,
useOriginalBaseline: draftUseOriginalBaseline,
});
} finally {
setIsApplying(false);
}
@ -50,7 +62,8 @@ export function ReportingFunctionalityButtons({
// reset the filter and trigger the fetch
setIsApplying(true);
setDraftHideNonCompliant(false);
await onApply(false);
setDraftUseOriginalBaseline(false);
await onApply({ hideNonCompliant: false, useOriginalBaseline: false });
} finally {
setIsApplying(false);
}
@ -61,6 +74,7 @@ export function ReportingFunctionalityButtons({
onOpenChange={(open) => {
if (open) {
setDraftHideNonCompliant(hideNonCompliant);
setDraftUseOriginalBaseline(useOriginalBaseline);
}
}}
>
@ -72,7 +86,7 @@ export function ReportingFunctionalityButtons({
className={`
relative flex items-center gap-2
${
hideNonCompliant
hideNonCompliant || useOriginalBaseline
? "border-brandmidblue/40 bg-brandlightblue/40"
: ""
}
@ -81,7 +95,7 @@ export function ReportingFunctionalityButtons({
{/* Filter icon */}
<svg
className={`h-4 w-4 ${
hideNonCompliant ? "text-brandmidblue" : "text-gray-500"
hideNonCompliant || useOriginalBaseline ? "text-brandmidblue" : "text-gray-500"
}`}
viewBox="0 0 20 20"
fill="currentColor"
@ -89,7 +103,7 @@ export function ReportingFunctionalityButtons({
<path d="M3 4a1 1 0 011-1h12a1 1 0 01.8 1.6l-4.8 6.4V16a1 1 0 01-1.447.894l-2-1A1 1 0 018 14v-2.999L3.2 5.6A1 1 0 013 4z" />
</svg>
Filter options
{hideNonCompliant && (
{(hideNonCompliant || useOriginalBaseline) && (
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-brandmidblue" />
)}
</Button>
@ -140,6 +154,47 @@ export function ReportingFunctionalityButtons({
</label>
</div>
{/* Use original SAP points */}
<div
className={`flex items-start gap-4 ${
!canFilterNonCompliant ? "opacity-50 pointer-events-none" : ""
}`}
>
<Checkbox
id="use-original-baseline"
checked={draftUseOriginalBaseline}
disabled={!canFilterNonCompliant}
onCheckedChange={(checked) =>
setDraftUseOriginalBaseline(Boolean(checked))
}
className="mt-1"
/>
<label
htmlFor="use-original-baseline"
className="cursor-pointer space-y-1"
>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900 leading-snug">
<svg
className="h-4 w-4 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path
fillRule="evenodd"
d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
clipRule="evenodd"
/>
</svg>
Use lodged SAP points
</div>
<div className="text-xs text-gray-500 leading-relaxed">
Base metrics on properties below the EPC target using their lodged SAP rating, rather than the current modelled rating
</div>
</label>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button

View file

@ -21,7 +21,8 @@ import {
} from "@/app/shadcn_components/ui/select";
import { CheckCircle2, XCircle, Upload, Loader2, Clock, ChevronDown, ChevronRight, Info } from "lucide-react";
import { uploadFileToS3 } from "@/app/utils/s3";
import type { ClassifiedDeal } from "./types";
import type { ClassifiedDeal, DocStatusMap } from "./types";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
// ── Types ─────────────────────────────────────────────────────────────────
@ -44,12 +45,14 @@ type FileEntry = {
measureName: string;
};
type Phase = "loading" | "upload" | "classify";
type Phase = "loading" | "measure-select" | "upload" | "classify";
type Props = {
deal: ClassifiedDeal;
portfolioId: string;
onClose: () => void;
docStatusMap?: DocStatusMap;
approvedMeasures?: string[]; // if non-empty, used instead of proposedMeasures
};
// ── Constants ─────────────────────────────────────────────────────────────
@ -200,11 +203,13 @@ async function recordUpload(payload: {
async function saveClassifications(
updates: { id: string; fileType: string; measureName?: string }[],
hubspotDealId?: string,
proposedMeasures?: string[],
): Promise<void> {
const res = await fetch("/api/upload/contractor-install", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updates }),
body: JSON.stringify({ updates, hubspotDealId, proposedMeasures }),
});
if (!res.ok) throw new Error("Failed to save classifications");
}
@ -257,7 +262,121 @@ function PasGuidancePanel() {
// ── DocType select ────────────────────────────────────────────────────────
function DocTypeSelect({ value, onChange, showHint = false }: { value: string; onChange: (v: string) => void; showHint?: boolean }) {
// ── DocType button grid — shown when a measure is selected ───────────────
function DocTypeButtonGrid({
value,
onChange,
requiredDocs,
uploadedDocs,
}: {
value: string;
onChange: (v: string) => void;
requiredDocs: string[];
uploadedDocs: string[];
}) {
const [showOther, setShowOther] = useState(false);
const uploadedSet = new Set(uploadedDocs);
const requiredSet = new Set(requiredDocs);
const isOtherSelected = value !== "" && !requiredSet.has(value);
return (
<div className="space-y-2">
{/* Required doc type buttons */}
<div className="flex flex-wrap gap-1.5">
{requiredDocs.map((docType) => {
const option = FILE_TYPE_OPTIONS.find((o) => o.value === docType);
const label = option?.label ?? docType;
const alreadyUploaded = uploadedSet.has(docType);
const isSelected = value === docType;
return (
<button
key={docType}
type="button"
onClick={() => { onChange(docType); setShowOther(false); }}
title={alreadyUploaded ? `${label} — already uploaded` : label}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
isSelected
? "bg-brandblue text-white border-brandblue shadow-sm"
: alreadyUploaded
? "bg-emerald-50 text-emerald-700 border-emerald-200 hover:border-emerald-400"
: "bg-white text-gray-700 border-gray-200 hover:border-brandblue/50 hover:bg-brandlightblue/10"
}`}
>
{alreadyUploaded && !isSelected && (
<svg className="h-3 w-3 text-emerald-500 shrink-0" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{label}
</button>
);
})}
{/* Other button */}
<button
type="button"
onClick={() => setShowOther((v) => !v)}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
isOtherSelected || showOther
? "bg-gray-100 text-gray-700 border-gray-300"
: "bg-white text-gray-400 border-gray-200 hover:border-gray-400 hover:text-gray-600"
}`}
>
Other {showOther ? "▲" : "▼"}
</button>
</div>
{/* Other: dropdown for non-required types */}
{(showOther || isOtherSelected) && (
<Select
value={isOtherSelected ? value : "__unset__"}
onValueChange={(v) => { onChange(v === "__unset__" ? "" : v); }}
>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="Select other type…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__" className="text-xs text-gray-400">Select other type</SelectItem>
{FILE_TYPE_GROUPS.map((group) => {
const items = FILE_TYPE_OPTIONS.filter(
(o) => o.group === group && !requiredSet.has(o.value),
);
if (!items.length) return null;
return (
<SelectGroup key={group}>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-2 py-1">
{group}
</SelectLabel>
{items.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</Select>
)}
{/* Show hint for selected type */}
{value && (() => {
const hint = FILE_TYPE_OPTIONS.find((o) => o.value === value)?.hint;
return hint ? <p className="text-[10px] text-blue-600 leading-snug">{hint}</p> : null;
})()}
</div>
);
}
// ── DocType select — fallback when no measure selected ────────────────────
function DocTypeSelect({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const selected = FILE_TYPE_OPTIONS.find((o) => o.value === value);
return (
@ -277,16 +396,14 @@ function DocTypeSelect({ value, onChange, showHint = false }: { value: string; o
{group}
</SelectLabel>
{items.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">
{o.label}
</SelectItem>
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</Select>
{showHint && selected?.hint && (
{selected?.hint && (
<p className="text-[10px] text-blue-600 leading-snug px-0.5">{selected.hint}</p>
)}
</div>
@ -305,8 +422,11 @@ function StatusIcon({ status, isExisting, errorMsg }: { status: FileStatus; isEx
// ── Main component ─────────────────────────────────────────────────────────
export default function ContractorUploadModal({ deal, portfolioId, onClose }: Props) {
const measures = parseMeasures(deal.proposedMeasures);
export default function ContractorUploadModal({ deal, portfolioId, onClose, docStatusMap, approvedMeasures }: Props) {
// Use approved measures when available; fall back to all proposed measures
const measures = (approvedMeasures && approvedMeasures.length > 0)
? approvedMeasures
: parseMeasures(deal.proposedMeasures);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [queue, setQueue] = useState<FileEntry[]>([]);
@ -314,6 +434,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
const [isUploading, setIsUploading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// The measure selected in the measure-select phase (empty = "not measure-specific")
const [selectedMeasure, setSelectedMeasure] = useState<string>("");
// ── Fetch existing unclassified files on mount ───────────────────────
@ -322,7 +444,7 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
const uprnParam = deal.uprn;
const propIdParam = deal.landlordPropertyId;
if (!uprnParam && !propIdParam) {
setPhase("upload");
setPhase(measures.length > 0 ? "measure-select" : "upload");
return;
}
@ -350,12 +472,14 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
}));
setQueue(entries);
setPhase("classify");
} else if (measures.length > 0) {
setPhase("measure-select");
} else {
setPhase("upload");
}
} catch {
// If fetch fails, just proceed to upload phase
setPhase("upload");
// If fetch fails, just proceed to measure-select (or upload if no measures)
setPhase(measures.length > 0 ? "measure-select" : "upload");
}
}
@ -373,7 +497,7 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
displaySize: formatSize(f.size),
status: "queued",
docType: "",
measureName: measures[0] ?? "",
measureName: selectedMeasure,
}));
setQueue((prev) => [...prev, ...newEntries]);
}
@ -473,6 +597,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
fileType: f.docType,
measureName: (f.measureName && f.measureName !== "__none__") ? f.measureName : undefined,
})),
deal.dealId,
measures.length > 0 ? measures : undefined,
);
onClose();
} catch {
@ -496,14 +622,24 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
<DialogHeader>
<DialogTitle>
{phase === "loading" ? "Loading…" :
phase === "measure-select" ? "Select Measure" :
phase === "upload" ? "Upload Documents" :
"Classify Documents"}
</DialogTitle>
<DialogDescription>
{phase === "loading" && "Checking for pending files…"}
{phase === "measure-select" && (
<>
Which measure are you uploading documents for?{" "}
<strong>{propertyLabel}</strong>
</>
)}
{phase === "upload" && (
<>
Upload install documents for <strong>{propertyLabel}</strong>.
{selectedMeasure
? <>Uploading documents for <strong>{selectedMeasure}</strong> <strong>{propertyLabel}</strong>.</>
: <>Upload install documents for <strong>{propertyLabel}</strong>.</>
}
{existingCount > 0 && ` ${existingCount} file${existingCount !== 1 ? "s" : ""} are pending classification.`}
</>
)}
@ -525,6 +661,55 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
</div>
)}
{/* ── Measure Select ── */}
{phase === "measure-select" && (() => {
const docStatus = docStatusMap?.[deal.dealId];
const measureProgressMap = new Map(
(docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]),
);
return (
<div className="space-y-3">
<p className="text-xs text-gray-500">
Select the measure you are uploading documents for. This helps track completion against required documents.
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{measures.map((measure) => {
const progress = measureProgressMap.get(measure);
const isComplete = progress?.isComplete ?? false;
const uploaded = progress?.uploadedCount ?? 0;
const required = progress?.requiredCount ?? getRequiredDocs(measure).length;
return (
<button
key={measure}
type="button"
onClick={() => { setSelectedMeasure(measure); setPhase("upload"); }}
className={`flex flex-col items-start gap-1 rounded-lg border px-3 py-2.5 text-left transition-colors hover:border-brandblue/40 hover:bg-brandlightblue/10 ${
isComplete
? "border-emerald-200 bg-emerald-50/60"
: uploaded > 0
? "border-amber-200 bg-amber-50/50"
: "border-gray-200 bg-white"
}`}
>
<span className="text-xs font-semibold text-gray-800 leading-tight">{measure}</span>
<span className={`text-[10px] font-medium ${isComplete ? "text-emerald-600" : uploaded > 0 ? "text-amber-600" : "text-gray-400"}`}>
{isComplete ? "✓ Complete" : `${uploaded} / ${required} docs`}
</span>
</button>
);
})}
</div>
<button
type="button"
onClick={() => { setSelectedMeasure(""); setPhase("upload"); }}
className="text-xs text-gray-400 hover:text-gray-600 underline underline-offset-2 mt-1"
>
Not measure-specific / other
</button>
</div>
);
})()}
{/* ── Phase 1: Upload ── */}
{phase === "upload" && (
<>
@ -592,46 +777,107 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
)}
{/* ── Phase 2: Classify ── */}
{phase === "classify" && (
<div className="space-y-3">
{phase === "classify" && (() => {
const docStatus = docStatusMap?.[deal.dealId];
const measureProgressMap = new Map(
(docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]),
);
return (
<div className="space-y-4">
{/* PAS guidance */}
<PasGuidancePanel />
{/* Column headers */}
<div className="grid grid-cols-[1fr_260px_180px] gap-2 px-1">
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">File</span>
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Document Type <span className="text-red-400">*</span></span>
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Measure</span>
</div>
{classifiableEntries.map((entry) => (
<div key={entry.id} className="grid grid-cols-[1fr_260px_180px] gap-2 items-center px-1">
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
<div className="min-w-0">
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
{entry.displaySize && <p className="text-[10px] text-gray-400">{entry.displaySize}</p>}
{entry.existingS3Key && <p className="text-[10px] text-amber-500">Previously uploaded</p>}
</div>
{/* Measure context banner */}
{selectedMeasure && (
<div className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-brandlightblue/20 border border-brandblue/20">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-brandblue">{selectedMeasure}</span>
{(() => {
const mp = measureProgressMap.get(selectedMeasure);
if (!mp) return null;
return (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full border ${
mp.isComplete ? "bg-emerald-50 text-emerald-700 border-emerald-200" : "bg-amber-50 text-amber-700 border-amber-200"
}`}>
{mp.uploadedCount}/{mp.requiredCount} docs uploaded
</span>
);
})()}
</div>
<DocTypeSelect value={entry.docType} onChange={(v) => updateEntryField(entry.id, "docType", v)} showHint />
{measures.length > 0 ? (
<Select value={entry.measureName || "__none__"} onValueChange={(v) => updateEntryField(entry.id, "measureName", v === "__none__" ? "" : v)}>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs text-gray-400"> None </SelectItem>
{measures.map((m) => (
<SelectItem key={m} value={m} className="text-xs">{m}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="text-xs text-gray-300"></span>
)}
<button
type="button"
onClick={() => setPhase("measure-select")}
className="text-[10px] text-brandblue/60 hover:text-brandblue underline underline-offset-2"
>
Change
</button>
</div>
))}
)}
{/* File list with classification */}
<div className="space-y-3">
{classifiableEntries.map((entry) => {
const entryMeasure = entry.measureName && entry.measureName !== "__none__" ? entry.measureName : null;
const requiredDocs = entryMeasure ? getRequiredDocs(entryMeasure) : null;
const uploadedDocs = entryMeasure ? (measureProgressMap.get(entryMeasure)?.uploaded ?? []) : [];
return (
<div key={entry.id} className="rounded-lg border border-gray-100 bg-gray-50/50 p-3 space-y-2.5">
{/* File info row */}
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
<p className="text-[10px] text-gray-400">
{entry.existingS3Key ? "Previously uploaded · " : ""}
{entry.displaySize ?? ""}
{entryMeasure && !selectedMeasure && (
<span className="ml-1 text-brandblue/70">{entryMeasure}</span>
)}
</p>
</div>
{/* Measure selector — only shown if no pre-selected measure */}
{!selectedMeasure && measures.length > 0 && (
<Select
value={entry.measureName || "__none__"}
onValueChange={(v) => updateEntryField(entry.id, "measureName", v === "__none__" ? "" : v)}
>
<SelectTrigger className="h-7 text-[10px] w-36 shrink-0">
<SelectValue placeholder="Measure…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs text-gray-400"> None </SelectItem>
{measures.map((m) => (
<SelectItem key={m} value={m} className="text-xs">{m}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Doc type selector */}
<div>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
Document type <span className="text-red-400">*</span>
</p>
{requiredDocs ? (
<DocTypeButtonGrid
value={entry.docType}
onChange={(v) => updateEntryField(entry.id, "docType", v)}
requiredDocs={requiredDocs}
uploadedDocs={uploadedDocs}
/>
) : (
<DocTypeSelect
value={entry.docType}
onChange={(v) => updateEntryField(entry.id, "docType", v)}
/>
)}
</div>
</div>
);
})}
</div>
{/* Failed uploads (info only) */}
{queue.filter((f) => f.status === "error").length > 0 && (
@ -651,7 +897,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
</p>
)}
</div>
)}
);
})()}
</div>
<DialogFooter className="pt-2 border-t border-gray-100 shrink-0">
@ -659,6 +906,10 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
<Button variant="secondary" onClick={onClose}>Cancel</Button>
)}
{phase === "measure-select" && (
<Button variant="secondary" onClick={onClose}>Cancel</Button>
)}
{phase === "upload" && (
<>
<Button variant="secondary" onClick={onClose} disabled={isUploading}>Cancel</Button>

View file

@ -121,6 +121,7 @@ export default function DampMouldRiskPanel({
"dealname",
"landlordPropertyId",
"dampMouldFlag",
"dampMouldAndRepairComments",
"coordinator",
];
@ -128,6 +129,7 @@ export default function DampMouldRiskPanel({
dealname: "Address",
landlordPropertyId: "Property Ref",
dampMouldFlag: "Coordinator Flag",
dampMouldAndRepairComments: "Comments",
coordinator: "Coordinator",
};

View file

@ -29,17 +29,18 @@ import {
import { Search, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { createDocumentTableColumns } from "./DocumentTableColumns";
import ContractorUploadModal from "./ContractorUploadModal";
import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType } from "./types";
import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType, ApprovalsByDeal } from "./types";
type RetroAssessmentFilter = "all" | "none" | "partial" | "complete";
type InstallStatusFilter = "all" | "none" | "hasDocs" | "partial" | "complete";
interface DocumentTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
docStatusMap: DocStatusMap;
portfolioId: string;
userCapability: PortfolioCapabilityType;
approvalsByDeal?: ApprovalsByDeal;
}
function escapeCell(value: unknown): string {
@ -53,7 +54,7 @@ function escapeCell(value: unknown): string {
: str;
}
export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability }: DocumentTableProps) {
export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability, approvalsByDeal }: DocumentTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [retroAssessmentFilter, setRetroAssessmentFilter] = useState<RetroAssessmentFilter>("all");
const [installStatusFilter, setInstallStatusFilter] = useState<InstallStatusFilter>("all");
@ -66,7 +67,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
const filteredData = useMemo(() => {
return data.filter((d) => {
const status = d.uprn ? docStatusMap[d.uprn] : undefined;
const status = docStatusMap[d.dealId];
if (retroAssessmentFilter !== "all") {
if (retroAssessmentFilter === "none" && !(!status || !status.hasSurveyDocs)) return false;
@ -114,7 +115,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
const header = "Address,Landlord ID,Retrofit Assessment Status,Install Docs Status";
const body = rows
.map((row) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
const status = docStatusMap[row.original.dealId];
const retroStatus = status?.isSurveyComplete
? "Complete"
: status?.hasSurveyDocs
@ -302,6 +303,8 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
deal={uploadDeal}
portfolioId={portfolioId}
onClose={() => setUploadDeal(null)}
docStatusMap={docStatusMap}
approvedMeasures={approvalsByDeal?.[uploadDeal.dealId] ?? []}
/>
)}

View file

@ -49,19 +49,38 @@ function RetroAssessmentBadge({ status }: { status: DocStatus | undefined }) {
function InstallDocsBadge({ status }: { status: DocStatus | undefined }) {
const installStatus = status?.installStatus ?? "none";
const measureProgress = status?.measureProgress ?? [];
// Build a tooltip showing per-measure doc counts (e.g. "ASHP: 5/9, CWI: 7/7")
const tooltip =
measureProgress.length > 0
? measureProgress
.map((m) => `${m.measureName}: ${m.uploadedCount}/${m.requiredCount}`)
.join(" | ")
: undefined;
if (installStatus === "all") {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-emerald-50 text-emerald-700 border-emerald-200">
<span
title={tooltip}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-emerald-50 text-emerald-700 border-emerald-200"
>
<CheckCircle2 className="h-3.5 w-3.5" />
All Measures
</span>
);
}
if (installStatus === "partial") {
const completedCount = measureProgress.filter((m) => m.isComplete).length;
const totalCount = measureProgress.length;
const label = totalCount > 0 ? `${completedCount} / ${totalCount} measures` : "Some Measures";
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-amber-50 text-amber-700 border-amber-200">
<span
title={tooltip}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-amber-50 text-amber-700 border-amber-200"
>
<AlertCircle className="h-3.5 w-3.5" />
Some Measures
{label}
</span>
);
}
@ -82,7 +101,7 @@ function InstallDocsBadge({ status }: { status: DocStatus | undefined }) {
}
export function createDocumentTableColumns(
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
docStatusMap: DocStatusMap = {},
onUpload?: (deal: ClassifiedDeal) => void,
): ColumnDef<ClassifiedDeal>[] {
@ -119,14 +138,14 @@ export function createDocumentTableColumns(
{
id: "retroAssessmentStatus",
accessorFn: (row) => {
const status = row.uprn ? docStatusMap[row.uprn] : undefined;
const status = docStatusMap[row.dealId];
if (status?.isSurveyComplete) return 2;
if (status?.hasSurveyDocs) return 1;
return 0;
},
header: ({ column }) => <SortableHeader label="Retrofit Assessment Docs" column={column as any} />,
cell: ({ row }) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
const status = docStatusMap[row.original.dealId];
return <RetroAssessmentBadge status={status} />;
},
enableHiding: false,
@ -136,7 +155,7 @@ export function createDocumentTableColumns(
{
id: "installDocs",
accessorFn: (row) => {
const status = row.uprn ? docStatusMap[row.uprn] : undefined;
const status = docStatusMap[row.dealId];
const s = status?.installStatus ?? "none";
if (s === "all") return 3;
if (s === "partial") return 2;
@ -145,7 +164,7 @@ export function createDocumentTableColumns(
},
header: ({ column }) => <SortableHeader label="Install Docs" column={column as any} />,
cell: ({ row }) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
const status = docStatusMap[row.original.dealId];
return <InstallDocsBadge status={status} />;
},
enableHiding: false,
@ -158,8 +177,7 @@ export function createDocumentTableColumns(
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Docs</span>
),
cell: ({ row }) => {
const uprn = row.original.uprn ?? "";
const status = uprn ? docStatusMap[uprn] : undefined;
const status = docStatusMap[row.original.dealId];
let icon: React.ReactNode;
let className: string;
@ -182,6 +200,7 @@ export function createDocumentTableColumns(
<button
onClick={() =>
onOpenDrawer(
row.original.dealId,
row.original.uprn,
row.original.landlordPropertyId,
row.original.dealname,

View file

@ -9,7 +9,13 @@ import {
TabsTrigger,
} from "@/app/shadcn_components/ui/tabs";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { BarChart2, Table2, FolderOpen, Wrench, AlertTriangle } from "lucide-react";
import {
BarChart2,
Table2,
FolderOpen,
Wrench,
AlertTriangle,
} from "lucide-react";
import DrillDownTable from "./DrillDownTable";
import PropertyTable from "./PropertyTable";
import DocumentTable from "./DocumentTable";
@ -40,9 +46,9 @@ export default function LiveTracker({
userEmail,
}: LiveTrackerProps) {
// ── Tab state ────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents" | "measures">(
"analytics",
);
const [activeTab, setActiveTab] = useState<
"analytics" | "properties" | "documents" | "measures"
>("analytics");
// ── Project selector (shared across both tabs) ───────────────────────
const projectCodes = projects.map((p) => p.projectCode);
@ -63,6 +69,7 @@ export default function LiveTracker({
// ── Document drawer (used by PropertyTable) ──────────────────────────
const [drawerState, setDrawerState] = useState<DocumentDrawerState>({
open: false,
dealId: null,
uprn: null,
landlordPropertyId: null,
dealname: null,
@ -81,7 +88,10 @@ export default function LiveTracker({
setOpenTable({
stage,
data: filteredDeals,
columns: (columns || ["dealname", "landlordPropertyId"]) as (keyof ClassifiedDeal)[],
columns: (columns || [
"dealname",
"landlordPropertyId",
]) as (keyof ClassifiedDeal)[],
columnLabels: (columnLabels || {
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
@ -90,8 +100,13 @@ export default function LiveTracker({
});
};
const handleOpenDrawer = (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => {
setDrawerState({ open: true, uprn, landlordPropertyId, dealname });
const handleOpenDrawer = (
dealId: string,
uprn: string | null,
landlordPropertyId: string | null,
dealname: string | null,
) => {
setDrawerState({ open: true, dealId, uprn, landlordPropertyId, dealname });
};
if (!totalDeals) {
@ -108,7 +123,11 @@ export default function LiveTracker({
<div className="space-y-4 w-full">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents" | "measures")}
onValueChange={(v) =>
setActiveTab(
v as "analytics" | "properties" | "documents" | "measures",
)
}
>
{/* Tab bar */}
<TabsList className="h-10 p-1 bg-brandlightblue/10 border border-brandblue/10 rounded-xl mb-6">
@ -127,7 +146,9 @@ export default function LiveTracker({
Properties
<span
className={`ml-1 min-w-[18px] h-[18px] px-1 rounded-full bg-amber-500 text-white text-[10px] font-bold flex items-center justify-center leading-none transition-opacity ${
pendingRemovalCount > 0 ? "opacity-100" : "opacity-0 pointer-events-none"
pendingRemovalCount > 0
? "opacity-100"
: "opacity-0 pointer-events-none"
}`}
aria-hidden={pendingRemovalCount === 0}
>
@ -139,7 +160,7 @@ export default function LiveTracker({
className="flex items-center gap-2 rounded-lg text-sm font-medium px-4 data-[state=active]:bg-white data-[state=active]:text-brandblue data-[state=active]:shadow-sm transition-all"
>
<FolderOpen className="h-3.5 w-3.5" />
Document Management
Documents
</TabsTrigger>
<TabsTrigger
value="measures"
@ -196,11 +217,14 @@ export default function LiveTracker({
</div>
)}
<div className={`flex items-center gap-2.5 px-4 py-3 rounded-xl border border-amber-200 bg-amber-50 text-amber-800 text-sm ${pendingRemovalCount === 0 ? "hidden" : ""}`}>
<div
className={`flex items-center gap-2.5 px-4 py-3 rounded-xl border border-amber-200 bg-amber-50 text-amber-800 text-sm ${pendingRemovalCount === 0 ? "hidden" : ""}`}
>
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
<span>
<span className="font-semibold">{pendingRemovalCount}</span>{" "}
{pendingRemovalCount === 1 ? "property has" : "properties have"} an outstanding removal request
{pendingRemovalCount === 1 ? "property has" : "properties have"}{" "}
an outstanding removal request
</span>
</div>
<PropertyTable
@ -225,7 +249,11 @@ export default function LiveTracker({
>
{projectCodes.map((code) =>
code === "__ALL__" ? (
<option key="__ALL__" value="__ALL__" style={{ fontWeight: 700 }}>
<option
key="__ALL__"
value="__ALL__"
style={{ fontWeight: 700 }}
>
All Projects
</option>
) : (
@ -243,6 +271,7 @@ export default function LiveTracker({
docStatusMap={docStatusMap}
portfolioId={portfolioId}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
/>
</div>
</TabsContent>
@ -260,7 +289,11 @@ export default function LiveTracker({
>
{projectCodes.map((code) =>
code === "__ALL__" ? (
<option key="__ALL__" value="__ALL__" style={{ fontWeight: 700 }}>
<option
key="__ALL__"
value="__ALL__"
style={{ fontWeight: 700 }}
>
All Projects
</option>
) : (
@ -373,11 +406,21 @@ export default function LiveTracker({
{/* ── Document drawer ────────────────────────────────────────────── */}
<PropertyDrawer
open={drawerState.open}
dealId={drawerState.dealId}
uprn={drawerState.uprn}
landlordPropertyId={drawerState.landlordPropertyId}
dealname={drawerState.dealname}
docStatus={
drawerState.dealId ? docStatusMap[drawerState.dealId] : undefined
}
onClose={() =>
setDrawerState({ open: false, uprn: null, landlordPropertyId: null, dealname: null })
setDrawerState({
open: false,
dealId: null,
uprn: null,
landlordPropertyId: null,
dealname: null,
})
}
/>

View file

@ -21,7 +21,7 @@ import {
DrawerTitle,
DrawerDescription,
} from "@/app/shadcn_components/ui/drawer";
import type { PropertyDocument } from "./types";
import type { PropertyDocument, DocStatus } from "./types";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
// Human-readable labels for all DB fileType enum values
@ -86,11 +86,9 @@ function formatDate(iso: string): string {
}
// -----------------------------------------------------------------------
// Individual document row
// Reusable download button — encapsulates the presigned URL mutation
// -----------------------------------------------------------------------
function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
function DownloadDocButton({ doc }: { doc: PropertyDocument }) {
const { mutate: download, isPending: signing } = useMutation({
mutationFn: async () => {
const res = await fetch("/api/sign-document-url", {
@ -107,6 +105,28 @@ function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?
},
});
return (
<button
onClick={() => download()}
disabled={signing}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{signing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="h-3.5 w-3.5" />
)}
{signing ? "Preparing…" : "Download"}
</button>
);
}
// -----------------------------------------------------------------------
// Individual document row — used in retrofit section and install fallback
// -----------------------------------------------------------------------
function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
return (
<motion.div
layout
@ -130,19 +150,7 @@ function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?
</div>
</div>
{/* Right: download button */}
<button
onClick={() => download()}
disabled={signing}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{signing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="h-3.5 w-3.5" />
)}
{signing ? "Preparing…" : "Download"}
</button>
<DownloadDocButton doc={doc} />
</motion.div>
);
}
@ -152,29 +160,34 @@ function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?
// -----------------------------------------------------------------------
interface PropertyDrawerProps {
open: boolean;
dealId?: string | null;
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;
docStatus?: DocStatus;
onClose: () => void;
}
export default function PropertyDrawer({
open,
dealId,
uprn,
landlordPropertyId,
dealname,
docStatus,
onClose,
}: PropertyDrawerProps) {
const canQuery = !!(uprn || landlordPropertyId);
const canQuery = !!(dealId || uprn || landlordPropertyId);
const {
data: fetchedDocuments = [],
isFetching,
isError,
} = useQuery({
queryKey: ["property-documents", uprn, landlordPropertyId],
queryKey: ["property-documents", dealId, uprn, landlordPropertyId],
queryFn: async () => {
const params = new URLSearchParams();
if (uprn) params.set("uprn", uprn);
if (dealId) params.set("dealId", dealId);
else if (uprn) params.set("uprn", uprn);
else if (landlordPropertyId)
params.set("landlordPropertyId", landlordPropertyId);
const res = await fetch(
@ -361,13 +374,112 @@ export default function PropertyDrawer({
key="install"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-2"
className="space-y-3"
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5 flex items-center gap-1.5">
<HardHat className="h-3.5 w-3.5" />
Install Documents
</h3>
{installDocs.length > 0 ? (
{docStatus?.measureProgress && docStatus.measureProgress.length > 0 ? (
// ── Per-measure checklist ──
<div className="space-y-4">
{docStatus.measureProgress.map((mp) => {
const measureDocs = installDocs.filter((d) => d.measureName === mp.measureName);
const uploadedTypeSet = new Set(measureDocs.map((d) => d.docType));
const missingTypes = mp.required.filter((t) => !uploadedTypeSet.has(t));
return (
<div key={mp.measureName} className="rounded-xl border border-gray-100 bg-gray-50/40 overflow-hidden">
{/* Measure header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-gray-100 bg-white">
<span className="text-xs font-semibold text-gray-800">{mp.measureName}</span>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${
mp.isComplete
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: mp.uploadedCount > 0
? "bg-amber-50 text-amber-700 border-amber-200"
: "bg-gray-100 text-gray-500 border-gray-200"
}`}>
{mp.uploadedCount} / {mp.requiredCount} docs
</span>
</div>
<div className="px-3 py-2.5 space-y-1.5">
{/* Uploaded required docs */}
{mp.uploaded.map((docType) => {
const doc = measureDocs.find((d) => d.docType === docType);
if (!doc) return null;
return (
<div key={docType} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-emerald-100 bg-emerald-50/50">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-5 h-5 rounded-full bg-emerald-100 border border-emerald-200 flex items-center justify-center">
<svg className="h-3 w-3 text-emerald-600" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[docType] ?? docType}</p>
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
</div>
</div>
<DownloadDocButton doc={doc} />
</div>
);
})}
{/* Missing required docs */}
{missingTypes.map((docType) => (
<div key={docType} className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-dashed border-amber-200 bg-amber-50/30">
<div className="shrink-0 w-5 h-5 rounded-full border-2 border-dashed border-amber-300 flex items-center justify-center">
<FileX className="h-2.5 w-2.5 text-amber-400" />
</div>
<p className="text-xs text-amber-700 font-medium">{DOC_TYPE_LABELS[docType] ?? docType}</p>
</div>
))}
{/* Extra docs uploaded for this measure (not in required list) */}
{measureDocs
.filter((d) => !mp.required.includes(d.docType))
.map((doc) => (
<div key={doc.id} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-gray-100 bg-white">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-5 h-5 rounded-full bg-sky-50 border border-sky-200 flex items-center justify-center">
<FileText className="h-3 w-3 text-sky-500" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[doc.docType] ?? doc.docType}</p>
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
</div>
</div>
<DownloadDocButton doc={doc} />
</div>
))
}
</div>
</div>
);
})}
{/* Unassigned / no-measure install docs */}
{(() => {
const knownMeasures = new Set(docStatus.measureProgress.map((m) => m.measureName));
const unassigned = installDocs.filter(
(d) => !d.measureName || !knownMeasures.has(d.measureName),
);
if (unassigned.length === 0) return null;
return (
<div className="space-y-1.5">
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-0.5">Other</h4>
{unassigned.map((doc) => (
<DocumentRow key={doc.id} doc={doc} showMeasure />
))}
</div>
);
})()}
</div>
) : installDocs.length > 0 ? (
// ── Fallback: flat list (no measure progress data) ──
<div className="space-y-1.5">
{installDocs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} showMeasure />

View file

@ -52,6 +52,10 @@ const COLUMN_LABELS: Record<string, string> = {
approvedPackage: "Approved Package",
actualMeasuresInstalled: "Installed Measures",
preSapScore: "Pre-SAP",
eiScore: "EI Score",
eiScorePotential: "EI Score (Potential)",
epcSapScore: "EPC SAP Score",
epcSapScorePotential: "EPC SAP (Potential)",
lodgementStatus: "Lodgement Status",
designDate: "Design Date",
fullLodgementDate: "Lodgement Date",
@ -62,7 +66,7 @@ type RemovalFilter = "all" | "pending_removal" | "removed" | "pending_re_additio
interface PropertyTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
onOpenDetail?: (deal: ClassifiedDeal) => void;
showDocuments?: boolean;
docStatusMap?: DocStatusMap;
@ -82,6 +86,10 @@ const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [
{ key: "approvedPackage", label: "Approved Package" },
{ key: "actualMeasuresInstalled", label: "Installed Measures" },
{ key: "preSapScore", label: "Pre-SAP" },
{ key: "eiScore", label: "EI Score" },
{ key: "eiScorePotential", label: "EI Score (Potential)" },
{ key: "epcSapScore", label: "EPC SAP Score" },
{ key: "epcSapScorePotential", label: "EPC SAP (Potential)" },
{ key: "lodgementStatus", label: "Lodgement Status" },
{ key: "designDate", label: "Design Date" },
{ key: "fullLodgementDate", label: "Lodgement Date" },
@ -115,6 +123,10 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
approvedPackage: false,
actualMeasuresInstalled: false,
preSapScore: false,
eiScore: false,
eiScorePotential: false,
epcSapScore: false,
epcSapScorePotential: false,
lodgementStatus: false,
designDate: false,
fullLodgementDate: false,
@ -128,7 +140,7 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
}
if (docFilter !== "all") {
result = result.filter((d) => {
const status = d.uprn ? docStatusMap[d.uprn] : undefined;
const status = docStatusMap[d.dealId];
if (docFilter === "none") return !status || !status.hasSurveyDocs;
if (docFilter === "has_docs") return !!status?.hasSurveyDocs;
if (docFilter === "incomplete") return !!status?.hasSurveyDocs && !status.isSurveyComplete;
@ -305,21 +317,26 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
Columns
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs text-gray-500">
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel className="text-xs text-gray-500 sticky top-0 bg-white z-10">
Toggle columns
</DropdownMenuLabel>
<DropdownMenuSeparator />
{toggleableColumns.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onCheckedChange={(val) => col.toggleVisibility(val)}
className="text-sm"
>
{COLUMN_LABELS[col.id] ?? col.id}
</DropdownMenuCheckboxItem>
))}
<div className="relative">
<div className="max-h-72 overflow-y-auto">
{toggleableColumns.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onCheckedChange={(val) => col.toggleVisibility(val)}
className="text-sm"
>
{COLUMN_LABELS[col.id] ?? col.id}
</DropdownMenuCheckboxItem>
))}
</div>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-white to-transparent" />
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>

View file

@ -42,10 +42,10 @@ function SortableHeader({
// -----------------------------------------------------------------------
// Column factory — takes onOpenDrawer so the Documents button can trigger it
// showDocuments controls whether the Docs action column is included
// docStatusMap provides per-UPRN document status for status indicators
// docStatusMap provides per-deal document status for status indicators
// -----------------------------------------------------------------------
export function createPropertyTableColumns(
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
showDocuments: boolean = false,
docStatusMap: DocStatusMap = {},
onOpenDetail?: (deal: ClassifiedDeal) => void,
@ -240,6 +240,54 @@ export function createPropertyTableColumns(
},
// ── EI score ─────────────────────────────────────────────────────────
{
accessorKey: "eiScore",
id: "eiScore",
header: ({ column }) => <SortableHeader label="EI Score" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-600">
{row.original.eiScore ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── EI score (potential) ──────────────────────────────────────────────
{
accessorKey: "eiScorePotential",
id: "eiScorePotential",
header: ({ column }) => <SortableHeader label="EI Score (Potential)" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-600">
{row.original.eiScorePotential ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── EPC SAP score ─────────────────────────────────────────────────────
{
accessorKey: "epcSapScore",
id: "epcSapScore",
header: ({ column }) => <SortableHeader label="EPC SAP Score" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-600">
{row.original.epcSapScore ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── EPC SAP score (potential) ─────────────────────────────────────────
{
accessorKey: "epcSapScorePotential",
id: "epcSapScorePotential",
header: ({ column }) => <SortableHeader label="EPC SAP (Potential)" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-600">
{row.original.epcSapScorePotential ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Lodgement status ─────────────────────────────────────────────────
{
accessorKey: "lodgementStatus",
@ -291,8 +339,7 @@ export function createPropertyTableColumns(
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Docs</span>
),
cell: ({ row }) => {
const uprn = row.original.uprn ?? "";
const status = uprn ? docStatusMap[uprn] : undefined;
const status = docStatusMap[row.original.dealId];
const isComplete = status?.isSurveyComplete;
const hasDocs = status?.hasSurveyDocs;
@ -315,7 +362,7 @@ export function createPropertyTableColumns(
return (
<button
onClick={() => onOpenDrawer(row.original.uprn, row.original.landlordPropertyId, row.original.dealname)}
onClick={() => onOpenDrawer(row.original.dealId, row.original.uprn, row.original.landlordPropertyId, row.original.dealname)}
className={className}
>
{icon}

View file

@ -13,8 +13,9 @@ import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
import { user as userTable } from "@/app/db/schema/users";
import type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
import type { HubspotDeal, DocStatusMap, DocStatus, MeasureDocProgress, PortfolioCapabilityType, ApprovalsByDeal, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
import type { InferSelectModel } from "drizzle-orm";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { Building2 } from "lucide-react";
@ -41,6 +42,7 @@ function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal {
pashubLink: row.pashubLink,
sharepointLink: row.sharepointLink,
dampMouldFlag: row.dampmouldGrowth,
dampMouldAndRepairComments: row.damnpMouldAndRepairComments,
preSapScore: row.preSap,
coordinator: row.coordinator,
ioeV1Date: row.mtpCompletionDate,
@ -59,6 +61,10 @@ function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal {
confirmedSurveyDate: row.confirmedSurveyDate,
surveyedDate: row.surveyedDate,
designType: row.dealType,
eiScore: row.eiScore,
eiScorePotential: row.eiScorePotential,
epcSapScore: row.epcSapScore,
epcSapScorePotential: row.epcSapScorePotential,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@ -224,74 +230,122 @@ export default async function LiveReportingPage(props: {
if (state !== "none") removalStatusByDeal[row.hubspotDealId] = state;
}
// Fetch survey document status for all properties
const uprnList = deals
// Fetch document status for all deals — two-phase strategy:
// Phase 1: query by dealId (reliable even when UPRN is missing from hubspot_deal_data)
// Phase 2: UPRN fallback only for deals that returned no results in phase 1
const docsByDealId = new Map<string, Array<{ fileType: string; measureName: string | null }>>();
if (dealIds.length > 0) {
const phase1Rows = await db
.select({
hubsotDealId: uploadedFiles.hubsotDealId,
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(inArray(uploadedFiles.hubsotDealId, dealIds));
for (const row of phase1Rows) {
if (!row.hubsotDealId || row.fileType === null) continue;
if (!docsByDealId.has(row.hubsotDealId)) docsByDealId.set(row.hubsotDealId, []);
docsByDealId.get(row.hubsotDealId)!.push({ fileType: row.fileType, measureName: row.measureName });
}
}
// Phase 2: for deals with no docs from phase 1 that have a UPRN, try UPRN lookup
const dealsWithoutDocs = deals.filter((d) => !docsByDealId.has(d.dealId));
const fallbackUprns = dealsWithoutDocs
.map((d) => d.uprn)
.filter((u): u is string => !!u)
.map((u) => {
try { return BigInt(u); } catch { return null; }
})
.map((u) => { try { return BigInt(u); } catch { return null; } })
.filter((u): u is bigint => u !== null);
let docStatusMap: DocStatusMap = {};
if (uprnList.length > 0) {
const docRows = await db
if (fallbackUprns.length > 0) {
const phase2Rows = await db
.select({
uprn: uploadedFiles.uprn,
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(inArray(uploadedFiles.uprn, uprnList));
.where(inArray(uploadedFiles.uprn, fallbackUprns));
// Group docs by UPRN
const docsByUprn = new Map<string, Array<{ fileType: string; measureName: string | null }>>();
for (const row of docRows) {
// Map phase 2 UPRN results back to dealId
const uprnToDealId = new Map<string, string>(
dealsWithoutDocs
.filter((d) => d.uprn)
.map((d) => {
try { return [String(BigInt(d.uprn!)), d.dealId] as [string, string]; } catch { return null; }
})
.filter((e): e is [string, string] => e !== null),
);
for (const row of phase2Rows) {
if (row.uprn === null || row.fileType === null) continue;
const key = String(row.uprn);
if (!docsByUprn.has(key)) docsByUprn.set(key, []);
docsByUprn.get(key)!.push({ fileType: row.fileType, measureName: row.measureName });
const dealId = uprnToDealId.get(String(row.uprn));
if (!dealId) continue;
if (!docsByDealId.has(dealId)) docsByDealId.set(dealId, []);
docsByDealId.get(dealId)!.push({ fileType: row.fileType, measureName: row.measureName });
}
}
// Build measures lookup from deals (uprn → proposed measure names)
const measuresByUprn = new Map<string, string[]>();
for (const deal of deals) {
if (deal.uprn) {
const key = String(deal.uprn);
const measures = (deal.proposedMeasures ?? "")
.split(",").map((m: string) => m.trim()).filter(Boolean);
measuresByUprn.set(key, measures);
}
}
// Build measures lookup by dealId (approved measures, falling back to proposed)
const measuresByDealId = new Map<string, string[]>();
for (const deal of deals) {
const approved = approvalsByDeal[deal.dealId] ?? [];
const measures = approved.length > 0
? approved
: (deal.proposedMeasures ?? "").split(",").map((m: string) => m.trim()).filter(Boolean);
measuresByDealId.set(deal.dealId, measures);
}
for (const [uprn, docs] of docsByUprn) {
const surveyDocs = docs.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType));
const installDocs = docs.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType));
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
// Build docStatusMap keyed by dealId
const docStatusMap: DocStatusMap = {};
const measures = measuresByUprn.get(uprn) ?? [];
let installStatus: DocStatus["installStatus"] = "none";
if (installDocs.length > 0) {
if (measures.length === 0) {
installStatus = "hasDocs";
} else {
const measuresWithDocs = new Set(
installDocs.map((d) => d.measureName).filter(Boolean),
);
installStatus = measures.every((m) => measuresWithDocs.has(m)) ? "all" : "partial";
}
}
for (const [dealId, docs] of docsByDealId) {
const surveyDocs = docs.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType));
const installDocs = docs.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType));
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
const status: DocStatus = {
presentSurveyTypes: Array.from(surveyTypeSet),
hasSurveyDocs: surveyDocs.length > 0,
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) => surveyTypeSet.has(t)),
hasInstallDocs: installDocs.length > 0,
installStatus,
const measures = measuresByDealId.get(dealId) ?? [];
// Compute per-measure document progress against the requirements matrix
const measureProgress: MeasureDocProgress[] = measures.map((measureName) => {
const required = getRequiredDocs(measureName);
const docsForMeasure = installDocs.filter((d) => d.measureName === measureName);
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
const uploaded = required.filter((r) => uploadedTypeSet.has(r));
return {
measureName,
required,
uploaded,
isComplete: uploaded.length === required.length,
uploadedCount: uploaded.length,
requiredCount: required.length,
};
docStatusMap[uprn] = status;
});
let installStatus: DocStatus["installStatus"] = "none";
if (installDocs.length > 0) {
if (measures.length === 0) {
installStatus = "hasDocs";
} else {
installStatus = measureProgress.every((m) => m.isComplete)
? "all"
: measureProgress.some((m) => m.uploadedCount > 0)
? "partial"
: "none";
}
}
docStatusMap[dealId] = {
presentSurveyTypes: Array.from(surveyTypeSet),
hasSurveyDocs: surveyDocs.length > 0,
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) => surveyTypeSet.has(t)),
hasInstallDocs: installDocs.length > 0,
installStatus,
measureProgress,
};
}
return (

View file

@ -28,6 +28,7 @@ export type HubspotDeal = {
pashubLink: string | null;
sharepointLink: string | null;
dampMouldFlag: string | null; // coordinator-stage damp/mould flag
dampMouldAndRepairComments: string | null; // coordinator damp/mould comments
preSapScore: string | null; // kept as text (HubSpot returns strings)
coordinator: string | null;
ioeV1Date: Date | null;
@ -46,6 +47,10 @@ export type HubspotDeal = {
confirmedSurveyDate: Date | null;
surveyedDate: Date | null;
designType: string | null;
eiScore: string | null;
eiScorePotential: string | null;
epcSapScore: string | null;
epcSapScorePotential: string | null;
createdAt: Date;
updatedAt: Date;
@ -250,6 +255,16 @@ export const SURVEY_ALL_DOC_TYPES = new Set<string>([
"ecmk_survey_xml",
]);
// Per-measure document upload progress
export type MeasureDocProgress = {
measureName: string;
required: string[]; // required fileType values for this measure
uploaded: string[]; // required fileType values that have been uploaded
isComplete: boolean;
uploadedCount: number;
requiredCount: number;
};
export type DocStatus = {
// Retrofit assessment docs
presentSurveyTypes: string[];
@ -258,16 +273,18 @@ export type DocStatus = {
// Install docs
hasInstallDocs: boolean;
installStatus: "none" | "partial" | "hasDocs" | "all";
// "all" = install docs exist for every proposed measure
// "partial" = some (but not all) proposed measures have docs
// "all" = all required docs uploaded for every proposed measure
// "partial" = some (but not all) proposed measures have complete docs
// "hasDocs" = has install docs but no measures defined on the deal
// "none" = no install docs at all
measureProgress: MeasureDocProgress[]; // one entry per proposed measure
};
export type DocStatusMap = Record<string, DocStatus>; // keyed by UPRN string
export type DocStatusMap = Record<string, DocStatus>; // keyed by dealId string
export type DocumentDrawerState = {
open: boolean;
dealId: string | null;
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;