modified table components and hooked up documents
Some checks failed
Next.js Build Check / build (push) Has been cancelled

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-01 22:32:25 +00:00
parent 66c9b94f41
commit 8ca1bf2799
9 changed files with 574 additions and 104 deletions

View file

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

View file

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

View file

@ -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<Record<keyof HubspotDeal, string>>;
}
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 <span className="text-gray-400 text-xs">No photos</span>;
return (
<div className="flex flex-wrap gap-1.5">
{urls.map((url, idx) => (
<button
key={idx}
onClick={() => handlePhotoDownload(url)}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-brandblue/5 text-brandblue text-xs font-medium rounded-lg hover:bg-brandblue/10 border border-brandblue/20 hover:border-brandblue/40 transition-all duration-150 active:scale-95"
>
<Download className="w-3.5 h-3.5" />
Download
</button>
))}
</div>
);
}
export default function DrillDownTable({
data,
columns: columnKeys,
columnLabels,
}: DrillDownTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
const visibleKeys: (keyof HubspotDeal)[] = columnKeys?.length
? columnKeys
: (Object.keys(data?.[0] || {}) as (keyof HubspotDeal)[]);
const columns = useMemo<ColumnDef<ClassifiedDeal>[]>(
() =>
visibleKeys.map((key) => ({
accessorKey: key as string,
id: key as string,
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
{columnLabels?.[key] ?? (key as string)}
</span>
),
cell: ({ row }) => {
const value = row.original[key as keyof ClassifiedDeal];
if (key === "majorConditionIssuePhotosS3") {
return <PhotoDownloadCell value={value} />;
}
return (
<span className="text-sm text-gray-800">
{value != null ? String(value) : (
<span className="text-gray-300"></span>
)}
</span>
);
},
})),
// 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 (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex gap-3 items-center">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
value={globalFilter}
onChange={(e) => {
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"
/>
</div>
<button
onClick={downloadCsv}
className="inline-flex items-center gap-2 h-9 px-3 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:border-brandblue/30 hover:text-brandblue transition-colors shrink-0"
>
<Download className="h-3.5 w-3.5" />
CSV
</button>
</div>
{/* Row count */}
<p className="text-xs text-gray-400">
Showing{" "}
<span className="font-semibold text-gray-600">{totalFiltered}</span>{" "}
{totalFiltered === 1 ? "row" : "rows"}
</p>
{/* Table */}
<div className="rounded-xl border border-gray-200 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="bg-gray-50/80 hover:bg-gray-50/80 border-b border-gray-200"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="h-10 px-4 first:pl-5 last:pr-5"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row, i) => (
<TableRow
key={row.id}
className={`border-b border-gray-100 transition-colors hover:bg-brandlightblue/10 ${
i % 2 === 0 ? "bg-white" : "bg-gray-50/30"
}`}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-3 px-4 first:pl-5 last:pr-5"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-sm text-gray-400"
>
No results found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{/* Pagination */}
{pageCount > 1 && (
<div className="flex items-center justify-between pt-1">
<span className="text-xs text-gray-500">
Page {currentPage} of {pageCount}
</span>
<div className="flex gap-1">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="h-7 w-7 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:border-brandblue/30 hover:text-brandblue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="h-7 w-7 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:border-brandblue/30 hover:text-brandblue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -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<DocumentDrawerState>({
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}
/>
</div>
</TabsContent>
@ -253,9 +257,10 @@ export default function LiveTracker({
<PropertyDrawer
open={drawerState.open}
uprn={drawerState.uprn}
landlordPropertyId={drawerState.landlordPropertyId}
dealname={drawerState.dealname}
onClose={() =>
setDrawerState({ open: false, uprn: null, dealname: null })
setDrawerState({ open: false, uprn: null, landlordPropertyId: null, dealname: null })
}
/>
</div>

View file

@ -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<string, string> = {
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: <Code2 className="h-3.5 w-3.5" />,
bg: "bg-amber-50",
text: "text-amber-700",
border: "border-amber-200",
};
}
if (docType.includes("DECENT_HOMES")) {
return {
icon: <BarChart3 className="h-3.5 w-3.5" />,
bg: "bg-violet-50",
text: "text-violet-700",
border: "border-violet-200",
};
}
return {
icon: <FileText className="h-3.5 w-3.5" />,
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 }) {
>
<div className="flex items-center gap-3 min-w-0">
{/* Doc type badge */}
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md border text-xs font-medium shrink-0 ${style.bg} ${style.text} ${style.border}`}
>
{style.icon}
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md border text-xs font-medium shrink-0 bg-sky-50 text-sky-700 border-sky-200">
<FileText className="h-3.5 w-3.5" />
{label}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-gray-400 hidden sm:block">
{formatDate(doc.s3FileUploadTimestamp)}
{formatDate(doc.s3UploadTimestamp)}
</span>
<button
onClick={handleDownload}
@ -155,6 +122,7 @@ function DocumentRow({ doc }: { doc: PropertyDocument }) {
interface PropertyDrawerProps {
open: boolean;
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;
onClose: () => void;
}
@ -162,30 +130,34 @@ interface PropertyDrawerProps {
export default function PropertyDrawer({
open,
uprn,
landlordPropertyId,
dealname,
onClose,
}: PropertyDrawerProps) {
const canQuery = !!(uprn || landlordPropertyId);
const {
data: documents = [],
isLoading,
isError,
} = useQuery({
queryKey: ["property-documents", uprn],
// TODO: Replace with real implementation when available
queryFn: async () => [],
enabled: open && !!uprn,
queryKey: ["property-documents", uprn, landlordPropertyId],
queryFn: async () => {
const params = new URLSearchParams();
if (uprn) params.set("uprn", uprn);
else if (landlordPropertyId) params.set("landlordPropertyId", landlordPropertyId);
const res = await fetch(`/api/live-tracking/property-documents?${params}`);
if (!res.ok) throw new Error("Failed to load documents");
return res.json() as Promise<PropertyDocument[]>;
},
enabled: open && canQuery,
staleTime: 30_000,
});
// Group docs by category for display
const grouped = (documents as PropertyDocument[]).reduce<
Record<string, PropertyDocument[]>
>((acc: Record<string, PropertyDocument[]>, doc: PropertyDocument) => {
const category = doc.docType.includes("XML")
? "XML Files"
: doc.docType.includes("DECENT_HOMES")
? "Decent Homes"
: "Survey Reports";
>((acc, doc) => {
const category = getDocCategory(doc.docType);
(acc[category] ??= []).push(doc);
return acc;
}, {});
@ -204,11 +176,15 @@ export default function PropertyDrawer({
<DrawerTitle className="text-lg font-semibold text-brandblue leading-tight truncate">
{dealname ?? "Property Documents"}
</DrawerTitle>
{uprn && (
{uprn ? (
<DrawerDescription className="text-xs text-gray-500 mt-0.5 font-mono">
UPRN: {uprn}
</DrawerDescription>
)}
) : landlordPropertyId ? (
<DrawerDescription className="text-xs text-gray-500 mt-0.5 font-mono">
Ref: {landlordPropertyId}
</DrawerDescription>
) : null}
</div>
<DrawerClose asChild>
<button

View file

@ -38,7 +38,7 @@ import {
import { Search, SlidersHorizontal, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { createPropertyTableColumns } from "./PropertyTableColumns";
import { STAGE_ORDER } from "./types";
import type { ClassifiedDeal, DisplayStage } from "./types";
import type { ClassifiedDeal, DisplayStage, DocStatusMap } from "./types";
// Human-readable labels for toggle dropdown
const COLUMN_LABELS: Record<string, string> = {
@ -58,10 +58,13 @@ const COLUMN_LABELS: Record<string, string> = {
fullLodgementDate: "Lodgement Date",
};
type DocFilter = "all" | "has_docs" | "incomplete" | "none";
interface PropertyTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (uprn: string | null, dealname: string | null) => void;
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
showDocuments?: boolean;
docStatusMap?: DocStatusMap;
}
const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [
@ -93,9 +96,10 @@ function escapeCell(value: unknown): string {
: str;
}
export default function PropertyTable({ data, onOpenDrawer, showDocuments = false }: PropertyTableProps) {
export default function PropertyTable({ data, onOpenDrawer, showDocuments = false, docStatusMap = {} }: PropertyTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [stageFilter, setStageFilter] = useState<string>("all");
const [docFilter, setDocFilter] = useState<DocFilter>("all");
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
@ -114,15 +118,27 @@ export default function PropertyTable({ data, onOpenDrawer, showDocuments = fals
fullLodgementDate: false,
});
// Pre-filter by stage before TanStack gets it
// Pre-filter by stage and doc status before TanStack gets it
const filteredData = useMemo(() => {
if (stageFilter === "all") return data;
return data.filter((d) => d.displayStage === stageFilter);
}, [data, stageFilter]);
let result = data;
if (stageFilter !== "all") {
result = result.filter((d) => d.displayStage === stageFilter);
}
if (docFilter !== "all") {
result = result.filter((d) => {
const status = d.uprn ? docStatusMap[d.uprn] : undefined;
if (docFilter === "none") return !status || !status.hasDocs;
if (docFilter === "has_docs") return !!status?.hasDocs;
if (docFilter === "incomplete") return !!status?.hasDocs && !status.isComplete;
return true;
});
}
return result;
}, [data, stageFilter, docFilter, docStatusMap]);
const columns = useMemo(
() => createPropertyTableColumns(onOpenDrawer, showDocuments),
[onOpenDrawer, showDocuments]
() => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap),
[onOpenDrawer, showDocuments, docStatusMap]
);
const table = useReactTable({
@ -212,6 +228,33 @@ export default function PropertyTable({ data, onOpenDrawer, showDocuments = fals
</SelectContent>
</Select>
{/* Docs filter */}
{showDocuments && (
<Select
value={docFilter}
onValueChange={(v) => {
setDocFilter(v as DocFilter);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<SelectTrigger className="h-9 w-[160px] text-sm border-gray-200 shrink-0">
{docFilter === "all"
? "All docs"
: docFilter === "has_docs"
? "Has docs"
: docFilter === "incomplete"
? "Incomplete docs"
: "No docs"}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All docs</SelectItem>
<SelectItem value="has_docs">Has docs</SelectItem>
<SelectItem value="incomplete">Incomplete docs</SelectItem>
<SelectItem value="none">No docs</SelectItem>
</SelectContent>
</Select>
)}
{/* Download CSV */}
<button
onClick={downloadCsv}
@ -260,6 +303,7 @@ export default function PropertyTable({ data, onOpenDrawer, showDocuments = fals
of{" "}
<span className="font-semibold text-gray-600">{totalFiltered}</span>{" "}
{stageFilter !== "all" ? `"${stageFilter}" ` : ""}
{docFilter !== "all" ? `(${docFilter === "has_docs" ? "has docs" : docFilter === "incomplete" ? "incomplete docs" : "no docs"}) ` : ""}
propert{totalFiltered === 1 ? "y" : "ies"}
</p>

View file

@ -1,9 +1,9 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, FileDown } from "lucide-react";
import { ArrowUpDown, FileDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal, DisplayStage } from "./types";
import type { ClassifiedDeal, DisplayStage, DocStatusMap } from "./types";
// -----------------------------------------------------------------------
// Stage badge — consistent pill rendering
@ -42,10 +42,12 @@ function SortableHeader({
// -----------------------------------------------------------------------
// Column factory — takes onOpenDrawer so the Documents button can trigger it
// showDocuments controls whether the Docs action column is included
// docStatusMap provides per-UPRN document status for status indicators
// -----------------------------------------------------------------------
export function createPropertyTableColumns(
onOpenDrawer: (uprn: string | null, dealname: string | null) => void,
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
showDocuments: boolean = false,
docStatusMap: DocStatusMap = {},
): ColumnDef<ClassifiedDeal>[] {
const columns: ColumnDef<ClassifiedDeal>[] = [
// ── Address ──────────────────────────────────────────────────────────
@ -289,18 +291,42 @@ export function createPropertyTableColumns(
if (showDocuments) {
columns.push({
id: "documents",
header: () => null,
cell: ({ row }) => (
<button
onClick={() =>
onOpenDrawer(row.original.uprn, row.original.dealname)
}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-brandblue/20 text-brandblue bg-brandblue/5 hover:bg-brandblue/10 hover:border-brandblue/40 transition-all duration-150 whitespace-nowrap"
>
<FileDown className="h-3.5 w-3.5" />
Docs
</button>
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Docs</span>
),
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 = <CheckCircle2 className="h-3.5 w-3.5" />;
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 = <AlertCircle className="h-3.5 w-3.5" />;
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 = <FileX className="h-3.5 w-3.5" />;
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 (
<button
onClick={() => onOpenDrawer(row.original.uprn, row.original.landlordPropertyId, row.original.dealname)}
className={className}
>
{icon}
Docs
</button>
);
},
enableSorting: false,
enableHiding: false,
});

View file

@ -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<string, Set<string>> = {};
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 (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
@ -94,7 +130,7 @@ export default async function LiveReportingPage(props: {
<div className="h-px bg-gray-200 mt-2" />
</div>
<LiveTracker {...trackerData} />
<LiveTracker {...trackerData} docStatusMap={docStatusMap} />
</div>
);
}

View file

@ -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<string, DocStatus>; // keyed by UPRN string
export type DocumentDrawerState = {
open: boolean;
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;
};