commit code that show complete guiness tracking

This commit is contained in:
Jun-te Kim 2026-02-26 11:20:36 +00:00
parent 694871a36b
commit bed89dbdec
9 changed files with 830 additions and 432 deletions

View file

@ -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<string, string>,
breakdown?: Record<string, Deal[]>
) => void;
}
export function DealStageChart({
deals,
onOpenTable,
}: DealStageChartProps) {
return (
<ProgressOverview deals={deals} onOpenTable={onOpenTable} />
);
}

View file

@ -40,7 +40,7 @@ export default function ExpandableCountBar<T>({
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 text-left">
<p className="text-sm font-semibold text-brandblue group-hover:text-brandmidblue transition-colors">
<p className="text-base font-semibold text-brandblue group-hover:text-brandmidblue transition-colors">
{title}
</p>
<p className="text-xs text-gray-500 mt-1">Click to view breakdown</p>
@ -105,10 +105,10 @@ export default function ExpandableCountBar<T>({
{/* Secondary Stats */}
{secondaryStats && secondaryStats.length > 0 && (
<div className="grid grid-cols-2 gap-2 pt-3 border-t border-brandblue/10">
{secondaryStats.map((stat) => (
{secondaryStats.map((stat, index) => (
<div key={stat.label} className="text-left">
<p className="text-xs text-gray-600 font-medium mb-1">{stat.label}</p>
<p className="text-lg font-bold text-brandblue">{stat.count}</p>
<p className={`text-lg font-bold ${index === 0 ? 'text-brandblue' : 'text-amber-500'}`}>{stat.count}</p>
</div>
))}
</div>

View file

@ -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<string, any>[];
}
export default function LiveTracker({
projects,
totalDeals,
majorConditionDeals,
}: LiveTrackerProps) {
// UI State: which table modal is open
const [openTable, setOpenTable] = useState<TableModal | null>(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<string, any[]>
);
const [openTable, setOpenTable] = useState<{
stage: string;
data: any[];
columns: string[];
columnLabels: Record<string, string>;
breakdown?: Record<string, any[]>;
} | 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<string, ClassifiedDeal[]> = {};
for (const deal of currentProject?.allDeals ?? []) {
if (deal.outcome) {
(dealsByOutcome[deal.outcome] ??= []).push(deal);
}
}
const handleOpenTable = (
stage: string,
filteredDeals: any[],
columns?: string[],
columnLabels?: Record<string, string>,
breakdown?: Record<string, any[]>
filteredDeals: ClassifiedDeal[],
columns?: (keyof HubspotDeal)[],
columnLabels?: Partial<Record<keyof HubspotDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>
) => {
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 (
<Card className="p-8 text-center bg-gradient-to-br from-brandlightblue/30 to-white border border-brandblue/10 shadow-sm">
<CardContent>
@ -113,9 +89,6 @@ export default function LiveTracker({ deals }: ReportsProps) {
</option>
))}
</select>
<div className="absolute right-2 top-3 text-brandblue pointer-events-none opacity-60 text-xs">
</div>
</div>
</div>
</Card>
@ -124,11 +97,11 @@ export default function LiveTracker({ deals }: ReportsProps) {
<StatCard
icon={Home}
title="Total Properties"
value={totalProperties}
value={totalDeals}
onClick={() =>
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"}
/>
</div>
{/* 📊 Project Insights */}
<div>
<div className="mb-6 pb-4 border-b border-brandblue/20 text-center">
<h2 className="text-lg font-bold text-brandblue break-words">
Project-Level Insights <span className="text-brandmidblue">{currentProjectCode}</span>
</h2>
</div>
{currentProject && (
<div>
<div className="mb-6 pb-4 border-b border-brandblue/20 text-center">
<h2 className="text-lg font-bold text-brandblue break-words">
Project-Level Insights {" "}
<span className="text-brandmidblue">{currentProjectCode}</span>
</h2>
</div>
<div className={`grid gap-6 ${hasSurveyData ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1 max-w-3xl mx-auto"}`}>
<motion.div
whileHover={{ scale: 1.01 }}
className="transition-all duration-300"
<div
className={`grid gap-6 ${
hasSurveyData
? "grid-cols-1 md:grid-cols-2"
: "grid-cols-1 max-w-3xl mx-auto"
}`}
>
<DealStageChart
deals={currentDeals}
onOpenTable={handleOpenTable}
/>
</motion.div>
{hasSurveyData && (
<motion.div
whileHover={{ scale: 1.01 }}
className="transition-all duration-300"
>
<SurveyedPieChart
deals={currentDeals}
<ProgressOverview
data={currentProject.progress}
onOpenTable={handleOpenTable}
/>
</motion.div>
)}
{hasSurveyData && (
<motion.div
whileHover={{ scale: 1.01 }}
className="transition-all duration-300"
>
<SurveyedResultsPieChart
slices={currentProject.outcomePieSlices}
dealsByOutcome={dealsByOutcome}
onOpenTable={handleOpenTable}
/>
</motion.div>
)}
</div>
</div>
</div>
)}
{/* 🔹 Table Modal */}
{openTable && (
@ -215,25 +198,54 @@ export default function LiveTracker({ deals }: ReportsProps) {
{openTable.stage}
</h2>
<p className="text-sm text-gray-600 mb-4">
Showing <span className="font-semibold text-brandblue">{openTable.data.length}</span> properties
Showing{" "}
<span className="font-semibold text-brandblue">
{openTable.data.length}
</span>{" "}
properties
</p>
{/* Breakdown Stats */}
{openTable.breakdown && (
<div className="grid grid-cols-2 gap-3">
{Object.entries(openTable.breakdown).map(([category, items]) => (
<div key={category} className="bg-gradient-to-br from-brandlightblue/20 to-brandlightblue/10 rounded-lg p-3 border border-brandblue/10">
<p className="text-xs uppercase tracking-wide font-semibold text-brandblue/70 mb-1">
{category}
</p>
<p className="text-2xl font-bold text-brandblue">
{items.length}
</p>
<p className="text-xs text-gray-500 mt-1">
{((items.length / openTable.data.length) * 100).toFixed(0)}% of total
</p>
</div>
))}
{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 (
<div
key={category}
className={`${bgColor} rounded-lg p-3 border ${borderColor}`}
>
<p
className={`text-xs uppercase tracking-wide font-semibold ${labelColor} mb-1`}
>
{category}
</p>
<p className={`text-2xl font-bold ${textColor}`}>
{items.length}
</p>
<p className="text-xs text-gray-500 mt-1">
{(
((items.length / openTable.data.length) * 100) |
0
)}
% of total
</p>
</div>
);
})}
</div>
)}
</div>
@ -243,6 +255,7 @@ export default function LiveTracker({ deals }: ReportsProps) {
data={openTable.data}
columns={openTable.columns}
columnLabels={openTable.columnLabels}
breakdown={openTable.breakdown}
/>
</div>
@ -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({
>
<div className="flex items-center justify-between">
<div>
<p className={`text-xs uppercase tracking-wide font-semibold ${config.text} opacity-70 mb-3`}>{title}</p>
<p className={`text-3xl font-bold ${config.value} group-hover:opacity-90 transition-opacity`}>
<p
className={`text-xs uppercase tracking-wide font-semibold ${config.text} opacity-70 mb-3`}
>
{title}
</p>
<p
className={`text-3xl font-bold ${config.value} opacity-50 group-hover:opacity-75 transition-opacity`}
>
{value}
{subtitle && (
<span className="text-base font-medium text-gray-600 ml-2">
@ -316,7 +343,9 @@ function StatCard({
)}
</p>
</div>
<Icon className={`h-8 w-8 ${config.icon} opacity-40 group-hover:opacity-70 transition-all duration-300`} />
<Icon
className={`h-8 w-8 ${config.icon} opacity-40 group-hover:opacity-70 transition-all duration-300`}
/>
</div>
</motion.button>
);

View file

@ -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<string, any>[];
data: ProjectProgressData;
onOpenTable?: (
stage: string,
deals: any[],
columns?: string[],
columnLabels?: Record<string, string>,
breakdown?: Record<string, any[]>
deals: ClassifiedDeal[],
columns?: (keyof HubspotDeal)[],
columnLabels?: Partial<Record<keyof HubspotDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>
) => 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<string, string> = {
"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, any>): 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<string, number> = {};
const stageDeals: Record<string, any[]> = {};
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<string, any[]>
) => {
const filteredDeals = deals.filter((deal) => {
const stage = resolveDisplayStage(deal);
return stages.includes(stage);
});
onOpenTable?.(label, filteredDeals, undefined, undefined, breakdown);
};
return (
<div className="space-y-6">
{/* Work Completed - Full Width Overview at Top */}
<motion.button
onClick={() => {
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"
>
<Card className="bg-gradient-to-br from-emerald-50/80 to-emerald-50/40 border-2 border-emerald-300/60 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 p-6 hover:border-emerald-400">
<div className="space-y-4">
<Card className="bg-gradient-to-br from-emerald-50/80 to-emerald-50/40 border-2 border-emerald-300/60 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 p-8 hover:border-emerald-400">
<div className="space-y-6">
{/* Header with Circular Progress */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-center justify-between gap-6">
<div className="flex-1">
<p className="text-sm font-bold text-emerald-900 uppercase tracking-wide">
<p className="text-3xl font-semibold text-emerald-900 uppercase tracking-wide mb-2">
Work Completed
</p>
<p className="text-xs text-emerald-700 mt-1">
<p className="text-lg text-emerald-700">
End-to-end project overview
</p>
</div>
{/* Circular Progress */}
<div className="relative w-24 h-24 flex-shrink-0">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
<div className="relative w-32 h-32 flex-shrink-0">
<svg
className="w-full h-full transform -rotate-90"
viewBox="0 0 100 100"
>
{/* Background circle */}
<circle
cx="50"
@ -223,14 +149,20 @@ export default function ProgressOverview({ deals, onOpenTable }: ProgressOvervie
r={radius}
fill="none"
stroke="url(#completedGradient)"
strokeWidth="3"
strokeWidth="4"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
className="transition-all duration-700 ease-out"
/>
<defs>
<linearGradient id="completedGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<linearGradient
id="completedGradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" stopColor="#059669" />
<stop offset="100%" stopColor="#10b981" />
</linearGradient>
@ -238,10 +170,10 @@ export default function ProgressOverview({ deals, onOpenTable }: ProgressOvervie
</svg>
{/* Center text */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-lg font-bold text-emerald-700">
<span className="text-3xl font-bold text-emerald-700">
{completedPercentage.toFixed(0)}%
</span>
<span className="text-xs text-emerald-600 font-semibold">
<span className="text-sm text-emerald-600 font-semibold mt-1">
{completedCount}/{totalDeals}
</span>
</div>
@ -261,46 +193,44 @@ export default function ProgressOverview({ deals, onOpenTable }: ProgressOvervie
<div className="grid grid-cols-2 gap-3">
<ExpandableCountBar
title="Coordination Completed"
count={coordinationCompletedDeals.length}
percentage={coordCompletedPercentage}
inProgressPercentage={coordInProgressPercentage}
total={totalDeals}
count={coordination.completedCount}
percentage={coordination.completedPercentage}
inProgressPercentage={coordination.inProgressPercentage}
total={coordination.total}
secondaryStats={[
{ label: "Coordination Completed", count: coordinationCompletedDeals.length },
{ label: "Coordination in Progress", count: coordinationInProgressDeals.length },
{
label: "Coordination Completed",
count: coordination.completedCount,
},
{
label: "Coordination in Progress",
count: coordination.inProgressCount,
},
]}
items={coordinationCompletedDeals.concat(coordinationInProgressDeals)}
onClick={() =>
handleSummaryClick(
"Coordination Status",
["Design in Progress", "Completed", "Coordination in Progress"],
coordinationBreakdown
)
}
items={[
...coordination.completedDeals,
...coordination.inProgressDeals,
]}
onClick={handleCoordinationClick}
/>
<ExpandableCountBar
title="Design Completed"
count={designCompletedDeals.length + designInProgressDeals.length}
percentage={designCompletedPercentage}
inProgressPercentage={designInProgressPercentageVal}
total={totalDeals}
count={design.completedCount}
percentage={design.completedPercentage}
inProgressPercentage={design.inProgressPercentage}
total={design.total}
secondaryStats={[
{ label: "Design Completed", count: designCompletedDeals.length },
{ label: "Design in Progress", count: designInProgressDeals.length },
{ label: "Design Completed", count: design.completedCount },
{ label: "Design in Progress", count: design.inProgressCount },
]}
items={designCompletedDeals.concat(designInProgressDeals)}
onClick={() =>
handleSummaryClick(
"Design Status",
["Design in Progress", "Completed"]
)
}
items={[...design.completedDeals, ...design.inProgressDeals]}
onClick={handleDesignClick}
/>
</div>
{/* Queries / Attention Required Section */}
{queriesDealsList.length > 0 && (
{queriesDeals.length > 0 && (
<motion.button
onClick={handleQueriesClick}
whileHover={{ scale: 1.02 }}
@ -326,10 +256,11 @@ export default function ProgressOverview({ deals, onOpenTable }: ProgressOvervie
{/* Count Display */}
<div className="pt-3 border-t border-amber-200/50">
<p className="text-4xl font-black text-amber-600 mb-1">
{queriesDealsList.length}
{queriesDeals.length}
</p>
<p className="text-xs font-semibold text-amber-700 opacity-70">
{queriesDealsList.length === 1 ? "property" : "properties"} awaiting action
{queriesDeals.length === 1 ? "property" : "properties"}{" "}
awaiting action
</p>
</div>

View file

@ -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<string, any>[];
onOpenTable?: (outcome: string, filteredDeals: Record<string, any>[]) => void;
slices: OutcomeSlice[];
dealsByOutcome: Record<string, ClassifiedDeal[]>;
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<string, number> = {};
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<string | null>(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 (
<Card className="flex flex-col items-center p-8 bg-gradient-to-br from-white to-brandlightblue/5 border border-brandblue/10">
{/* Header */}
@ -80,7 +62,7 @@ export default function SurveyedPieChart({
{/* Donut Chart (Centered) */}
<div className="relative flex justify-center items-center mt-6">
<DonutChart
data={data}
data={chartData}
category="amount"
index="name"
valueFormatter={(n) => `${n.toLocaleString()}`}
@ -101,16 +83,18 @@ export default function SurveyedPieChart({
<span className="text-[0.95rem] font-bold text-brandblue">
{name}
</span>
<span className="text-gray-600 text-xs mt-1">{amount.toLocaleString()}</span>
<span className="text-gray-600 text-xs mt-1">
{amount.toLocaleString()}
</span>
</div>
</div>
);
}}
/>
{data.length > 0 && (
{slices.length > 0 && (
<div className="absolute text-center">
<span className="text-4xl font-bold text-brandblue">
{data.reduce((a, b) => a + b.amount, 0)}
{slices.reduce((a, b) => a + b.amount, 0)}
</span>
</div>
)}
@ -118,11 +102,11 @@ export default function SurveyedPieChart({
{/* Legend (Clean Grid Layout) */}
<div className="mt-10 flex flex-wrap justify-center gap-x-6 gap-y-3 max-w-[95%] border-t border-brandblue/10 pt-8">
{data.map((item, idx) => (
{slices.map((slice, idx) => (
<button
key={item.name}
onClick={() => handleClick(item)}
onMouseEnter={() => setHovered(item.name)}
key={slice.name}
onClick={() => handleClick(slice)}
onMouseEnter={() => setHovered(slice.name)}
onMouseLeave={() => setHovered(null)}
className="relative flex items-center space-x-2 text-sm text-gray-700 hover:text-brandblue cursor-pointer transition-colors px-3 py-2 rounded-lg hover:bg-brandlightblue/20"
>
@ -130,14 +114,14 @@ export default function SurveyedPieChart({
className={`inline-block w-3 h-3 rounded-full bg-${colors[idx]} border-2 border-${colors[idx]}/40 flex-shrink-0 transition-all`}
/>
<span className="font-medium truncate max-w-[100px]">
{item.name}
{slice.name}
</span>
<span className="text-xs text-gray-500 ml-1 whitespace-nowrap font-semibold">
{item.percentage}%
{slice.percentage}%
</span>
{/* Tooltip on hover */}
{hovered === item.name && (
{hovered === slice.name && (
<div
className="absolute -top-12 left-1/2 -translate-x-1/2 bg-white/95 backdrop-blur-md
px-4 py-3 rounded-lg shadow-lg border border-brandblue/20 text-gray-800
@ -145,9 +129,11 @@ export default function SurveyedPieChart({
>
<div className="flex flex-col items-center">
<span className="text-[0.95rem] font-bold text-brandblue">
{item.name}
{slice.name}
</span>
<span className="text-gray-600 text-xs mt-1">
{slice.amount.toLocaleString()}
</span>
<span className="text-gray-600 text-xs mt-1">{item.amount.toLocaleString()}</span>
</div>
</div>
)}

View file

@ -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<string, any>[];
columns?: string[];
columnLabels?: Record<string, string>;
data: ClassifiedDeal[];
columns?: (keyof HubspotDeal)[];
columnLabels?: Partial<Record<keyof HubspotDeal, string>>;
breakdown?: Record<string, ClassifiedDeal[]>;
}
export default function TableViewer({
data,
columns,
columnLabels,
breakdown,
}: TableViewerProps) {
const [searchTerms, setSearchTerms] = useState<Record<string, string>>({});
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<string, ClassifiedDeal[]> | 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({
<thead className="bg-gradient-to-r from-brandblue/5 to-brandmidblue/5 sticky top-0 border-b border-brandblue/10">
<tr>
{visibleColumns.map((col) => (
<th
key={col}
className="p-4 text-left font-bold text-brandblue"
>
<th key={col as string} className="p-4 text-left font-bold text-brandblue">
<div className="flex flex-col gap-2">
<span className="text-xs uppercase tracking-wide">{columnLabels?.[col] || col}</span>
<span className="text-xs uppercase tracking-wide">
{columnLabels?.[col] || (col as string)}
</span>
<input
type="text"
placeholder="Search..."
@ -120,7 +178,7 @@ export default function TableViewer({
</tr>
</thead>
<tbody>
{filteredData.length === 0 ? (
{sortedFilteredData.length === 0 ? (
<tr>
<td
colSpan={visibleColumns.length}
@ -130,18 +188,24 @@ export default function TableViewer({
</td>
</tr>
) : (
filteredData.map((row, i) => (
<tr
key={i}
className="odd:bg-white even:bg-brandlightblue/3 hover:bg-brandlightblue/10 transition-colors border-b border-brandblue/5 last:border-b-0"
>
{visibleColumns.map((col) => (
<td key={col} className="p-4 text-gray-800">
{renderCellContent(col, row[col])}
</td>
))}
</tr>
))
sortedFilteredData.map((row, i) => {
const status = getRowStatus(row);
return (
<tr
key={i}
className={`${getRowBackgroundColor(status)} hover:opacity-75 transition-all border-b border-brandblue/5 last:border-b-0`}
>
{visibleColumns.map((col) => (
<td key={col as string} className="p-4 text-gray-800">
{renderCellContent(
col,
row[col as keyof ClassifiedDeal]
)}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>

View file

@ -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 (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
<div className="mb-6">
@ -63,7 +68,7 @@ export default async function LiveReportingPage(props: {
<div className="h-px bg-gray-200 mt-2" />
</div>
<LiveTracker deals={deals} />
<LiveTracker {...trackerData} />
</div>
);
}

View file

@ -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<string, string> = {
"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<string, ClassifiedDeal[]> = {};
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<Record<string, number>> = {};
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<number>(
(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<string, ClassifiedDeal[]> = {};
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,
};
}

View file

@ -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<Record<keyof HubspotDeal, string>>;
breakdown?: Record<string, ClassifiedDeal[]>;
};
// -----------------------------------------------------------------------
// 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",
];