Merge pull request #127 from Hestia-Homes/new-reporting

changing name
This commit is contained in:
KhalimCK 2025-11-12 21:34:00 +00:00 committed by GitHub
commit e4fb19c245
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 283 additions and 90 deletions

View file

@ -13,3 +13,5 @@ export const filesFromSurveyor = pgTable("files_from_surveyor", {
s3JsonUrl: text("s3_json_url").notNull(),
uploadedAt: timestamp("uploaded_at").notNull().defaultNow(),
});
export type FilesFromSurveyor = typeof filesFromSurveyor.$inferSelect;

View file

@ -25,7 +25,7 @@ export async function MagicLinksEmail({
const result = await transport.sendMail({
to: identifier,
from: provider.from,
subject: "Your secure Domna IQ sign-in link",
subject: "Your secure Ara sign-in link",
text: plainText({ url, host }),
html: domnaHtml({ url, host, brandColor, accentColor, brown, background }),
});
@ -70,13 +70,13 @@ function domnaHtml({
</tr>
<tr>
<td align="center" style="padding: 10px 10px 10px; color: #333;">
<h2 style="color: ${brandColor}; font-size: 22px; margin-bottom: 16px;">Welcome back to Domna IQ</h2>
<h2 style="color: ${brandColor}; font-size: 22px; margin-bottom: 16px;">Welcome back to Ara by Domna</h2>
<p style="font-size: 16px; line-height: 1.6; color: #444; margin-bottom: 32px;">
Click below to securely sign in to your account and continue your retrofit journey.
</p>
<a href="${url}" target="_blank"
style="display: inline-block; padding: 14px 28px; background: ${brown}; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px;">
Sign in to Domna IQ
Sign in to Ara
</a>
<p style="margin-top: 36px; font-size: 13px; color: #777;">
If you didnt request this email, you can safely ignore it.
@ -94,5 +94,5 @@ function domnaHtml({
}
function plainText({ url, host }: { url: string; host: string }) {
return `Sign in to Domna IQ\n${url}\n\nIf you did not request this email, you can safely ignore it.\n`;
return `Sign in to Ara by Domna\n${url}\n\nIf you did not request this email, you can safely ignore it.\n`;
}

View file

@ -176,7 +176,7 @@ export default function OnboardingPage() {
<div className="relative z-10 flex items-start justify-start w-full h-full">
<div className="mt-20 p-20 text-gray-100 shadow-xl w-[75%] rounded-br-[8rem] bg-gradient-to-r from-brandblue to-midblue">
<h2 className="text-5xl font-bold mb-4">Welcome to Domna IQ</h2>
<h2 className="text-5xl font-bold mb-4">Welcome to Ara</h2>
<p className="text-xl leading-relaxed text-brandbrown">
Help us get to know you so we can tailor your experience.
</p>

View file

@ -0,0 +1,14 @@
export default async function ReportingPage(props: {
params: Promise<{ slug: string }>;
}) {
const params = await props.params;
const portfolioId = params.slug;
return (
<>
<div className="flex justify-center">
<div>Reporting Page for portfolio: {portfolioId}</div>
</div>
</>
);
}

View file

@ -0,0 +1 @@

View file

@ -4,82 +4,186 @@ import React from "react";
import { TableCell, TableRow } from "@/app/shadcn_components/ui/table";
import { BrandButton } from "@/app/components/Buttons";
import { UploadModal } from "./UploadModal";
import { documentTypeTitles, type ReportType } from "@/app/db/surveyDB/schema/documents";
import type { getUploadedFiles, getUploadedFile } from "@/app/db/surveyDB/schema/surveyDB";
import {
documentTypeTitles,
type ReportType,
} from "@/app/db/surveyDB/schema/documents";
import type { getUploadedFiles } from "@/app/db/surveyDB/schema/surveyDB";
import type { FilesFromSurveyor } from "@/app/db/schema/files_from_surveyor";
import { useMutation } from "@tanstack/react-query";
type Props = {
reportType: ReportType;
reportType: ReportType | null;
uprn: string;
files: getUploadedFiles;
files: getUploadedFiles | FilesFromSurveyor[];
};
export const DocumentSection: React.FC<Props> = ({ reportType, uprn, files }) => {
type AnyFile = Record<string, unknown>;
const DATE_KEYS = [
"uploadedAt",
"s3FileUploadTimestamp",
"createdAt",
"updatedAt",
"timestamp",
] as const;
const URL_KEYS = ["s3FileUri", "s3JsonUrl", "url", "href", "link"] as const;
function pickFirst<T = unknown>(
obj: AnyFile,
keys: readonly string[]
): T | undefined {
for (const k of keys) {
if (k in obj && obj[k] != null) return obj[k] as T;
}
return undefined;
}
function asDate(val: unknown): Date | undefined {
if (!val) return undefined;
if (val instanceof Date) return val;
const d = new Date(String(val));
return isNaN(d.getTime()) ? undefined : d;
}
type NormalizedFile = {
url?: string;
uploadedAt?: Date;
original: AnyFile;
};
const NAME_KEYS = ["name", "filename", "fileName", "title"] as const;
function normalizeFile(f: AnyFile): NormalizedFile & { name?: string } {
const url = pickFirst<string>(f, URL_KEYS);
const dateRaw = pickFirst<unknown>(f, DATE_KEYS);
const uploadedAt = asDate(dateRaw);
let name = pickFirst<string>(f, NAME_KEYS);
if (!name && url) {
// Extract last path segment from URL
const parts = url.split("/");
name = decodeURIComponent(parts[parts.length - 1]);
}
return { url, uploadedAt, name, original: f };
}
function getLatestNormalized(files: AnyFile[]): NormalizedFile | null {
if (!Array.isArray(files) || files.length === 0) return null;
const normalized = files.map(normalizeFile);
// Prefer items that have a valid uploadedAt. If none have a date, fall back to first.
const withDate = normalized.filter((n) => n.uploadedAt);
if (withDate.length === 0) return normalized[0];
return withDate.reduce((acc, cur) =>
cur.uploadedAt!.getTime() > acc.uploadedAt!.getTime() ? cur : acc
);
}
function formatWhen(d: Date | string) {
const date = new Date(d);
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
const hh = String(date.getUTCHours()).padStart(2, "0");
const min = String(date.getUTCMinutes()).padStart(2, "0");
return `${dd} ${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][date.getUTCMonth()]} ${yyyy}, ${hh}:${min} UTC`;
}
export function usePresignedUrl() {
return useMutation({
mutationFn: async (key: string) => {
const res = await fetch("/api/sign-s3-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
});
if (!res.ok) {
throw new Error("Failed to get presigned URL");
}
const data = await res.json();
return data.url as string;
},
});
}
export const DocumentSection: React.FC<Props> = ({
reportType,
uprn,
files,
}) => {
const [showUploadModal, setShowUploadModal] = React.useState(false);
const router = useRouter();
console.log("files", files)
const latest = React.useMemo(
() => getLatestNormalized(files as unknown as AnyFile[]),
[files]
);
// const latestFile = React.useMemo<getUploadedFile | null>(() => {
// if (!files?.length) return null;
// return files.reduce((acc, cur) => {
// const accTime = new Date(acc.s3FileUploadTimestamp as any).getTime();
// const curTime = new Date(cur.s3FileUploadTimestamp as any).getTime();
// return curTime > accTime ? cur : acc;
// }, files[0]);
// }, [files]);
const title =
reportType && documentTypeTitles[reportType] !== "Other"
? documentTypeTitles[reportType]
: latest?.url
? decodeURIComponent(latest.url.split("/").pop() || "")
: "Other";
// const latestFile = React.useMemo(() => {
// console.log("Recomputing latestFile from", files);
// if (!Array.isArray(files) || files.length === 0) return null;
// return files.reduce((acc, cur) => {
// const accTime = new Date(acc.s3FileUploadTimestamp).getTime();
// const curTime = new Date(cur.s3FileUploadTimestamp).getTime();
// return curTime > accTime ? cur : acc;
// }, files[0]);
// }, [JSON.stringify(files)]);
const latestFile = files.length > 0 ? files.reduce((acc, cur) => {
return new Date(cur.s3FileUploadTimestamp).getTime() > new Date(acc.s3FileUploadTimestamp).getTime() ? cur : acc;
}) : null;
const formatWhen = (d: string | Date) =>
new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
timeZone: "UTC"
}).format(new Date(d));
const title = documentTypeTitles[reportType];
const count = files.length;
const latestUpload = latestFile ? String(formatWhen(latestFile.s3FileUploadTimestamp)) : "";
// console.log("latestFile", latestFile)
const latestUpload = latest?.uploadedAt ? formatWhen(latest.uploadedAt) : "";
const { mutateAsync: getSignedUrl, isPending } = usePresignedUrl();
const handleViewFile = async (fileUrl: string) => {
try {
// Extract S3 key from the full URL
const bucketDomain = "retrofit-data-dev.s3.eu-west-2.amazonaws.com/";
const key = fileUrl.split(bucketDomain)[1];
if (!key) throw new Error("Invalid S3 file URL");
const signedUrl = await getSignedUrl(key);
window.open(signedUrl, "_blank", "noopener,noreferrer");
} catch (err) {
console.error("Failed to open presigned URL:", err);
alert("Could not open file.");
}
};
return (
<>
<TableRow className="bg-gray-50">
<TableCell className="px-6 py-4 text-sm text-gray-900">
<TableCell
className="px-4 py-4 text-sm text-gray-900 max-w-[250px] truncate"
title={title}
>
{title}
</TableCell>
<TableCell className="px-6 py-4 text-sm text-gray-500">
{latestFile ? (
{latest ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<a
href={latestFile.s3FileUri}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:no-underline"
>
View latest file
</a>
{/* <div className="text-xs text-gray-400">
uploaded {latestUpload}
</div> */}
{latest.url ? (
<button
type="button"
onClick={() => handleViewFile(latest.url!)}
disabled={isPending}
className="underline underline-offset-2 hover:no-underline disabled:opacity-50"
>
{isPending ? "Loading..." : "View latest file"}
</button>
) : (
<span className="text-gray-400">Latest file has no URL</span>
)}
{latestUpload && (
<div className="text-xs text-gray-400">
uploaded {latestUpload}
</div>
)}
</div>
<span className="text-xs text-gray-500">
{count} file{count !== 1 && "s"} on record
@ -90,22 +194,26 @@ const latestFile = files.length > 0 ? files.reduce((acc, cur) => {
)}
</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);
router.refresh();
}}
documentType={reportType}
uprn={uprn}
/>
</TableCell>
{reportType ? (
<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);
router.refresh();
}}
documentType={reportType}
uprn={uprn}
/>
</TableCell>
) : (
<TableCell className="px-6 py-4 text-sm text-right w-1/6"></TableCell>
)}
</TableRow>
</>
);

View file

@ -10,7 +10,7 @@ import { DocumentSection } from "./DocumentSection";
import {
type ReportType,
REPORT_TYPES,
dbLabelToReportType, // <-- import the map
dbLabelToReportType,
} from "@/app/db/surveyDB/schema/documents";
import type { getUploadedFile } from "@/app/db/surveyDB/schema/surveyDB";
@ -27,7 +27,7 @@ export const DocumentsTable: React.FC<Props> = ({
const map: Partial<Record<ReportType, getUploadedFile[]>> = {};
for (const file of uploadedFilesData ?? []) {
const uiKey = dbLabelToReportType[file.docType]; // map DB → UI
const uiKey = dbLabelToReportType[file.docType]; // map DB → UI. We may not have a docType for surveyor files
if (!uiKey) continue; // unknown/legacy type? skip safely
(map[uiKey] ??= []).push(file);
@ -45,14 +45,11 @@ export const DocumentsTable: React.FC<Props> = ({
return map;
}, [uploadedFilesData]);
console.log("filesByType", filesByType);
return (
<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">
{REPORT_TYPES.map((reportType) => {
const filesForType = filesByType[reportType] ?? [];
console.log("reportType", reportType);
return (
<React.Fragment key={reportType}>
<DocumentSection

View file

@ -0,0 +1,38 @@
"use client";
import React from "react";
import {
Table,
TableBody,
TableRow,
TableCell,
} from "@/app/shadcn_components/ui/table";
import { DocumentSection } from "./DocumentSection";
import { FilesFromSurveyor } from "@/app/db/schema/files_from_surveyor";
type Props = {
uprn: string;
files: FilesFromSurveyor[];
};
export const GenericDocumentsTable: React.FC<Props> = ({ uprn, files }) => {
return (
<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">
{files.map((file, index) => {
return (
<React.Fragment key={index}>
<DocumentSection
reportType={null}
uprn={uprn}
files={[file]} // array of rows
/>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={3} className="h-3 p-0" />
</TableRow>
</React.Fragment>
);
})}
</TableBody>
</Table>
);
};

View file

@ -1,7 +1,11 @@
import { getPropertyMeta } from "@/app/portfolio/[slug]/building-passport/[propertyId]/utils";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { DocumentsTable } from "./DocumentsTable";
import { GenericDocumentsTable } from "./GenericDocumentsTable";
import { surveyDB } from "@/app/db/surveyDB/connection";
import { db } from "@/app/db/db";
import { filesFromSurveyor } from "@/app/db/schema/files_from_surveyor";
import type { FilesFromSurveyor } from "@/app/db/schema/files_from_surveyor";
import { uploadedFiles } from "@/app/db/surveyDB/schema/surveyDB";
import { type getUploadedFiles } from "@/app/db/surveyDB/schema/surveyDB";
@ -13,6 +17,23 @@ async function getDocuments(uprn: number): Promise<getUploadedFiles> {
return result;
}
async function getSurveyorDocuments(
portfolioId: string,
propertyId: string
): Promise<FilesFromSurveyor[]> {
const files = await db
.select()
.from(filesFromSurveyor)
.where(
and(
eq(filesFromSurveyor.portfolioId, BigInt(portfolioId)),
eq(filesFromSurveyor.propertyId, BigInt(propertyId))
)
);
return files;
}
export default async function DocumentsPage(props: {
params: Promise<{ slug: string; propertyId: string }>;
}) {
@ -26,7 +47,8 @@ export default async function DocumentsPage(props: {
const propertyMeta = await getPropertyMeta(propertyId);
const uploadedFiles = await getDocuments(propertyMeta.uprn);
console.log("Uploaded files:", uploadedFiles);
// We also fetch surveyor documents, which is a temp solution
const surveyorDocuments = await getSurveyorDocuments(params.slug, propertyId);
return (
<>
@ -40,6 +62,18 @@ export default async function DocumentsPage(props: {
uploadedFilesData={uploadedFiles}
/>
</div>
<div className="py-4"></div>
<div className="flex items-center justify-between py-4 px-6 bg-brandblue text-white font-semibold text-lg rounded-md">
Surveyor Uploaded Documents
</div>
<div className="py-4">
<GenericDocumentsTable
uprn={propertyMeta.uprn.toString()}
files={surveyorDocuments}
/>
</div>
<div className="py-4"></div>
<div className="flex items-center justify-between py-4 px-6 bg-brandblue text-white font-semibold text-lg rounded-md">
Coordination

View file

@ -26,7 +26,6 @@ const UploadPage: React.FC = () => {
);
if (res.ok) {
const data = await res.json();
console.log(data, "hello");
setFiles(data.files);
}
};

View file

@ -144,7 +144,7 @@ export default function LoadingPage(props: {
Building your retrofit plan
</h2>
<p className="text-slate-600 mb-3 text-base">
Domna IQ is analysing your data and generating your plan summary.
Ara is analysing your data and generating your plan summary.
</p>
<div className="relative w-full h-2 bg-gray-200 rounded-full overflow-hidden mb-6">

View file

@ -67,7 +67,7 @@ export default function RemoteAssessmentClient({
{/* Hero text split to use horizontal space better */}
<div className="flex flex-col md:flex-row justify-between gap-10">
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl">
Domna IQ analyses your property data, models retrofit options, and
Ara analyses your property data, models retrofit options, and
estimates potential funding all without an on-site survey.
</p>
<p className="text-sm text-white/70 max-w-md md:text-right">

View file

@ -26,9 +26,9 @@ module.exports = {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
"domna-gradient":
"linear-gradient(135deg, #14163d 0%, #2d348f 45%, #3943b7 70%, #eff6fc 100%)",
},
"domna-gradient":
"linear-gradient(135deg, #14163d 0%, #2d348f 45%, #3943b7 70%, #eff6fc 100%)",
},
colors: {
tremor: {
brand: {