From ac30e3c13a01ae1590ac306aed5ff28906bbc566 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 4 Apr 2026 13:35:50 +0000 Subject: [PATCH] adding portfolio organisation (wip_ --- src/app/db/db.ts | 2 + src/app/db/migrations/meta/_journal.json | 7 + .../settings/PortfolioSettings.tsx | 4 + .../[slug]/(portfolio)/settings/page.tsx | 11 +- .../your-projects/live/AnalyticsView.tsx | 96 +++++-- .../live/CompletionTrendsChart.tsx | 272 +++++++++--------- .../your-projects/live/LiveTracker.tsx | 11 + .../your-projects/live/PropertyTable.tsx | 11 +- .../live/PropertyTableColumns.tsx | 40 +-- .../(portfolio)/your-projects/live/page.tsx | 90 ++++-- 10 files changed, 314 insertions(+), 230 deletions(-) diff --git a/src/app/db/db.ts b/src/app/db/db.ts index 5219612..992e73d 100644 --- a/src/app/db/db.ts +++ b/src/app/db/db.ts @@ -12,6 +12,7 @@ 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"; +import * as PortfolioOrgSchema from "@/app/db/schema/portfolio_organisation"; export const pool = new Pool({ host: process.env.DB_HOST, @@ -35,6 +36,7 @@ const schema = { ...Users, ...CrmSchema, ...UploadedFilesSchema, + ...PortfolioOrgSchema, }; export const db = drizzle(pool, { diff --git a/src/app/db/migrations/meta/_journal.json b/src/app/db/migrations/meta/_journal.json index 9fee291..4b628e3 100644 --- a/src/app/db/migrations/meta/_journal.json +++ b/src/app/db/migrations/meta/_journal.json @@ -1142,6 +1142,13 @@ "when": 1775041844023, "tag": "0162_powerful_paladin", "breakpoints": true + }, + { + "idx": 163, + "version": "7", + "when": 1775309608582, + "tag": "0163_fat_mentallo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx index b493531..169b965 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/PortfolioSettings.tsx @@ -35,6 +35,7 @@ import { PortfolioGoal as PortfolioGoalOptions } from "@/app/db/schema/portfolio import { useSession } from "next-auth/react"; import PortfolioPlanTable from "@/app/components/portfolio/measures/PlanTable"; import { UsersPermissionsCard } from "./UsersPermissionsCard"; +import OrganisationLinkCard from "./OrganisationLinkCard"; // dropdown selection component for both goal and status @@ -215,9 +216,11 @@ async function deletePortfolio({ export default function PortfolioSettings({ portfolioId, portfolioSettingsData, + isDomnaUser = false, }: { portfolioId: string; portfolioSettingsData: PortfolioSettingsType; + isDomnaUser?: boolean; }) { // This is a client component so we can access the session directly const session = useSession(); @@ -475,6 +478,7 @@ export default function PortfolioSettings({ + {isDomnaUser && }
diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/page.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/page.tsx index 67ae43f..4732c67 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/page.tsx @@ -1,3 +1,5 @@ +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { getPortfolioSettings } from "../../utils"; import PortfolioSettings from "./PortfolioSettings"; @@ -8,7 +10,13 @@ export default async function PortfolioSettingsPage( ) { const params = await props.params; const portfolioId = params.slug; - const portfolioSettingsData = await getPortfolioSettings(portfolioId); + + const [portfolioSettingsData, session] = await Promise.all([ + getPortfolioSettings(portfolioId), + getServerSession(AuthOptions), + ]); + + const isDomnaUser = !!session?.user?.email?.endsWith("@domna.homes"); return ( <> @@ -16,6 +24,7 @@ export default async function PortfolioSettingsPage( diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx index 5aea535..73b3dec 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx @@ -95,6 +95,66 @@ function StatCard({ ); } +// ----------------------------------------------------------------------- +// Per-stage column config for the drill-down table +// ----------------------------------------------------------------------- +type StageTableConfig = { + cols: (keyof ClassifiedDeal)[]; + labels: Partial>; +}; + +const STAGE_TABLE_CONFIG: Record = { + "Booking in Progress": { + cols: ["dealname", "landlordPropertyId", "confirmedSurveyDate", "ioeV1Date"], + labels: { + dealname: "Address", + landlordPropertyId: "Ref", + confirmedSurveyDate: "Confirmed Survey Date", + ioeV1Date: "Expected Commencement", + }, + }, + "Assessment in Progress": { + cols: ["dealname", "landlordPropertyId", "confirmedSurveyDate", "ioeV1Date", "outcome", "coordinator"], + labels: { + dealname: "Address", + landlordPropertyId: "Ref", + confirmedSurveyDate: "Confirmed Survey Date", + ioeV1Date: "Expected Commencement", + outcome: "Outcome", + coordinator: "Surveyor", + }, + }, + "Coordination in Progress": { + cols: ["dealname", "landlordPropertyId", "coordinator", "preSapScore", "coordinationStatus"], + labels: { + dealname: "Address", + landlordPropertyId: "Ref", + coordinator: "Coordinator", + preSapScore: "Pre-SAP Score", + coordinationStatus: "Coordination Status", + }, + }, + "Design in Progress": { + cols: ["dealname", "landlordPropertyId", "designer", "proposedMeasures", "designType"], + labels: { + dealname: "Address", + landlordPropertyId: "Ref", + designer: "Designer", + proposedMeasures: "Proposed Measures", + designType: "Design Type", + }, + }, + _default: { + cols: ["dealname", "landlordPropertyId", "displayStage", "installer"], + labels: { + dealname: "Address", + landlordPropertyId: "Ref", + displayStage: "Stage", + installer: "Installer", + }, + }, +}; + // ----------------------------------------------------------------------- // Pipeline Funnel — rich card rows // ----------------------------------------------------------------------- @@ -116,7 +176,7 @@ function PipelineFunnel({ reason?: string, ) => void; }) { - const [mode, setMode] = useState<"current" | "cumulative">("cumulative"); + const [mode, setMode] = useState<"current" | "cumulative">("current"); const ALWAYS_VISIBLE: DisplayStage[] = ["At Lodgement", "Project Complete"]; const visibleStages = funnelStages.filter( @@ -180,34 +240,10 @@ function PipelineFunnel({ key={s.stage} whileHover={{ scale: 1.01, y: -1 }} transition={{ duration: 0.15 }} - onClick={() => - onOpenTable( - `Pipeline — ${s.stage}`, - deals, - [ - "dealname", - "landlordPropertyId", - "displayStage", - "coordinator", - "designer", - "installer", - ], - { - dealname: "Address", - landlordPropertyId: "Ref", - displayStage: "Stage", - coordinator: "Coordinator", - designer: "Designer", - installer: "Installer", - }, - undefined, - `Pipeline — ${s.stage}`, - mode === "cumulative" - ? `Properties that have reached the "${s.stage}" stage or beyond.` - : `Properties currently at the "${s.stage}" stage.`, - undefined, - ) - } + onClick={() => { + const { cols, labels } = STAGE_TABLE_CONFIG[s.stage] ?? STAGE_TABLE_CONFIG._default; + onOpenTable(`Pipeline — ${s.stage}`, deals, cols, labels); + }} className={`w-full text-left rounded-xl border ${c.border} ${c.bg} p-4 shadow-sm hover:shadow-md transition-shadow`} type="button" > @@ -339,8 +375,6 @@ export default function AnalyticsView({ {/* Row 1.5: Completion trends chart */} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx index 9f92ec7..d869e97 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx @@ -2,9 +2,18 @@ import { useState } from "react"; import { AlertCircle } from "lucide-react"; -import { Card, Title, BarChart, Legend } from "@tremor/react"; -import { Button } from "@/app/shadcn_components/ui/button"; -import { Input } from "@/app/shadcn_components/ui/input"; +import { Card, Title } from "@tremor/react"; +import { + BarChart as RechartsBarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + LabelList, + ResponsiveContainer, + Legend as RechartsLegend, +} from "recharts"; import { Select, SelectContent, @@ -15,7 +24,6 @@ import type { ClassifiedDeal } from "./types"; interface CompletionTrendsChartProps { deals: ClassifiedDeal[]; - isDomnaUser?: boolean; projectCode?: string; onOpenTable?: ( stage: string, @@ -45,13 +53,13 @@ const METRICS = [ }, ]; -// Brand colour palette (hex values work directly with Tremor v3 colors prop) +// Brand colour palette const C = { - blue: "#5d6be0", // lighter periwinkle blue - midblue: "#3943b7", // brandmidblue - lightblue: "#8b96e9", // soft pale blue for 3rd series - paleblue: "#b8bef4", // 4th series — replaces brown in bar charts - brown: "#c4a47c", // accent only — trend/target lines + blue: "#5d6be0", + midblue: "#3943b7", + lightblue: "#8b96e9", + paleblue: "#b8bef4", + brown: "#c4a47c", }; function ChartTooltip({ @@ -64,10 +72,13 @@ function ChartTooltip({ label?: string; }) { if (!active || !payload?.length) return null; + // Filter out the internal _total key + const visible = payload.filter((p) => p.name !== "_total"); + if (!visible.length) return null; return (

{label}

- {payload.map((item, i) => ( + {visible.map((item, i) => (
= {}; for (const deal of deals) { if (filter && !filter(deal)) continue; - const date = deal[dateField as keyof ClassifiedDeal] as - | string - | Date - | null; + const date = deal[dateField as keyof ClassifiedDeal] as string | Date | null; if (!date) continue; const d = new Date(date); if (isNaN(d.getTime())) continue; @@ -153,16 +159,14 @@ function aggregateByWeek( })); } -// Coordination: buckets V1 and V2 completions separately function aggregateCoordinationByWeek( deals: ClassifiedDeal[], -): Array<{ week: string; "V1 (MTP)": number; "V2 (Re-model)": number }> { +): Array<{ week: string; "V1 (MTP)": number; "V2 (Re-model)": number; _total: number }> { const v1Counts: Record = {}; const v2Counts: Record = {}; for (const deal of deals) { const status = (deal.coordinationStatus ?? "").toUpperCase(); - if (status.includes("(V1) IOE/MTP COMPLETE") && deal.ioeV1Date) { const d = new Date(deal.ioeV1Date); if (!isNaN(d.getTime())) { @@ -170,7 +174,6 @@ function aggregateCoordinationByWeek( v1Counts[key] = (v1Counts[key] || 0) + 1; } } - if (status.includes("(V2) IOE/MTP COMPLETE") && deal.ioeV2Date) { const d = new Date(deal.ioeV2Date); if (!isNaN(d.getTime())) { @@ -184,17 +187,16 @@ function aggregateCoordinationByWeek( Array.from(new Set([...Object.keys(v1Counts), ...Object.keys(v2Counts)])), ); - return allKeys.map((isoKey) => ({ - week: formatMonday(isoKey), - "V1 (MTP)": v1Counts[isoKey] ?? 0, - "V2 (Re-model)": v2Counts[isoKey] ?? 0, - })); + return allKeys.map((isoKey) => { + const v1 = v1Counts[isoKey] ?? 0; + const v2 = v2Counts[isoKey] ?? 0; + return { week: formatMonday(isoKey), "V1 (MTP)": v1, "V2 (Re-model)": v2, _total: v1 + v2 }; + }); } -// Assessments: Retrofit Assessment vs EPC, keyed by surveyedDate function aggregateAssessmentsByWeek( deals: ClassifiedDeal[], -): Array<{ week: string; "Retrofit Assessment": number; EPC: number }> { +): Array<{ week: string; "Retrofit Assessment": number; EPC: number; _total: number }> { const retrofitCounts: Record = {}; const epcCounts: Record = {}; @@ -204,32 +206,27 @@ function aggregateAssessmentsByWeek( const isEpc = o === "EPC Completed"; if (!isRetrofit && !isEpc) continue; if (!deal.surveyedDate) continue; - const d = new Date(deal.surveyedDate); if (isNaN(d.getTime())) continue; const key = getMondayOfWeek(d); - if (isRetrofit) retrofitCounts[key] = (retrofitCounts[key] || 0) + 1; if (isEpc) epcCounts[key] = (epcCounts[key] || 0) + 1; } const allKeys = fillWeekGaps( - Array.from( - new Set([...Object.keys(retrofitCounts), ...Object.keys(epcCounts)]), - ), + Array.from(new Set([...Object.keys(retrofitCounts), ...Object.keys(epcCounts)])), ); - return allKeys.map((isoKey) => ({ - week: formatMonday(isoKey), - "Retrofit Assessment": retrofitCounts[isoKey] ?? 0, - EPC: epcCounts[isoKey] ?? 0, - })); + return allKeys.map((isoKey) => { + const r = retrofitCounts[isoKey] ?? 0; + const e = epcCounts[isoKey] ?? 0; + return { week: formatMonday(isoKey), "Retrofit Assessment": r, EPC: e, _total: r + e }; + }); } -// Lodgements: Stage 1 vs Lodged Measures function aggregateLodgementsByWeek( deals: ClassifiedDeal[], -): Array<{ week: string; "Stage 1 Lodgement": number; "Lodged Measures": number }> { +): Array<{ week: string; "Stage 1 Lodgement": number; "Lodged Measures": number; _total: number }> { const stageCounts: Record = {}; const measuresCounts: Record = {}; @@ -251,19 +248,16 @@ function aggregateLodgementsByWeek( } const allKeys = fillWeekGaps( - Array.from( - new Set([...Object.keys(stageCounts), ...Object.keys(measuresCounts)]), - ), + Array.from(new Set([...Object.keys(stageCounts), ...Object.keys(measuresCounts)])), ); - return allKeys.map((isoKey) => ({ - week: formatMonday(isoKey), - "Stage 1 Lodgement": stageCounts[isoKey] ?? 0, - "Lodged Measures": measuresCounts[isoKey] ?? 0, - })); + return allKeys.map((isoKey) => { + const s = stageCounts[isoKey] ?? 0; + const m = measuresCounts[isoKey] ?? 0; + return { week: formatMonday(isoKey), "Stage 1 Lodgement": s, "Lodged Measures": m, _total: s + m }; + }); } -// Designs: stacked by design type (Uploaded only) function aggregateDesignsByWeek( deals: ClassifiedDeal[], ): Array> { @@ -275,10 +269,8 @@ function aggregateDesignsByWeek( const d = new Date(deal.designDate); if (isNaN(d.getTime())) continue; const key = getMondayOfWeek(d); - const rawType = deal.designType ?? "Unknown"; const label = DESIGN_TYPE_LABELS[rawType] ?? rawType; - if (!counts[key]) counts[key] = {}; counts[key][label] = (counts[key][label] || 0) + 1; } @@ -286,25 +278,34 @@ function aggregateDesignsByWeek( const allKeys = fillWeekGaps(Object.keys(counts)); return allKeys.map((isoKey) => { const entry: Record = { week: formatMonday(isoKey) }; + let total = 0; for (const label of DESIGN_TYPE_ORDER) { - entry[label] = counts[isoKey]?.[label] ?? 0; + const v = counts[isoKey]?.[label] ?? 0; + entry[label] = v; + total += v; } + entry._total = total; return entry; }); } +// Compute total completed count for metrics that support it +function computeTotalCompleted( + metric: string, + chartData: Record[], + categories: string[], +): number | null { + if (!["bookings", "assessments", "coordination", "design"].includes(metric)) return null; + return chartData.reduce((sum, row) => { + return sum + categories.reduce((s, cat) => s + ((row[cat] as number) || 0), 0); + }, 0); +} + export default function CompletionTrendsChart({ deals, - isDomnaUser, - projectCode, onOpenTable, }: CompletionTrendsChartProps) { const [metric, setMetric] = useState(METRICS[0].key); - const [targets, setTargets] = useState<{ [week: string]: number }>({}); - const [targetInput, setTargetInput] = useState<{ - week: string; - value: string; - }>({ week: "", value: "" }); const selectedMetric = METRICS.find((m) => m.key === metric)!; const isCoordination = metric === "coordination"; @@ -313,18 +314,15 @@ export default function CompletionTrendsChart({ const isDesign = metric === "design"; const isStacked = isCoordination || isAssessments || isLodgement || isDesign; - // Assessments from external surveyors (no surveyedDate recorded) + // External assessments with no date const undatedAssessments = isAssessments ? deals.filter((d) => { const o = d.outcome ?? ""; - return ( - (o === "Surveyed" || o === "Surveyed - Pending Upload") && - !d.surveyedDate - ); + return (o === "Surveyed" || o === "Surveyed - Pending Upload") && !d.surveyedDate; }) : []; - // Compute chart data, categories, and colours in one place + // Build chart data let chartData: Record[]; let categories: string[]; let colors: string[]; @@ -350,33 +348,32 @@ export default function CompletionTrendsChart({ chartData = singleData.map((d) => ({ week: d.week, [selectedMetric.label]: d.value, - ...(targets[d.week] !== undefined ? { Target: targets[d.week] } : {}), })); - categories = [selectedMetric.label, ...(isDomnaUser ? ["Target"] : [])]; - colors = [C.blue, C.brown]; + categories = [selectedMetric.label]; + colors = [C.blue]; } - const handleAddTarget = () => { - if (!targetInput.week || !targetInput.value) return; - setTargets((prev) => ({ - ...prev, - [targetInput.week]: Number(targetInput.value), - })); - setTargetInput({ week: "", value: "" }); - }; + const totalCompleted = computeTotalCompleted(metric, chartData, categories); return ( -
-
- + {/* Header row */} + <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-4"> + <div className="flex flex-col gap-2"> + <Title className="text-brandblue text-lg font-bold"> Trends Over Time -

- Switch between metrics to see weekly trends. -

+ {totalCompleted !== null && ( +
+ {totalCompleted} + + {metric === "bookings" ? "booked to date" : "completed to date"} + + +
+ )}
-
+
- setTargetInput((ti) => ({ ...ti, week: e.target.value })) - } - className="w-32 text-xs" - /> - - setTargetInput((ti) => ({ ...ti, value: e.target.value })) - } - className="w-24 text-xs" - /> - -
- )} - - + {/* Undated external assessments — shown above the chart */} {isAssessments && undatedAssessments.length > 0 && ( -
+
{undatedAssessments.length}{" "} - assessment{undatedAssessments.length !== 1 ? "s" : ""} from external surveyors have no date recorded + external assessment{undatedAssessments.length !== 1 ? "s" : ""} have no date recorded
{onOpenTable && ( @@ -466,11 +420,59 @@ export default function CompletionTrendsChart({ )}
)} + + {/* Chart */} + + + + + + } cursor={{ fill: "rgba(89,107,224,0.06)" }} /> + {categories.map((cat, i) => ( + + {/* For stacked bars: show total on the top (last) bar only via _total. + For non-stacked: show each bar's own value. */} + {i === categories.length - 1 && ( + (v === 0 ? "" : v)} + /> + )} + + ))} + + + + {/* Legend for stacked charts */} {isStacked && ( - ({ + value: cat, + type: "square" as const, + color: colors[i], + }))} /> )} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index 04086ea..502bd71 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -15,6 +15,7 @@ import PropertyTable from "./PropertyTable"; import DocumentTable from "./DocumentTable"; import type { HubspotDeal } from "./types"; import PropertyDrawer from "./PropertyDrawer"; +import PropertyDetailDrawer from "./PropertyDetailDrawer"; import AnalyticsView from "./AnalyticsView"; import type { LiveTrackerProps, @@ -53,6 +54,9 @@ export default function LiveTracker({ dealname: null, }); + // ── Property detail drawer ─────────────────────────────────────────── + const [detailDeal, setDetailDeal] = useState(null); + const handleOpenTable = ( stage: string, filteredDeals: ClassifiedDeal[], @@ -166,6 +170,7 @@ export default function LiveTracker({
@@ -302,6 +307,12 @@ export default function LiveTracker({ setDrawerState({ open: false, uprn: null, landlordPropertyId: null, dealname: null }) } /> + + {/* ── Property detail drawer ─────────────────────────────────────── */} + setDetailDeal(null)} + />
); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx index 4a61021..ac29be0 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx @@ -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, DocStatusMap } from "./types"; +import type { ClassifiedDeal, DocStatusMap } from "./types"; // Human-readable labels for toggle dropdown const COLUMN_LABELS: Record = { @@ -52,7 +52,6 @@ const COLUMN_LABELS: Record = { approvedPackage: "Approved Package", actualMeasuresInstalled: "Installed Measures", preSapScore: "Pre-SAP", - postSapScore: "Post-SAP", lodgementStatus: "Lodgement Status", designDate: "Design Date", fullLodgementDate: "Lodgement Date", @@ -63,6 +62,7 @@ type DocFilter = "all" | "has_docs" | "incomplete" | "none"; interface PropertyTableProps { data: ClassifiedDeal[]; onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void; + onOpenDetail?: (deal: ClassifiedDeal) => void; showDocuments?: boolean; docStatusMap?: DocStatusMap; } @@ -96,7 +96,7 @@ function escapeCell(value: unknown): string { : str; } -export default function PropertyTable({ data, onOpenDrawer, showDocuments = false, docStatusMap = {} }: PropertyTableProps) { +export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDocuments = false, docStatusMap = {} }: PropertyTableProps) { const [globalFilter, setGlobalFilter] = useState(""); const [stageFilter, setStageFilter] = useState("all"); const [docFilter, setDocFilter] = useState("all"); @@ -112,7 +112,6 @@ export default function PropertyTable({ data, onOpenDrawer, showDocuments = fals approvedPackage: false, actualMeasuresInstalled: false, preSapScore: false, - postSapScore: false, lodgementStatus: false, designDate: false, fullLodgementDate: false, @@ -137,8 +136,8 @@ export default function PropertyTable({ data, onOpenDrawer, showDocuments = fals }, [data, stageFilter, docFilter, docStatusMap]); const columns = useMemo( - () => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap), - [onOpenDrawer, showDocuments, docStatusMap] + () => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap, onOpenDetail), + [onOpenDrawer, showDocuments, docStatusMap, onOpenDetail] ); const table = useReactTable({ diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx index 07d4e53..9562418 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx @@ -1,7 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { ArrowUpDown, FileDown, CheckCircle2, AlertCircle, FileX } from "lucide-react"; +import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react"; import { STAGE_COLORS } from "./types"; import type { ClassifiedDeal, DisplayStage, DocStatusMap } from "./types"; @@ -48,6 +48,7 @@ export function createPropertyTableColumns( onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void, showDocuments: boolean = false, docStatusMap: DocStatusMap = {}, + onOpenDetail?: (deal: ClassifiedDeal) => void, ): ColumnDef[] { const columns: ColumnDef[] = [ // ── Address ────────────────────────────────────────────────────────── @@ -57,9 +58,18 @@ export function createPropertyTableColumns( header: ({ column }) => , cell: ({ row }) => (
-

- {row.original.dealname ?? "—"} -

+ {onOpenDetail ? ( + + ) : ( +

+ {row.original.dealname ?? "—"} +

+ )}
), enableHiding: false, @@ -221,28 +231,6 @@ export function createPropertyTableColumns( }, }, - // ── 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 ───────────────────────────────────────────────── { 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 87c9391..55da823 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -7,14 +7,13 @@ import { computeLiveTrackerData } from "./transforms"; import { db } from "@/app/db/db"; import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table"; import { uploadedFiles } from "@/app/db/schema/uploaded_files"; +import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation"; +import { organisation } from "@/app/db/schema/organisation"; 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. -// 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"; +import { Card, CardContent } from "@/app/shadcn_components/ui/card"; +import { Building2 } from "lucide-react"; type DbDeal = InferSelectModel; @@ -37,25 +36,25 @@ function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal { designStatus: row.designStatus, pashubLink: row.pashubLink, sharepointLink: row.sharepointLink, - dampMouldFlag: row.dampmouldGrowth, // DB: dampmouldGrowth - preSapScore: row.preSap, // DB: preSap + dampMouldFlag: row.dampmouldGrowth, + preSapScore: row.preSap, coordinator: row.coordinator, - ioeV1Date: row.mtpCompletionDate, // DB: mtpCompletionDate - ioeV2Date: row.mtpReModelCompletionDate, // DB: mtpReModelCompletionDate - ioeV3Date: row.ioeV3CompletionDate, // DB: ioeV3CompletionDate + ioeV1Date: row.mtpCompletionDate, + ioeV2Date: row.mtpReModelCompletionDate, + ioeV3Date: row.ioeV3CompletionDate, proposedMeasures: row.proposedMeasures, approvedPackage: row.approvedPackage, designer: row.designer, - designDate: row.designCompletionDate, // DB: designCompletionDate + designDate: row.designCompletionDate, actualMeasuresInstalled: row.actualMeasuresInstalled, installer: row.installer, installerHandover: row.installerHandover, lodgementStatus: row.lodgementStatus, measuresLodgementDate: row.measuresLodgementDate, - fullLodgementDate: row.lodgementDate, // DB: lodgementDate + fullLodgementDate: row.lodgementDate, confirmedSurveyDate: row.confirmedSurveyDate, - surveyedDate: row.SurveyedDate, // DB: surveyed_date - designType: row.dealType, // DB: design_type + surveyedDate: row.SurveyedDate, + designType: row.dealType, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -64,26 +63,64 @@ function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal { export default async function LiveReportingPage(props: { params: Promise<{ slug: string }>; }) { - const { slug: _portfolioId } = await props.params; + const { slug: portfolioId } = await props.params; const user = await getServerSession(AuthOptions); if (!user?.user) { - console.error("User not found"); redirect("/"); } - // ⚠️ Using HARDCODED_COMPANY_ID — see constant above before deploying + // Look up the linked organisation for this portfolio + const link = await db + .select({ hubspotCompanyId: organisation.hubspotCompanyId }) + .from(portfolioOrganisation) + .innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id)) + .where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId))) + .limit(1); + + const pageHeader = ( +
+
Live Projects
+

+ {`Check in on your projects' progress with real-time data updates.`} +

+
+
+ ); + + if (!link.length || !link[0].hubspotCompanyId) { + return ( +
+ {pageHeader} + + +
+ +
+
+

No organisation linked

+

+ A Domna administrator needs to connect this portfolio to an organisation in{" "} + Portfolio Settings before live tracking data can be displayed. +

+
+
+
+
+ ); + } + + const companyId = link[0].hubspotCompanyId; + const rawDeals = await db .select() .from(hubspotDealData) - .where(eq(hubspotDealData.companyId, HARDCODED_COMPANY_ID)); - - console.log("Fetched deals from DB:", rawDeals.length); + .where(eq(hubspotDealData.companyId, companyId)); const deals = rawDeals.map(mapDbRowToHubspotDeal); const trackerData = computeLiveTrackerData(deals); - // ── Fetch survey document status for all properties ───────────────── + // Fetch survey document status for all properties const uprnList = deals .map((d) => d.uprn) .filter((u): u is string => !!u) @@ -120,16 +157,7 @@ export default async function LiveReportingPage(props: { return (
-
-
- Live Projects -
-

- {`Check in on your projects' progress with real-time data updates.`} -

-
-
- + {pageHeader}
);