Working on remote assessment modal ui

This commit is contained in:
Khalim Conn-Kowlessar 2025-06-13 11:57:49 +01:00
parent d4bf6fad9a
commit b99069a7d4
9 changed files with 3384 additions and 230 deletions

View file

@ -17,16 +17,18 @@ import UploadCsvModal from "@/app/portfolio/[slug]/components/UploadCsvModal";
import RemoteAssessmentModal from "@/app/portfolio/[slug]/components/RemoteAssessmentModal";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ScenarioSelect } from "@/app/db/schema/recommendations";
interface ToolbarProps {
portfolioId: string;
scenarios: ScenarioSelect[];
}
const navigationMenuTriggerStyle = cva(
"bg-gray-50 cursor-pointer group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-200 hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-gray-200 "
);
export function Toolbar({ portfolioId }: ToolbarProps) {
export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
const router = useRouter();
function handleClickSettings() {
@ -94,6 +96,7 @@ export function Toolbar({ portfolioId }: ToolbarProps) {
isOpen={isRemoteAssessmentOpen}
setIsOpen={setIsRemoteAssessmentOpen}
portfolioId={portfolioId}
scenarios={scenarios}
/>
<UploadCsvModal
isOpen={modalIsOpen}

View file

@ -0,0 +1 @@
ALTER TABLE "scenario" ADD COLUMN "goal_value" text;

File diff suppressed because it is too large Load diff

View file

@ -701,6 +701,13 @@
"when": 1742138715788,
"tag": "0099_faulty_nicolaos",
"breakpoints": true
},
{
"idx": 100,
"version": "5",
"when": 1749810028140,
"tag": "0100_wakeful_doctor_doom",
"breakpoints": true
}
]
}

View file

