diff --git a/README.md b/README.md index 081be45a..6937a3e0 100644 --- a/README.md +++ b/README.md @@ -132,4 +132,4 @@ the permission set to access the bucket, `rerofit-plan-inputs-`. The name Quick wins: -- [] Frequently asked questions page +- [] Frequently asked questions page. diff --git a/package-lock.json b/package-lock.json index b309af78..115a8d52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4de599f4..e5fabc67 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/plan/categorisation/route.ts b/src/app/api/plan/categorisation/route.ts new file mode 100644 index 00000000..9452a445 --- /dev/null +++ b/src/app/api/plan/categorisation/route.ts @@ -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, + }); + } + +} \ No newline at end of file diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index aef25052..66670d04 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -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 */} - setShowToast(false)} message="Survey Request Recieved!" subtext="We'll be in contact soon. πŸŽ‰" diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/RecommendationsOptions.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/RecommendationsOptions.tsx new file mode 100644 index 00000000..0aa01a0d --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/RecommendationsOptions.tsx @@ -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 ( +
+ + + {name} + + + Priority {index + 1} + +
+ ); +} + +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([]); + const [warning, setWarning] = useState(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 ( + + + + + + +
+ + +
+ +
+

Select scenarios to consider

+ + {scenarios.map((scenario) => ( +
+ s.id === scenario.id)} + onCheckedChange={() => toggleScenario(scenario.id)} + /> + +
+ ))} +
+ + {selectedScenarioObjects.length > 0 && ( +
+
+

Drag to prioritise

+ + + + + + + +

+ This decides the order of selection if multiple scenarios have equal + outputs. +

+
+
+
+
+ + + + {selectedScenarioObjects.map((scenario, index) => ( + + ))} + + +
+ )} + + {warning && ( +

{warning}

+ )} + +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index c0ec45a5..47dfeaf8 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -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(false); const [appliedHideNonCompliant, setAppliedHideNonCompliant] = useState(false); + const [showToast, setShowToast] = useState(false); const drawerOpen = Boolean(selectedScenarioId); @@ -266,6 +269,14 @@ export function ReportingClientArea({ )} + { !selectedScenarioId && + setShowToast(true)} + /> + } {/* LOADING + ERROR STATES */} @@ -338,6 +349,15 @@ export function ReportingClientArea({ data={measuresData ?? null} error={measuresError} /> + + setShowToast(false)} + message="Recommendation process triggered" + subtext="Recommendations might take a few minutes to update" + timeoutMs={6000} + /> ); } diff --git a/src/app/portfolio/[slug]/components/BookingSuccessToast.tsx b/src/app/portfolio/[slug]/components/SuccessToast.tsx similarity index 84% rename from src/app/portfolio/[slug]/components/BookingSuccessToast.tsx rename to src/app/portfolio/[slug]/components/SuccessToast.tsx index 7b9410d8..20567a5b 100644 --- a/src/app/portfolio/[slug]/components/BookingSuccessToast.tsx +++ b/src/app/portfolio/[slug]/components/SuccessToast.tsx @@ -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]);