Merge pull request #195 from Hestia-Homes/main

Categorisation Trigger
This commit is contained in:
Daniel Roth 2026-03-05 16:19:27 +00:00 committed by GitHub
commit dee14a2a35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 475 additions and 12 deletions

View file

@ -132,4 +132,4 @@ the permission set to access the bucket, `rerofit-plan-inputs-<stage>`. The name
Quick wins:
- [] Frequently asked questions page
- [] Frequently asked questions page.

56
package-lock.json generated
View file

@ -11,6 +11,9 @@
"@aws-sdk/client-s3": "^3.971.0",
"@aws-sdk/client-sqs": "^3.864.0",
"@aws-sdk/s3-request-presigner": "^3.927.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^3.9.1",
@ -1242,6 +1245,59 @@
"ms": "^2.1.1"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@drizzle-team/brocli": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",

View file

@ -17,6 +17,9 @@
"@aws-sdk/client-s3": "^3.971.0",
"@aws-sdk/client-sqs": "^3.864.0",
"@aws-sdk/s3-request-presigner": "^3.927.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^3.9.1",

View file

@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const CategorisationBodySchema = z.object({
portfolio_id: z.number(),
scenarios_to_consider: z.array(z.number()).optional(),
scenario_priority_order: z.array(z.number()).optional(),
})
export async function POST(request: NextRequest) {
console.log("API hit");
const body = await request.json();
let validatedBody;
try {
validatedBody = CategorisationBodySchema.parse(body);
} catch (error) {
console.error("Invalid input: ", error);
return new NextResponse(JSON.stringify({ msg: "Invalid input" }), {
status: 400,
});
}
try {
// This triggers the work distribution, but doesn't wait for the lambdas to complete categorisation
// Instead we'll check the task ID before allowing user to select the new recommendations
const headers = {
"x-api-key": process.env.FASTAPI_API_KEY || "",
Authorization: `Bearer ${
request.cookies.get("__Secure-next-auth.session-token")?.value ||
request.cookies.get("next-auth.session-token")?.value
}`,
"Content-Type": "application/json",
};
const url = `${process.env.FASTAPI_API_URL}/v1/plan/categorisation`;
console.log("Request:", url, validatedBody);
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(validatedBody),
});
if (!response.ok) {
console.error("Error triggering plan:", response.statusText);
return new NextResponse(
JSON.stringify({ msg: "Error triggering plan" }),
{
status: 500,
},
);
}
return new NextResponse(JSON.stringify({ msg: "Categorisation job started" }), {
status: 200,
});
} catch (error) {
console.error(error);
return new NextResponse(JSON.stringify({ msg: "Internal server error" }), {
status: 500,
});
}
}

View file

@ -21,7 +21,7 @@ import { Button } from "@/app/shadcn_components/ui/button";
import { cva } from "class-variance-authority";
import { getUploadedFile } from "@/app/db/surveyDB/schema/surveyDB";
import BookSurveyModal from "@/app/portfolio/[slug]/components/BookSurveyModal";
import BookingSuccessToast from "@/app/portfolio/[slug]/components/BookingSuccessToast";
import SuccessToast from "@/app/portfolio/[slug]/components/SuccessToast";
import { PropertyMeta } from "@/app/db/schema/property";
interface ToolbarProps {
@ -179,8 +179,9 @@ export function Toolbar({
)}
{/* ✅ Toast */}
<BookingSuccessToast
<SuccessToast
show={showToast}
showConfetti={showToast}
onClose={() => setShowToast(false)}
message="Survey Request Recieved!"
subtext="We'll be in contact soon. 🎉"

View file

@ -0,0 +1,312 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import { Button } from "@/app/shadcn_components/ui/button";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
import { Label } from "@/app/shadcn_components/ui/label";
import { GripVertical } from "lucide-react";
import { HelpCircle } from "lucide-react";
import { Loader2 } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
import {
DndContext,
closestCenter,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ScenarioSummary } from "./types";
export interface RecommendationsOptionsProps {
disabled?: boolean;
scenarios: ScenarioSummary[]
portfolioId: number
onSuccess: () => void;
}
interface ScenarioWithPriority {
id: number;
priority: number; // 1 = highest, 2 = next, etc.
}
interface CategorisationTriggerRequest {
portfolio_id: number;
scenarios_to_consider?: number[] | null;
scenario_priority_order?: number[] | null;
}
function mapScenariosToPayload(
selectedScenarios: ScenarioWithPriority[],
portfolio_id: number
): CategorisationTriggerRequest {
if (!selectedScenarios || selectedScenarios.length === 0) {
return {
portfolio_id,
scenarios_to_consider: null,
scenario_priority_order: null,
};
}
// Sort by priority just in case
const sorted = [...selectedScenarios].sort((a, b) => a.priority - b.priority);
return {
portfolio_id,
scenarios_to_consider: sorted.map((s) => s.id),
scenario_priority_order: sorted.map((s) => s.id),
};
}
function SortableScenarioItem({
id,
name,
index,
}: {
id: number;
name: string;
index: number;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-2 p-2 border rounded bg-muted cursor-grab
${isDragging ? "opacity-70 scale-105 shadow-lg" : ""}
`}
{...attributes}
>
<GripVertical
className="h-4 w-4 text-muted-foreground"
{...listeners}
/>
<span className="flex-1">{name}</span>
<span className="text-xs text-muted-foreground">
Priority {index + 1}
</span>
</div>
);
}
function sendCategorisationRequest(selectedScenarios: ScenarioWithPriority[], portfolioId: number) {
const payload = mapScenariosToPayload(selectedScenarios, portfolioId);
return fetch("/api/plan/categorisation", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export function RecommendationsOptions({
disabled = false,
scenarios,
portfolioId,
onSuccess
}: RecommendationsOptionsProps) {
const [isApplying, setIsApplying] = useState(false);
const [open, setOpen] = useState(false);
const [selectedScenarios, setSelectedScenarios] = useState<ScenarioWithPriority[]>([]);
const [warning, setWarning] = useState<string | null>(null);
const toggleScenario = (id: number) => {
setWarning("")
setSelectedScenarios((prev) => {
const exists = prev.find((s) => s.id === id);
if (exists) {
// Remove
return prev.filter((s) => s.id !== id);
} else {
// Add at the end with next priority
return [...prev, { id, priority: prev.length + 1 }];
}
});
};
const handleSelectAll = () => {
setWarning("")
setSelectedScenarios(
scenarios.map((s, index) => ({ id: s.id, priority: index + 1 }))
);
};
const handleDeselectAll = () => {
setWarning("")
setSelectedScenarios([]);
};
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setSelectedScenarios((items) => {
const oldIndex = items.findIndex((s) => s.id === active.id);
const newIndex = items.findIndex((s) => s.id === over.id);
const newOrder = arrayMove(items, oldIndex, newIndex);
// Update priority to match array index
return newOrder.map((s, index) => ({ ...s, priority: index + 1 }));
});
};
const { mutate, isPending } = useMutation({
mutationFn: () => sendCategorisationRequest(selectedScenarios, portfolioId),
// onSuccess: () => {
// },
// onError: () => {
// }
});
const handleSubmit = () => {
if (selectedScenarios.length === 1) {
setWarning("Cannot generate recommendations for a single scenario");
return;
}
setWarning(null);
mutate();
onSuccess();
setOpen(false);
};
const handleCancel = () => {
setWarning("")
setSelectedScenarios([]);
setOpen(false);
};
const selectedScenarioObjects = selectedScenarios.map(
(s) => ({
...scenarios.find((sc) => sc.id === s.id)!,
priority: s.priority,
})
);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={disabled || isApplying}
className={`
rounded-md px-3 py-2 text-sm font-medium transition
${
disabled
? "bg-gray-200 text-gray-400 cursor-not-allowed"
: "bg-brandblue text-white hover:bg-hoverblue"
}
`}
>
Calculate Recommended
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80 p-4 space-y-4 max-h-[50vh] overflow-y-auto">
<div className="flex justify-between">
<Button size="sm" variant="ghost" onClick={handleSelectAll}>
Select all
</Button>
<Button size="sm" variant="ghost" onClick={handleDeselectAll}>
Deselect all
</Button>
</div>
<div className="space-y-2">
<h4 className="font-semibold">Select scenarios to consider</h4>
{scenarios.map((scenario) => (
<div key={scenario.id} className="flex items-center gap-2">
<Checkbox
checked={selectedScenarios.some((s) => s.id === scenario.id)}
onCheckedChange={() => toggleScenario(scenario.id)}
/>
<Label>{scenario.name}</Label>
</div>
))}
</div>
{selectedScenarioObjects.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1">
<h4 className="font-semibold">Drag to prioritise</h4>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>
This decides the order of selection if multiple scenarios have equal
outputs.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<DndContext
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedScenarios}
strategy={verticalListSortingStrategy}
>
{selectedScenarioObjects.map((scenario, index) => (
<SortableScenarioItem
key={scenario.id}
id={scenario.id}
name={scenario.name}
index={index}
/>
))}
</SortableContext>
</DndContext>
</div>
)}
{warning && (
<p className="text-sm text-red-600 font-medium">{warning}</p>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={isApplying}>
<span className="flex items-center gap-2">
{isApplying && <Loader2 className="h-4 w-4 animate-spin" />}
Submit
</span>
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -22,6 +22,8 @@ import type {
ScenarioSummary,
} from "./types";
import { ReportingFunctionalityButtons } from "./ReportingFunctionalityButtons";
import { RecommendationsOptions } from "./RecommendationsOptions";
import SuccessToast from "../../components/SuccessToast";
interface ReportingClientAreaProps {
baseline: BaselineMetrics;
@ -87,6 +89,7 @@ export function ReportingClientArea({
const [measuresOpen, setMeasuresOpen] = useState<boolean>(false);
const [appliedHideNonCompliant, setAppliedHideNonCompliant] =
useState<boolean>(false);
const [showToast, setShowToast] = useState(false);
const drawerOpen = Boolean(selectedScenarioId);
@ -266,6 +269,14 @@ export function ReportingClientArea({
</button>
</div>
)}
{ !selectedScenarioId &&
<RecommendationsOptions
disabled={scenarioBusy}
scenarios={scenarios}
portfolioId={portfolioId}
onSuccess={() => setShowToast(true)}
/>
}
</div>
{/* LOADING + ERROR STATES */}
@ -338,6 +349,15 @@ export function ReportingClientArea({
data={measuresData ?? null}
error={measuresError}
/>
<SuccessToast
show={showToast}
showConfetti={false}
onClose={() => setShowToast(false)}
message="Recommendation process triggered"
subtext="Recommendations might take a few minutes to update"
timeoutMs={6000}
/>
</>
);
}

View file

@ -5,28 +5,32 @@ import { motion, AnimatePresence } from "framer-motion";
import Confetti from "react-confetti";
import { CheckCircle } from "lucide-react";
interface BookingSuccessToastProps {
interface SuccessToastProps {
show: boolean;
showConfetti: boolean;
onClose: () => void;
message?: string;
subtext?: string;
message: string;
subtext: string;
timeoutMs?: number;
}
export default function BookingSuccessToast({
export default function SuccessToast({
show,
showConfetti,
onClose,
message = "Booking Confirmed!",
subtext = "Youre all set. 🎉",
}: BookingSuccessToastProps) {
message,
subtext,
timeoutMs = 4000
}: SuccessToastProps) {
const [confetti, setConfetti] = useState(false);
useEffect(() => {
if (show) {
setConfetti(true);
setConfetti(showConfetti);
const timer = setTimeout(() => {
setConfetti(false);
onClose();
}, 4000);
}, timeoutMs);
return () => clearTimeout(timer);
}
}, [show, onClose]);