mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
adding portfolio organisation (wip_
This commit is contained in:
parent
988bd2ce8f
commit
ac30e3c13a
10 changed files with 314 additions and 230 deletions
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -1142,6 +1142,13 @@
|
|||
"when": 1775041844023,
|
||||
"tag": "0162_powerful_paladin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 163,
|
||||
"version": "7",
|
||||
"when": 1775309608582,
|
||||
"tag": "0163_fat_mentallo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue