mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #34 from Hestia-Homes/measures-summary
allow ventilation sap points to be negative
This commit is contained in:
commit
fb08e86a8e
8 changed files with 235 additions and 144 deletions
|
|
@ -189,7 +189,8 @@ export default function RecommendationCard({
|
|||
<tr>
|
||||
<td className="font-medium">SAP Points:</td>
|
||||
<td>
|
||||
{cardComponent.sapPoints < 0.1
|
||||
{cardComponent.sapPoints < 0.1 &&
|
||||
cardComponent.type !== "mechanical_ventilation"
|
||||
? "Negligible"
|
||||
: cardComponent.sapPoints}
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
Cog6ToothIcon,
|
||||
BuildingOfficeIcon,
|
||||
ChartBarIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
NavigationMenu,
|
||||
|
|
@ -40,6 +41,10 @@ export function Toolbar({ portfolioId }: ToolbarProps) {
|
|||
router.push(`/portfolio/${portfolioId}/summary`);
|
||||
}
|
||||
|
||||
function handleClickMeasures() {
|
||||
router.push(`/portfolio/${portfolioId}/measures`);
|
||||
}
|
||||
|
||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||
const [isRemoteAssessmentOpen, setIsRemoteAssessmentOpen] = useState(false);
|
||||
|
||||
|
|
@ -62,6 +67,14 @@ export function Toolbar({ portfolioId }: ToolbarProps) {
|
|||
Summary
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
onClick={handleClickMeasures}
|
||||
>
|
||||
<WrenchScrewdriverIcon className="h-4 w-4 mr-2" />
|
||||
Measures
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
onClick={handleClickSettings}
|
||||
|
|
|
|||
|
|
@ -194,18 +194,21 @@ export type RecommendationType =
|
|||
|
||||
export type UnnestedRecommendation = {
|
||||
quantity: number;
|
||||
quantityUnit: string;
|
||||
quantityUnit: string | null;
|
||||
estimatedCost: number;
|
||||
materialType: string;
|
||||
materialType: string | null;
|
||||
propertyId: string;
|
||||
measureType: string | null;
|
||||
measureTypeCount: number | undefined;
|
||||
};
|
||||
|
||||
export interface PortfolioPlanRecommendation {
|
||||
quantity: number;
|
||||
quantityUnit: string;
|
||||
quantityUnit: string | null;
|
||||
estimatedCost: number;
|
||||
materialType: string;
|
||||
materialType: string | null;
|
||||
numberOfProperties: number;
|
||||
measureTypeCount: number | undefined;
|
||||
}
|
||||
|
||||
export interface RecommendationMaterialToMaterial extends Material {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import PortfolioPlanTable from "@/app/components/portfolio/plan/PlanTable";
|
||||
import { getPortfolioPlan } from "../../utils";
|
||||
import { portfolioPlanColumns } from "@/app/components/portfolio/plan/PlanTableColumns";
|
||||
import PortfolioPlanTable from "@/app/components/portfolio/measures/PlanTable";
|
||||
import { getPortfolioMeasures } from "../../utils";
|
||||
import { portfolioPlanColumns } from "@/app/components/portfolio/measures/PlanTableColumns";
|
||||
|
||||
export default async function PortfolioPlan({
|
||||
params,
|
||||
|
|
@ -8,14 +8,14 @@ export default async function PortfolioPlan({
|
|||
params: { slug: string };
|
||||
}) {
|
||||
const portfolioId = params.slug;
|
||||
const portfolioPlan = await getPortfolioPlan(portfolioId);
|
||||
const portfolioMeasures = await getPortfolioMeasures(portfolioId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-center mt-8">
|
||||
{
|
||||
<PortfolioPlanTable
|
||||
data={portfolioPlan}
|
||||
data={portfolioMeasures}
|
||||
columns={portfolioPlanColumns}
|
||||
/>
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ import {
|
|||
import { PortfolioStatus as PortfolioStatusOptions } from "@/app/db/schema/portfolio";
|
||||
import { PortfolioGoal as PortfolioGoalOptions } from "@/app/db/schema/portfolio";
|
||||
import { useSession } from "next-auth/react";
|
||||
import PortfolioPlanTable from "@/app/components/portfolio/plan/PlanTable";
|
||||
import PortfolioPlanTable from "@/app/components/portfolio/measures/PlanTable";
|
||||
|
||||
// dropdown selection component for both goal and status
|
||||
|
||||
|
|
@ -110,7 +110,7 @@ const updateSettings = async ({
|
|||
|
||||
const permissionsData = await permissionsReponse.json();
|
||||
const permitted = permissionsData.permitted;
|
||||
console.log("USER IS PERMITTED TO DO THIS!!!!")
|
||||
console.log("USER IS PERMITTED TO DO THIS!!!!");
|
||||
// If the user is not permitted to delete the portfolio, we'll throw an error
|
||||
if (!permitted) {
|
||||
throw new Error("User is not permitted to update this portfolio");
|
||||
|
|
@ -120,7 +120,7 @@ const updateSettings = async ({
|
|||
// We will create a js object with the starting values
|
||||
// We will then update the values that are not null
|
||||
|
||||
const body: bodyType = {}
|
||||
const body: bodyType = {};
|
||||
|
||||
if (name) {
|
||||
body.name = name;
|
||||
|
|
@ -146,8 +146,6 @@ const updateSettings = async ({
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
body: requestBody,
|
||||
|
||||
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -370,109 +368,153 @@ export default function PortfolioSettings({
|
|||
// 4) Create the API
|
||||
|
||||
return (
|
||||
|
||||
<div className="w-auto mt-4 p-4 bg-gray-50 rounded-lg text-brandblue">
|
||||
<div className="rounded-md border border-gray-700">
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Rename the Portfolio:<p className="text-xs text-gray-500">Permanently change the name of your portfolio</p>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Input value={portfolioName} onChange={handlePortfolioNameChange} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className="w-28" onClick={handleRename}>Rename</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Change the Portfolio Budget:<p className="text-xs text-gray-500">The total budget across ALL properties. Works aim to stay within this budget</p>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Input type="number" value={portfolioBudget ?? undefined} onChange={handlePortfolioBudgetUpdate} onKeyDown={(e) => handleNumericKeyDown(e)}/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className="w-28" onClick={handleBudgetUpdate}>Update</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Change the Portfolio Goal:<p className="text-xs text-gray-500">Adjust the overall aim of the works conducted on this portfolio</p>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<SettingsDropdown className="w-full" startingValue={portfolioGoal} options={PortfolioGoalOptions} setOption={setPortfolioGoal}/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className="w-28" onClick={handleGoalUpdate}>Update</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Change the Status of the Portfolio:<p className="text-xs text-gray-500">Adjust where the portfolio stands in the works pipeline</p>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<SettingsDropdown className="w-full" startingValue={portfolioStatus} options={PortfolioStatusOptions} setOption={setPortfolioStatus}/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className="w-28" onClick={handleStatusUpdate}>Update</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="w-auto mt-4 p-4 bg-gray-50 rounded-lg text-brandblue">
|
||||
<div className="rounded-md border border-gray-700">
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Rename the Portfolio:
|
||||
<p className="text-xs text-gray-500">
|
||||
Permanently change the name of your portfolio
|
||||
</p>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={portfolioName}
|
||||
onChange={handlePortfolioNameChange}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className="w-28" onClick={handleRename}>
|
||||
Rename
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Change the Portfolio Budget:
|
||||
<p className="text-xs text-gray-500">
|
||||
The total budget across ALL properties. Works aim to stay
|
||||
within this budget
|
||||
</p>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={portfolioBudget ?? undefined}
|
||||
onChange={handlePortfolioBudgetUpdate}
|
||||
onKeyDown={(e) => handleNumericKeyDown(e)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className="w-28" onClick={handleBudgetUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Change the Portfolio Goal:
|
||||
<p className="text-xs text-gray-500">
|
||||
Adjust the overall aim of the works conducted on this
|
||||
portfolio
|
||||
</p>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<SettingsDropdown
|
||||
className="w-full"
|
||||
startingValue={portfolioGoal}
|
||||
options={PortfolioGoalOptions}
|
||||
setOption={setPortfolioGoal}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className="w-28" onClick={handleGoalUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Change the Status of the Portfolio:
|
||||
<p className="text-xs text-gray-500">
|
||||
Adjust where the portfolio stands in the works pipeline
|
||||
</p>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<SettingsDropdown
|
||||
className="w-full"
|
||||
startingValue={portfolioStatus}
|
||||
options={PortfolioStatusOptions}
|
||||
setOption={setPortfolioStatus}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className="w-28" onClick={handleStatusUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="rounded-md border border-red-500 mt-2">
|
||||
<Table>
|
||||
<TableHead className="text-lg text-brandblue">Danger Zone:</TableHead>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Delete the Portfolio:
|
||||
<p className="text-xs text-gray-500">
|
||||
Permanently delete the portfolio and all property data
|
||||
assigned to this portfolio
|
||||
</p>
|
||||
</TableHead>
|
||||
<TableCell className="flex justify-end">
|
||||
<Button
|
||||
className="bg-red-700 w-42"
|
||||
onClick={handleOpenDeleteModal}
|
||||
>
|
||||
Delete Portfolio
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<p>
|
||||
To confirm, please type the name of the portfolio (
|
||||
<strong>{portfolioSettingsData.name}</strong>)
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirmationByName}
|
||||
onChange={(e) => setDeleteConfirmationByName(e.target.value)}
|
||||
placeholder="Type portfolio name"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="bg-green-600"
|
||||
onClick={() => setIsDeleteModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-red-700"
|
||||
onClick={handleDeleteConfirmation}
|
||||
disabled={
|
||||
deleteConfirmationByName !== portfolioSettingsData.name
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-red-500 mt-2">
|
||||
<Table>
|
||||
<TableHead className="text-lg text-brandblue">Danger Zone:</TableHead>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
Delete the Portfolio:<p className="text-xs text-gray-500">Permanently delete the portfolio and all property data assigned to this portfolio</p>
|
||||
</TableHead>
|
||||
<TableCell className="flex justify-end">
|
||||
<Button className="bg-red-700 w-42" onClick={handleOpenDeleteModal}>Delete Portfolio</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<p>
|
||||
To confirm, please type the name of the portfolio (
|
||||
<strong>{portfolioSettingsData.name}</strong>)
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirmationByName}
|
||||
onChange={(e) => setDeleteConfirmationByName(e.target.value)}
|
||||
placeholder="Type portfolio name"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="bg-green-600"
|
||||
onClick={() => setIsDeleteModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-red-700"
|
||||
onClick={handleDeleteConfirmation}
|
||||
disabled={deleteConfirmationByName !== portfolioSettingsData.name}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -449,62 +449,73 @@ export async function getProperties(
|
|||
|
||||
interface UnaggregatedPortfolioPlanRecommendation {
|
||||
quantity: number;
|
||||
quantityUnit: string;
|
||||
quantityUnit: string | null;
|
||||
estimatedCost: number;
|
||||
materialType: string;
|
||||
materialType: string | null;
|
||||
propertyId?: string;
|
||||
numberOfProperties?: number; // Optional field to hold the count of unique propertyIds
|
||||
measureType: string | null;
|
||||
measureTypeCount: number | undefined; // Optional field to hold the count of unique measureTypes
|
||||
}
|
||||
|
||||
function aggregateRecommendations(
|
||||
data: UnaggregatedPortfolioPlanRecommendation[]
|
||||
): PortfolioPlanRecommendation[] {
|
||||
const grouped: {
|
||||
[key: string]: PortfolioPlanRecommendation & { propertyIds: Set<string> };
|
||||
[key: string]: PortfolioPlanRecommendation & {
|
||||
propertyIds: Set<string>;
|
||||
measureTypes: Set<string>;
|
||||
};
|
||||
} = {};
|
||||
|
||||
data.forEach((item) => {
|
||||
// Use the combination of quantityUnit and materialType as a unique key
|
||||
const key = `${item.quantityUnit}_${item.materialType}`;
|
||||
// Use the combination of quantityUnit and materialType (or measureType if materialType is missing) as a unique key
|
||||
const key = item.materialType
|
||||
? `${item.quantityUnit}_${item.materialType}`
|
||||
: `${item.quantityUnit}_${item.measureType}`;
|
||||
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = {
|
||||
...item,
|
||||
numberOfProperties: 0, // Initialize to 0
|
||||
propertyIds: item.propertyId ? new Set([item.propertyId]) : new Set(),
|
||||
measureTypes: item.measureType
|
||||
? new Set([item.measureType])
|
||||
: new Set(),
|
||||
};
|
||||
} else {
|
||||
grouped[key].quantity += item.quantity;
|
||||
grouped[key].estimatedCost += item.estimatedCost;
|
||||
|
||||
if (item.propertyId) {
|
||||
grouped[key].propertyIds.add(item.propertyId);
|
||||
}
|
||||
|
||||
if (item.measureType) {
|
||||
grouped[key].measureTypes.add(item.measureType);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Round the results to 2 decimal places and compute uniquePropertyCount
|
||||
// Round the results to 2 decimal places, compute uniquePropertyCount, and count unique measureTypes
|
||||
for (const key in grouped) {
|
||||
grouped[key].quantity = parseFloat(grouped[key].quantity.toFixed(2));
|
||||
grouped[key].estimatedCost = parseFloat(
|
||||
grouped[key].estimatedCost.toFixed(2)
|
||||
);
|
||||
grouped[key].numberOfProperties = grouped[key].propertyIds.size;
|
||||
grouped[key].measureTypeCount = grouped[key].measureTypes.size;
|
||||
|
||||
// Cleanup temporary properties
|
||||
delete (grouped[key] as any).propertyIds; // using type assertion to bypass the TS error
|
||||
delete (grouped[key] as any).measureTypes; // using type assertion to bypass the TS error
|
||||
}
|
||||
|
||||
return Object.values(grouped);
|
||||
}
|
||||
|
||||
export async function getPortfolioPlan(portfolioId: string) {
|
||||
// To do this we need to do the following:
|
||||
// 1. For the portfolioId, get all of the default plans. This can be done from the plan table
|
||||
// 2. For the plans, get the recommendations. This can be done from the planRecommendation table
|
||||
// 3. For the recommendations get the materials, the quantity and the cost.
|
||||
// 4. For the materials, get the type of material
|
||||
|
||||
// There was some trouble performing all of the relations in a single query so we split it into
|
||||
// three requests (unfortunately)
|
||||
|
||||
export async function getPortfolioMeasures(portfolioId: string) {
|
||||
// Step 1: Get all default plans for the portfolioId
|
||||
const plans = await db.query.plan.findMany({
|
||||
where: and(
|
||||
eq(property.portfolioId, BigInt(portfolioId)),
|
||||
|
|
@ -514,6 +525,7 @@ export async function getPortfolioPlan(portfolioId: string) {
|
|||
|
||||
const planIds = plans.map((plan) => plan.id);
|
||||
|
||||
// Step 2: Get recommendations for the plans
|
||||
const recommendations = await db.query.planRecommendations.findMany({
|
||||
where: inArray(planRecommendations.planId, planIds),
|
||||
});
|
||||
|
|
@ -522,12 +534,13 @@ export async function getPortfolioPlan(portfolioId: string) {
|
|||
(recommendation) => recommendation.id
|
||||
);
|
||||
|
||||
// Step 3: Get data for recommendations, including materials and measureTypes
|
||||
const data = await db.query.recommendation.findMany({
|
||||
where: and(
|
||||
inArray(recommendation.id, recommendationIds),
|
||||
eq(recommendation.default, true)
|
||||
),
|
||||
columns: { propertyId: true },
|
||||
columns: { propertyId: true, measureType: true },
|
||||
with: {
|
||||
recommendationMaterials: {
|
||||
with: {
|
||||
|
|
@ -546,22 +559,41 @@ export async function getPortfolioPlan(portfolioId: string) {
|
|||
},
|
||||
});
|
||||
|
||||
// Step 4: Handle recommendations with or without materials
|
||||
const unnestedRecommendations: UnnestedRecommendation[] = data.reduce(
|
||||
(acc: UnnestedRecommendation[], recommendation) => {
|
||||
const materials = recommendation.recommendationMaterials.map(
|
||||
(material) => ({
|
||||
quantity: material.quantity,
|
||||
quantityUnit: material.quantityUnit,
|
||||
estimatedCost: material.estimatedCost,
|
||||
materialType: material.material.type,
|
||||
if (recommendation.recommendationMaterials.length === 0) {
|
||||
// Handle case where no materials are associated
|
||||
acc.push({
|
||||
quantity: 0,
|
||||
quantityUnit: null,
|
||||
estimatedCost: 0,
|
||||
materialType: null,
|
||||
propertyId: String(recommendation.propertyId),
|
||||
})
|
||||
);
|
||||
return [...acc, ...materials];
|
||||
measureType: recommendation.measureType, // Ensure measureType is included
|
||||
measureTypeCount: undefined, // Initialize to undefined
|
||||
});
|
||||
} else {
|
||||
// Process recommendations with materials
|
||||
const materials = recommendation.recommendationMaterials.map(
|
||||
(material) => ({
|
||||
quantity: material.quantity,
|
||||
quantityUnit: material.quantityUnit,
|
||||
estimatedCost: material.estimatedCost,
|
||||
materialType: material.material.type,
|
||||
propertyId: String(recommendation.propertyId),
|
||||
measureType: recommendation.measureType, // Include measureType
|
||||
measureTypeCount: undefined, // Initialize to undefined
|
||||
})
|
||||
);
|
||||
acc.push(...materials);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Step 5: Aggregate the recommendations
|
||||
const aggregated = aggregateRecommendations(unnestedRecommendations);
|
||||
|
||||
return aggregated;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue