Merge pull request #34 from Hestia-Homes/measures-summary

allow ventilation sap points to be negative
This commit is contained in:
KhalimCK 2025-03-15 18:35:13 +00:00 committed by GitHub
commit fb08e86a8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 235 additions and 144 deletions

View file

@ -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>

View file

@ -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}

View file

@ -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 {

View file

@ -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}
/>
}

View file

@ -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>
);
}

View file

@ -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;