-
+
{completedPercentage.toFixed(0)}%
@@ -256,13 +148,19 @@ export default function ProgressOverview({ data, onOpenTable }: ProgressOverview
- Work Completed
+
+ Work Completed
+
{completedCount}
- / {nonQueryTotal}
+
+ / {nonQueryTotal}
+
+
+
+ Properties fully lodged and funded
-
Properties fully lodged and funded
@@ -278,13 +176,26 @@ export default function ProgressOverview({ data, onOpenTable }: ProgressOverview
key={item.stage}
whileHover={{ scale: 1.03 }}
onClick={() =>
- onOpenTable?.(item.stage, item.deals, EARLY_COLUMNS, EARLY_LABELS)
+ onOpenTable?.(
+ item.stage,
+ item.deals,
+ EARLY_COLUMNS,
+ EARLY_LABELS,
+ undefined,
+ item.stage,
+ `Properties currently in the "${item.stage}" stage.`,
+ undefined,
+ )
}
- className={`group text-left rounded-xl border p-3 transition-all duration-200 hover:shadow-sm ${c.bg} ${c.border} hover:opacity-90`}
+ className={`group text-left rounded-xl border p-3 transition-all duration-200 hover:shadow-md ${c.bg} ${c.border} hover:opacity-95`}
>
-
-
+
+
{item.stage}
@@ -298,131 +209,6 @@ export default function ProgressOverview({ data, onOpenTable }: ProgressOverview
)}
- {/* ── Phase summary cards ──────────────────────────────────────── */}
-
-
- onOpenTable?.(
- "Coordination Status",
- [...coordination.completedDeals, ...coordination.inProgressDeals],
- COORD_COLUMNS,
- COORD_LABELS,
- {
- "Coordination Complete": coordination.completedDeals,
- "In Progress": coordination.inProgressDeals,
- }
- )
- }
- />
-
- onOpenTable?.(
- "Design Status",
- [...design.completedDeals, ...design.inProgressDeals],
- DESIGN_COLUMNS,
- DESIGN_LABELS,
- {
- "Design Complete": design.completedDeals,
- "In Progress": design.inProgressDeals,
- }
- )
- }
- />
-
- onOpenTable?.(
- "Installation Status",
- [...install.completedDeals, ...install.inProgressDeals],
- INSTALL_COLUMNS,
- INSTALL_LABELS,
- {
- "Install Complete": install.completedDeals,
- "In Progress": install.inProgressDeals,
- }
- )
- }
- />
-
- onOpenTable?.(
- "Lodgement Status",
- [...lodgement.completedDeals, ...lodgement.inProgressDeals],
- LODGE_COLUMNS,
- LODGE_LABELS,
- {
- "Fully Lodged": lodgement.completedDeals,
- "In Progress": lodgement.inProgressDeals,
- }
- )
- }
- />
-
-
- {/* ── Requires input ────────────────────────────────────────────── */}
- {queriesDeals.length > 0 && (
-
- onOpenTable?.(
- "Properties Requiring Attention",
- queriesDeals,
- QUERIES_COLUMNS,
- QUERIES_LABELS
- )
- }
- className="group w-full text-left rounded-xl border border-amber-300 bg-gradient-to-r from-amber-50 to-white p-4 hover:border-amber-400 hover:shadow-sm transition-all duration-200"
- >
-
-
-
-
- Requires Your Input
-
- {queriesDeals.length}
-
-
-
- Click to see the issue, survey outcome, and coordination status for each property
-
-
-
-
-
- )}
);
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx
new file mode 100644
index 00000000..c4464bcc
--- /dev/null
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx
@@ -0,0 +1,313 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { useState } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import {
+ FileDown,
+ FileText,
+ Code2,
+ BarChart3,
+ Loader2,
+ FolderOpen,
+ X,
+ ExternalLink,
+} from "lucide-react";
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerDescription,
+} from "@/app/shadcn_components/ui/drawer";
+import type { PropertyDocument } from "./types";
+
+// Human-readable labels for DB_REPORT_TYPES 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",
+};
+
+// 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",
+ };
+}
+
+function formatDate(iso: string): string {
+ try {
+ return new Date(iso).toLocaleDateString("en-GB", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+// -----------------------------------------------------------------------
+// Individual document row
+// -----------------------------------------------------------------------
+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 }),
+ });
+ 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");
+ } finally {
+ setSigning(false);
+ }
+ }
+
+ return (
+
+
+ {/* Doc type badge */}
+
+ {style.icon}
+ {label}
+
+
+
+
+
+ {formatDate(doc.s3FileUploadTimestamp)}
+
+
+
+
+ );
+}
+
+// -----------------------------------------------------------------------
+// PropertyDrawer — main component
+// -----------------------------------------------------------------------
+interface PropertyDrawerProps {
+ open: boolean;
+ uprn: string | null;
+ dealname: string | null;
+ onClose: () => void;
+}
+
+export default function PropertyDrawer({
+ open,
+ uprn,
+ dealname,
+ onClose,
+}: PropertyDrawerProps) {
+ const {
+ data: documents = [],
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: ["property-documents", uprn],
+ // TODO: Replace with real implementation when available
+ queryFn: async () => [],
+ enabled: open && !!uprn,
+ staleTime: 30_000,
+ });
+
+ // Group docs by category for display
+ const grouped = (documents as PropertyDocument[]).reduce<
+ Record
+ >((acc: Record, doc: PropertyDocument) => {
+ const category = doc.docType.includes("XML")
+ ? "XML Files"
+ : doc.docType.includes("DECENT_HOMES")
+ ? "Decent Homes"
+ : "Survey Reports";
+ (acc[category] ??= []).push(doc);
+ return acc;
+ }, {});
+
+ const hasDocuments = documents.length > 0;
+
+ return (
+ !v && onClose()} direction="right">
+
+ {/* Remove the default drag handle */}
+
+
+
+
+
+
+ {dealname ?? "Property Documents"}
+
+ {uprn && (
+
+ UPRN: {uprn}
+
+ )}
+
+
+
+
+
+
+ {hasDocuments && !isLoading && (
+
+
+
+ {documents.length} document{documents.length !== 1 ? "s" : ""}
+
+
+ )}
+
+
+ {/* Body */}
+
+ {/* Loading state */}
+ {isLoading && (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ )}
+
+ {/* Error state */}
+ {isError && !isLoading && (
+
+
+
+
+
+ Could not load documents
+
+
+ Please try again later.
+
+
+ )}
+
+ {/* Empty state */}
+ {!isLoading && !isError && !hasDocuments && (
+
+
+
+
+
+ No documents uploaded
+
+
+ Survey documents will appear here once uploaded for this
+ property.
+
+
+ )}
+
+ {/* Document groups */}
+
+ {!isLoading &&
+ !isError &&
+ hasDocuments &&
+ Object.entries(grouped).map(([category, docs]) => (
+
+
+ {category}
+
+
+ {docs.map((doc) => (
+
+ ))}
+
+
+ ))}
+
+
+
+ {/* Footer */}
+
+
+ Download links expire after 30 minutes. Refresh to generate a new
+ link.
+
+
+
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx
new file mode 100644
index 00000000..3d9c447d
--- /dev/null
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx
@@ -0,0 +1,378 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import {
+ useReactTable,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getSortedRowModel,
+ getPaginationRowModel,
+ flexRender,
+ type SortingState,
+ type VisibilityState,
+ type PaginationState,
+} 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 {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/app/shadcn_components/ui/dropdown-menu";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+} from "@/app/shadcn_components/ui/select";
+import { Search, SlidersHorizontal, ChevronLeft, ChevronRight, Download } from "lucide-react";
+import { createPropertyTableColumns } from "./PropertyTableColumns";
+import { STAGE_ORDER } from "./types";
+import type { ClassifiedDeal, DisplayStage } from "./types";
+
+// Human-readable labels for toggle dropdown
+const COLUMN_LABELS: Record = {
+ landlordPropertyId: "Property Ref",
+ uprn: "UPRN",
+ projectCode: "Project",
+ coordinator: "Coordinator",
+ designer: "Designer",
+ installer: "Installer",
+ proposedMeasures: "Proposed Measures",
+ approvedPackage: "Approved Package",
+ actualMeasuresInstalled: "Installed Measures",
+ preSapScore: "Pre-SAP",
+ postSapScore: "Post-SAP",
+ lodgementStatus: "Lodgement Status",
+ designDate: "Design Date",
+ fullLodgementDate: "Lodgement Date",
+};
+
+interface PropertyTableProps {
+ data: ClassifiedDeal[];
+ onOpenDrawer: (uprn: string | null, dealname: string | null) => void;
+ showDocuments?: boolean;
+}
+
+const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [
+ { key: "dealname", label: "Address" },
+ { key: "landlordPropertyId", label: "Property Ref" },
+ { key: "uprn", label: "UPRN" },
+ { key: "displayStage", label: "Stage" },
+ { key: "projectCode", label: "Project" },
+ { key: "coordinator", label: "Coordinator" },
+ { key: "designer", label: "Designer" },
+ { key: "installer", label: "Installer" },
+ { key: "proposedMeasures", label: "Proposed Measures" },
+ { key: "approvedPackage", label: "Approved Package" },
+ { key: "actualMeasuresInstalled", label: "Installed Measures" },
+ { key: "preSapScore", label: "Pre-SAP" },
+ { key: "lodgementStatus", label: "Lodgement Status" },
+ { key: "designDate", label: "Design Date" },
+ { key: "fullLodgementDate", label: "Lodgement Date" },
+];
+
+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;
+}
+
+export default function PropertyTable({ data, onOpenDrawer, showDocuments = false }: PropertyTableProps) {
+ const [globalFilter, setGlobalFilter] = useState("");
+ const [stageFilter, setStageFilter] = useState("all");
+ const [sorting, setSorting] = useState([]);
+ const [pagination, setPagination] = useState({
+ pageIndex: 0,
+ pageSize: 25,
+ });
+ const [columnVisibility, setColumnVisibility] = useState({
+ designer: false,
+ installer: false,
+ proposedMeasures: false,
+ approvedPackage: false,
+ actualMeasuresInstalled: false,
+ preSapScore: false,
+ postSapScore: false,
+ lodgementStatus: false,
+ designDate: false,
+ fullLodgementDate: false,
+ });
+
+ // Pre-filter by stage before TanStack gets it
+ const filteredData = useMemo(() => {
+ if (stageFilter === "all") return data;
+ return data.filter((d) => d.displayStage === stageFilter);
+ }, [data, stageFilter]);
+
+ const columns = useMemo(
+ () => createPropertyTableColumns(onOpenDrawer, showDocuments),
+ [onOpenDrawer, showDocuments]
+ );
+
+ const table = useReactTable({
+ data: filteredData,
+ columns,
+ state: {
+ globalFilter,
+ sorting,
+ pagination,
+ columnVisibility,
+ },
+ onGlobalFilterChange: setGlobalFilter,
+ onSortingChange: setSorting,
+ onPaginationChange: setPagination,
+ onColumnVisibilityChange: setColumnVisibility,
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ globalFilterFn: "includesString",
+ });
+
+ const downloadCsv = () => {
+ const rows = table.getFilteredRowModel().rows;
+ const header = CSV_FIELDS.map((f) => f.label).join(",");
+ const body = rows
+ .map((row) =>
+ CSV_FIELDS.map((f) => escapeCell(row.original[f.key])).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 = "properties.csv";
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const toggleableColumns = table
+ .getAllColumns()
+ .filter((col) => col.getCanHide() && COLUMN_LABELS[col.id]);
+
+ const pageCount = table.getPageCount();
+ const currentPage = table.getState().pagination.pageIndex + 1;
+ const totalFiltered = table.getFilteredRowModel().rows.length;
+
+ return (
+
+ {/* Toolbar */}
+
+ {/* Search */}
+
+
+ {
+ setGlobalFilter(e.target.value);
+ setPagination((p) => ({ ...p, pageIndex: 0 }));
+ }}
+ placeholder="Search address, UPRN, coordinator…"
+ className="pl-9 h-9 text-sm border-gray-200 focus:border-brandblue/40 focus:ring-brandblue/20"
+ />
+
+
+ {/* Stage filter */}
+
+
+ {/* Download CSV */}
+
+
+ {/* Column visibility */}
+
+
+
+
+
+
+ Toggle columns
+
+
+ {toggleableColumns.map((col) => (
+ col.toggleVisibility(val)}
+ className="text-sm"
+ >
+ {COLUMN_LABELS[col.id] ?? col.id}
+
+ ))}
+
+
+
+
+ {/* Result count */}
+
+ Showing{" "}
+
+ {Math.min(
+ table.getState().pagination.pageSize,
+ totalFiltered - table.getState().pagination.pageIndex * table.getState().pagination.pageSize
+ )}
+ {" "}
+ of{" "}
+ {totalFiltered}{" "}
+ {stageFilter !== "all" ? `"${stageFilter}" ` : ""}
+ propert{totalFiltered === 1 ? "y" : "ies"}
+
+
+ {/* 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 properties match the current filters.
+
+
+ )}
+
+
+
+
+
+ {/* Pagination */}
+ {pageCount > 1 && (
+
+
+ Rows per page:
+
+
+
+
+
+ Page {currentPage} of {pageCount}
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx
new file mode 100644
index 00000000..7f9b0ceb
--- /dev/null
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx
@@ -0,0 +1,310 @@
+"use client";
+
+import { ColumnDef } from "@tanstack/react-table";
+import { ArrowUpDown, FileDown } from "lucide-react";
+import { STAGE_COLORS } from "./types";
+import type { ClassifiedDeal, DisplayStage } from "./types";
+
+// -----------------------------------------------------------------------
+// Stage badge — consistent pill rendering
+// -----------------------------------------------------------------------
+function StageBadge({ stage }: { stage: DisplayStage }) {
+ const c = STAGE_COLORS[stage] ?? STAGE_COLORS["Unknown Stage"];
+ return (
+
+
+ {stage}
+
+ );
+}
+
+// Sortable column header helper
+function SortableHeader({
+ label,
+ column,
+}: {
+ label: string;
+ column: { toggleSorting: (desc: boolean) => void; getIsSorted: () => false | "asc" | "desc" };
+}) {
+ return (
+
+ );
+}
+
+// -----------------------------------------------------------------------
+// Column factory — takes onOpenDrawer so the Documents button can trigger it
+// showDocuments controls whether the Docs action column is included
+// -----------------------------------------------------------------------
+export function createPropertyTableColumns(
+ onOpenDrawer: (uprn: string | null, dealname: string | null) => void,
+ showDocuments: boolean = false,
+): ColumnDef[] {
+ const columns: ColumnDef[] = [
+ // ── Address ──────────────────────────────────────────────────────────
+ {
+ accessorKey: "dealname",
+ id: "dealname",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+
+ {row.original.dealname ?? "—"}
+
+
+ ),
+ enableHiding: false,
+ },
+
+ // ── Property ref ─────────────────────────────────────────────────────
+ {
+ accessorKey: "landlordPropertyId",
+ id: "landlordPropertyId",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {row.original.landlordPropertyId ?? "—"}
+
+ ),
+ },
+
+ // ── UPRN ─────────────────────────────────────────────────────────────
+ {
+ accessorKey: "uprn",
+ id: "uprn",
+ header: () => (
+ UPRN
+ ),
+ cell: ({ row }) => (
+
+ {row.original.uprn ?? "—"}
+
+ ),
+ },
+
+ // ── Stage badge ──────────────────────────────────────────────────────
+ {
+ accessorKey: "displayStage",
+ id: "displayStage",
+ header: ({ column }) => ,
+ cell: ({ row }) => ,
+ filterFn: (row, _id, filterValue: string) =>
+ row.original.displayStage === filterValue,
+ enableHiding: false,
+ },
+
+ // ── Project code ─────────────────────────────────────────────────────
+ {
+ accessorKey: "projectCode",
+ id: "projectCode",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {row.original.projectCode ?? "—"}
+
+ ),
+ },
+
+ // ── Coordinator ──────────────────────────────────────────────────────
+ {
+ accessorKey: "coordinator",
+ id: "coordinator",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {row.original.coordinator ?? —}
+
+ ),
+ },
+
+ // ── Designer ─────────────────────────────────────────────────────────
+ {
+ accessorKey: "designer",
+ id: "designer",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {row.original.designer ?? —}
+
+ ),
+ },
+
+ // ── Installer ────────────────────────────────────────────────────────
+ {
+ accessorKey: "installer",
+ id: "installer",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {row.original.installer ?? —}
+
+ ),
+ },
+
+ // ── Proposed measures ────────────────────────────────────────────────
+ {
+ accessorKey: "proposedMeasures",
+ id: "proposedMeasures",
+ header: () => (
+
+ Proposed Measures
+
+ ),
+ cell: ({ row }) => (
+
+ {row.original.proposedMeasures ?? —}
+
+ ),
+ },
+
+ // ── Approved package ─────────────────────────────────────────────────
+ {
+ accessorKey: "approvedPackage",
+ id: "approvedPackage",
+ header: () => (
+
+ Approved Package
+
+ ),
+ cell: ({ row }) => (
+
+ {row.original.approvedPackage ?? —}
+
+ ),
+ },
+
+ // ── Installed measures ───────────────────────────────────────────────
+ {
+ accessorKey: "actualMeasuresInstalled",
+ id: "actualMeasuresInstalled",
+ header: () => (
+
+ Installed
+
+ ),
+ cell: ({ row }) => (
+
+ {row.original.actualMeasuresInstalled ?? —}
+
+ ),
+ },
+
+ // ── Pre-SAP score ────────────────────────────────────────────────────
+ {
+ accessorKey: "preSapScore",
+ id: "preSapScore",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const score = row.original.preSapScore;
+ if (!score) return —;
+ const n = Number(score);
+ const colour =
+ n < 30
+ ? "text-red-600 bg-red-50"
+ : n < 50
+ ? "text-amber-700 bg-amber-50"
+ : "text-emerald-700 bg-emerald-50";
+ return (
+
+ {score}
+
+ );
+ },
+ },
+
+ // ── Post-SAP score ───────────────────────────────────────────────────
+ {
+ accessorKey: "postSapScore",
+ id: "postSapScore",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const score = row.original.postSapScore;
+ if (!score) return —;
+ const n = Number(score);
+ const colour =
+ n >= 70
+ ? "text-emerald-700 bg-emerald-50"
+ : n >= 50
+ ? "text-sky-700 bg-sky-50"
+ : "text-amber-700 bg-amber-50";
+ return (
+
+ {score}
+
+ );
+ },
+ },
+
+ // ── Lodgement status ─────────────────────────────────────────────────
+ {
+ accessorKey: "lodgementStatus",
+ id: "lodgementStatus",
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {row.original.lodgementStatus ?? —}
+
+ ),
+ },
+
+ // ── Design date ──────────────────────────────────────────────────────
+ {
+ accessorKey: "designDate",
+ id: "designDate",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const d = row.original.designDate;
+ return (
+
+ {d ? new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "2-digit" }) : —}
+
+ );
+ },
+ },
+
+ // ── Full lodgement date ──────────────────────────────────────────────
+ {
+ accessorKey: "fullLodgementDate",
+ id: "fullLodgementDate",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const d = row.original.fullLodgementDate;
+ return (
+
+ {d ? new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "2-digit" }) : —}
+
+ );
+ },
+ },
+
+ ];
+
+ if (showDocuments) {
+ columns.push({
+ id: "documents",
+ header: () => null,
+ cell: ({ row }) => (
+
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ });
+ }
+
+ return columns;
+}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/TableViewer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/TableViewer.tsx
index 680c0bea..3770b26c 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/TableViewer.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/TableViewer.tsx
@@ -9,6 +9,9 @@ interface TableViewerProps {
columns?: (keyof HubspotDeal)[];
columnLabels?: Partial>;
breakdown?: Record;
+ title?: string;
+ description?: string;
+ reason?: string;
}
export default function TableViewer({
@@ -16,16 +19,32 @@ export default function TableViewer({
columns,
columnLabels,
breakdown,
+ title,
+ description,
+ reason,
}: TableViewerProps) {
const [searchTerms, setSearchTerms] = useState>({});
- const visibleColumns = columns?.length
- ? columns
+
+ // Always include key context columns if not present
+ const contextCols: (keyof HubspotDeal)[] = [
+ "outcomeNotes",
+ "majorConditionIssueDescription",
+ "coordinationStatus",
+ "designStatus",
+ ];
+ let visibleColumns: (keyof HubspotDeal)[] = columns?.length
+ ? [...columns]
: (Object.keys(data?.[0] || {}) as (keyof HubspotDeal)[]);
+ for (const col of contextCols) {
+ if (!visibleColumns.includes(col)) {
+ visibleColumns.push(col);
+ }
+ }
// Helper: Get category for a row based on breakdown
const getCategoryForRow = (
row: ClassifiedDeal,
- brk: Record | undefined
+ brk: Record | undefined,
): string | undefined => {
if (!brk) return undefined;
for (const [category, items] of Object.entries(brk)) {
@@ -77,9 +96,11 @@ export default function TableViewer({
visibleColumns.every((col) => {
const term = searchTerms[col]?.toLowerCase() || "";
if (!term) return true;
- const value = String(row[col as keyof ClassifiedDeal] ?? "").toLowerCase();
+ const value = String(
+ row[col as keyof ClassifiedDeal] ?? "",
+ ).toLowerCase();
return value.includes(term);
- })
+ }),
);
// Inline sort derivation (no useMemo)
@@ -152,11 +173,30 @@ export default function TableViewer({
return (
+ {/* Context header */}
+ {(title || description || reason) && (
+
+ {title && (
+
{title}
+ )}
+ {description && (
+
{description}
+ )}
+ {reason && (
+
+ {reason}
+
+ )}
+
+ )}
{visibleColumns.map((col) => (
- |
+ |
{columnLabels?.[col] || (col as string)}
@@ -197,10 +237,7 @@ export default function TableViewer({
>
{visibleColumns.map((col) => (
|
- {renderCellContent(
- col,
- row[col as keyof ClassifiedDeal]
- )}
+ {renderCellContent(col, row[col as keyof ClassifiedDeal])}
|
))}
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 6a957e42..f7eee9af 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx
@@ -1,11 +1,61 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { redirect } from "next/navigation";
+import { eq } from "drizzle-orm";
import LiveTracker from "./LiveTracker";
import { computeLiveTrackerData } from "./transforms";
-// MOCK PHASE: using mock data while DB migration is in progress
-// When migration lands, restore the DB query below and remove this import
-import { MOCK_DEALS } from "./mockData";
+import { db } from "@/app/db/db";
+import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
+import type { HubspotDeal } from "./types";
+import type { InferSelectModel } from "drizzle-orm";
+
+// ⚠️ ⚠️ ⚠️ HARDCODED COMPANY ID — temporary for testing only.
+// TODO: derive this from the portfolio slug once the portfolio↔company mapping exists.
+// Do NOT ship this to production without replacing it with the real lookup.
+const HARDCODED_COMPANY_ID = "86970043613";
+
+type DbDeal = InferSelectModel;
+
+function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal {
+ return {
+ id: row.id,
+ dealId: row.dealId,
+ dealname: row.dealname,
+ dealstage: row.dealstage,
+ companyId: row.companyId,
+ projectCode: row.projectCode,
+ landlordPropertyId: row.landlordPropertyId,
+ uprn: row.uprn,
+ outcome: row.outcome,
+ outcomeNotes: row.outcomeNotes,
+ majorConditionIssueDescription: row.majorConditionIssueDescription,
+ majorConditionIssuePhotos: row.majorConditionIssuePhotos,
+ majorConditionIssuePhotosS3: row.majorConditionIssuePhotosS3,
+ coordinationStatus: row.coordinationStatus,
+ designStatus: row.designStatus,
+ pashubLink: row.pashubLink,
+ sharepointLink: row.sharepointLink,
+ dampMouldFlag: row.dampmouldGrowth, // DB: dampmouldGrowth
+ preSapScore: row.preSap, // DB: preSap
+ coordinator: row.coordinator,
+ ioeV1Date: row.mtpCompletionDate, // DB: mtpCompletionDate
+ ioeV2Date: row.mtpReModelCompletionDate, // DB: mtpReModelCompletionDate
+ ioeV3Date: row.ioeV3CompletionDate, // DB: ioeV3CompletionDate
+ proposedMeasures: row.proposedMeasures,
+ approvedPackage: row.approvedPackage,
+ designer: row.designer,
+ designDate: row.designCompletionDate, // DB: designCompletionDate
+ actualMeasuresInstalled: row.actualMeasuresInstalled,
+ installer: row.installer,
+ installerHandover: row.installerHandover,
+ lodgementStatus: row.lodgementStatus,
+ measuresLodgementDate: row.measuresLodgementDate,
+ fullLodgementDate: row.lodgementDate, // DB: lodgementDate
+ confirmedSurveyDate: row.confirmedSurveyDate,
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ };
+}
export default async function LiveReportingPage(props: {
params: Promise<{ slug: string }>;
@@ -18,13 +68,17 @@ export default async function LiveReportingPage(props: {
redirect("/");
}
- // MOCK PHASE ↓↓↓
- // TODO: replace with real DB query once migration is applied:
- // const [company] = await surveyDB.select().from(hubspotCompanyData).where(eq(hubspotCompanyData.groupId, portfolioId));
- // const deals = await surveyDB.select().from(hubspotDealData).where(eq(hubspotDealData.companyId, company.companyId));
- // const trackerData = computeLiveTrackerData(deals as HubspotDeal[]);
- const trackerData = computeLiveTrackerData(MOCK_DEALS);
- // ↑↑↑ END MOCK PHASE
+ // ⚠️ Using HARDCODED_COMPANY_ID — see constant above before deploying
+ const rawDeals = await db
+ .select()
+ .from(hubspotDealData)
+ .where(eq(hubspotDealData.companyId, HARDCODED_COMPANY_ID));
+
+ console.log("Fetched deals from DB:", rawDeals.length);
+
+ const trackerData = computeLiveTrackerData(
+ rawDeals.map(mapDbRowToHubspotDeal),
+ );
return (
@@ -42,5 +96,3 @@ export default async function LiveReportingPage(props: {
);
}
-
-
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts
index 4119cf6f..ab5916c3 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts
@@ -398,6 +398,16 @@ export function computeLiveTrackerData(
})
);
+ // When there are multiple project codes, prepend a synthetic "All Projects" entry
+ if (projects.length > 1) {
+ projects.unshift({
+ projectCode: "__ALL__",
+ progress: computeProjectProgress(classified),
+ outcomePieSlices: computeOutcomeSlices(classified),
+ allDeals: classified,
+ });
+ }
+
return {
projects,
totalDeals: classified.length,
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 8b0ae9ad..6eb365d0 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts
@@ -40,20 +40,10 @@ export type HubspotDeal = {
actualMeasuresInstalled: string | null;
installer: string | null;
installerHandover: string | null;
- postSapScore: string | null;
lodgementStatus: string | null;
measuresLodgementDate: Date | null;
fullLodgementDate: Date | null;
-
- // ── Internally tracked stubs (no HubSpot source yet) ─────────────────
- raDateBooking: Date | null;
- raDateActual: Date | null;
- raStatus: string | null;
- postEpcDateBooking: Date | null;
- postEpcDateActual: Date | null;
- postEpcStatus: string | null;
- postEprDate: Date | null;
- postEprStatus: string | null;
+ confirmedSurveyDate: Date | null;
createdAt: Date;
updatedAt: Date;