mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
pulling from mainMerge branch 'main' of github.com:Hestia-Homes/assessment-model into feature/make_delete_work
This commit is contained in:
commit
e6e836d733
35 changed files with 36348 additions and 155 deletions
|
|
@ -26,6 +26,7 @@ const PresignedUrlBodySchema = z
|
|||
file_type: z.enum(["csv", "xlsx"]).optional(), // Specify the file type
|
||||
file_format: z.enum(["domna_asset_list"]).optional().nullable(), // Specify the file format
|
||||
sheet_name: z.string().optional().nullable(), // Specify the sheet name if applicable
|
||||
enforce_fabric_first: z.boolean().optional().default(false),
|
||||
})
|
||||
.refine((data) => data.goal !== "Increasing EPC" || !!data.goal_value, {
|
||||
path: ["goal_value"],
|
||||
|
|
|
|||
|
|
@ -77,58 +77,32 @@ export async function GET(
|
|||
// `);
|
||||
|
||||
const result = await db.execute(sql`
|
||||
WITH latest_plans AS (
|
||||
SELECT DISTINCT ON (property_id)
|
||||
*
|
||||
FROM plan
|
||||
WHERE portfolio_id = ${pid}
|
||||
AND scenario_id = ${sid}
|
||||
ORDER BY property_id, created_at DESC
|
||||
),
|
||||
|
||||
recommendation_flags AS (
|
||||
SELECT
|
||||
r.id AS recommendation_id,
|
||||
r.measure_type AS measure_type,
|
||||
r.property_id AS property_id,
|
||||
r.estimated_cost AS estimated_cost,
|
||||
r.type AS type
|
||||
|
||||
FROM latest_plans lp
|
||||
JOIN plan_recommendations pr
|
||||
ON pr.plan_id = lp.id
|
||||
JOIN recommendation r
|
||||
ON r.id = pr.recommendation_id
|
||||
|
||||
LEFT JOIN recommendation_materials rm
|
||||
ON rm.recommendation_id = r.id
|
||||
LEFT JOIN material m
|
||||
ON m.id = rm.material_id
|
||||
AND m.is_active = true
|
||||
|
||||
WHERE r.default = true
|
||||
AND r.already_installed = false
|
||||
|
||||
GROUP BY
|
||||
r.id,
|
||||
r.measure_type,
|
||||
r.type,
|
||||
r.property_id,
|
||||
r.estimated_cost
|
||||
)
|
||||
|
||||
SELECT
|
||||
measure_type,
|
||||
type,
|
||||
|
||||
COUNT(DISTINCT property_id)::int AS homes_count,
|
||||
SUM(estimated_cost)::float AS total_cost,
|
||||
AVG(estimated_cost)::float AS average_cost
|
||||
|
||||
FROM recommendation_flags
|
||||
r.measure_type,
|
||||
r.type,
|
||||
COUNT(DISTINCT r.property_id)::int AS homes_count,
|
||||
SUM(r.estimated_cost)::float AS total_cost,
|
||||
AVG(r.estimated_cost)::float AS average_cost
|
||||
FROM recommendation r
|
||||
WHERE r.default = true
|
||||
AND r.already_installed = false
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM (
|
||||
SELECT DISTINCT ON (p.property_id)
|
||||
p.id
|
||||
FROM plan p
|
||||
WHERE p.portfolio_id = ${pid}
|
||||
AND p.scenario_id = ${sid}
|
||||
ORDER BY p.property_id, p.created_at DESC
|
||||
) lp
|
||||
JOIN plan_recommendations pr
|
||||
ON pr.plan_id = lp.id
|
||||
WHERE pr.recommendation_id = r.id
|
||||
)
|
||||
GROUP BY
|
||||
measure_type,
|
||||
type
|
||||
r.measure_type,
|
||||
r.type
|
||||
ORDER BY total_cost DESC;
|
||||
`);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ type BaselineAggregates = {
|
|||
avg_bills: number | null;
|
||||
total_carbon: number | null;
|
||||
total_bills: number | null;
|
||||
total_sap_uplift: number | null;
|
||||
sap_points_array: (number | null)[];
|
||||
};
|
||||
|
||||
|
|
@ -35,26 +36,39 @@ export async function GET(
|
|||
//
|
||||
const baselineResult = await db.execute(sql`
|
||||
WITH latest_plans AS (
|
||||
SELECT DISTINCT ON (property_id)
|
||||
*
|
||||
FROM plan
|
||||
WHERE portfolio_id = ${pid}
|
||||
AND scenario_id = ${sid}
|
||||
ORDER BY property_id, created_at DESC
|
||||
)
|
||||
SELECT DISTINCT ON (property_id)
|
||||
*
|
||||
FROM plan
|
||||
WHERE portfolio_id = ${pid}
|
||||
AND scenario_id = ${sid}
|
||||
ORDER BY property_id, created_at DESC
|
||||
)
|
||||
|
||||
SELECT
|
||||
COUNT(*)::int AS n_units,
|
||||
SELECT
|
||||
COUNT(*)::int AS n_units,
|
||||
|
||||
AVG(post_sap_points)::float AS avg_sap,
|
||||
AVG(post_co2_emissions)::float AS avg_carbon,
|
||||
AVG(post_energy_bill)::float AS avg_bills,
|
||||
AVG(lp.post_sap_points)::float AS avg_sap,
|
||||
AVG(lp.post_co2_emissions)::float AS avg_carbon,
|
||||
AVG(lp.post_energy_bill)::float AS avg_bills,
|
||||
|
||||
SUM(post_co2_emissions)::float AS total_carbon,
|
||||
SUM(post_energy_bill)::float AS total_bills,
|
||||
SUM(lp.post_co2_emissions)::float AS total_carbon,
|
||||
SUM(lp.post_energy_bill)::float AS total_bills,
|
||||
|
||||
ARRAY_AGG(post_sap_points) AS sap_points_array
|
||||
FROM latest_plans;
|
||||
SUM(
|
||||
CASE
|
||||
WHEN lp.cost_of_works > 0.01
|
||||
AND p.current_sap_points IS NOT NULL
|
||||
AND lp.post_sap_points IS NOT NULL
|
||||
THEN lp.post_sap_points - p.current_sap_points
|
||||
ELSE 0
|
||||
END
|
||||
)::float AS total_sap_uplift,
|
||||
|
||||
ARRAY_AGG(lp.post_sap_points) AS sap_points_array
|
||||
|
||||
FROM latest_plans lp
|
||||
JOIN property p
|
||||
ON p.id = lp.property_id;
|
||||
`);
|
||||
|
||||
const baseline = baselineResult.rows[0] as BaselineAggregates | undefined;
|
||||
|
|
@ -113,7 +127,8 @@ export async function GET(
|
|||
const contingency = upgraded.contingency ?? 0;
|
||||
const total_funding = upgraded.total_funding ?? 0;
|
||||
const net_cost = construction_cost - total_funding;
|
||||
const pc_cost = construction_cost * 0.28; // Placeholder for PC cost
|
||||
const pc_cost = construction_cost * 0.3; // Placeholder for PC cost
|
||||
const total_sap_uplift = baseline.total_sap_uplift ?? 0;
|
||||
|
||||
//
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -161,5 +176,6 @@ export async function GET(
|
|||
n_units_upgraded > 0
|
||||
? (construction_cost + pc_cost) / n_units_upgraded
|
||||
: 0,
|
||||
total_sap_uplift,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,9 +108,9 @@ export default function RecommendationCard({
|
|||
) as Recommendation;
|
||||
|
||||
// A recommendation type could have no default recommendation, so we need to check if it exists
|
||||
const alreadyInstalled = defaultComponent
|
||||
? defaultComponent.alreadyInstalled
|
||||
: false;
|
||||
const alreadyInstalled = recommendationData.some(
|
||||
(rec) => rec.alreadyInstalled
|
||||
);
|
||||
|
||||
const [cardComponent, setCardComponent] =
|
||||
useState<Recommendation>(defaultComponent);
|
||||
|
|
@ -131,8 +131,8 @@ export default function RecommendationCard({
|
|||
const cardClassName = alreadyInstalled
|
||||
? alreadyInstalledStyling
|
||||
: cardComponent
|
||||
? selectionStyling
|
||||
: noSelectionStyling;
|
||||
? selectionStyling
|
||||
: noSelectionStyling;
|
||||
|
||||
const optionTextClassName = alreadyInstalled
|
||||
? "text-brandgold"
|
||||
|
|
@ -141,8 +141,8 @@ export default function RecommendationCard({
|
|||
const optionsText = alreadyInstalled
|
||||
? "Already installed"
|
||||
: cardComponent
|
||||
? "Click for more options"
|
||||
: "Click to select";
|
||||
? "Click for more options"
|
||||
: "Click to select";
|
||||
|
||||
const openModal = () => {
|
||||
// If the card is already installed, we don't want to open the modal
|
||||
|
|
|
|||
|
|
@ -19,12 +19,14 @@ import {
|
|||
SecondaryEnergyEfficiencyImpactCard,
|
||||
} from "./EnergyEfficiencyImpactCard";
|
||||
import { FundingPackageWithMeasures } from "@/app/db/schema/funding";
|
||||
import { InstalledMeasureSummary } from "@/app/portfolio/[slug]/building-passport/[propertyId]/utils";
|
||||
|
||||
interface RecommendationContainerProps {
|
||||
recommendations: Recommendation[];
|
||||
propertyMeta: PropertyMeta;
|
||||
planMeta: Plan;
|
||||
funding: FundingPackageWithMeasures[];
|
||||
installedMeasures: InstalledMeasureSummary[];
|
||||
}
|
||||
|
||||
const typeToCategoryMap: { [key in RecommendationType]?: RecommendationType } =
|
||||
|
|
@ -57,7 +59,13 @@ export default function RecommendationContainer({
|
|||
propertyMeta,
|
||||
planMeta,
|
||||
funding,
|
||||
installedMeasures,
|
||||
}: RecommendationContainerProps) {
|
||||
// Get the unique types of installed measures for easy lookup
|
||||
const installedMeasureTypeSet = new Set(
|
||||
installedMeasures.map((m) => m.measureType)
|
||||
);
|
||||
|
||||
const categorizedRecommendations = recommendations.reduce(
|
||||
(acc, curr) => {
|
||||
const typeKey = curr.type as RecommendationType;
|
||||
|
|
@ -66,11 +74,28 @@ export default function RecommendationContainer({
|
|||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(curr);
|
||||
|
||||
const alreadyInstalled =
|
||||
curr.measureType != null &&
|
||||
installedMeasureTypeSet.has(curr.measureType);
|
||||
|
||||
acc[category].push({
|
||||
...curr,
|
||||
alreadyInstalled: alreadyInstalled,
|
||||
sapPoints: alreadyInstalled ? 0 : curr.sapPoints,
|
||||
estimatedCost: alreadyInstalled ? 0 : curr.estimatedCost,
|
||||
co2EquivalentSavings: alreadyInstalled ? 0 : curr.co2EquivalentSavings,
|
||||
energyCostSavings: alreadyInstalled ? 0 : curr.energyCostSavings,
|
||||
kwhSavings: alreadyInstalled ? 0 : curr.kwhSavings,
|
||||
labourDays: alreadyInstalled ? 0 : curr.labourDays,
|
||||
});
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<RecommendationType, (typeof recommendations)[0][]>
|
||||
{} as Record<
|
||||
RecommendationType,
|
||||
(Recommendation & { alreadyInstalled: boolean })[]
|
||||
>
|
||||
);
|
||||
|
||||
const defaultWallsRecommendations =
|
||||
|
|
|
|||
|
|
@ -70,11 +70,6 @@ export function Toolbar({
|
|||
const [openModal, setOpenModal] = useState(false);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
console.log(propertyId, "PropertyID")
|
||||
console.log(portfolioId, "porfolio id")
|
||||
console.log(propertyMeta, "property meta")
|
||||
console.log(decentHomes, "decent homes")
|
||||
|
||||
function handleClickSettings() {
|
||||
console.log("Settings were clicked, implement me");
|
||||
}
|
||||
|
|
@ -129,8 +124,6 @@ export function Toolbar({
|
|||
</NavigationMenuLink>
|
||||
);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
|
|
|
|||
1
src/app/db/migrations/0142_serious_whirlwind.sql
Normal file
1
src/app/db/migrations/0142_serious_whirlwind.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE INDEX "idx_recommendation_active_defaults" ON "recommendation" USING btree ("id") WHERE "recommendation"."default" = true AND "recommendation"."already_installed" = false;
|
||||
1
src/app/db/migrations/0143_magenta_magus.sql
Normal file
1
src/app/db/migrations/0143_magenta_magus.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE INDEX "idx_plan_latest_per_property" ON "plan" USING btree ("portfolio_id","scenario_id","property_id","created_at" DESC NULLS LAST);
|
||||
2
src/app/db/migrations/0144_lovely_moira_mactaggert.sql
Normal file
2
src/app/db/migrations/0144_lovely_moira_mactaggert.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
CREATE INDEX CONCURRENTLY "idx_plan_recommendations_plan_rec" ON "plan_recommendations" USING btree ("plan_id","recommendation_id");--> statement-breakpoint
|
||||
CREATE INDEX CONCURRENTLY "idx_recommendation_active_id_property" ON "recommendation" USING btree ("id","property_id") WHERE "recommendation"."default" = true AND "recommendation"."already_installed" = false;
|
||||
19
src/app/db/migrations/0145_brave_power_pack.sql
Normal file
19
src/app/db/migrations/0145_brave_power_pack.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
CREATE TYPE "public"."measure_type" AS ENUM('air_source_heat_pump', 'boiler_upgrade', 'high_heat_retention_storage_heaters', 'secondary_heating', 'roomstat_programmer_trvs', 'time_temperature_zone_control', 'cylinder_thermostat', 'cavity_wall_insulation', 'extension_cavity_wall_insulation', 'external_wall_insulation', 'internal_wall_insulation', 'loft_insulation', 'flat_roof_insulation', 'room_roof_insulation', 'solid_floor_insulation', 'suspended_floor_insulation', 'double_glazing', 'secondary_glazing', 'draught_proofing', 'mechanical_ventilation', 'low_energy_lighting', 'solar_pv', 'hot_water_tank_insulation', 'sealing_open_fireplace');--> statement-breakpoint
|
||||
CREATE TABLE "installed_measure" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"uprn" text NOT NULL,
|
||||
"measure_type" "measure_type" NOT NULL,
|
||||
"installed_at" timestamp DEFAULT now(),
|
||||
"sap_points" real,
|
||||
"carbon_savings" real,
|
||||
"kwh_savings" real,
|
||||
"bill_savings" real,
|
||||
"heat_demand_savings" real,
|
||||
"source" text,
|
||||
"is_active" boolean DEFAULT true NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "idx_installed_measure_uprn" ON "installed_measure" USING btree ("uprn");--> statement-breakpoint
|
||||
CREATE INDEX "idx_installed_measure_uprn_active" ON "installed_measure" USING btree ("uprn") WHERE "installed_measure"."is_active" = true;--> statement-breakpoint
|
||||
CREATE INDEX "idx_installed_measure_measure_type" ON "installed_measure" USING btree ("measure_type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_installed_measure_uprn_measure" ON "installed_measure" USING btree ("uprn","measure_type") WHERE "installed_measure"."is_active" = true;
|
||||
3
src/app/db/migrations/0146_tiny_george_stacy.sql
Normal file
3
src/app/db/migrations/0146_tiny_george_stacy.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "property" ADD COLUMN "installed_measures_sap_point_adjustment" real;--> statement-breakpoint
|
||||
ALTER TABLE "property" ADD COLUMN "is_sap_points_adjusted_for_installed_measures" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "property" ADD COLUMN "original_sap_points" real;
|
||||
2
src/app/db/migrations/0147_confused_killer_shrike.sql
Normal file
2
src/app/db/migrations/0147_confused_killer_shrike.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE "property_installed_measures" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DROP TABLE "property_installed_measures" CASCADE;--> statement-breakpoint
|
||||
9
src/app/db/migrations/0148_first_gamma_corps.sql
Normal file
9
src/app/db/migrations/0148_first_gamma_corps.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
ALTER TABLE "property_details_epc" ADD COLUMN "original_co2_emissions" real;--> statement-breakpoint
|
||||
ALTER TABLE "property_details_epc" ADD COLUMN "original_primary_energy_consumption" real;--> statement-breakpoint
|
||||
ALTER TABLE "property_details_epc" ADD COLUMN "original_current_energy_demand" real;--> statement-breakpoint
|
||||
ALTER TABLE "property_details_epc" ADD COLUMN "original_current_energy_demand_heating_hotwater" real;--> statement-breakpoint
|
||||
ALTER TABLE "property_details_epc" ADD COLUMN "installed_measures_co2_adjustment" real;--> statement-breakpoint
|
||||
ALTER TABLE "property_details_epc" ADD COLUMN "installed_measures_energy_demand_adjustment" real;--> statement-breakpoint
|
||||
ALTER TABLE "property_details_epc" ADD COLUMN "installed_measures_total_energy_bill_adjustment" real;--> statement-breakpoint
|
||||
ALTER TABLE "property_details_epc" ADD COLUMN "installed_measures_heat_demand_adjustment" real;--> statement-breakpoint
|
||||
ALTER TABLE "property_details_epc" ADD COLUMN "is_epc_adjusted_for_installed_measures" boolean DEFAULT false;
|
||||
12
src/app/db/migrations/2026_01_06_recommendation_cover.sql
Normal file
12
src/app/db/migrations/2026_01_06_recommendation_cover.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- migrations/2026_01_06_recommendation_cover.sql
|
||||
|
||||
CREATE INDEX CONCURRENTLY idx_recommendation_active_cover
|
||||
ON recommendation (
|
||||
id,
|
||||
property_id,
|
||||
measure_type,
|
||||
type
|
||||
)
|
||||
INCLUDE (estimated_cost)
|
||||
WHERE default = true
|
||||
AND already_installed = false;
|
||||
4958
src/app/db/migrations/meta/0142_snapshot.json
Normal file
4958
src/app/db/migrations/meta/0142_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4991
src/app/db/migrations/meta/0143_snapshot.json
Normal file
4991
src/app/db/migrations/meta/0143_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5034
src/app/db/migrations/meta/0144_snapshot.json
Normal file
5034
src/app/db/migrations/meta/0144_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5215
src/app/db/migrations/meta/0145_snapshot.json
Normal file
5215
src/app/db/migrations/meta/0145_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5234
src/app/db/migrations/meta/0146_snapshot.json
Normal file
5234
src/app/db/migrations/meta/0146_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5188
src/app/db/migrations/meta/0147_snapshot.json
Normal file
5188
src/app/db/migrations/meta/0147_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5243
src/app/db/migrations/meta/0148_snapshot.json
Normal file
5243
src/app/db/migrations/meta/0148_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -995,6 +995,55 @@
|
|||
"when": 1767619224711,
|
||||
"tag": "0141_amazing_harry_osborn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 142,
|
||||
"version": "7",
|
||||
"when": 1767692283901,
|
||||
"tag": "0142_serious_whirlwind",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 143,
|
||||
"version": "7",
|
||||
"when": 1767693565375,
|
||||
"tag": "0143_magenta_magus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 144,
|
||||
"version": "7",
|
||||
"when": 1767704869539,
|
||||
"tag": "0144_lovely_moira_mactaggert",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 145,
|
||||
"version": "7",
|
||||
"when": 1767810791075,
|
||||
"tag": "0145_brave_power_pack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 146,
|
||||
"version": "7",
|
||||
"when": 1767812381964,
|
||||
"tag": "0146_tiny_george_stacy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 147,
|
||||
"version": "7",
|
||||
"when": 1767814056667,
|
||||
"tag": "0147_confused_killer_shrike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 148,
|
||||
"version": "7",
|
||||
"when": 1767823836420,
|
||||
"tag": "0148_first_gamma_corps",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -132,6 +132,17 @@ export const property = pgTable(
|
|||
currentEpcRating: epcEnum("current_epc_rating"),
|
||||
currentSapPoints: real("current_sap_points"),
|
||||
currentValuation: real("current_valuation"),
|
||||
|
||||
// When we have already installed measures, we will adjust the SAP points to reflect this. We keep a record of
|
||||
// 1) The number of points we've adjusted by
|
||||
// 2) a flag to indicate whether the SAP points have been adjusted, for easily filtering
|
||||
installedMeasuresSapPointAdjustment: real(
|
||||
"installed_measures_sap_point_adjustment"
|
||||
),
|
||||
isSapPointsAdjustedForInstalledMeasures: boolean(
|
||||
"is_sap_points_adjusted_for_installed_measures"
|
||||
).default(false),
|
||||
originalSapPoints: real("original_sap_points"),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("uq_property_portfolio_uprn")
|
||||
|
|
@ -195,8 +206,10 @@ export const propertyDetailsEpc = pgTable(
|
|||
numberStoreys: integer("number_of_storeys"),
|
||||
mainsGas: boolean("mains_gas"),
|
||||
energyTariff: text("energy_tariff"),
|
||||
// This is heat demand
|
||||
primaryEnergyConsumption: real("primary_energy_consumption"),
|
||||
co2Emissions: real("co2_emissions"),
|
||||
// Bad naming but currentEnergyDemand is the current kwh consumption - needs to be renamed
|
||||
currentEnergyDemand: real("current_energy_demand"),
|
||||
currentEnergyDemandHeatingHotwater: real(
|
||||
"current_energy_demand_heating_hotwater"
|
||||
|
|
@ -216,6 +229,36 @@ export const propertyDetailsEpc = pgTable(
|
|||
appliancesEnergyCostCurrent: real("appliances_cost_current"),
|
||||
gasStandingCharge: real("gas_standing_charge"),
|
||||
electricityStandingCharge: real("electricity_standing_charge"),
|
||||
|
||||
// When we have already installed measures, we will adjust the carbon, bills, kwh, heat demandto reflect this. We keep a record of
|
||||
// 1) The adjustments
|
||||
// 2) original values
|
||||
// 3) a flag to indicate whether the values have been adjusted, for easily filtering
|
||||
|
||||
// original values - we don't need bills because we don't actually adjust any of the originals we just subtract adjustments from current values
|
||||
originalCo2Emissions: real("original_co2_emissions"),
|
||||
originalPrimaryEnergyConsumption: real(
|
||||
"original_primary_energy_consumption"
|
||||
),
|
||||
originalCurrentEnergyDemand: real("original_current_energy_demand"),
|
||||
originalCurrentEnergyDemandHeatingHotwater: real(
|
||||
"original_current_energy_demand_heating_hotwater"
|
||||
),
|
||||
|
||||
// adjustment quantities
|
||||
installedMeasuresCo2Adjustment: real("installed_measures_co2_adjustment"),
|
||||
installedMeasuresEnergyDemandAdjustment: real(
|
||||
"installed_measures_energy_demand_adjustment"
|
||||
),
|
||||
installedMeasuresTotalEnergyBillAdjustment: real(
|
||||
"installed_measures_total_energy_bill_adjustment"
|
||||
),
|
||||
installedMeasuresHeatDemandAdjustment: real(
|
||||
"installed_measures_heat_demand_adjustment"
|
||||
),
|
||||
isEpcAdjustedForInstalledMeasures: boolean(
|
||||
"is_epc_adjusted_for_installed_measures"
|
||||
).default(false),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("uq_property_details_epc_property_portfolio").on(
|
||||
|
|
@ -283,23 +326,6 @@ export const nonIntrusiveSurveyNotes = pgTable("non_intrusive_survey_notes", {
|
|||
note: text("note").notNull(),
|
||||
});
|
||||
|
||||
// This model is a record of the measures that have already been installed for a property
|
||||
// This is considered as supplementary daa and stored against the UPRN
|
||||
// RecommendationType is the
|
||||
export const propertyInstalledMeasures = pgTable(
|
||||
"property_installed_measures",
|
||||
{
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
uprn: bigint("uprn", { mode: "bigint" }).notNull(),
|
||||
// The material types define the list of measures we should expect
|
||||
measureType: materialTypeEnum("measure_type").notNull(),
|
||||
// Record of when this measure was inserted into the db
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
// Record of when this measure was actually installed
|
||||
installedAt: timestamp("installed_at").notNull().defaultNow(),
|
||||
}
|
||||
);
|
||||
|
||||
export type Property = InferModel<typeof property, "select">;
|
||||
export type PropertyDetailsEpc = InferModel<
|
||||
typeof propertyDetailsEpc,
|
||||
|
|
|
|||
|
|
@ -13,9 +13,53 @@ import {
|
|||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { Material, material } from "./materials";
|
||||
import { InferModel } from "drizzle-orm";
|
||||
import { InferModel, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
// For recommendations, measure types was initially defined as a string but we should convert this to an enum in the future
|
||||
|
||||
export const measureTypeEnum = pgEnum("measure_type", [
|
||||
// Heating systems
|
||||
"air_source_heat_pump",
|
||||
"boiler_upgrade",
|
||||
"high_heat_retention_storage_heaters",
|
||||
"secondary_heating",
|
||||
|
||||
// Heating controls
|
||||
"roomstat_programmer_trvs",
|
||||
"time_temperature_zone_control",
|
||||
"cylinder_thermostat",
|
||||
|
||||
// Insulation
|
||||
"cavity_wall_insulation",
|
||||
"extension_cavity_wall_insulation",
|
||||
"external_wall_insulation",
|
||||
"internal_wall_insulation",
|
||||
"loft_insulation",
|
||||
"flat_roof_insulation",
|
||||
"room_roof_insulation",
|
||||
"solid_floor_insulation",
|
||||
"suspended_floor_insulation",
|
||||
|
||||
// Windows & doors
|
||||
"double_glazing",
|
||||
"secondary_glazing",
|
||||
"draught_proofing",
|
||||
|
||||
// Ventilation
|
||||
"mechanical_ventilation",
|
||||
|
||||
// Lighting
|
||||
"low_energy_lighting",
|
||||
|
||||
// Renewables
|
||||
"solar_pv",
|
||||
|
||||
// Other fabric / hot water
|
||||
"hot_water_tank_insulation",
|
||||
"sealing_open_fireplace",
|
||||
]);
|
||||
|
||||
export const recommendation = pgTable(
|
||||
"recommendation",
|
||||
{
|
||||
|
|
@ -46,7 +90,20 @@ export const recommendation = pgTable(
|
|||
labourDays: real("labour_days"),
|
||||
alreadyInstalled: boolean("already_installed").default(false),
|
||||
},
|
||||
(table) => [index("recommendation_property_id_idx").on(table.propertyId)]
|
||||
(table) => [
|
||||
index("recommendation_property_id_idx").on(table.propertyId),
|
||||
index("idx_recommendation_active_defaults")
|
||||
.on(table.id)
|
||||
.where(
|
||||
sql`${table.default} = true AND ${table.alreadyInstalled} = false`
|
||||
),
|
||||
|
||||
index("idx_recommendation_active_id_property")
|
||||
.on(table.id, table.propertyId)
|
||||
.where(
|
||||
sql`${table.default} = true AND ${table.alreadyInstalled} = false`
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
export const unitQuantity: [string, ...string[]] = ["m2", "part", "kwp"];
|
||||
|
|
@ -165,6 +222,12 @@ export const plan = pgTable(
|
|||
table.portfolioId,
|
||||
table.scenarioId
|
||||
),
|
||||
index("idx_plan_latest_per_property").on(
|
||||
table.portfolioId,
|
||||
table.scenarioId,
|
||||
table.propertyId,
|
||||
table.createdAt.desc()
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -181,7 +244,13 @@ export const planRecommendations = pgTable(
|
|||
.notNull()
|
||||
.references(() => recommendation.id),
|
||||
},
|
||||
(table) => [index("idx_plan_recommendations_plan_id").on(table.planId)]
|
||||
(table) => [
|
||||
index("idx_plan_recommendations_plan_id").on(table.planId),
|
||||
index("idx_plan_recommendations_plan_rec").on(
|
||||
table.planId,
|
||||
table.recommendationId
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
export const HousingType: [string, ...string[]] = ["Private", "Social"];
|
||||
|
|
@ -241,6 +310,44 @@ export const scenario = pgTable("scenario", {
|
|||
valuationReturnOnInvestment: text("valuation_return_on_investment"),
|
||||
});
|
||||
|
||||
export const installedMeasure = pgTable(
|
||||
"installed_measure",
|
||||
{
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
|
||||
uprn: bigint("uprn", { mode: "bigint" }).notNull(),
|
||||
|
||||
measureType: measureTypeEnum("measure_type").notNull(),
|
||||
installedAt: timestamp("installed_at").defaultNow(),
|
||||
|
||||
// Impacts
|
||||
sapPoints: real("sap_points"),
|
||||
carbonSavings: real("carbon_savings"), // tonnes CO₂e / yr
|
||||
kwhSavings: real("kwh_savings"), // kWh / yr
|
||||
billSavings: real("bill_savings"), // £ / yr
|
||||
heatDemandSavings: real("heat_demand_savings"),
|
||||
|
||||
//
|
||||
source: text("source"), // e.g. "EPC", "Survey", "Installer"
|
||||
|
||||
// Soft delete / supersession
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_installed_measure_uprn").on(table.uprn),
|
||||
|
||||
index("idx_installed_measure_uprn_active")
|
||||
.on(table.uprn)
|
||||
.where(sql`${table.isActive} = true`),
|
||||
|
||||
index("idx_installed_measure_measure_type").on(table.measureType),
|
||||
|
||||
index("idx_installed_measure_uprn_measure")
|
||||
.on(table.uprn, table.measureType)
|
||||
.where(sql`${table.isActive} = true`),
|
||||
]
|
||||
);
|
||||
|
||||
export type Plan = InferModel<typeof plan, "select">;
|
||||
export type Recommendation = InferModel<typeof recommendation, "select">;
|
||||
export type PlanRecommendations = InferModel<
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { PropertyWithRelations } from "@/app/db/schema/property";
|
|||
import PropertyTable from "../components/PropertyTable";
|
||||
|
||||
import SummaryBox from "@/app/components/portfolio/SummaryBox";
|
||||
import { Component } from "lucide-react";
|
||||
|
||||
// We enfore caching of data for 60 seconds
|
||||
export const revalidate = 60;
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export function BreakdownChart({
|
|||
|
||||
// Baseline (stacked)
|
||||
rows.push({
|
||||
label: `${epc} (baseline)`,
|
||||
label: `${epc}`,
|
||||
[friendlyKeys.actual]: d.actual ?? 0,
|
||||
[friendlyKeys.estimated]: d.estimated ?? 0,
|
||||
[friendlyKeys.scenario]: 0,
|
||||
|
|
@ -114,6 +114,7 @@ export function BreakdownChart({
|
|||
stack={selected === "epc"}
|
||||
customTooltip={MyTooltip}
|
||||
className="h-64"
|
||||
showGridLines={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ export function ReportingClientArea({
|
|||
// ----------------------------------------
|
||||
// Scenario specific metrics that appear in the drawer (from API) and cannot be overlayed on baseline
|
||||
// ----------------------------------------
|
||||
|
||||
const scenarioSpecific = scenarioData
|
||||
? {
|
||||
constructionCost: scenarioData.construction_cost,
|
||||
|
|
@ -151,14 +152,14 @@ export function ReportingClientArea({
|
|||
contingency: scenarioData.contingency,
|
||||
funding: scenarioData.total_funding,
|
||||
costPerSap:
|
||||
scenarioData.construction_cost > 0
|
||||
? scenarioData.gross_per_unit /
|
||||
(scenarioData.avg_sap - (baseline.averages.avg_sap ?? 0))
|
||||
scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0
|
||||
? (scenarioData.construction_cost + scenarioData.pc_cost) /
|
||||
scenarioData.total_sap_uplift
|
||||
: 0,
|
||||
costPerCo2:
|
||||
scenarioData.construction_cost > 0
|
||||
? (scenarioData.construction_cost + scenarioData.pc_cost) /
|
||||
scenarioData.total_carbon
|
||||
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon)
|
||||
: 0,
|
||||
netCost: scenarioData.net_cost,
|
||||
grossPerUnit: scenarioData.gross_per_unit,
|
||||
|
|
@ -175,7 +176,6 @@ export function ReportingClientArea({
|
|||
scenarioData.n_units_upgraded,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Baseline stays baseline
|
||||
const activeMetrics = baseline;
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ export async function getAverages(
|
|||
e.lighting_cost_current +
|
||||
e.appliances_cost_current +
|
||||
e.gas_standing_charge +
|
||||
e.electricity_standing_charge
|
||||
e.electricity_standing_charge -
|
||||
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
|
||||
)::float AS avg_bills,
|
||||
AVG(e.primary_energy_consumption)::float AS avg_energy_consumption
|
||||
FROM property p
|
||||
|
|
@ -56,7 +57,8 @@ export async function getTotals(portfolioId: number): Promise<TotalMetrics> {
|
|||
e.lighting_cost_current +
|
||||
e.appliances_cost_current +
|
||||
e.gas_standing_charge +
|
||||
e.electricity_standing_charge
|
||||
e.electricity_standing_charge -
|
||||
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
|
||||
)::float AS total_bills
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
getRecommendations,
|
||||
getPlanMeta,
|
||||
getPlanFunding,
|
||||
getInstalledMeasuresByUprn,
|
||||
} from "../../utils";
|
||||
|
||||
export default async function Recommendations(props: {
|
||||
|
|
@ -14,6 +15,7 @@ export default async function Recommendations(props: {
|
|||
const recommendations = await getRecommendations(params.planId);
|
||||
const planMeta = await getPlanMeta(params.planId);
|
||||
const funding = await getPlanFunding(params.planId);
|
||||
const installedMeasures = await getInstalledMeasuresByUprn(propertyMeta.uprn);
|
||||
|
||||
return (
|
||||
<div className="leading-loose tracking-wider">
|
||||
|
|
@ -22,6 +24,7 @@ export default async function Recommendations(props: {
|
|||
propertyMeta={propertyMeta}
|
||||
planMeta={planMeta}
|
||||
funding={funding}
|
||||
installedMeasures={installedMeasures}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -61,11 +61,9 @@ function PlanCard({
|
|||
);
|
||||
}
|
||||
|
||||
export default async function RecommendationPlans(
|
||||
props: {
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
}
|
||||
) {
|
||||
export default async function RecommendationPlans(props: {
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
const plans = await getPlans(params.propertyId);
|
||||
|
|
@ -79,22 +77,12 @@ export default async function RecommendationPlans(
|
|||
<div className="max-w-3xl">
|
||||
{plans.map((plan, index) => {
|
||||
// We accumulate the cost and the sap points for only the default recommendations
|
||||
const totalEstimatedCost = plan.planRecommendations.reduce(
|
||||
(acc, rec) => {
|
||||
if (rec.recommendation.default) {
|
||||
return acc + rec.recommendation.estimatedCost;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
0
|
||||
);
|
||||
|
||||
const totalSapPoints = plan.planRecommendations.reduce((acc, rec) => {
|
||||
if (rec.recommendation.default) {
|
||||
return acc + rec.recommendation.sapPoints;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
const totalEstimatedCost = plan.costOfWorks || 0;
|
||||
|
||||
const totalSapPoints =
|
||||
(plan.postSapPoints || propertyMeta.currentSapPoints) -
|
||||
propertyMeta.currentSapPoints;
|
||||
|
||||
// Placeholder while we return 999 for all sap points
|
||||
const expectedSapPoints = Math.min(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
planRecommendations,
|
||||
plan,
|
||||
Plan,
|
||||
installedMeasure,
|
||||
} from "@/app/db/schema/recommendations";
|
||||
import { db } from "@/app/db/db";
|
||||
import { surveyDB } from "@/app/db/surveyDB/connection";
|
||||
|
|
@ -28,17 +29,31 @@ import {
|
|||
} from "@/app/db/schema/energy_assessments";
|
||||
import {
|
||||
fundingPackage,
|
||||
FundingPackageWithMeasures
|
||||
FundingPackageWithMeasures,
|
||||
} from "@/app/db/schema/funding";
|
||||
import { getUploadedFile, uploadedFiles, DB_REPORT_TYPES } from "@/app/db/surveyDB/schema/surveyDB";
|
||||
import {
|
||||
getUploadedFile,
|
||||
uploadedFiles,
|
||||
DB_REPORT_TYPES,
|
||||
} from "@/app/db/surveyDB/schema/surveyDB";
|
||||
|
||||
export type InstalledMeasureSummary = {
|
||||
measureType: string;
|
||||
installedAt: Date | null;
|
||||
sapPoints: number | null;
|
||||
carbonSavings: number | null;
|
||||
kwhSavings: number | null;
|
||||
billSavings: number | null;
|
||||
};
|
||||
|
||||
export async function getEnergyAssessmentFromS3(s3Uri: string): Promise<any> {
|
||||
const url = new URL(s3Uri);
|
||||
|
||||
const bucketMatch = url.hostname.match(/^(.+)\.s3/);
|
||||
const bucket = bucketMatch?.[1];
|
||||
const key = url.pathname.startsWith("/") ? url.pathname.slice(1) : url.pathname;
|
||||
const key = url.pathname.startsWith("/")
|
||||
? url.pathname.slice(1)
|
||||
: url.pathname;
|
||||
|
||||
if (!bucket || !key) {
|
||||
throw new Error("Could not extract bucket or key from URI");
|
||||
|
|
@ -65,7 +80,9 @@ type RecommendationList = {
|
|||
recommendation: Recommendation;
|
||||
}[];
|
||||
|
||||
export async function getPlanFunding(planId: string): Promise<FundingPackageWithMeasures[]> {
|
||||
export async function getPlanFunding(
|
||||
planId: string
|
||||
): Promise<FundingPackageWithMeasures[]> {
|
||||
const data = await db.query.fundingPackage.findMany({
|
||||
where: eq(fundingPackage.planId, BigInt(planId)),
|
||||
with: {
|
||||
|
|
@ -80,13 +97,22 @@ export async function getPlanFunding(planId: string): Promise<FundingPackageWith
|
|||
return data;
|
||||
}
|
||||
|
||||
export async function getDocument(
|
||||
{uprn, documentType}: {uprn: string; documentType: typeof DB_REPORT_TYPES[number]}
|
||||
): Promise<getUploadedFile> {
|
||||
export async function getDocument({
|
||||
uprn,
|
||||
documentType,
|
||||
}: {
|
||||
uprn: string;
|
||||
documentType: (typeof DB_REPORT_TYPES)[number];
|
||||
}): Promise<getUploadedFile> {
|
||||
// We get the latest entry for the given UPRN and document type, by s3JsonUploadTimestamp
|
||||
const data = await surveyDB.query.uploadedFiles.findFirst({
|
||||
where: and(eq(uploadedFiles.uprn, String(uprn)), eq(uploadedFiles.docType, documentType)),
|
||||
orderBy: (uploadedFiles, { desc }) => [desc(uploadedFiles.s3JsonUploadTimestamp)]
|
||||
where: and(
|
||||
eq(uploadedFiles.uprn, String(uprn)),
|
||||
eq(uploadedFiles.docType, documentType)
|
||||
),
|
||||
orderBy: (uploadedFiles, { desc }) => [
|
||||
desc(uploadedFiles.s3JsonUploadTimestamp),
|
||||
],
|
||||
});
|
||||
// We may not have an uploaded document so we return an empty array
|
||||
if (!data) {
|
||||
|
|
@ -146,7 +172,10 @@ export async function getRecommendations(
|
|||
}
|
||||
|
||||
// unnest the recommendations
|
||||
const recommendations = data.map((item) => item.recommendation);
|
||||
// We drop measures that are already installed
|
||||
const recommendations = data
|
||||
.map((item) => item.recommendation)
|
||||
.filter((rec) => !rec.alreadyInstalled);
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
|
@ -177,20 +206,20 @@ export async function getPlans(propertyId: string): Promise<PlanRelation[]> {
|
|||
const data = await db.query.plan.findMany({
|
||||
where: eq(plan.propertyId, BigInt(propertyId)),
|
||||
orderBy: [desc(plan.createdAt)],
|
||||
with: {
|
||||
planRecommendations: {
|
||||
columns: {},
|
||||
with: {
|
||||
recommendation: {
|
||||
columns: {
|
||||
estimatedCost: true,
|
||||
sapPoints: true,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// with: {
|
||||
// planRecommendations: {
|
||||
// columns: {},
|
||||
// with: {
|
||||
// recommendation: {
|
||||
// columns: {
|
||||
// estimatedCost: true,
|
||||
// sapPoints: true,
|
||||
// default: true,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
|
|
@ -456,3 +485,26 @@ export function formatHeatDemandFeatures(
|
|||
// },
|
||||
];
|
||||
}
|
||||
|
||||
export async function getInstalledMeasuresByUprn(
|
||||
uprn: number
|
||||
): Promise<InstalledMeasureSummary[]> {
|
||||
const data = await db
|
||||
.select({
|
||||
measureType: installedMeasure.measureType,
|
||||
installedAt: installedMeasure.installedAt,
|
||||
sapPoints: installedMeasure.sapPoints,
|
||||
carbonSavings: installedMeasure.carbonSavings,
|
||||
kwhSavings: installedMeasure.kwhSavings,
|
||||
billSavings: installedMeasure.billSavings,
|
||||
})
|
||||
.from(installedMeasure)
|
||||
.where(
|
||||
and(
|
||||
eq(installedMeasure.uprn, BigInt(uprn)),
|
||||
eq(installedMeasure.isActive, true)
|
||||
)
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export const uploadCsvSchema = baseFormSchema.extend({
|
|||
},
|
||||
z.union([z.number(), z.null()]).optional()
|
||||
),
|
||||
enforce_fabric_first: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type UploadCsvFormValues = z.infer<typeof uploadCsvSchema>;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Transition,
|
||||
TransitionChild,
|
||||
} from "@headlessui/react";
|
||||
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
|
||||
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
|
@ -250,6 +251,7 @@ export function useUploadCsvPlan({
|
|||
onSuccessRedirect,
|
||||
fileType,
|
||||
fileFormat,
|
||||
enforceFabricFirst,
|
||||
}: {
|
||||
file: File;
|
||||
portfolioId: string;
|
||||
|
|
@ -266,6 +268,7 @@ export function useUploadCsvPlan({
|
|||
onSuccessRedirect: (path: string) => void;
|
||||
fileType: "csv" | "xlsx";
|
||||
fileFormat: "domna_asset_list" | null;
|
||||
enforceFabricFirst: boolean;
|
||||
}) {
|
||||
const session = useSession();
|
||||
const userId = String(session.data?.user.dbId);
|
||||
|
|
@ -308,6 +311,7 @@ export function useUploadCsvPlan({
|
|||
ashp_cop: Number(ashpCop),
|
||||
file_type: fileType, // Pass the file type for backend processing
|
||||
file_format: fileFormat,
|
||||
enforce_fabric_first: enforceFabricFirst,
|
||||
};
|
||||
|
||||
const triggerRes = await fetch("/api/plan/trigger", {
|
||||
|
|
@ -421,6 +425,7 @@ export default function UploadCsvModal({
|
|||
selectedSheet,
|
||||
sheetCount: sheetCounts[selectedSheet] || 0,
|
||||
onSuccessRedirect: (path) => router.push(path),
|
||||
enforceFabricFirst: form.watch("enforce_fabric_first"),
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async () => {
|
||||
|
|
@ -733,6 +738,38 @@ export default function UploadCsvModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/*enforceFabricFirst checkbox */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enforce_fabric_first"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-6">
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<FormControl>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
// disabled={selectedScenario !== NEW_SENTINEL} // Always editable
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
Enforce Fabric First
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
Require insulation and fabric upgrades before
|
||||
heating measures are recommended.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown mt-2" />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-auto pt-4 flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -508,6 +508,7 @@ export async function getProperties(
|
|||
LEFT JOIN recommendation r
|
||||
ON r.id = pr.recommendation_id
|
||||
AND r.default = true
|
||||
and r.already_installed = false
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
${combinedWhere}
|
||||
GROUP BY
|
||||
|
|
|
|||
|
|
@ -136,6 +136,10 @@ export function formatDateTime(dateTimeString: string | Date): string {
|
|||
export function formatNumber(number: number): string {
|
||||
if (number === 0) return "0";
|
||||
|
||||
if (number < 1) {
|
||||
return number.toFixed(2);
|
||||
}
|
||||
|
||||
const suffixes = ["", "k", "m", "b", "t"];
|
||||
const suffixIndex = Math.floor(Math.log10(Math.abs(number)) / 3);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue