mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Merge branch 'main' of https://github.com/Hestia-Homes/assessment-model into stefhomepage
This commit is contained in:
commit
34d8b1d3ca
27 changed files with 3660 additions and 30 deletions
|
|
@ -2,6 +2,14 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next
|
|||
|
||||
## Getting Started
|
||||
|
||||
When first getting set up you'll firstly want to install the existing dependencies. To do this, simply run
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
yarn install
|
||||
```
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
7
public/.well-known/microsoft-identity-association.json
Normal file
7
public/.well-known/microsoft-identity-association.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"associatedApplications": [
|
||||
{
|
||||
"applicationId": "069e75ee-ba54-45ff-ba77-a06f29c0e21c"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import AzureADProvider from "next-auth/providers/azure-ad";
|
||||
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
|
||||
import { db } from "@/app/db/db";
|
||||
import { user as userTable, User } from "@/app/db/schema/users";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const { GOOGLE_CLIENT_ID = "", GOOGLE_CLIENT_SECRET = "" } = process.env;
|
||||
const {
|
||||
AZURE_AD_CLIENT_ID = "",
|
||||
AZURE_AD_CLIENT_SECRET = "",
|
||||
AZURE_AD_TENANT_ID = "",
|
||||
AZURE_AD_B2C_TENANT_NAME = "",
|
||||
AZURE_AD_B2C_CLIENT_ID = "",
|
||||
AZURE_AD_B2C_CLIENT_SECRET = "",
|
||||
AZURE_AD_B2C_PRIMARY_USER_FLOW = "",
|
||||
} = process.env;
|
||||
|
||||
type OauthProvider = "google";
|
||||
|
|
@ -32,10 +35,50 @@ export const AuthOptions: NextAuthOptions = {
|
|||
},
|
||||
},
|
||||
}),
|
||||
AzureADProvider({
|
||||
clientId: AZURE_AD_CLIENT_ID,
|
||||
clientSecret: AZURE_AD_CLIENT_SECRET,
|
||||
tenantId: AZURE_AD_TENANT_ID,
|
||||
AzureADB2CProvider({
|
||||
tenantId: AZURE_AD_B2C_TENANT_NAME,
|
||||
clientId: AZURE_AD_B2C_CLIENT_ID,
|
||||
clientSecret: AZURE_AD_B2C_CLIENT_SECRET,
|
||||
primaryUserFlow: AZURE_AD_B2C_PRIMARY_USER_FLOW,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid profile offline_access",
|
||||
prompt: "login",
|
||||
},
|
||||
},
|
||||
}),
|
||||
CredentialsProvider({
|
||||
name: "Email Login",
|
||||
credentials: {
|
||||
email: {
|
||||
label: "Email",
|
||||
type: "email",
|
||||
},
|
||||
},
|
||||
async authorize(credentials, req) {
|
||||
if (!credentials || !credentials.email) {
|
||||
throw new Error("Email is required");
|
||||
}
|
||||
|
||||
const { email } = credentials;
|
||||
|
||||
// Query the database to find the user by email
|
||||
const dbUser = await db
|
||||
.select()
|
||||
.from(userTable)
|
||||
.where(eq(userTable.email, email));
|
||||
|
||||
// If the email exists, return the user object (no password check)
|
||||
if (dbUser.length === 1) {
|
||||
return {
|
||||
id: dbUser[0].id.toString(), // Convert bigint to string to avoid serialization issues
|
||||
email: dbUser[0].email,
|
||||
dbId: dbUser[0].id.toString(), // Ensure dbId is added and is a string
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
],
|
||||
pages: {
|
||||
|
|
|
|||
81
src/app/api/energy-assessment-documents/route.ts
Normal file
81
src/app/api/energy-assessment-documents/route.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// pages/api/get-presigned-url.ts
|
||||
import S3 from "aws-sdk/clients/s3";
|
||||
import STS from "aws-sdk/clients/sts"; // Import STS for temporary credentials
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
// Validate the input
|
||||
const PresignedUrlBodySchema = z.object({
|
||||
fileKey: z.string(),
|
||||
});
|
||||
|
||||
// Function to get temporary credentials using GetSessionToken
|
||||
async function getTemporaryCredentials() {
|
||||
const sts = new STS({
|
||||
accessKeyId: process.env.RETROFIT_ENERGY_ASSESSMENTS_AWS_ACCESS_KEY, // Your permanent access key
|
||||
secretAccessKey: process.env.ENERGY_ASSESSMENTS_AWS_SECRET, // Your permanent secret access key
|
||||
region: process.env.PRESIGN_AWS_REGION,
|
||||
});
|
||||
|
||||
try {
|
||||
// Request temporary credentials with GetSessionToken
|
||||
const data = await sts.getSessionToken({ DurationSeconds: 900 }).promise(); // Token valid for 15 minutes
|
||||
|
||||
// Check if credentials are present
|
||||
if (!data.Credentials) {
|
||||
throw new Error("Failed to retrieve temporary credentials");
|
||||
}
|
||||
|
||||
return data.Credentials;
|
||||
} catch (error) {
|
||||
console.error("Error fetching temporary credentials:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// API handler
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
let validatedBody;
|
||||
|
||||
try {
|
||||
validatedBody = PresignedUrlBodySchema.parse(body);
|
||||
} catch (error) {
|
||||
console.error("Invalid input: ", error);
|
||||
return new NextResponse(JSON.stringify({ msg: "Invalid input" }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Get temporary credentials using GetSessionToken
|
||||
const credentials = await getTemporaryCredentials();
|
||||
|
||||
// Initialize S3 with temporary credentials
|
||||
const s3 = new S3({
|
||||
signatureVersion: "v4",
|
||||
region: process.env.PRESIGN_AWS_REGION,
|
||||
accessKeyId: credentials.AccessKeyId,
|
||||
secretAccessKey: credentials.SecretAccessKey,
|
||||
sessionToken: credentials.SessionToken, // Include session token
|
||||
});
|
||||
|
||||
const { fileKey } = validatedBody;
|
||||
|
||||
// Generate presigned URL valid for 5 minutes
|
||||
const preSignedUrl = await s3.getSignedUrl("getObject", {
|
||||
Bucket: process.env.RETROFIT_ENERGY_ASSESSMENTS_BUCKET,
|
||||
Key: fileKey,
|
||||
Expires: 5 * 60, // URL expiration in seconds
|
||||
});
|
||||
|
||||
return new NextResponse(JSON.stringify({ url: preSignedUrl }), {
|
||||
status: 200,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating presigned URL:", error);
|
||||
return new NextResponse(JSON.stringify({ msg: "Internal server error" }), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
export default function Beta() {
|
||||
return <div>This application is not ready for general usage</div>;
|
||||
return <div>You do not have access to this application currently</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,3 +18,31 @@ export function TanButton({
|
|||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrandButton({
|
||||
label,
|
||||
onClick,
|
||||
backgroundColor,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: Dispatch<SetStateAction<any>>;
|
||||
backgroundColor: "brandblue" | "brandgold"; // Restrict backgroundColor to these two options
|
||||
}) {
|
||||
// Dictionary to map background colors to hover colors
|
||||
const hoverColors = {
|
||||
brandblue: "hover:bg-hoverblue",
|
||||
brandgold: "hover:bg-hovergold",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex justify-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2
|
||||
${backgroundColor === "brandblue" ? "bg-brandblue" : "bg-brandgold"}
|
||||
${hoverColors[backgroundColor]}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ export default function StatusBadge({
|
|||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Badge className={statusConfig.class}>{statusConfig.text}</Badge>
|
||||
<Badge
|
||||
className={`truncate overflow-hidden whitespace-nowrap ${statusConfig.class}`}
|
||||
>
|
||||
{statusConfig.text}
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div>
|
||||
|
|
@ -60,6 +64,12 @@ const statusColor: {
|
|||
hoverText: "This portfolio is currently in the assessment stage",
|
||||
propertyHoverText: "This property is currently in the assessment stage",
|
||||
},
|
||||
survey: {
|
||||
class: "bg-brandblue hover:bg-hoverblue",
|
||||
text: "survey",
|
||||
hoverText: "This portfolio is currently in the survey stage",
|
||||
propertyHoverText: "This property is currently in the survey stage",
|
||||
},
|
||||
tendering: {
|
||||
class: "bg-emerald-500 hover:bg-emerald-500",
|
||||
text: "Tendering",
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ export default function EpcCard({
|
|||
expected = false,
|
||||
kwh = null,
|
||||
carbon = null,
|
||||
sap = null,
|
||||
}: {
|
||||
epcRating: string;
|
||||
fullMargin: boolean;
|
||||
expected?: boolean;
|
||||
kwh?: number | null;
|
||||
carbon?: number | null;
|
||||
sap?: string | null;
|
||||
}) {
|
||||
let marginClass = "";
|
||||
if (fullMargin) {
|
||||
|
|
@ -30,28 +32,35 @@ export default function EpcCard({
|
|||
return (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center p-4 shadow rounded-md max-w-xl justify-start text-gray-100 " +
|
||||
"flex flex-col items-center p-4 shadow rounded-md max-w-xl justify-start text-gray-100 " +
|
||||
bgColour
|
||||
}
|
||||
>
|
||||
<div className="text-xl font-bold mb-4 text-center">{title}</div>
|
||||
<div className="text-6xl font-bold ">{epcRating}</div>
|
||||
|
||||
{/* EPC Rating and SAP Rating */}
|
||||
<div className="flex items-baseline justify-center">
|
||||
{/* EPC Rating */}
|
||||
<div className="text-6xl font-bold">{epcRating}</div>
|
||||
|
||||
{/* SAP Rating (only if sap is provided) */}
|
||||
{sap !== null && (
|
||||
<div className="text-lg font-medium ml-2 text-gray-100">{sap}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(kwh || carbon) && (
|
||||
<table className="mt-6">
|
||||
<tbody>
|
||||
{kwh && (
|
||||
<tr>
|
||||
{" "}
|
||||
{/* Added vertical padding to each row */}
|
||||
<td className="text-gray-50">{kwh.toFixed(0)} kWh</td>
|
||||
</tr>
|
||||
)}
|
||||
{carbon && (
|
||||
<tr>
|
||||
{" "}
|
||||
<td className="text-gray-50 py-2">
|
||||
{carbon}t CO <sub>2</sub>
|
||||
{carbon}t CO<sub>2</sub>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const alreadyInstalledStyling =
|
|||
|
||||
const TitleMap = {
|
||||
mechanical_ventilation: "Mechanical Ventilation",
|
||||
trickle_vents: "Trickle Vents",
|
||||
sealing_open_fireplace: "Sealing Open Fireplace",
|
||||
low_energy_lighting: "Low Energy Lighting",
|
||||
// Walls
|
||||
|
|
@ -33,6 +34,7 @@ const TitleMap = {
|
|||
exposed_floor_insulation: "Exposed Floor Insulation",
|
||||
// Windows
|
||||
windows_glazing: "Window Glazing",
|
||||
mixed_glazing: "Mixed - Secondary and Double Glazing",
|
||||
// Solar pv
|
||||
solar_pv: "Solar Photovoltaic Panels System",
|
||||
// Heating
|
||||
|
|
@ -47,6 +49,8 @@ const TitleMap = {
|
|||
roof_insulation: "Roof Insulation",
|
||||
// Cylinder thermostat
|
||||
cylinder_thermostat: "Cylinder Thermostat",
|
||||
// Draught proofing
|
||||
draught_proofing: "Draught Proofing",
|
||||
};
|
||||
|
||||
type RecommendationCardProps = {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,21 @@ export default function RecommendationContainer({
|
|||
(rec: Recommendation) => rec.default
|
||||
) || emptyImpactState;
|
||||
|
||||
const defaultTrickleVentsRecommendations =
|
||||
categorizedRecommendations.trickle_vents?.find(
|
||||
(rec: Recommendation) => rec.default
|
||||
) || emptyImpactState;
|
||||
|
||||
const defaultMixedGlazingRecommendations =
|
||||
categorizedRecommendations.mixed_glazing?.find(
|
||||
(rec: Recommendation) => rec.default
|
||||
) || emptyImpactState;
|
||||
|
||||
const defaultDraughtProofingRecommendations =
|
||||
categorizedRecommendations.draught_proofing?.find(
|
||||
(rec: Recommendation) => rec.default
|
||||
) || emptyImpactState;
|
||||
|
||||
const [costMap, setCostMap] = useState<RecommendationMetricMap>({
|
||||
wall_insulation: defaultWallsRecommendations.estimatedCost || 0,
|
||||
floor_insulation: defaultFloorRecommendations.estimatedCost || 0,
|
||||
|
|
@ -145,6 +160,9 @@ export default function RecommendationContainer({
|
|||
defaultSecondaryHeatingRecommendations.estimatedCost || 0,
|
||||
cylinder_thermostat:
|
||||
defaultCylinderThermostatRecommendations.estimatedCost || 0,
|
||||
trickle_vents: defaultTrickleVentsRecommendations.estimatedCost || 0,
|
||||
mixed_glazing: defaultMixedGlazingRecommendations.estimatedCost || 0,
|
||||
draught_proofing: defaultDraughtProofingRecommendations.estimatedCost || 0,
|
||||
});
|
||||
|
||||
const [sapMap, setSapMap] = useState<RecommendationMetricMap>({
|
||||
|
|
@ -163,6 +181,9 @@ export default function RecommendationContainer({
|
|||
secondary_heating: defaultSecondaryHeatingRecommendations.sapPoints || 0,
|
||||
cylinder_thermostat:
|
||||
defaultCylinderThermostatRecommendations.sapPoints || 0,
|
||||
trickle_vents: defaultTrickleVentsRecommendations.sapPoints || 0,
|
||||
mixed_glazing: defaultMixedGlazingRecommendations.sapPoints || 0,
|
||||
draught_proofing: defaultDraughtProofingRecommendations.sapPoints || 0,
|
||||
});
|
||||
|
||||
const [labourDaysMap, setLabourDaysMap] = useState<RecommendationMetricMap>({
|
||||
|
|
@ -181,6 +202,9 @@ export default function RecommendationContainer({
|
|||
secondary_heating: defaultSecondaryHeatingRecommendations.labourDays || 0,
|
||||
cylinder_thermostat:
|
||||
defaultCylinderThermostatRecommendations.labourDays || 0,
|
||||
trickle_vents: defaultTrickleVentsRecommendations.labourDays || 0,
|
||||
mixed_glazing: defaultMixedGlazingRecommendations.labourDays || 0,
|
||||
draught_proofing: defaultDraughtProofingRecommendations.labourDays || 0,
|
||||
});
|
||||
|
||||
const [co2SavingsMap, setCo2SavingsMap] = useState<RecommendationMetricMap>({
|
||||
|
|
@ -204,6 +228,10 @@ export default function RecommendationContainer({
|
|||
defaultSecondaryHeatingRecommendations.co2EquivalentSavings || 0,
|
||||
cylinder_thermostat:
|
||||
defaultCylinderThermostatRecommendations.co2EquivalentSavings || 0,
|
||||
trickle_vents: defaultTrickleVentsRecommendations.co2EquivalentSavings || 0,
|
||||
mixed_glazing: defaultMixedGlazingRecommendations.co2EquivalentSavings || 0,
|
||||
draught_proofing:
|
||||
defaultDraughtProofingRecommendations.co2EquivalentSavings || 0,
|
||||
});
|
||||
|
||||
const [energyCostSavingsMap, setEnergyCostSavingsMap] =
|
||||
|
|
@ -228,6 +256,10 @@ export default function RecommendationContainer({
|
|||
defaultSecondaryHeatingRecommendations.energyCostSavings || 0,
|
||||
cylinder_thermostat:
|
||||
defaultCylinderThermostatRecommendations.energyCostSavings || 0,
|
||||
trickle_vents: defaultTrickleVentsRecommendations.energyCostSavings || 0,
|
||||
mixed_glazing: defaultMixedGlazingRecommendations.energyCostSavings || 0,
|
||||
draught_proofing:
|
||||
defaultDraughtProofingRecommendations.energyCostSavings || 0,
|
||||
});
|
||||
|
||||
const [kwhSavingsMap, setKwhSavingsMap] = useState<RecommendationMetricMap>({
|
||||
|
|
@ -246,6 +278,9 @@ export default function RecommendationContainer({
|
|||
secondary_heating: defaultSecondaryHeatingRecommendations.kwhSavings || 0,
|
||||
cylinder_thermostat:
|
||||
defaultCylinderThermostatRecommendations.kwhSavings || 0,
|
||||
trickle_vents: defaultTrickleVentsRecommendations.kwhSavings || 0,
|
||||
mixed_glazing: defaultMixedGlazingRecommendations.kwhSavings || 0,
|
||||
draught_proofing: defaultDraughtProofingRecommendations.kwhSavings || 0,
|
||||
});
|
||||
|
||||
const [totalEstimatedCost, setTotalEstimatedCost] = useState(
|
||||
|
|
|
|||
|
|
@ -39,6 +39,16 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) {
|
|||
</NavigationMenuLink>
|
||||
);
|
||||
|
||||
const energyAssessmentsReportButton = (
|
||||
<NavigationMenuLink
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/energy-assessment`}
|
||||
>
|
||||
<NewspaperIcon className="h-4 w-4 mr-2" />
|
||||
Energy Assessment
|
||||
</NavigationMenuLink>
|
||||
);
|
||||
|
||||
const solarAnalysisButton = (
|
||||
<NavigationMenuLink
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
|
|
@ -73,13 +83,7 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) {
|
|||
{preAssessmentReportButton}
|
||||
{solarAnalysisButton}
|
||||
{recommendationsButton}
|
||||
{/* <NavigationMenuLink
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/plan-optimiser`}
|
||||
>
|
||||
<LightBulbIcon className="h-4 w-4 mr-2" />
|
||||
Plan optimiser
|
||||
</NavigationMenuLink> */}
|
||||
{energyAssessmentsReportButton}
|
||||
<NavigationMenuItem
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
onClick={handleClickSettings}
|
||||
|
|
|
|||
71
src/app/components/signin/CredentialsButton.tsx
Normal file
71
src/app/components/signin/CredentialsButton.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useState, useEffect, SetStateAction } from "react";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export default function EmailSignInButton({
|
||||
error: initialError,
|
||||
}: {
|
||||
error: string | undefined;
|
||||
}) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState(initialError);
|
||||
|
||||
const handleSubmit = async (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
const res = await signIn("credentials", {
|
||||
email,
|
||||
});
|
||||
|
||||
if (res?.error) {
|
||||
setError("You are not a valid user.");
|
||||
} else {
|
||||
console.log("Login successful");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailChange = (e: {
|
||||
target: { value: SetStateAction<string> };
|
||||
}) => {
|
||||
setEmail(e.target.value);
|
||||
if (error) {
|
||||
setError(undefined); // Clear the error when the user starts typing
|
||||
}
|
||||
};
|
||||
|
||||
// Sync initial error state with server-side error prop
|
||||
useEffect(() => {
|
||||
setError(initialError);
|
||||
}, [initialError]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
{/* Wrapper to control width and layout */}
|
||||
<div className="flex items-center w-full space-x-1">
|
||||
{/* Email input field using shadcn input */}
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
className="flex-1 h-10 rounded-lg border-gray-300" // Full width input
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-10 w-10 bg-brandblue text-white hover:bg-hoverblue rounded-lg flex items-center justify-center" // Fixed size button
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Reserve space for the error message */}
|
||||
<div className="min-h-[3rem] text-center">
|
||||
{error && <p className="text-red-500">You are not a valid user.</p>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ const MicrosoftSignInButton = () => {
|
|||
<Button
|
||||
data-testid="microsoft-signin-btn"
|
||||
className="text-black hover:text-gray-400 text-xl hover:bg-gray-100 rounded-lg p-0"
|
||||
onClick={() => signIn("azure-ad", { callbackUrl })} // Note the provider ID "azure-ad" must match the one you've set in NextAuth config
|
||||
onClick={() => signIn("azure-ad-b2c", { callbackUrl })} // Note the provider ID "azure-ad" must match the one you've set in NextAuth config
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
|
|||
1
src/app/db/migrations/0096_married_umar.sql
Normal file
1
src/app/db/migrations/0096_married_umar.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TYPE "status" ADD VALUE 'survey';
|
||||
2939
src/app/db/migrations/meta/0096_snapshot.json
Normal file
2939
src/app/db/migrations/meta/0096_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -673,6 +673,13 @@
|
|||
"when": 1725474928372,
|
||||
"tag": "0095_sloppy_ikaris",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 96,
|
||||
"version": "5",
|
||||
"when": 1725897920431,
|
||||
"tag": "0096_married_umar",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -195,3 +195,17 @@ export const energyAssessmentDocuments = pgTable(
|
|||
|
||||
// Types for the new table
|
||||
export type EnergyAssessment = InferModel<typeof energyAssessment, "select">;
|
||||
export type EnergyAssessmentScenario = InferModel<
|
||||
typeof energyAssessmentScenarios,
|
||||
"select"
|
||||
>;
|
||||
export type EnergyAssessmentDocument = InferModel<
|
||||
typeof energyAssessmentDocuments,
|
||||
"select"
|
||||
>;
|
||||
|
||||
// We define a type for the energyassessment docments that embeds a scenario in
|
||||
// the document
|
||||
export type EnergyAssessmentDocumentWithScenario = EnergyAssessmentDocument & {
|
||||
scenario: EnergyAssessmentScenario | null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { InferModel } from "drizzle-orm";
|
|||
|
||||
export const PortfolioStatus: [string, ...string[]] = [
|
||||
"scoping",
|
||||
"survey",
|
||||
"assessment",
|
||||
"tendering",
|
||||
"project underway",
|
||||
|
|
|
|||
|
|
@ -185,7 +185,10 @@ export type RecommendationType =
|
|||
| "hot_water_tank_insulation"
|
||||
| "heating_control"
|
||||
| "secondary_heating"
|
||||
| "cylinder_thermostat";
|
||||
| "cylinder_thermostat"
|
||||
| "trickle_vents"
|
||||
| "mixed_glazing"
|
||||
| "draught_proofing";
|
||||
|
||||
export type UnnestedRecommendation = {
|
||||
quantity: number;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import {
|
||||
energyAssessmentDocuments,
|
||||
energyAssessmentScenarios,
|
||||
} from "./energy_assessments";
|
||||
// This script contains ALL relations for the database, used by drizzle-orm
|
||||
|
||||
import { relations } from "drizzle-orm";
|
||||
|
|
@ -139,3 +143,14 @@ export const nonIntrusiveSurveyNotesRelations = relations(
|
|||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Define a relation from a EnergyAssessmentDocument to EnergyAssessmentScenario. This is a many to one
|
||||
export const energyAssessmentDocumentsRelations = relations(
|
||||
energyAssessmentDocuments,
|
||||
({ one }) => ({
|
||||
scenario: one(energyAssessmentScenarios, {
|
||||
fields: [energyAssessmentDocuments.scenarioId],
|
||||
references: [energyAssessmentScenarios.id],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import { Inter } from "next/font/google";
|
|||
// If loading a variable font, you don't need to specify the font weight
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
|
|
|
|||
|
|
@ -2,16 +2,24 @@ import { getServerSession } from "next-auth/next";
|
|||
import { AuthOptions } from "./api/auth/[...nextauth]/route";
|
||||
import GoogleSignInButton from "./components/signin/GoogleSignInButton";
|
||||
import MicrosoftSignInButton from "./components/signin/MicrosoftSignInButton";
|
||||
import EmailSignInButton from "./components/signin/CredentialsButton";
|
||||
import { redirect } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
|
||||
export default async function Home() {
|
||||
export default async function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { error?: string };
|
||||
}) {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
|
||||
if (session?.user) {
|
||||
redirect("/home");
|
||||
}
|
||||
|
||||
// Extract the error parameter from the searchParams object
|
||||
const error = searchParams.error;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Left Half */}
|
||||
|
|
@ -23,7 +31,7 @@ export default async function Home() {
|
|||
|
||||
{/* Right Half */}
|
||||
<section className="w-1/2 flex items-center justify-center">
|
||||
<div className="w-full max-w-lg p-8 rounded-lg flex flex-col items-center justify-center">
|
||||
<div className="w-full max-w-lg p-8 rounded-lg flex flex-col items-center justify-center">
|
||||
<Image
|
||||
className="mb-8"
|
||||
src="/HestiaLogoWhite.png"
|
||||
|
|
@ -37,10 +45,15 @@ export default async function Home() {
|
|||
<div className="text-brandmidblue text-lg mb-4">
|
||||
Start managing your portfolios
|
||||
</div>
|
||||
|
||||
<div className="mb-2 min-w-[19rem]">
|
||||
{/* This width has been manually set to align the buttons but should be improved */}
|
||||
<EmailSignInButton error={error} />
|
||||
</div>
|
||||
<div className="text-md"> Sign in with a Social Account</div>
|
||||
<div className="mb-2">
|
||||
<MicrosoftSignInButton />
|
||||
</div>
|
||||
|
||||
<GoogleSignInButton />
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@/app/shadcn_components/ui/table";
|
||||
import { BrandButton } from "@/app/components/Buttons";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { DocumentType } from "@/app/db/schema/energy_assessments";
|
||||
|
||||
// Use the type directly from the array
|
||||
type Document = {
|
||||
id: bigint;
|
||||
documentType: (typeof DocumentType)[number]; // Create a union type from the array
|
||||
documentLocation: string; // S3 file key
|
||||
uploadedAt: Date;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
documents: Document[];
|
||||
allowedTypes: (typeof DocumentType)[number][]; // Use the union type for allowedTypes as well
|
||||
};
|
||||
|
||||
// Descriptions based on the document types
|
||||
const descriptions: { [key in (typeof DocumentType)[number]]: string } = {
|
||||
EPR: "Contains current energy performance of your home, and recommendations for improvement",
|
||||
"Condition Report":
|
||||
"Detailed report on property condition, including photographs",
|
||||
"Evidence Report": "Additional photographic evidence from the survey",
|
||||
"Summary Information": "Summarized survey outputs and site notes",
|
||||
"Floor Plan": "Layout of the property and room/window dimensions",
|
||||
"Scenario Draft EPC": "Draft EPC based on a given scenario",
|
||||
"Scenario Site Notes": "Site notes from the scenario analysis",
|
||||
};
|
||||
|
||||
// Fetch the presigned URL from the API
|
||||
async function generatePresignedUrl(fileKey: string) {
|
||||
const response = await fetch("/api/energy-assessment-documents", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ fileKey }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to generate presigned URL");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.url;
|
||||
}
|
||||
|
||||
export const DocumentsTable: React.FC<Props> = ({
|
||||
documents,
|
||||
allowedTypes,
|
||||
}) => {
|
||||
// Filter the documents based on the allowed types
|
||||
const relevantDocuments = documents.filter((doc) =>
|
||||
allowedTypes.includes(doc.documentType)
|
||||
);
|
||||
|
||||
// Mutation to handle the presigned URL generation
|
||||
const { mutate: fetchPresignedUrl } = useMutation(
|
||||
// Use the file key as the argument to generate the URL
|
||||
async (fileKey: string) => await generatePresignedUrl(fileKey),
|
||||
{
|
||||
onSuccess: (url) => {
|
||||
window.open(url, "_blank"); // Open the file in a new tab
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error generating presigned URL:", error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const handleDownload = (documentLocation: string) => {
|
||||
// Generate URL and open in new tab
|
||||
fetchPresignedUrl(documentLocation);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table className="min-w-full divide-y divide-gray-200 shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
||||
<TableBody className="bg-white divide-y divide-gray-200">
|
||||
{relevantDocuments.map((doc) => (
|
||||
<TableRow key={doc.id.toString()}>
|
||||
<TableCell className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{doc.documentType}
|
||||
</TableCell>
|
||||
<TableCell className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{descriptions[doc.documentType] || ""}
|
||||
</TableCell>
|
||||
<TableCell className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<BrandButton
|
||||
label="Download"
|
||||
onClick={() => handleDownload(doc.documentLocation)} // Call the download handler
|
||||
backgroundColor="brandgold"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/app/shadcn_components/ui/card";
|
||||
|
||||
import {
|
||||
getEnergyAssessment,
|
||||
getEnergyAssessmentDocuments,
|
||||
getPropertyMeta,
|
||||
} from "../utils";
|
||||
import { DocumentsTable } from "./DocumentsTable";
|
||||
import { getEpcColorClass } from "@/app/utils";
|
||||
|
||||
// Helper function to clean scenario names
|
||||
function cleanScenarioName(scenarioName: string | undefined) {
|
||||
if (!scenarioName) {
|
||||
return scenarioName;
|
||||
}
|
||||
|
||||
return scenarioName.startsWith("Scenario")
|
||||
? scenarioName.replace(/^Scenario\s*/i, "").trim()
|
||||
: scenarioName;
|
||||
}
|
||||
|
||||
type InfoCardProps = {
|
||||
title: string;
|
||||
value: number | string;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
const InfoCard: React.FC<InfoCardProps> = ({ title, value, unit }) => {
|
||||
const isEnergyRating = title === "Energy Rating";
|
||||
const bgColorClass = isEnergyRating ? getEpcColorClass(value.toString()) : ""; // Get the EPC color if it's Energy Rating
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`flex-1 max-w-xs ${isEnergyRating ? bgColorClass : ""} ${
|
||||
isEnergyRating ? "text-white" : ""
|
||||
}`}
|
||||
>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle
|
||||
className={`${
|
||||
isEnergyRating ? "text-white" : "text-gray-700"
|
||||
} text-sm`}
|
||||
>
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center p-6">
|
||||
<div
|
||||
className={`flex justify-center items-baseline ${
|
||||
isEnergyRating ? "text-white" : "text-gray-800"
|
||||
}`}
|
||||
>
|
||||
<span className="text-4xl font-bold">{value}</span>
|
||||
<span className="ml-1 text-sm">{unit}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default async function EnergyAssessmentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string; propertyId: string };
|
||||
}) {
|
||||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
const ea = await getEnergyAssessment(propertyMeta.uprn);
|
||||
|
||||
// ea will be an empty object {} if there is no energy assessment
|
||||
|
||||
if (Object.keys(ea).length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="text-2xl font-semibold mb-4">
|
||||
This property does not have a Domna energy assessment
|
||||
</div>
|
||||
<p className="text-lg">Please check back later for updates.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const documents = await getEnergyAssessmentDocuments(ea.id);
|
||||
|
||||
const table1AllowedTypes = [
|
||||
"EPR",
|
||||
"Condition Report",
|
||||
"Evidence Report",
|
||||
"Summary Information",
|
||||
"Floor Plan",
|
||||
];
|
||||
|
||||
const scenarioAllowedTypes = ["Scenario Draft EPC", "Scenario Site Notes"];
|
||||
|
||||
// Separate core documents and scenario-specific documents
|
||||
const coreDocuments = documents.filter(
|
||||
(doc) =>
|
||||
doc.scenarioId === null && table1AllowedTypes.includes(doc.documentType)
|
||||
);
|
||||
|
||||
const scenarioDocuments = documents.filter(
|
||||
(doc) =>
|
||||
doc.scenarioId !== null && scenarioAllowedTypes.includes(doc.documentType)
|
||||
);
|
||||
|
||||
// Extract unique scenarios from the documents
|
||||
const scenarios = Array.from(
|
||||
new Set(scenarioDocuments.map((doc) => doc.scenario?.scenarioName))
|
||||
).filter(Boolean); // Filter out any null or undefined scenario names
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* EPC Card and Summary Information */}
|
||||
<div className="flex flex-row justify-between my-8 space-x-6 w-full">
|
||||
{/* EPC Rating Card */}
|
||||
|
||||
<InfoCard
|
||||
title="Energy Rating"
|
||||
value={ea.currentEnergyRating}
|
||||
unit={ea.currentEnergyEfficiency}
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Carbon Emissions"
|
||||
value={ea.co2EmissionsCurrent}
|
||||
unit="tCO2/year"
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Heat Demand"
|
||||
value={ea.energyConsumptionCurrent}
|
||||
unit="kWh/m²/year"
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Est. Heating kWh"
|
||||
value={ea.spaceHeatingKwh}
|
||||
unit="kWh"
|
||||
/>
|
||||
|
||||
{/* Estimated Hot Water kWh */}
|
||||
<InfoCard
|
||||
title="Est. Hot Water kWh"
|
||||
value={ea.waterHeatingKwh}
|
||||
unit="kWh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Core Survey Documents */}
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between py-4 px-6 bg-brandblue text-white font-semibold text-lg rounded-md">
|
||||
Core Survey Documents
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<DocumentsTable
|
||||
documents={coreDocuments}
|
||||
allowedTypes={table1AllowedTypes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scenario-Specific Documents */}
|
||||
{scenarios.map((scenarioName) => {
|
||||
const scenarioDocs = scenarioDocuments.filter(
|
||||
(doc) => doc.scenario?.scenarioName === scenarioName
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={scenarioName} className="py-4">
|
||||
<div className="flex items-center justify-between py-4 px-6 bg-brandblue text-white font-semibold text-lg rounded-md">
|
||||
Scenario: {cleanScenarioName(scenarioName)}
|
||||
</div>
|
||||
<DocumentsTable
|
||||
documents={scenarioDocs}
|
||||
allowedTypes={scenarioAllowedTypes}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { recommendation } from "../../../../db/schema/recommendations";
|
||||
import {
|
||||
Recommendation,
|
||||
planRecommendations,
|
||||
|
|
@ -18,11 +17,53 @@ import {
|
|||
import { plan, Plan } from "@/app/db/schema/recommendations";
|
||||
import { getRating } from "@/app/utils";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import {
|
||||
energyAssessment,
|
||||
EnergyAssessment,
|
||||
energyAssessmentDocuments,
|
||||
EnergyAssessmentDocument,
|
||||
EnergyAssessmentDocumentWithScenario,
|
||||
} from "@/app/db/schema/energy_assessments";
|
||||
|
||||
type RecommendationList = {
|
||||
recommendation: Recommendation;
|
||||
}[];
|
||||
|
||||
export async function getEnergyAssessment(
|
||||
uprn: number
|
||||
): Promise<EnergyAssessment> {
|
||||
const data = await db.query.energyAssessment.findFirst({
|
||||
where: eq(energyAssessment.uprn, BigInt(uprn)),
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
// If there's no data, we return an empty array. This signififies that no energy assessment has been conducted
|
||||
return {} as EnergyAssessment;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getEnergyAssessmentDocuments(
|
||||
energyAssessmentId: bigint
|
||||
): Promise<EnergyAssessmentDocumentWithScenario[]> {
|
||||
const data = await db.query.energyAssessmentDocuments.findMany({
|
||||
where: eq(
|
||||
energyAssessmentDocuments.energyAssessmentId,
|
||||
BigInt(energyAssessmentId)
|
||||
),
|
||||
with: {
|
||||
scenario: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getRecommendations(
|
||||
planId: string
|
||||
): Promise<Recommendation[]> {
|
||||
|
|
|
|||
|
|
@ -12,4 +12,7 @@ export interface RecommendationMetricMap {
|
|||
heating_control: number;
|
||||
secondary_heating: number;
|
||||
cylinder_thermostat: number;
|
||||
trickle_vents: number;
|
||||
mixed_glazing: number;
|
||||
draught_proofing: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ module.exports = {
|
|||
brandtan: "#d3b488",
|
||||
hovertan: "#947750",
|
||||
brandgold: "#f1bb06",
|
||||
hovergold: "#c79d12",
|
||||
brandbrown: "#3d1e05",
|
||||
brandmidblue: "#3943b7",
|
||||
border: "hsl(var(--border))",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue