From bed89dbdec349a6d1a4975e08104d0acc164344a Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 26 Feb 2026 11:20:36 +0000 Subject: [PATCH] commit code that show complete guiness tracking --- .../your-projects/live/DealStageChart.tsx | 33 -- .../your-projects/live/ExpandableCountBar.tsx | 6 +- .../live/{Report.tsx => LiveTracker.tsx} | 259 +++++++------- .../your-projects/live/ProgressOverview.tsx | 315 +++++++----------- .../live/SurveyedResultsPieChart.tsx | 92 +++-- .../your-projects/live/TableViewer.tsx | 132 ++++++-- .../(portfolio)/your-projects/live/page.tsx | 9 +- .../your-projects/live/transforms.ts | 263 +++++++++++++++ .../(portfolio)/your-projects/live/types.ts | 153 +++++++++ 9 files changed, 830 insertions(+), 432 deletions(-) delete mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/DealStageChart.tsx rename src/app/portfolio/[slug]/(portfolio)/your-projects/live/{Report.tsx => LiveTracker.tsx} (53%) create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts 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 3bf3e0f..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DealStageChart.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import ProgressOverview from "./ProgressOverview"; - -interface Deal { - dealname: string; - landlordPropertyId: string; - dealstage: string; - coordinationStatus?: string; - designStatus?: string; - [key: string]: any; -} - -interface DealStageChartProps { - deals: Deal[]; - onOpenTable?: ( - stageName: string, - filteredDeals: Deal[], - columns?: string[], - columnLabels?: Record, - breakdown?: Record - ) => void; -} - -export function DealStageChart({ - deals, - onOpenTable, -}: DealStageChartProps) { - - return ( - - ); -} \ No newline at end of file diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExpandableCountBar.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExpandableCountBar.tsx index 76c2a9a..d1bba6b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExpandableCountBar.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExpandableCountBar.tsx @@ -40,7 +40,7 @@ export default function ExpandableCountBar({ >
-

+

{title}

Click to view breakdown

@@ -105,10 +105,10 @@ export default function ExpandableCountBar({ {/* Secondary Stats */} {secondaryStats && secondaryStats.length > 0 && (
- {secondaryStats.map((stat) => ( + {secondaryStats.map((stat, index) => (

{stat.label}

-

{stat.count}

+

{stat.count}

))}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/Report.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx similarity index 53% rename from src/app/portfolio/[slug]/(portfolio)/your-projects/live/Report.tsx rename to src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index e6c4448..4e1770d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/Report.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -1,86 +1,62 @@ "use client"; import { useState } from "react"; -import { DealStageChart } from "./DealStageChart"; -import SurveyedPieChart from "./SurveyedResultsPieChart"; +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"; -interface ReportsProps { - deals: Record[]; -} +export default function LiveTracker({ + projects, + totalDeals, + majorConditionDeals, +}: LiveTrackerProps) { + // UI State: which table modal is open + const [openTable, setOpenTable] = useState(null); -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; - breakdown?: Record; - } | null>(null); - - const projectCodes = Object.keys(groupedDeals); + // UI State: which project tab is selected + const projectCodes = projects.map((p) => p.projectCode); const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]); - const currentDeals = groupedDeals[currentProjectCode]; + const currentProject = projects.find( + (p) => p.projectCode === 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 - ); + // Compute minor stuff inline (not data processing) const majorIssues = majorConditionDeals.length; - const majorPercent = ((majorIssues / totalProperties) * 100).toFixed(1); + 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: any[], - columns?: string[], - columnLabels?: Record, - breakdown?: Record + 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.", - }, + columns: columns || ["dealname", "landlordPropertyId"], + columnLabels: columnLabels || { + dealname: "Address Ref.", + landlordPropertyId: "Property Ref.", + }, breakdown, }); }; - if (!deals?.length) { + if (!totalDeals) { return ( @@ -113,9 +89,6 @@ export default function LiveTracker({ deals }: ReportsProps) { ))} -
- ▼ -
@@ -124,11 +97,11 @@ export default function LiveTracker({ deals }: ReportsProps) { handleOpenTable( "All Properties", - deals, + projects.flatMap((p) => p.allDeals), ["dealname", "landlordPropertyId", "projectCode"], { dealname: "Address Ref.", @@ -154,52 +127,62 @@ export default function LiveTracker({ deals }: ReportsProps) { "dealname", "landlordPropertyId", "majorConditionIssueDescription", - "majorConditionIssuePhotosS3" + "majorConditionIssuePhotosS3", ], { dealname: "Address Ref.", landlordPropertyId: "Property Ref.", majorConditionIssueDescription: "Surveyor's Notes", - majorConditionIssuePhotosS3: "Photo Evidence" + majorConditionIssuePhotosS3: "Photo Evidence", } ) } - accent={majorIssues > 0 ? "red" : "brandblue"} + accent={majorIssues > 0 ? "bright-red" : "red"} /> {/* 📊 Project Insights */} -
-
-

- Project-Level Insights — {currentProjectCode} -

-
+ {currentProject && ( +
+
+

+ Project-Level Insights —{" "} + {currentProjectCode} +

+
-
- - - - - {hasSurveyData && ( - - )} + + {hasSurveyData && ( + + + + )} +
-
+ )} {/* 🔹 Table Modal */} {openTable && ( @@ -215,25 +198,54 @@ export default function LiveTracker({ deals }: ReportsProps) { {openTable.stage}

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

{/* Breakdown Stats */} {openTable.breakdown && (
- {Object.entries(openTable.breakdown).map(([category, items]) => ( -
-

- {category} -

-

- {items.length} -

-

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

-
- ))} + {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 +

+
+ ); + })}
)} @@ -243,6 +255,7 @@ export default function LiveTracker({ deals }: ReportsProps) { data={openTable.data} columns={openTable.columns} columnLabels={openTable.columnLabels} + breakdown={openTable.breakdown} /> @@ -275,7 +288,7 @@ function StatCard({ value: string | number; subtitle?: string; onClick: () => void; - accent?: "brandblue" | "red"; + accent?: "brandblue" | "red" | "bright-red"; }) { const accentConfig = { brandblue: { @@ -284,16 +297,24 @@ function StatCard({ text: "text-brandblue", value: "text-brandblue", hover: "hover:border-brandblue/40 hover:shadow-lg", - icon: "text-brandblue" + icon: "text-brandblue", }, red: { - gradient: "from-red-50/50 to-red-50/20", - border: "border-red-200/50", - text: "text-red-600", - value: "text-red-700", - hover: "hover:border-red-300 hover:shadow-lg", - icon: "text-red-600" - } + 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]; @@ -306,8 +327,14 @@ function StatCard({ >
-

{title}

-

+

+ {title} +

+

{value} {subtitle && ( @@ -316,7 +343,9 @@ function StatCard({ )}

- +
); 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 3abac38..12a0bfd 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ProgressOverview.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ProgressOverview.tsx @@ -1,133 +1,100 @@ "use client"; -import { Card, Title } from "@tremor/react"; +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 { - deals: Record[]; + data: ProjectProgressData; onOpenTable?: ( stage: string, - deals: any[], - columns?: string[], - columnLabels?: Record, - breakdown?: Record + deals: ClassifiedDeal[], + columns?: (keyof HubspotDeal)[], + columnLabels?: Partial>, + breakdown?: Record ) => void; } -export default function ProgressOverview({ deals, onOpenTable }: ProgressOverviewProps) { - const STAGE_ORDER = [ - "Scope & Planning", - "Booking in Progress", - "Assessment in Progress", - "Coordination in Progress", - "Design in Progress", - "Completed", - ]; +export default function ProgressOverview({ + data, + onOpenTable, +}: ProgressOverviewProps) { + // Pre-computed values from props + const { + completedPercentage, + completedCount, + totalDeals, + queriesDeals, + coordination, + design, + } = data; - const STAGE_LABELS: 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", + // 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 && data.completedDeals.length > 0) { + onOpenTable( + "Completed Properties", + data.completedDeals, + ["dealname", "landlordPropertyId"], + { + dealname: "Address Ref.", + landlordPropertyId: "Property Ref.", + } + ); + } }; - const getAfterAssessmentLabel = ( - coordinationStatus?: string, - designStatus?: string - ): string => { - const coordStatusUpper = coordinationStatus?.toUpperCase() ?? ""; - const designStatusUpper = designStatus?.toUpperCase() ?? ""; - - if (coordStatusUpper === "RA ISSUE") return "Queries"; - - if ( - coordStatusUpper.includes("(V1) IOE/MTP COMPLETE") || - coordStatusUpper.includes("(V2) IOE/MTP COMPLETE") || - coordStatusUpper.includes("(V3) IOE/MTP COMPLETE") - ) { - if (designStatusUpper === "UPLOADED") return "Completed"; - return "Design in Progress"; + 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 + ); } - - return "Coordination in Progress"; }; - const resolveDisplayStage = (deal: Record): string => { - let stageName = STAGE_LABELS[deal.dealstage] || "Unknown Stage"; - - if (stageName === "AFTER_ASSESSMENT") { - stageName = - getAfterAssessmentLabel( - deal.coordinationStatus, - deal.designStatus - ) || "Coordination in Progress"; - } - - if (stageName === "Scope & Planning") { - const coordStatusUpper = deal.coordinationStatus?.toUpperCase() ?? ""; - if (coordStatusUpper === "RA ISSUE") { - stageName = "Queries"; - } - } - - if (stageName === "Assessment in Progress") { - const coordStatusUpper = deal.coordinationStatus?.toUpperCase() ?? ""; - if (coordStatusUpper === "RA ISSUE") { - stageName = "Queries"; - } - } - - return stageName; - }; - - // Separate queries from main stages - const queriesDealsList = deals.filter((deal) => resolveDisplayStage(deal) === "Queries"); - - // Calculate stage distribution (excluding queries) - const stageCounts: Record = {}; - const stageDeals: Record = {}; - - deals.forEach((deal) => { - const stage = resolveDisplayStage(deal); - if (stage !== "Queries") { - stageCounts[stage] = (stageCounts[stage] || 0) + 1; - if (!stageDeals[stage]) stageDeals[stage] = []; - stageDeals[stage].push(deal); - } - }); - - const total = Object.values(stageCounts).reduce((a, b) => a + b, 0); - const completedCount = stageCounts["Completed"] || 0; - const completedPercentage = total > 0 ? (completedCount / total) * 100 : 0; - - // Calculate progress for each stage - const stageProgress = STAGE_ORDER.filter((s) => s !== "Queries").map((stage) => { - const count = stageCounts[stage] || 0; - const percentage = total > 0 ? (count / total) * 100 : 0; - return { stage, count, percentage, deals: stageDeals[stage] || [] }; - }); - - const handleStageClick = (item: any) => { - if (onOpenTable && item.deals.length > 0) { - onOpenTable(item.stage, item.deals, ["dealname", "landlordPropertyId"], { - dealname: "Address Ref.", - landlordPropertyId: "Property Ref.", - }); + 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 && queriesDealsList.length > 0) { + if (onOpenTable && queriesDeals.length > 0) { onOpenTable( "Properties Needing Attention", - queriesDealsList, + queriesDeals, ["dealname", "landlordPropertyId", "coordinationStatus"], { dealname: "Address Ref.", @@ -138,74 +105,33 @@ export default function ProgressOverview({ deals, onOpenTable }: ProgressOvervie } }; - const radius = 40 - const circumference = 2 * Math.PI * radius - const strokeDashoffset = circumference - (completedPercentage / 100) * circumference - - // Coordination and Design calculations - const completedDeals = deals.filter((deal) => resolveDisplayStage(deal) === "Completed"); - const coordinationInProgressDeals = deals.filter((deal) => resolveDisplayStage(deal) === "Coordination in Progress"); - const coordinationCompletedDeals = deals.filter((deal) => { - const stage = resolveDisplayStage(deal); - return stage === "Design in Progress" || stage === "Completed"; - }); - const designInProgressDeals = deals.filter((deal) => resolveDisplayStage(deal) === "Design in Progress"); - const designCompletedDeals = deals.filter((deal) => resolveDisplayStage(deal) === "Completed"); - - const totalDeals = deals.length; - const coordCompletedPercentage = totalDeals > 0 ? (coordinationCompletedDeals.length / totalDeals) * 100 : 0; - const coordInProgressPercentage = totalDeals > 0 ? (coordinationInProgressDeals.length / totalDeals) * 100 : 0; - const designCompletedPercentage = totalDeals > 0 ? (designCompletedDeals.length / totalDeals) * 100 : 0; - const designInProgressPercentageVal = totalDeals > 0 ? (designInProgressDeals.length / totalDeals) * 100 : 0; - - const coordinationBreakdown = { - "Coordination Completed": coordinationCompletedDeals, - "Coordination in Progress": coordinationInProgressDeals, - }; - - const handleSummaryClick = ( - label: string, - stages: string[], - breakdown?: Record - ) => { - const filteredDeals = deals.filter((deal) => { - const stage = resolveDisplayStage(deal); - return stages.includes(stage); - }); - onOpenTable?.(label, filteredDeals, undefined, undefined, breakdown); - }; - return (
{/* Work Completed - Full Width Overview at Top */} { - if (onOpenTable && completedDeals.length > 0) { - onOpenTable("Completed Properties", completedDeals, ["dealname", "landlordPropertyId"], { - dealname: "Address Ref.", - landlordPropertyId: "Property Ref.", - }); - } - }} + onClick={handleCompletedClick} whileHover={{ scale: 1.02 }} className="group relative text-left w-full" > - -
+ +
{/* Header with Circular Progress */} -
+
-

+

Work Completed

-

+

End-to-end project overview

{/* Circular Progress */} -
- +
+ {/* Background circle */} - + @@ -238,10 +170,10 @@ export default function ProgressOverview({ deals, onOpenTable }: ProgressOvervie {/* Center text */}
- + {completedPercentage.toFixed(0)}% - + {completedCount}/{totalDeals}
@@ -261,46 +193,44 @@ export default function ProgressOverview({ deals, onOpenTable }: ProgressOvervie
- handleSummaryClick( - "Coordination Status", - ["Design in Progress", "Completed", "Coordination in Progress"], - coordinationBreakdown - ) - } + items={[ + ...coordination.completedDeals, + ...coordination.inProgressDeals, + ]} + onClick={handleCoordinationClick} /> - handleSummaryClick( - "Design Status", - ["Design in Progress", "Completed"] - ) - } + items={[...design.completedDeals, ...design.inProgressDeals]} + onClick={handleDesignClick} />
{/* Queries / Attention Required Section */} - {queriesDealsList.length > 0 && ( + {queriesDeals.length > 0 && (

- {queriesDealsList.length} + {queriesDeals.length}

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

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 3ac61c3..f1e46f3 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,35 +27,26 @@ 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 */} @@ -80,7 +62,7 @@ export default function SurveyedPieChart({ {/* Donut Chart (Centered) */}
`${n.toLocaleString()}`} @@ -101,16 +83,18 @@ export default function SurveyedPieChart({ {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)}
)} @@ -118,11 +102,11 @@ export default function SurveyedPieChart({ {/* Legend (Clean Grid Layout) */}
- {data.map((item, idx) => ( + {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 1c7a3b3..680c0be 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[] = []; @@ -97,12 +156,11 @@ export default function TableViewer({ {visibleColumns.map((col) => ( - +
- {columnLabels?.[col] || col} + + {columnLabels?.[col] || (col as string)} + - {filteredData.length === 0 ? ( + {sortedFilteredData.length === 0 ? ( ) : ( - filteredData.map((row, i) => ( - - {visibleColumns.map((col) => ( - - {renderCellContent(col, row[col])} - - ))} - - )) + sortedFilteredData.map((row, i) => { + const status = getRowStatus(row); + return ( + + {visibleColumns.map((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 3554296..53b04a4 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 0000000..5d7be34 --- /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 0000000..229a2ed --- /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", +];