mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
consolidated ui in live project tracking and adding graphs
Some checks are pending
Next.js Build Check / build (push) Waiting to run
Some checks are pending
Next.js Build Check / build (push) Waiting to run
This commit is contained in:
parent
224fcc8ab8
commit
cc5e21727f
13 changed files with 2176 additions and 394 deletions
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue