From d4bf6fad9aa2413859a3e10dc531785b1b3c461e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 6 Jun 2025 13:55:01 +0100 Subject: [PATCH] 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 +
+
+ +
+
+ + ); }