Merge pull request #129 from Hestia-Homes/main

name change - download photos
This commit is contained in:
KhalimCK 2025-11-12 21:34:33 +00:00 committed by GitHub
commit d405245175
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 15646 additions and 272 deletions

1468
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,7 @@
},
"dependencies": {
"@aws-sdk/client-sqs": "^3.864.0",
"@aws-sdk/s3-request-presigner": "^3.927.0",
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.0.18",
"@hookform/resolvers": "^3.9.1",
@ -45,6 +46,7 @@
"autoprefixer": "10.4.14",
"aws-sdk": "^2.1415.0",
"class-variance-authority": "^0.6.1",
"client-s3": "github:aws-sdk/client-s3",
"clsx": "^1.2.1",
"drizzle-orm": "^0.44.5",
"esbuild": "^0.25.8",

View file

@ -0,0 +1,32 @@
// /app/api/sign-s3-url/route.ts
import { NextResponse } from "next/server";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({
region: process.env.RETROFIT_DATA_DEV_REGION,
credentials: {
accessKeyId: process.env.RETROFIT_DATA_DEV_ACCESS_KEY!,
secretAccessKey: process.env.RETROFIT_DATA_DEV_SECRET_KEY!,
},
});
export async function POST(req: Request) {
try {
const { key } = await req.json(); // key = "path/to/photo.jpg"
if (!key) return NextResponse.json({ error: "Missing key" }, { status: 400 });
const command = new GetObjectCommand({
Bucket: process.env.RETROFIT_DATA_DEV_S3_BUCKET_NAME!,
Key: key,
});
// URL expires in 30 minutes
const signedUrl = await getSignedUrl(s3, command, { expiresIn: 1800 });
return NextResponse.json({ url: signedUrl });
} catch (error) {
console.error("Error generating signed URL:", error);
return NextResponse.json({ error: "Failed to sign URL" }, { status: 500 });
}
}

View file

@ -0,0 +1,157 @@
import { NextRequest, NextResponse } from "next/server";
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { db } from "@/app/db/db";
import { filesFromSurveyor } from "@/app/db/schema/files_from_surveyor";
import { and, eq } from "drizzle-orm";
// ✅ Initialize S3 Client (AWS SDK v3)
const s3 = new S3Client({
region: process.env.RETROFIT_DATA_DEV_REGION,
credentials: {
accessKeyId: process.env.RETROFIT_DATA_DEV_ACCESS_KEY!,
secretAccessKey: process.env.RETROFIT_DATA_DEV_SECRET_KEY!,
},
});
// ✅ Helper: safely convert BigInt fields to string
function serializeBigInt<T extends Record<string, any>>(obj: T): T {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [
key,
typeof value === "bigint" ? value.toString() : value,
])
) as T;
}
// -------------------
// 📤 POST — Upload files to S3 + Save to DB
// -------------------
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const files = formData.getAll("files") as File[];
const portfolioIdStr = formData.get("portfolioId") as string;
const propertyIdStr = formData.get("propertyId") as string;
if (!portfolioIdStr || !propertyIdStr) {
return NextResponse.json(
{ msg: "Missing portfolioId or propertyId" },
{ status: 400 }
);
}
const portfolioId = BigInt(portfolioIdStr);
const propertyId = BigInt(propertyIdStr);
if (!files || files.length === 0) {
return NextResponse.json({ msg: "No files uploaded" }, { status: 400 });
}
const uploadedFiles = await Promise.all(
files.map(async (file) => {
const timestamp = Date.now();
const safeName = file.name.replace(/\s+/g, "_");
const fileKey = `files_from_surveyor/${portfolioId}/${propertyId}/${timestamp}-${safeName}`;
const buffer = Buffer.from(await file.arrayBuffer());
await s3.send(
new PutObjectCommand({
Bucket: process.env.RETROFIT_DATA_DEV_S3_BUCKET_NAME!,
Key: fileKey,
Body: buffer,
ContentType: file.type,
})
);
const fileUrl = `https://${process.env.RETROFIT_DATA_DEV_S3_BUCKET_NAME}.s3.${process.env.RETROFIT_DATA_DEV_REGION}.amazonaws.com/${fileKey}`;
return { fileKey, fileUrl };
})
);
const inserted = await db
.insert(filesFromSurveyor)
.values(
uploadedFiles.map((file) => ({
portfolioId,
propertyId,
s3JsonUrl: file.fileUrl,
}))
)
.returning();
// ✅ Serialize BigInts before sending JSON
const safeInserted = inserted.map(serializeBigInt);
return NextResponse.json({ files: safeInserted }, { status: 200 });
} catch (error) {
console.error("❌ S3 or DB Error:", error);
return NextResponse.json(
{ msg: "File upload failed", error: String(error) },
{ status: 500 }
);
}
}
// -------------------
// 📥 GET — Fetch uploaded files + Presigned URLs
// -------------------
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const portfolioIdStr = searchParams.get("portfolioId");
const propertyIdStr = searchParams.get("propertyId");
if (!portfolioIdStr || !propertyIdStr) {
return NextResponse.json(
{ msg: "Missing portfolioId or propertyId" },
{ status: 400 }
);
}
const portfolioId = BigInt(portfolioIdStr);
const propertyId = BigInt(propertyIdStr);
// ✅ Correct Drizzle where syntax
const files = await db
.select()
.from(filesFromSurveyor)
.where(
and(
eq(filesFromSurveyor.portfolioId, portfolioId),
eq(filesFromSurveyor.propertyId, propertyId)
)
);
// ✅ Add 30-min presigned URLs
const withSignedUrls = await Promise.all(
files.map(async (file) => {
const key = file.s3JsonUrl.split(".amazonaws.com/")[1];
const presignedUrl = await getSignedUrl(
s3,
new GetObjectCommand({
Bucket: process.env.RETROFIT_DATA_DEV_S3_BUCKET_NAME!,
Key: key,
}),
{ expiresIn: 60 * 30 } // 30 minutes
);
return serializeBigInt({
...file,
presignedUrl,
});
})
);
return NextResponse.json({ files: withSignedUrls }, { status: 200 });
} catch (error) {
console.error("❌ Fetch error:", error);
return NextResponse.json(
{ msg: "Error fetching files", error: String(error) },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,23 @@
CREATE TABLE "sub_tasks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"task_id" uuid NOT NULL,
"job_started" timestamp (6) with time zone,
"job_completed" timestamp (6) with time zone,
"status" text DEFAULT 'In Progress' NOT NULL,
"inputs" text,
"outputs" text,
"cloud_logs_url" text,
"updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tasks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"task_source" text NOT NULL,
"job_started" timestamp (6) with time zone,
"job_completed" timestamp (6) with time zone,
"status" text DEFAULT 'In Progress' NOT NULL,
"service" text,
"updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "sub_tasks" ADD CONSTRAINT "sub_tasks_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,4 @@
ALTER TABLE "sub_tasks" RENAME TO "sub_task";--> statement-breakpoint
ALTER TABLE "sub_task" DROP CONSTRAINT "sub_tasks_task_id_tasks_id_fk";
--> statement-breakpoint
ALTER TABLE "sub_task" ADD CONSTRAINT "sub_task_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,10 @@
CREATE TABLE "files_from_surveyor" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"portfolio_id" bigint NOT NULL,
"property_id" bigint NOT NULL,
"s3_json_url" text NOT NULL,
"uploaded_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "files_from_surveyor" ADD CONSTRAINT "files_from_surveyor_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "files_from_surveyor" ADD CONSTRAINT "files_from_surveyor_property_id_property_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."property"("id") ON DELETE no action ON UPDATE no action;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -869,6 +869,27 @@
"when": 1761660543815,
"tag": "0123_cloudy_gambit",
"breakpoints": true
},
{
"idx": 124,
"version": "7",
"when": 1762793610067,
"tag": "0124_mute_alice",
"breakpoints": true
},
{
"idx": 125,
"version": "7",
"when": 1762793952263,
"tag": "0125_steady_thunderbolts",
"breakpoints": true
},
{
"idx": 126,
"version": "7",
"when": 1762892339561,
"tag": "0126_third_hawkeye",
"breakpoints": true
}
]
}

View file

@ -17,6 +17,7 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
majorConditionIssueDescription: text("major_condition_issue_description"),
majorConditionIssuePhotos: text("major_condition_issue_photos"),
majorConditionIssuePhotosS3: text("major_condition_issue_evidence_s3_url"),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()

View file

@ -0,0 +1,17 @@
import { pgTable, uuid, bigint, text, timestamp } from "drizzle-orm/pg-core";
import { portfolio } from "./portfolio";
import { property } from "./property";
export const filesFromSurveyor = pgTable("files_from_surveyor", {
id: uuid("id").defaultRandom().primaryKey(),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id),
propertyId: bigint("property_id", { mode: "bigint" })
.notNull()
.references(() => property.id),
s3JsonUrl: text("s3_json_url").notNull(),
uploadedAt: timestamp("uploaded_at").notNull().defaultNow(),
});
export type FilesFromSurveyor = typeof filesFromSurveyor.$inferSelect;

View file

@ -0,0 +1,25 @@
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
import { tasks } from "./tasks";
export const subTasks = pgTable("sub_task", {
id: uuid("id").defaultRandom().primaryKey(),
// foreign key to parent task
taskId: uuid("task_id")
.notNull()
.references(() => tasks.id, { onDelete: "cascade" }),
jobStarted: timestamp("job_started", { precision: 6, withTimezone: true }),
jobCompleted: timestamp("job_completed", { precision: 6, withTimezone: true }),
status: text("status").notNull().default("In Progress"),
inputs: text("inputs"), // could later change to JSONB if desired
outputs: text("outputs"),
cloudLogsURL: text("cloud_logs_url"),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});

