diff --git a/src/app/db/db.ts b/src/app/db/db.ts index fc9d1186..1d2d7789 100644 --- a/src/app/db/db.ts +++ b/src/app/db/db.ts @@ -10,6 +10,7 @@ import * as EnergyAssessmentsSchema from "@/app/db/schema/energy_assessments"; 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"; export const pool = new Pool({ host: process.env.DB_HOST, @@ -31,6 +32,7 @@ const schema = { ...EnergyAssessmentsSchema, ...FundingSchema, ...Users, + ...CrmSchema, }; export const db = drizzle(pool, { diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx new file mode 100644 index 00000000..8aaf9b9b --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx @@ -0,0 +1,381 @@ +"use client"; + +import { useState } from "react"; +import { motion } from "framer-motion"; +import { Home, AlertTriangle, ToggleLeft, ToggleRight } from "lucide-react"; +import { Card, CardContent } from "@/app/shadcn_components/ui/card"; +import ProgressOverview from "./ProgressOverview"; +import SurveyedResultsPieChart from "./SurveyedResultsPieChart"; +import DampMouldRiskPanel from "./DampMouldRiskPanel"; +import CompletionTrendsChart from "./CompletionTrendsChart"; +import { STAGE_COLORS, STAGE_ORDER } from "./types"; +import type { + ProjectData, + ClassifiedDeal, + TableModal, + FunnelStage, +} from "./types"; + +// ----------------------------------------------------------------------- +// Stat card (reused from original LiveTracker) +// ----------------------------------------------------------------------- +function StatCard({ + icon: Icon, + title, + value, + subtitle, + onClick, + accent = "brandblue", +}: { + icon: React.ElementType; + title: string; + value: string | number; + subtitle?: string; + onClick: () => void; + accent?: "brandblue" | "red" | "bright-red"; +}) { + const accentConfig = { + brandblue: { + gradient: "from-brandlightblue/30 to-brandlightblue/10", + border: "border-brandblue/20", + text: "text-brandblue", + value: "text-brandblue", + hover: "hover:border-brandblue/40 hover:shadow-lg", + icon: "text-brandblue", + }, + red: { + gradient: "from-red-100/30 to-red-50/20", + border: "border-red-300/40", + text: "text-red-500", + value: "text-red-500", + hover: "hover:border-red-300/60 hover:shadow-lg", + icon: "text-red-500", + }, + "bright-red": { + gradient: "from-red-100 to-red-50", + border: "border-red-500", + text: "text-red-700", + value: "text-red-900", + hover: "hover:border-red-600 hover:shadow-lg", + icon: "text-red-700", + }, + }; + const config = accentConfig[accent]; + + return ( + +
+
+

+ {title} +

+

+ {value} + {subtitle && ( + + {subtitle} + + )} +

+
+ +
+
+ ); +} + +// ----------------------------------------------------------------------- +// Pipeline Funnel — rich card rows +// ----------------------------------------------------------------------- +function PipelineFunnel({ + funnelStages, + allDeals, + onOpenTable, +}: { + funnelStages: FunnelStage[]; + allDeals: ClassifiedDeal[]; + onOpenTable: ( + stage: string, + deals: ClassifiedDeal[], + columns?: (keyof ClassifiedDeal)[], + columnLabels?: Partial>, + breakdown?: Record, + title?: string, + description?: string, + reason?: string, + ) => void; +}) { + const [mode, setMode] = useState<"current" | "cumulative">("cumulative"); + + const visibleStages = funnelStages.filter( + (s) => s.currentCount > 0 || s.cumulativeCount > 0, + ); + + const maxCount = Math.max( + ...visibleStages.map((s) => + mode === "current" ? s.currentCount : s.cumulativeCount, + ), + 1, + ); + + return ( + + +
+
+

+ Pipeline Overview +

+

+ {mode === "cumulative" + ? "Properties that have reached each stage or beyond" + : "Properties currently at each stage"} +

