Merge pull request #122 from Hestia-Homes/feature/major_condition_issue

Feature/major condition issue
This commit is contained in:
KhalimCK 2025-11-06 12:47:25 +00:00 committed by GitHub
commit 2d69cbdcb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 302 additions and 115 deletions

View file

@ -15,6 +15,9 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
outcome: text("outcome"),
outcomeNotes: text("outcome_notes"),
majorConditionIssueDescription: text("major_condition_issue_description"),
majorConditionIssuePhotos: text("major_condition_issue_photos"),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),

View file

@ -2,6 +2,8 @@
@tailwind components;
@tailwind utilities;
/* 🌞 Light Theme (raw HSL values) */
:root {
--background: 0 0% 100%;

View file

@ -6,6 +6,7 @@ import EmailSignInButton from "./components/signin/EmailSignInButton";
import { redirect } from "next/navigation";
import Image from "next/image";
export default async function Home(props: {
searchParams: Promise<{ error?: string }>;
}) {

View file

@ -7,121 +7,173 @@ const STAGE_ORDER = [
"Initial planning",
"Booking team to contact tenant",
"Survey in progress",
"Not viable",
"Needs Heating Upgrade installed",
"Needs support",
"Coordination + design",
"Ready to be installed",
"Ready for install",
"Installed - Ready for Post work EPC",
"Needs support from HA",
"Not viable for funding",
];
const stage = (label: string) => STAGE_ORDER.find((s) => s === label)!;
// 🏷️ Deal stage → display stage mapping
const STAGE_LABELS: Record<string, string> = {
"1617223910": stage("Initial planning"),
"3583836399": stage("Initial planning"),
"1617223910": stage("Initial planning"), // 0 - [Ops] Backlog
"3583836399": stage("Initial planning"), // 0 - [Ops] Route Planning
"3589581001": stage("Booking team to contact tenant"),
"3569878239": stage("Booking team to contact tenant"),
"1617223911": stage("Booking team to contact tenant"),
"1984184569": stage("Booking team to contact tenant"),
"3569572028": stage("Booking team to contact tenant"),
"3570936026": stage("Booking team to contact tenant"),
"2663668937": stage("Booking team to contact tenant"),
"1984401629": stage("Booking team to contact tenant"),
"2558220518": stage("Booking team to contact tenant"),
"3474594026": stage("Booking team to contact tenant"),
"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("Needs support from HA"), // 4 - [Bookings/Sales] Booking issues - needs HA support (Check with Aidan)
"1984401629": stage("Survey in progress"), // 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("Survey in progress"),
"1617223913": stage("Survey in progress"),
"3206388924": stage("Survey in progress"),
"1617223915": stage("Survey in progress"),
"1617223917": stage("Survey in progress"),
"2571417798": stage("Survey in progress"),
"1887736000": stage("Survey in progress"),
"1617223916": stage("Survey in progress"),
"2628341989": stage("Survey in progress"),
"3441170637": stage("Survey in progress"),
"1617223912": stage("Survey in progress"), // 2 - [Ops] Ready for Assignment to Route
"1617223913": stage("Survey in progress"), // 2 - [Ops] Survey in Progress
"3206388924": stage("Survey in progress"), // 2 - [Ops] Surveyed - Pending Upload from Surveyor
"1617223915": stage("Survey in progress"), // 2 - [Ops] No Access - Need Sign Off
"1617223917": stage("Not viable for funding"), // 3 - [Ops] No Access - No Revisit
"2571417798": stage("Booking team to contact tenant"), // 1 - [Ops] Surveyed under 2019 - Needs Re-survey
"1887735998": stage("Not viable"),
"1617223916": stage("Coordination + design"), // 5 - [Ops] Properties to Review Manually
"2628341989": stage("Coordination + design"), // 5 - [Ops] Assessment needs correction
"3441170637": stage("Coordination + design"), // 5 - [Ops] Awaiting PV Design
"3061261536": stage("Needs Heating Upgrade installed"),
"1887735999": stage("Needs Heating Upgrade installed"),
"3016601828": stage("Needs Heating Upgrade installed"),
"1887735998": stage("Not viable for funding"), // 3 - [Ops] Not Viable
"3061261536": stage("Needs support from HA"), // 4 - [Sales/Tech] Major condition issue
"1887735999": stage("Needs support from HA"), // 4 - [Ops] Needs HA Works
"3016601828": stage("Needs support from HA"), // 4 - [Engagement Team] EPC C Before Works
"1617223914": stage("Coordination + design"), // 5 - [Ops] Surveyed in Pashub, Transit Job to Co-ordination
"2628233422": stage("Coordination + design"), // 5 - [Coordination] Ready for coordination
"2702650617": stage("Coordination + design"), // 5 - [Design] Ready for Design
"2473886962": stage("Coordination + design"), // 5 - [Design] Design in progress
"1617223914": stage("Needs support"),
"2628233422": stage("Needs support"),
"2702650617": stage("Needs support"),
"2473886962": stage("Needs support"),
"1668803774": stage("Coordination + design"),
"3440363736": stage("Coordination + design"),
"2769407183": stage("Needs Heating Upgrade installed"),
"1668803774": stage("Ready for install"), // 6 - [Finance] Ready for Invoicing
"3440363736": stage("Ready for install"), // 6 - [Finance] Needs Invoicing - Files Sent
"2769407183": stage("Needs support from HA"), // 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;
reason?: string;
[key: string]: any;
}
interface DealStageChartProps {
deals: any[];
onOpenTable?: (stageName: string, filteredDeals: any[]) => void;
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> = {};
// Count deals by stage
deals.forEach((d) => {
const stageId = d.dealstage || "unknown";
const stageName = STAGE_LABELS[stageId] || "Unknown Stage";
counts[stageName] = (counts[stageName] || 0) + 1;
});
// Ensure every stage in STAGE_ORDER has a default of 0
const complete = STAGE_ORDER.map((name) => ({
return STAGE_ORDER.map((name) => ({
name,
value: counts[name] || 0,
}));
// Sort according to STAGE_ORDER (just in case)
return complete.sort((a, b) => {
const aIndex = STAGE_ORDER.indexOf(a.name);
const bIndex = STAGE_ORDER.indexOf(b.name);
return (
(aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) -
(bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex)
);
});
}, [deals]);
// ✅ Calculate total deals
const total = useMemo(() => deals.length, [deals]);
const total = deals.length;
const handleBarClick = (value: { name: string; value: number }) => {
const filtered = deals.filter((d) => {
const stageId = d.dealstage || "unknown";
const stageName = STAGE_LABELS[stageId] || "Unknown Stage";
return stageName === value.name;
});
onOpenTable?.(value.name, filtered);
const filteredDeals: Deal[] = deals
.filter((d) => {
const stageName = STAGE_LABELS[d.dealstage] || "Unknown Stage";
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>);
};
return (
<Card className="max-w-lg mx-auto bg-white rounded-2xl shadow-md hover:shadow-lg transition-all duration-200 p-6">
<div className="flex flex-col items-center mb-4">
<Title className="text-gray-800 text-lg font-semibold tracking-tight text-center">
// Split into normal + exception stages
const normalStages = data.filter(
(d) =>
!["Needs support from HA", "Not viable for funding"].includes(d.name) &&
d.name !== ""
);
const exceptionStages = data.filter((d) =>
["Needs support from HA", "Not viable for funding"].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-sm text-gray-500 text-center mt-1">
<p className="text-xs text-gray-500 mt-0.5">
Click a bar to view related properties
</p>
{/* ✅ Total count */}
<p className="text-sm text-gray-700 font-medium mt-2">
<p className="text-xs text-gray-700 font-medium mt-1">
Total: {total.toLocaleString()} properties
</p>
</div>
<div className="w-full">
<div className="w-full max-w-md">
<BarList
data={data}
data={normalStages}
color="blue"
sortOrder="none"
className="cursor-pointer"
@ -129,5 +181,28 @@ export function DealStageChart({ deals, onOpenTable }: DealStageChartProps) {
/>
</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>
);
}

View file

@ -10,13 +10,12 @@ import {
CardTitle,
CardContent,
} from "@/app/shadcn_components/ui/card";
import { Home, AlertTriangle, BarChart3 } from "lucide-react";
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) {
@ -32,7 +31,10 @@ export default function LiveTracker({ deals }: ReportsProps) {
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];
@ -43,8 +45,23 @@ export default function LiveTracker({ deals }: ReportsProps) {
const majorIssues = majorConditionDeals.length;
const majorPercent = ((majorIssues / totalProperties) * 100).toFixed(1);
const handleOpenTable = (stage: string, filteredDeals: any[]) => {
setOpenTable({ stage, data: filteredDeals });
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) {
@ -66,18 +83,42 @@ export default function LiveTracker({ deals }: ReportsProps) {
icon={Home}
title="Total Properties"
value={totalProperties}
onClick={() => handleOpenTable("All Properties", deals)}
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="Major Condition Issues"
value={`${majorIssues} `}
title="Awaab's Law Reporting"
value={`${majorIssues}`}
subtitle={`(${majorPercent}%)`}
onClick={() =>
handleOpenTable("Major Condition Issues", majorConditionDeals)
handleOpenTable(
"Awaab's Law Reporting",
majorConditionDeals,
[
"dealname",
"landlordPropertyId",
"majorConditionIssueDescription",
],
{
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
majorConditionIssueDescription: "Surveyor's Notes"
}
)
}
accent="red"
/>
@ -155,18 +196,8 @@ export default function LiveTracker({ deals }: ReportsProps) {
<div className="flex-1 overflow-auto">
<TableViewer
data={openTable.data}
columns={[
"dealname",
"landlordPropertyId",
"outcome",
"outcomeNotes",
]}
columnLabels={{
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
outcome: "Outcome",
outcomeNotes: "Notes from Surveyor",
}}
columns={openTable.columns}
columnLabels={openTable.columnLabels}
/>
</div>
@ -185,7 +216,7 @@ export default function LiveTracker({ deals }: ReportsProps) {
);
}
/** 🔸Small stat card to match DashboardSummary visuals */
/** 🔸Small stat card component */
function StatCard({
icon: Icon,
title,

View file

@ -1,7 +1,7 @@
"use client";
import { DonutChart, Card, Title } from "@tremor/react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
interface SurveyedPieChartProps {
deals: Record<string, any>[];
@ -24,6 +24,18 @@ export default function SurveyedPieChart({
"Rescheduled",
];
const colors = [
"indigo-600",
"indigo-400",
"blue-300",
"amber-400",
"amber-200",
"slate-400",
"gray-300",
"gray-100",
"gray-200",
];
const data = useMemo(() => {
const outcomeCounts: Record<string, number> = {};
deals.forEach((deal) => {
@ -32,9 +44,11 @@ export default function SurveyedPieChart({
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]);
@ -44,35 +58,96 @@ export default function SurveyedPieChart({
onOpenTable?.(value.name, filteredDeals);
};
return (
<Card className="max-w-lg mx-auto bg-white rounded-2xl shadow-md hover:shadow-lg transition-all duration-200 p-6">
<div className="flex flex-col items-center space-y-4">
<Title className="text-gray-800 text-lg font-semibold tracking-tight text-center">
Survey Outcomes
</Title>
<p className="text-sm text-gray-500 text-center -mt-2">
Click a segment to view filtered properties
</p>
const [hovered, setHovered] = useState<string | null>(null);
return (
<Card className="flex flex-col items-center p-6 pt-10 pb-8 bg-white">
{/* Header */}
<div className="text-center mb-4">
<Title className="text-gray-800 text-[15px] font-semibold tracking-tight">
Survey Performance
</Title>
<p className="text-xs text-gray-500 mt-1">
Click a segment or label to view filtered properties
</p>
</div>
{/* Donut Chart (Centered) */}
<div className="relative flex justify-center items-center mt-6">
<DonutChart
data={data}
category="amount"
index="name"
valueFormatter={(n) => `${n.toLocaleString()}`}
colors={[
"#2d348f",
"#14163d",
"#3943b7",
"#5d6be0",
"black",
"#eff6fc",
"lightBlue",
"navy",
"azure",
]}
className="w-64 h-64 cursor-pointer transition-transform hover:scale-[1.03]"
colors={colors}
onValueChange={handleClick}
showLabel={false}
className="w-64 h-64 cursor-pointer"
customTooltip={({ payload }) => {
const item = payload?.[0]?.payload;
if (!item) return null;
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"
>
<div className="flex flex-col items-center">
<span className="text-[0.95rem] font-semibold text-gray-900">
{name}
</span>
<span className="opacity-70">{amount.toLocaleString()}</span>
</div>
</div>
);
}}
/>
{data.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>
</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)}
onMouseLeave={() => setHovered(null)}
className="relative flex items-center space-x-2 text-sm text-gray-700 hover:text-gray-900 cursor-pointer transition-colors"
>
<span
className={`inline-block w-3.5 h-3.5 rounded-full bg-${colors[idx]} border border-gray-300 flex-shrink-0`}
/>
<span className="font-medium truncate max-w-[110px]">
{item.name}
</span>
<span className="text-xs text-gray-500 ml-1 whitespace-nowrap">
{item.percentage}%
</span>
{/* Tooltip on hover */}
{hovered === item.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
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>
<span className="opacity-70">{item.amount.toLocaleString()}</span>
</div>
</div>
)}
</div>
))}
</div>
</Card>
);