mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Merge pull request #129 from Hestia-Homes/main
name change - download photos
This commit is contained in:
commit
d405245175
29 changed files with 15646 additions and 272 deletions
1468
package-lock.json
generated
1468
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
32
src/app/api/sign-s3-url/route.ts
Normal file
32
src/app/api/sign-s3-url/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
157
src/app/api/upload/retrofit-data/route.ts
Normal file
157
src/app/api/upload/retrofit-data/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/db/migrations/0124_mute_alice.sql
Normal file
23
src/app/db/migrations/0124_mute_alice.sql
Normal 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;
|
||||
4
src/app/db/migrations/0125_steady_thunderbolts.sql
Normal file
4
src/app/db/migrations/0125_steady_thunderbolts.sql
Normal 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;
|
||||
10
src/app/db/migrations/0126_third_hawkeye.sql
Normal file
10
src/app/db/migrations/0126_third_hawkeye.sql
Normal 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;
|
||||
4490
src/app/db/migrations/meta/0124_snapshot.json
Normal file
4490
src/app/db/migrations/meta/0124_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4490
src/app/db/migrations/meta/0125_snapshot.json
Normal file
4490
src/app/db/migrations/meta/0125_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4562
src/app/db/migrations/meta/0126_snapshot.json
Normal file
4562
src/app/db/migrations/meta/0126_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
17
src/app/db/schema/files_from_surveyor.ts
Normal file
17
src/app/db/schema/files_from_surveyor.ts
Normal 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;
|
||||
25
src/app/db/schema/tasks/subtask.ts
Normal file
25
src/app/db/schema/tasks/subtask.ts
Normal 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(),
|
||||
});
|
||||
19
src/app/db/schema/tasks/tasks.ts
Normal file
19
src/app/db/schema/tasks/tasks.ts
Normal 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(),
|
||||
});
|
||||
|
|
@ -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 didn’t 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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
14
src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx
Normal file
14
src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/app/portfolio/[slug]/(portfolio)/reporting/utils.ts
Normal file
1
src/app/portfolio/[slug]/(portfolio)/reporting/utils.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue