Merge pull request #49 from Hestia-Homes/costs-migration

New remote assessment modal
This commit is contained in:
KhalimCK 2025-07-15 19:08:23 +01:00 committed by GitHub
commit 2d87a3c1b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 4332 additions and 416 deletions

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { MeasureKeyEnum } from "@/app/db/schema/recommendations";
const PresignedUrlBodySchema = z.object({
portfolio_id: z.string(),
@ -12,6 +13,12 @@ const PresignedUrlBodySchema = z.object({
budget: z.number().optional().nullable(),
scenario_name: z.string().optional(),
event_type: z.enum(["remote_assessment"]).optional(),
// inclusions is a list of measures, where the values are in measuresList
inclusions: z.array(MeasureKeyEnum).optional(),
exclusions: z.array(MeasureKeyEnum).optional(),
already_installed_file_path: z.string().optional(),
// optional scenario_id to link the plan to an existing scenario
scenario_id: z.string().optional().nullable(),
});
export async function POST(request: NextRequest) {

View file

@ -6,6 +6,8 @@ import {
HomeModernIcon,
WrenchScrewdriverIcon,
SunIcon,
CircleStackIcon,
BoltIcon,
} from "@heroicons/react/24/outline";
import {
NavigationMenu,
@ -35,7 +37,7 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) {
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/pre-assessment-report`}
>
<NewspaperIcon className="h-4 w-4 mr-2" />
Pre-assessment Condition Report
Data
</NavigationMenuLink>
);
@ -44,18 +46,28 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) {
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/energy-assessment`}
>
<NewspaperIcon className="h-4 w-4 mr-2" />
<BoltIcon className="h-4 w-4 mr-2" />
Energy Assessment
</NavigationMenuLink>
);
const documentsButton = (
<NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/documents`}
>
<CircleStackIcon className="h-4 w-4 mr-2" />
Documents
</NavigationMenuLink>
);
const solarAnalysisButton = (
<NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/solar-analysis`}
>
<SunIcon className="h-4 w-4 mr-2" />
Solar Analysis
Solar
</NavigationMenuLink>
);
@ -76,13 +88,14 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) {
href={`/portfolio/${portfolioId}/building-passport/${propertyId}`}
>
<HomeModernIcon className="h-4 w-4 mr-2" />
Property Information
Summary
</NavigationMenuLink>
<NavigationMenuList>
{preAssessmentReportButton}
{solarAnalysisButton}
{recommendationsButton}
{documentsButton}
{energyAssessmentsReportButton}
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}

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,27 @@
// db.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as documentsSchema from "@/app/db/documents_schema/documents";
import * as relations from "@/app/db/documents_schema/relations";
export const pool = new Pool({
host: process.env.DOCUMENTS_DB_HOST,
port: Number(process.env.DOCUMENTS_DB_PORT),
user: process.env.DOCUMENTS_DB_USERNAME,
password: process.env.DOCUMENTS_DB_PASSWORD,
database: process.env.DOCUMENTS_DB_NAME,
max: 20,
idleTimeoutMillis: 30000,
ssl: {
rejectUnauthorized: false, // set to true if you're using a CA-signed cert
},
});
const schema = {
...documentsSchema,
...relations,
};
export const documentsDB = drizzle(pool, {
schema: schema,
});

View file