+
+ +
+ +
+ {visibleStages.map((s) => { + const count = + mode === "current" ? s.currentCount : s.cumulativeCount; + const pct = mode === "current" ? s.currentPct : s.cumulativePct; + const pastCount = s.cumulativeCount - s.currentCount; + const barWidth = maxCount > 0 ? (count / maxCount) * 100 : 0; + const c = STAGE_COLORS[s.stage]; + + const deals = allDeals.filter((d) => + mode === "current" + ? d.displayStage === s.stage + : STAGE_ORDER.indexOf(d.displayStage) >= + STAGE_ORDER.indexOf(s.stage), + ); + + return ( + + 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, + ) + } + className={`w-full text-left rounded-xl border ${c.border} ${c.bg} p-4 shadow-sm hover:shadow-md transition-shadow`} + type="button" + > + {/* Header row: dot + name + pct badge */} +
+
+ + + {s.stage} + +
+ + {pct.toFixed(0)}% + +
+ + {/* Progress bar */} +
+ 0 ? "0.5rem" : 0 }} + /> +
+ + {/* Stats row */} +
+
+ {count} + + {mode === "current" ? "here now" : "reached stage"} + +
+ {mode === "cumulative" && pastCount > 0 && ( +
+ {pastCount} + {" past this stage"} +
+ )} +
+
+ ); + })} +
+
+
+ ); +} + +// ----------------------------------------------------------------------- +// AnalyticsView — props +// ----------------------------------------------------------------------- +interface AnalyticsViewProps { + projects: { projectCode: string }[]; + currentProject: ProjectData; + currentProjectCode: string; + onProjectChange: (code: string) => void; + onOpenTable: ( + stage: string, + deals: ClassifiedDeal[], + columns?: (keyof ClassifiedDeal)[], + columnLabels?: Partial>, + breakdown?: Record, + ) => void; + majorConditionDeals: ClassifiedDeal[]; + totalDeals: number; +} + +export default function AnalyticsView({ + projects, + currentProject, + currentProjectCode, + onProjectChange, + onOpenTable, + majorConditionDeals, + totalDeals, +}: AnalyticsViewProps) { + return ( +
+ {/* Row 1: project selector + stat card (Properties in project) */} +
+ {/* Project selector */} + +
+

+ Select Project +

+
+ +
+
+
+ + {/* Properties in project */} + + onOpenTable( + `${currentProjectCode} — All Properties`, + currentProject.allDeals, + ["dealname", "landlordPropertyId"], + { dealname: "Address Ref.", landlordPropertyId: "Property Ref." }, + ) + } + accent="brandblue" + /> +
+ + {/* Row 1.5: Completion trends chart */} + + + {/* Row 2: section header */} +
+

+ Project Insights —{" "} + + {currentProjectCode === "__ALL__" ? "All Projects" : currentProjectCode} + +

+
+ + {/* Row 3: Progress overview only (no donut chart) */} +
+ + + +
+ + {/* Row 4: Pipeline Funnel */} + + + {/* Row 5: Damp & Mould Risk (moved up) */} + +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx new file mode 100644 index 00000000..f510ea70 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useState } from "react"; +import { Card, Title, LineChart, Legend } from "@tremor/react"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { Input } from "@/app/shadcn_components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@/app/shadcn_components/ui/select"; +import type { ClassifiedDeal } from "./types"; + +interface CompletionTrendsChartProps { + deals: ClassifiedDeal[]; + isDomnaUser?: boolean; + projectCode?: string; +} + +const METRICS = [ + { key: "bookings", label: "Bookings", dateField: "confirmedSurveyDate" }, + { + key: "assessments", + label: "Completed Assessments", + dateField: "raDateActual", + }, + { + key: "coordination", + label: "Completed Coordination", + dateField: "ioeV1Date", + }, + { key: "design", label: "Completed Designs", dateField: "designDate" }, + { + key: "lodgement", + label: "Completed Lodgements", + dateField: "fullLodgementDate", + }, +]; + +// Returns the Monday of the week containing `date`, as a locale string key +function getMondayOfWeek(date: Date): string { + const d = new Date(date); + const day = d.getDay(); // 0=Sun … 6=Sat + d.setDate(d.getDate() - (day === 0 ? 6 : day - 1)); + d.setHours(0, 0, 0, 0); + // Use ISO date string as sort key; displayed as en-GB short date + return d.toISOString().split("T")[0]; // "2026-03-30" +} + +function formatMonday(isoDate: string): string { + return new Date(isoDate).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +function aggregateByWeek(deals: ClassifiedDeal[], dateField: string) { + const weekCounts: Record = {}; + for (const deal of deals) { + const date = deal[dateField as keyof ClassifiedDeal] as + | string + | Date + | null; + if (!date) continue; + const d = new Date(date); + if (isNaN(d.getTime())) continue; + const key = getMondayOfWeek(d); + weekCounts[key] = (weekCounts[key] || 0) + 1; + } + return Object.entries(weekCounts) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([isoKey, value]) => ({ week: formatMonday(isoKey), value })); +} + +// Fills all missing Monday ISO-date keys between min and max with 0s +function fillWeekGaps(keys: string[]): string[] { + if (keys.length === 0) return []; + const sorted = [...keys].sort(); + const result: string[] = []; + const current = new Date(sorted[0]); + const end = new Date(sorted[sorted.length - 1]); + while (current <= end) { + result.push(current.toISOString().split("T")[0]); + current.setDate(current.getDate() + 7); + } + return result; +} + +// Coordination-specific aggregator: buckets V1 and V2 completions separately +function aggregateCoordinationByWeek( + deals: ClassifiedDeal[], +): Array<{ week: string; "V1 (MTP)": number; "V2 (Re-model)": 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())) { + const key = getMondayOfWeek(d); + 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())) { + const key = getMondayOfWeek(d); + v2Counts[key] = (v2Counts[key] || 0) + 1; + } + } + } + + const allKeys = fillWeekGaps( + 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, + })); +} + +export default function CompletionTrendsChart({ + deals, + isDomnaUser, + projectCode, +}: CompletionTrendsChartProps) { + const [metric, setMetric] = useState(METRICS[0].key); + // Targets: { [week]: number } + 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"; + + const coordData = isCoordination ? aggregateCoordinationByWeek(deals) : null; + const singleData = isCoordination + ? null + : aggregateByWeek(deals, selectedMetric.dateField); + + // Merge targets into non-coordination chart data + const chartData = isCoordination + ? coordData! + : singleData!.map((d) => ({ + week: d.week, + [selectedMetric.label]: d.value, + ...(targets[d.week] !== undefined ? { Target: targets[d.week] } : {}), + })); + + // Add target handler (Domna only) + const handleAddTarget = () => { + if (!targetInput.week || !targetInput.value) return; + setTargets((prev) => ({ + ...prev, + [targetInput.week]: Number(targetInput.value), + })); + setTargetInput({ week: "", value: "" }); + }; + + return ( + +
+
+ + Trends Over Time + +

