Creating document management and upload ui

This commit is contained in:
Khalim Conn-Kowlessar 2025-06-06 13:55:01 +01:00
parent 7ca2fc25c7
commit d4bf6fad9a
7 changed files with 403 additions and 22 deletions

View file

@ -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<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

@ -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],
}),
}));

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,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<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"
);
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>
</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

@ -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 <div>Document go here</div>;
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>
</>
);
}