diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DealStageChart.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DealStageChart.tsx deleted file mode 100644 index 082ff27f..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DealStageChart.tsx +++ /dev/null @@ -1,277 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { BarList, Card, Title } from "@tremor/react"; - -const STAGE_ORDER = [ - "Initial planning", - "Booking Team to contact tenant", - "In Assessment", - "In Coordination", - "In Design", - "Completed", - "Queries", -]; - -const stage = (label: string) => STAGE_ORDER.find((s) => s === label)!; - -// πŸ”§ Helper function to determine stage label after assessment based on coordination and design status -const getAfterAssessmentLabel = ( - coordinationStatus?: string, - designStatus?: string -): string => { - // Normalize strings to uppercase for case-insensitive comparison - const coordStatusUpper = coordinationStatus?.toUpperCase() ?? ""; - const designStatusUpper = designStatus?.toUpperCase() ?? ""; - - // 1. If coordination status is 'ra issue', return to 'queries' - if (coordStatusUpper === "RA ISSUE") { - return "Queries"; - } - - // 2. If coordination status contains v1/v2/v3 ioe/mtp completed, show as 'In Design' - if ( - coordStatusUpper.includes("(V1) IOE/MTP COMPLETE") || - coordStatusUpper.includes("(V2) IOE/MTP COMPLETE") || - coordStatusUpper.includes("(V3) IOE/MTP COMPLETE") - ) { - // 3. If design status is 'Uploaded', show as 'Completed' - if (designStatusUpper === "UPLOADED") { - return "Completed"; - } - // Otherwise show as 'In Design' - return "In Design"; - } - - // Default to 'In Coordination' - return "In Coordination"; -}; - -// 🏷️ Deal stage β†’ display stage mapping -const STAGE_LABELS: Record = { - "1617223910": stage("Initial planning"), // 0 - [Ops] Backlog - "3583836399": stage("Initial planning"), // 0 - [Ops] Route Planning - - "3589581001": stage("Booking team to contact tenant"), // 1 - [Bookings] Ready for Bookings Team - "3569878239": stage("Booking team to contact tenant"), // 1 - [Bookings] Send initial booking SMS - "1617223911": stage("Booking team to contact tenant"), // 1 - [Bookings] Send Email - "1984184569": stage("Booking team to contact tenant"), // 1 - [Bookings] Phone booking - "3569572028": stage("Booking team to contact tenant"), // 1 - [Bookings] Preferences received from Tenant - "3570936026": stage("Booking team to contact tenant"), // 1 - [Bookings] Send Confirmation Comms - "2663668937": stage("Queries"), // 4 - [Bookings/Sales] Booking issues - needs HA support (Check with Aidan) - "1984401629": stage("In Assessment"), // 2 - [Bookings/Ops/Sales] No Contact Details - Ready for Route - "2558220518": stage("Booking team to contact tenant"), // 1 - [Ops] Not attempted - needs reallocation - "3474594026": stage("Booking team to contact tenant"), // 1 - [Ops/Bookings] Rebooked - Needs updating - - "1617223912": stage("In Assessment"), // 2 - [Ops] Ready for Assignment to Route - "1617223913": stage("In Assessment"), // 2 - [Ops] Survey in Progress - "3206388924": stage("In Assessment"), // 2 - [Ops] Surveyed - Pending Upload from Surveyor - "1617223915": stage("In Assessment"), // 2 - [Ops] No Access - Need Sign Off - "1617223917": stage("Queries"), // 3 - [Ops] No Access - No Revisit - "2571417798": stage("Booking team to contact tenant"), // 1 - [Ops] Surveyed under 2019 - Needs Re-survey - - "1617223916": stage("In Assessment"), // 5 - [Ops] Properties to Review Manually - - // πŸ”§ ===== AFTER ASSESSMENT - Determine exact stage using coordination/design status logic ===== - // These are special internal stages that will be processed by getAfterAssessmentLabel - // and mapped to their final display stages ("In Coordination", "In Design", "Completed") - "2628341989": "AFTER_ASSESSMENT", // 5 - [Ops] Assessment needs correction - "3441170637": "AFTER_ASSESSMENT", // 5 - [Ops] Awaiting PV Design - - "1617223914": "AFTER_ASSESSMENT", // 5 - [Ops] Surveyed in Pashub, Transit Job to Co-ordination - "2628233422": "AFTER_ASSESSMENT", // 5 - [Coordination] Ready for coordination - "2702650617": "AFTER_ASSESSMENT", // 5 - [Design] Ready for Design - "2473886962": "AFTER_ASSESSMENT", // 5 - [Design] Design in progress - - "1668803774": "AFTER_ASSESSMENT", // 6 - [Finance] Ready for Invoicing - "3440363736": "AFTER_ASSESSMENT", // 6 - [Finance] Needs Invoicing - Files Sent - - // πŸ”§ Exception stages (handled separately) - "1887735998": stage("Queries"), // 3 - [Ops] Not Viable - "3061261536": stage("Queries"), // 4 - [Sales/Tech] Major condition issue - "1887735999": stage("Queries"), // 4 - [Ops] Needs HA Works - "3016601828": stage("Queries"), // 4 - [Engagement Team] EPC C Before Works - "2769407183": stage("Queries"), // 4 - [Ops] PV - Needs Heating Upgrade (Pre EPR D) -}; - -// 🧩 Reasons for exception stages (HA support / Not viable) -const STAGE_REASONS: Record = { - // ---- Needs support from HA ---- - "2663668937": "Booking issues due to tenant difficulties.", - "3061261536": "Awaab's Law", - "1887735999": "", - "3016601828": "RA is currently EPR C. Convert to EPC?", - "2769407183": "Needs HA heating upgrade. Domna/HA discussion required.", - - // ---- Not viable for funding ---- - "1617223917": "", - "1887735998": "", -}; - -// βœ… Define an explicit Deal type for clarity -interface Deal { - dealname: string; - landlordPropertyId: string; - dealstage: string; - coordinationStatus?: string; - designStatus?: string; - reason?: string; - [key: string]: any; -} - -interface DealStageChartProps { - deals: Deal[]; - onOpenTable?: ( - stageName: string, - filteredDeals: Deal[], - columns?: string[], - columnLabels?: { [key: string]: string } - ) => void; -} - -export function DealStageChart({ deals, onOpenTable }: DealStageChartProps) { - const data = useMemo(() => { - const counts: Record = {}; - - deals.forEach((d) => { - const stageId = d.dealstage || "unknown"; - let stageName = STAGE_LABELS[stageId] || "Unknown Stage"; - - // πŸ”§ For deals marked as "AFTER_ASSESSMENT", determine exact stage using coordination/design status logic - if (stageName === "AFTER_ASSESSMENT") { - const label = getAfterAssessmentLabel(d.coordinationStatus, d.designStatus); - stageName = label || "In Coordination"; // Default to "In Coordination" if no label returned - } - - // πŸ”§ For "Initial Planning" deals, check if coordination status is 'RA ISSUE' - if (stageName === "Initial planning") { - const coordStatusUpper = d.coordinationStatus?.toUpperCase() ?? ""; - if (coordStatusUpper === "RA ISSUE") { - stageName = "Queries"; - } - } - - counts[stageName] = (counts[stageName] || 0) + 1; - }); - - return STAGE_ORDER.map((name) => ({ - name, - value: counts[name] || 0, - })); - }, [deals]); - - const total = deals.length; - - const handleBarClick = (value: { name: string; value: number }) => { - const filteredDeals: Deal[] = deals - .filter((d) => { - let stageName = STAGE_LABELS[d.dealstage] || "Unknown Stage"; - - // πŸ”§ For deals marked as "AFTER_ASSESSMENT", determine exact stage using coordination/design status logic - if (stageName === "AFTER_ASSESSMENT") { - const label = getAfterAssessmentLabel(d.coordinationStatus, d.designStatus); - stageName = label || "In Coordination"; // Default to "In Coordination" if no label returned - } - - // πŸ”§ For "Initial Planning" deals, check if coordination status is 'RA ISSUE' - if (stageName === "Initial planning") { - const coordStatusUpper = d.coordinationStatus?.toUpperCase() ?? ""; - if (coordStatusUpper === "RA ISSUE") { - stageName = "Queries"; - } - } - - return stageName === value.name; - }) - .map((d) => ({ - ...d, - // βœ… Always provide a string to avoid undefined issues - reason: STAGE_REASONS[d.dealstage] ?? "", - })); - - const isException = - value.name === "Needs support from HA" || - value.name === "Not viable for funding"; - - // Add "Reason" column if it's an exception stage - const columns = isException - ? ["dealname", "landlordPropertyId", "reason"] - : ["dealname", "landlordPropertyId"]; - - const columnLabels = isException - ? { - dealname: "Address Ref.", - landlordPropertyId: "Property Ref.", - reason: "Reason", - } - : { - dealname: "Address Ref.", - landlordPropertyId: "Property Ref.", - }; - - // βœ… Explicit cast ensures no type mismatch - onOpenTable?.(value.name, filteredDeals, columns, columnLabels as Record); - }; - - // Split into normal + exception stages - const normalStages = data.filter( - (d) => - !["Queries"].includes(d.name) && - d.name !== "" - ); - - const exceptionStages = data.filter((d) => - ["Queries"].includes(d.name) - ); - -return ( -
- {/* βœ… Main Progress Chart */} - -
- - Project Progress by Stage - -