+ Switch between metrics to see weekly trends. +

+
+
+ +
+
+ + {isDomnaUser && !isCoordination && ( +
+ + Add/Edit Targets (visible to Domna users only): + + + setTargetInput((ti) => ({ ...ti, week: e.target.value })) + } + className="w-32 text-xs" + /> + + setTargetInput((ti) => ({ ...ti, value: e.target.value })) + } + className="w-24 text-xs" + /> + +
+ )} + + {isCoordination ? ( + <> + + + + ) : ( + <> + + + + )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DampMouldRiskPanel.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DampMouldRiskPanel.tsx new file mode 100644 index 00000000..d8e1e83e --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DampMouldRiskPanel.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Droplets, AlertTriangle, ShieldAlert } from "lucide-react"; +import { Card, CardContent } from "@/app/shadcn_components/ui/card"; +import type { DampMouldRiskData, ClassifiedDeal } from "./types"; + +interface DampMouldRiskPanelProps { + risk: DampMouldRiskData; + onOpenTable: ( + stage: string, + deals: ClassifiedDeal[], + columns?: (keyof ClassifiedDeal)[], + columnLabels?: Partial> + ) => void; +} + +function RiskStatCard({ + label, + subtitle, + count, + total, + icon: Icon, + color, + onClick, +}: { + label: string; + subtitle: string; + count: number; + total: number; + icon: React.ElementType; + color: "amber" | "orange" | "red"; + onClick: () => void; +}) { + const pct = total > 0 ? ((count / total) * 100).toFixed(1) : "0.0"; + + const styles = { + amber: { + gradient: "from-amber-50 to-amber-50/30", + border: "border-amber-200", + hover: "hover:border-amber-300 hover:shadow-md", + icon: "text-amber-500", + badge: "bg-amber-100 text-amber-700", + bar: "bg-amber-400", + value: "text-amber-700", + }, + orange: { + gradient: "from-orange-50 to-orange-50/30", + border: "border-orange-200", + hover: "hover:border-orange-300 hover:shadow-md", + icon: "text-orange-500", + badge: "bg-orange-100 text-orange-700", + bar: "bg-orange-400", + value: "text-orange-700", + }, + red: { + gradient: "from-red-50 to-red-50/30", + border: "border-red-300", + hover: "hover:border-red-400 hover:shadow-md", + icon: "text-red-500", + badge: "bg-red-100 text-red-700", + bar: "bg-red-500", + value: "text-red-700", + }, + }; + + const s = styles[color]; + + return ( + +
+
+ +
+ + {pct}% + +
+ +

{count}

+

{label}

+

{subtitle}

+ + {/* Mini progress bar */} +
+
+
+ + ); +} + +export default function DampMouldRiskPanel({ + risk, + onOpenTable, +}: DampMouldRiskPanelProps) { + const { totalDeals } = risk; + + const surveyColumns: (keyof ClassifiedDeal)[] = [ + "dealname", + "landlordPropertyId", + "majorConditionIssueDescription", + "majorConditionIssuePhotosS3", + ]; + + const surveyLabels: Partial> = { + dealname: "Address", + landlordPropertyId: "Property Ref", + majorConditionIssueDescription: "Surveyor Notes", + majorConditionIssuePhotosS3: "Photo Evidence", + }; + + const coordColumns: (keyof ClassifiedDeal)[] = [ + "dealname", + "landlordPropertyId", + "dampMouldFlag", + "coordinator", + ]; + + const coordLabels: Partial> = { + dealname: "Address", + landlordPropertyId: "Property Ref", + dampMouldFlag: "Coordinator Flag", + coordinator: "Coordinator", + }; + + const bothFlaggedDeals = risk.surveyFlagDeals.filter((d) => !!d.dampMouldFlag); + + const noRisk = + risk.surveyFlagCount === 0 && + risk.coordinatorFlagCount === 0; + + return ( + + + {/* Header */} +
+
+ +
+
+

+ Awaab's Law — Damp & Mould Risk +

+

+ Comparison of flags raised at survey vs coordination stage +

+
+
+ + {noRisk ? ( +
+
+ +
+

+ No damp or mould flags recorded for this project. +

+
+ ) : ( + <> +
+ + onOpenTable( + "Damp & Mould — Survey Stage Flags", + risk.surveyFlagDeals, + surveyColumns, + surveyLabels + ) + } + /> + + onOpenTable( + "Damp & Mould — Coordination Stage Flags", + risk.coordinatorFlagDeals, + coordColumns, + coordLabels + ) + } + /> + + onOpenTable( + "Damp & Mould — Flagged at Both Stages", + bothFlaggedDeals, + coordColumns, + coordLabels + ) + } + /> +
+ + {/* Missed risk callout */} + {risk.coordinatorFlagCount > risk.surveyFlagCount && ( +
+ +

+ + {risk.coordinatorFlagCount - risk.surveyFlagCount} additional{" "} + {risk.coordinatorFlagCount - risk.surveyFlagCount === 1 ? "property was" : "properties were"}{" "} + + flagged for damp & mould at the coordination stage that{" "} + {risk.coordinatorFlagCount - risk.surveyFlagCount === 1 ? "was" : "were"} not + identified during the initial survey. +

+
+ )} + + )} +
+
+ ); +} 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 ccdb2d56..1f13111e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -18,7 +18,6 @@ import type { LiveTrackerProps, TableModal, ClassifiedDeal, - HubspotDeal, DocumentDrawerState, } from "./types"; @@ -28,13 +27,15 @@ export default function LiveTracker({ majorConditionDeals, }: LiveTrackerProps) { // ── Tab state ──────────────────────────────────────────────────────── - const [activeTab, setActiveTab] = useState<"analytics" | "properties">("analytics"); + const [activeTab, setActiveTab] = useState<"analytics" | "properties">( + "analytics", + ); // ── Project selector (shared across both tabs) ─────────────────────── const projectCodes = projects.map((p) => p.projectCode); const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]); const currentProject = projects.find( - (p) => p.projectCode === currentProjectCode + (p) => p.projectCode === currentProjectCode, ); // ── Drill-down table modal (used by AnalyticsView) ─────────────────── @@ -50,18 +51,18 @@ export default function LiveTracker({ const handleOpenTable = ( stage: string, filteredDeals: ClassifiedDeal[], - columns?: (keyof HubspotDeal)[], - columnLabels?: Partial>, - breakdown?: Record + columns?: (keyof ClassifiedDeal)[], + columnLabels?: Partial>, + breakdown?: Record, ) => { setOpenTable({ stage, data: filteredDeals, - columns: columns || ["dealname", "landlordPropertyId"], - columnLabels: columnLabels || { + columns: (columns || ["dealname", "landlordPropertyId"]) as (keyof ClassifiedDeal)[], + columnLabels: (columnLabels || { dealname: "Address Ref.", landlordPropertyId: "Property Ref.", - }, + }) as Partial>, breakdown, }); }; @@ -131,11 +132,21 @@ export default function LiveTracker({ onChange={(e) => setCurrentProjectCode(e.target.value)} className="px-3 py-1.5 border border-brandblue/20 rounded-lg bg-white text-sm text-gray-800 font-medium focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all appearance-none pr-8" > - {projectCodes.map((code) => ( - - ))} + {projectCodes.map((code) => + code === "__ALL__" ? ( + + ) : ( + + ), + )}
)} @@ -143,6 +154,7 @@ export default function LiveTracker({ @@ -175,47 +187,49 @@ export default function LiveTracker({ {openTable.breakdown && (
- {Object.entries(openTable.breakdown).map(([category, items]) => { - const isCompleted = category.includes("Completed"); - const bgColor = isCompleted - ? "bg-gradient-to-br from-brandblue/25 to-brandblue/15" - : "bg-gradient-to-br from-amber-100/40 to-amber-50/30"; - const borderColor = isCompleted - ? "border-brandblue/40" - : "border-amber-200/50"; - const textColor = isCompleted ? "text-brandblue" : "text-amber-600"; - const labelColor = isCompleted - ? "text-brandblue" - : "text-amber-600/70"; + {Object.entries(openTable.breakdown).map( + ([category, items]) => { + const isCompleted = category.includes("Completed"); + const bgColor = isCompleted + ? "bg-gradient-to-br from-brandblue/25 to-brandblue/15" + : "bg-gradient-to-br from-amber-100/40 to-amber-50/30"; + const borderColor = isCompleted + ? "border-brandblue/40" + : "border-amber-200/50"; + const textColor = isCompleted + ? "text-brandblue" + : "text-amber-600"; + const labelColor = isCompleted + ? "text-brandblue" + : "text-amber-600/70"; - return ( -
-

- {category} -

-

- {items.length} -

-

- {((items.length / openTable.data.length) * 100) | 0}% of total -

-
- ); - })} + return ( +
+

+ {category} +

+

+ {items.length} +

+

+ {((items.length / openTable.data.length) * 100) | 0} + % of total +

+
+ ); + }, + )}
)} -
- +
+ {}} />
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ProgressOverview.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ProgressOverview.tsx index 36445711..7eeaaa55 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ProgressOverview.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ProgressOverview.tsx @@ -1,102 +1,63 @@ "use client"; import { motion } from "framer-motion"; -import { AlertCircle, CheckCircle2, ArrowRight, Users, PenLine, Hammer, FileCheck } from "lucide-react"; +import { CheckCircle2, ArrowRight } from "lucide-react"; import { Card, CardContent } from "@/app/shadcn_components/ui/card"; import { STAGE_COLORS } from "./types"; -import type { ProjectProgressData, ClassifiedDeal, ClassifiedDeal as CD } from "./types"; +import type { + ProjectProgressData, + ClassifiedDeal, +} from "./types"; -// ----------------------------------------------------------------------- -// Columns shown per card — enough context to understand WHY -// ----------------------------------------------------------------------- -const QUERIES_COLUMNS: (keyof CD)[] = [ - "dealname", "landlordPropertyId", "displayStage", "outcome", "coordinationStatus", "outcomeNotes", +const EARLY_COLUMNS: (keyof ClassifiedDeal)[] = [ + "dealname", + "landlordPropertyId", + "displayStage", + "preSapScore", + "outcome", ]; -const QUERIES_LABELS: Partial> = { - dealname: "Address", - landlordPropertyId: "Ref", - displayStage: "Reason / Stage", - outcome: "Survey Outcome", - coordinationStatus: "Coordination Status", - outcomeNotes: "Notes", -}; - -const COORD_COLUMNS: (keyof CD)[] = [ - "dealname", "landlordPropertyId", "displayStage", "coordinator", "coordinationStatus", "ioeV1Date", -]; -const COORD_LABELS: Partial> = { - dealname: "Address", - landlordPropertyId: "Ref", - displayStage: "Current Stage", - coordinator: "Coordinator", - coordinationStatus: "Coordination Status", - ioeV1Date: "IOE/MTP Date", -}; - -const DESIGN_COLUMNS: (keyof CD)[] = [ - "dealname", "landlordPropertyId", "displayStage", "designer", "designStatus", "approvedPackage", "designDate", -]; -const DESIGN_LABELS: Partial> = { - dealname: "Address", - landlordPropertyId: "Ref", - displayStage: "Current Stage", - designer: "Designer", - designStatus: "Design Status", - approvedPackage: "Package", - designDate: "Design Date", -}; - -const INSTALL_COLUMNS: (keyof CD)[] = [ - "dealname", "landlordPropertyId", "displayStage", "installer", "installerHandover", "actualMeasuresInstalled", -]; -const INSTALL_LABELS: Partial> = { - dealname: "Address", - landlordPropertyId: "Ref", - displayStage: "Current Stage", - installer: "Installer", - installerHandover: "Handover Date", - actualMeasuresInstalled: "Measures Installed", -}; - -const LODGE_COLUMNS: (keyof CD)[] = [ - "dealname", "landlordPropertyId", "displayStage", "lodgementStatus", "postSapScore", "measuresLodgementDate", "fullLodgementDate", -]; -const LODGE_LABELS: Partial> = { - dealname: "Address", - landlordPropertyId: "Ref", - displayStage: "Current Stage", - lodgementStatus: "Lodgement Status", - postSapScore: "Post-SAP Score", - measuresLodgementDate: "Measures Lodged", - fullLodgementDate: "Full Lodgement Date", -}; - -const EARLY_COLUMNS: (keyof CD)[] = [ - "dealname", "landlordPropertyId", "displayStage", "preSapScore", "outcome", "raStatus", -]; -const EARLY_LABELS: Partial> = { +const EARLY_LABELS: Partial> = { dealname: "Address", landlordPropertyId: "Ref", displayStage: "Current Stage", preSapScore: "Pre-SAP Score", outcome: "Survey Outcome", - raStatus: "Assessment Status", }; // ----------------------------------------------------------------------- // Circular progress ring (SVG) // ----------------------------------------------------------------------- -function RingProgress({ pct, color = "#14163d", size = 80 }: { pct: number; color?: string; size?: number }) { +function RingProgress({ + pct, + color = "#14163d", + size = 80, +}: { + pct: number; + color?: string; + size?: number; +}) { const r = 34; const circ = 2 * Math.PI * r; const offset = circ - (Math.min(pct, 100) / 100) * circ; return ( - + @@ -104,91 +65,6 @@ function RingProgress({ pct, color = "#14163d", size = 80 }: { pct: number; colo ); } -// ----------------------------------------------------------------------- -// Clickable phase summary card (coordination, design, install, lodgement) -// ----------------------------------------------------------------------- -function PhaseCard({ - icon: Icon, - title, - completedCount, - inProgressCount, - total, - completedPct, - inProgressPct, - ringColor, - onClick, -}: { - icon: React.ElementType; - title: string; - completedCount: number; - inProgressCount: number; - total: number; - completedPct: number; - inProgressPct: number; - ringColor: string; - onClick: () => void; -}) { - return ( - -
-
-
-
- -
- - {title} - -
- -
-
- Complete - {completedCount} -
-
-
-
- {inProgressCount > 0 && ( - <> -
- In progress - {inProgressCount} -
-
-
-
- - )} -
-
- -
- -
- {completedPct.toFixed(0)}% -
-
-
- -
- - View breakdown -
- - ); -} - // ----------------------------------------------------------------------- // Main component // ----------------------------------------------------------------------- @@ -197,33 +73,36 @@ interface ProgressOverviewProps { onOpenTable?: ( stage: string, deals: ClassifiedDeal[], - columns?: (keyof CD)[], - columnLabels?: Partial>, - breakdown?: Record + columns?: (keyof ClassifiedDeal)[], + columnLabels?: Partial>, + breakdown?: Record, ) => void; } -export default function ProgressOverview({ data, onOpenTable }: ProgressOverviewProps) { +export default function ProgressOverview({ + data, + onOpenTable, +}: ProgressOverviewProps) { const { completedPercentage, completedCount, nonQueryTotal, - queriesDeals, stageProgress, - coordination, - design, - install, - lodgement, } = data; // Early-stage rows (scope / booking / assessment) - const earlyStages = ["Scope & Planning", "Booking in Progress", "Assessment in Progress"]; - const earlyItems = stageProgress.filter((s) => earlyStages.includes(s.stage) && s.count > 0); + const earlyStages = [ + "Scope & Planning", + "Booking in Progress", + "Assessment in Progress", + ]; + const earlyItems = stageProgress.filter( + (s) => earlyStages.includes(s.stage) && s.count > 0, + ); return ( - + - {/* ── Completion header ──────────────────────────────────────────── */}
- +
{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) => ( - ))} 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;
+
{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])}