@ -13,7 +13,6 @@ import {
} from "drizzle-orm/pg-core";
import { Material, material } from "./materials";
import { InferModel } from "drizzle-orm";
import { ar } from "drizzle-orm/column.d-b7dc3bdb";
export const recommendation = pgTable("recommendation", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
@ -108,6 +107,7 @@ export const scenario = pgTable("scenario", {
createdAt: timestamp("created_at").notNull().defaultNow(),
housingType: housingTypeEnum("housing_type").notNull(),
goal: goalEnum("goal").notNull(),
goalValue: text("goal_value"),
triggerFilePath: text("trigger_file_path"),
alreadyInstalledFilePath: text("already_installed_file_path"),
patchesFilePath: text("patches_file_path"),

View file

@ -1,5 +1,5 @@
import { Toolbar } from "@/app/components/portfolio/Toolbar";
import { getPortfolio } from "../utils";
import { getPortfolio, getPortfolioScenarios } from "../utils";
export default async function PortfolioLayout({
children, // will be a page or nested layout
@ -10,6 +10,8 @@ export default async function PortfolioLayout({
}) {
const portfolioId = params.slug;
const { name: portfolioName } = await getPortfolio(portfolioId);
// We retrieve the scenarios associated with the portfolio
const scenarios = await getPortfolioScenarios(portfolioId);
return (
<section>
@ -21,7 +23,7 @@ export default async function PortfolioLayout({
<div className="flex justify-center">
<div className="grid grid-cols-8 w-full max-w-8xl">
<div className="col-span-12 justify-center bg-gray-50 py-2">
<Toolbar portfolioId={portfolioId} />
<Toolbar portfolioId={portfolioId} scenarios={scenarios} />
</div>
</div>
</div>

View file

@ -20,6 +20,9 @@ import {
FormDescription,
} from "@/app/shadcn_components/ui/form";
import { useToast } from "@/app/hooks/use-toast";
import { ScenarioSelect } from "@/app/db/schema/recommendations";
import { useState } from "react";
import { SelectScenarioDropdown } from "./SelectScenarioDropdown";
type Option = {
label: string;
@ -175,7 +178,7 @@ export function SelectDropdown({
<Menu as="div" className="relative inline-block text-left w-full">
<Float>
<Menu.Button className="inline-flex justify-center w-1/2 px-4 py-2 text-sm font-medium text-white bg-brandblue rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75">
{selectedOption || "Select an option"}
{selectedOption || "Select option"}
<ChevronDownIcon
className="ml-2 -mr-1 h-5 w-5 text-violet-200 hover:text-violet-100"
aria-hidden="true"
@ -227,7 +230,7 @@ export function SelectUpDropdown({
<Menu as="div" className="relative inline-block text-left w-full">
<Float placement="right-end" offset={4} shift>
<Menu.Button className={menuButtonStyle(width)}>
{selectedOption || "Select an option"}
{selectedOption || "Select option"}
<ChevronRightIcon
className="ml-2 -mr-1 h-5 w-5 text-violet-200 hover:text-violet-100"
aria-hidden="true"
@ -520,14 +523,32 @@ function useCreateRemoteAssessment({
}
export default function RemoteAssessmentModal({
portfolioId,
isOpen,
setIsOpen,
portfolioId,
scenarios,
}: {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
setIsOpen: (open: boolean) => void;
portfolioId: string;
scenarios: ScenarioSelect[];
}) {
const NEW_SENTINEL = "__new__";
const [selectedScenario, setSelectedScenario] = useState<string | null>(null);
const { toast } = useToast();
const scenarioOptions: Option[] = useMemo(
() => [
{ label: "Create new scenario…", value: NEW_SENTINEL, disabled: false },
...scenarios.map((s) => ({
label: s.name,
value: s.name,
disabled: false,
})),
],
[scenarios]
);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
@ -539,45 +560,62 @@ export default function RemoteAssessmentModal({
postcode: "",
uprn: 0,
valuation: 0,
propertyType: null,
builtForm: null,
},
});
const { reset, setValue } = form;
// const [toastState, setToastState] = useState<any>([]);
const { toast } = useToast();
const {
handleSubmit: triggerAssessment,
presignedUrlIsLoading,
presignedUrlIsError,
} = useCreateRemoteAssessment({
portfolioId,
uprn: form.watch("uprn"),
addressLineOne: form.watch("addressLineOne"),
postcode: form.watch("postcode"),
valuation: form.watch("valuation"),
propertyType: form.watch("propertyType"),
builtForm: form.watch("builtForm"),
});
const onSubmit = async (data: FormValues) => {
try {
await handleSubmit(data);
form.reset();
setIsOpen(false);
toast({
title: "The Remote Assesment has been sent",
description: "",
const onSelectScenario = (opt: Option) => {
setSelectedScenario(opt.value);
if (opt.value === NEW_SENTINEL) {
reset({
...form.getValues(),
scenario: "",
housingType: "",
goal: "",
goalValue: "",
});
} catch (error) {
console.error("Error submitting form:", error);
} else {
const picked = scenarios.find((s) => s.name === opt.value);
if (!picked) return;
setValue("scenario", picked.name);
setValue("housingType", picked.housingType);
setValue("goal", picked.goal);
setValue("goalValue", picked.goalValue);
}
};
const { handleSubmit, presignedUrlIsLoading, presignedUrlIsError } =
useCreateRemoteAssessment({
portfolioId,
uprn: form.watch("uprn"),
addressLineOne: form.watch("addressLineOne"),
postcode: form.watch("postcode"),
valuation: form.watch("valuation"),
propertyType: form.watch("propertyType"),
builtForm: form.watch("builtForm"),
});
const onSubmit = form.handleSubmit(async (data) => {
console.log("Triggered");
await triggerAssessment(data);
form.reset();
setIsOpen(false);
toast({ title: "Remote assessment sent" });
});
return (
<>
<Transition appear show={isOpen} as={Fragment}>
<Dialog
as="div"
className="relative z-10"
onClose={() => setIsOpen(false)}
>
<Transition show={isOpen} as={Fragment}>
<Dialog
as="div"
className="fixed inset-0 z-10 overflow-y-auto"
onClose={() => setIsOpen(false)}
>
<div className="min-h-screen px-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@ -587,32 +625,51 @@ export default function RemoteAssessmentModal({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
<Dialog.Overlay className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-1/2 max-w-screen-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900"
>
Remote Assessment Details
</Dialog.Title>
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
{/* Spacer for centering */}
<span
className="inline-block h-screen align-middle"
aria-hidden="true"
>
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="inline-block w-full max-w-2xl p-6 my-8 overflow-hidden text-left align-middle transition-all transform bg-white shadow-xl rounded-2xl">
<Dialog.Title className="text-lg font-medium">
Remote Assessment Details
</Dialog.Title>
<FormProvider {...form}>
<form onSubmit={onSubmit} className="space-y-6 mt-4">
{/* Scenario selector */}
<FormItem>
<FormLabel>Select scenario</FormLabel>
<FormControl>
<SelectScenarioDropdown
scenarios={scenarios.map((s) => ({
label: s.name,
value: s.name,
}))}
selectedValue={selectedScenario}
onSelect={onSelectScenario}
/>
</FormControl>
</FormItem>
{selectedScenario !== null && (
<>
{/* 2) Scenario Name */}
<FormField
control={form.control}
name="scenario"
@ -622,6 +679,7 @@ export default function RemoteAssessmentModal({
<FormControl>
<Input
placeholder="Enter scenario name"
disabled={selectedScenario !== NEW_SENTINEL}
{...field}
/>
</FormControl>
@ -630,6 +688,7 @@ export default function RemoteAssessmentModal({
)}
/>
{/* 3) Housing Type */}
<FormField
control={form.control}
name="housingType"
@ -637,19 +696,24 @@ export default function RemoteAssessmentModal({
<FormItem>
<FormLabel>Housing Type</FormLabel>
<FormControl>
<SelectDropdown
options={selecthousingTypeOptions}
selectedOption={field.value}
onSelectOption={(option) =>
field.onChange(option.value)
}
/>
{selectedScenario === NEW_SENTINEL ? (
<SelectDropdown
options={selecthousingTypeOptions}
selectedOption={field.value}
onSelectOption={(o) =>
field.onChange(o.value)
}
/>
) : (
<Input value={field.value} disabled />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 4) Goal */}
<FormField
control={form.control}
name="goal"
@ -657,19 +721,24 @@ export default function RemoteAssessmentModal({
<FormItem>
<FormLabel>Goal</FormLabel>
<FormControl>
<SelectDropdown
options={selectGoalOptions}
selectedOption={field.value}
onSelectOption={(option) =>
field.onChange(option.value)
}
/>
{selectedScenario === NEW_SENTINEL ? (
<SelectDropdown
options={selectGoalOptions}
selectedOption={field.value}
onSelectOption={(o) =>
field.onChange(o.value)
}
/>
) : (
<Input value={field.value} disabled />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 5) Goal Value */}
<FormField
control={form.control}
name="goalValue"
@ -677,181 +746,173 @@ export default function RemoteAssessmentModal({
<FormItem>
<FormLabel>Goal Value</FormLabel>
<FormControl>
<SelectDropdown
options={goalValueOptions}
{selectedScenario === NEW_SENTINEL ? (
<SelectDropdown
options={goalValueOptions}
selectedOption={field.value}
onSelectOption={(o) =>
field.onChange(o.value)
}
/>
) : (
<Input value={field.value} disabled />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="addressLineOne"
render={({ field }) => (
<FormItem>
<FormLabel>Address</FormLabel>
<FormControl>
<Input placeholder="Enter address" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="postcode"
render={({ field }) => (
<FormItem>
<FormLabel>Postcode</FormLabel>
<FormControl>
<Input placeholder="Enter postcode" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="uprn"
render={({ field }) => (
<FormItem>
<FormLabel>UPRN</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter UPRN"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="valuation"
render={({ field }) => (
<FormItem>
<FormLabel>Valuation</FormLabel>
<FormDescription>
The valuation can be found at{" "}
<a
href={`https://www.zoopla.co.uk/property/uprn/${form.watch(
"uprn"
)}/`}
target="_blank"
rel="noreferrer"
>
zoopla property page
</a>
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="Enter valuation"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-2">
<p className="text-sm text-gray-600">
<strong>Optional:</strong> Property Type and Built Form
are only required if no EPC is available.
</p>
<div className="flex gap-4">
<FormField
control={form.control}
name="propertyType"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Property Type</FormLabel>
<FormControl>
<SelectUpDropdown
options={propertyTypeOptions}
selectedOption={field.value}
onSelectOption={(option) =>
field.onChange(option.value)
}
onSelectOption={(o) => field.onChange(o.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="addressLineOne"
name="builtForm"
render={({ field }) => (
<FormItem>
<FormLabel>Address</FormLabel>
<FormItem className="w-full">
<FormLabel>Built Form</FormLabel>
<FormControl>
<Input placeholder="Enter address" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="postcode"
render={({ field }) => (
<FormItem>
<FormLabel>Postcode</FormLabel>
<FormControl>
<Input placeholder="Enter postcode" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="uprn"
render={({ field }) => (
<FormItem>
<FormLabel>UPRN</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter UPRN"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
<SelectUpDropdown
options={builtFormOptions}
selectedOption={field.value}
onSelectOption={(o) => field.onChange(o.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<FormField
control={form.control}
name="valuation"
render={({ field }) => (
<FormItem>
<FormLabel>Valuation </FormLabel>
<FormDescription>
The valuation can be found at{" "}
<a
href={`https://www.zoopla.co.uk/property/uprn/${form.watch(
"uprn"
)}/`}
target="_blank"
>
`https://www.zoopla.co.uk/property/uprn/
{form.watch("uprn")}/`
</a>
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="Enter valuation"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={presignedUrlIsLoading}>
{presignedUrlIsLoading ? "Submitting…" : "Submit"}
</Button>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-gray-600">
<strong>Optional:</strong> Property Type and Built
Form are only required if no EPC is available.
</p>
<div className="flex flex-row gap-4">
<div className="w-full">
<FormField
control={form.control}
name="propertyType"
render={({ field }) => (
<FormItem>
<FormLabel>Property Type</FormLabel>
<FormControl>
<SelectUpDropdown
options={propertyTypeOptions}
selectedOption={field.value}
onSelectOption={(option) =>
field.onChange(option.value)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-full">
<FormField
control={form.control}
name="builtForm"
render={({ field }) => (
<FormItem>
<FormLabel>Built Form</FormLabel>
<FormControl>
<SelectUpDropdown
options={builtFormOptions}
selectedOption={field.value}
onSelectOption={(option) =>
field.onChange(option.value)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
<div className="flex justify-end gap-4 mt-6">
<Button
type="button"
variant="outline"
onClick={() => {
setIsOpen(false);
}}
>
Cancel
</Button>
<Button type="submit" disabled={presignedUrlIsLoading}>
{presignedUrlIsLoading ? "Submitting..." : "Submit"}
</Button>
</div>
{presignedUrlIsError && (
<p className="text-red-500 mt-2">
Error uploading files
</p>
)}
</form>
</FormProvider>
</Dialog.Panel>
</Transition.Child>
{presignedUrlIsError && (
<p className="text-red-500">Error uploading files</p>
)}
</form>
</FormProvider>
</div>
</div>
</Dialog>
</Transition>
</>
</Transition.Child>
</div>
</Dialog>
</Transition>
);
}
function setIsOpen(arg0: boolean) {

View file

@ -0,0 +1,80 @@
import { Menu, Transition } from "@headlessui/react";
import { Fragment } from "react";
import { Button } from "@/app/shadcn_components/ui/button";
import { PlusIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
export type ScenarioOption = {
label: string;
value: string;
};
interface ScenarioSelectProps {
selectedValue: string | null;
onSelect: (option: ScenarioOption) => void;
scenarios: ScenarioOption[];
}
export function SelectScenarioDropdown({
selectedValue,
onSelect,
scenarios,
}: ScenarioSelectProps) {
const createOption: ScenarioOption = {
label: "Create new scenario…",
value: "__new__",
};
const options = [createOption, ...scenarios];
const selectedLabel =
options.find((o) => o.value === selectedValue)?.label ||
"Select or create...";
return (
<Menu as="div" className="relative w-full text-left">
<Menu.Button
as={Button}
variant="default"
className="w-full justify-between bg-brandmidblue text-white"
>
<span>{selectedLabel}</span>
<ChevronDownIcon className="ml-2 h-5 w-5" aria-hidden="true" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-150"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-100"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items className="absolute mt-2 w-full bg-white border border-gray-200 rounded-lg shadow-lg z-10">
{options.map((opt) => (
<Menu.Item key={opt.value}>
{({ active }) => (
<Button
variant="ghost"
className={`w-full flex items-center text-left px-4 py-2 ${
active ? "bg-brandmidblue text-white" : "text-gray-700"
}`}
onClick={() => onSelect(opt)}
>
{opt.value === createOption.value && (
<PlusIcon
className={`mr-2 h-5 w-5 ${
active ? "text-white" : "text-brandmidblue"
}`}
aria-hidden="true"
/>
)}
{opt.label}
</Button>
)}
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Menu>
);
}

View file

@ -68,6 +68,18 @@ export async function getPortfolio(portfolioId: string): Promise<Portfolio> {
return data[0];
}
export async function getPortfolioScenarios(
portfolioId: string
): Promise<ScenarioSelect[]> {
// This function will grab all scenarios from the database for a given portfolio
const scenarios = await db
.select()
.from(scenario)
.where(eq(scenario.portfolioId, BigInt(portfolioId)));
return scenarios;
}
export async function getPortfolioPerformance(
portfolioId: string
): Promise<ScenarioSelect[]> {