- Click a bar to view related properties -

-

- Total: {total.toLocaleString()} properties -

-
- -
- -
-
- - {/* 🚨 Exception Chart */} - -
- - Needs HA Support & Not Viable - -

- Click to explore exception properties (reasons appear in table) -

-
- -
- -
-
-
-); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExpandableCountBar.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExpandableCountBar.tsx new file mode 100644 index 00000000..d1bba6b5 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExpandableCountBar.tsx @@ -0,0 +1,120 @@ +"use client" + +interface ExpandableCountBarProps { + title: string + items: T[] + count?: number + percentage?: number + inProgressPercentage?: number + total?: number + secondaryStats?: Array<{ label: string; count: number }> + onClick?: (items: T[]) => void + className?: string +} + +export default function ExpandableCountBar({ + title, + items, + count, + percentage, + inProgressPercentage, + total, + secondaryStats, + onClick, + className = "", +}: ExpandableCountBarProps) { + + const displayCount = count ?? items.length + const displayTotal = total ?? items.length + const displayPercentage = percentage ?? 0 + const displayInProgressPercentage = inProgressPercentage ?? 0 + const radius = 40 + const circumference = 2 * Math.PI * radius + const completedStrokeDashoffset = circumference - (displayPercentage / 100) * circumference + const inProgressStrokeDashoffset = circumference - ((displayPercentage + displayInProgressPercentage) / 100) * circumference + + return ( + + ) +} \ No newline at end of file diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx new file mode 100644 index 00000000..10017d77 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -0,0 +1,359 @@ +"use client"; + +import { useState } from "react"; +import ProgressOverview from "./ProgressOverview"; +import SurveyedResultsPieChart from "./SurveyedResultsPieChart"; +import TableViewer from "./TableViewer"; +import { Card, CardContent } from "@/app/shadcn_components/ui/card"; +import { Home, AlertTriangle } from "lucide-react"; +import { motion } from "framer-motion"; +import type { LiveTrackerProps, TableModal, ClassifiedDeal, HubspotDeal } from "./types"; + +export default function LiveTracker({ + projects, + totalDeals, + majorConditionDeals, +}: LiveTrackerProps) { + // UI State: which table modal is open + const [openTable, setOpenTable] = useState(null); + + // UI State: which project tab is selected + const projectCodes = projects.map((p) => p.projectCode); + const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]); + const currentProject = projects.find( + (p) => p.projectCode === currentProjectCode + ); + + // Compute minor stuff inline (not data processing) + const majorIssues = majorConditionDeals.length; + const majorPercent = ((majorIssues / totalDeals) * 100).toFixed(1); + const hasSurveyData = (currentProject?.outcomePieSlices.length ?? 0) > 0; + + // Group allDeals by outcome for pie chart click handler + const dealsByOutcome: Record = {}; + for (const deal of currentProject?.allDeals ?? []) { + if (deal.outcome) { + (dealsByOutcome[deal.outcome] ??= []).push(deal); + } + } + + const handleOpenTable = ( + stage: string, + filteredDeals: ClassifiedDeal[], + columns?: (keyof HubspotDeal)[], + columnLabels?: Partial>, + breakdown?: Record + ) => { + setOpenTable({ + stage, + data: filteredDeals, + columns: columns || ["dealname", "landlordPropertyId"], + columnLabels: columnLabels || { + dealname: "Address Ref.", + landlordPropertyId: "Property Ref.", + }, + breakdown, + }); + }; + + if (!totalDeals) { + return ( + + +

No deal data available.

+
+
+ ); + } + + return ( +
+ {/* 🌍 Global Overview */} +
+ {/* Project Selector */} + +
+

+ Select Project +

+
+ +
+
+
+ + {/* Total Properties */} + + handleOpenTable( + "All Properties", + projects.flatMap((p) => p.allDeals), + ["dealname", "landlordPropertyId", "projectCode"], + { + dealname: "Address Ref.", + landlordPropertyId: "Property Ref.", + projectCode: "Project Code", + } + ) + } + accent="brandblue" + /> + + {/* Major Issues */} + + handleOpenTable( + "Awaab's Law Reporting", + majorConditionDeals, + [ + "dealname", + "landlordPropertyId", + "majorConditionIssueDescription", + "majorConditionIssuePhotosS3", + ], + { + dealname: "Address Ref.", + landlordPropertyId: "Property Ref.", + majorConditionIssueDescription: "Surveyor's Notes", + majorConditionIssuePhotosS3: "Photo Evidence", + } + ) + } + accent={majorIssues > 0 ? "bright-red" : "red"} + /> +
+ + {/* πŸ“Š Project Insights */} + {currentProject && ( +
+
+

+ Project-Level Insights β€”{" "} + {currentProjectCode} +

+
+ +
+ + + + + {hasSurveyData && ( + + + + )} +
+
+ )} + + {/* πŸ”Ή Table Modal */} + {openTable && ( +
setOpenTable(null)} + > + e.stopPropagation()} + > +
+

+ {openTable.stage} +

+

+ Showing{" "} + + {openTable.data.length} + {" "} + properties +

+ + {/* Breakdown Stats */} + {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"; + + return ( +
+

+ {category} +

+

+ {items.length} +

+

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

+
+ ); + })} +
+ )} +
+ +
+ +
+ +
+ +
+
+
+ )} +
+ ); +} + +/** πŸ”ΈSmall stat card component */ +function StatCard({ + icon: Icon, + title, + value, + subtitle, + onClick, + accent = "brandblue", +}: { + icon: any; + 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} + + )} +

+
+ +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ProgressOverview.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ProgressOverview.tsx new file mode 100644 index 00000000..d745c4c1 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ProgressOverview.tsx @@ -0,0 +1,339 @@ +"use client"; + +import { Card } from "@tremor/react"; +import { AlertCircle } from "lucide-react"; +import { motion } from "framer-motion"; +import ExpandableCountBar from "./ExpandableCountBar"; +import type { ProjectProgressData, ClassifiedDeal, HubspotDeal } from "./types"; + +interface ProgressOverviewProps { + data: ProjectProgressData; + onOpenTable?: ( + stage: string, + deals: ClassifiedDeal[], + columns?: (keyof HubspotDeal)[], + columnLabels?: Partial>, + breakdown?: Record + ) => void; +} + +export default function ProgressOverview({ + data, + onOpenTable, +}: ProgressOverviewProps) { + // Pre-computed values from props + const { + completedPercentage, + completedCount, + totalDeals, + queriesDeals, + coordination, + design, + } = data; + + // SVG circle calculations (pure, no memo needed) + const radius = 40; + const circumference = 2 * Math.PI * radius; + const strokeDashoffset = circumference - (completedPercentage / 100) * circumference; + + const handleCompletedClick = () => { + if (onOpenTable) { + onOpenTable( + "Completed Properties", + data.completedDeals, + ["dealname", "landlordPropertyId"], + { + dealname: "Address Ref.", + landlordPropertyId: "Property Ref.", + } + ); + } + }; + + const handleCoordinationClick = () => { + if (onOpenTable) { + const coordinationBreakdown = { + "Coordination Completed": coordination.completedDeals, + "Coordination in Progress": coordination.inProgressDeals, + }; + const allCoordDeals = [ + ...coordination.completedDeals, + ...coordination.inProgressDeals, + ]; + onOpenTable( + "Coordination Status", + allCoordDeals, + undefined, + undefined, + coordinationBreakdown + ); + } + }; + + const handleDesignClick = () => { + if (onOpenTable) { + const designBreakdown = { + "Design Completed": design.completedDeals, + "Design in Progress": design.inProgressDeals, + }; + const allDesignDeals = [ + ...design.completedDeals, + ...design.inProgressDeals, + ]; + onOpenTable( + "Design Status", + allDesignDeals, + undefined, + undefined, + designBreakdown + ); + } + }; + + const handleQueriesClick = () => { + if (onOpenTable && queriesDeals.length > 0) { + onOpenTable( + "Properties Needing Attention", + queriesDeals, + ["dealname", "landlordPropertyId", "coordinationStatus"], + { + dealname: "Address Ref.", + landlordPropertyId: "Property Ref.", + coordinationStatus: "Issue", + } + ); + } + }; + + return ( +
+ {/* Work Completed - Full Width Overview at Top */} + + +
+ {/* Header with Circular Progress */} +
+
+

+ Work Completed +

+

+ End-to-end project overview +

+
+ + {/* Circular Progress */} +
+ + {/* Background circle */} + + {/* Progress circle */} + + + + + + + + + {/* Center text */} +
+ + {completedPercentage.toFixed(0)}% + + + {completedCount}/{totalDeals} + +
+
+
+ + {/* CTA */} +
+ View Completed Properties + β†’ +
+
+
+
+ + {/* Early Stage Cards - Scope, Booking, Assessment */} + {(() => { + const earlyStages = [ + "Scope & Planning", + "Booking in Progress", + "Assessment in Progress", + ]; + const earlyStageItems = data.stageProgress.filter((s) => + earlyStages.includes(s.stage) + ); + + return earlyStageItems.length > 0 ? ( +
+ {earlyStageItems.map((item) => ( + { + if (onOpenTable) { + onOpenTable( + item.stage, + item.deals, + ["dealname", "landlordPropertyId"], + { + dealname: "Address Ref.", + landlordPropertyId: "Property Ref.", + } + ); + } + }} + whileHover={{ scale: 1.02 }} + className="group relative text-left" + > + +
+
+

+ {item.stage} +

+

+ {item.count} +

+
+ +
+

+ {item.percentage.toFixed(0)}% of total +

+
+ +
+ View + β†’ +
+
+
+
+ ))} +
+ ) : null; + })()} + + {/* Project Summary Cards - Coordination & Design */} +
+ + + +
+ + {/* Queries / Attention Required Section */} + {queriesDeals.length > 0 && ( + + +
+ {/* Header with Alert */} +
+
+ +
+
+

+ Requires Your Input +

+

+ These properties need your feedback or assistance to progress +

+
+
+ + {/* Count Display */} +
+

+ {queriesDeals.length} +

+

+ {queriesDeals.length === 1 ? "property" : "properties"}{" "} + awaiting action +

+
+ + {/* CTA */} +
+ Review Details + β†’ +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/Report.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/Report.tsx deleted file mode 100644 index 31a7a6bb..00000000 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/Report.tsx +++ /dev/null @@ -1,284 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { DealStageChart } from "./DealStageChart"; -import SurveyedPieChart from "./SurveyedResultsPieChart"; -import TableViewer from "./TableViewer"; -import { - Card, - CardHeader, - CardTitle, - CardContent, -} from "@/app/shadcn_components/ui/card"; -import { Home, AlertTriangle } from "lucide-react"; -import { motion } from "framer-motion"; - -interface ReportsProps { - deals: Record[]; -} - -const MAJOR_CONDITION_STAGE_ID = "3061261536"; - -export default function LiveTracker({ deals }: ReportsProps) { - - const groupedDeals = deals.reduce( - (acc, deal) => { - const project = deal.projectCode || "Unknown Project"; - (acc[project] ||= []).push(deal); - return acc; - }, - {} as Record - ); - - const [openTable, setOpenTable] = useState<{ - stage: string; - data: any[]; - columns: string[]; - columnLabels: Record; - } | null>(null); - - const projectCodes = Object.keys(groupedDeals); - const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]); - const currentDeals = groupedDeals[currentProjectCode]; - - // Check if there's any survey data - const surveyorOutcomes = [ - "Surveyed", - "Surveyed - Pending Upload", - "Tenant Refusal", - "Other", - "Not Viable", - "Not Attempted", - "No Answer", - "Cancelled / No Show", - "Rescheduled", - ]; - const hasSurveyData = currentDeals.some((deal: any) => - deal.outcome && surveyorOutcomes.includes(deal.outcome) - ); - const totalProperties = deals.length; - const majorConditionDeals = deals.filter( - (d) => d.dealstage === MAJOR_CONDITION_STAGE_ID - ); - const majorIssues = majorConditionDeals.length; - const majorPercent = ((majorIssues / totalProperties) * 100).toFixed(1); - - const handleOpenTable = ( - stage: string, - filteredDeals: any[], - columns?: string[], - columnLabels?: Record - ) => { - setOpenTable({ - stage, - data: filteredDeals, - columns: - columns || ["dealname", "landlordPropertyId"], - columnLabels: - columnLabels || { - dealname: "Address Ref.", - landlordPropertyId: "Property Ref.", - }, - }); - }; - - if (!deals?.length) { - return ( - - -

No deal data available.

-
-
- ); - } - - return ( -
- {/* 🌍 Global Overview */} -
- {/* Total Properties */} - - handleOpenTable( - "All Properties", - deals, - ["dealname", "landlordPropertyId", "projectCode"], - { - dealname: "Address Ref.", - landlordPropertyId: "Property Ref.", - projectCode: "Project Code", - } - ) - } - accent="brandblue" - /> - - {/* Major Issues */} - - handleOpenTable( - "Awaab's Law Reporting", - majorConditionDeals, - [ - "dealname", - "landlordPropertyId", - "majorConditionIssueDescription", - "majorConditionIssuePhotosS3" - ], - { - dealname: "Address Ref.", - landlordPropertyId: "Property Ref.", - majorConditionIssueDescription: "Surveyor's Notes", - majorConditionIssuePhotosS3: "Photo Evidence" - } - ) - } - accent="red" - /> - - {/* Project Selector */} - - - -

- Select Project -

-
-
- -
- -
- β–Ό -
-
-
-
-
- - {/* πŸ“Š Project Insights */} - - - - Project-Level Insights β€” {currentProjectCode} - - - - - - - - - {hasSurveyData && ( - - - - )} - - - - {/* πŸ”Ή Table Modal */} - {openTable && ( -
-
-

- {openTable.stage} β€” {openTable.data.length} Properties -

- -
- -
- -
- -
-
-
- )} -
- ); -} - -/** πŸ”ΈSmall stat card component */ -function StatCard({ - icon: Icon, - title, - value, - subtitle, - onClick, - accent = "brandblue", -}: { - icon: any; - title: string; - value: string | number; - subtitle?: string; - onClick: () => void; - accent?: "brandblue" | "red"; -}) { - const accentColor = - accent === "red" - ? "from-red-50 to-white text-red-600 hover:border-red-300" - : "from-brandlightblue/20 to-white text-brandblue hover:border-brandblue/40"; - - return ( - -
-
-

{title}

-

- {value} - {subtitle && ( - - {subtitle} - - )} -

-
- -
-
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyedResultsPieChart.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyedResultsPieChart.tsx index 8162454d..f1e46f38 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyedResultsPieChart.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyedResultsPieChart.tsx @@ -1,29 +1,20 @@ "use client"; import { DonutChart, Card, Title } from "@tremor/react"; -import { useMemo, useState } from "react"; +import { useState } from "react"; +import type { OutcomeSlice, ClassifiedDeal } from "./types"; interface SurveyedPieChartProps { - deals: Record[]; - onOpenTable?: (outcome: string, filteredDeals: Record[]) => void; + slices: OutcomeSlice[]; + dealsByOutcome: Record; + onOpenTable?: (outcome: string, filteredDeals: ClassifiedDeal[]) => void; } -export default function SurveyedPieChart({ - deals, +export default function SurveyedResultsPieChart({ + slices, + dealsByOutcome, onOpenTable, }: SurveyedPieChartProps) { - const surveyorOutcomes = [ - "Surveyed", - "Surveyed - Pending Upload", - "Tenant Refusal", - "Other", - "Not Viable", - "Not Attempted", - "No Answer", - "Cancelled / No Show", - "Rescheduled", - ]; - const colors = [ "indigo-600", "indigo-400", @@ -36,43 +27,34 @@ export default function SurveyedPieChart({ "gray-200", ]; - const data = useMemo(() => { - const outcomeCounts: Record = {}; - deals.forEach((deal) => { - const outcome = deal.outcome; - if (outcome && surveyorOutcomes.includes(outcome)) { - outcomeCounts[outcome] = (outcomeCounts[outcome] || 0) + 1; - } - }); - const total = Object.values(outcomeCounts).reduce((a, b) => a + b, 0); - return Object.entries(outcomeCounts).map(([name, amount]) => ({ - name, - amount, - percentage: total ? ((amount / total) * 100).toFixed(1) : "0.0", - })); - }, [deals]); - - const handleClick = (value: { name: string; amount: number }) => { - if (!value) return; - const filteredDeals = deals.filter((d) => d.outcome === value.name); - onOpenTable?.(value.name, filteredDeals); - }; - const [hovered, setHovered] = useState(null); + const handleClick = (slice: OutcomeSlice) => { + if (!slice) return; + const filteredDeals = dealsByOutcome[slice.name] ?? []; + onOpenTable?.(slice.name, filteredDeals); + }; + // Don't show the chart if there's no data - if (data.length === 0) { + if (slices.length === 0) { return null; } + // Convert OutcomeSlice to chart data format + const chartData = slices.map((slice) => ({ + name: slice.name, + amount: slice.amount, + percentage: slice.percentage, + })); + return ( - + {/* Header */} -
- + <div className="text-center mb-8 pb-6 border-b border-brandblue/10 w-full"> + <Title className="text-brandblue text-[16px] font-bold tracking-tight mb-2"> Survey Performance -

+

Click a segment or label to view filtered properties

@@ -80,7 +62,7 @@ export default function SurveyedPieChart({ {/* Donut Chart (Centered) */}
`${n.toLocaleString()}`} @@ -94,64 +76,68 @@ export default function SurveyedPieChart({ const { name, amount } = item; return (
- + {name} - {amount.toLocaleString()} + + {amount.toLocaleString()} +
); }} /> - {data.length > 0 && ( + {slices.length > 0 && (
- - {data.reduce((a, b) => a + b.amount, 0)} + + {slices.reduce((a, b) => a + b.amount, 0)}
)}
{/* Legend (Clean Grid Layout) */} -
- {data.map((item, idx) => ( -
handleClick(item)} - onMouseEnter={() => setHovered(item.name)} +
+ {slices.map((slice, idx) => ( +
+ ))}
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 f16b084b..680c0bea 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/TableViewer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/TableViewer.tsx @@ -1,36 +1,95 @@ "use client"; -import { useState, useMemo } from "react"; +import { useState } from "react"; import { Download } from "lucide-react"; +import type { ClassifiedDeal, HubspotDeal } from "./types"; interface TableViewerProps { - data: Record[]; - columns?: string[]; - columnLabels?: Record; + data: ClassifiedDeal[]; + columns?: (keyof HubspotDeal)[]; + columnLabels?: Partial>; + breakdown?: Record; } export default function TableViewer({ data, columns, columnLabels, + breakdown, }: TableViewerProps) { const [searchTerms, setSearchTerms] = useState>({}); const visibleColumns = columns?.length ? columns - : Object.keys(data?.[0] || {}); + : (Object.keys(data?.[0] || {}) as (keyof HubspotDeal)[]); - const filteredData = useMemo(() => { - return data.filter((row) => - visibleColumns.every((col) => { - const term = searchTerms[col]?.toLowerCase() || ""; - if (!term) return true; - const value = String(row[col] ?? "").toLowerCase(); - return value.includes(term); - }) - ); - }, [data, searchTerms, visibleColumns]); + // Helper: Get category for a row based on breakdown + const getCategoryForRow = ( + row: ClassifiedDeal, + brk: Record | undefined + ): string | undefined => { + if (!brk) return undefined; + for (const [category, items] of Object.entries(brk)) { + if (items.includes(row)) return category; + } + return undefined; + }; - const renderCellContent = (col: string, value: any) => { + const getRowStatus = (row: ClassifiedDeal) => { + if (!breakdown) return "untouched"; + + const category = getCategoryForRow(row, breakdown); + if (category?.includes("Completed")) { + return "completed"; + } else if (category?.includes("Progress")) { + return "progress"; + } + return "untouched"; + }; + + const getRowBackgroundColor = (status: string) => { + switch (status) { + case "completed": + return "bg-white"; + case "progress": + return "bg-white"; + case "untouched": + return "bg-white"; + default: + return "bg-white"; + } + }; + + const getSortPriority = (status: string) => { + switch (status) { + case "completed": + return 0; + case "progress": + return 1; + case "untouched": + return 2; + default: + return 3; + } + }; + + // Inline filter derivation (no useMemo) + const filteredData = data.filter((row) => + visibleColumns.every((col) => { + const term = searchTerms[col]?.toLowerCase() || ""; + if (!term) return true; + const value = String(row[col as keyof ClassifiedDeal] ?? "").toLowerCase(); + return value.includes(term); + }) + ); + + // Inline sort derivation (no useMemo) + const sortedFilteredData = [...filteredData].sort((a, b) => { + const statusA = getRowStatus(a); + const statusB = getRowStatus(b); + return getSortPriority(statusA) - getSortPriority(statusB); + }); + + const renderCellContent = (col: keyof HubspotDeal, value: any) => { if (col === "majorConditionIssuePhotosS3" && value) { let urls: string[] = []; @@ -78,10 +137,10 @@ export default function TableViewer({ ))}
@@ -92,21 +151,20 @@ export default function TableViewer({ }; return ( -
+
- + {visibleColumns.map((col) => ( - - {filteredData.length === 0 ? ( + {sortedFilteredData.length === 0 ? ( ) : ( - filteredData.map((row, i) => ( - - {visibleColumns.map((col) => ( - - ))} - - )) + sortedFilteredData.map((row, i) => { + const status = getRowStatus(row); + return ( + + {visibleColumns.map((col) => ( + + ))} + + ); + }) )}
-
- {columnLabels?.[col] || col} +
+
+ + {columnLabels?.[col] || (col as string)} + setSearchTerms((prev) => ({ ...prev, @@ -120,28 +178,34 @@ export default function TableViewer({
No results found
- {renderCellContent(col, row[col])} -
+ {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 35542966..53b04a45 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -5,7 +5,9 @@ import { surveyDB } from "../../../../../db/surveyDB/connection"; import { hubspotDealData } from "../../../../../db/schema/crm/hubspot_deal_table"; import { hubspotCompanyData } from "@/app/db/schema/crm/hubspot_company_table"; import { eq } from "drizzle-orm"; -import LiveTracker from "./Report"; +import LiveTracker from "./LiveTracker"; +import { computeLiveTrackerData } from "./transforms"; +import type { HubspotDeal } from "./types"; export default async function LiveReportingPage(props: { params: Promise<{ slug: string }>; @@ -51,6 +53,9 @@ export default async function LiveReportingPage(props: { ); } + // πŸ”„ Transform raw deals to typed and computed data + const trackerData = computeLiveTrackerData(deals as HubspotDeal[]); + return (
@@ -63,7 +68,7 @@ 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 new file mode 100644 index 00000000..5d7be344 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts @@ -0,0 +1,263 @@ +/** + * Live Tracking Feature - Pure Data Transformation Functions + * No React, no hooks, no side effects. All business logic lives here. + */ + +import type { + HubspotDeal, + ClassifiedDeal, + DisplayStage, + ProjectProgressData, + ProjectData, + OutcomeSlice, + LiveTrackerProps, + WorkPhaseStats, +} from "./types"; + +import { + STAGE_ORDER, + SURVEYOR_OUTCOMES, + MAJOR_CONDITION_STAGE_ID, +} from "./types"; + +// ----------------------------------------------------------------------- +// Stage ID -> raw label mapping +// ----------------------------------------------------------------------- +const STAGE_ID_MAP: Record = { + "1617223910": "Scope & Planning", + "3583836399": "Scope & Planning", + "3589581001": "Booking in Progress", + "1984401629": "Assessment in Progress", + "2628233422": "AFTER_ASSESSMENT", + "2702650617": "AFTER_ASSESSMENT", + "2473886962": "AFTER_ASSESSMENT", + "1668803774": "AFTER_ASSESSMENT", + "1887735998": "Queries", +}; + +// ----------------------------------------------------------------------- +// After-assessment sub-classification +// Resolves AFTER_ASSESSMENT deals based on coordinationStatus and designStatus +// ----------------------------------------------------------------------- +function resolveAfterAssessmentStage( + coordinationStatus: string | null, + designStatus: string | null +): DisplayStage { + const coord = coordinationStatus?.toUpperCase() ?? ""; + const design = designStatus?.toUpperCase() ?? ""; + + // RA ISSUE always -> Queries + if (coord === "RA ISSUE") return "Queries"; + + // V1/V2/V3 IOE/MTP COMPLETE pattern + if ( + coord.includes("(V1) IOE/MTP COMPLETE") || + coord.includes("(V2) IOE/MTP COMPLETE") || + coord.includes("(V3) IOE/MTP COMPLETE") + ) { + return design === "UPLOADED" ? "Completed" : "Design in Progress"; + } + + // Default for AFTER_ASSESSMENT + return "Coordination in Progress"; +} + +// ----------------------------------------------------------------------- +// Resolve display stage for a single deal +// Maps dealstage ID + coordinationStatus + designStatus -> DisplayStage +// ----------------------------------------------------------------------- +export function resolveDisplayStage(deal: HubspotDeal): DisplayStage { + const raw = STAGE_ID_MAP[deal.dealstage ?? ""] ?? "Unknown Stage"; + + if (raw === "AFTER_ASSESSMENT") { + return resolveAfterAssessmentStage( + deal.coordinationStatus, + deal.designStatus + ); + } + + // RA ISSUE override can apply to other stages too + if (raw === "Scope & Planning" || raw === "Assessment in Progress") { + if (deal.coordinationStatus?.toUpperCase() === "RA ISSUE") { + return "Queries"; + } + } + + return raw as DisplayStage; +} + +// ----------------------------------------------------------------------- +// Classify all deals in a list +// Adds displayStage to each deal +// ----------------------------------------------------------------------- +export function classifyDeals(deals: HubspotDeal[]): ClassifiedDeal[] { + return deals.map((deal) => ({ + ...deal, + displayStage: resolveDisplayStage(deal), + })); +} + +// ----------------------------------------------------------------------- +// Compute all ProjectProgressData for a set of already-classified deals +// ----------------------------------------------------------------------- +export function computeProjectProgress( + deals: ClassifiedDeal[] +): ProjectProgressData { + const queriesDeals = deals.filter((d) => d.displayStage === "Queries"); + const nonQueryDeals = deals.filter((d) => d.displayStage !== "Queries"); + const nonQueryTotal = nonQueryDeals.length; + + // Stage counts/percentages (queries excluded from percentage calculation) + const stageBuckets: Record = {}; + for (const deal of nonQueryDeals) { + (stageBuckets[deal.displayStage] ??= []).push(deal); + } + + const stageProgress = STAGE_ORDER.filter((s) => s !== "Queries").map( + (stage) => { + const stageDeals = stageBuckets[stage] ?? []; + return { + stage, + count: stageDeals.length, + percentage: + nonQueryTotal > 0 ? (stageDeals.length / nonQueryTotal) * 100 : 0, + deals: stageDeals, + }; + } + ); + + const completedDeals = stageBuckets["Completed"] ?? []; + const completedCount = completedDeals.length; + const completedPercentage = + nonQueryTotal > 0 ? (completedCount / nonQueryTotal) * 100 : 0; + + const totalDeals = deals.length; + + // Coordination phase: + // completed = Design in Progress + Completed (i.e. coordination is done) + // in progress = Coordination in Progress + const coordCompletedDeals = deals.filter( + (d) => + d.displayStage === "Design in Progress" || + d.displayStage === "Completed" + ); + const coordInProgressDeals = deals.filter( + (d) => d.displayStage === "Coordination in Progress" + ); + + const coordination: WorkPhaseStats = { + completedDeals: coordCompletedDeals, + inProgressDeals: coordInProgressDeals, + completedCount: coordCompletedDeals.length, + inProgressCount: coordInProgressDeals.length, + completedPercentage: + totalDeals > 0 + ? (coordCompletedDeals.length / totalDeals) * 100 + : 0, + inProgressPercentage: + totalDeals > 0 + ? (coordInProgressDeals.length / totalDeals) * 100 + : 0, + total: totalDeals, + }; + + // Design phase: + // completed = Completed stage + // in progress = Design in Progress + const designInProgressDeals = deals.filter( + (d) => d.displayStage === "Design in Progress" + ); + + const design: WorkPhaseStats = { + completedDeals, + inProgressDeals: designInProgressDeals, + completedCount, + inProgressCount: designInProgressDeals.length, + completedPercentage: + totalDeals > 0 ? (completedCount / totalDeals) * 100 : 0, + inProgressPercentage: + totalDeals > 0 + ? (designInProgressDeals.length / totalDeals) * 100 + : 0, + total: totalDeals, + }; + + return { + stageProgress, + queriesDeals, + completedDeals, + completedCount, + completedPercentage, + nonQueryTotal, + totalDeals, + coordination, + design, + }; +} + +// ----------------------------------------------------------------------- +// Compute outcome pie slices for the surveyed pie chart +// ----------------------------------------------------------------------- +export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] { + const counts: Partial> = {}; + + for (const deal of deals) { + if ( + deal.outcome && + (SURVEYOR_OUTCOMES as readonly string[]).includes(deal.outcome) + ) { + counts[deal.outcome] = (counts[deal.outcome] ?? 0) + 1; + } + } + + const total = Object.values(counts).reduce( + (a, b) => a + (b ?? 0), + 0 + ); + + return Object.entries(counts).map(([name, amount]) => ({ + name, + amount: amount ?? 0, + percentage: + total > 0 ? (((amount ?? 0) / total) * 100).toFixed(1) : "0.0", + })); +} + +// ----------------------------------------------------------------------- +// Top-level function called by page.tsx +// Orchestrates all transformations: classify, group by project, compute stats +// ----------------------------------------------------------------------- +export function computeLiveTrackerData( + rawDeals: HubspotDeal[] +): LiveTrackerProps { + // Classify all deals (add displayStage field) + const classified = classifyDeals(rawDeals); + + // Filter for major condition deals (Awaab's Law) + const majorConditionDeals = classified.filter( + (d) => d.dealstage === MAJOR_CONDITION_STAGE_ID + ); + + // Group deals by projectCode + const grouped: Record = {}; + for (const deal of classified) { + const key = deal.projectCode ?? "Unknown Project"; + (grouped[key] ??= []).push(deal); + } + + // For each project group, compute progress data and outcome slices + const projects: ProjectData[] = Object.entries(grouped).map( + ([projectCode, deals]) => ({ + projectCode, + progress: computeProjectProgress(deals), + outcomePieSlices: computeOutcomeSlices(deals), + allDeals: deals, + }) + ); + + return { + projects, + totalDeals: classified.length, + majorConditionDeals, + }; +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts new file mode 100644 index 00000000..229a2ede --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -0,0 +1,153 @@ +/** + * Live Tracking Feature - Type Definitions + * Single source of truth for all TypeScript interfaces and constants + */ + +// ----------------------------------------------------------------------- +// Raw DB row from hubspotDealData table +// ----------------------------------------------------------------------- +export type HubspotDeal = { + id: string; + dealId: string; + dealname: string | null; + dealstage: string | null; + companyId: string | null; + projectCode: string | null; + landlordPropertyId: string | null; + uprn: string | null; + outcome: string | null; + outcomeNotes: string | null; + majorConditionIssueDescription: string | null; + majorConditionIssuePhotos: string | null; + majorConditionIssuePhotosS3: string | null; + coordinationStatus: string | null; + designStatus: string | null; + createdAt: Date; + updatedAt: Date; +}; + +// ----------------------------------------------------------------------- +// Stage classification result - human-readable display labels +// ----------------------------------------------------------------------- +export type DisplayStage = + | "Scope & Planning" + | "Booking in Progress" + | "Assessment in Progress" + | "Coordination in Progress" + | "Design in Progress" + | "Completed" + | "Queries" + | "Unknown Stage"; + +// ----------------------------------------------------------------------- +// A classified deal - original row plus its resolved display stage +// ----------------------------------------------------------------------- +export type ClassifiedDeal = HubspotDeal & { + displayStage: DisplayStage; +}; + +// ----------------------------------------------------------------------- +// One entry in the stage progress bar list +// ----------------------------------------------------------------------- +export type StageProgressItem = { + stage: DisplayStage; + count: number; + percentage: number; // out of non-query total + deals: ClassifiedDeal[]; +}; + +// ----------------------------------------------------------------------- +// Coordination/Design summary card data +// ----------------------------------------------------------------------- +export type WorkPhaseStats = { + completedDeals: ClassifiedDeal[]; + inProgressDeals: ClassifiedDeal[]; + completedCount: number; + inProgressCount: number; + completedPercentage: number; // out of ALL deals in project + inProgressPercentage: number; + total: number; +}; + +// ----------------------------------------------------------------------- +// All computed data for the ProgressOverview component +// ----------------------------------------------------------------------- +export type ProjectProgressData = { + stageProgress: StageProgressItem[]; + queriesDeals: ClassifiedDeal[]; + completedDeals: ClassifiedDeal[]; + completedCount: number; + completedPercentage: number; // out of non-query total + nonQueryTotal: number; + totalDeals: number; + coordination: WorkPhaseStats; + design: WorkPhaseStats; +}; + +// ----------------------------------------------------------------------- +// Surveyed outcome entry (for pie chart) +// ----------------------------------------------------------------------- +export type OutcomeSlice = { + name: string; // outcome label + amount: number; + percentage: string; // pre-formatted "12.3" +}; + +// ----------------------------------------------------------------------- +// What LiveTracker receives from page.tsx for one project +// ----------------------------------------------------------------------- +export type ProjectData = { + projectCode: string; + progress: ProjectProgressData; + outcomePieSlices: OutcomeSlice[]; // empty array = hide pie chart + allDeals: ClassifiedDeal[]; // for table drill-downs within project +}; + +// ----------------------------------------------------------------------- +// Top-level props for LiveTracker (client root) +// ----------------------------------------------------------------------- +export type LiveTrackerProps = { + projects: ProjectData[]; + totalDeals: number; + majorConditionDeals: ClassifiedDeal[]; // for Awaab's Law card +}; + +// ----------------------------------------------------------------------- +// Table drill-down shape (stays in LiveTracker state) +// ----------------------------------------------------------------------- +export type TableModal = { + stage: string; + data: ClassifiedDeal[]; + columns: (keyof HubspotDeal)[]; + columnLabels: Partial>; + breakdown?: Record; +}; + +// ----------------------------------------------------------------------- +// Surveyor outcome constants (single source of truth) +// ----------------------------------------------------------------------- +export const SURVEYOR_OUTCOMES = [ + "Surveyed", + "Surveyed - Pending Upload", + "Tenant Refusal", + "Other", + "Not Viable", + "Not Attempted", + "No Answer", + "Cancelled / No Show", + "Rescheduled", +] as const; + +export type SurveyorOutcome = (typeof SURVEYOR_OUTCOMES)[number]; + +export const MAJOR_CONDITION_STAGE_ID = "3061261536" as const; + +// Order of stages for grouping/display (queries excluded from this list) +export const STAGE_ORDER: DisplayStage[] = [ + "Scope & Planning", + "Booking in Progress", + "Assessment in Progress", + "Coordination in Progress", + "Design in Progress", + "Completed", +];