View file

@ -0,0 +1,19 @@
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
export const tasks = pgTable("tasks", {
id: uuid("id").defaultRandom().primaryKey(),
taskSource: text("task_source").notNull(),
jobStarted: timestamp("job_started", { precision: 6, withTimezone: true }),
jobCompleted: timestamp("job_completed", { precision: 6, withTimezone: true }),
status: text("status").notNull().default("In Progress"), // default status
service: text("service"), // e.g. plan, wchg etc
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});

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

@ -112,11 +112,13 @@ export default function LiveTracker({ deals }: ReportsProps) {
"dealname",
"landlordPropertyId",
"majorConditionIssueDescription",
"majorConditionIssuePhotosS3"
],
{
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
majorConditionIssueDescription: "Surveyor's Notes"
majorConditionIssueDescription: "Surveyor's Notes",
majorConditionIssuePhotosS3: "Photo Evidence"
}
)
}

View file

@ -1,6 +1,7 @@
"use client";
import { useState, useMemo } from "react";
import { Download } from "lucide-react";
interface TableViewerProps {
data: Record<string, any>[];
@ -8,9 +9,15 @@ interface TableViewerProps {
columnLabels?: Record<string, string>;
}
export default function TableViewer({ data, columns, columnLabels }: TableViewerProps) {
export default function TableViewer({
data,
columns,
columnLabels,
}: TableViewerProps) {
const [searchTerms, setSearchTerms] = useState<Record<string, string>>({});
const visibleColumns = columns?.length ? columns : Object.keys(data?.[0] || {});
const visibleColumns = columns?.length
? columns
: Object.keys(data?.[0] || {});
const filteredData = useMemo(() => {
return data.filter((row) =>
@ -23,13 +30,77 @@ export default function TableViewer({ data, columns, columnLabels }: TableViewer
);
}, [data, searchTerms, visibleColumns]);
const renderCellContent = (col: string, value: any) => {
if (col === "majorConditionIssuePhotosS3" && value) {
let urls: string[] = [];
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
urls = Array.isArray(parsed) ? parsed : [value];
} catch {
urls = value.split(/[\s,]+/).filter((u) => u.startsWith("http"));
}
} else if (Array.isArray(value)) {
urls = value;
}
if (urls.length === 0)
return <span className="text-gray-400">No photos</span>;
const handleDownload = async (rawUrl: string) => {
try {
// Extract the object key (after the bucket domain)
const key = rawUrl.split(".amazonaws.com/")[1];
if (!key) return alert("Invalid S3 key");
const res = await fetch("/api/sign-s3-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
});
const data = await res.json();
if (data.url) {
window.open(data.url, "_blank");
} else {
alert("Failed to get signed URL");
}
} catch (err) {
console.error(err);
alert("Error downloading file");
}
};
return (
<div className="flex flex-wrap gap-2">
{urls.map((url, idx) => (
<button
key={idx}
onClick={() => handleDownload(url)}
className="flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-600 text-xs rounded hover:bg-blue-100 transition"
>
<Download className="w-3 h-3" />
<span>Download Photos</span>
</button>
))}
</div>
);
}
return String(value ?? "");
};
return (
<div className="overflow-x-auto border rounded-xl shadow-lg bg-white">
<table className="min-w-full text-sm border-collapse">
<thead className="bg-gray-100 sticky top-0">
<tr>
{visibleColumns.map((col) => (
<th key={col} className="border-b p-3 text-left text-gray-700 font-semibold">
<th
key={col}
className="border-b p-3 text-left text-gray-700 font-semibold"
>
<div className="flex flex-col gap-1">
<span>{columnLabels?.[col] || col}</span>
<input
@ -37,7 +108,10 @@ export default function TableViewer({ data, columns, columnLabels }: TableViewer
placeholder="Search..."
className="p-1 border border-gray-300 rounded text-xs focus:ring-1 focus:ring-blue-400 outline-none"
onChange={(e) =>
setSearchTerms((prev) => ({ ...prev, [col]: e.target.value }))
setSearchTerms((prev) => ({
...prev,
[col]: e.target.value,
}))
}
/>
</div>
@ -48,7 +122,10 @@ export default function TableViewer({ data, columns, columnLabels }: TableViewer
<tbody>
{filteredData.length === 0 ? (
<tr>
<td colSpan={visibleColumns.length} className="text-center py-6 text-gray-400">
<td
colSpan={visibleColumns.length}
className="text-center py-6 text-gray-400"
>
No results found
</td>
</tr>
@ -60,7 +137,7 @@ export default function TableViewer({ data, columns, columnLabels }: TableViewer
>
{visibleColumns.map((col) => (
<td key={col} className="border-b p-3 text-gray-700">
{String(row[col] ?? "")}
{renderCellContent(col, row[col])}
</td>
))}
</tr>

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

@ -0,0 +1,134 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams } from "next/navigation";
interface FileRecord {
id: number;
s3JsonUrl: string;
portfolioId: string;
propertyId: string;
presignedUrl?: string;
createdAt?: string;
}
const UploadPage: React.FC = () => {
const [isUploading, setIsUploading] = useState(false);
const [files, setFiles] = useState<FileRecord[]>([]);
const params = useParams();
const portfolioId = params?.slug as string;
const propertyId = params?.propertyId as string;
const fetchFiles = async () => {
const res = await fetch(
`/api/upload/retrofit-data?portfolioId=${portfolioId}&propertyId=${propertyId}`
);
if (res.ok) {
const data = await res.json();
setFiles(data.files);
}
};
useEffect(() => {
if (portfolioId && propertyId) {
fetchFiles();
}
}, [portfolioId, propertyId]);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const formData = new FormData();
formData.append("portfolioId", portfolioId);
formData.append("propertyId", propertyId);
Array.from(files).forEach((file) => formData.append("files", file));
try {
setIsUploading(true);
const res = await fetch("/api/upload/retrofit-data", {
method: "POST",
body: formData,
});
if (!res.ok) throw new Error("Upload failed");
await fetchFiles();
alert("✅ Files uploaded successfully!");
} catch (err) {
console.error(err);
alert("❌ Upload failed");
} finally {
setIsUploading(false);
}
};
return (
<div className="flex flex-col items-center justify-start min-h-screen gap-6 bg-gray-50 p-8">
<h1 className="text-2xl font-semibold">Upload Retrofit Data Files</h1>
<div className="text-gray-700 text-center">
<p>
<strong>Portfolio ID:</strong> {portfolioId}
</p>
<p>
<strong>Property ID:</strong> {propertyId}
</p>
</div>
<label
htmlFor="file-upload"
className={`cursor-pointer px-5 py-3 rounded-lg transition ${
isUploading
? "bg-gray-400 cursor-not-allowed"
: "bg-blue-600 hover:bg-blue-700 text-white"
}`}
>
{isUploading ? "Uploading..." : "Choose Files"}
</label>
<input
id="file-upload"
type="file"
multiple
onChange={handleFileChange}
disabled={isUploading}
className="hidden"
/>
<div className="w-full max-w-3xl mt-10">
<h2 className="text-lg font-medium mb-4">Uploaded Files</h2>
{files.length === 0 ? (
<p className="text-gray-500">No files uploaded yet.</p>
) : (
<table className="min-w-full border border-gray-200 bg-white rounded-lg shadow-sm">
<thead className="bg-gray-100">
<tr>
<th className="p-3 text-left">File URL</th>
<th className="p-3 text-left">Action</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr key={file.id} className="border-t">
<td className="p-3 text-blue-700 break-all">
{file.s3JsonUrl}
</td>
<td className="p-3">
<button
onClick={() => window.open(file.presignedUrl, "_blank")}
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700"
>
View
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};
export default UploadPage;

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: {