mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Merge pull request #189 from Hestia-Homes/main
guiness partnership live tracker
This commit is contained in:
commit
2fbf42a52f
10 changed files with 1404 additions and 676 deletions
|
|
@ -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<string, string> = {
|
||||
"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<string, string> = {
|
||||
// ---- Needs support from HA ----
|
||||
"2663668937": "Booking issues due to tenant difficulties.",
|
||||
"3061261536": "Awaab's Law",
|
||||
"1887735999": "<Please contact the Tech Team for implementation>",
|
||||
"3016601828": "RA is currently EPR C. Convert to EPC?",
|
||||
"2769407183": "Needs HA heating upgrade. Domna/HA discussion required.",
|
||||
|
||||
// ---- Not viable for funding ----
|
||||
"1617223917": "<Please contact the Tech Team for implementation>",
|
||||
"1887735998": "<Please contact the Tech Team for implementation>",
|
||||
};
|
||||
|
||||
// ✅ 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<string, number> = {};
|
||||
|
||||
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<string, string>);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* ✅ Main Progress Chart */}
|
||||
<Card className="bg-white rounded-xl shadow-sm hover:shadow-md transition-all duration-200 p-6 flex flex-col items-center justify-center">
|
||||
<div className="text-center mb-3">
|
||||
<Title className="text-gray-800 text-base font-semibold">
|
||||
Project Progress by Stage
|
||||
</Title>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Click a bar to view related properties
|
||||
</p>
|
||||
<p className="text-xs text-gray-700 font-medium mt-1">
|
||||
Total: {total.toLocaleString()} properties
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<BarList
|
||||
data={normalStages}
|
||||
color="blue"
|
||||
sortOrder="none"
|
||||
className="cursor-pointer"
|
||||
onValueChange={handleBarClick}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 🚨 Exception Chart */}
|
||||
<Card className="bg-white rounded-xl shadow-sm hover:shadow-md transition-all duration-200 p-6 flex flex-col items-center justify-center">
|
||||
<div className="text-center mb-3">
|
||||
<Title className="text-gray-800 text-base font-semibold">
|
||||
Needs HA Support & Not Viable
|
||||
</Title>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Click to explore exception properties (reasons appear in table)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<BarList
|
||||
data={exceptionStages}
|
||||
color="red"
|
||||
sortOrder="none"
|
||||
className="cursor-pointer"
|
||||
onValueChange={handleBarClick}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
"use client"
|
||||
|
||||
interface ExpandableCountBarProps<T> {
|
||||
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<T>({
|
||||
title,
|
||||
items,
|
||||
count,
|
||||
percentage,
|
||||
inProgressPercentage,
|
||||
total,
|
||||
secondaryStats,
|
||||
onClick,
|
||||
className = "",
|
||||
}: ExpandableCountBarProps<T>) {
|
||||
|
||||
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 (
|
||||
<button
|
||||
onClick={() => onClick?.(items)}
|
||||
className={`w-full cursor-pointer rounded-xl border border-brandblue/20 bg-gradient-to-br from-brandlightblue/20 to-brandlightblue/5 shadow-sm hover:shadow-md hover:border-brandblue/40 transition-all duration-300 p-6 flex flex-col gap-4 group active:scale-95 ${className}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 text-left">
|
||||
<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>
|
||||
</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">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
className="text-brandblue/10"
|
||||
/>
|
||||
{/* In Progress circle (gold) - drawn first so it appears underneath */}
|
||||
{displayInProgressPercentage > 0 && (
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="3"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={inProgressStrokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-700 ease-out"
|
||||
/>
|
||||
)}
|
||||
{/* Completed progress circle (blue) - drawn last so it appears on top */}
|
||||
{displayPercentage > 0 && (
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#14163d"
|
||||
strokeWidth="3"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={completedStrokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-700 ease-out"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
{/* Center text */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-lg font-bold text-brandblue">
|
||||
{displayPercentage.toFixed(0)}%
|
||||
</span>
|
||||
<span className="text-xs text-gray-600 font-semibold">
|
||||
{displayCount}/{displayTotal}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary Stats */}
|
||||
{secondaryStats && secondaryStats.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 pt-3 border-t border-brandblue/10">
|
||||
{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 ${index === 0 ? 'text-brandblue' : 'text-amber-500'}`}>{stat.count}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-brandblue/40 group-hover:text-brandblue/70 text-xl transition-colors transform group-hover:-rotate-180 duration-300 self-end">▼</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<TableModal | null>(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<string, ClassifiedDeal[]> = {};
|
||||
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<Record<keyof HubspotDeal, string>>,
|
||||
breakdown?: Record<string, ClassifiedDeal[]>
|
||||
) => {
|
||||
setOpenTable({
|
||||
stage,
|
||||
data: filteredDeals,
|
||||
columns: columns || ["dealname", "landlordPropertyId"],
|
||||
columnLabels: columnLabels || {
|
||||
dealname: "Address Ref.",
|
||||
landlordPropertyId: "Property Ref.",
|
||||
},
|
||||
breakdown,
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
<p className="text-gray-600 text-sm">No deal data available.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 w-full">
|
||||
{/* 🌍 Global Overview */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* Project Selector */}
|
||||
<Card className="flex flex-col justify-center items-center border border-brandblue/10 bg-gradient-to-br from-brandlightblue/20 to-white shadow-sm hover:shadow-md transition-shadow p-5">
|
||||
<div className="w-full flex flex-col">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-600 mb-3 font-semibold">
|
||||
Select Project
|
||||
</p>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="projectSelect"
|
||||
value={currentProjectCode}
|
||||
onChange={(e) => setCurrentProjectCode(e.target.value)}
|
||||
className="w-full px-4 py-2.5 pr-10 border border-brandblue/20 rounded-lg bg-white text-gray-800 font-medium text-center focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all appearance-none"
|
||||
>
|
||||
{projectCodes.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Total Properties */}
|
||||
<StatCard
|
||||
icon={Home}
|
||||
title="Total Properties"
|
||||
value={totalDeals}
|
||||
onClick={() =>
|
||||
handleOpenTable(
|
||||
"All Properties",
|
||||
projects.flatMap((p) => p.allDeals),
|
||||
["dealname", "landlordPropertyId", "projectCode"],
|
||||
{
|
||||
dealname: "Address Ref.",
|
||||
landlordPropertyId: "Property Ref.",
|
||||
projectCode: "Project Code",
|
||||
}
|
||||
)
|
||||
}
|
||||
accent="brandblue"
|
||||
/>
|
||||
|
||||
{/* Major Issues */}
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
title="Awaab's Law Reporting"
|
||||
value={`${majorIssues}`}
|
||||
subtitle={`(${majorPercent}%)`}
|
||||
onClick={() =>
|
||||
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"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 📊 Project Insights */}
|
||||
{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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 🔹 Table Modal */}
|
||||
{openTable && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-md transition-opacity"
|
||||
onClick={() => setOpenTable(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-6xl h-[90vh] flex flex-col border border-brandblue/10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-6 border-b border-brandblue/10 pb-6">
|
||||
<h2 className="text-2xl font-bold text-brandblue mb-3">
|
||||
{openTable.stage}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
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]) => {
|
||||
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>
|
||||
|
||||
<div className="flex-1 overflow-auto rounded-lg border border-gray-100">
|
||||
<TableViewer
|
||||
data={openTable.data}
|
||||
columns={openTable.columns}
|
||||
columnLabels={openTable.columnLabels}
|
||||
breakdown={openTable.breakdown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenTable(null);
|
||||
}}
|
||||
className="px-6 py-2.5 bg-gradient-to-r from-brandblue to-brandmidblue text-white font-medium rounded-lg hover:shadow-md transition-all duration-200 hover:opacity-90"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 🔸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 (
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className={`group relative text-left border rounded-xl bg-gradient-to-br ${config.gradient} ${config.border} transition-all duration-300 shadow-sm ${config.hover} p-6`}
|
||||
>
|
||||
<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} opacity-50 group-hover:opacity-75 transition-opacity`}
|
||||
>
|
||||
{value}
|
||||
{subtitle && (
|
||||
<span className="text-base font-medium text-gray-600 ml-2">
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Icon
|
||||
className={`h-8 w-8 ${config.icon} opacity-40 group-hover:opacity-70 transition-all duration-300`}
|
||||
/>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Record<keyof HubspotDeal, string>>,
|
||||
breakdown?: Record<string, ClassifiedDeal[]>
|
||||
) => 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 (
|
||||
<div className="space-y-6">
|
||||
{/* Work Completed - Full Width Overview at Top */}
|
||||
<motion.button
|
||||
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-8 hover:border-emerald-400">
|
||||
<div className="space-y-6">
|
||||
{/* Header with Circular Progress */}
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<p className="text-3xl font-semibold text-emerald-900 uppercase tracking-wide mb-2">
|
||||
Work Completed
|
||||
</p>
|
||||
<p className="text-lg text-emerald-700">
|
||||
End-to-end project overview
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Circular Progress */}
|
||||
<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"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
className="text-emerald-200"
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="url(#completedGradient)"
|
||||
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%"
|
||||
>
|
||||
<stop offset="0%" stopColor="#059669" />
|
||||
<stop offset="100%" stopColor="#10b981" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
{/* Center text */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-bold text-emerald-700">
|
||||
{completedPercentage.toFixed(0)}%
|
||||
</span>
|
||||
<span className="text-sm text-emerald-600 font-semibold mt-1">
|
||||
{completedCount}/{totalDeals}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex items-center gap-2 text-emerald-700 group-hover:text-emerald-900 transition-colors pt-2 border-t border-emerald-200/50">
|
||||
<span className="text-sm font-semibold">View Completed Properties</span>
|
||||
<span className="text-lg">→</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.button>
|
||||
|
||||
{/* 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 ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{earlyStageItems.map((item) => (
|
||||
<motion.button
|
||||
key={item.stage}
|
||||
onClick={() => {
|
||||
if (onOpenTable) {
|
||||
onOpenTable(
|
||||
item.stage,
|
||||
item.deals,
|
||||
["dealname", "landlordPropertyId"],
|
||||
{
|
||||
dealname: "Address Ref.",
|
||||
landlordPropertyId: "Property Ref.",
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="group relative text-left"
|
||||
>
|
||||
<Card className="bg-gradient-to-br from-blue-50/80 to-blue-50/40 border-2 border-blue-300/60 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 p-4 hover:border-blue-400 h-full flex flex-col">
|
||||
<div className="space-y-3 flex-1 flex flex-col justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-blue-900 uppercase tracking-wide mb-1">
|
||||
{item.stage}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-blue-700">
|
||||
{item.count}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-blue-200/50 pt-3">
|
||||
<p className="text-xs text-blue-600 font-semibold">
|
||||
{item.percentage.toFixed(0)}% of total
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-blue-600 group-hover:text-blue-800 transition-colors">
|
||||
<span className="text-xs font-semibold">View</span>
|
||||
<span className="text-sm">→</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* Project Summary Cards - Coordination & Design */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ExpandableCountBar
|
||||
title="Coordination Completed"
|
||||
count={coordination.completedCount}
|
||||
percentage={coordination.completedPercentage}
|
||||
inProgressPercentage={coordination.inProgressPercentage}
|
||||
total={coordination.total}
|
||||
secondaryStats={[
|
||||
{
|
||||
label: "Coordination Completed",
|
||||
count: coordination.completedCount,
|
||||
},
|
||||
{
|
||||
label: "Coordination in Progress",
|
||||
count: coordination.inProgressCount,
|
||||
},
|
||||
]}
|
||||
items={[
|
||||
...coordination.completedDeals,
|
||||
...coordination.inProgressDeals,
|
||||
]}
|
||||
onClick={handleCoordinationClick}
|
||||
/>
|
||||
|
||||
<ExpandableCountBar
|
||||
title="Design Completed"
|
||||
count={design.completedCount}
|
||||
percentage={design.completedPercentage}
|
||||
inProgressPercentage={design.inProgressPercentage}
|
||||
total={design.total}
|
||||
secondaryStats={[
|
||||
{ label: "Design Completed", count: design.completedCount },
|
||||
{ label: "Design in Progress", count: design.inProgressCount },
|
||||
]}
|
||||
items={[...design.completedDeals, ...design.inProgressDeals]}
|
||||
onClick={handleDesignClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Queries / Attention Required Section */}
|
||||
{queriesDeals.length > 0 && (
|
||||
<motion.button
|
||||
onClick={handleQueriesClick}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="group relative text-left w-full"
|
||||
>
|
||||
<Card className="bg-gradient-to-br from-amber-50/80 to-amber-50/40 border-2 border-amber-300/60 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 p-6 hover:border-amber-400">
|
||||
<div className="space-y-4">
|
||||
{/* Header with Alert */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<AlertCircle className="w-6 h-6 text-amber-600 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold text-amber-900 uppercase tracking-wide">
|
||||
Requires Your Input
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 mt-1">
|
||||
These properties need your feedback or assistance to progress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Count Display */}
|
||||
<div className="pt-3 border-t border-amber-200/50">
|
||||
<p className="text-4xl font-black text-amber-600 mb-1">
|
||||
{queriesDeals.length}
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-amber-700 opacity-70">
|
||||
{queriesDeals.length === 1 ? "property" : "properties"}{" "}
|
||||
awaiting action
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex items-center gap-2 text-amber-700 group-hover:text-amber-900 transition-colors">
|
||||
<span className="text-sm font-semibold">Review Details</span>
|
||||
<span className="text-lg">→</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, any>[];
|
||||
}
|
||||
|
||||
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>;
|
||||
} | 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<string, string>
|
||||
) => {
|
||||
setOpenTable({
|
||||
stage,
|
||||
data: filteredDeals,
|
||||
columns:
|
||||
columns || ["dealname", "landlordPropertyId"],
|
||||
columnLabels:
|
||||
columnLabels || {
|
||||
dealname: "Address Ref.",
|
||||
landlordPropertyId: "Property Ref.",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!deals?.length) {
|
||||
return (
|
||||
<Card className="p-8 text-center bg-gradient-to-br from-white to-gray-50 border border-gray-100 shadow-sm">
|
||||
<CardContent>
|
||||
<p className="text-gray-500 text-sm">No deal data available.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 w-full">
|
||||
{/* 🌍 Global Overview */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* Total Properties */}
|
||||
<StatCard
|
||||
icon={Home}
|
||||
title="Total Properties"
|
||||
value={totalProperties}
|
||||
onClick={() =>
|
||||
handleOpenTable(
|
||||
"All Properties",
|
||||
deals,
|
||||
["dealname", "landlordPropertyId", "projectCode"],
|
||||
{
|
||||
dealname: "Address Ref.",
|
||||
landlordPropertyId: "Property Ref.",
|
||||
projectCode: "Project Code",
|
||||
}
|
||||
)
|
||||
}
|
||||
accent="brandblue"
|
||||
/>
|
||||
|
||||
{/* Major Issues */}
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
title="Awaab's Law Reporting"
|
||||
value={`${majorIssues}`}
|
||||
subtitle={`(${majorPercent}%)`}
|
||||
onClick={() =>
|
||||
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 */}
|
||||
<Card className="flex flex-col justify-center items-center border border-gray-100 bg-gradient-to-br from-white to-gray-50 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="font-normal">
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">
|
||||
Select Project
|
||||
</p>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative w-56">
|
||||
<select
|
||||
id="projectSelect"
|
||||
value={currentProjectCode}
|
||||
onChange={(e) => setCurrentProjectCode(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg bg-white text-gray-800 focus:ring-2 focus:ring-brandblue focus:outline-none"
|
||||
>
|
||||
{projectCodes.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-3 top-2.5 text-gray-400 pointer-events-none">
|
||||
▼
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 📊 Project Insights */}
|
||||
<Card className="border border-gray-100 bg-gradient-to-br from-white to-gray-50 shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-lg font-semibold text-brandblue tracking-tight">
|
||||
Project-Level Insights — {currentProjectCode}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className={`grid gap-6 ${hasSurveyData ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1 max-w-2xl mx-auto"}`}>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
className="border rounded-xl p-5 bg-white shadow-sm hover:shadow-md transition"
|
||||
>
|
||||
<DealStageChart
|
||||
deals={currentDeals}
|
||||
onOpenTable={handleOpenTable}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{hasSurveyData && (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
className="border rounded-xl p-5 bg-white shadow-sm hover:shadow-md transition"
|
||||
>
|
||||
<SurveyedPieChart
|
||||
deals={currentDeals}
|
||||
onOpenTable={handleOpenTable}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 🔹 Table Modal */}
|
||||
{openTable && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm transition-opacity">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-6 w-full max-w-6xl h-[90vh] flex flex-col animate-fadeIn">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-center text-gray-800">
|
||||
{openTable.stage} — {openTable.data.length} Properties
|
||||
</h2>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<TableViewer
|
||||
data={openTable.data}
|
||||
columns={openTable.columns}
|
||||
columnLabels={openTable.columnLabels}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
onClick={() => setOpenTable(null)}
|
||||
className="px-6 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg transition"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 🔸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 (
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className={`group relative text-left border rounded-xl bg-gradient-to-br ${accentColor} transition-all duration-200 shadow-sm hover:shadow-md p-5`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{title}</p>
|
||||
<p className="text-3xl font-bold text-gray-800 group-hover:text-inherit">
|
||||
{value}
|
||||
{subtitle && (
|
||||
<span className="text-base font-medium text-gray-500 ml-1">
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Icon className="h-6 w-6 opacity-50 group-hover:opacity-100 transition" />
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,43 +27,34 @@ 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-6 pt-10 pb-8 bg-white">
|
||||
<Card className="flex flex-col items-center p-8 bg-gradient-to-br from-white to-brandlightblue/5 border border-brandblue/10">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-4">
|
||||
<Title className="text-gray-800 text-[15px] font-semibold tracking-tight">
|
||||
<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
|
||||
</Title>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-gray-500">
|
||||
Click a segment or label to view filtered properties
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -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()}`}
|
||||
|
|
@ -94,64 +76,68 @@ export default function SurveyedPieChart({
|
|||
const { name, amount } = item;
|
||||
return (
|
||||
<div
|
||||
className="bg-white/80 backdrop-blur-md px-4 py-2.5 rounded-lg shadow-md
|
||||
border border-gray-200 text-gray-800 text-sm font-medium"
|
||||
className="bg-white/90 backdrop-blur-md px-4 py-3 rounded-lg shadow-lg
|
||||
border border-brandblue/20 text-gray-800 text-sm font-medium"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-[0.95rem] font-semibold text-gray-900">
|
||||
<span className="text-[0.95rem] font-bold text-brandblue">
|
||||
{name}
|
||||
</span>
|
||||
<span className="opacity-70">{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-3xl font-semibold text-gray-800">
|
||||
{data.reduce((a, b) => a + b.amount, 0)}
|
||||
<span className="text-4xl font-bold text-brandblue">
|
||||
{slices.reduce((a, b) => a + b.amount, 0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend (Clean Grid Layout) */}
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-x-6 gap-y-3 max-w-[90%]">
|
||||
{data.map((item, idx) => (
|
||||
<div
|
||||
key={item.name}
|
||||
onClick={() => handleClick(item)}
|
||||
onMouseEnter={() => setHovered(item.name)}
|
||||
<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">
|
||||
{slices.map((slice, idx) => (
|
||||
<button
|
||||
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-gray-900 cursor-pointer transition-colors"
|
||||
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"
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-3.5 h-3.5 rounded-full bg-${colors[idx]} border border-gray-300 flex-shrink-0`}
|
||||
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-[110px]">
|
||||
{item.name}
|
||||
<span className="font-medium truncate max-w-[100px]">
|
||||
{slice.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 ml-1 whitespace-nowrap">
|
||||
{item.percentage}%
|
||||
<span className="text-xs text-gray-500 ml-1 whitespace-nowrap font-semibold">
|
||||
{slice.percentage}%
|
||||
</span>
|
||||
|
||||
{/* Tooltip on hover */}
|
||||
{hovered === item.name && (
|
||||
{hovered === slice.name && (
|
||||
<div
|
||||
className="absolute -top-11 left-1/2 -translate-x-1/2 bg-white/80 backdrop-blur-md
|
||||
px-4 py-2.5 rounded-lg shadow-md border border-gray-200 text-gray-800
|
||||
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
|
||||
text-sm font-medium whitespace-nowrap z-20"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-[0.95rem] font-semibold text-gray-900">
|
||||
{item.name}
|
||||
<span className="text-[0.95rem] font-bold text-brandblue">
|
||||
{slice.name}
|
||||
</span>
|
||||
<span className="text-gray-600 text-xs mt-1">
|
||||
{slice.amount.toLocaleString()}
|
||||
</span>
|
||||
<span className="opacity-70">{item.amount.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
||||
|
|
@ -78,10 +137,10 @@ export default function TableViewer({
|
|||
<button
|
||||
key={idx}
|
||||
onClick={() => handleDownload(url)}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-600 text-xs rounded hover:bg-blue-100 transition"
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-brandblue/10 to-brandmidblue/10 text-brandblue text-xs font-medium rounded-lg hover:from-brandblue/20 hover:to-brandmidblue/20 border border-brandblue/20 hover:border-brandblue/40 transition-all duration-300 active:scale-95"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
<span>Download Photos</span>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Download</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -92,21 +151,20 @@ export default function TableViewer({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto border rounded-xl shadow-lg bg-white">
|
||||
<div className="overflow-x-auto border border-brandblue/10 rounded-xl shadow-lg bg-white">
|
||||
<table className="min-w-full text-sm border-collapse">
|
||||
<thead className="bg-gray-100 sticky top-0">
|
||||
<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="border-b p-3 text-left text-gray-700 font-semibold"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{columnLabels?.[col] || col}</span>
|
||||
<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 as string)}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="p-1 border border-gray-300 rounded text-xs focus:ring-1 focus:ring-blue-400 outline-none"
|
||||
className="p-2 border border-brandblue/20 rounded-lg text-xs focus:ring-2 focus:ring-brandblue focus:border-brandblue outline-none bg-white hover:border-brandblue/40 transition-all"
|
||||
onChange={(e) =>
|
||||
setSearchTerms((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -120,28 +178,34 @@ export default function TableViewer({
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredData.length === 0 ? (
|
||||
{sortedFilteredData.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={visibleColumns.length}
|
||||
className="text-center py-6 text-gray-400"
|
||||
className="text-center py-8 text-gray-500 font-medium"
|
||||
>
|
||||
No results found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredData.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="odd:bg-white even:bg-gray-50 hover:bg-blue-50 transition"
|
||||
>
|
||||
{visibleColumns.map((col) => (
|
||||
<td key={col} className="border-b p-3 text-gray-700">
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
153
src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts
Normal file
153
src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts
Normal 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",
|
||||
];
|
||||
Loading…
Add table
Reference in a new issue