@ -0,0 +1,74 @@
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
import { pgEnum } from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
export const reportType: [string, ...string[]] = [
"QUIDOS_PRESITE_NOTE",
"CHARTED_SURVEYOR_REPORT",
"ENERGY_PERFORMANCE_REPORT",
"U_VALUE_CALCULATOR_REPORT",
"OVERWRITING_U_VALUE_DECLARATION_FORM",
"OSMOSIS_CONDITION_PAS_2035_REPORT",
];
const reportTypeEnum = pgEnum("report_type", reportType);
export const companyInfo = pgTable("companyinfo", {
id: uuid("id").primaryKey().defaultRandom(),
address: text("address").notNull(),
tradingName: text("trading_name").notNull(),
postCode: text("post_code").notNull(),
faxNumber: text("fax_number"),
relatedPartyDisclosure: text("related_party_disclosure"),
});
// --- assessorInfo table ---
export const assessorInfo = pgTable("assessorinfo", {
id: uuid("id").primaryKey().defaultRandom(),
accreditationNumber: text("accreditation_number").notNull(),
name: text("name").notNull(),
phoneNumber: text("phone_number"),
emailAddress: text("email_address"),
companyId: uuid("company_id").references(() => companyInfo.id),
});
// --- buildings table ---
export const buildings = pgTable("buildings", {
id: uuid("id").primaryKey().defaultRandom(),
address: text("address").notNull(),
postcode: text("postcode").notNull(),
uprn: text("UPRN").notNull(),
landlordId: text("landlord_id").notNull(),
domnaId: text("domna_id").notNull(),
});
// --- documents table ---
export const documents = pgTable("documents", {
id: uuid("id").primaryKey().defaultRandom(),
authorId: uuid("assessor_id")
.notNull()
.references(() => assessorInfo.id),
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
documentType: reportTypeEnum("document_type").notNull(),
buildingId: uuid("building_id")
.notNull()
.references(() => buildings.id),
targetTable: text("target_table").notNull(),
targetId: uuid("target_id").notNull(),
});
export type Building = InferModel<typeof buildings, "select">;
export type Document = InferModel<typeof documents, "select">;
export type AssessorInfo = InferModel<typeof assessorInfo, "select">;
export type DocumentWithAuthor = Document & {
author: AssessorInfo;
};
export type BuildingWithDocuments = Building & {
documents: DocumentWithAuthor[];
};
export type ReportType = (typeof reportType)[number];

View file

