mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge remote-tracking branch 'origin/main' into feature/onbarding_of_addresses
This commit is contained in:
commit
9f8ea85fed
27 changed files with 34502 additions and 195 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
3
src/app/db/migrations/0183_careless_darkhawk.sql
Normal file
3
src/app/db/migrations/0183_careless_darkhawk.sql
Normal 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;
|
||||
261
src/app/db/migrations/0184_tiny_annihilus.sql
Normal file
261
src/app/db/migrations/0184_tiny_annihilus.sql
Normal 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");
|
||||
3
src/app/db/migrations/0185_slimy_mindworm.sql
Normal file
3
src/app/db/migrations/0185_slimy_mindworm.sql
Normal 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;
|
||||
2
src/app/db/migrations/0186_equal_baron_zemo.sql
Normal file
2
src/app/db/migrations/0186_equal_baron_zemo.sql
Normal 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;
|
||||
6989
src/app/db/migrations/meta/0183_snapshot.json
Normal file
6989
src/app/db/migrations/meta/0183_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
8616
src/app/db/migrations/meta/0184_snapshot.json
Normal file
8616
src/app/db/migrations/meta/0184_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
8616
src/app/db/migrations/meta/0185_snapshot.json
Normal file
8616
src/app/db/migrations/meta/0185_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
8616
src/app/db/migrations/meta/0186_snapshot.json
Normal file
8616
src/app/db/migrations/meta/0186_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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(),
|
||||
},
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
81
src/app/lib/measureDocumentRequirements.ts
Normal file
81
src/app/lib/measureDocumentRequirements.ts
Normal 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",
|
||||
};
|
||||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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] ?? []}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue