From 7ca2fc25c7ee5b14b25cd319c43d96bb51b17320 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 4 Jun 2025 15:27:21 +0100 Subject: [PATCH 1/6] added connection to jtk's db --- .../components/building-passport/Toolbar.tsx | 21 +++++-- src/app/db/documents_db.ts | 27 ++++++++ src/app/db/documents_schema/documents.ts | 61 +++++++++++++++++++ src/app/db/documents_schema/relations.ts | 14 +++++ .../[propertyId]/documents/page.tsx | 57 +++++++++++++++++ 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 src/app/db/documents_db.ts create mode 100644 src/app/db/documents_schema/documents.ts create mode 100644 src/app/db/documents_schema/relations.ts create mode 100644 src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index 58866ed..0c7807a 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -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`} > - Pre-assessment Condition Report + Data ); @@ -44,18 +46,28 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) { className={navigationMenuTriggerStyle() + " ml-3 mr-2"} href={`/portfolio/${portfolioId}/building-passport/${propertyId}/energy-assessment`} > - + Energy Assessment ); + const documentsButton = ( + + + Documents + + ); + const solarAnalysisButton = ( - Solar Analysis + Solar ); @@ -76,13 +88,14 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) { href={`/portfolio/${portfolioId}/building-passport/${propertyId}`} > - Property Information + Summary {preAssessmentReportButton} {solarAnalysisButton} {recommendationsButton} + {documentsButton} {energyAssessmentsReportButton} 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: reportType("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; +export type Document = InferModel; diff --git a/src/app/db/documents_schema/relations.ts b/src/app/db/documents_schema/relations.ts new file mode 100644 index 0000000..99ad5cd --- /dev/null +++ b/src/app/db/documents_schema/relations.ts @@ -0,0 +1,14 @@ +import { pgTable, serial, text, integer } from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { buildings, documents } from "@/app/db/documents_schema/documents"; + +export const buildingsRelations = relations(buildings, ({ many }) => ({ + documents: many(documents), +})); + +export const postsRelations = relations(documents, ({ one }) => ({ + building: one(buildings, { + fields: [documents.buildingId], + references: [buildings.id], + }), +})); diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx new file mode 100644 index 0000000..b8c064b --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx @@ -0,0 +1,57 @@ +import { documentsDB } from "@/app/db/documents_db"; +import { + buildings, + Document, + Building, +} from "@/app/db/documents_schema/documents"; +import { getPropertyMeta } from "@/app/portfolio/[slug]/building-passport/[propertyId]/utils"; +import { eq } from "drizzle-orm"; + +export type BuildingWithDocuments = Building & { + documents: Document[]; +}; + +async function getDocuments( + uprn: number +): Promise { + const result = documentsDB.query.buildings.findFirst({ + where: eq(buildings.uprn, String(uprn)), + with: { + documents: true, + }, + }); + + // If we have no buildings, we return an empty object + if (!result) { + return { + id: "", + address: "", + postcode: "", + uprn: String(uprn), + landlordId: "", + domnaId: "", + documents: [], + } 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); + console.log("Property Meta:", propertyMeta.uprn); + const documents = await getDocuments(propertyMeta.uprn); + console.log("Documents:", documents); + + return
Document go here
; +} From d4bf6fad9aa2413859a3e10dc531785b1b3c461e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 6 Jun 2025 13:55:01 +0100 Subject: [PATCH 2/6] Creating document management and upload ui --- src/app/db/documents_schema/documents.ts | 31 +++-- src/app/db/documents_schema/relations.ts | 12 +- .../documents/DocumentSection.tsx | 109 ++++++++++++++++++ .../[propertyId]/documents/DocumentsTable.tsx | 106 +++++++++++++++++ .../[propertyId]/documents/MenuButton.tsx | 34 ++++++ .../[propertyId]/documents/UploadModal.tsx | 101 ++++++++++++++++ .../[propertyId]/documents/page.tsx | 32 +++-- 7 files changed, 403 insertions(+), 22 deletions(-) create mode 100644 src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentSection.tsx create mode 100644 src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsTable.tsx create mode 100644 src/app/portfolio/[slug]/building-passport/[propertyId]/documents/MenuButton.tsx create mode 100644 src/app/portfolio/[slug]/building-passport/[propertyId]/documents/UploadModal.tsx diff --git a/src/app/db/documents_schema/documents.ts b/src/app/db/documents_schema/documents.ts index 4cb418c..11006b5 100644 --- a/src/app/db/documents_schema/documents.ts +++ b/src/app/db/documents_schema/documents.ts @@ -2,14 +2,16 @@ 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 = pgEnum("report_type", [ - "quidos_presite_note", - "charted_surveyor_report", - "energy_performance_report", - "u_value_calculator_report", - "overwriting_u_value_declaration_form", - "osmosis_condition_pas_2035_report", -]); +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(), @@ -48,7 +50,7 @@ export const documents = pgTable("documents", { .notNull() .references(() => assessorInfo.id), createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - documentType: reportType("document_type").notNull(), + documentType: reportTypeEnum("document_type").notNull(), buildingId: uuid("building_id") .notNull() @@ -59,3 +61,14 @@ export const documents = pgTable("documents", { export type Building = InferModel; export type Document = InferModel; +export type AssessorInfo = InferModel; + +export type DocumentWithAuthor = Document & { + author: AssessorInfo; +}; + +export type BuildingWithDocuments = Building & { + documents: DocumentWithAuthor[]; +}; + +export type ReportType = (typeof reportType)[number]; diff --git a/src/app/db/documents_schema/relations.ts b/src/app/db/documents_schema/relations.ts index 99ad5cd..927be39 100644 --- a/src/app/db/documents_schema/relations.ts +++ b/src/app/db/documents_schema/relations.ts @@ -1,14 +1,22 @@ import { pgTable, serial, text, integer } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; -import { buildings, documents } from "@/app/db/documents_schema/documents"; +import { + buildings, + documents, + assessorInfo, +} from "@/app/db/documents_schema/documents"; export const buildingsRelations = relations(buildings, ({ many }) => ({ documents: many(documents), })); -export const postsRelations = relations(documents, ({ one }) => ({ +export const documentsRelations = relations(documents, ({ one }) => ({ building: one(buildings, { fields: [documents.buildingId], references: [buildings.id], }), + author: one(assessorInfo, { + fields: [documents.authorId], + references: [assessorInfo.id], + }), })); diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentSection.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentSection.tsx new file mode 100644 index 0000000..5b52f10 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentSection.tsx @@ -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 = { + 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 ( + <> + + + {title} + + + + {docs.length > 0 ? ( + + ) : ( + No documents available + )} + + + + setShowUploadModal(true)} + backgroundColor="brandblue" + /> + + setShowUploadModal(false)} + documentType={documentType} + fileTypes={fileTypes} + /> + + + + {expanded && + docs.map((doc) => ( + + + {`Uploaded: ${doc.createdAt.toLocaleDateString("en-GB")}`} +
+ {descriptions[doc.documentType] ?? ""} +
+
+ + + {`Created by: ${ + doc.author.emailAddress ?? "No Author Information" + }`} + + + + { + console.log("View clicked for", doc.id); + }} + onDelete={() => { + console.log("Delete clicked for", doc.id); + }} + /> + +
+ ))} + + ); +}; diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsTable.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsTable.tsx new file mode 100644 index 0000000..231fc35 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsTable.tsx @@ -0,0 +1,106 @@ +"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 = ({ + 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" + ); + + return ( + // Quidos Pre-Site Notes Row + + + + + + + + + + + + + +
+ ); +}; diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/MenuButton.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/MenuButton.tsx new file mode 100644 index 0000000..16fbe11 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/MenuButton.tsx @@ -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 = ({ onView, onDelete }) => { + return ( + + + + + + View + + Delete + + + + ); +}; diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/UploadModal.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/UploadModal.tsx new file mode 100644 index 0000000..53ef373 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/UploadModal.tsx @@ -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 = { + QUIDOS_PRESITE_NOTE: "RdSAP Summary Report", +}; + +export const UploadModal = ({ + open, + onClose, + documentType, + fileTypes = ".xml,.pdf", +}: UploadModalProps) => { + const [uploadFiles, setUploadFiles] = useState([]); + const [buttonDisabled, setButtonDisabled] = useState(true); + + function handleInputOnChange(e: React.ChangeEvent) { + 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 ( + + + + Upload Document + + Upload an {titles[documentType]}. Once uploaded, + automated extraction can begin. + + + +
+ +
+ + + + + +
+
+ ); +}; diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx index b8c064b..5faeba3 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx @@ -1,15 +1,12 @@ import { documentsDB } from "@/app/db/documents_db"; import { buildings, - Document, - Building, + DocumentWithAuthor, + BuildingWithDocuments, } from "@/app/db/documents_schema/documents"; import { getPropertyMeta } from "@/app/portfolio/[slug]/building-passport/[propertyId]/utils"; import { eq } from "drizzle-orm"; - -export type BuildingWithDocuments = Building & { - documents: Document[]; -}; +import { DocumentsTable } from "./DocumentsTable"; async function getDocuments( uprn: number @@ -17,7 +14,11 @@ async function getDocuments( const result = documentsDB.query.buildings.findFirst({ where: eq(buildings.uprn, String(uprn)), with: { - documents: true, + documents: { + with: { + author: true, // Include author information - there will only be one author per document + }, + }, }, }); @@ -30,7 +31,7 @@ async function getDocuments( uprn: String(uprn), landlordId: "", domnaId: "", - documents: [], + documents: [] as DocumentWithAuthor[], } as BuildingWithDocuments; } @@ -49,9 +50,18 @@ export default async function DocumentsPage({ } const propertyMeta = await getPropertyMeta(propertyId); - console.log("Property Meta:", propertyMeta.uprn); const documents = await getDocuments(propertyMeta.uprn); - console.log("Documents:", documents); - return
Document go here
; + return ( + <> +
+
+ Core Survey Documents +
+
+ +
+
+ + ); } From b99069a7d42d68d7b52bbcd881f3a93c90955cb6 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 13 Jun 2025 11:57:49 +0100 Subject: [PATCH 3/6] Working on remote assessment modal ui --- src/app/components/portfolio/Toolbar.tsx | 5 +- .../migrations/0100_wakeful_doctor_doom.sql | 1 + src/app/db/migrations/meta/0100_snapshot.json | 2988 +++++++++++++++++ src/app/db/migrations/meta/_journal.json | 7 + src/app/db/schema/recommendations.ts | 2 +- .../portfolio/[slug]/(portfolio)/layout.tsx | 6 +- .../components/RemoteAssessmentModal.tsx | 513 +-- .../components/SelectScenarioDropdown.tsx | 80 + src/app/portfolio/[slug]/utils.ts | 12 + 9 files changed, 3384 insertions(+), 230 deletions(-) create mode 100644 src/app/db/migrations/0100_wakeful_doctor_doom.sql create mode 100644 src/app/db/migrations/meta/0100_snapshot.json create mode 100644 src/app/portfolio/[slug]/components/SelectScenarioDropdown.tsx diff --git a/src/app/components/portfolio/Toolbar.tsx b/src/app/components/portfolio/Toolbar.tsx index 35d7718..1ba07b6 100644 --- a/src/app/components/portfolio/Toolbar.tsx +++ b/src/app/components/portfolio/Toolbar.tsx @@ -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} /> @@ -21,7 +23,7 @@ export default async function PortfolioLayout({
- +
diff --git a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx index 8b119d9..01586f4 100644 --- a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx +++ b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx @@ -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({ - {selectedOption || "Select an option"} + {selectedOption || "Select option"}