mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
working on reporting page
This commit is contained in:
parent
04ccaf60f1
commit
6605223865
5 changed files with 829 additions and 606 deletions
|
|
@ -1,41 +1,51 @@
|
|||
"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";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/app/shadcn_components/ui/tabs";
|
||||
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
|
||||
import { BarChart2, Table2 } from "lucide-react";
|
||||
import TableViewer from "./TableViewer";
|
||||
import PropertyTable from "./PropertyTable";
|
||||
import PropertyDrawer from "./PropertyDrawer";
|
||||
import AnalyticsView from "./AnalyticsView";
|
||||
import type {
|
||||
LiveTrackerProps,
|
||||
TableModal,
|
||||
ClassifiedDeal,
|
||||
HubspotDeal,
|
||||
DocumentDrawerState,
|
||||
} from "./types";
|
||||
|
||||
export default function LiveTracker({
|
||||
projects,
|
||||
totalDeals,
|
||||
majorConditionDeals,
|
||||
}: LiveTrackerProps) {
|
||||
// UI State: which table modal is open
|
||||
const [openTable, setOpenTable] = useState<TableModal | null>(null);
|
||||
// ── Tab state ────────────────────────────────────────────────────────
|
||||
const [activeTab, setActiveTab] = useState<"analytics" | "properties">("analytics");
|
||||
|
||||
// UI State: which project tab is selected
|
||||
// ── Project selector (shared across both tabs) ───────────────────────
|
||||
const projectCodes = projects.map((p) => p.projectCode);
|
||||
const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]);
|
||||
const currentProject = projects.find(
|
||||
(p) => p.projectCode === currentProjectCode
|
||||
);
|
||||
|
||||
// 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;
|
||||
// ── Drill-down table modal (used by AnalyticsView) ───────────────────
|
||||
const [openTable, setOpenTable] = useState<TableModal | null>(null);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
// ── Document drawer (used by PropertyTable) ──────────────────────────
|
||||
const [drawerState, setDrawerState] = useState<DocumentDrawerState>({
|
||||
open: false,
|
||||
uprn: null,
|
||||
dealname: null,
|
||||
});
|
||||
|
||||
const handleOpenTable = (
|
||||
stage: string,
|
||||
|
|
@ -56,6 +66,10 @@ export default function LiveTracker({
|
|||
});
|
||||
};
|
||||
|
||||
const handleOpenDrawer = (uprn: string | null, dealname: string | null) => {
|
||||
setDrawerState({ open: true, uprn, dealname });
|
||||
};
|
||||
|
||||
if (!totalDeals) {
|
||||
return (
|
||||
<Card className="p-8 text-center bg-gradient-to-br from-brandlightblue/30 to-white border border-brandblue/10 shadow-sm">
|
||||
|
|
@ -67,123 +81,74 @@ export default function LiveTracker({
|
|||
}
|
||||
|
||||
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 per Project */}
|
||||
<StatCard
|
||||
icon={Home}
|
||||
title="Properties in Project"
|
||||
value={currentProject?.allDeals.length ?? 0}
|
||||
onClick={() =>
|
||||
handleOpenTable(
|
||||
`${currentProjectCode} — All Properties`,
|
||||
currentProject?.allDeals ?? [],
|
||||
["dealname", "landlordPropertyId"],
|
||||
{
|
||||
dealname: "Address Ref.",
|
||||
landlordPropertyId: "Property Ref.",
|
||||
}
|
||||
)
|
||||
}
|
||||
accent="brandblue"
|
||||
/>
|
||||
|
||||
{/* Major Issues */}
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
title="Awaab's Law Reporting"
|
||||
value={`${majorIssues}`}
|
||||
subtitle={`(${majorPercent}% across all projects)`}
|
||||
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"
|
||||
}`}
|
||||
<div className="space-y-4 w-full">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "analytics" | "properties")}
|
||||
>
|
||||
{/* Tab bar */}
|
||||
<TabsList className="h-10 p-1 bg-brandlightblue/10 border border-brandblue/10 rounded-xl mb-6">
|
||||
<TabsTrigger
|
||||
value="analytics"
|
||||
className="flex items-center gap-2 rounded-lg text-sm font-medium px-4 data-[state=active]:bg-white data-[state=active]:text-brandblue data-[state=active]:shadow-sm transition-all"
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
className="transition-all duration-300"
|
||||
>
|
||||
<ProgressOverview
|
||||
data={currentProject.progress}
|
||||
onOpenTable={handleOpenTable}
|
||||
/>
|
||||
</motion.div>
|
||||
<BarChart2 className="h-3.5 w-3.5" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="properties"
|
||||
className="flex items-center gap-2 rounded-lg text-sm font-medium px-4 data-[state=active]:bg-white data-[state=active]:text-brandblue data-[state=active]:shadow-sm transition-all"
|
||||
>
|
||||
<Table2 className="h-3.5 w-3.5" />
|
||||
Properties
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{hasSurveyData && (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
className="transition-all duration-300"
|
||||
>
|
||||
<SurveyedResultsPieChart
|
||||
slices={currentProject.outcomePieSlices}
|
||||
dealsByOutcome={dealsByOutcome}
|
||||
onOpenTable={handleOpenTable}
|
||||
/>
|
||||
</motion.div>
|
||||
{/* Analytics tab */}
|
||||
<TabsContent value="analytics" className="mt-0">
|
||||
{currentProject && (
|
||||
<AnalyticsView
|
||||
projects={projects}
|
||||
currentProject={currentProject}
|
||||
currentProjectCode={currentProjectCode}
|
||||
onProjectChange={setCurrentProjectCode}
|
||||
onOpenTable={handleOpenTable}
|
||||
majorConditionDeals={majorConditionDeals}
|
||||
totalDeals={totalDeals}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Properties tab */}
|
||||
<TabsContent value="properties" className="mt-0">
|
||||
<div className="space-y-4">
|
||||
{/* Project selector — mirrors analytics tab */}
|
||||
{projects.length > 1 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500 shrink-0">Project:</span>
|
||||
<select
|
||||
value={currentProjectCode}
|
||||
onChange={(e) => setCurrentProjectCode(e.target.value)}
|
||||
className="px-3 py-1.5 border border-brandblue/20 rounded-lg bg-white text-sm text-gray-800 font-medium focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all appearance-none pr-8"
|
||||
>
|
||||
{projectCodes.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🔹 Table Modal */}
|
||||
<PropertyTable
|
||||
data={currentProject?.allDeals ?? []}
|
||||
onOpenDrawer={handleOpenDrawer}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ── Drill-down table modal ─────────────────────────────────────── */}
|
||||
{openTable && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-md transition-opacity"
|
||||
|
|
@ -208,7 +173,6 @@ export default function LiveTracker({
|
|||
properties
|
||||
</p>
|
||||
|
||||
{/* Breakdown Stats */}
|
||||
{openTable.breakdown && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Object.entries(openTable.breakdown).map(([category, items]) => {
|
||||
|
|
@ -219,9 +183,7 @@ export default function LiveTracker({
|
|||
const borderColor = isCompleted
|
||||
? "border-brandblue/40"
|
||||
: "border-amber-200/50";
|
||||
const textColor = isCompleted
|
||||
? "text-brandblue"
|
||||
: "text-amber-600";
|
||||
const textColor = isCompleted ? "text-brandblue" : "text-amber-600";
|
||||
const labelColor = isCompleted
|
||||
? "text-brandblue"
|
||||
: "text-amber-600/70";
|
||||
|
|
@ -231,20 +193,14 @@ export default function LiveTracker({
|
|||
key={category}
|
||||
className={`${bgColor} rounded-lg p-3 border ${borderColor}`}
|
||||
>
|
||||
<p
|
||||
className={`text-xs uppercase tracking-wide font-semibold ${labelColor} mb-1`}
|
||||
>
|
||||
<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
|
||||
{((items.length / openTable.data.length) * 100) | 0}% of total
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -264,10 +220,7 @@ export default function LiveTracker({
|
|||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenTable(null);
|
||||
}}
|
||||
onClick={() => 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
|
||||
|
|
@ -276,83 +229,16 @@ export default function LiveTracker({
|
|||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Document drawer ────────────────────────────────────────────── */}
|
||||
<PropertyDrawer
|
||||
open={drawerState.open}
|
||||
uprn={drawerState.uprn}
|
||||
dealname={drawerState.dealname}
|
||||
onClose={() =>
|
||||
setDrawerState({ open: false, uprn: null, dealname: null })
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,339 +1,429 @@
|
|||
"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";
|
||||
import { AlertCircle, CheckCircle2, ArrowRight, Users, PenLine, Hammer, FileCheck } from "lucide-react";
|
||||
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
|
||||
import { STAGE_COLORS } from "./types";
|
||||
import type { ProjectProgressData, ClassifiedDeal, ClassifiedDeal as CD } from "./types";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Columns shown per card — enough context to understand WHY
|
||||
// -----------------------------------------------------------------------
|
||||
const QUERIES_COLUMNS: (keyof CD)[] = [
|
||||
"dealname", "landlordPropertyId", "displayStage", "outcome", "coordinationStatus", "outcomeNotes",
|
||||
];
|
||||
const QUERIES_LABELS: Partial<Record<keyof CD, string>> = {
|
||||
dealname: "Address",
|
||||
landlordPropertyId: "Ref",
|
||||
displayStage: "Reason / Stage",
|
||||
outcome: "Survey Outcome",
|
||||
coordinationStatus: "Coordination Status",
|
||||
outcomeNotes: "Notes",
|
||||
};
|
||||
|
||||
const COORD_COLUMNS: (keyof CD)[] = [
|
||||
"dealname", "landlordPropertyId", "displayStage", "coordinator", "coordinationStatus", "ioeV1Date",
|
||||
];
|
||||
const COORD_LABELS: Partial<Record<keyof CD, string>> = {
|
||||
dealname: "Address",
|
||||
landlordPropertyId: "Ref",
|
||||
displayStage: "Current Stage",
|
||||
coordinator: "Coordinator",
|
||||
coordinationStatus: "Coordination Status",
|
||||
ioeV1Date: "IOE/MTP Date",
|
||||
};
|
||||
|
||||
const DESIGN_COLUMNS: (keyof CD)[] = [
|
||||
"dealname", "landlordPropertyId", "displayStage", "designer", "designStatus", "approvedPackage", "designDate",
|
||||
];
|
||||
const DESIGN_LABELS: Partial<Record<keyof CD, string>> = {
|
||||
dealname: "Address",
|
||||
landlordPropertyId: "Ref",
|
||||
displayStage: "Current Stage",
|
||||
designer: "Designer",
|
||||
designStatus: "Design Status",
|
||||
approvedPackage: "Package",
|
||||
designDate: "Design Date",
|
||||
};
|
||||
|
||||
const INSTALL_COLUMNS: (keyof CD)[] = [
|
||||
"dealname", "landlordPropertyId", "displayStage", "installer", "installerHandover", "actualMeasuresInstalled",
|
||||
];
|
||||
const INSTALL_LABELS: Partial<Record<keyof CD, string>> = {
|
||||
dealname: "Address",
|
||||
landlordPropertyId: "Ref",
|
||||
displayStage: "Current Stage",
|
||||
installer: "Installer",
|
||||
installerHandover: "Handover Date",
|
||||
actualMeasuresInstalled: "Measures Installed",
|
||||
};
|
||||
|
||||
const LODGE_COLUMNS: (keyof CD)[] = [
|
||||
"dealname", "landlordPropertyId", "displayStage", "lodgementStatus", "postSapScore", "measuresLodgementDate", "fullLodgementDate",
|
||||
];
|
||||
const LODGE_LABELS: Partial<Record<keyof CD, string>> = {
|
||||
dealname: "Address",
|
||||
landlordPropertyId: "Ref",
|
||||
displayStage: "Current Stage",
|
||||
lodgementStatus: "Lodgement Status",
|
||||
postSapScore: "Post-SAP Score",
|
||||
measuresLodgementDate: "Measures Lodged",
|
||||
fullLodgementDate: "Full Lodgement Date",
|
||||
};
|
||||
|
||||
const EARLY_COLUMNS: (keyof CD)[] = [
|
||||
"dealname", "landlordPropertyId", "displayStage", "preSapScore", "outcome", "raStatus",
|
||||
];
|
||||
const EARLY_LABELS: Partial<Record<keyof CD, string>> = {
|
||||
dealname: "Address",
|
||||
landlordPropertyId: "Ref",
|
||||
displayStage: "Current Stage",
|
||||
preSapScore: "Pre-SAP Score",
|
||||
outcome: "Survey Outcome",
|
||||
raStatus: "Assessment Status",
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Circular progress ring (SVG)
|
||||
// -----------------------------------------------------------------------
|
||||
function RingProgress({ pct, color = "#14163d", size = 80 }: { pct: number; color?: string; size?: number }) {
|
||||
const r = 34;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const offset = circ - (Math.min(pct, 100) / 100) * circ;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 80 80" className="-rotate-90">
|
||||
<circle cx="40" cy="40" r={r} fill="none" stroke="#e5e7eb" strokeWidth="6" />
|
||||
<circle
|
||||
cx="40" cy="40" r={r} fill="none"
|
||||
stroke={color} strokeWidth="6"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: "stroke-dashoffset 0.8s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Clickable phase summary card (coordination, design, install, lodgement)
|
||||
// -----------------------------------------------------------------------
|
||||
function PhaseCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
completedCount,
|
||||
inProgressCount,
|
||||
total,
|
||||
completedPct,
|
||||
inProgressPct,
|
||||
ringColor,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
completedCount: number;
|
||||
inProgressCount: number;
|
||||
total: number;
|
||||
completedPct: number;
|
||||
inProgressPct: number;
|
||||
ringColor: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onClick={onClick}
|
||||
className="group text-left w-full rounded-xl border border-gray-200 bg-white hover:border-brandblue/30 hover:shadow-md transition-all duration-200 p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="p-1.5 rounded-lg bg-brandblue/8 border border-brandblue/10">
|
||||
<Icon className="h-3.5 w-3.5 text-brandblue" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">Complete</span>
|
||||
<span className="font-semibold text-brandblue">{completedCount}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brandblue rounded-full transition-all duration-700"
|
||||
style={{ width: `${completedPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
{inProgressCount > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-xs mt-2">
|
||||
<span className="text-gray-500">In progress</span>
|
||||
<span className="font-semibold text-amber-600">{inProgressCount}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-400 rounded-full transition-all duration-700"
|
||||
style={{ width: `${inProgressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<RingProgress pct={completedPct} color={ringColor} size={64} />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-gray-700">{completedPct.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-3 pt-3 border-t border-gray-100 text-xs text-brandblue/60 group-hover:text-brandblue transition-colors">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
<span>View breakdown</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Main component
|
||||
// -----------------------------------------------------------------------
|
||||
interface ProgressOverviewProps {
|
||||
data: ProjectProgressData;
|
||||
onOpenTable?: (
|
||||
stage: string,
|
||||
deals: ClassifiedDeal[],
|
||||
columns?: (keyof HubspotDeal)[],
|
||||
columnLabels?: Partial<Record<keyof HubspotDeal, string>>,
|
||||
columns?: (keyof CD)[],
|
||||
columnLabels?: Partial<Record<keyof CD, string>>,
|
||||
breakdown?: Record<string, ClassifiedDeal[]>
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function ProgressOverview({
|
||||
data,
|
||||
onOpenTable,
|
||||
}: ProgressOverviewProps) {
|
||||
// Pre-computed values from props
|
||||
export default function ProgressOverview({ data, onOpenTable }: ProgressOverviewProps) {
|
||||
const {
|
||||
completedPercentage,
|
||||
completedCount,
|
||||
totalDeals,
|
||||
nonQueryTotal,
|
||||
queriesDeals,
|
||||
stageProgress,
|
||||
coordination,
|
||||
design,
|
||||
install,
|
||||
lodgement,
|
||||
} = 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",
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
// Early-stage rows (scope / booking / assessment)
|
||||
const earlyStages = ["Scope & Planning", "Booking in Progress", "Assessment in Progress"];
|
||||
const earlyItems = stageProgress.filter((s) => earlyStages.includes(s.stage) && s.count > 0);
|
||||
|
||||
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>
|
||||
<Card className="border border-brandblue/10 shadow-sm">
|
||||
<CardContent className="p-6 space-y-5">
|
||||
|
||||
{/* 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 && (
|
||||
{/* ── Completion header ──────────────────────────────────────────── */}
|
||||
<motion.button
|
||||
onClick={handleQueriesClick}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="group relative text-left w-full"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
onClick={() =>
|
||||
onOpenTable?.(
|
||||
"Completed Properties",
|
||||
data.completedDeals,
|
||||
["dealname", "landlordPropertyId", "displayStage", "actualMeasuresInstalled", "postSapScore", "fullLodgementDate"],
|
||||
{
|
||||
dealname: "Address",
|
||||
landlordPropertyId: "Ref",
|
||||
displayStage: "Stage",
|
||||
actualMeasuresInstalled: "Measures Installed",
|
||||
postSapScore: "Post-SAP",
|
||||
fullLodgementDate: "Lodgement Date",
|
||||
}
|
||||
)
|
||||
}
|
||||
className="group w-full text-left rounded-xl border border-emerald-200 bg-gradient-to-r from-emerald-50 to-white p-5 hover:border-emerald-300 hover:shadow-sm transition-all duration-200"
|
||||
>
|
||||
<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 className="flex items-center gap-4">
|
||||
<div className="relative shrink-0">
|
||||
<RingProgress pct={completedPercentage} color="#059669" size={72} />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-emerald-700">
|
||||
{completedPercentage.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
<span className="text-sm font-semibold text-emerald-800">Work Completed</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-emerald-700">
|
||||
{completedCount}
|
||||
<span className="text-sm font-medium text-emerald-600/70 ml-1">/ {nonQueryTotal}</span>
|
||||
</p>
|
||||
<p className="text-xs text-emerald-600/80 mt-0.5">Properties fully lodged and funded</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-emerald-400 group-hover:text-emerald-600 group-hover:translate-x-0.5 transition-all shrink-0" />
|
||||
</div>
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Early stage chips ─────────────────────────────────────────── */}
|
||||
{earlyItems.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{earlyItems.map((item) => {
|
||||
const c = STAGE_COLORS[item.stage];
|
||||
return (
|
||||
<motion.button
|
||||
key={item.stage}
|
||||
whileHover={{ scale: 1.03 }}
|
||||
onClick={() =>
|
||||
onOpenTable?.(item.stage, item.deals, EARLY_COLUMNS, EARLY_LABELS)
|
||||
}
|
||||
className={`group text-left rounded-xl border p-3 transition-all duration-200 hover:shadow-sm ${c.bg} ${c.border} hover:opacity-90`}
|
||||
>
|
||||
<div className={`flex items-center gap-1 mb-1.5`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${c.dot}`} />
|
||||
<span className={`text-xs font-semibold ${c.text} truncate leading-tight`}>
|
||||
{item.stage}
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-xl font-bold ${c.text}`}>{item.count}</p>
|
||||
<p className={`text-xs ${c.text} opacity-70 mt-0.5`}>
|
||||
{item.percentage.toFixed(0)}% of total
|
||||
</p>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Phase summary cards ──────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<PhaseCard
|
||||
icon={Users}
|
||||
title="Coordination"
|
||||
completedCount={coordination.completedCount}
|
||||
inProgressCount={coordination.inProgressCount}
|
||||
total={coordination.total}
|
||||
completedPct={coordination.completedPercentage}
|
||||
inProgressPct={coordination.inProgressPercentage}
|
||||
ringColor="#14163d"
|
||||
onClick={() =>
|
||||
onOpenTable?.(
|
||||
"Coordination Status",
|
||||
[...coordination.completedDeals, ...coordination.inProgressDeals],
|
||||
COORD_COLUMNS,
|
||||
COORD_LABELS,
|
||||
{
|
||||
"Coordination Complete": coordination.completedDeals,
|
||||
"In Progress": coordination.inProgressDeals,
|
||||
}
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PhaseCard
|
||||
icon={PenLine}
|
||||
title="Design"
|
||||
completedCount={design.completedCount}
|
||||
inProgressCount={design.inProgressCount}
|
||||
total={design.total}
|
||||
completedPct={design.completedPercentage}
|
||||
inProgressPct={design.inProgressPercentage}
|
||||
ringColor="#7c3aed"
|
||||
onClick={() =>
|
||||
onOpenTable?.(
|
||||
"Design Status",
|
||||
[...design.completedDeals, ...design.inProgressDeals],
|
||||
DESIGN_COLUMNS,
|
||||
DESIGN_LABELS,
|
||||
{
|
||||
"Design Complete": design.completedDeals,
|
||||
"In Progress": design.inProgressDeals,
|
||||
}
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PhaseCard
|
||||
icon={Hammer}
|
||||
title="Installation"
|
||||
completedCount={install.completedCount}
|
||||
inProgressCount={install.inProgressCount}
|
||||
total={install.total}
|
||||
completedPct={install.completedPercentage}
|
||||
inProgressPct={install.inProgressPercentage}
|
||||
ringColor="#d97706"
|
||||
onClick={() =>
|
||||
onOpenTable?.(
|
||||
"Installation Status",
|
||||
[...install.completedDeals, ...install.inProgressDeals],
|
||||
INSTALL_COLUMNS,
|
||||
INSTALL_LABELS,
|
||||
{
|
||||
"Install Complete": install.completedDeals,
|
||||
"In Progress": install.inProgressDeals,
|
||||
}
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PhaseCard
|
||||
icon={FileCheck}
|
||||
title="Lodgement"
|
||||
completedCount={lodgement.completedCount}
|
||||
inProgressCount={lodgement.inProgressCount}
|
||||
total={lodgement.total}
|
||||
completedPct={lodgement.completedPercentage}
|
||||
inProgressPct={lodgement.inProgressPercentage}
|
||||
ringColor="#0891b2"
|
||||
onClick={() =>
|
||||
onOpenTable?.(
|
||||
"Lodgement Status",
|
||||
[...lodgement.completedDeals, ...lodgement.inProgressDeals],
|
||||
LODGE_COLUMNS,
|
||||
LODGE_LABELS,
|
||||
{
|
||||
"Fully Lodged": lodgement.completedDeals,
|
||||
"In Progress": lodgement.inProgressDeals,
|
||||
}
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Requires input ────────────────────────────────────────────── */}
|
||||
{queriesDeals.length > 0 && (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
onClick={() =>
|
||||
onOpenTable?.(
|
||||
"Properties Requiring Attention",
|
||||
queriesDeals,
|
||||
QUERIES_COLUMNS,
|
||||
QUERIES_LABELS
|
||||
)
|
||||
}
|
||||
className="group w-full text-left rounded-xl border border-amber-300 bg-gradient-to-r from-amber-50 to-white p-4 hover:border-amber-400 hover:shadow-sm transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-amber-100 border border-amber-200 shrink-0">
|
||||
<AlertCircle className="h-4 w-4 text-amber-600 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-amber-800">Requires Your Input</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold bg-amber-500 text-white">
|
||||
{queriesDeals.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-700/80 mt-0.5">
|
||||
Click to see the issue, survey outcome, and coordination status for each property
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-amber-400 group-hover:text-amber-600 group-hover:translate-x-0.5 transition-all shrink-0" />
|
||||
</div>
|
||||
</motion.button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { redirect } from "next/navigation";
|
||||
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 "./LiveTracker";
|
||||
import { computeLiveTrackerData } from "./transforms";
|
||||
import type { HubspotDeal } from "./types";
|
||||
// MOCK PHASE: using mock data while DB migration is in progress
|
||||
// When migration lands, restore the DB query below and remove this import
|
||||
import { MOCK_DEALS } from "./mockData";
|
||||
|
||||
export default async function LiveReportingPage(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug: portfolioId } = await props.params;
|
||||
const { slug: _portfolioId } = await props.params;
|
||||
const user = await getServerSession(AuthOptions);
|
||||
|
||||
if (!user?.user) {
|
||||
|
|
@ -20,41 +18,13 @@ export default async function LiveReportingPage(props: {
|
|||
redirect("/");
|
||||
}
|
||||
|
||||
// 🏢 Fetch the company
|
||||
|
||||
const [company] = await surveyDB
|
||||
.select()
|
||||
.from(hubspotCompanyData)
|
||||
.where(eq(hubspotCompanyData.groupId, portfolioId));
|
||||
|
||||
if (!company) {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#14163d] via-[#2d348f] to-[#3943b7] text-white">
|
||||
<div className="text-center bg-white/10 backdrop-blur-md text-gray-200 p-8 rounded-2xl shadow-2xl border border-white/10">
|
||||
No information to show.
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// 💼 Fetch deals for that company
|
||||
const deals = await surveyDB
|
||||
.select()
|
||||
.from(hubspotDealData)
|
||||
.where(eq(hubspotDealData.companyId, company.companyId));
|
||||
|
||||
if (!deals || deals.length === 0) {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#14163d] via-[#2d348f] to-[#3943b7] text-white">
|
||||
<div className="text-center bg-white/10 backdrop-blur-md text-gray-200 p-8 rounded-2xl shadow-2xl border border-white/10">
|
||||
No information to show.
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// 🔄 Transform raw deals to typed and computed data
|
||||
const trackerData = computeLiveTrackerData(deals as HubspotDeal[]);
|
||||
// MOCK PHASE ↓↓↓
|
||||
// TODO: replace with real DB query once migration is applied:
|
||||
// const [company] = await surveyDB.select().from(hubspotCompanyData).where(eq(hubspotCompanyData.groupId, portfolioId));
|
||||
// const deals = await surveyDB.select().from(hubspotDealData).where(eq(hubspotDealData.companyId, company.companyId));
|
||||
// const trackerData = computeLiveTrackerData(deals as HubspotDeal[]);
|
||||
const trackerData = computeLiveTrackerData(MOCK_DEALS);
|
||||
// ↑↑↑ END MOCK PHASE
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import type {
|
|||
OutcomeSlice,
|
||||
LiveTrackerProps,
|
||||
WorkPhaseStats,
|
||||
DampMouldRiskData,
|
||||
FunnelStage,
|
||||
} from "./types";
|
||||
|
||||
import {
|
||||
|
|
@ -25,7 +27,7 @@ import {
|
|||
// -----------------------------------------------------------------------
|
||||
const STAGE_ID_MAP: Record<string, string> = {
|
||||
"1617223910": "Scope & Planning", //[Ops] Backlog
|
||||
"3583836399": "Scope & Planning", //[Ops] Route Planning
|
||||
"3583836399": "Scope & Planning", //[Ops] Route Planning
|
||||
"3589581001": "Booking in Progress", // [Bookings] Ready for Bookings Team
|
||||
"3569878239": "Booking in Progress", //[Bookings] Send initial booking SMS
|
||||
"1617223911": "Booking in Progress", // [Bookings] Send Email
|
||||
|
|
@ -39,13 +41,13 @@ const STAGE_ID_MAP: Record<string, string> = {
|
|||
"1617223913": "Assessment in Progress", //[Ops] Survey in Progress
|
||||
"2558220518": "Assessment in Progress", // [Ops] Not attempted - needs reallocation
|
||||
"3474594026": "Booking in Progress", //[Ops/Bookings] Rebooked - Needs updating
|
||||
"3206388924": "Assessment in Progress", //[Ops] Surveyed - Pending Upload from Surveyor (Up to Khalim + Kev as debatable)
|
||||
"3206388924": "Assessment in Progress", //[Ops] Surveyed - Pending Upload from Surveyor
|
||||
"1617223915": "Queries", //[Ops] No Access - Need Sign Off
|
||||
"1617223917": "Queries", //[Ops] No Access - No Revisit
|
||||
"1887735998": "Queries", //[Ops] Not Viable
|
||||
"3061261536": "Queries", //[Sales/Tech] Major condition issue
|
||||
"3948185842": "AFTER_ASSESSMENT", //[Admin] Admin to check all paperwork for external comms
|
||||
"1617223914": "AFTER_ASSESSMENT",// [Ops]Surveyed in Pashub, Transit Job to Co-ordination
|
||||
"1617223914": "AFTER_ASSESSMENT", // [Ops] Surveyed in Pashub, Transit Job to Co-ordination
|
||||
"1617223916": "Queries", // [Ops] Properties to Review Manually
|
||||
"2628341989": "Assessment in Progress", //[Ops] Assessment needs correction
|
||||
"3441170637": "AFTER_ASSESSMENT", //[Ops] Awaiting PV Design
|
||||
|
|
@ -54,9 +56,6 @@ const STAGE_ID_MAP: Record<string, string> = {
|
|||
"1960060104": "Queries", //[Ops] HA Informed
|
||||
"1960060105": "Queries", //[Ops] HA Works Scheduled
|
||||
"1960060106": "AFTER_ASSESSMENT", //[Ops] HA Works Complete
|
||||
// "1668803772": "", //[Ops] ERF Delivered to HA
|
||||
// "1668803773": "", //[Ops] ERF Signed
|
||||
// "2769407183": "", //[Ops] PV - Needs Heating Upgrade (Pre EPR D)
|
||||
"2769407184": "Queries", //[Ops] Talk to client, Needs Heating Upgrade (Pre EPR C)
|
||||
"2702650617": "AFTER_ASSESSMENT", //[Design] Ready for Design
|
||||
"2473886962": "AFTER_ASSESSMENT", //[Design] Design in progress
|
||||
|
|
@ -65,12 +64,12 @@ const STAGE_ID_MAP: Record<string, string> = {
|
|||
|
||||
// -----------------------------------------------------------------------
|
||||
// After-assessment sub-classification
|
||||
// Resolves AFTER_ASSESSMENT deals based on coordinationStatus and designStatus
|
||||
// Resolves AFTER_ASSESSMENT deals based on coordinationStatus + designStatus
|
||||
// -----------------------------------------------------------------------
|
||||
function resolveAfterAssessmentStage(
|
||||
coordinationStatus: string | null,
|
||||
designStatus: string | null
|
||||
): DisplayStage {
|
||||
): "Coordination in Progress" | "Design in Progress" | "POST_DESIGN" | "Queries" {
|
||||
const coord = coordinationStatus?.toUpperCase() ?? "";
|
||||
const design = designStatus?.toUpperCase() ?? "";
|
||||
|
||||
|
|
@ -83,25 +82,42 @@ function resolveAfterAssessmentStage(
|
|||
coord.includes("(V2) IOE/MTP COMPLETE") ||
|
||||
coord.includes("(V3) IOE/MTP COMPLETE")
|
||||
) {
|
||||
return design === "UPLOADED" ? "Completed" : "Design in Progress";
|
||||
return design === "UPLOADED" ? "POST_DESIGN" : "Design in Progress";
|
||||
}
|
||||
|
||||
// Default for AFTER_ASSESSMENT
|
||||
return "Coordination in Progress";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Post-design sub-classification
|
||||
// Called when design is UPLOADED — resolves install / lodgement / completed
|
||||
// -----------------------------------------------------------------------
|
||||
function resolvePostDesignStage(deal: HubspotDeal): DisplayStage {
|
||||
if (deal.fullLodgementDate) return "Completed";
|
||||
if (deal.lodgementStatus) return "Lodgement";
|
||||
if (deal.actualMeasuresInstalled || deal.installerHandover) return "Installation Complete";
|
||||
return "Awaiting Install";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Resolve display stage for a single deal
|
||||
// Maps dealstage ID + coordinationStatus + designStatus -> DisplayStage
|
||||
// Maps dealstage ID + coordination/design/install status -> DisplayStage
|
||||
// -----------------------------------------------------------------------
|
||||
export function resolveDisplayStage(deal: HubspotDeal): DisplayStage {
|
||||
const raw = STAGE_ID_MAP[deal.dealstage ?? ""] ?? "AFTER_ASSESSMENT";
|
||||
|
||||
if (raw === "AFTER_ASSESSMENT") {
|
||||
return resolveAfterAssessmentStage(
|
||||
const afterAssessment = resolveAfterAssessmentStage(
|
||||
deal.coordinationStatus,
|
||||
deal.designStatus
|
||||
);
|
||||
|
||||
if (afterAssessment === "POST_DESIGN") {
|
||||
return resolvePostDesignStage(deal);
|
||||
}
|
||||
|
||||
return afterAssessment;
|
||||
}
|
||||
|
||||
// RA ISSUE override can apply to other stages too
|
||||
|
|
@ -125,6 +141,52 @@ export function classifyDeals(deals: HubspotDeal[]): ClassifiedDeal[] {
|
|||
}));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Compute damp & mould risk — survey vs coordination stage comparison
|
||||
// -----------------------------------------------------------------------
|
||||
export function computeDampMouldRisk(deals: ClassifiedDeal[]): DampMouldRiskData {
|
||||
const surveyFlagDeals = deals.filter((d) => !!d.majorConditionIssuePhotosS3);
|
||||
const coordinatorFlagDeals = deals.filter((d) => !!d.dampMouldFlag);
|
||||
const bothFlaggedCount = surveyFlagDeals.filter((d) => !!d.dampMouldFlag).length;
|
||||
|
||||
return {
|
||||
surveyFlagCount: surveyFlagDeals.length,
|
||||
coordinatorFlagCount: coordinatorFlagDeals.length,
|
||||
bothFlaggedCount,
|
||||
totalDeals: deals.length,
|
||||
surveyFlagDeals,
|
||||
coordinatorFlagDeals,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Compute pipeline funnel — dual counts (current snapshot + cumulative)
|
||||
// -----------------------------------------------------------------------
|
||||
export function computeFunnelStages(deals: ClassifiedDeal[]): FunnelStage[] {
|
||||
const nonQueryDeals = deals.filter((d) => d.displayStage !== "Queries");
|
||||
const total = nonQueryDeals.length;
|
||||
|
||||
return STAGE_ORDER.map((stage) => {
|
||||
const stageIndex = STAGE_ORDER.indexOf(stage);
|
||||
|
||||
const currentCount = nonQueryDeals.filter(
|
||||
(d) => d.displayStage === stage
|
||||
).length;
|
||||
|
||||
const cumulativeCount = nonQueryDeals.filter(
|
||||
(d) => STAGE_ORDER.indexOf(d.displayStage) >= stageIndex
|
||||
).length;
|
||||
|
||||
return {
|
||||
stage,
|
||||
currentCount,
|
||||
currentPct: total > 0 ? (currentCount / total) * 100 : 0,
|
||||
cumulativeCount,
|
||||
cumulativePct: total > 0 ? (cumulativeCount / total) * 100 : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Compute all ProjectProgressData for a set of already-classified deals
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -162,12 +224,16 @@ export function computeProjectProgress(
|
|||
const totalDeals = deals.length;
|
||||
|
||||
// Coordination phase:
|
||||
// completed = Design in Progress + Completed (i.e. coordination is done)
|
||||
// completed = Design in Progress + Awaiting Install + Installation Complete + Lodgement + Completed
|
||||
// in progress = Coordination in Progress
|
||||
const coordCompletedDeals = deals.filter(
|
||||
(d) =>
|
||||
d.displayStage === "Design in Progress" ||
|
||||
d.displayStage === "Completed"
|
||||
const coordCompletedDeals = deals.filter((d) =>
|
||||
[
|
||||
"Design in Progress",
|
||||
"Awaiting Install",
|
||||
"Installation Complete",
|
||||
"Lodgement",
|
||||
"Completed",
|
||||
].includes(d.displayStage)
|
||||
);
|
||||
const coordInProgressDeals = deals.filter(
|
||||
(d) => d.displayStage === "Coordination in Progress"
|
||||
|
|
@ -179,33 +245,78 @@ export function computeProjectProgress(
|
|||
completedCount: coordCompletedDeals.length,
|
||||
inProgressCount: coordInProgressDeals.length,
|
||||
completedPercentage:
|
||||
totalDeals > 0
|
||||
? (coordCompletedDeals.length / totalDeals) * 100
|
||||
: 0,
|
||||
totalDeals > 0 ? (coordCompletedDeals.length / totalDeals) * 100 : 0,
|
||||
inProgressPercentage:
|
||||
totalDeals > 0
|
||||
? (coordInProgressDeals.length / totalDeals) * 100
|
||||
: 0,
|
||||
totalDeals > 0 ? (coordInProgressDeals.length / totalDeals) * 100 : 0,
|
||||
total: totalDeals,
|
||||
};
|
||||
|
||||
// Design phase:
|
||||
// completed = Completed stage
|
||||
// completed = Awaiting Install + Installation Complete + Lodgement + Completed
|
||||
// in progress = Design in Progress
|
||||
const designCompletedDeals = deals.filter((d) =>
|
||||
[
|
||||
"Awaiting Install",
|
||||
"Installation Complete",
|
||||
"Lodgement",
|
||||
"Completed",
|
||||
].includes(d.displayStage)
|
||||
);
|
||||
const designInProgressDeals = deals.filter(
|
||||
(d) => d.displayStage === "Design in Progress"
|
||||
);
|
||||
|
||||
const design: WorkPhaseStats = {
|
||||
completedDeals,
|
||||
completedDeals: designCompletedDeals,
|
||||
inProgressDeals: designInProgressDeals,
|
||||
completedCount,
|
||||
completedCount: designCompletedDeals.length,
|
||||
inProgressCount: designInProgressDeals.length,
|
||||
completedPercentage:
|
||||
totalDeals > 0 ? (designCompletedDeals.length / totalDeals) * 100 : 0,
|
||||
inProgressPercentage:
|
||||
totalDeals > 0 ? (designInProgressDeals.length / totalDeals) * 100 : 0,
|
||||
total: totalDeals,
|
||||
};
|
||||
|
||||
// Install phase:
|
||||
// completed = Lodgement + Completed
|
||||
// in progress = Installation Complete
|
||||
const installCompletedDeals = deals.filter((d) =>
|
||||
["Lodgement", "Completed"].includes(d.displayStage)
|
||||
);
|
||||
const installInProgressDeals = deals.filter(
|
||||
(d) => d.displayStage === "Installation Complete"
|
||||
);
|
||||
|
||||
const install: WorkPhaseStats = {
|
||||
completedDeals: installCompletedDeals,
|
||||
inProgressDeals: installInProgressDeals,
|
||||
completedCount: installCompletedDeals.length,
|
||||
inProgressCount: installInProgressDeals.length,
|
||||
completedPercentage:
|
||||
totalDeals > 0 ? (installCompletedDeals.length / totalDeals) * 100 : 0,
|
||||
inProgressPercentage:
|
||||
totalDeals > 0 ? (installInProgressDeals.length / totalDeals) * 100 : 0,
|
||||
total: totalDeals,
|
||||
};
|
||||
|
||||
// Lodgement phase:
|
||||
// completed = Completed
|
||||
// in progress = Lodgement
|
||||
const lodgementInProgressDeals = deals.filter(
|
||||
(d) => d.displayStage === "Lodgement"
|
||||
);
|
||||
|
||||
const lodgement: WorkPhaseStats = {
|
||||
completedDeals,
|
||||
inProgressDeals: lodgementInProgressDeals,
|
||||
completedCount,
|
||||
inProgressCount: lodgementInProgressDeals.length,
|
||||
completedPercentage:
|
||||
totalDeals > 0 ? (completedCount / totalDeals) * 100 : 0,
|
||||
inProgressPercentage:
|
||||
totalDeals > 0
|
||||
? (designInProgressDeals.length / totalDeals) * 100
|
||||
? (lodgementInProgressDeals.length / totalDeals) * 100
|
||||
: 0,
|
||||
total: totalDeals,
|
||||
};
|
||||
|
|
@ -220,6 +331,10 @@ export function computeProjectProgress(
|
|||
totalDeals,
|
||||
coordination,
|
||||
design,
|
||||
install,
|
||||
lodgement,
|
||||
dampMouldRisk: computeDampMouldRisk(deals),
|
||||
funnelStages: computeFunnelStages(deals),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
// -----------------------------------------------------------------------
|
||||
// Raw DB row from hubspotDealData table
|
||||
// New CRM-synced fields are nullable — populated by HubSpot sync
|
||||
// -----------------------------------------------------------------------
|
||||
export type HubspotDeal = {
|
||||
id: string;
|
||||
|
|
@ -22,12 +23,46 @@ export type HubspotDeal = {
|
|||
majorConditionIssuePhotosS3: string | null;
|
||||
coordinationStatus: string | null;
|
||||
designStatus: string | null;
|
||||
|
||||
// ── CRM-synced additions ──────────────────────────────────────────────
|
||||
pashubLink: string | null;
|
||||
sharepointLink: string | null;
|
||||
dampMouldFlag: string | null; // coordinator-stage damp/mould flag
|
||||
preSapScore: string | null; // kept as text (HubSpot returns strings)
|
||||
coordinator: string | null;
|
||||
ioeV1Date: Date | null;
|
||||
ioeV2Date: Date | null;
|
||||
ioeV3Date: Date | null;
|
||||
proposedMeasures: string | null;
|
||||
approvedPackage: string | null;
|
||||
designer: string | null;
|
||||
designDate: Date | null;
|
||||
actualMeasuresInstalled: string | null;
|
||||
installer: string | null;
|
||||
installerHandover: string | null;
|
||||
postSapScore: string | null;
|
||||
lodgementStatus: string | null;
|
||||
measuresLodgementDate: Date | null;
|
||||
fullLodgementDate: Date | null;
|
||||
|
||||
// ── Internally tracked stubs (no HubSpot source yet) ─────────────────
|
||||
raDateBooking: Date | null;
|
||||
raDateActual: Date | null;
|
||||
raStatus: string | null;
|
||||
postEpcDateBooking: Date | null;
|
||||
postEpcDateActual: Date | null;
|
||||
postEpcStatus: string | null;
|
||||
postEprDate: Date | null;
|
||||
postEprStatus: string | null;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Stage classification result - human-readable display labels
|
||||
// Stage classification result — human-readable display labels
|
||||
// Full end-to-end pipeline: assessment → coordination → design →
|
||||
// install → lodgement → completed (funded)
|
||||
// -----------------------------------------------------------------------
|
||||
export type DisplayStage =
|
||||
| "Scope & Planning"
|
||||
|
|
@ -35,12 +70,15 @@ export type DisplayStage =
|
|||
| "Assessment in Progress"
|
||||
| "Coordination in Progress"
|
||||
| "Design in Progress"
|
||||
| "Awaiting Install"
|
||||
| "Installation Complete"
|
||||
| "Lodgement"
|
||||
| "Completed"
|
||||
| "Queries"
|
||||
| "Unknown Stage";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// A classified deal - original row plus its resolved display stage
|
||||
// A classified deal — original row plus its resolved display stage
|
||||
// -----------------------------------------------------------------------
|
||||
export type ClassifiedDeal = HubspotDeal & {
|
||||
displayStage: DisplayStage;
|
||||
|
|
@ -57,7 +95,7 @@ export type StageProgressItem = {
|
|||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Coordination/Design summary card data
|
||||
// Coordination/Design/Install/Lodgement summary card data
|
||||
// -----------------------------------------------------------------------
|
||||
export type WorkPhaseStats = {
|
||||
completedDeals: ClassifiedDeal[];
|
||||
|
|
@ -69,6 +107,29 @@ export type WorkPhaseStats = {
|
|||
total: number;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Damp & mould risk comparison (survey-stage vs coordination-stage flags)
|
||||
// -----------------------------------------------------------------------
|
||||
export type DampMouldRiskData = {
|
||||
surveyFlagCount: number; // majorConditionIssuePhotosS3 not null
|
||||
coordinatorFlagCount: number; // dampMouldFlag not null/non-empty
|
||||
bothFlaggedCount: number; // flagged at both stages (highest risk)
|
||||
totalDeals: number;
|
||||
surveyFlagDeals: ClassifiedDeal[];
|
||||
coordinatorFlagDeals: ClassifiedDeal[];
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pipeline funnel data — dual counts per stage
|
||||
// -----------------------------------------------------------------------
|
||||
export type FunnelStage = {
|
||||
stage: DisplayStage;
|
||||
currentCount: number; // deals at exactly this stage right now
|
||||
currentPct: number; // as % of non-query total
|
||||
cumulativeCount: number; // deals that have reached this stage or beyond
|
||||
cumulativePct: number;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// All computed data for the ProgressOverview component
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -82,6 +143,10 @@ export type ProjectProgressData = {
|
|||
totalDeals: number;
|
||||
coordination: WorkPhaseStats;
|
||||
design: WorkPhaseStats;
|
||||
install: WorkPhaseStats;
|
||||
lodgement: WorkPhaseStats;
|
||||
dampMouldRisk: DampMouldRiskData;
|
||||
funnelStages: FunnelStage[];
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -114,15 +179,34 @@ export type LiveTrackerProps = {
|
|||
|
||||
// -----------------------------------------------------------------------
|
||||
// Table drill-down shape (stays in LiveTracker state)
|
||||
// columns can include computed ClassifiedDeal fields (e.g. displayStage)
|
||||
// -----------------------------------------------------------------------
|
||||
export type TableModal = {
|
||||
stage: string;
|
||||
data: ClassifiedDeal[];
|
||||
columns: (keyof HubspotDeal)[];
|
||||
columnLabels: Partial<Record<keyof HubspotDeal, string>>;
|
||||
columns: (keyof ClassifiedDeal)[];
|
||||
columnLabels: Partial<Record<keyof ClassifiedDeal, string>>;
|
||||
breakdown?: Record<string, ClassifiedDeal[]>;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Document drawer types
|
||||
// -----------------------------------------------------------------------
|
||||
export type PropertyDocument = {
|
||||
id: string;
|
||||
s3FileUri: string;
|
||||
s3JsonUri: string | null;
|
||||
docType: string;
|
||||
s3FileUploadTimestamp: string; // ISO string
|
||||
uprn: string;
|
||||
};
|
||||
|
||||
export type DocumentDrawerState = {
|
||||
open: boolean;
|
||||
uprn: string | null;
|
||||
dealname: string | null;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Surveyor outcome constants (single source of truth)
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -149,5 +233,83 @@ export const STAGE_ORDER: DisplayStage[] = [
|
|||
"Assessment in Progress",
|
||||
"Coordination in Progress",
|
||||
"Design in Progress",
|
||||
"Awaiting Install",
|
||||
"Installation Complete",
|
||||
"Lodgement",
|
||||
"Completed",
|
||||
];
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Stage colour mapping — used for badges (PropertyTable) and funnel bars (AnalyticsView)
|
||||
// -----------------------------------------------------------------------
|
||||
export const STAGE_COLORS: Record<
|
||||
DisplayStage,
|
||||
{ bg: string; text: string; border: string; dot: string }
|
||||
> = {
|
||||
"Scope & Planning": {
|
||||
bg: "bg-slate-100",
|
||||
text: "text-slate-700",
|
||||
border: "border-slate-200",
|
||||
dot: "bg-slate-400",
|
||||
},
|
||||
"Booking in Progress": {
|
||||
bg: "bg-sky-50",
|
||||
text: "text-sky-700",
|
||||
border: "border-sky-200",
|
||||
dot: "bg-sky-400",
|
||||
},
|
||||
"Assessment in Progress": {
|
||||
bg: "bg-violet-50",
|
||||
text: "text-violet-700",
|
||||
border: "border-violet-200",
|
||||
dot: "bg-violet-400",
|
||||
},
|
||||
"Coordination in Progress": {
|
||||
bg: "bg-amber-50",
|
||||
text: "text-amber-700",
|
||||
border: "border-amber-200",
|
||||
dot: "bg-amber-400",
|
||||
},
|
||||
"Design in Progress": {
|
||||
bg: "bg-orange-50",
|
||||
text: "text-orange-700",
|
||||
border: "border-orange-200",
|
||||
dot: "bg-orange-400",
|
||||
},
|
||||
"Awaiting Install": {
|
||||
bg: "bg-purple-50",
|
||||
text: "text-purple-700",
|
||||
border: "border-purple-200",
|
||||
dot: "bg-purple-400",
|
||||
},
|
||||
"Installation Complete": {
|
||||
bg: "bg-teal-50",
|
||||
text: "text-teal-700",
|
||||
border: "border-teal-200",
|
||||
dot: "bg-teal-400",
|
||||
},
|
||||
Lodgement: {
|
||||
bg: "bg-cyan-50",
|
||||
text: "text-cyan-700",
|
||||
border: "border-cyan-200",
|
||||
dot: "bg-cyan-400",
|
||||
},
|
||||
Completed: {
|
||||
bg: "bg-emerald-50",
|
||||
text: "text-emerald-700",
|
||||
border: "border-emerald-200",
|
||||
dot: "bg-emerald-500",
|
||||
},
|
||||
Queries: {
|
||||
bg: "bg-red-50",
|
||||
text: "text-red-600",
|
||||
border: "border-red-200",
|
||||
dot: "bg-red-400",
|
||||
},
|
||||
"Unknown Stage": {
|
||||
bg: "bg-gray-50",
|
||||
text: "text-gray-500",
|
||||
border: "border-gray-100",
|
||||
dot: "bg-gray-300",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue