Merge branch 'main' of github.com:Hestia-Homes/assessment-model into feature/upload_a_file_to_s2_bucket_via_lambda

This commit is contained in:
Jun-te Kim 2025-08-05 15:32:21 +00:00
commit 3986e99887
9 changed files with 238 additions and 136 deletions

View file

@ -1,28 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { MeasureKeyEnum } from "@/app/db/schema/recommendations";
import { PortfolioGoal } from "@/app/db/schema/portfolio";
const PresignedUrlBodySchema = z.object({
portfolio_id: z.string(),
housing_type: z.enum(["Social", "Private"]),
goal: z.enum(["Increasing EPC", "Reduce energy consumption"]),
goal_value: z.string(),
trigger_file_path: z.string(),
valuation_file_path: z.string(),
multi_plan: z.boolean().optional(),
budget: z.number().optional().nullable(),
scenario_name: z.string().optional(),
event_type: z.enum(["remote_assessment"]).optional(),
// inclusions is a list of measures, where the values are in measuresList
inclusions: z.array(MeasureKeyEnum).optional(),
exclusions: z.array(MeasureKeyEnum).optional(),
already_installed_file_path: z.string().optional(),
// optional scenario_id to link the plan to an existing scenario
scenario_id: z.string().optional().nullable(),
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
});
const PresignedUrlBodySchema = z
.object({
portfolio_id: z.string(),
housing_type: z.enum(["Social", "Private"]),
goal: z.enum(PortfolioGoal),
goal_value: z.string().nullable(), // Nullable to handle cases where goal_value is not applicable
trigger_file_path: z.string(),
valuation_file_path: z.string(),
multi_plan: z.boolean().optional(),
budget: z.number().optional().nullable(),
scenario_name: z.string().optional(),
sheet_count: z.number().optional(), // Number of rows in the selected sheet
event_type: z.enum(["remote_assessment"]).optional(),
ashp_cop: z.number().optional(),
// inclusions is a list of measures, where the values are in measuresList
inclusions: z.array(MeasureKeyEnum).optional(),
exclusions: z.array(MeasureKeyEnum).optional(),
already_installed_file_path: z.string().optional(),
// optional scenario_id to link the plan to an existing scenario
scenario_id: z.string().optional().nullable(),
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
})
.refine((data) => data.goal !== "Increasing EPC" || !!data.goal_value, {
path: ["goal_value"],
message: "Target EPC Rating is required when goal is Increasing EPC",
});
export async function POST(request: NextRequest) {
// For the moment, this api specifically handles uploads of csvs

View file

@ -23,7 +23,34 @@ interface ToolbarProps {
}
const navigationMenuTriggerStyle = cva(
"bg-gray-50 cursor-pointer group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-200 hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-gray-200"
[
"bg-gray-50",
"cursor-pointer",
"group",
"inline-flex",
"h-10",
"w-max",
"items-center",
"justify-center",
"rounded-md",
"bg-background",
"px-4",
"py-2",
"text-sm",
"font-medium",
"transition-colors",
"hover:bg-gray-200",
"hover:text-accent-foreground",
"focus:bg-accent",
"focus:text-accent-foreground",
"focus:outline-none",
"disabled:pointer-events-none",
"disabled:opacity-50",
"data-[active]:bg-accent/50",
"data-[state=open]:bg-gray-200",
//
"text-gray-900",
].join(" ")
);
export function Toolbar({ propertyId, portfolioId }: ToolbarProps) {

View file

@ -65,7 +65,7 @@ export default function AddNewDropDown({
return (
<NavigationMenuItem>
<NavigationMenuTrigger className="bg-gray-50">
<NavigationMenuTrigger className="bg-gray-50 text-gray-900">
Add New
</NavigationMenuTrigger>
<NavigationMenuContent>

View file

@ -94,7 +94,7 @@ function SummaryBox({ scenarios, numProperties }: SummaryBoxProps) {
return (
<div className="p-6 bg-white rounded-lg leading-relaxed">
<h2 className="text-2xl font-bold mb-4 text-brandblue text-center">
<h2 className="text-2xl font-bold mb-4 text-brandblue text-center ">
Portfolio Summary
</h2>
<div className="mb-4 flex items-center justify-center">
@ -102,7 +102,7 @@ function SummaryBox({ scenarios, numProperties }: SummaryBoxProps) {
id="scenario-select"
value={selectedScenarioId}
onChange={(e) => handleScenarioChange(e.target.value)}
className="p-2 border rounded w-full"
className="p-2 border rounded w-full text-black"
>
{scenarios.map((scenario) => (
<option key={String(scenario.id)} value={String(scenario.id)}>

View file

@ -25,7 +25,7 @@ interface ToolbarProps {
}
const navigationMenuTriggerStyle = cva(
"bg-gray-50 cursor-pointer group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-200 hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-gray-200 "
"bg-gray-50 cursor-pointer group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-200 hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-gray-200 text-gray-900"
);
export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {

View file

@ -199,7 +199,7 @@ const SummaryTable = ({
}}
/>
<div className="overflow-x-auto shadow-md sm:rounded-lg">
<div className="text-black overflow-x-auto shadow-md sm:rounded-lg">
<Table className="min-w-full">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

View file

@ -1,6 +1,6 @@
// formSchemas.ts
import * as z from "zod";
import { MeasureKeyEnum } from "@/app/db/schema/recommendations";
import { MeasureKeyEnum, HousingType } from "@/app/db/schema/recommendations";
export const baseFormSchema = z.object({
measures: z.array(MeasureKeyEnum).min(1, "At least one measure is required"),
@ -8,18 +8,34 @@ export const baseFormSchema = z.object({
export type BaseFormValues = z.infer<typeof baseFormSchema>;
export const RemoteAssessmentFormSchema = baseFormSchema.extend({
scenario: z.string().min(1),
goal: z.string().min(1),
goalValue: z.string().min(1),
housingType: z.string().min(1),
addressLineOne: z.string().min(1),
postcode: z.string().min(1),
uprn: z.number().min(1, "UPRN must be a valid number"),
valuation: z.number().min(1, "Valuation must be a valid number"),
propertyType: z.string().nullable(),
builtForm: z.string().nullable(),
});
export const RemoteAssessmentFormSchema = baseFormSchema
.extend({
scenario: z.string().min(1),
goal: z.string().min(1),
// goalValue: z.string().min(1),
goalValue: z.string().optional(),
budget: z.number().optional(),
housingType: z.enum(HousingType),
addressLineOne: z.string().min(1),
postcode: z.string().min(1),
uprn: z.number().min(1, "UPRN must be a valid number"),
valuation: z.number().min(1, "Valuation must be a valid number"),
propertyType: z.string().nullable(),
builtForm: z.string().nullable(),
})
.refine((data) => data.goal !== "Increasing EPC" || !!data.goalValue, {
path: ["goalValue"],
message: "Target EPC Rating is required when goal is Increasing EPC",
})
.refine(
(data) =>
data.goal === "Increasing EPC" ||
(typeof data.budget === "number" && data.budget > 0),
{
path: ["budget"],
message: "Budget is required for this goal",
}
);
export type RemoteAssessmentFormValues = z.infer<
typeof RemoteAssessmentFormSchema

View file

@ -119,9 +119,14 @@ const selectGoalOptions = [
disabled: false,
},
{
label: "Reduce energy consumption",
value: "Reduce energy consumption",
disabled: true,
label: "Energy Savings",
value: "Energy Savings",
disabled: false,
},
{
label: "Reducing CO2 emissions",
value: "Reducing CO2 emissions",
disabled: false,
},
];
@ -147,7 +152,7 @@ interface EngineTriggerBody {
portfolio_id: string;
housing_type: string;
goal: string;
goal_value: string;
goal_value: string | null;
trigger_file_path: string;
already_installed_file_path: string;
patches_file_path: string;
@ -155,7 +160,7 @@ interface EngineTriggerBody {
valuation_file_path: string;
scenario_name: string;
multi_plan: boolean;
budget: null;
budget: number | null;
event_type: string;
inclusions: (typeof measuresList)[number][];
scenario_id?: string | null;
@ -357,12 +362,18 @@ function useCreateRemoteAssessment({
async function triggerEngine(data: RemoteAssessmentFormValues) {
try {
// Goal value should not be missing at this point
if (data.goal === "Increasing EPC" && !data.goalValue) {
throw new Error("Goal value is required");
}
const triggerBody: EngineTriggerBody = {
scenario_id: scenarioId === "__new__" ? null : scenarioId,
portfolio_id: portfolioId,
housing_type: data.housingType,
goal: data.goal,
goal_value: data.goalValue,
// We only send goal_value if the goal is "Increasing EPC"
goal_value: data.goalValue || null,
trigger_file_path: assetListFileKey,
already_installed_file_path: "",
patches_file_path: "",
@ -371,7 +382,8 @@ function useCreateRemoteAssessment({
scenario_name: data.scenario,
inclusions: data.measures,
multi_plan: true,
budget: null,
// If the goal is "Increasing EPC", we don't send a budget
budget: data.budget || null,
event_type: "remote_assessment",
};
@ -461,10 +473,11 @@ export default function RemoteAssessmentModal({
housingType: "",
goal: "",
goalValue: "",
budget: undefined,
addressLineOne: "",
postcode: "",
uprn: undefined, // Must match expected type
valuation: undefined, // Must match expected type
uprn: undefined,
valuation: undefined,
propertyType: null,
builtForm: null,
measures: measuresList,
@ -474,6 +487,7 @@ export default function RemoteAssessmentModal({
const { isValid, isSubmitting } = formState;
const measures = form.watch("measures");
const goal = form.watch("goal");
const {
handleSubmit: triggerAssessment,
@ -657,32 +671,73 @@ export default function RemoteAssessmentModal({
)}
/>
{/* Goal Value */}
<FormField
control={form.control}
name="goalValue"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-800">
Goal Value
</FormLabel>
<FormControl>
{selectedScenario === NEW_SENTINEL ? (
<SelectDropdown
options={goalValueOptions}
selectedOption={field.value}
onSelectOption={(opt) =>
field.onChange(opt.value)
}
/>
) : (
<Input value={field.value} disabled />
{goal && (
<>
{goal === "Increasing EPC" && (
<FormField
control={form.control}
name="goalValue"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-800">
Target EPC Rating
</FormLabel>
<FormControl>
{selectedScenario === NEW_SENTINEL ? (
<SelectDropdown
options={goalValueOptions}
selectedOption={field.value || ""}
onSelectOption={(opt) =>
field.onChange(opt.value)
}
/>
) : (
<Input value={field.value} disabled />
)}
</FormControl>
<FormMessage className="text-brandbrown" />
</FormItem>
)}
</FormControl>
<FormMessage className="text-brandbrown" />
</FormItem>
)}
/>
/>
)}
{/* ✅ Budget shows for ALL goals but is only mandatory when goal != Increasing EPC */}
<FormField
control={form.control}
name="budget"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-800">
{/* We mark budget as (optional) when the goal is increasing EPC*/}
Budget (£){" "}
{goal === "Increasing EPC" && (
<span className="text-sm text-gray-500">
(optional)
</span>
)}
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter budget"
{...field}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value)
)
}
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
/>
</FormControl>
<FormMessage className="text-brandbrown" />
</FormItem>
)}
/>
</>
)}
</div>
</>
)}

View file

@ -75,6 +75,7 @@ export async function validateClientFile(file: File): Promise<{
file_type?: "csv" | "xlsx";
isStandardised?: boolean;
sheetNames?: string[];
sheetRowCounts?: Record<string, number>;
fileFormat?: "domna_asset_list" | null;
}> {
const sizeMB = file.size / (1024 * 1024);
@ -114,45 +115,78 @@ export async function validateClientFile(file: File): Promise<{
let isStandardised = false;
let sheetNames: string[] = [];
let sheetRowCounts: Record<string, number> = {};
if (fileType === "csv") {
const text = await file.text();
const lines = text.split("\n").map((line) => line.split(","));
const headers = lines[0].map((h) => h.trim());
isStandardised = headers.includes("domna_property_id");
return {
isValid: true,
file_type: "csv",
isStandardised,
fileFormat: isStandardised ? "domna_asset_list" : null,
};
}
if (fileType === "xlsx") {
const arrayBuffer = await file.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, {
type: "array",
sheets: ["Standardised Asset List"],
});
const workbook = XLSX.read(arrayBuffer, { type: "array" });
sheetNames = workbook.SheetNames;
const worksheet = workbook.Sheets["Standardised Asset List"];
if (worksheet) {
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
const headers = jsonData[0] as string[];
isStandardised = headers?.includes("domna_property_id");
for (const sheetName of sheetNames) {
const worksheet = workbook.Sheets[sheetName];
if (!worksheet) continue;
const jsonData = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
}) as any[][];
if (sheetName === "Standardised Asset List") {
const headers = jsonData[0] as string[];
isStandardised = headers?.includes("domna_property_id");
}
// Count data rows, ignoring trailing blank rows and headers
const rowCount = jsonData
.slice(1) // skip header
.filter((row) =>
row.some((cell) => cell !== undefined && cell !== null && cell !== "")
).length;
sheetRowCounts[sheetName] = rowCount;
}
console.log("Sheet Counts", sheetRowCounts);
if (!isStandardised) {
return {
isValid: false,
error: "Excel file is not a valid domna asset list.",
file_type: "xlsx",
sheetNames,
sheetRowCounts,
};
}
return {
isValid: true,
file_type: "xlsx",
isStandardised,
fileFormat: "domna_asset_list",
sheetNames,
sheetRowCounts,
};
}
// fallback
return {
isValid: true,
file_type: fileType,
isStandardised,
sheetNames: fileType === "xlsx" && isStandardised ? sheetNames : undefined,
fileFormat: isStandardised ? "domna_asset_list" : null,
isStandardised: false,
};
}
@ -198,7 +232,9 @@ export function useUploadCsvPlan({
goalValue,
housingType,
scenarioName,
scenarioId,
selectedSheet,
sheetCount,
budget,
ashpCop,
measures,
@ -214,7 +250,9 @@ export function useUploadCsvPlan({
ashpCop: number;
housingType: string;
scenarioName: string;
scenarioId?: string | null;
selectedSheet: string;
sheetCount: number;
measures: (typeof measuresList)[number][];
onSuccessRedirect: (path: string) => void;
fileType: "csv" | "xlsx";
@ -241,7 +279,7 @@ export function useUploadCsvPlan({
await uploadFileToS3({ presignedUrl: url, file });
const body = {
scenario_id: scenarioName === NEW_SENTINEL ? null : scenarioName,
scenario_id: scenarioName === NEW_SENTINEL ? null : scenarioId,
portfolio_id: portfolioId,
housing_type: housingType,
goal: goal,
@ -257,7 +295,8 @@ export function useUploadCsvPlan({
inclusions: measures,
event_type: "remote_assessment",
sheet_name: selectedSheet,
ashp_cop: ashpCop,
sheet_count: sheetCount,
ashp_cop: Number(ashpCop),
file_type: fileType, // Pass the file type for backend processing
file_format: fileFormat,
};
@ -321,6 +360,7 @@ export default function UploadCsvModal({
const [fileFormat, setFileFormat] = useState<"domna_asset_list" | null>(null);
const [isValidating, setIsValidating] = useState(false);
const [fileIsValid, setFileIsValid] = useState<boolean | null>(null);
const [sheetCounts, setSheetCounts] = useState<Record<string, number>>({});
const scenarioOptions = useMemo(
() =>
@ -363,11 +403,13 @@ export default function UploadCsvModal({
budget: form.watch("budget") || null,
ashpCop: form.watch("ashpCop"),
scenarioName: form.watch("scenario"),
scenarioId: selectedScenario === NEW_SENTINEL ? null : selectedScenario,
measures: form.watch("measures"),
fileType: fileType,
fileFormat: fileFormat,
portfolioId,
selectedSheet,
sheetCount: sheetCounts[selectedSheet] || 0,
onSuccessRedirect: (path) => router.push(path),
});
@ -442,6 +484,7 @@ export default function UploadCsvModal({
setCsvFile(file);
setFileType(result.file_type!);
setFileFormat(result.fileFormat ?? null);
setSheetCounts(result.sheetRowCounts || {});
if (result.sheetNames) {
setSheetNames(result.sheetNames);
@ -657,53 +700,6 @@ export default function UploadCsvModal({
)}
/>
</div>
{/* <FormField
control={form.control}
name="cop"
render={({ field }) => (
<FormItem>
<FormLabel>Heat pump COP</FormLabel>
<FormControl>
<input
type="number"
step="0.1"
{...field}
disabled={selectedScenario !== "__new__"}
className={`w-full rounded-lg border ${
selectedScenario !== "__new__"
? "bg-gray-100"
: "bg-white"
} border-brandbrown px-4 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brandbrown`}
/>
</FormControl>
</FormItem>
)}
/> */}
{/* <FormField
control={form.control}
name="budget"
render={({ field }) => (
<FormItem>
<FormLabel>Budget (optional)</FormLabel>
<FormControl>
<input
type="text"
inputMode="decimal"
value={field.value ?? ""}
onChange={(e) =>
field.onChange(
e.target.value === ""
? null
: parseFloat(e.target.value)
)
}
/>
</FormControl>
</FormItem>
)}
/> */}
</>
)}