Merge remote-tracking branch 'origin/main' into feature/frontend_landlord_overrides

This commit is contained in:
Jun-te Kim 2026-05-28 17:40:26 +00:00
commit 4915c95875
11 changed files with 205 additions and 16 deletions

View file

@ -129,7 +129,3 @@ In our terraform stack, we have a module called `s3_presignable_bucket` which co
We will generate a pre-signed url and then make a post request to that endpoint to store that data to s3. Part of that process is the creation of an AWS IAM role which contains
the permission set to access the bucket, `rerofit-plan-inputs-<stage>`. The name of this IAM role is `s3_presign_role_<stage>` and for our NextJS application, as it's hosted outside of AWS (for the moment), we need to generate a set of access credentials to give the application access to this bucket. The access key and secret key are automatically generated and stored in AWS secrets manager under `dev/presign_frontend/access_key` and `dev/presign_frontend/secret_key` and need to be set in the environment for the pre-sign api to store csv data to aws.
Quick wins:
- [] Frequently asked questions page.

56
package-lock.json generated
View file

@ -25,6 +25,7 @@
"@radix-ui/react-hover-card": "^1.0.6",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.1.3",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.0.3",
@ -3812,6 +3813,61 @@
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",

View file

@ -33,6 +33,7 @@
"@radix-ui/react-hover-card": "^1.0.6",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.1.3",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.0.3",

View file

@ -47,7 +47,7 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
expectedCommencementDate: timestamp("expected_commencement_date", { precision: 6, withTimezone: true }),
coordination_comments: text("coordination_comments"),
surveyor: text("surveyor"),
damnpMouldAndRepairComments: text("damp_mould_and_repairs_comments"),
dampMouldAndRepairComments: text("damp_mould_and_repairs_comments"),
batch: text("batch"),
batchDescription: text("batch_description"),
blockReference: text("block_reference"),

View file

