consolidated ui in live project tracking and adding graphs
Some checks are pending
Next.js Build Check / build (push) Waiting to run

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-01 11:13:14 +00:00
parent 224fcc8ab8
commit cc5e21727f
13 changed files with 2176 additions and 394 deletions

View file

@ -10,6 +10,7 @@ import * as EnergyAssessmentsSchema from "@/app/db/schema/energy_assessments";
import * as FundingSchema from "@/app/db/schema/funding";
import * as Relations from "@/app/db/schema/relations";
import * as Users from "@/app/db/schema/users";
import * as CrmSchema from "@/app/db/schema/crm/hubspot_deal_table";
export const pool = new Pool({
host: process.env.DB_HOST,
@ -31,6 +32,7 @@ const schema = {
...EnergyAssessmentsSchema,
...FundingSchema,
...Users,
...CrmSchema,
};
export const db = drizzle(pool, {

View file

@ -0,0 +1,381 @@
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
import { Home, AlertTriangle, ToggleLeft, ToggleRight } from "lucide-react";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import ProgressOverview from "./ProgressOverview";
import SurveyedResultsPieChart from "./SurveyedResultsPieChart";
import DampMouldRiskPanel from "./DampMouldRiskPanel";
import CompletionTrendsChart from "./CompletionTrendsChart";
import { STAGE_COLORS, STAGE_ORDER } from "./types";
import type {
ProjectData,
ClassifiedDeal,
TableModal,
FunnelStage,
} from "./types";
// -----------------------------------------------------------------------
// Stat card (reused from original LiveTracker)
// -----------------------------------------------------------------------
function StatCard({
icon: Icon,
title,
value,
subtitle,
onClick,
accent = "brandblue",
}: {
icon: React.ElementType;
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>
);
}
// -----------------------------------------------------------------------
// Pipeline Funnel — rich card rows
// -----------------------------------------------------------------------
function PipelineFunnel({
funnelStages,
allDeals,
onOpenTable,
}: {
funnelStages: FunnelStage[];
allDeals: ClassifiedDeal[];
onOpenTable: (
stage: string,
deals: ClassifiedDeal[],
columns?: (keyof ClassifiedDeal)[],
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>,
title?: string,
description?: string,
reason?: string,
) => void;
}) {
const [mode, setMode] = useState<"current" | "cumulative">("cumulative");
const visibleStages = funnelStages.filter(
(s) => s.currentCount > 0 || s.cumulativeCount > 0,
);
const maxCount = Math.max(
...visibleStages.map((s) =>
mode === "current" ? s.currentCount : s.cumulativeCount,
),
1,
);
return (
<Card className="border border-brandblue/10 shadow-sm">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-5">
<div>
<h3 className="text-base font-semibold text-brandblue">
Pipeline Overview
</h3>
<p className="text-sm text-gray-500 mt-0.5">
{mode === "cumulative"
? "Properties that have reached each stage or beyond"
: "Properties currently at each stage"}
</p>
</div>
<button
onClick={() =>
setMode((m) => (m === "current" ? "cumulative" : "current"))
}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-brandblue/20 bg-brandblue/5 text-xs font-medium text-brandblue hover:bg-brandblue/10 transition-colors"
>
{mode === "cumulative" ? (
<ToggleRight className="h-3.5 w-3.5" />
) : (
<ToggleLeft className="h-3.5 w-3.5" />
)}
{mode === "cumulative" ? "Cumulative" : "Point-in-time"}
</button>
</div>
<div className="space-y-2">
{visibleStages.map((s) => {
const count =
mode === "current" ? s.currentCount : s.cumulativeCount;
const pct = mode === "current" ? s.currentPct : s.cumulativePct;
const pastCount = s.cumulativeCount - s.currentCount;
const barWidth = maxCount > 0 ? (count / maxCount) * 100 : 0;
const c = STAGE_COLORS[s.stage];
const deals = allDeals.filter((d) =>
mode === "current"
? d.displayStage === s.stage
: STAGE_ORDER.indexOf(d.displayStage) >=
STAGE_ORDER.indexOf(s.stage),
);
return (
<motion.button
key={s.stage}
whileHover={{ scale: 1.01, y: -1 }}
transition={{ duration: 0.15 }}
onClick={() =>
onOpenTable(
`Pipeline — ${s.stage}`,
deals,
[
"dealname",
"landlordPropertyId",
"displayStage",
"coordinator",
"designer",
"installer",
],
{
dealname: "Address",
landlordPropertyId: "Ref",
displayStage: "Stage",
coordinator: "Coordinator",
designer: "Designer",
installer: "Installer",
},
undefined,
`Pipeline — ${s.stage}`,
mode === "cumulative"
? `Properties that have reached the "${s.stage}" stage or beyond.`
: `Properties currently at the "${s.stage}" stage.`,
undefined,
)
}
className={`w-full text-left rounded-xl border ${c.border} ${c.bg} p-4 shadow-sm hover:shadow-md transition-shadow`}
type="button"
>
{/* Header row: dot + name + pct badge */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${c.dot}`} />
<span className={`text-sm font-semibold ${c.text}`}>
{s.stage}
</span>
</div>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full bg-white/60 ${c.text}`}>
{pct.toFixed(0)}%
</span>
</div>
{/* Progress bar */}
<div className="h-2 bg-white/50 rounded-full overflow-hidden mb-3">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${barWidth}%` }}
transition={{ duration: 0.7, ease: "easeOut" }}
className={`h-full rounded-full ${c.dot}`}
style={{ minWidth: count > 0 ? "0.5rem" : 0 }}
/>
</div>
{/* Stats row */}
<div className="flex items-center gap-4">
<div>
<span className={`text-2xl font-bold ${c.text}`}>{count}</span>
<span className={`text-xs ml-1.5 ${c.text} opacity-70`}>
{mode === "current" ? "here now" : "reached stage"}
</span>
</div>
{mode === "cumulative" && pastCount > 0 && (
<div className={`text-xs ${c.text} opacity-60 border-l border-current/20 pl-4`}>
<span className="font-semibold">{pastCount}</span>
{" past this stage"}
</div>
)}
</div>
</motion.button>
);
})}
</div>
</CardContent>
</Card>
);
}
// -----------------------------------------------------------------------
// AnalyticsView — props
// -----------------------------------------------------------------------
interface AnalyticsViewProps {
projects: { projectCode: string }[];
currentProject: ProjectData;
currentProjectCode: string;
onProjectChange: (code: string) => void;
onOpenTable: (
stage: string,
deals: ClassifiedDeal[],
columns?: (keyof ClassifiedDeal)[],
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>,
) => void;
majorConditionDeals: ClassifiedDeal[];
totalDeals: number;
}
export default function AnalyticsView({
projects,
currentProject,
currentProjectCode,
onProjectChange,
onOpenTable,
majorConditionDeals,
totalDeals,
}: AnalyticsViewProps) {
return (
<div className="space-y-6">
{/* Row 1: project selector + stat card (Properties in project) */}
<div className="grid grid-cols-1 sm:grid-cols-2 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
value={currentProjectCode}
onChange={(e) => onProjectChange(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"
>
{projects.map((p) =>
p.projectCode === "__ALL__" ? (
<option key="__ALL__" value="__ALL__" style={{ fontWeight: 700 }}>
All Projects
</option>
) : (
<option key={p.projectCode} value={p.projectCode}>
{p.projectCode}
</option>
)
)}
</select>
</div>
</div>
</Card>
{/* Properties in project */}
<StatCard
icon={Home}
title="Properties in Project"
value={currentProject.allDeals.length}
onClick={() =>
onOpenTable(
`${currentProjectCode} — All Properties`,
currentProject.allDeals,
["dealname", "landlordPropertyId"],
{ dealname: "Address Ref.", landlordPropertyId: "Property Ref." },
)
}
accent="brandblue"
/>
</div>
{/* Row 1.5: Completion trends chart */}
<CompletionTrendsChart
deals={currentProject.allDeals}
isDomnaUser={true} // TODO: Replace with real user check
projectCode={currentProjectCode}
/>
{/* Row 2: section header */}
<div className="pb-3 border-b border-brandblue/10 text-center">
<h2 className="text-base font-bold text-brandblue">
Project Insights {" "}
<span className="text-brandmidblue">
{currentProjectCode === "__ALL__" ? "All Projects" : currentProjectCode}
</span>
</h2>
</div>
{/* Row 3: Progress overview only (no donut chart) */}
<div>
<motion.div
whileHover={{ scale: 1.005 }}
className="transition-all duration-300"
>
<ProgressOverview
data={currentProject.progress}
onOpenTable={onOpenTable}
/>
</motion.div>
</div>
{/* Row 4: Pipeline Funnel */}
<PipelineFunnel
funnelStages={currentProject.progress.funnelStages}
allDeals={currentProject.allDeals}
onOpenTable={onOpenTable}
/>
{/* Row 5: Damp & Mould Risk (moved up) */}
<DampMouldRiskPanel
risk={currentProject.progress.dampMouldRisk}
onOpenTable={onOpenTable}
/>
</div>
);
}

View file

@ -0,0 +1,269 @@
"use client";
import { useState } from "react";
import { Card, Title, LineChart, Legend } from "@tremor/react";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/app/shadcn_components/ui/select";
import type { ClassifiedDeal } from "./types";
interface CompletionTrendsChartProps {
deals: ClassifiedDeal[];
isDomnaUser?: boolean;
projectCode?: string;
}
const METRICS = [
{ key: "bookings", label: "Bookings", dateField: "confirmedSurveyDate" },
{
key: "assessments",
label: "Completed Assessments",
dateField: "raDateActual",
},
{
key: "coordination",
label: "Completed Coordination",
dateField: "ioeV1Date",
},
{ key: "design", label: "Completed Designs", dateField: "designDate" },
{
key: "lodgement",
label: "Completed Lodgements",
dateField: "fullLodgementDate",
},
];
// Returns the Monday of the week containing `date`, as a locale string key
function getMondayOfWeek(date: Date): string {
const d = new Date(date);
const day = d.getDay(); // 0=Sun … 6=Sat
d.setDate(d.getDate() - (day === 0 ? 6 : day - 1));
d.setHours(0, 0, 0, 0);
// Use ISO date string as sort key; displayed as en-GB short date
return d.toISOString().split("T")[0]; // "2026-03-30"
}
function formatMonday(isoDate: string): string {
return new Date(isoDate).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
}
function aggregateByWeek(deals: ClassifiedDeal[], dateField: string) {
const weekCounts: Record<string, number> = {};
for (const deal of deals) {
const date = deal[dateField as keyof ClassifiedDeal] as
| string
| Date
| null;
if (!date) continue;
const d = new Date(date);
if (isNaN(d.getTime())) continue;
const key = getMondayOfWeek(d);
weekCounts[key] = (weekCounts[key] || 0) + 1;
}
return Object.entries(weekCounts)
.sort(([a], [b]) => a.localeCompare(b))
.map(([isoKey, value]) => ({ week: formatMonday(isoKey), value }));
}
// Fills all missing Monday ISO-date keys between min and max with 0s
function fillWeekGaps(keys: string[]): string[] {
if (keys.length === 0) return [];
const sorted = [...keys].sort();
const result: string[] = [];
const current = new Date(sorted[0]);
const end = new Date(sorted[sorted.length - 1]);
while (current <= end) {
result.push(current.toISOString().split("T")[0]);
current.setDate(current.getDate() + 7);
}
return result;
}
// Coordination-specific aggregator: buckets V1 and V2 completions separately
function aggregateCoordinationByWeek(
deals: ClassifiedDeal[],
): Array<{ week: string; "V1 (MTP)": number; "V2 (Re-model)": number }> {
const v1Counts: Record<string, number> = {};
const v2Counts: Record<string, number> = {};
for (const deal of deals) {
const status = (deal.coordinationStatus ?? "").toUpperCase();
if (status.includes("(V1) IOE/MTP COMPLETE") && deal.ioeV1Date) {
const d = new Date(deal.ioeV1Date);
if (!isNaN(d.getTime())) {
const key = getMondayOfWeek(d);
v1Counts[key] = (v1Counts[key] || 0) + 1;
}
}
if (status.includes("(V2) IOE/MTP COMPLETE") && deal.ioeV2Date) {
const d = new Date(deal.ioeV2Date);
if (!isNaN(d.getTime())) {
const key = getMondayOfWeek(d);
v2Counts[key] = (v2Counts[key] || 0) + 1;
}
}
}
const allKeys = fillWeekGaps(
Array.from(new Set([...Object.keys(v1Counts), ...Object.keys(v2Counts)])),
);
return allKeys.map((isoKey) => ({
week: formatMonday(isoKey),
"V1 (MTP)": v1Counts[isoKey] ?? 0,
"V2 (Re-model)": v2Counts[isoKey] ?? 0,
}));
}
export default function CompletionTrendsChart({
deals,
isDomnaUser,
projectCode,
}: CompletionTrendsChartProps) {
const [metric, setMetric] = useState(METRICS[0].key);
// Targets: { [week]: number }
const [targets, setTargets] = useState<{ [week: string]: number }>({});
const [targetInput, setTargetInput] = useState<{
week: string;
value: string;
}>({ week: "", value: "" });
const selectedMetric = METRICS.find((m) => m.key === metric)!;
const isCoordination = metric === "coordination";
const coordData = isCoordination ? aggregateCoordinationByWeek(deals) : null;
const singleData = isCoordination
? null
: aggregateByWeek(deals, selectedMetric.dateField);
// Merge targets into non-coordination chart data
const chartData = isCoordination
? coordData!
: singleData!.map((d) => ({
week: d.week,
[selectedMetric.label]: d.value,
...(targets[d.week] !== undefined ? { Target: targets[d.week] } : {}),
}));
// Add target handler (Domna only)
const handleAddTarget = () => {
if (!targetInput.week || !targetInput.value) return;
setTargets((prev) => ({
...prev,
[targetInput.week]: Number(targetInput.value),
}));
setTargetInput({ week: "", value: "" });
};
return (
<Card className="p-6 border border-brandblue/10 bg-white shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-2">
<div>
<Title className="text-brandblue text-lg font-bold mb-1">
Trends Over Time
</Title>
<p className="text-xs text-gray-500">
Switch between metrics to see weekly trends.
</p>
</div>
<div className="flex gap-2 items-end">
<Select value={metric} onValueChange={setMetric}>
<SelectTrigger className="w-56 h-9 text-sm border-gray-200">
{METRICS.find((m) => m.key === metric)?.label}
</SelectTrigger>
<SelectContent>
{METRICS.map((m) => (
<SelectItem key={m.key} value={m.key} className="text-sm">
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isDomnaUser && !isCoordination && (
<div className="mb-4 flex flex-wrap gap-2 items-end border border-dashed border-brandblue/20 rounded-lg p-3 bg-brandlightblue/10">
<span className="text-xs font-semibold text-brandblue mr-2">
Add/Edit Targets (visible to Domna users only):
</span>
<Input
placeholder="Week (e.g. 2026-W13)"
value={targetInput.week}
onChange={(e) =>
setTargetInput((ti) => ({ ...ti, week: e.target.value }))
}
className="w-32 text-xs"
/>
<Input
placeholder="Target"
type="number"
value={targetInput.value}
onChange={(e) =>
setTargetInput((ti) => ({ ...ti, value: e.target.value }))
}
className="w-24 text-xs"
/>
<Button
size="sm"
onClick={handleAddTarget}
className="h-8 px-3 text-xs"
>
Add/Update
</Button>
</div>
)}
{isCoordination ? (
<>
<LineChart
data={chartData}
index="week"
categories={["V1 (MTP)", "V2 (Re-model)"]}
colors={["blue", "violet"]}
yAxisWidth={40}
className="h-72"
showLegend={true}
showGridLines={true}
curveType="monotone"
/>
<Legend
categories={["V1 (MTP)", "V2 (Re-model)"]}
colors={["blue", "violet"]}
className="mt-4"
/>
</>
) : (
<>
<LineChart
data={chartData}
index="week"
categories={[selectedMetric.label, ...(isDomnaUser ? ["Target"] : [])]}
colors={["blue", "red"]}
yAxisWidth={40}
className="h-72"
showLegend={true}
showGridLines={true}
curveType="monotone"
/>
<Legend
categories={[selectedMetric.label, ...(isDomnaUser ? ["Target"] : [])]}
colors={["blue", "red"]}
className="mt-4"
/>
</>
)}
</Card>
);
}

View file

@ -0,0 +1,240 @@
"use client";
import { motion } from "framer-motion";
import { Droplets, AlertTriangle, ShieldAlert } from "lucide-react";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import type { DampMouldRiskData, ClassifiedDeal } from "./types";
interface DampMouldRiskPanelProps {
risk: DampMouldRiskData;
onOpenTable: (
stage: string,
deals: ClassifiedDeal[],
columns?: (keyof ClassifiedDeal)[],
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>
) => void;
}
function RiskStatCard({
label,
subtitle,
count,
total,
icon: Icon,
color,
onClick,
}: {
label: string;
subtitle: string;
count: number;
total: number;
icon: React.ElementType;
color: "amber" | "orange" | "red";
onClick: () => void;
}) {
const pct = total > 0 ? ((count / total) * 100).toFixed(1) : "0.0";
const styles = {
amber: {
gradient: "from-amber-50 to-amber-50/30",
border: "border-amber-200",
hover: "hover:border-amber-300 hover:shadow-md",
icon: "text-amber-500",
badge: "bg-amber-100 text-amber-700",
bar: "bg-amber-400",
value: "text-amber-700",
},
orange: {
gradient: "from-orange-50 to-orange-50/30",
border: "border-orange-200",
hover: "hover:border-orange-300 hover:shadow-md",
icon: "text-orange-500",
badge: "bg-orange-100 text-orange-700",
bar: "bg-orange-400",
value: "text-orange-700",
},
red: {
gradient: "from-red-50 to-red-50/30",
border: "border-red-300",
hover: "hover:border-red-400 hover:shadow-md",
icon: "text-red-500",
badge: "bg-red-100 text-red-700",
bar: "bg-red-500",
value: "text-red-700",
},
};
const s = styles[color];
return (
<motion.button
whileHover={{ scale: 1.02 }}
onClick={onClick}
disabled={count === 0}
className={`group w-full text-left rounded-xl border bg-gradient-to-br ${s.gradient} ${s.border} ${s.hover} p-5 transition-all duration-200 shadow-sm disabled:opacity-50 disabled:cursor-default`}
>
<div className="flex items-start justify-between mb-3">
<div className="p-2 rounded-lg bg-white/70">
<Icon className={`h-4 w-4 ${s.icon}`} />
</div>
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${s.badge}`}>
{pct}%
</span>
</div>
<p className={`text-2xl font-bold ${s.value} mb-0.5`}>{count}</p>
<p className="text-sm font-medium text-gray-700">{label}</p>
<p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>
{/* Mini progress bar */}
<div className="mt-3 h-1 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${s.bar} rounded-full transition-all duration-700`}
style={{ width: `${Math.min(Number(pct), 100)}%` }}
/>
</div>
</motion.button>
);
}
export default function DampMouldRiskPanel({
risk,
onOpenTable,
}: DampMouldRiskPanelProps) {
const { totalDeals } = risk;
const surveyColumns: (keyof ClassifiedDeal)[] = [
"dealname",
"landlordPropertyId",
"majorConditionIssueDescription",
"majorConditionIssuePhotosS3",
];
const surveyLabels: Partial<Record<keyof ClassifiedDeal, string>> = {
dealname: "Address",
landlordPropertyId: "Property Ref",
majorConditionIssueDescription: "Surveyor Notes",
majorConditionIssuePhotosS3: "Photo Evidence",
};
const coordColumns: (keyof ClassifiedDeal)[] = [
"dealname",
"landlordPropertyId",
"dampMouldFlag",
"coordinator",
];
const coordLabels: Partial<Record<keyof ClassifiedDeal, string>> = {
dealname: "Address",
landlordPropertyId: "Property Ref",
dampMouldFlag: "Coordinator Flag",
coordinator: "Coordinator",
};
const bothFlaggedDeals = risk.surveyFlagDeals.filter((d) => !!d.dampMouldFlag);
const noRisk =
risk.surveyFlagCount === 0 &&
risk.coordinatorFlagCount === 0;
return (
<Card className="border border-amber-200/60 bg-gradient-to-br from-amber-50/40 to-white shadow-sm">
<CardContent className="p-6">
{/* Header */}
<div className="flex items-start gap-3 mb-5">
<div className="p-2.5 rounded-xl bg-amber-100 border border-amber-200">
<Droplets className="h-5 w-5 text-amber-600" />
</div>
<div>
<h3 className="text-base font-semibold text-gray-800">
Awaab&apos;s Law Damp & Mould Risk
</h3>
<p className="text-sm text-gray-500 mt-0.5">
Comparison of flags raised at survey vs coordination stage
</p>
</div>
</div>
{noRisk ? (
<div className="flex items-center gap-3 py-4 px-4 rounded-xl bg-emerald-50 border border-emerald-200">
<div className="p-1.5 rounded-lg bg-emerald-100">
<ShieldAlert className="h-4 w-4 text-emerald-600" />
</div>
<p className="text-sm font-medium text-emerald-700">
No damp or mould flags recorded for this project.
</p>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
<RiskStatCard
label="Flagged at Survey"
subtitle="Identified by assessor"
count={risk.surveyFlagCount}
total={totalDeals}
icon={AlertTriangle}
color="amber"
onClick={() =>
onOpenTable(
"Damp & Mould — Survey Stage Flags",
risk.surveyFlagDeals,
surveyColumns,
surveyLabels
)
}
/>
<RiskStatCard
label="Flagged at Coordination"
subtitle="Identified after survey"
count={risk.coordinatorFlagCount}
total={totalDeals}
icon={Droplets}
color="orange"
onClick={() =>
onOpenTable(
"Damp & Mould — Coordination Stage Flags",
risk.coordinatorFlagDeals,
coordColumns,
coordLabels
)
}
/>
<RiskStatCard
label="Flagged at Both Stages"
subtitle="Highest risk — action required"
count={risk.bothFlaggedCount}
total={totalDeals}
icon={ShieldAlert}
color="red"
onClick={() =>
onOpenTable(
"Damp & Mould — Flagged at Both Stages",
bothFlaggedDeals,
coordColumns,
coordLabels
)
}
/>
</div>
{/* Missed risk callout */}
{risk.coordinatorFlagCount > risk.surveyFlagCount && (
<div className="flex items-start gap-2.5 p-3.5 rounded-lg bg-orange-50 border border-orange-200">
<AlertTriangle className="h-4 w-4 text-orange-500 mt-0.5 shrink-0" />
<p className="text-xs text-orange-700 leading-relaxed">
<span className="font-semibold">
{risk.coordinatorFlagCount - risk.surveyFlagCount} additional{" "}
{risk.coordinatorFlagCount - risk.surveyFlagCount === 1 ? "property was" : "properties were"}{" "}
</span>
flagged for damp & mould at the coordination stage that{" "}
{risk.coordinatorFlagCount - risk.surveyFlagCount === 1 ? "was" : "were"} not
identified during the initial survey.
</p>
</div>
)}
</>
)}
</CardContent>
</Card>
);
}

View file

@ -18,7 +18,6 @@ import type {
LiveTrackerProps,
TableModal,
ClassifiedDeal,
HubspotDeal,
DocumentDrawerState,
} from "./types";
@ -28,13 +27,15 @@ export default function LiveTracker({
majorConditionDeals,
}: LiveTrackerProps) {
// ── Tab state ────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<"analytics" | "properties">("analytics");
const [activeTab, setActiveTab] = useState<"analytics" | "properties">(
"analytics",
);
// ── 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
(p) => p.projectCode === currentProjectCode,
);
// ── Drill-down table modal (used by AnalyticsView) ───────────────────
@ -50,18 +51,18 @@ export default function LiveTracker({
const handleOpenTable = (
stage: string,
filteredDeals: ClassifiedDeal[],
columns?: (keyof HubspotDeal)[],
columnLabels?: Partial<Record<keyof HubspotDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>
columns?: (keyof ClassifiedDeal)[],
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>,
) => {
setOpenTable({
stage,
data: filteredDeals,
columns: columns || ["dealname", "landlordPropertyId"],
columnLabels: columnLabels || {
columns: (columns || ["dealname", "landlordPropertyId"]) as (keyof ClassifiedDeal)[],
columnLabels: (columnLabels || {
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
},
}) as Partial<Record<keyof ClassifiedDeal, string>>,
breakdown,
});
};
@ -131,11 +132,21 @@ export default function LiveTracker({
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>
))}
{projectCodes.map((code) =>
code === "__ALL__" ? (
<option
key="__ALL__"
value="__ALL__"
style={{ fontWeight: 700 }}
>
All Projects
</option>
) : (
<option key={code} value={code}>
{code}
</option>
),
)}
</select>
</div>
)}
@ -143,6 +154,7 @@ export default function LiveTracker({
<PropertyTable
data={currentProject?.allDeals ?? []}
onOpenDrawer={handleOpenDrawer}
showDocuments={true}
/>
</div>
</TabsContent>
@ -175,47 +187,49 @@ export default function LiveTracker({
{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";
{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>
);
})}
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 className="flex-1 overflow-auto rounded-lg border border-gray-100 bg-white p-4">
<PropertyTable data={openTable.data} onOpenDrawer={() => {}} />
</div>
<div className="mt-6 flex justify-end gap-3">

View file

@ -1,102 +1,63 @@
"use client";
import { motion } from "framer-motion";
import { AlertCircle, CheckCircle2, ArrowRight, Users, PenLine, Hammer, FileCheck } from "lucide-react";
import { CheckCircle2, ArrowRight } 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";
import type {
ProjectProgressData,
ClassifiedDeal,
} from "./types";
// -----------------------------------------------------------------------
// Columns shown per card — enough context to understand WHY
// -----------------------------------------------------------------------
const QUERIES_COLUMNS: (keyof CD)[] = [
"dealname", "landlordPropertyId", "displayStage", "outcome", "coordinationStatus", "outcomeNotes",
const EARLY_COLUMNS: (keyof ClassifiedDeal)[] = [
"dealname",
"landlordPropertyId",
"displayStage",
"preSapScore",
"outcome",
];
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>> = {
const EARLY_LABELS: Partial<Record<keyof ClassifiedDeal, 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 }) {
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}
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" }}
/>
@ -104,91 +65,6 @@ function RingProgress({ pct, color = "#14163d", size = 80 }: { pct: number; colo
);
}
// -----------------------------------------------------------------------
// 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
// -----------------------------------------------------------------------
@ -197,33 +73,36 @@ interface ProgressOverviewProps {
onOpenTable?: (
stage: string,
deals: ClassifiedDeal[],
columns?: (keyof CD)[],
columnLabels?: Partial<Record<keyof CD, string>>,
breakdown?: Record<string, ClassifiedDeal[]>
columns?: (keyof ClassifiedDeal)[],
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>,
) => void;
}
export default function ProgressOverview({ data, onOpenTable }: ProgressOverviewProps) {
export default function ProgressOverview({
data,
onOpenTable,
}: ProgressOverviewProps) {
const {
completedPercentage,
completedCount,
nonQueryTotal,
queriesDeals,
stageProgress,
coordination,
design,
install,
lodgement,
} = data;
// 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);
const earlyStages = [
"Scope & Planning",
"Booking in Progress",
"Assessment in Progress",
];
const earlyItems = stageProgress.filter(
(s) => earlyStages.includes(s.stage) && s.count > 0,
);
return (
<Card className="border border-brandblue/10 shadow-sm">
<Card className="border border-brandblue/10 shadow-md rounded-2xl bg-white">
<CardContent className="p-6 space-y-5">
{/* ── Completion header ──────────────────────────────────────────── */}
<motion.button
whileHover={{ scale: 1.01 }}
@ -231,22 +110,35 @@ export default function ProgressOverview({ data, onOpenTable }: ProgressOverview
onOpenTable?.(
"Completed Properties",
data.completedDeals,
["dealname", "landlordPropertyId", "displayStage", "actualMeasuresInstalled", "postSapScore", "fullLodgementDate"],
[
"dealname",
"landlordPropertyId",
"displayStage",
"actualMeasuresInstalled",
"fullLodgementDate",
],
{
dealname: "Address",
landlordPropertyId: "Ref",
displayStage: "Stage",
actualMeasuresInstalled: "Measures Installed",
postSapScore: "Post-SAP",
fullLodgementDate: "Lodgement Date",
}
},
undefined,
"Completed Properties",
"These properties have completed all stages and are fully lodged/funded.",
"Work completed and funding claimed.",
)
}
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"
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-md transition-all duration-200"
>
<div className="flex items-center gap-4">
<div className="relative shrink-0">
<RingProgress pct={completedPercentage} color="#059669" size={72} />
<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)}%
@ -256,13 +148,19 @@ export default function ProgressOverview({ data, onOpenTable }: ProgressOverview
<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>
<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>
<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>
<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>
@ -278,13 +176,26 @@ export default function ProgressOverview({ data, onOpenTable }: ProgressOverview
key={item.stage}
whileHover={{ scale: 1.03 }}
onClick={() =>
onOpenTable?.(item.stage, item.deals, EARLY_COLUMNS, EARLY_LABELS)
onOpenTable?.(
item.stage,
item.deals,
EARLY_COLUMNS,
EARLY_LABELS,
undefined,
item.stage,
`Properties currently in the "${item.stage}" stage.`,
undefined,
)
}
className={`group text-left rounded-xl border p-3 transition-all duration-200 hover:shadow-sm ${c.bg} ${c.border} hover:opacity-90`}
className={`group text-left rounded-xl border p-3 transition-all duration-200 hover:shadow-md ${c.bg} ${c.border} hover:opacity-95`}
>
<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`}>
<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>
@ -298,131 +209,6 @@ export default function ProgressOverview({ data, onOpenTable }: ProgressOverview
</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>
);

View file

@ -0,0 +1,313 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
FileDown,
FileText,
Code2,
BarChart3,
Loader2,
FolderOpen,
X,
ExternalLink,
} from "lucide-react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
} from "@/app/shadcn_components/ui/drawer";
import type { PropertyDocument } from "./types";
// Human-readable labels for DB_REPORT_TYPES enum values
const DOC_TYPE_LABELS: Record<string, string> = {
ECO_CONDITION_REPORT: "Condition Report (PAS 2035)",
ENERGY_PERFORMANCE_REPORT_SUMMARY_INFORMATION: "EPC Summary Report",
LIG_XML: "LIG XML",
RDSAP_XML: "RdSAP XML",
FULLSAP_XML: "Full SAP XML",
DECENT_HOMES_RAW_DATA: "Decent Homes Raw Data",
DECENT_HOMES_PROPERTY_META: "Decent Homes Property Meta",
DECENT_HOMES_SUMMARY: "Decent Homes Summary",
};
// Icon + colour per doc category
function docTypeStyle(docType: string): {
icon: React.ReactNode;
bg: string;
text: string;
border: string;
} {
if (docType.includes("XML")) {
return {
icon: <Code2 className="h-3.5 w-3.5" />,
bg: "bg-amber-50",
text: "text-amber-700",
border: "border-amber-200",
};
}
if (docType.includes("DECENT_HOMES")) {
return {
icon: <BarChart3 className="h-3.5 w-3.5" />,
bg: "bg-violet-50",
text: "text-violet-700",
border: "border-violet-200",
};
}
return {
icon: <FileText className="h-3.5 w-3.5" />,
bg: "bg-sky-50",
text: "text-sky-700",
border: "border-sky-200",
};
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
} catch {
return iso;
}
}
// -----------------------------------------------------------------------
// Individual document row
// -----------------------------------------------------------------------
function DocumentRow({ doc }: { doc: PropertyDocument }) {
const [signing, setSigning] = useState(false);
const style = docTypeStyle(doc.docType);
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
async function handleDownload() {
setSigning(true);
try {
// Extract S3 key from the full URI — same pattern as TableViewer.tsx
const key = doc.s3FileUri.split(".amazonaws.com/")[1];
if (!key) {
window.open(doc.s3FileUri, "_blank");
return;
}
const res = await fetch("/api/sign-s3-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
});
if (!res.ok) throw new Error("Failed to get signed URL");
const data = await res.json();
window.open(data.url, "_blank");
} catch {
// Fallback: open raw URI
window.open(doc.s3FileUri, "_blank");
} finally {
setSigning(false);
}
}
return (
<motion.div
layout
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-gray-100 bg-white hover:border-brandblue/20 hover:shadow-sm transition-all duration-150"
>
<div className="flex items-center gap-3 min-w-0">
{/* Doc type badge */}
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md border text-xs font-medium shrink-0 ${style.bg} ${style.text} ${style.border}`}
>
{style.icon}
{label}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-gray-400 hidden sm:block">
{formatDate(doc.s3FileUploadTimestamp)}
</span>
<button
onClick={handleDownload}
disabled={signing}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{signing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="h-3.5 w-3.5" />
)}
{signing ? "Preparing…" : "Download"}
</button>
</div>
</motion.div>
);
}
// -----------------------------------------------------------------------
// PropertyDrawer — main component
// -----------------------------------------------------------------------
interface PropertyDrawerProps {
open: boolean;
uprn: string | null;
dealname: string | null;
onClose: () => void;
}
export default function PropertyDrawer({
open,
uprn,
dealname,
onClose,
}: PropertyDrawerProps) {
const {
data: documents = [],
isLoading,
isError,
} = useQuery({
queryKey: ["property-documents", uprn],
// TODO: Replace with real implementation when available
queryFn: async () => [],
enabled: open && !!uprn,
staleTime: 30_000,
});
// Group docs by category for display
const grouped = (documents as PropertyDocument[]).reduce<
Record<string, PropertyDocument[]>
>((acc: Record<string, PropertyDocument[]>, doc: PropertyDocument) => {
const category = doc.docType.includes("XML")
? "XML Files"
: doc.docType.includes("DECENT_HOMES")
? "Decent Homes"
: "Survey Reports";
(acc[category] ??= []).push(doc);
return acc;
}, {});
const hasDocuments = documents.length > 0;
return (
<Drawer open={open} onOpenChange={(v) => !v && onClose()} direction="right">
<DrawerContent className="fixed right-0 top-0 bottom-0 h-full w-full max-w-[440px] rounded-l-2xl rounded-r-none mt-0 flex flex-col border-l border-t-0 border-b-0 border-r-0 border-brandblue/10 bg-white shadow-2xl">
{/* Remove the default drag handle */}
<div className="hidden" />
<DrawerHeader className="px-6 pt-6 pb-4 border-b border-gray-100">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0 pr-4">
<DrawerTitle className="text-lg font-semibold text-brandblue leading-tight truncate">
{dealname ?? "Property Documents"}
</DrawerTitle>
{uprn && (
<DrawerDescription className="text-xs text-gray-500 mt-0.5 font-mono">
UPRN: {uprn}
</DrawerDescription>
)}
</div>
<DrawerClose asChild>
<button
onClick={onClose}
className="shrink-0 p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
>
<X className="h-4 w-4" />
</button>
</DrawerClose>
</div>
{hasDocuments && !isLoading && (
<div className="mt-3 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-brandblue/10 border border-brandblue/20">
<FileDown className="h-3.5 w-3.5 text-brandblue" />
<span className="text-xs font-medium text-brandblue">
{documents.length} document{documents.length !== 1 ? "s" : ""}
</span>
</div>
)}
</DrawerHeader>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
{/* Loading state */}
{isLoading && (
<div className="space-y-3 pt-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-14 rounded-lg bg-gray-100 animate-pulse"
/>
))}
</div>
)}
{/* Error state */}
{isError && !isLoading && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-3">
<ExternalLink className="h-5 w-5 text-red-400" />
</div>
<p className="text-sm font-medium text-gray-700">
Could not load documents
</p>
<p className="text-xs text-gray-500 mt-1">
Please try again later.
</p>
</div>
)}
{/* Empty state */}
{!isLoading && !isError && !hasDocuments && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-12 h-12 rounded-full bg-gray-50 border border-gray-200 flex items-center justify-center mb-3">
<FolderOpen className="h-6 w-6 text-gray-400" />
</div>
<p className="text-sm font-medium text-gray-700">
No documents uploaded
</p>
<p className="text-xs text-gray-400 mt-1 max-w-[220px]">
Survey documents will appear here once uploaded for this
property.
</p>
</div>
)}
{/* Document groups */}
<AnimatePresence>
{!isLoading &&
!isError &&
hasDocuments &&
Object.entries(grouped).map(([category, docs]) => (
<motion.div
key={category}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-2"
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5">
{category}
</h3>
<div className="space-y-1.5">
{docs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} />
))}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50/50">
<p className="text-xs text-gray-400">
Download links expire after 30 minutes. Refresh to generate a new
link.
</p>
</div>
</DrawerContent>
</Drawer>
);
}

View file

@ -0,0 +1,378 @@
"use client";
import { useMemo, useState } from "react";
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
flexRender,
type SortingState,
type VisibilityState,
type PaginationState,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { Input } from "@/app/shadcn_components/ui/input";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/app/shadcn_components/ui/select";
import { Search, SlidersHorizontal, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { createPropertyTableColumns } from "./PropertyTableColumns";
import { STAGE_ORDER } from "./types";
import type { ClassifiedDeal, DisplayStage } from "./types";
// Human-readable labels for toggle dropdown
const COLUMN_LABELS: Record<string, string> = {
landlordPropertyId: "Property Ref",
uprn: "UPRN",
projectCode: "Project",
coordinator: "Coordinator",
designer: "Designer",
installer: "Installer",
proposedMeasures: "Proposed Measures",
approvedPackage: "Approved Package",
actualMeasuresInstalled: "Installed Measures",
preSapScore: "Pre-SAP",
postSapScore: "Post-SAP",
lodgementStatus: "Lodgement Status",
designDate: "Design Date",
fullLodgementDate: "Lodgement Date",
};
interface PropertyTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (uprn: string | null, dealname: string | null) => void;
showDocuments?: boolean;
}
const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [
{ key: "dealname", label: "Address" },
{ key: "landlordPropertyId", label: "Property Ref" },
{ key: "uprn", label: "UPRN" },
{ key: "displayStage", label: "Stage" },
{ key: "projectCode", label: "Project" },
{ key: "coordinator", label: "Coordinator" },
{ key: "designer", label: "Designer" },
{ key: "installer", label: "Installer" },
{ key: "proposedMeasures", label: "Proposed Measures" },
{ key: "approvedPackage", label: "Approved Package" },
{ key: "actualMeasuresInstalled", label: "Installed Measures" },
{ key: "preSapScore", label: "Pre-SAP" },
{ key: "lodgementStatus", label: "Lodgement Status" },
{ key: "designDate", label: "Design Date" },
{ key: "fullLodgementDate", label: "Lodgement Date" },
];
function escapeCell(value: unknown): string {
if (value === null || value === undefined) return "";
const str =
value instanceof Date
? value.toLocaleDateString("en-GB")
: String(value);
return str.includes(",") || str.includes('"') || str.includes("\n")
? `"${str.replace(/"/g, '""')}"`
: str;
}
export default function PropertyTable({ data, onOpenDrawer, showDocuments = false }: PropertyTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [stageFilter, setStageFilter] = useState<string>("all");
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
designer: false,
installer: false,
proposedMeasures: false,
approvedPackage: false,
actualMeasuresInstalled: false,
preSapScore: false,
postSapScore: false,
lodgementStatus: false,
designDate: false,
fullLodgementDate: false,
});
// Pre-filter by stage before TanStack gets it
const filteredData = useMemo(() => {
if (stageFilter === "all") return data;
return data.filter((d) => d.displayStage === stageFilter);
}, [data, stageFilter]);
const columns = useMemo(
() => createPropertyTableColumns(onOpenDrawer, showDocuments),
[onOpenDrawer, showDocuments]
);
const table = useReactTable({
data: filteredData,
columns,
state: {
globalFilter,
sorting,
pagination,
columnVisibility,
},
onGlobalFilterChange: setGlobalFilter,
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
globalFilterFn: "includesString",
});
const downloadCsv = () => {
const rows = table.getFilteredRowModel().rows;
const header = CSV_FIELDS.map((f) => f.label).join(",");
const body = rows
.map((row) =>
CSV_FIELDS.map((f) => escapeCell(row.original[f.key])).join(",")
)
.join("\n");
const blob = new Blob([header + "\n" + body], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "properties.csv";
a.click();
URL.revokeObjectURL(url);
};
const toggleableColumns = table
.getAllColumns()
.filter((col) => col.getCanHide() && COLUMN_LABELS[col.id]);
const pageCount = table.getPageCount();
const currentPage = table.getState().pagination.pageIndex + 1;
const totalFiltered = table.getFilteredRowModel().rows.length;
return (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
{/* Search */}
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
value={globalFilter}
onChange={(e) => {
setGlobalFilter(e.target.value);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
placeholder="Search address, UPRN, coordinator…"
className="pl-9 h-9 text-sm border-gray-200 focus:border-brandblue/40 focus:ring-brandblue/20"
/>
</div>
{/* Stage filter */}
<Select
value={stageFilter}
onValueChange={(v) => {
setStageFilter(v);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<SelectTrigger className="h-9 w-[180px] text-sm border-gray-200 shrink-0">
{stageFilter === "all"
? "All stages"
: stageFilter}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All stages</SelectItem>
{STAGE_ORDER.map((stage) => (
<SelectItem key={stage} value={stage}>
{stage}
</SelectItem>
))}
<SelectItem value="Queries">Queries</SelectItem>
</SelectContent>
</Select>
{/* Download CSV */}
<button
onClick={downloadCsv}
className="inline-flex items-center gap-2 h-9 px-3 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:border-brandblue/30 hover:text-brandblue transition-colors shrink-0"
>
<Download className="h-3.5 w-3.5" />
CSV
</button>
{/* Column visibility */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="inline-flex items-center gap-2 h-9 px-3 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:border-brandblue/30 hover:text-brandblue transition-colors shrink-0">
<SlidersHorizontal className="h-3.5 w-3.5" />
Columns
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs text-gray-500">
Toggle columns
</DropdownMenuLabel>
<DropdownMenuSeparator />
{toggleableColumns.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onCheckedChange={(val) => col.toggleVisibility(val)}
className="text-sm"
>
{COLUMN_LABELS[col.id] ?? col.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Result count */}
<p className="text-xs text-gray-400">
Showing{" "}
<span className="font-semibold text-gray-600">
{Math.min(
table.getState().pagination.pageSize,
totalFiltered - table.getState().pagination.pageIndex * table.getState().pagination.pageSize
)}
</span>{" "}
of{" "}
<span className="font-semibold text-gray-600">{totalFiltered}</span>{" "}
{stageFilter !== "all" ? `"${stageFilter}" ` : ""}
propert{totalFiltered === 1 ? "y" : "ies"}
</p>
{/* Table */}
<div className="rounded-xl border border-gray-200 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="bg-gray-50/80 hover:bg-gray-50/80 border-b border-gray-200"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="h-10 px-4 first:pl-5 last:pr-5"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row, i) => (
<TableRow
key={row.id}
className={`border-b border-gray-100 transition-colors hover:bg-brandlightblue/10 ${
i % 2 === 0 ? "bg-white" : "bg-gray-50/30"
}`}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-3 px-4 first:pl-5 last:pr-5"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-32 text-center text-sm text-gray-400"
>
No properties match the current filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{/* Pagination */}
{pageCount > 1 && (
<div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Rows per page:</span>
<Select
value={String(table.getState().pagination.pageSize)}
onValueChange={(v) =>
table.setPageSize(Number(v))
}
>
<SelectTrigger className="h-7 w-16 text-xs border-gray-200">
{table.getState().pagination.pageSize}
</SelectTrigger>
<SelectContent>
{[10, 25, 50, 100].map((n) => (
<SelectItem key={n} value={String(n)} className="text-xs">
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">
Page {currentPage} of {pageCount}
</span>
<div className="flex gap-1">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="h-7 w-7 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:border-brandblue/30 hover:text-brandblue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="h-7 w-7 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:border-brandblue/30 hover:text-brandblue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,310 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, FileDown } from "lucide-react";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal, DisplayStage } from "./types";
// -----------------------------------------------------------------------
// Stage badge — consistent pill rendering
// -----------------------------------------------------------------------
function StageBadge({ stage }: { stage: DisplayStage }) {
const c = STAGE_COLORS[stage] ?? STAGE_COLORS["Unknown Stage"];
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap ${c.bg} ${c.text} ${c.border}`}
>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${c.dot}`} />
{stage}
</span>
);
}
// Sortable column header helper
function SortableHeader({
label,
column,
}: {
label: string;
column: { toggleSorting: (desc: boolean) => void; getIsSorted: () => false | "asc" | "desc" };
}) {
return (
<button
className="flex items-center gap-1 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-brandblue transition-colors group"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{label}
<ArrowUpDown className="h-3 w-3 opacity-40 group-hover:opacity-70 transition-opacity" />
</button>
);
}
// -----------------------------------------------------------------------
// Column factory — takes onOpenDrawer so the Documents button can trigger it
// showDocuments controls whether the Docs action column is included
// -----------------------------------------------------------------------
export function createPropertyTableColumns(
onOpenDrawer: (uprn: string | null, dealname: string | null) => void,
showDocuments: boolean = false,
): ColumnDef<ClassifiedDeal>[] {
const columns: ColumnDef<ClassifiedDeal>[] = [
// ── Address ──────────────────────────────────────────────────────────
{
accessorKey: "dealname",
id: "dealname",
header: ({ column }) => <SortableHeader label="Address" column={column as any} />,
cell: ({ row }) => (
<div className="max-w-[220px]">
<p className="text-sm font-medium text-gray-900 leading-tight truncate">
{row.original.dealname ?? "—"}
</p>
</div>
),
enableHiding: false,
},
// ── Property ref ─────────────────────────────────────────────────────
{
accessorKey: "landlordPropertyId",
id: "landlordPropertyId",
header: ({ column }) => <SortableHeader label="Ref" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-500">
{row.original.landlordPropertyId ?? "—"}
</span>
),
},
// ── UPRN ─────────────────────────────────────────────────────────────
{
accessorKey: "uprn",
id: "uprn",
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">UPRN</span>
),
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-400">
{row.original.uprn ?? "—"}
</span>
),
},
// ── Stage badge ──────────────────────────────────────────────────────
{
accessorKey: "displayStage",
id: "displayStage",
header: ({ column }) => <SortableHeader label="Stage" column={column as any} />,
cell: ({ row }) => <StageBadge stage={row.original.displayStage} />,
filterFn: (row, _id, filterValue: string) =>
row.original.displayStage === filterValue,
enableHiding: false,
},
// ── Project code ─────────────────────────────────────────────────────
{
accessorKey: "projectCode",
id: "projectCode",
header: ({ column }) => <SortableHeader label="Project" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-medium text-gray-600 bg-gray-100 px-2 py-0.5 rounded">
{row.original.projectCode ?? "—"}
</span>
),
},
// ── Coordinator ──────────────────────────────────────────────────────
{
accessorKey: "coordinator",
id: "coordinator",
header: ({ column }) => <SortableHeader label="Coordinator" column={column as any} />,
cell: ({ row }) => (
<span className="text-sm text-gray-700">
{row.original.coordinator ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Designer ─────────────────────────────────────────────────────────
{
accessorKey: "designer",
id: "designer",
header: ({ column }) => <SortableHeader label="Designer" column={column as any} />,
cell: ({ row }) => (
<span className="text-sm text-gray-700">
{row.original.designer ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Installer ────────────────────────────────────────────────────────
{
accessorKey: "installer",
id: "installer",
header: ({ column }) => <SortableHeader label="Installer" column={column as any} />,
cell: ({ row }) => (
<span className="text-sm text-gray-700">
{row.original.installer ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Proposed measures ────────────────────────────────────────────────
{
accessorKey: "proposedMeasures",
id: "proposedMeasures",
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Proposed Measures
</span>
),
cell: ({ row }) => (
<span className="text-xs text-gray-600 max-w-[180px] line-clamp-2 leading-snug">
{row.original.proposedMeasures ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Approved package ─────────────────────────────────────────────────
{
accessorKey: "approvedPackage",
id: "approvedPackage",
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Approved Package
</span>
),
cell: ({ row }) => (
<span className="text-xs text-gray-600">
{row.original.approvedPackage ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Installed measures ───────────────────────────────────────────────
{
accessorKey: "actualMeasuresInstalled",
id: "actualMeasuresInstalled",
header: () => (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Installed
</span>
),
cell: ({ row }) => (
<span className="text-xs text-gray-600 max-w-[180px] line-clamp-2">
{row.original.actualMeasuresInstalled ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Pre-SAP score ────────────────────────────────────────────────────
{
accessorKey: "preSapScore",
id: "preSapScore",
header: ({ column }) => <SortableHeader label="Pre-SAP" column={column as any} />,
cell: ({ row }) => {
const score = row.original.preSapScore;
if (!score) return <span className="text-gray-300"></span>;
const n = Number(score);
const colour =
n < 30
? "text-red-600 bg-red-50"
: n < 50
? "text-amber-700 bg-amber-50"
: "text-emerald-700 bg-emerald-50";
return (
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${colour}`}>
{score}
</span>
);
},
},
// ── Post-SAP score ───────────────────────────────────────────────────
{
accessorKey: "postSapScore",
id: "postSapScore",
header: ({ column }) => <SortableHeader label="Post-SAP" column={column as any} />,
cell: ({ row }) => {
const score = row.original.postSapScore;
if (!score) return <span className="text-gray-300"></span>;
const n = Number(score);
const colour =
n >= 70
? "text-emerald-700 bg-emerald-50"
: n >= 50
? "text-sky-700 bg-sky-50"
: "text-amber-700 bg-amber-50";
return (
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${colour}`}>
{score}
</span>
);
},
},
// ── Lodgement status ─────────────────────────────────────────────────
{
accessorKey: "lodgementStatus",
id: "lodgementStatus",
header: ({ column }) => <SortableHeader label="Lodgement" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs text-gray-600">
{row.original.lodgementStatus ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Design date ──────────────────────────────────────────────────────
{
accessorKey: "designDate",
id: "designDate",
header: ({ column }) => <SortableHeader label="Design Date" column={column as any} />,
cell: ({ row }) => {
const d = row.original.designDate;
return (
<span className="text-xs text-gray-500">
{d ? new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "2-digit" }) : <span className="text-gray-300"></span>}
</span>
);
},
},
// ── Full lodgement date ──────────────────────────────────────────────
{
accessorKey: "fullLodgementDate",
id: "fullLodgementDate",
header: ({ column }) => <SortableHeader label="Lodgement Date" column={column as any} />,
cell: ({ row }) => {
const d = row.original.fullLodgementDate;
return (
<span className="text-xs text-gray-500">
{d ? new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "2-digit" }) : <span className="text-gray-300"></span>}
</span>
);
},
},
];
if (showDocuments) {
columns.push({
id: "documents",
header: () => null,
cell: ({ row }) => (
<button
onClick={() =>
onOpenDrawer(row.original.uprn, row.original.dealname)
}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-brandblue/20 text-brandblue bg-brandblue/5 hover:bg-brandblue/10 hover:border-brandblue/40 transition-all duration-150 whitespace-nowrap"
>
<FileDown className="h-3.5 w-3.5" />
Docs
</button>
),
enableSorting: false,
enableHiding: false,
});
}
return columns;
}

View file

@ -9,6 +9,9 @@ interface TableViewerProps {
columns?: (keyof HubspotDeal)[];
columnLabels?: Partial<Record<keyof HubspotDeal, string>>;
breakdown?: Record<string, ClassifiedDeal[]>;
title?: string;
description?: string;
reason?: string;
}
export default function TableViewer({
@ -16,16 +19,32 @@ export default function TableViewer({
columns,
columnLabels,
breakdown,
title,
description,
reason,
}: TableViewerProps) {
const [searchTerms, setSearchTerms] = useState<Record<string, string>>({});
const visibleColumns = columns?.length
? columns
// Always include key context columns if not present
const contextCols: (keyof HubspotDeal)[] = [
"outcomeNotes",
"majorConditionIssueDescription",
"coordinationStatus",
"designStatus",
];
let visibleColumns: (keyof HubspotDeal)[] = columns?.length
? [...columns]
: (Object.keys(data?.[0] || {}) as (keyof HubspotDeal)[]);
for (const col of contextCols) {
if (!visibleColumns.includes(col)) {
visibleColumns.push(col);
}
}
// Helper: Get category for a row based on breakdown
const getCategoryForRow = (
row: ClassifiedDeal,
brk: Record<string, ClassifiedDeal[]> | undefined
brk: Record<string, ClassifiedDeal[]> | undefined,
): string | undefined => {
if (!brk) return undefined;
for (const [category, items] of Object.entries(brk)) {
@ -77,9 +96,11 @@ export default function TableViewer({
visibleColumns.every((col) => {
const term = searchTerms[col]?.toLowerCase() || "";
if (!term) return true;
const value = String(row[col as keyof ClassifiedDeal] ?? "").toLowerCase();
const value = String(
row[col as keyof ClassifiedDeal] ?? "",
).toLowerCase();
return value.includes(term);
})
}),
);
// Inline sort derivation (no useMemo)
@ -152,11 +173,30 @@ export default function TableViewer({
return (
<div className="overflow-x-auto border border-brandblue/10 rounded-xl shadow-lg bg-white">
{/* Context header */}
{(title || description || reason) && (
<div className="px-6 pt-6 pb-2 border-b border-brandblue/10 bg-gradient-to-r from-brandblue/5 to-brandmidblue/5 rounded-t-xl">
{title && (
<h2 className="text-xl font-bold text-brandblue mb-1">{title}</h2>
)}
{description && (
<p className="text-sm text-gray-600 mb-1">{description}</p>
)}
{reason && (
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-amber-50 border border-amber-200 text-xs text-amber-700 font-medium mb-1">
{reason}
</div>
)}
</div>
)}
<table className="min-w-full text-sm border-collapse">
<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 as string} className="p-4 text-left font-bold text-brandblue">
<th
key={col as string}
className="p-4 text-left font-bold text-brandblue"
>
<div className="flex flex-col gap-2">
<span className="text-xs uppercase tracking-wide">
{columnLabels?.[col] || (col as string)}
@ -197,10 +237,7 @@ export default function TableViewer({
>
{visibleColumns.map((col) => (
<td key={col as string} className="p-4 text-gray-800">
{renderCellContent(
col,
row[col as keyof ClassifiedDeal]
)}
{renderCellContent(col, row[col as keyof ClassifiedDeal])}
</td>
))}
</tr>

View file

@ -1,11 +1,61 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { redirect } from "next/navigation";
import { eq } from "drizzle-orm";
import LiveTracker from "./LiveTracker";
import { computeLiveTrackerData } from "./transforms";
// 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";
import { db } from "@/app/db/db";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import type { HubspotDeal } from "./types";
import type { InferSelectModel } from "drizzle-orm";
// ⚠️ ⚠️ ⚠️ HARDCODED COMPANY ID — temporary for testing only.
// TODO: derive this from the portfolio slug once the portfolio↔company mapping exists.
// Do NOT ship this to production without replacing it with the real lookup.
const HARDCODED_COMPANY_ID = "86970043613";
type DbDeal = InferSelectModel<typeof hubspotDealData>;
function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal {
return {
id: row.id,
dealId: row.dealId,
dealname: row.dealname,
dealstage: row.dealstage,
companyId: row.companyId,
projectCode: row.projectCode,
landlordPropertyId: row.landlordPropertyId,
uprn: row.uprn,
outcome: row.outcome,
outcomeNotes: row.outcomeNotes,
majorConditionIssueDescription: row.majorConditionIssueDescription,
majorConditionIssuePhotos: row.majorConditionIssuePhotos,
majorConditionIssuePhotosS3: row.majorConditionIssuePhotosS3,
coordinationStatus: row.coordinationStatus,
designStatus: row.designStatus,
pashubLink: row.pashubLink,
sharepointLink: row.sharepointLink,
dampMouldFlag: row.dampmouldGrowth, // DB: dampmouldGrowth
preSapScore: row.preSap, // DB: preSap
coordinator: row.coordinator,
ioeV1Date: row.mtpCompletionDate, // DB: mtpCompletionDate
ioeV2Date: row.mtpReModelCompletionDate, // DB: mtpReModelCompletionDate
ioeV3Date: row.ioeV3CompletionDate, // DB: ioeV3CompletionDate
proposedMeasures: row.proposedMeasures,
approvedPackage: row.approvedPackage,
designer: row.designer,
designDate: row.designCompletionDate, // DB: designCompletionDate
actualMeasuresInstalled: row.actualMeasuresInstalled,
installer: row.installer,
installerHandover: row.installerHandover,
lodgementStatus: row.lodgementStatus,
measuresLodgementDate: row.measuresLodgementDate,
fullLodgementDate: row.lodgementDate, // DB: lodgementDate
confirmedSurveyDate: row.confirmedSurveyDate,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export default async function LiveReportingPage(props: {
params: Promise<{ slug: string }>;
@ -18,13 +68,17 @@ export default async function LiveReportingPage(props: {
redirect("/");
}
// 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
// ⚠️ Using HARDCODED_COMPANY_ID — see constant above before deploying
const rawDeals = await db
.select()
.from(hubspotDealData)
.where(eq(hubspotDealData.companyId, HARDCODED_COMPANY_ID));
console.log("Fetched deals from DB:", rawDeals.length);
const trackerData = computeLiveTrackerData(
rawDeals.map(mapDbRowToHubspotDeal),
);
return (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
@ -42,5 +96,3 @@ export default async function LiveReportingPage(props: {
</div>
);
}

View file

@ -398,6 +398,16 @@ export function computeLiveTrackerData(
})
);
// When there are multiple project codes, prepend a synthetic "All Projects" entry
if (projects.length > 1) {
projects.unshift({
projectCode: "__ALL__",
progress: computeProjectProgress(classified),
outcomePieSlices: computeOutcomeSlices(classified),
allDeals: classified,
});
}
return {
projects,
totalDeals: classified.length,

View file

@ -40,20 +40,10 @@ export type HubspotDeal = {
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;
confirmedSurveyDate: Date | null;
createdAt: Date;
updatedAt: Date;