improving ui of remote assessment modal

This commit is contained in:
Khalim Conn-Kowlessar 2025-06-13 21:23:11 +01:00
parent b99069a7d4
commit 4b23c950d5
4 changed files with 308 additions and 288 deletions

View file

@ -0,0 +1,172 @@
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";
import { Float } from "@headlessui-float/react";
export type Option = { label: string; value: string; disabled?: boolean };
// Extend ScenarioOption to include extra metadata
export type ScenarioOption = {
label: string; // scenario name
value: string; // scenario value
housingType?: string; // existing scenario housing type
goal?: string; // existing scenario goal
goalValue?: string; // existing scenario goal value
};
interface ScenarioSelectProps {
selectedValue: string | null;
onSelect: (option: ScenarioOption) => void;
scenarios: ScenarioOption[];
}
interface SelectDropdownProps {
options: Option[];
selectedOption: string;
onSelectOption: (opt: Option) => void;
}
export function SelectScenarioDropdown({
selectedValue,
onSelect,
scenarios,
}: ScenarioSelectProps) {
const newOption: ScenarioOption = {
label: "New scenario",
value: "__new__",
};
const options = [newOption, ...scenarios];
const selectedLabel =
options.find((o) => o.value === selectedValue)?.label || "Choose scenario";
return (
<Menu as="div" className="relative w-full text-left">
<Menu.Button
as={Button}
variant="default"
className="w-full justify-start bg-brandmidblue text-white rounded-lg shadow-sm hover:bg-brandblue focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brandmidblue"
>
{selectedValue === newOption.value && (
<PlusIcon className="mr-2 h-5 w-5 text-white" aria-hidden="true" />
)}
<span className="flex-1 text-left">{selectedLabel}</span>
<ChevronDownIcon
className="ml-2 h-5 w-5 text-white"
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 py-1">
{options.map((opt) => (
<Menu.Item key={opt.value}>
{({ active }) => (
<button
onClick={() => onSelect(opt)}
className={
`group w-full text-left px-4 py-3 text-sm transition-colors flex flex-col space-y-1 ` +
(active ? "bg-brandmidblue text-white" : "text-gray-800")
}
>
{/* If new scenario, show plus icon and label only */}
{opt.value === newOption.value ? (
<div className="flex items-center">
<PlusIcon
className={`mr-2 h-5 w-5 ${
active ? "text-white" : "text-brandmidblue"
}`}
aria-hidden="true"
/>
<span>{opt.label}</span>
</div>
) : (
/* Existing scenario: show two rows side-by-side pairs */
<>
<div className="flex justify-between w-full">
<span className="font-medium">{opt.label}</span>
<span className="italic">{opt.housingType}</span>
</div>
<div className="flex justify-between w-full text-sm">
<span>{opt.goal}</span>
<span>{opt.goalValue}</span>
</div>
</>
)}
</button>
)}
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Menu>
);
}
export function SelectDropdown({
options,
selectedOption,
onSelectOption,
}: {
options: Option[];
selectedOption: string;
onSelectOption: (opt: Option) => void;
}) {
const label =
options.find((o) => o.value === selectedOption)?.label || "Select…";
return (
<Menu as="div" className="relative w-full text-left">
<Menu.Button
as={Button}
variant="outline"
className="w-full flex items-center justify-between gap-2 rounded-lg border border-brandbrown bg-white px-4 py-2 text-sm text-gray-700 shadow-sm hover:border-brandbrown focus:outline-none focus:ring-2 focus:ring-brandbrown"
>
<span className="truncate flex-1 text-left">{label}</span>
<ChevronDownIcon className="h-5 w-5 text-gray-500" />
</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 z-10 mt-2 w-full rounded-lg border border-brandbrown bg-white shadow-xl focus:outline-none">
{options.map((opt) => (
<Menu.Item key={opt.value} disabled={opt.disabled}>
{({ active, disabled }) => (
<button
type="button"
onClick={() => onSelectOption(opt)}
disabled={disabled}
className={`w-full px-4 py-2 text-sm text-left transition-colors ${
disabled
? "cursor-not-allowed text-gray-400"
: active
? "bg-brandbrown text-white"
: "text-gray-700 hover:bg-gray-50"
}`}
>
{opt.label}
</button>
)}
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Menu>
);
}

View file

@ -4,8 +4,6 @@ import { Dialog, Transition, Menu } from "@headlessui/react";
import { Fragment, useMemo } from "react";
import { Input } from "@/app/shadcn_components/ui/input";
import { Button } from "@/app/shadcn_components/ui/button";
import { Float } from "@headlessui-float/react";
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { useMutation } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useForm, FormProvider } from "react-hook-form";
@ -22,7 +20,10 @@ import {
import { useToast } from "@/app/hooks/use-toast";
import { ScenarioSelect } from "@/app/db/schema/recommendations";
import { useState } from "react";
import { SelectScenarioDropdown } from "./SelectScenarioDropdown";
import {
SelectScenarioDropdown,
SelectDropdown,
} from "./RemoteAssessmentDropdowns";
type Option = {
label: string;
@ -169,106 +170,6 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
export function SelectDropdown({
options,
selectedOption,
onSelectOption,
}: DropdownProps) {
return (
<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 option"}
<ChevronDownIcon
className="ml-2 -mr-1 h-5 w-5 text-violet-200 hover:text-violet-100"
aria-hidden="true"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items className=" origin-bottom left-0 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
{options.map((option) => (
<Menu.Item key={option.value} disabled={option.disabled}>
{({ active }) => (
<button
className={`${
active
? "bg-brandmidblue text-white w-full"
: "text-gray-900 w-full"
} group flex items-center px-4 py-2 text-sm `}
onClick={() => onSelectOption(option)}
>
{option.label}
</button>
)}
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Float>
</Menu>
);
}
export function SelectUpDropdown({
options,
selectedOption,
onSelectOption,
width = "w-1/2",
}: OptionalDropdownProps) {
const menuButtonStyle = (width = "w-full") =>
`inline-flex justify-center ${width} 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`;
return (
<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 option"}
<ChevronRightIcon
className="ml-2 -mr-1 h-5 w-5 text-violet-200 hover:text-violet-100"
aria-hidden="true"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items className=" origin-bottom left-0 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
{options.map((option) => (
<Menu.Item key={option.value} disabled={option.disabled}>
{({ active }) => (
<button
className={`${
active
? "bg-brandmidblue text-white w-full"
: "text-gray-900 w-full"
} group flex items-center px-4 py-2 text-sm `}
onClick={() => onSelectOption(option)}
>
{option.label}
</button>
)}
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Float>
</Menu>
);
}
async function uploadCsvToS3({
presignedUrl,
file,
@ -645,7 +546,7 @@ export default function RemoteAssessmentModal({
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">
<div className="inline-block w-full max-w-2xl p-6 my-8 overflow-visible 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>
@ -669,99 +570,112 @@ export default function RemoteAssessmentModal({
{selectedScenario !== null && (
<>
{/* 2) Scenario Name */}
<FormField
control={form.control}
name="scenario"
render={({ field }) => (
<FormItem>
<FormLabel>Scenario Name</FormLabel>
<FormControl>
<Input
placeholder="Enter scenario name"
disabled={selectedScenario !== NEW_SENTINEL}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 3) Housing Type */}
<FormField
control={form.control}
name="housingType"
render={({ field }) => (
<FormItem>
<FormLabel>Housing Type</FormLabel>
<FormControl>
{selectedScenario === NEW_SENTINEL ? (
<SelectDropdown
options={selecthousingTypeOptions}
selectedOption={field.value}
onSelectOption={(o) =>
field.onChange(o.value)
}
<div className="grid grid-cols-2 gap-4">
{/* Scenario Name */}
<FormField
control={form.control}
name="scenario"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-800">
Scenario Name
</FormLabel>
<FormControl>
<Input
{...field}
disabled={selectedScenario !== NEW_SENTINEL}
placeholder="Scenario name"
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
/>
) : (
<Input value={field.value} disabled />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</FormControl>
<FormMessage className="text-brandbrown" />
</FormItem>
)}
/>
{/* 4) Goal */}
<FormField
control={form.control}
name="goal"
render={({ field }) => (
<FormItem>
<FormLabel>Goal</FormLabel>
<FormControl>
{selectedScenario === NEW_SENTINEL ? (
<SelectDropdown
options={selectGoalOptions}
selectedOption={field.value}
onSelectOption={(o) =>
field.onChange(o.value)
}
/>
) : (
<Input value={field.value} disabled />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Housing Type */}
<FormField
control={form.control}
name="housingType"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-800">
Housing Type
</FormLabel>
<FormControl>
{selectedScenario === NEW_SENTINEL ? (
<SelectDropdown
options={selecthousingTypeOptions}
selectedOption={field.value}
onSelectOption={(o) =>
field.onChange(o.value)
}
/>
) : (
<Input value={field.value} disabled />
)}
</FormControl>
<FormMessage className="text-brandbrown" />
</FormItem>
)}
/>
</div>
{/* 5) Goal Value */}
<FormField
control={form.control}
name="goalValue"
render={({ field }) => (
<FormItem>
<FormLabel>Goal Value</FormLabel>
<FormControl>
{selectedScenario === NEW_SENTINEL ? (
<SelectDropdown
options={goalValueOptions}
selectedOption={field.value}
onSelectOption={(o) =>
field.onChange(o.value)
}
/>
) : (
<Input value={field.value} disabled />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
{/* Goal */}
<FormField
control={form.control}
name="goal"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-800">
Goal
</FormLabel>
<FormControl>
{selectedScenario === NEW_SENTINEL ? (
<SelectDropdown
options={selectGoalOptions}
selectedOption={field.value}
onSelectOption={(o) =>
field.onChange(o.value)
}
/>
) : (
<Input value={field.value} disabled />
)}
</FormControl>
<FormMessage className="text-brandbrown" />
</FormItem>
)}
/>
{/* Goal Value */}
<FormField
control={form.control}
name="goalValue"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-800">
Goal Value
</FormLabel>
<FormControl>
{selectedScenario === NEW_SENTINEL ? (
<SelectDropdown
options={selecthousingTypeOptions}
selectedOption={field.value}
onSelectOption={(opt) =>
field.onChange(opt.value)
}
/>
) : (
<Input value={field.value} disabled />
)}
</FormControl>
<FormMessage className="text-brandbrown" />
</FormItem>
)}
/>
</div>
</>
)}
@ -770,11 +684,15 @@ export default function RemoteAssessmentModal({
name="addressLineOne"
render={({ field }) => (
<FormItem>
<FormLabel>Address</FormLabel>
<FormLabel className="text-gray-800">Address</FormLabel>
<FormControl>
<Input placeholder="Enter address" {...field} />
<Input
placeholder="Enter address"
{...field}
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
/>
</FormControl>
<FormMessage />
<FormMessage className="text-brandbrown" />
</FormItem>
)}
/>
@ -784,11 +702,17 @@ export default function RemoteAssessmentModal({
name="postcode"
render={({ field }) => (
<FormItem>
<FormLabel>Postcode</FormLabel>
<FormLabel className="text-gray-800">
Postcode
</FormLabel>
<FormControl>
<Input placeholder="Enter postcode" {...field} />
<Input
placeholder="Enter postcode"
{...field}
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
/>
</FormControl>
<FormMessage />
<FormMessage className="text-brandbrown" />
</FormItem>
)}
/>
@ -798,7 +722,7 @@ export default function RemoteAssessmentModal({
name="uprn"
render={({ field }) => (
<FormItem>
<FormLabel>UPRN</FormLabel>
<FormLabel className="text-gray-800">UPRN</FormLabel>
<FormControl>
<Input
type="number"
@ -807,9 +731,10 @@ export default function RemoteAssessmentModal({
onChange={(e) =>
field.onChange(Number(e.target.value))
}
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
/>
</FormControl>
<FormMessage />
<FormMessage className="text-brandbrown" />
</FormItem>
)}
/>
@ -819,7 +744,9 @@ export default function RemoteAssessmentModal({
name="valuation"
render={({ field }) => (
<FormItem>
<FormLabel>Valuation</FormLabel>
<FormLabel className="text-gray-800">
Valuation
</FormLabel>
<FormDescription>
The valuation can be found at{" "}
<a
@ -840,9 +767,10 @@ export default function RemoteAssessmentModal({
onChange={(e) =>
field.onChange(Number(e.target.value))
}
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
/>
</FormControl>
<FormMessage />
<FormMessage className="text-brandbrown" />
</FormItem>
)}
/>
@ -860,7 +788,7 @@ export default function RemoteAssessmentModal({
<FormItem className="w-full">
<FormLabel>Property Type</FormLabel>
<FormControl>
<SelectUpDropdown
<SelectDropdown
options={propertyTypeOptions}
selectedOption={field.value}
onSelectOption={(o) => field.onChange(o.value)}
@ -877,7 +805,7 @@ export default function RemoteAssessmentModal({
<FormItem className="w-full">
<FormLabel>Built Form</FormLabel>
<FormControl>
<SelectUpDropdown
<SelectDropdown
options={builtFormOptions}
selectedOption={field.value}
onSelectOption={(o) => field.onChange(o.value)}

View file

@ -1,80 +0,0 @@
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

@ -102,7 +102,7 @@ module.exports = {
hovertan: "#947750",
brandgold: "#f1bb06",
hovergold: "#c79d12",
brandbrown: "#3d1e05",
brandbrown: "#c4a47c",
brandmidblue: "#3943b7",
brandlightblue: "#00a9f4",
border: "hsl(var(--border))",
@ -144,7 +144,7 @@ module.exports = {
hoverblue: "#3e4073",
brandtan: "#d3b488",
hovertan: "#947750",
brandbrown: "#3d1e05",
brandbrown: "#c4a47c",
brandmidblue: "#3943b7",
brandlightblue: "#00a9f4",
},