adding portfolio organisation (wip_

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-04 13:35:50 +00:00
parent 988bd2ce8f
commit ac30e3c13a
10 changed files with 314 additions and 230 deletions

View file

@ -12,6 +12,7 @@ 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";
import * as UploadedFilesSchema from "@/app/db/schema/uploaded_files";
import * as PortfolioOrgSchema from "@/app/db/schema/portfolio_organisation";
export const pool = new Pool({
host: process.env.DB_HOST,
@ -35,6 +36,7 @@ const schema = {
...Users,
...CrmSchema,
...UploadedFilesSchema,
...PortfolioOrgSchema,
};
export const db = drizzle(pool, {

View file

@ -1142,6 +1142,13 @@
"when": 1775041844023,
"tag": "0162_powerful_paladin",
"breakpoints": true
},
{
"idx": 163,
"version": "7",
"when": 1775309608582,
"tag": "0163_fat_mentallo",
"breakpoints": true
}
]
}

View file

@ -35,6 +35,7 @@ import { PortfolioGoal as PortfolioGoalOptions } from "@/app/db/schema/portfolio
import { useSession } from "next-auth/react";
import PortfolioPlanTable from "@/app/components/portfolio/measures/PlanTable";
import { UsersPermissionsCard } from "./UsersPermissionsCard";
import OrganisationLinkCard from "./OrganisationLinkCard";
// dropdown selection component for both goal and status
@ -215,9 +216,11 @@ async function deletePortfolio({
export default function PortfolioSettings({
portfolioId,
portfolioSettingsData,
isDomnaUser = false,
}: {
portfolioId: string;
portfolioSettingsData: PortfolioSettingsType;
isDomnaUser?: boolean;
}) {
// This is a client component so we can access the session directly
const session = useSession();
@ -475,6 +478,7 @@ export default function PortfolioSettings({
</Table>
</div>
<UsersPermissionsCard portfolioId={portfolioId} />
{isDomnaUser && <OrganisationLinkCard portfolioId={portfolioId} />}
<div className="rounded-md border border-red-500 mt-2">
<Table>
<TableHeader>

View file

@ -1,3 +1,5 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getPortfolioSettings } from "../../utils";
import PortfolioSettings from "./PortfolioSettings";
@ -8,7 +10,13 @@ export default async function PortfolioSettingsPage(
) {
const params = await props.params;
const portfolioId = params.slug;
const portfolioSettingsData = await getPortfolioSettings(portfolioId);
const [portfolioSettingsData, session] = await Promise.all([
getPortfolioSettings(portfolioId),
getServerSession(AuthOptions),
]);
const isDomnaUser = !!session?.user?.email?.endsWith("@domna.homes");
return (
<>
@ -16,6 +24,7 @@ export default async function PortfolioSettingsPage(
<PortfolioSettings
portfolioId={portfolioId}
portfolioSettingsData={portfolioSettingsData}
isDomnaUser={isDomnaUser}
/>
</div>
</>

View file

@ -95,6 +95,66 @@ function StatCard({
);
}
// -----------------------------------------------------------------------
// Per-stage column config for the drill-down table
// -----------------------------------------------------------------------
type StageTableConfig = {
cols: (keyof ClassifiedDeal)[];
labels: Partial<Record<keyof ClassifiedDeal, string>>;
};
const STAGE_TABLE_CONFIG: Record<string, StageTableConfig> = {
"Booking in Progress": {
cols: ["dealname", "landlordPropertyId", "confirmedSurveyDate", "ioeV1Date"],
labels: {
dealname: "Address",
landlordPropertyId: "Ref",
confirmedSurveyDate: "Confirmed Survey Date",
ioeV1Date: "Expected Commencement",
},
},
"Assessment in Progress": {
cols: ["dealname", "landlordPropertyId", "confirmedSurveyDate", "ioeV1Date", "outcome", "coordinator"],
labels: {
dealname: "Address",
landlordPropertyId: "Ref",
confirmedSurveyDate: "Confirmed Survey Date",
ioeV1Date: "Expected Commencement",
outcome: "Outcome",
coordinator: "Surveyor",
},
},
"Coordination in Progress": {
cols: ["dealname", "landlordPropertyId", "coordinator", "preSapScore", "coordinationStatus"],
labels: {
dealname: "Address",
landlordPropertyId: "Ref",
coordinator: "Coordinator",
preSapScore: "Pre-SAP Score",
coordinationStatus: "Coordination Status",
},
},
"Design in Progress": {
cols: ["dealname", "landlordPropertyId", "designer", "proposedMeasures", "designType"],
labels: {
dealname: "Address",
landlordPropertyId: "Ref",
designer: "Designer",
proposedMeasures: "Proposed Measures",
designType: "Design Type",
},
},
_default: {
cols: ["dealname", "landlordPropertyId", "displayStage", "installer"],
labels: {
dealname: "Address",
landlordPropertyId: "Ref",
displayStage: "Stage",
installer: "Installer",
},
},
};
// -----------------------------------------------------------------------
// Pipeline Funnel — rich card rows
// -----------------------------------------------------------------------
@ -116,7 +176,7 @@ function PipelineFunnel({
reason?: string,
) => void;
}) {
const [mode, setMode] = useState<"current" | "cumulative">("cumulative");
const [mode, setMode] = useState<"current" | "cumulative">("current");
const ALWAYS_VISIBLE: DisplayStage[] = ["At Lodgement", "Project Complete"];
const visibleStages = funnelStages.filter(
@ -180,34 +240,10 @@ function PipelineFunnel({
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,
)
}
onClick={() => {
const { cols, labels } = STAGE_TABLE_CONFIG[s.stage] ?? STAGE_TABLE_CONFIG._default;
onOpenTable(`Pipeline — ${s.stage}`, deals, cols, labels);
}}
className={`w-full text-left rounded-xl border ${c.border} ${c.bg} p-4 shadow-sm hover:shadow-md transition-shadow`}
type="button"
>
@ -339,8 +375,6 @@ export default function AnalyticsView({
{/* Row 1.5: Completion trends chart */}
<CompletionTrendsChart
deals={currentProject.allDeals}
isDomnaUser={true}
projectCode={currentProjectCode}
onOpenTable={onOpenTable}
/>

View file

@ -2,9 +2,18 @@
import { useState } from "react";
import { AlertCircle } from "lucide-react";
import { Card, Title, BarChart, Legend } from "@tremor/react";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import { Card, Title } from "@tremor/react";
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
LabelList,
ResponsiveContainer,
Legend as RechartsLegend,
} from "recharts";
import {
Select,
SelectContent,
@ -15,7 +24,6 @@ import type { ClassifiedDeal } from "./types";
interface CompletionTrendsChartProps {
deals: ClassifiedDeal[];
isDomnaUser?: boolean;
projectCode?: string;
onOpenTable?: (
stage: string,
@ -45,13 +53,13 @@ const METRICS = [
},
];
// Brand colour palette (hex values work directly with Tremor v3 colors prop)
// Brand colour palette
const C = {
blue: "#5d6be0", // lighter periwinkle blue
midblue: "#3943b7", // brandmidblue
lightblue: "#8b96e9", // soft pale blue for 3rd series
paleblue: "#b8bef4", // 4th series — replaces brown in bar charts
brown: "#c4a47c", // accent only — trend/target lines
blue: "#5d6be0",
midblue: "#3943b7",
lightblue: "#8b96e9",
paleblue: "#b8bef4",
brown: "#c4a47c",
};
function ChartTooltip({
@ -64,10 +72,13 @@ function ChartTooltip({
label?: string;
}) {
if (!active || !payload?.length) return null;
// Filter out the internal _total key
const visible = payload.filter((p) => p.name !== "_total");
if (!visible.length) return null;
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-lg px-3 py-2 text-sm min-w-[140px]">
<p className="font-semibold text-gray-700 mb-1.5 border-b border-gray-100 pb-1">{label}</p>
{payload.map((item, i) => (
{visible.map((item, i) => (
<div key={i} className="flex items-center justify-between gap-3 py-0.5">
<div className="flex items-center gap-1.5">
<span
@ -97,13 +108,12 @@ const DESIGN_TYPE_ORDER = [
"Standard (Simple)",
];
// Returns the Monday of the week containing `date`, as an ISO date string key
function getMondayOfWeek(date: Date): string {
const d = new Date(date);
const day = d.getDay(); // 0=Sun … 6=Sat
const day = d.getDay();
d.setDate(d.getDate() - (day === 0 ? 6 : day - 1));
d.setHours(0, 0, 0, 0);
return d.toISOString().split("T")[0]; // "2026-03-30"
return d.toISOString().split("T")[0];
}
function formatMonday(isoDate: string): string {
@ -114,7 +124,6 @@ function formatMonday(isoDate: string): string {
});
}
// Fills all missing Monday ISO-date keys between min and max
function fillWeekGaps(keys: string[]): string[] {
if (keys.length === 0) return [];
const sorted = [...keys].sort();
@ -136,10 +145,7 @@ function aggregateByWeek(
const weekCounts: Record<string, number> = {};
for (const deal of deals) {
if (filter && !filter(deal)) continue;
const date = deal[dateField as keyof ClassifiedDeal] as
| string
| Date
| null;
const date = deal[dateField as keyof ClassifiedDeal] as string | Date | null;
if (!date) continue;
const d = new Date(date);
if (isNaN(d.getTime())) continue;
@ -153,16 +159,14 @@ function aggregateByWeek(
}));
}
// Coordination: buckets V1 and V2 completions separately
function aggregateCoordinationByWeek(
deals: ClassifiedDeal[],
): Array<{ week: string; "V1 (MTP)": number; "V2 (Re-model)": number }> {
): Array<{ week: string; "V1 (MTP)": number; "V2 (Re-model)": number; _total: 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())) {
@ -170,7 +174,6 @@ function aggregateCoordinationByWeek(
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())) {
@ -184,17 +187,16 @@ function aggregateCoordinationByWeek(
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,
}));
return allKeys.map((isoKey) => {
const v1 = v1Counts[isoKey] ?? 0;
const v2 = v2Counts[isoKey] ?? 0;
return { week: formatMonday(isoKey), "V1 (MTP)": v1, "V2 (Re-model)": v2, _total: v1 + v2 };
});
}
// Assessments: Retrofit Assessment vs EPC, keyed by surveyedDate
function aggregateAssessmentsByWeek(
deals: ClassifiedDeal[],
): Array<{ week: string; "Retrofit Assessment": number; EPC: number }> {
): Array<{ week: string; "Retrofit Assessment": number; EPC: number; _total: number }> {
const retrofitCounts: Record<string, number> = {};
const epcCounts: Record<string, number> = {};
@ -204,32 +206,27 @@ function aggregateAssessmentsByWeek(
const isEpc = o === "EPC Completed";
if (!isRetrofit && !isEpc) continue;
if (!deal.surveyedDate) continue;
const d = new Date(deal.surveyedDate);
if (isNaN(d.getTime())) continue;
const key = getMondayOfWeek(d);
if (isRetrofit) retrofitCounts[key] = (retrofitCounts[key] || 0) + 1;
if (isEpc) epcCounts[key] = (epcCounts[key] || 0) + 1;
}
const allKeys = fillWeekGaps(
Array.from(
new Set([...Object.keys(retrofitCounts), ...Object.keys(epcCounts)]),
),
Array.from(new Set([...Object.keys(retrofitCounts), ...Object.keys(epcCounts)])),
);
return allKeys.map((isoKey) => ({
week: formatMonday(isoKey),
"Retrofit Assessment": retrofitCounts[isoKey] ?? 0,
EPC: epcCounts[isoKey] ?? 0,
}));
return allKeys.map((isoKey) => {
const r = retrofitCounts[isoKey] ?? 0;
const e = epcCounts[isoKey] ?? 0;
return { week: formatMonday(isoKey), "Retrofit Assessment": r, EPC: e, _total: r + e };
});
}
// Lodgements: Stage 1 vs Lodged Measures
function aggregateLodgementsByWeek(
deals: ClassifiedDeal[],
): Array<{ week: string; "Stage 1 Lodgement": number; "Lodged Measures": number }> {
): Array<{ week: string; "Stage 1 Lodgement": number; "Lodged Measures": number; _total: number }> {
const stageCounts: Record<string, number> = {};
const measuresCounts: Record<string, number> = {};
@ -251,19 +248,16 @@ function aggregateLodgementsByWeek(
}
const allKeys = fillWeekGaps(
Array.from(
new Set([...Object.keys(stageCounts), ...Object.keys(measuresCounts)]),
),
Array.from(new Set([...Object.keys(stageCounts), ...Object.keys(measuresCounts)])),
);
return allKeys.map((isoKey) => ({
week: formatMonday(isoKey),
"Stage 1 Lodgement": stageCounts[isoKey] ?? 0,
"Lodged Measures": measuresCounts[isoKey] ?? 0,
}));
return allKeys.map((isoKey) => {
const s = stageCounts[isoKey] ?? 0;
const m = measuresCounts[isoKey] ?? 0;
return { week: formatMonday(isoKey), "Stage 1 Lodgement": s, "Lodged Measures": m, _total: s + m };
});
}
// Designs: stacked by design type (Uploaded only)
function aggregateDesignsByWeek(
deals: ClassifiedDeal[],
): Array<Record<string, string | number>> {
@ -275,10 +269,8 @@ function aggregateDesignsByWeek(
const d = new Date(deal.designDate);
if (isNaN(d.getTime())) continue;
const key = getMondayOfWeek(d);
const rawType = deal.designType ?? "Unknown";
const label = DESIGN_TYPE_LABELS[rawType] ?? rawType;
if (!counts[key]) counts[key] = {};
counts[key][label] = (counts[key][label] || 0) + 1;
}
@ -286,25 +278,34 @@ function aggregateDesignsByWeek(
const allKeys = fillWeekGaps(Object.keys(counts));
return allKeys.map((isoKey) => {
const entry: Record<string, string | number> = { week: formatMonday(isoKey) };
let total = 0;
for (const label of DESIGN_TYPE_ORDER) {
entry[label] = counts[isoKey]?.[label] ?? 0;
const v = counts[isoKey]?.[label] ?? 0;
entry[label] = v;
total += v;
}
entry._total = total;
return entry;
});
}
// Compute total completed count for metrics that support it
function computeTotalCompleted(
metric: string,
chartData: Record<string, string | number>[],
categories: string[],
): number | null {
if (!["bookings", "assessments", "coordination", "design"].includes(metric)) return null;
return chartData.reduce((sum, row) => {
return sum + categories.reduce((s, cat) => s + ((row[cat] as number) || 0), 0);
}, 0);
}
export default function CompletionTrendsChart({
deals,
isDomnaUser,
projectCode,
onOpenTable,
}: CompletionTrendsChartProps) {
const [metric, setMetric] = useState(METRICS[0].key);
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";
@ -313,18 +314,15 @@ export default function CompletionTrendsChart({
const isDesign = metric === "design";
const isStacked = isCoordination || isAssessments || isLodgement || isDesign;
// Assessments from external surveyors (no surveyedDate recorded)
// External assessments with no date
const undatedAssessments = isAssessments
? deals.filter((d) => {
const o = d.outcome ?? "";
return (
(o === "Surveyed" || o === "Surveyed - Pending Upload") &&
!d.surveyedDate
);
return (o === "Surveyed" || o === "Surveyed - Pending Upload") && !d.surveyedDate;
})
: [];
// Compute chart data, categories, and colours in one place
// Build chart data
let chartData: Record<string, string | number>[];
let categories: string[];
let colors: string[];
@ -350,33 +348,32 @@ export default function CompletionTrendsChart({
chartData = singleData.map((d) => ({
week: d.week,
[selectedMetric.label]: d.value,
...(targets[d.week] !== undefined ? { Target: targets[d.week] } : {}),
}));
categories = [selectedMetric.label, ...(isDomnaUser ? ["Target"] : [])];
colors = [C.blue, C.brown];
categories = [selectedMetric.label];
colors = [C.blue];
}
const handleAddTarget = () => {
if (!targetInput.week || !targetInput.value) return;
setTargets((prev) => ({
...prev,
[targetInput.week]: Number(targetInput.value),
}));
setTargetInput({ week: "", value: "" });
};
const totalCompleted = computeTotalCompleted(metric, chartData, categories);
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">
{/* Header row */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-4">
<div className="flex flex-col gap-2">
<Title className="text-brandblue text-lg font-bold">
Trends Over Time
</Title>
<p className="text-xs text-gray-500">
Switch between metrics to see weekly trends.
</p>
{totalCompleted !== null && (
<div className="inline-flex items-center gap-2 self-start px-3 py-1.5 rounded-full bg-gradient-to-r from-brandmidblue/10 to-brandlightblue/50 border border-brandblue/20 shadow-sm">
<span className="text-brandmidblue font-bold text-base leading-none" suppressHydrationWarning>{totalCompleted}</span>
<span className="text-xs text-brandblue font-medium">
{metric === "bookings" ? "booked to date" : "completed to date"}
</span>
<span className="text-brandmidblue text-xs leading-none"></span>
</div>
)}
</div>
<div className="flex gap-2 items-end">
<div className="flex gap-2 items-start">
<Select value={metric} onValueChange={setMetric}>
<SelectTrigger className="w-56 h-9 text-sm border-gray-200">
{METRICS.find((m) => m.key === metric)?.label}
@ -392,57 +389,14 @@ export default function CompletionTrendsChart({
</div>
</div>
{isDomnaUser && !isStacked && (
<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>
)}
<BarChart
data={chartData}
index="week"
categories={categories}
colors={colors}
yAxisWidth={40}
className="h-72"
showLegend={false}
showGridLines={true}
stack={isStacked}
customTooltip={ChartTooltip}
/>
{/* Undated external assessments — shown above the chart */}
{isAssessments && undatedAssessments.length > 0 && (
<div className="mt-4 flex items-center justify-between gap-3 p-3 rounded-lg border border-amber-200 bg-amber-50/60">
<div className="mb-4 flex items-center justify-between gap-3 p-3 rounded-lg border border-amber-200 bg-amber-50/60">
<div className="flex items-center gap-2 min-w-0">
<AlertCircle className="h-4 w-4 text-amber-500 shrink-0" />
<span className="text-sm text-amber-700">
<span className="font-semibold">{undatedAssessments.length}</span>{" "}
assessment{undatedAssessments.length !== 1 ? "s" : ""} from external surveyors have no date recorded
external assessment{undatedAssessments.length !== 1 ? "s" : ""} have no date recorded
</span>
</div>
{onOpenTable && (
@ -466,11 +420,59 @@ export default function CompletionTrendsChart({
)}
</div>
)}
{/* Chart */}
<ResponsiveContainer width="100%" height={288}>
<RechartsBarChart data={chartData} margin={{ top: 20, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis
dataKey="week"
tick={{ fontSize: 10, fill: "#9ca3af" }}
axisLine={false}
tickLine={false}
/>
<YAxis
width={36}
tick={{ fontSize: 10, fill: "#9ca3af" }}
axisLine={false}
tickLine={false}
allowDecimals={false}
/>
<Tooltip content={<ChartTooltip />} cursor={{ fill: "rgba(89,107,224,0.06)" }} />
{categories.map((cat, i) => (
<Bar
key={cat}
dataKey={cat}
stackId={isStacked ? "stack" : undefined}
fill={colors[i]}
radius={i === categories.length - 1 || !isStacked ? [3, 3, 0, 0] : [0, 0, 0, 0]}
>
{/* For stacked bars: show total on the top (last) bar only via _total.
For non-stacked: show each bar's own value. */}
{i === categories.length - 1 && (
<LabelList
dataKey={isStacked ? "_total" : cat}
position="top"
style={{ fontSize: 10, fill: "#6b7280", fontWeight: 500 }}
formatter={(v: number) => (v === 0 ? "" : v)}
/>
)}
</Bar>
))}
</RechartsBarChart>
</ResponsiveContainer>
{/* Legend for stacked charts */}
{isStacked && (
<Legend
categories={categories}
colors={colors}
className="mt-4"
<RechartsLegend
wrapperStyle={{ paddingTop: "12px", fontSize: "12px", color: "#6b7280" }}
iconType="square"
iconSize={10}
payload={categories.map((cat, i) => ({
value: cat,
type: "square" as const,
color: colors[i],
}))}
/>
)}
</Card>

View file

@ -15,6 +15,7 @@ import PropertyTable from "./PropertyTable";
import DocumentTable from "./DocumentTable";
import type { HubspotDeal } from "./types";
import PropertyDrawer from "./PropertyDrawer";
import PropertyDetailDrawer from "./PropertyDetailDrawer";
import AnalyticsView from "./AnalyticsView";
import type {
LiveTrackerProps,
@ -53,6 +54,9 @@ export default function LiveTracker({
dealname: null,
});
// ── Property detail drawer ───────────────────────────────────────────
const [detailDeal, setDetailDeal] = useState<ClassifiedDeal | null>(null);
const handleOpenTable = (
stage: string,
filteredDeals: ClassifiedDeal[],
@ -166,6 +170,7 @@ export default function LiveTracker({
<PropertyTable
data={currentProject?.allDeals ?? []}
onOpenDrawer={handleOpenDrawer}
onOpenDetail={setDetailDeal}
docStatusMap={docStatusMap}
/>
</div>
@ -302,6 +307,12 @@ export default function LiveTracker({
setDrawerState({ open: false, uprn: null, landlordPropertyId: null, dealname: null })
}
/>
{/* ── Property detail drawer ─────────────────────────────────────── */}
<PropertyDetailDrawer
deal={detailDeal}
onClose={() => setDetailDeal(null)}
/>
</div>
);
}

View file

@ -38,7 +38,7 @@ import {
import { Search, SlidersHorizontal, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { createPropertyTableColumns } from "./PropertyTableColumns";
import { STAGE_ORDER } from "./types";
import type { ClassifiedDeal, DisplayStage, DocStatusMap } from "./types";
import type { ClassifiedDeal, DocStatusMap } from "./types";
// Human-readable labels for toggle dropdown
const COLUMN_LABELS: Record<string, string> = {
@ -52,7 +52,6 @@ const COLUMN_LABELS: Record<string, string> = {
approvedPackage: "Approved Package",
actualMeasuresInstalled: "Installed Measures",
preSapScore: "Pre-SAP",
postSapScore: "Post-SAP",
lodgementStatus: "Lodgement Status",
designDate: "Design Date",
fullLodgementDate: "Lodgement Date",
@ -63,6 +62,7 @@ type DocFilter = "all" | "has_docs" | "incomplete" | "none";
interface PropertyTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
onOpenDetail?: (deal: ClassifiedDeal) => void;
showDocuments?: boolean;
docStatusMap?: DocStatusMap;
}
@ -96,7 +96,7 @@ function escapeCell(value: unknown): string {
: str;
}
export default function PropertyTable({ data, onOpenDrawer, showDocuments = false, docStatusMap = {} }: PropertyTableProps) {
export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDocuments = false, docStatusMap = {} }: PropertyTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [stageFilter, setStageFilter] = useState<string>("all");
const [docFilter, setDocFilter] = useState<DocFilter>("all");
@ -112,7 +112,6 @@ export default function PropertyTable({ data, onOpenDrawer, showDocuments = fals
approvedPackage: false,
actualMeasuresInstalled: false,
preSapScore: false,
postSapScore: false,
lodgementStatus: false,
designDate: false,
fullLodgementDate: false,
@ -137,8 +136,8 @@ export default function PropertyTable({ data, onOpenDrawer, showDocuments = fals
}, [data, stageFilter, docFilter, docStatusMap]);
const columns = useMemo(
() => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap),
[onOpenDrawer, showDocuments, docStatusMap]
() => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap, onOpenDetail),
[onOpenDrawer, showDocuments, docStatusMap, onOpenDetail]
);
const table = useReactTable({

View file

@ -1,7 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, FileDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal, DisplayStage, DocStatusMap } from "./types";
@ -48,6 +48,7 @@ export function createPropertyTableColumns(
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
showDocuments: boolean = false,
docStatusMap: DocStatusMap = {},
onOpenDetail?: (deal: ClassifiedDeal) => void,
): ColumnDef<ClassifiedDeal>[] {
const columns: ColumnDef<ClassifiedDeal>[] = [
// ── Address ──────────────────────────────────────────────────────────
@ -57,9 +58,18 @@ export function createPropertyTableColumns(
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>
{onOpenDetail ? (
<button
onClick={() => onOpenDetail(row.original)}
className="text-sm font-medium text-brandblue hover:text-brandmidblue hover:underline underline-offset-2 leading-tight text-left truncate w-full transition-colors"
>
{row.original.dealname ?? "—"}
</button>
) : (
<p className="text-sm font-medium text-gray-900 leading-tight truncate">
{row.original.dealname ?? "—"}
</p>
)}
</div>
),
enableHiding: false,
@ -221,28 +231,6 @@ export function createPropertyTableColumns(
},
},
// ── 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 ─────────────────────────────────────────────────
{

View file

@ -7,14 +7,13 @@ import { computeLiveTrackerData } from "./transforms";
import { db } from "@/app/db/db";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
import { organisation } from "@/app/db/schema/organisation";
import type { HubspotDeal, DocStatusMap, DocStatus } from "./types";
import { EXPECTED_SURVEY_DOC_TYPES } 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";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { Building2 } from "lucide-react";
type DbDeal = InferSelectModel<typeof hubspotDealData>;
@ -37,25 +36,25 @@ function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal {
designStatus: row.designStatus,
pashubLink: row.pashubLink,
sharepointLink: row.sharepointLink,
dampMouldFlag: row.dampmouldGrowth, // DB: dampmouldGrowth
preSapScore: row.preSap, // DB: preSap
dampMouldFlag: row.dampmouldGrowth,
preSapScore: row.preSap,
coordinator: row.coordinator,
ioeV1Date: row.mtpCompletionDate, // DB: mtpCompletionDate
ioeV2Date: row.mtpReModelCompletionDate, // DB: mtpReModelCompletionDate
ioeV3Date: row.ioeV3CompletionDate, // DB: ioeV3CompletionDate
ioeV1Date: row.mtpCompletionDate,
ioeV2Date: row.mtpReModelCompletionDate,
ioeV3Date: row.ioeV3CompletionDate,
proposedMeasures: row.proposedMeasures,
approvedPackage: row.approvedPackage,
designer: row.designer,
designDate: row.designCompletionDate, // DB: designCompletionDate
designDate: row.designCompletionDate,
actualMeasuresInstalled: row.actualMeasuresInstalled,
installer: row.installer,
installerHandover: row.installerHandover,
lodgementStatus: row.lodgementStatus,
measuresLodgementDate: row.measuresLodgementDate,
fullLodgementDate: row.lodgementDate, // DB: lodgementDate
fullLodgementDate: row.lodgementDate,
confirmedSurveyDate: row.confirmedSurveyDate,
surveyedDate: row.SurveyedDate, // DB: surveyed_date
designType: row.dealType, // DB: design_type
surveyedDate: row.SurveyedDate,
designType: row.dealType,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@ -64,26 +63,64 @@ function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal {
export default async function LiveReportingPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug: _portfolioId } = await props.params;
const { slug: portfolioId } = await props.params;
const user = await getServerSession(AuthOptions);
if (!user?.user) {
console.error("User not found");
redirect("/");
}
// ⚠️ Using HARDCODED_COMPANY_ID — see constant above before deploying
// Look up the linked organisation for this portfolio
const link = await db
.select({ hubspotCompanyId: organisation.hubspotCompanyId })
.from(portfolioOrganisation)
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
.limit(1);
const pageHeader = (
<div className="mb-6">
<header className="text-3xl font-semibold text-brandblue">Live Projects</header>
<p className="text-sm text-gray-500">
{`Check in on your projects' progress with real-time data updates.`}
</p>
<div className="h-px bg-gray-200 mt-2" />
</div>
);
if (!link.length || !link[0].hubspotCompanyId) {
return (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
{pageHeader}
<Card className="border border-brandblue/10 shadow-sm">
<CardContent className="flex flex-col items-center justify-center py-16 text-center gap-4">
<div className="p-4 rounded-full bg-brandlightblue/40 border border-brandblue/10">
<Building2 className="h-8 w-8 text-brandblue/50" />
</div>
<div>
<p className="text-base font-semibold text-gray-700">No organisation linked</p>
<p className="text-sm text-gray-400 mt-1 max-w-sm">
A Domna administrator needs to connect this portfolio to an organisation in{" "}
<strong>Portfolio Settings</strong> before live tracking data can be displayed.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
const companyId = link[0].hubspotCompanyId;
const rawDeals = await db
.select()
.from(hubspotDealData)
.where(eq(hubspotDealData.companyId, HARDCODED_COMPANY_ID));
console.log("Fetched deals from DB:", rawDeals.length);
.where(eq(hubspotDealData.companyId, companyId));
const deals = rawDeals.map(mapDbRowToHubspotDeal);
const trackerData = computeLiveTrackerData(deals);
// ── Fetch survey document status for all properties ─────────────────
// Fetch survey document status for all properties
const uprnList = deals
.map((d) => d.uprn)
.filter((u): u is string => !!u)
@ -120,16 +157,7 @@ export default async function LiveReportingPage(props: {
return (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
<div className="mb-6">
<header className="text-3xl font-semibold text-brandblue">
Live Projects
</header>
<p className="text-sm text-gray-500">
{`Check in on your projects' progress with real-time data updates.`}
</p>
<div className="h-px bg-gray-200 mt-2" />
</div>
{pageHeader}
<LiveTracker {...trackerData} docStatusMap={docStatusMap} />
</div>
);