@ -0,0 +1,22 @@
import { pgTable, serial, text, integer } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import {
buildings,
documents,
assessorInfo,
} from "@/app/db/documents_schema/documents";
export const buildingsRelations = relations(buildings, ({ many }) => ({
documents: many(documents),
}));
export const documentsRelations = relations(documents, ({ one }) => ({
building: one(buildings, {
fields: [documents.buildingId],
references: [buildings.id],
}),
author: one(assessorInfo, {
fields: [documents.authorId],
references: [assessorInfo.id],
}),
}));

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,7 @@ import {
} from "drizzle-orm/pg-core";
import { Material, material } from "./materials";
import { InferModel } from "drizzle-orm";
import { ar } from "drizzle-orm/column.d-b7dc3bdb";
import { z } from "zod";
export const recommendation = pgTable("recommendation", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
@ -108,6 +108,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"),
@ -232,3 +233,39 @@ export interface RecommendationWithMaterials {
totalWorkHours: number;
recommendationMaterials: RecommendationMaterialToMaterial[];
}
export const measuresDisplayLabels = {
internal_wall_insulation: "Internal Wall Insulation",
external_wall_insulation: "External Wall Insulation",
cavity_wall_insulation: "Cavity Wall Insulation",
loft_insulation: "Loft Insulation",
flat_roof_insulation: "Flat Roof Insulation",
room_roof_insulation: "Room-in-Roof Insulation",
suspended_floor_insulation: "Suspended Floor Insulation",
solid_floor_insulation: "Solid Floor Insulation",
boiler_upgrade: "Boiler Upgrade",
high_heat_retention_storage_heater: "High Heat Retention Storage Heater",
air_source_heat_pump: "Air Source Heat Pump",
secondary_heating: "Secondary Heating",
solar_pv: "Solar PV",
double_glazing: "Double Glazing",
secondary_glazing: "Secondary Glazing",
ventilation: "Ventilation",
low_energy_lighting: "Low Energy Lighting",
fireplace: "Fireplace",
hot_water_tank_insulation: "Hot Water Tank Insulation",
cylinder_thermostat: "Cylinder Thermostat",
} as const;
export type MeasureKey = keyof typeof measuresDisplayLabels;
export const measuresList: MeasureKey[] = Object.keys(
measuresDisplayLabels
) as MeasureKey[];
export const MeasureKeyEnum = z.enum([
...Object.keys(measuresDisplayLabels),
] as [
MeasureKey, // Force at least one measure key
...MeasureKey[]
]);

View file

@ -11,6 +11,7 @@ import { Toaster } from "@/app/shadcn_components/ui/toaster";
// If loading a variable font, you don't need to specify the font weight
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export const metadata = {

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

@ -0,0 +1,109 @@
"use client";
import React from "react";
import { TableCell, TableRow } from "@/app/shadcn_components/ui/table";
import {
DocumentWithAuthor,
ReportType,
} from "@/app/db/documents_schema/documents";
import { BrandButton } from "@/app/components/Buttons";
import { MenuButton } from "./MenuButton";
import { useState } from "react";
import { UploadModal } from "./UploadModal";
// Descriptions based on the document types
const descriptions: Record<ReportType, string> = {
QUIDOS_PRESITE_NOTE:
"Pre-site note from Quidos, detailing surveyor's findings",
CHARTED_SURVEYOR_REPORT: "Detailed report by a chartered surveyor",
ENERGY_PERFORMANCE_REPORT: "Energy performance breakdown",
U_VALUE_CALCULATOR_REPORT: "Calculated U-values for walls, floors, and roofs",
OVERWRITING_U_VALUE_DECLARATION_FORM: "Signed form for overwriting U-values",
OSMOSIS_CONDITION_PAS_2035_REPORT:
"Osmosis-generated PAS 2035 Condition Report",
};
export const DocumentSection = ({
title,
docs,
sectionKey,
documentType,
fileTypes,
}: {
title: string;
docs: DocumentWithAuthor[];
sectionKey: string;
documentType: ReportType;
fileTypes: ".xml,.pdf" | ".xml" | ".pdf";
}) => {
const [showUploadModal, setShowUploadModal] = useState(false);
const [expanded, setExpanded] = useState(false);
const toggle = () => setExpanded((prev) => !prev);
return (
<>
<TableRow className="bg-gray-50">
<TableCell className="px-6 py-4 text-sm text-gray-900">
{title}
</TableCell>
<TableCell className="px-6 py-4 text-sm text-gray-500">
{docs.length > 0 ? (
<button
onClick={toggle}
className="text-brandgold font-medium hover:underline"
>
{expanded ? "Hide Documents" : `View Documents (${docs.length})`}
</button>
) : (
<span className="text-gray-400 italic">No documents available</span>
)}
</TableCell>
<TableCell className="px-6 py-4 text-sm text-right w-1/6">
<BrandButton
label="Upload"
onClick={() => setShowUploadModal(true)}
backgroundColor="brandblue"
/>
<UploadModal
open={showUploadModal}
onClose={() => setShowUploadModal(false)}
documentType={documentType}
fileTypes={fileTypes}
/>
</TableCell>
</TableRow>
{expanded &&
docs.map((doc) => (
<TableRow key={doc.id}>
<TableCell className="px-6 py-4 text-sm text-gray-800">
{`Uploaded: ${doc.createdAt.toLocaleDateString("en-GB")}`}
<div className="text-xs text-gray-500 mt-1">
{descriptions[doc.documentType] ?? ""}
</div>
</TableCell>
<TableCell className="px-6 py-4 text-sm text-gray-500">
{`Created by: ${
doc.author.emailAddress ?? "No Author Information"
}`}
</TableCell>
<TableCell className="px-6 py-4 text-sm text-right">
<MenuButton
onView={() => {
console.log("View clicked for", doc.id);
}}
onDelete={() => {
console.log("Delete clicked for", doc.id);
}}
/>
</TableCell>
</TableRow>
))}
</>
);
};

View file

@ -0,0 +1,136 @@
"use client";
import React from "react";
import {
Table,
TableBody,
TableRow,
TableCell,
} from "@/app/shadcn_components/ui/table";
import { DocumentWithAuthor } from "@/app/db/documents_schema/documents";
import { DocumentSection } from "./DocumentSection";
import { useMutation } from "@tanstack/react-query";
import { MenuButton } from "./MenuButton";
type Props = {
documents: DocumentWithAuthor[];
// allowedTypes: (typeof DocumentType)[number][]; // Use the union type for allowedTypes as well
};
// Fetch the presigned URL from the API
async function generatePresignedUrl(fileKey: string) {
const response = await fetch("/api/energy-assessment-documents", {
method: "POST",
body: JSON.stringify({ fileKey }),
});
if (!response.ok) {
throw new Error("Failed to generate presigned URL");
}
const data = await response.json();
return data.url;
}
export const DocumentsTable: React.FC<Props> = ({
documents,
// allowedTypes,
}) => {
const [expanded, setExpanded] = React.useState(false);
// Mutation to handle the presigned URL generation
const { mutate: fetchPresignedUrl } = useMutation(
// Use the file key as the argument to generate the URL
async (fileKey: string) => await generatePresignedUrl(fileKey),
{
onSuccess: (url) => {
window.open(url, "_blank"); // Open the file in a new tab
},
onError: (error) => {
console.error("Error generating presigned URL:", error);
},
}
);
const handleDownload = () => {
// Generate URL and open in new tab
// fetchPresignedUrl(documentLocation);
console.log("Download button clicked");
};
const handleUpload = () => {
// Handle the upload logic here
console.log("Upload button clicked");
};
// We split out the various document types. Filter all of the quidos pre-site notes
const quidosPreSite = documents.filter(
(doc) => doc.documentType === "QUIDOS_PRESITE_NOTE"
);
const osmosisConditionReport = documents.filter(
(doc) => doc.documentType === "OSMOSIS_CONDITION_PAS_2035_REPORT"
);
const floors = documents.filter((doc) => doc.documentType === "FLOOR_PLAN");
const occupancy = documents.filter(
(doc) => doc.documentType === "OCCUPANCY_ASSESSMENT"
);
return (
// Quidos Pre-Site Notes Row
<Table className="min-w-full table-fixed divide-y divide-gray-200 shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<TableBody className="bg-white divide-y divide-gray-200">
<DocumentSection
title="RdSAP Summary Report"
docs={quidosPreSite}
sectionKey="rdsap"
documentType="QUIDOS_PRESITE_NOTE"
fileTypes=".pdf"
/>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={3} className="h-3 p-0" />
</TableRow>
<DocumentSection
title="Condition Report"
docs={osmosisConditionReport}
sectionKey="condition"
documentType="OSMOSIS_CONDITION_PAS_2035_REPORT"
fileTypes=".pdf"
/>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={3} className="h-3 p-0" />
</TableRow>
<DocumentSection
title="Floor Plan"
docs={floors}
sectionKey="floorplan"
documentType="FLOOR_PLAN"
fileTypes=".pdf"
/>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={3} className="h-3 p-0" />
</TableRow>
<DocumentSection
title="Occupancy Assessment"
docs={occupancy}
sectionKey="occupancy"
documentType="OCCUPANCY_ASSESSMENT"
fileTypes=".pdf"
/>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={3} className="h-3 p-0" />
</TableRow>
</TableBody>
</Table>
);
};

View file

@ -0,0 +1,34 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import { MoreVertical } from "lucide-react";
import { Button } from "@/app/shadcn_components/ui/button";
type Props = {
onView: () => void;
onDelete: () => void;
};
export const MenuButton: React.FC<Props> = ({ onView, onDelete }) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem onClick={onView}>View</DropdownMenuItem>
<DropdownMenuItem
onClick={onDelete}
className="text-red-600 focus:text-red-600"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View file

@ -0,0 +1,101 @@
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import { Button } from "@/app/shadcn_components/ui/button";
import { ReportType } from "@/app/db/documents_schema/documents";
import { Input } from "@/app/shadcn_components/ui/input";
import { useState } from "react";
type UploadModalProps = {
open: boolean;
onClose: () => void;
documentType: string;
fileTypes: ".xml,.pdf" | ".xml" | ".pdf";
};
const titles: Record<ReportType, string> = {
QUIDOS_PRESITE_NOTE: "RdSAP Summary Report",
};
export const UploadModal = ({
open,
onClose,
documentType,
fileTypes = ".xml,.pdf",
}: UploadModalProps) => {
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
const [buttonDisabled, setButtonDisabled] = useState(true);
function handleInputOnChange(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files) {
const filesArray = Array.from(e.target.files);
const extensions = filesArray.map((file) =>
file.name.split(".").pop()?.toLowerCase()
);
// The valid extension are defined by filetypes e.g. ".xml,.pdf" so we split on the comma
const validExtensions = fileTypes
.split(",")
.map((ext) => ext.replace(".", ""));
// Check if the files have valid extensions
const isValid = extensions.every((ext) =>
validExtensions.includes(ext || "")
);
if (isValid) {
setUploadFiles(filesArray);
setButtonDisabled(false);
} else {
setButtonDisabled(true);
}
} else {
setButtonDisabled(true);
}
}
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Upload Document</DialogTitle>
<DialogDescription>
Upload an <strong>{titles[documentType]}</strong>. Once uploaded,
automated extraction can begin.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input
type="file"
accept={fileTypes}
multiple={false}
className="cursor-pointer"
onChange={handleInputOnChange}
/>
</div>
<DialogFooter>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => {
console.log("Uploading for", documentType);
onClose();
}}
disabled={buttonDisabled}
>
Upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,71 @@
import { documentsDB } from "@/app/db/documents_db";
import {
buildings,
DocumentWithAuthor,
BuildingWithDocuments,
} from "@/app/db/documents_schema/documents";
import { getPropertyMeta } from "@/app/portfolio/[slug]/building-passport/[propertyId]/utils";
import { eq } from "drizzle-orm";
import { DocumentsTable } from "./DocumentsTable";
async function getDocuments(
uprn: number
): Promise<BuildingWithDocuments | undefined> {
const result = documentsDB.query.buildings.findFirst({
where: eq(buildings.uprn, String(uprn)),
with: {
documents: {
with: {
author: true, // Include author information - there will only be one author per document
},
},
},
});
// If we have no buildings, we return an empty object
if (!result) {
return {
id: "",
address: "",
postcode: "",
uprn: String(uprn),
landlordId: "",
domnaId: "",
documents: [] as DocumentWithAuthor[],
} as BuildingWithDocuments;
}
return result;
}
export default async function DocumentsPage({
params,
}: {
params: { slug: string; propertyId: string };
}) {
// Get the property UPRN
const propertyId = params.propertyId;
if (!propertyId || propertyId === "0") {
throw Error("Invalid propertyId");
}
const propertyMeta = await getPropertyMeta(propertyId);
const documents = await getDocuments(propertyMeta.uprn);
return (
<>
<div className="mt-6">
<div className="flex items-center justify-between py-4 px-6 bg-brandblue text-white font-semibold text-lg rounded-md">
Core Survey Documents
</div>
<div className="py-4">
<DocumentsTable documents={documents?.documents ?? []} />
</div>
<div className="flex items-center justify-between py-4 px-6 bg-brandblue text-white font-semibold text-lg rounded-md">
Coordination
</div>
</div>
</>
);
}

View file

@ -0,0 +1,173 @@
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
type="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>
);
}

File diff suppressed because it is too large Load diff

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[]> {

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",
},