@ -106,6 +106,7 @@ export default function DampMouldRiskPanel({
const surveyColumns: (keyof ClassifiedDeal)[] = [
"dealname",
"landlordPropertyId",
"dampMouldFlag",
"majorConditionIssueDescription",
"majorConditionIssuePhotosS3",
];
@ -113,6 +114,7 @@ export default function DampMouldRiskPanel({
const surveyLabels: Partial<Record<keyof ClassifiedDeal, string>> = {
dealname: "Address",
landlordPropertyId: "Property Ref",
dampMouldFlag: "Damp & Mould",
majorConditionIssueDescription: "Surveyor Notes",
majorConditionIssuePhotosS3: "Photo Evidence",
};
@ -128,7 +130,7 @@ export default function DampMouldRiskPanel({
const coordLabels: Partial<Record<keyof ClassifiedDeal, string>> = {
dealname: "Address",
landlordPropertyId: "Property Ref",
dampMouldFlag: "Coordinator Flag",
dampMouldFlag: "Damp & Mould",
dampMouldAndRepairComments: "Comments",
coordinator: "Coordinator",
};
@ -147,7 +149,7 @@ export default function DampMouldRiskPanel({
</div>
<div>
<h3 className="text-base font-semibold text-gray-800">
Awaab&apos;s Law Damp & Mould Risk
Awaab&apos;s Law Damp, Mould & Other Condition Issues
</h3>
<p className="text-sm text-gray-500 mt-0.5">
Comparison of flags raised at survey vs coordination stage
@ -161,7 +163,7 @@ export default function DampMouldRiskPanel({
<ShieldAlert className="h-4 w-4 text-emerald-600" />
</div>
<p className="text-sm font-medium text-emerald-700">
No damp or mould flags recorded for this project.
No condition issues recorded for this project.
</p>
</div>
) : (
@ -176,7 +178,7 @@ export default function DampMouldRiskPanel({
color="red"
onClick={() =>
onOpenTable(
"Damp & Mould — Survey Stage Flags",
"Condition Issues — Survey Stage",
risk.surveyFlagDeals,
surveyColumns,
surveyLabels
@ -192,7 +194,7 @@ export default function DampMouldRiskPanel({
color="red"
onClick={() =>
onOpenTable(
"Damp & Mould — Coordination Stage Flags",
"Condition Issues — Coordination Stage",
risk.coordinatorFlagDeals,
coordColumns,
coordLabels
@ -210,7 +212,7 @@ export default function DampMouldRiskPanel({
{risk.coordinatorFlagCount - risk.surveyFlagCount} additional{" "}
{risk.coordinatorFlagCount - risk.surveyFlagCount === 1 ? "property was" : "properties were"}{" "}
</span>
flagged for damp & mould at the coordination stage that{" "}
flagged with condition issues at the coordination stage that{" "}
{risk.coordinatorFlagCount - risk.surveyFlagCount === 1 ? "was" : "were"} not
identified during the initial survey.
</p>

View file

@ -22,9 +22,68 @@ import {
TableRow,
} from "@/app/shadcn_components/ui/table";
import { Input } from "@/app/shadcn_components/ui/input";
import { Search, Download, ChevronLeft, ChevronRight } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/app/shadcn_components/ui/popover";
import { Search, Download, ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
import type { ClassifiedDeal, HubspotDeal } from "./types";
const NO_COMMENT_PLACEHOLDER =
"Damp & mould discovered — no note from coordinator";
const COMMENT_PREVIEW_LIMIT = 60;
function DampMouldBadgeCell({ value }: { value: unknown }) {
const isYes =
typeof value === "string" && value.trim().toLowerCase() === "yes";
if (!isYes) return null;
return (
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700 border border-red-200">
Damp & Mould
</span>
);
}
function DampMouldCommentCell({ value }: { value: unknown }) {
const comment = typeof value === "string" ? value.trim() : "";
if (!comment) {
return (
<span className="text-sm italic text-gray-500">
{NO_COMMENT_PLACEHOLDER}
</span>
);
}
const preview =
comment.length > COMMENT_PREVIEW_LIMIT
? comment.slice(0, COMMENT_PREVIEW_LIMIT).trimEnd() + "…"
: comment;
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="group inline-flex items-center gap-1 text-left text-sm text-gray-800 hover:text-brandblue underline-offset-2 hover:underline focus:outline-none focus:underline"
>
<span>{preview}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-gray-400 group-hover:text-brandblue" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-80 max-w-[90vw] whitespace-pre-wrap break-words text-sm text-gray-800"
>
{comment}
</PopoverContent>
</Popover>
);
}
interface DrillDownTableProps {
data: ClassifiedDeal[];
columns?: (keyof HubspotDeal)[];
@ -127,6 +186,12 @@ export default function DrillDownTable({
if (key === "majorConditionIssuePhotosS3") {
return <PhotoDownloadCell value={value} />;
}
if (key === "dampMouldAndRepairComments") {
return <DampMouldCommentCell value={value} />;
}
if (key === "dampMouldFlag") {
return <DampMouldBadgeCell value={value} />;
}
return (
<span className="text-sm text-gray-800">
{value != null ? String(value) : (

View file

@ -165,7 +165,7 @@ const mockDealRow = {
pashubLink: null,
sharepointLink: null,
dampmouldGrowth: null,
damnpMouldAndRepairComments: null,
dampMouldAndRepairComments: null,
preSap: null,
mtpCompletionDate: null,
mtpReModelCompletionDate: null,

View file

@ -34,7 +34,7 @@ export function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal {
pashubLink: d.pashubLink,
sharepointLink: d.sharepointLink,
dampMouldFlag: d.dampmouldGrowth,
dampMouldAndRepairComments: d.damnpMouldAndRepairComments,
dampMouldAndRepairComments: d.dampMouldAndRepairComments,
preSapScore: d.preSap,
coordinator: row.coordinator,
ioeV1Date: d.mtpCompletionDate,

View file

@ -305,6 +305,38 @@ describe("computeDampMouldRisk", () => {
expect(result.coordinatorFlagCount).toBe(2);
});
it("ignores a growth flag of 'No' when there is no comment", () => {
const deals = [
makeClassified({ dampMouldFlag: "No", dampMouldAndRepairComments: null }),
makeClassified({ dampMouldFlag: "no", dampMouldAndRepairComments: " " }),
];
const result = computeDampMouldRisk(deals);
expect(result.coordinatorFlagCount).toBe(0);
});
it("treats a 'yes' growth flag case-insensitively, ignoring whitespace", () => {
const deals = [
makeClassified({ dampMouldFlag: "yes" }),
makeClassified({ dampMouldFlag: " Yes " }),
makeClassified({ dampMouldFlag: "YES" }),
];
const result = computeDampMouldRisk(deals);
expect(result.coordinatorFlagCount).toBe(3);
});
it("counts a deal with a comment but no growth flag as coordinator-flagged", () => {
const deals = [
makeClassified({
dampMouldFlag: null,
dampMouldAndRepairComments: "Mould in NE bedroom corner",
}),
makeClassified({ dampMouldFlag: null, dampMouldAndRepairComments: null }),
];
const result = computeDampMouldRisk(deals);
expect(result.coordinatorFlagCount).toBe(1);
expect(result.coordinatorFlagDeals).toHaveLength(1);
});
it("counts deals flagged at both stages independently", () => {
const deals = [
makeClassified({ majorConditionIssuePhotosS3: "s3://x", dampMouldFlag: "Yes" }),

View file

@ -145,10 +145,16 @@ export function classifyDeals(deals: HubspotDeal[]): ClassifiedDeal[] {
// -----------------------------------------------------------------------
// Compute damp & mould risk — survey vs coordination stage comparison
// -----------------------------------------------------------------------
function isCoordinatorFlagged(d: ClassifiedDeal): boolean {
const growthIsYes = d.dampMouldFlag?.trim().toLowerCase() === "yes";
const hasComment = !!d.dampMouldAndRepairComments?.trim();
return growthIsYes || hasComment;
}
export function computeDampMouldRisk(deals: ClassifiedDeal[]): DampMouldRiskData {
const surveyFlagDeals = deals.filter((d) => !!d.majorConditionIssuePhotosS3);
const coordinatorFlagDeals = deals.filter((d) => !!d.dampMouldFlag);
const bothFlaggedCount = surveyFlagDeals.filter((d) => !!d.dampMouldFlag).length;
const coordinatorFlagDeals = deals.filter(isCoordinatorFlagged);
const bothFlaggedCount = surveyFlagDeals.filter(isCoordinatorFlagged).length;
return {
surveyFlagCount: surveyFlagDeals.length,

View file

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }