mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
commit
dee14a2a35
8 changed files with 475 additions and 12 deletions
|
|
@ -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
56
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
67
src/app/api/plan/categorisation/route.ts
Normal file
67
src/app/api/plan/categorisation/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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. 🎉"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = "You’re 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]);
|
||||
Loading…
Add table
Reference in a new issue