diff --git a/src/app/api/live-tracking/property-documents/route.ts b/src/app/api/live-tracking/property-documents/route.ts new file mode 100644 index 0000000..9ebf596 --- /dev/null +++ b/src/app/api/live-tracking/property-documents/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { eq, or } from "drizzle-orm"; +import { db } from "@/app/db/db"; +import { uploadedFiles } from "@/app/db/schema/uploaded_files"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const uprnParam = searchParams.get("uprn"); + const landlordPropertyIdParam = searchParams.get("landlordPropertyId"); + + if (!uprnParam && !landlordPropertyIdParam) { + return NextResponse.json( + { error: "uprn or landlordPropertyId is required" }, + { status: 400 }, + ); + } + + try { + const conditions = []; + + if (uprnParam) { + const uprnBigInt = BigInt(uprnParam); + conditions.push(eq(uploadedFiles.uprn, uprnBigInt)); + } + + if (landlordPropertyIdParam) { + conditions.push( + eq(uploadedFiles.landlordPropertyId, landlordPropertyIdParam), + ); + } + + const rows = await db + .select() + .from(uploadedFiles) + .where(conditions.length === 1 ? conditions[0] : or(...conditions)); + + const documents = rows.map((row) => ({ + id: String(row.id), + s3FileKey: row.s3FileKey, + s3FileBucket: row.s3FileBucket, + docType: row.fileType ?? "unknown", + s3UploadTimestamp: row.s3UploadTimestamp.toISOString(), + uprn: row.uprn !== null ? String(row.uprn) : null, + landlordPropertyId: row.landlordPropertyId, + })); + + return NextResponse.json(documents); + } catch (error) { + console.error("Error fetching property documents:", error); + return NextResponse.json( + { error: "Failed to fetch documents" }, + { status: 500 }, + ); + } +} diff --git a/src/app/db/db.ts b/src/app/db/db.ts index 1d2d778..5219612 100644 --- a/src/app/db/db.ts +++ b/src/app/db/db.ts @@ -11,6 +11,7 @@ import * as FundingSchema from "@/app/db/schema/funding"; import * as Relations from "@/app/db/schema/relations"; import * as Users from "@/app/db/schema/users"; import * as CrmSchema from "@/app/db/schema/crm/hubspot_deal_table"; +import * as UploadedFilesSchema from "@/app/db/schema/uploaded_files"; export const pool = new Pool({ host: process.env.DB_HOST, @@ -33,6 +34,7 @@ const schema = { ...FundingSchema, ...Users, ...CrmSchema, + ...UploadedFilesSchema, }; export const db = drizzle(pool, { diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DrillDownTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DrillDownTable.tsx new file mode 100644 index 0000000..eaf3444 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DrillDownTable.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + useReactTable, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + getPaginationRowModel, + flexRender, + type SortingState, + type PaginationState, + type ColumnDef, +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/app/shadcn_components/ui/table"; +import { Input } from "@/app/shadcn_components/ui/input"; +import { Search, Download, ChevronLeft, ChevronRight } from "lucide-react"; +import type { ClassifiedDeal, HubspotDeal } from "./types"; + +interface DrillDownTableProps { + data: ClassifiedDeal[]; + columns?: (keyof HubspotDeal)[]; + columnLabels?: Partial>; +} + +function escapeCell(value: unknown): string { + if (value === null || value === undefined) return ""; + const str = + value instanceof Date ? value.toLocaleDateString("en-GB") : String(value); + return str.includes(",") || str.includes('"') || str.includes("\n") + ? `"${str.replace(/"/g, '""')}"` + : str; +} + +async function handlePhotoDownload(rawUrl: string) { + try { + 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"); + } +} + +function PhotoDownloadCell({ value }: { value: unknown }) { + 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 as string[]; + } + + if (urls.length === 0) return No photos; + + return ( +
+ {urls.map((url, idx) => ( + + ))} +
+ ); +} + +export default function DrillDownTable({ + data, + columns: columnKeys, + columnLabels, +}: DrillDownTableProps) { + const [globalFilter, setGlobalFilter] = useState(""); + const [sorting, setSorting] = useState([]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 25, + }); + + const visibleKeys: (keyof HubspotDeal)[] = columnKeys?.length + ? columnKeys + : (Object.keys(data?.[0] || {}) as (keyof HubspotDeal)[]); + + const columns = useMemo[]>( + () => + visibleKeys.map((key) => ({ + accessorKey: key as string, + id: key as string, + header: () => ( + + {columnLabels?.[key] ?? (key as string)} + + ), + cell: ({ row }) => { + const value = row.original[key as keyof ClassifiedDeal]; + if (key === "majorConditionIssuePhotosS3") { + return ; + } + return ( + + {value != null ? String(value) : ( + + )} + + ); + }, + })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [visibleKeys.join(","), columnLabels], + ); + + const table = useReactTable({ + data, + columns, + state: { globalFilter, sorting, pagination }, + onGlobalFilterChange: setGlobalFilter, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + globalFilterFn: "includesString", + }); + + const downloadCsv = () => { + const rows = table.getFilteredRowModel().rows; + const exportKeys = visibleKeys.filter((k) => k !== "majorConditionIssuePhotosS3"); + const header = exportKeys + .map((k) => columnLabels?.[k] ?? (k as string)) + .join(","); + const body = rows + .map((row) => + exportKeys.map((k) => escapeCell(row.original[k as keyof ClassifiedDeal])).join(","), + ) + .join("\n"); + const blob = new Blob([header + "\n" + body], { + type: "text/csv;charset=utf-8;", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "data.csv"; + a.click(); + URL.revokeObjectURL(url); + }; + + const pageCount = table.getPageCount(); + const currentPage = table.getState().pagination.pageIndex + 1; + const totalFiltered = table.getFilteredRowModel().rows.length; + + return ( +
+ {/* Toolbar */} +
+
+ + { + setGlobalFilter(e.target.value); + setPagination((p) => ({ ...p, pageIndex: 0 })); + }} + placeholder="Search…" + className="pl-9 h-9 text-sm border-gray-200 focus:border-brandblue/40 focus:ring-brandblue/20" + /> +
+ +
+ + {/* Row count */} +

+ Showing{" "} + {totalFiltered}{" "} + {totalFiltered === 1 ? "row" : "rows"} +

+ + {/* Table */} +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row, i) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results found. + + + )} + +
+
+
+ + {/* Pagination */} + {pageCount > 1 && ( +
+ + Page {currentPage} of {pageCount} + +
+ + +
+
+ )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index 4ecd754..5418d15 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -20,12 +20,14 @@ import type { TableModal, ClassifiedDeal, DocumentDrawerState, + DocStatusMap, } from "./types"; export default function LiveTracker({ projects, totalDeals, majorConditionDeals, + docStatusMap, }: LiveTrackerProps) { // ── Tab state ──────────────────────────────────────────────────────── const [activeTab, setActiveTab] = useState<"analytics" | "properties">( @@ -46,6 +48,7 @@ export default function LiveTracker({ const [drawerState, setDrawerState] = useState({ open: false, uprn: null, + landlordPropertyId: null, dealname: null, }); @@ -68,8 +71,8 @@ export default function LiveTracker({ }); }; - const handleOpenDrawer = (uprn: string | null, dealname: string | null) => { - setDrawerState({ open: true, uprn, dealname }); + const handleOpenDrawer = (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => { + setDrawerState({ open: true, uprn, landlordPropertyId, dealname }); }; if (!totalDeals) { @@ -156,6 +159,7 @@ export default function LiveTracker({ data={currentProject?.allDeals ?? []} onOpenDrawer={handleOpenDrawer} showDocuments={true} + docStatusMap={docStatusMap} /> @@ -253,9 +257,10 @@ export default function LiveTracker({ - setDrawerState({ open: false, uprn: null, dealname: null }) + setDrawerState({ open: false, uprn: null, landlordPropertyId: null, dealname: null }) } /> diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx index c4464bc..0199ada 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx @@ -6,8 +6,6 @@ import { motion, AnimatePresence } from "framer-motion"; import { FileDown, FileText, - Code2, - BarChart3, Loader2, FolderOpen, X, @@ -23,47 +21,22 @@ import { } from "@/app/shadcn_components/ui/drawer"; import type { PropertyDocument } from "./types"; -// Human-readable labels for DB_REPORT_TYPES enum values +// Human-readable labels for the main DB fileType enum values const DOC_TYPE_LABELS: Record = { - ECO_CONDITION_REPORT: "Condition Report (PAS 2035)", - ENERGY_PERFORMANCE_REPORT_SUMMARY_INFORMATION: "EPC Summary Report", - LIG_XML: "LIG XML", - RDSAP_XML: "RdSAP XML", - FULLSAP_XML: "Full SAP XML", - DECENT_HOMES_RAW_DATA: "Decent Homes Raw Data", - DECENT_HOMES_PROPERTY_META: "Decent Homes Property Meta", - DECENT_HOMES_SUMMARY: "Decent Homes Summary", + photo_pack: "Photo Pack", + site_note: "Site Note", + rd_sap_site_note: "RdSAP Site Note", + pas_2023_ventilation: "PAS 2023 Ventilation", + pas_2023_condition: "PAS 2023 Condition Report", + pas_significance: "PAS Significance", + par_photo_pack: "PAR Photo Pack", + pas_2023_property: "PAS 2023 Property Report", + pas_2023_occupancy: "PAS 2023 Occupancy Report", }; -// Icon + colour per doc category -function docTypeStyle(docType: string): { - icon: React.ReactNode; - bg: string; - text: string; - border: string; -} { - if (docType.includes("XML")) { - return { - icon: , - bg: "bg-amber-50", - text: "text-amber-700", - border: "border-amber-200", - }; - } - if (docType.includes("DECENT_HOMES")) { - return { - icon: , - bg: "bg-violet-50", - text: "text-violet-700", - border: "border-violet-200", - }; - } - return { - icon: , - bg: "bg-sky-50", - text: "text-sky-700", - border: "border-sky-200", - }; +// All survey docs go under this group for now (extensible later) +function getDocCategory(_docType: string): string { + return "Survey Documents"; } function formatDate(iso: string): string { @@ -83,29 +56,25 @@ function formatDate(iso: string): string { // ----------------------------------------------------------------------- function DocumentRow({ doc }: { doc: PropertyDocument }) { const [signing, setSigning] = useState(false); - const style = docTypeStyle(doc.docType); const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType; async function handleDownload() { setSigning(true); try { - // Extract S3 key from the full URI — same pattern as TableViewer.tsx - const key = doc.s3FileUri.split(".amazonaws.com/")[1]; - if (!key) { - window.open(doc.s3FileUri, "_blank"); - return; - } const res = await fetch("/api/sign-s3-url", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key }), + body: JSON.stringify({ key: doc.s3FileKey }), }); if (!res.ok) throw new Error("Failed to get signed URL"); const data = await res.json(); window.open(data.url, "_blank"); } catch { - // Fallback: open raw URI - window.open(doc.s3FileUri, "_blank"); + // Fallback: construct raw S3 URL + window.open( + `https://${doc.s3FileBucket}.s3.amazonaws.com/${doc.s3FileKey}`, + "_blank", + ); } finally { setSigning(false); } @@ -120,17 +89,15 @@ function DocumentRow({ doc }: { doc: PropertyDocument }) { >
{/* Doc type badge */} - - {style.icon} + + {label}
- {formatDate(doc.s3FileUploadTimestamp)} + {formatDate(doc.s3UploadTimestamp)}
+ header: () => ( + Docs ), + cell: ({ row }) => { + const uprn = row.original.uprn ?? ""; + const status = uprn ? docStatusMap[uprn] : undefined; + const isComplete = status?.isComplete; + const hasDocs = status?.hasDocs; + + let icon: React.ReactNode; + let className: string; + + if (isComplete) { + icon = ; + className = + "inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-emerald-200 text-emerald-700 bg-emerald-50 hover:bg-emerald-100 hover:border-emerald-300 transition-all duration-150 whitespace-nowrap"; + } else if (hasDocs) { + icon = ; + className = + "inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-amber-200 text-amber-700 bg-amber-50 hover:bg-amber-100 hover:border-amber-300 transition-all duration-150 whitespace-nowrap"; + } else { + icon = ; + className = + "inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-gray-200 text-gray-400 bg-gray-50 hover:bg-gray-100 hover:border-gray-300 transition-all duration-150 whitespace-nowrap"; + } + + return ( + + ); + }, enableSorting: false, enableHiding: false, }); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx index eca568f..87c9391 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -1,12 +1,14 @@ import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { redirect } from "next/navigation"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import LiveTracker from "./LiveTracker"; import { computeLiveTrackerData } from "./transforms"; import { db } from "@/app/db/db"; import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table"; -import type { HubspotDeal } from "./types"; +import { uploadedFiles } from "@/app/db/schema/uploaded_files"; +import type { HubspotDeal, DocStatusMap, DocStatus } from "./types"; +import { EXPECTED_SURVEY_DOC_TYPES } from "./types"; import type { InferSelectModel } from "drizzle-orm"; // ⚠️ ⚠️ ⚠️ HARDCODED COMPANY ID — temporary for testing only. @@ -78,9 +80,43 @@ export default async function LiveReportingPage(props: { console.log("Fetched deals from DB:", rawDeals.length); - const trackerData = computeLiveTrackerData( - rawDeals.map(mapDbRowToHubspotDeal), - ); + const deals = rawDeals.map(mapDbRowToHubspotDeal); + const trackerData = computeLiveTrackerData(deals); + + // ── Fetch survey document status for all properties ───────────────── + const uprnList = deals + .map((d) => d.uprn) + .filter((u): u is string => !!u) + .map((u) => { + try { return BigInt(u); } catch { return null; } + }) + .filter((u): u is bigint => u !== null); + + let docStatusMap: DocStatusMap = {}; + + if (uprnList.length > 0) { + const docRows = await db + .select() + .from(uploadedFiles) + .where(inArray(uploadedFiles.uprn, uprnList)); + + const grouped: Record> = {}; + for (const row of docRows) { + if (row.uprn === null || row.fileType === null) continue; + const key = String(row.uprn); + (grouped[key] ??= new Set()).add(row.fileType); + } + + for (const [uprn, types] of Object.entries(grouped)) { + const presentTypes = Array.from(types); + const status: DocStatus = { + presentTypes, + hasDocs: presentTypes.length > 0, + isComplete: EXPECTED_SURVEY_DOC_TYPES.every((t) => types.has(t)), + }; + docStatusMap[uprn] = status; + } + } return (
@@ -94,7 +130,7 @@ export default async function LiveReportingPage(props: {
- +
); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts index f00495b..c51e7d8 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -167,6 +167,7 @@ export type LiveTrackerProps = { projects: ProjectData[]; totalDeals: number; majorConditionDeals: ClassifiedDeal[]; // for Awaab's Law card + docStatusMap: DocStatusMap; }; // ----------------------------------------------------------------------- @@ -186,16 +187,39 @@ export type TableModal = { // ----------------------------------------------------------------------- export type PropertyDocument = { id: string; - s3FileUri: string; - s3JsonUri: string | null; - docType: string; - s3FileUploadTimestamp: string; // ISO string - uprn: string; + s3FileKey: string; // S3 object key — used directly for presigned URL + s3FileBucket: string; // S3 bucket name + docType: string; // fileType enum value + s3UploadTimestamp: string; // ISO string + uprn: string | null; + landlordPropertyId: string | null; }; +// All survey document types expected for a complete survey +export const EXPECTED_SURVEY_DOC_TYPES = [ + "photo_pack", + "site_note", + "rd_sap_site_note", + "pas_2023_ventilation", + "pas_2023_condition", + "pas_significance", + "par_photo_pack", + "pas_2023_property", + "pas_2023_occupancy", +] as const; + +export type DocStatus = { + presentTypes: string[]; + hasDocs: boolean; + isComplete: boolean; // all EXPECTED_SURVEY_DOC_TYPES present +}; + +export type DocStatusMap = Record; // keyed by UPRN string + export type DocumentDrawerState = { open: boolean; uprn: string | null; + landlordPropertyId: string | null; dealname: string | null; };