mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
modified table components and hooked up documents
Some checks failed
Next.js Build Check / build (push) Has been cancelled
Some checks failed
Next.js Build Check / build (push) Has been cancelled
This commit is contained in:
parent
66c9b94f41
commit
8ca1bf2799
9 changed files with 574 additions and 104 deletions
55
src/app/api/live-tracking/property-documents/route.ts
Normal file
55
src/app/api/live-tracking/property-documents/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue