From 8325cb6206d6aeebc1137677a3651bc219518344 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 30 Oct 2025 17:15:30 +0000 Subject: [PATCH 01/20] added files for reports --- .db-env | 2 + .devcontainer/docker-compose.yml | 13 +- package-lock.json | 4 +- package.json | 4 +- .../db/schema/crm/hubspot_company_table.ts | 18 +++ src/app/db/schema/crm/hubspot_deal_table.ts | 27 ++++ .../(portfolio)/reports/DealStageChart.tsx | 122 +++++++++++++++++ .../[slug]/(portfolio)/reports/Report.tsx | 123 ++++++++++++++++++ .../reports/SurveyedResultsPieChart.tsx | 79 +++++++++++ .../(portfolio)/reports/TableViewer.tsx | 79 +++++++++++ .../[slug]/(portfolio)/reports/page.tsx | 34 +++++ .../building-passport/[propertyId]/layout.tsx | 2 + src/lib/utils.ts | 11 ++ 13 files changed, 513 insertions(+), 5 deletions(-) create mode 100644 .db-env create mode 100644 src/app/db/schema/crm/hubspot_company_table.ts create mode 100644 src/app/db/schema/crm/hubspot_deal_table.ts create mode 100644 src/app/portfolio/[slug]/(portfolio)/reports/DealStageChart.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/reports/Report.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/reports/SurveyedResultsPieChart.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/reports/TableViewer.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/reports/page.tsx diff --git a/.db-env b/.db-env new file mode 100644 index 00000000..d30a6431 --- /dev/null +++ b/.db-env @@ -0,0 +1,2 @@ +PGADMIN_DEFAULT_EMAIL=junte@domna.homes +PGADMIN_DEFAULT_PASSWORD=makingwarmhomes \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 9fdf7ebc..b6b65093 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: frontend: @@ -14,6 +14,17 @@ services: networks: - frontend-net + pgadmin: + image: dpage/pgadmin4 + hostname: pgadmin + ports: + - 5556:80 + env_file: + - ../.db-env + restart: unless-stopped + networks: + - frontend-net + networks: frontend-net: driver: bridge diff --git a/package-lock.json b/package-lock.json index 76492d3f..cc71fb58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "@remixicon/react": "^4.2.0", "@tanstack/react-query": "^4.29.12", "@tanstack/react-table": "^8.9.3", - "@tremor/react": "^3.16.0", + "@tremor/react": "^3.18.7", "@types/node": "20.2.3", "@types/react": "18.3.1", "@types/react-dom": "18.3.1", @@ -64,7 +64,7 @@ "zod": "^3.23.8" }, "devDependencies": { - "@tailwindcss/forms": "^0.5.7", + "@tailwindcss/forms": "^0.5.10", "@testing-library/cypress": "^10.0.3", "@types/nodemailer": "^7.0.2", "@types/pg": "^8.10.2", diff --git a/package.json b/package.json index 922bc3e2..fcb44cc9 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@remixicon/react": "^4.2.0", "@tanstack/react-query": "^4.29.12", "@tanstack/react-table": "^8.9.3", - "@tremor/react": "^3.16.0", + "@tremor/react": "^3.18.7", "@types/node": "20.2.3", "@types/react": "18.3.1", "@types/react-dom": "18.3.1", @@ -70,7 +70,7 @@ "zod": "^3.23.8" }, "devDependencies": { - "@tailwindcss/forms": "^0.5.7", + "@tailwindcss/forms": "^0.5.10", "@testing-library/cypress": "^10.0.3", "@types/nodemailer": "^7.0.2", "@types/pg": "^8.10.2", diff --git a/src/app/db/schema/crm/hubspot_company_table.ts b/src/app/db/schema/crm/hubspot_company_table.ts new file mode 100644 index 00000000..71aa4193 --- /dev/null +++ b/src/app/db/schema/crm/hubspot_company_table.ts @@ -0,0 +1,18 @@ +import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"; + +export const hubspotCompanyData = pgTable("hubspot_company_data", { + id: uuid("id").defaultRandom().primaryKey(), + + companyId: text("company_id").notNull(), + companyName: text("company_name"), + groupId: text("group_id"), + + createdAt: timestamp("created_at", { precision: 6, withTimezone: true }) + .defaultNow() + .notNull(), + + updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true }) + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), +}); diff --git a/src/app/db/schema/crm/hubspot_deal_table.ts b/src/app/db/schema/crm/hubspot_deal_table.ts new file mode 100644 index 00000000..753a7370 --- /dev/null +++ b/src/app/db/schema/crm/hubspot_deal_table.ts @@ -0,0 +1,27 @@ +import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"; +import { InferModel } from "drizzle-orm"; + +export const hubspotDealData = pgTable("hubspot_deal_data", { + id: uuid("id").defaultRandom().primaryKey(), + + dealId: text("deal_id").notNull(), + dealname: text("dealname"), + dealstage: text("dealstage"), + companyId: text("company_id"), + projectCode: text("project_code"), + + landlordPropertyId: text("landlord_property_id"), + uprn: text("uprn"), + outcome: text("outcome"), + outcomeNotes: text("outcome_notes"), + + createdAt: timestamp("created_at", { precision: 6, withTimezone: true }) + .defaultNow() + .notNull(), + + updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true }) + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), +}); + diff --git a/src/app/portfolio/[slug]/(portfolio)/reports/DealStageChart.tsx b/src/app/portfolio/[slug]/(portfolio)/reports/DealStageChart.tsx new file mode 100644 index 00000000..4a6bd739 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reports/DealStageChart.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { BarList, Card, Title } from "@tremor/react"; +import TableViewer from "./TableViewer"; + +const STAGE_ORDER = [ + "Initial Planning", // 0 + "Booking Team to contact Tenant", // 1 + "Survey in Progress", // 2 + "Not viable", // 3 + "Needs HA Support", // 4 + "Coordination + Design", // 5 + "Ready to be installed" //7 +]; + +const STAGE_LABELS: Record = { + "1617223910": STAGE_ORDER[0], + "3583836399": STAGE_ORDER[0], + "3589581001": STAGE_ORDER[1], + "3569878239": STAGE_ORDER[1], + "1617223911": STAGE_ORDER[1], + "1984184569": STAGE_ORDER[1], + "3569572028": STAGE_ORDER[1], + "3570936026": STAGE_ORDER[1], + "2663668937": STAGE_ORDER[1], + "1984401629": STAGE_ORDER[1], + "1617223912": STAGE_ORDER[2], + "1617223913": STAGE_ORDER[2], + "2558220518": STAGE_ORDER[1], + "3474594026": STAGE_ORDER[1], + "3206388924": STAGE_ORDER[2], + "1617223915": STAGE_ORDER[2], + "1617223917": STAGE_ORDER[2], + "1887735998": STAGE_ORDER[3], + "3061261536": STAGE_ORDER[4], + "2571417798": "[Ops] Surveyed under 2019 - Needs Re-survey", + "1617223914": STAGE_ORDER[5], + "1887736000": "[Deprecated, please don't use] Files Missing From Assessor", + "1617223916": "[Ops] Properties to Review Manually", + "2628341989": STAGE_ORDER[2], + "3441170637": STAGE_ORDER[2], // check if assessor or coordination + "2628233422": STAGE_ORDER[5], + "1887735999": STAGE_ORDER[4], + "1960060104": "[Ops] HA Informed", + "1960060105": "[Ops] HA Works Scheduled", + "1960060106": "[Ops] HA Works Complete", + "1668803772": "[Ops] ERF Delivered to HA", + "1668803773": "[Ops] ERF Signed", + "2769407183": "[Ops] PV - Needs Heating Upgrade (Pre EPR D)", + "2769407184": "[Ops] Talk to client, Needs Heating Upgrade (Pre EPR C)", + "2702650617": STAGE_ORDER[5], + "2473886962": STAGE_ORDER[5], + "3016601828": STAGE_ORDER[4], + "3389868276": "[Engagement Team] Blocked - Needs Completion of Pilot", + "3389880508": "[Engagement Team] Blocked - Installer Negotiation", + "3399016689": "[Engagement Team] Eligible but blocked - part of incomplete flat", + "1668803774": STAGE_ORDER[6], // Ready for Invoicing + "3440363736": STAGE_ORDER[6], // [Finance] Needs Invoicing - Files Sent + "1618526429": "[Ops] Invoiced - Send Files to Installer", + "3080225005": "[Ops] Files Sent to Installer", + "1961258215": "[Ops] Installer Cancelled - Finalized", + "1961258214": "[Ops] Installer Cancelled - In Progress", + "1961258213": "[Ops] Install Scheduled", + "1617223918": "[Ops] Install Complete", + "1961258216": "[Compliance] Lodgement Complete", + "1961258217": "[Compliance] Documentation Sent to HA", + "3027432668": "[Team ???] Submitted to " +} + +interface DealStageChartProps { + deals: any[]; + onOpenTable?: (stageName: string, filteredDeals: any[]) => void; +} + +export function DealStageChart({ deals, onOpenTable }: DealStageChartProps) { + const data = useMemo(() => { + const counts: Record = {}; + deals.forEach((d) => { + const stageId = d.dealstage || "unknown"; + const stageName = STAGE_LABELS[stageId] || "Unknown Stage"; + counts[stageName] = (counts[stageName] || 0) + 1; + }); + + const unsorted = Object.entries(counts).map(([name, value]) => ({ + name, + value, + })); + + return unsorted.sort((a, b) => { + const aIndex = STAGE_ORDER.indexOf(a.name); + const bIndex = STAGE_ORDER.indexOf(b.name); + return ( + (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) - + (bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex) + ); + }); + }, [deals]); + + // handle click event + const handleBarClick = (value: { name: string; value: number }) => { + const filtered = deals.filter((d) => { + const stageId = d.dealstage || "unknown"; + const stageName = STAGE_LABELS[stageId] || "Unknown Stage"; + return stageName === value.name; + }); + + onOpenTable?.(value.name, filtered); + }; + + return ( + + Project Progress + + + ); +} \ No newline at end of file diff --git a/src/app/portfolio/[slug]/(portfolio)/reports/Report.tsx b/src/app/portfolio/[slug]/(portfolio)/reports/Report.tsx new file mode 100644 index 00000000..b2726c11 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reports/Report.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import { DealStageChart } from "./DealStageChart"; +import SurveyedPieChart from "./SurveyedResultsPieChart"; +import TableViewer from "./TableViewer"; + +interface ReportsProps { + deals: Record[]; +} + +export default function Reports({ deals }: ReportsProps) { + const [openTable, setOpenTable] = useState<{ + stage: string; + data: any[]; + } | null>(null); + + const handleOpenTable = (stage: string, filteredDeals: any[]) => { + setOpenTable({ stage, data: filteredDeals }); + }; + + if (!deals || deals.length === 0) { + return ( +
+ No deal data available. +
+ ); + } + + // Group deals by projectCode + const groupedDeals = deals.reduce((acc, deal) => { + const project = deal.projectCode || "Unknown Project"; + if (!acc[project]) acc[project] = []; + acc[project].push(deal); + return acc; + }, {} as Record); + + const projectCodes = Object.keys(groupedDeals); + const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]); + const currentDeals = groupedDeals[currentProjectCode]; + + return ( +
+{/* πŸ”Ή Centered Dropdown Selector for Projects */} +
+

+ Select Project +

+ +
+ + + {/* Custom dropdown arrow */} +
+ β–Ό +
+
+
+ + {/* Charts */} +
+
+ +
+
+ +
+
+ +
+ Showing project {currentProjectCode} +
+ + {/* πŸ”Ή Modal Table */} + {openTable && ( +
+
+

+ {openTable.stage} β€” {openTable.data.length} Properties +

+ +
+ +
+ +
+ +
+
+
+ )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reports/SurveyedResultsPieChart.tsx b/src/app/portfolio/[slug]/(portfolio)/reports/SurveyedResultsPieChart.tsx new file mode 100644 index 00000000..7cb0d3c0 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reports/SurveyedResultsPieChart.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { DonutChart, Card, Title } from "@tremor/react"; +import { useMemo, useState } from "react"; + +interface SurveyedPieChartProps { + deals: Record[]; + onOpenTable?: (outcome: string, filteredDeals: Record[]) => void; +} + +export default function SurveyedPieChart({ + deals, + onOpenTable, +}: SurveyedPieChartProps) { + const [selected, setSelected] = useState(null); + + const surveyorOutcomes = [ + "Surveyed", + "Surveyed - Pending Upload", + "Tenant Refusal", + "Other", + "Not Viable", + "Not Attempted", + "No Answer", + "Cancelled / No Show", + "Rescheduled", + ]; + + const data = useMemo(() => { + const outcomeCounts: Record = {}; + + deals.forEach((deal) => { + const outcome = deal.outcome; + if (outcome && surveyorOutcomes.includes(outcome)) { + outcomeCounts[outcome] = (outcomeCounts[outcome] || 0) + 1; + } + }); + + return Object.entries(outcomeCounts).map(([name, amount]) => ({ + name, + amount, + })); + }, [deals]); + + const handleClick = (value: { name: string; amount: number }) => { + if (!value) return; // guard clause + const filteredDeals = deals.filter((d) => d.outcome === value.name); + setSelected(null); // remove highlight after click + onOpenTable?.(value.name, filteredDeals); + }; + + return ( + +
+ + Surveyed Outcome + + + `${n.toLocaleString()}`} + colors={[ + "indigo", + "cyan", + "emerald", + "amber", + "rose", + "violet", + "gray", + ]} + className="w-64 h-64 cursor-pointer" + onValueChange={handleClick} + /> +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reports/TableViewer.tsx b/src/app/portfolio/[slug]/(portfolio)/reports/TableViewer.tsx new file mode 100644 index 00000000..04883320 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reports/TableViewer.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState, useMemo } from "react"; + +interface TableViewerProps { + data: Record[]; + columns?: string[]; // optional: which columns to show + columnLabels?: Record; // πŸ‘ˆ map data keys to display names +} + +export default function TableViewer({ data, columns, columnLabels }: TableViewerProps) { + const [searchTerms, setSearchTerms] = useState>({}); + + const visibleColumns = columns?.length ? columns : Object.keys(data?.[0] || {}); + + const filteredData = useMemo(() => { + return data.filter((row) => + visibleColumns.every((col) => { + const term = searchTerms[col]?.toLowerCase() || ""; + if (!term) return true; + const value = String(row[col] ?? "").toLowerCase(); + return value.includes(term); + }) + ); + }, [data, searchTerms, visibleColumns]); + + return ( +
+ + + + {visibleColumns.map((col) => ( + + ))} + + + + {filteredData.length === 0 ? ( + + + + ) : ( + filteredData.map((row, i) => ( + + {visibleColumns.map((col) => ( + + ))} + + )) + )} + +
+
+ + {columnLabels?.[col] || col} + + + setSearchTerms((prev) => ({ + ...prev, + [col]: e.target.value, + })) + } + /> +
+
+ No results found +
+ {String(row[col] ?? "")} +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reports/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reports/page.tsx new file mode 100644 index 00000000..30dacdf5 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reports/page.tsx @@ -0,0 +1,34 @@ +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { redirect } from "next/navigation"; +import { surveyDB } from "../../../../db/surveyDB/connection"; +import { hubspotDealData } from "../../../../db/schema/crm/hubspot_deal_table"; +import { eq } from "drizzle-orm"; +import Reports from "./Report"; + +const Demo = async () => { + const user = await getServerSession(AuthOptions); + + if (!user?.user) { + console.error("User not found"); + redirect("/"); + } + + // Abri company id + const companyId = "237615001799"; + + const deals = await surveyDB + .select() + .from(hubspotDealData) + .where(eq(hubspotDealData.companyId, companyId)); + console.log(deals); + + return ( + <> +

hello reports

+ {/* */} + + ); +}; + +export default Demo; \ No newline at end of file diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx index 11358e3b..2c470411 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx @@ -2,6 +2,8 @@ import { Toolbar } from "@/app/components/building-passport/Toolbar"; import { getPropertyMeta, getDocument } from "./utils"; import BackToPortfolioButton from "@/app/components/building-passport/BackToPortfolioButton"; import { ExclamationCircleIcon } from "@heroicons/react/24/outline"; +// import "@tremor/react/dist/esm/tremor.css"; + function EstimatedDataNotification() { return ( diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 6c7dcee6..dc8e111b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,14 @@ import { twMerge } from "tailwind-merge" export function cn (...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export const focusRing = [ + // base + "outline outline-offset-2 outline-0 focus-visible:outline-2", + // outline color + "outline-blue-500 dark:outline-blue-500", +] + +export function cx(...args: ClassValue[]) { + return twMerge(clsx(...args)) +} \ No newline at end of file From 276b23a900b933a8671d1cdfbdd6b93cc82f3152 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 30 Oct 2025 17:22:46 +0000 Subject: [PATCH 02/20] renamed --- src/app/components/building-passport/Toolbar.tsx | 3 ++- src/app/components/portfolio/Toolbar.tsx | 12 +++++++++++- .../{reports => live-projects}/DealStageChart.tsx | 0 .../{reports => live-projects}/Report.tsx | 0 .../SurveyedResultsPieChart.tsx | 0 .../{reports => live-projects}/TableViewer.tsx | 0 .../(portfolio)/{reports => live-projects}/page.tsx | 3 +-- 7 files changed, 14 insertions(+), 4 deletions(-) rename src/app/portfolio/[slug]/(portfolio)/{reports => live-projects}/DealStageChart.tsx (100%) rename src/app/portfolio/[slug]/(portfolio)/{reports => live-projects}/Report.tsx (100%) rename src/app/portfolio/[slug]/(portfolio)/{reports => live-projects}/SurveyedResultsPieChart.tsx (100%) rename src/app/portfolio/[slug]/(portfolio)/{reports => live-projects}/TableViewer.tsx (100%) rename src/app/portfolio/[slug]/(portfolio)/{reports => live-projects}/page.tsx (91%) diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index 20aa8443..966f2831 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -124,6 +124,8 @@ export function Toolbar({ ); + + return ( <>
@@ -145,7 +147,6 @@ export function Toolbar({ {solarAnalysisButton} {recommendationsButton} {documentsButton} - Measures */} - + + + Live Projects + { return ( <> -

hello reports

- {/* */} + ); }; From ee253daf76cf92874f5333ec7002e76159527858 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 30 Oct 2025 20:35:03 +0000 Subject: [PATCH 03/20] fixed slow loading of the properties data --- package-lock.json | 23 +++ package.json | 1 + src/app/components/StatusBadge.tsx | 13 +- src/app/db/schema/property.ts | 25 +-- src/app/layout.tsx | 1 + src/app/portfolio/[slug]/(portfolio)/page.tsx | 3 + .../[slug]/components/propertyTable.tsx | 38 +++- .../components/propertyTableColumns.tsx | 172 +++++++++++------- src/app/portfolio/[slug]/utils.ts | 115 +++++------- 9 files changed, 235 insertions(+), 156 deletions(-) diff --git a/package-lock.json b/package-lock.json index 76492d3f..a207d462 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.0.7", "@remixicon/react": "^4.2.0", + "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-query": "^4.29.12", "@tanstack/react-table": "^8.9.3", "@tremor/react": "^3.16.0", @@ -5549,6 +5550,22 @@ "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.19.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz", + "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==", + "license": "MIT", + "dependencies": { + "remove-accents": "0.5.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "4.40.0", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.40.0.tgz", @@ -12985,6 +13002,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", + "license": "MIT" + }, "node_modules/request-progress": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", diff --git a/package.json b/package.json index 922bc3e2..4bbcb1e4 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.0.7", "@remixicon/react": "^4.2.0", + "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-query": "^4.29.12", "@tanstack/react-table": "^8.9.3", "@tremor/react": "^3.16.0", diff --git a/src/app/components/StatusBadge.tsx b/src/app/components/StatusBadge.tsx index e2bca7e2..f7d6ca08 100644 --- a/src/app/components/StatusBadge.tsx +++ b/src/app/components/StatusBadge.tsx @@ -8,7 +8,11 @@ import { HoverCardTrigger, } from "@/app/shadcn_components/ui/hover-card"; -type ExtendedStatus = (typeof PortfolioStatus)[number] | "ECO4" | "GBIS"; +type ExtendedStatus = + | (typeof PortfolioStatus)[number] + | "ECO4" + | "GBIS" + | "NONE"; export default function StatusBadge({ status, @@ -18,6 +22,7 @@ export default function StatusBadge({ isProperty?: boolean; }) { const statusConfig = statusColor[status]; + console.log("status", status, statusConfig); return ( @@ -129,4 +134,10 @@ const statusColor: { hoverText: "This property is funded under the GBIS scheme", propertyHoverText: "This property is funded under the GBIS scheme", }, + NONE: { + class: "bg-gray-400 hover:bg-gray-400", + text: "No Funding", + hoverText: "This property has no funding scheme applied", + propertyHoverText: "This property has no funding scheme applied", + }, }; diff --git a/src/app/db/schema/property.ts b/src/app/db/schema/property.ts index 410fdfa2..cb68b2d2 100644 --- a/src/app/db/schema/property.ts +++ b/src/app/db/schema/property.ts @@ -257,25 +257,20 @@ export interface PropertyToRecommendation { sapPoints?: number | null; } -export interface PropertyWithRelations { - status: string | null; - id: bigint; - portfolioId: bigint; - creationStatus: string; +export interface PropertyWithRelations extends Record { + id: number | string | bigint; + portfolioId: number | string | bigint; address: string | null; postcode: string | null; - target: { epc?: string | null; heatDemand?: number | null }; - recommendations: PropertyToRecommendation[]; - cost?: number | null; + status: string | null; + creationStatus: string | null; currentEpcRating: string | null; currentSapPoints: number | null; - plans: { - id: bigint; - isDefault?: boolean; - fundingPackage?: { - scheme: string | null; - } | null; - }[]; + targetEpc: string | null; + planId: number | null; + fundingScheme: string | null; + totalRecommendationSapPoints: number | null; + totalRecommendationCost: number | null; } export type NonIntrusiveSurveyNotes = InferModel< diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c5f03c0b..adc6ba69 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,7 @@ import { cache } from "react"; import { Inter } from "next/font/google"; import { Toaster } from "@/app/shadcn_components/ui/toaster"; import { SpeedInsights } from "@vercel/speed-insights/next"; +import { X } from "lucide-react"; // If loading a variable font, you don't need to specify the font weight const inter = Inter({ diff --git a/src/app/portfolio/[slug]/(portfolio)/page.tsx b/src/app/portfolio/[slug]/(portfolio)/page.tsx index aec0b4d6..db09b4da 100644 --- a/src/app/portfolio/[slug]/(portfolio)/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/page.tsx @@ -73,11 +73,14 @@ export default async function Page(props: { ]; } + // Time how long this takes + console.time("getProperties3"); const properties: PropertyWithRelations[] = await getProperties( portfolioId, 1000, 0 ); + console.timeEnd("getProperties3"); return ( <> diff --git a/src/app/portfolio/[slug]/components/propertyTable.tsx b/src/app/portfolio/[slug]/components/propertyTable.tsx index bc13b0d3..32ea3c2d 100644 --- a/src/app/portfolio/[slug]/components/propertyTable.tsx +++ b/src/app/portfolio/[slug]/components/propertyTable.tsx @@ -25,6 +25,15 @@ import { DataTablePagination } from "./propertyTablePagination"; import React from "react"; import { Input } from "@/app/shadcn_components/ui/input"; import { PropertyWithRelations } from "@/app/db/schema/property"; +import { rankItem } from "@tanstack/match-sorter-utils"; +import { FilterFn } from "@tanstack/react-table"; + +// Optional: Fuzzy global filter +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + const itemRank = rankItem(String(row.getValue(columnId) ?? ""), value); + addMeta?.({ itemRank }); + return itemRank.passed; +}; interface DataTableProps { columns: ColumnDef[]; @@ -49,6 +58,7 @@ export default function DataTable({ const [columnFilters, setColumnFilters] = React.useState( [] ); + const [globalFilter, setGlobalFilter] = React.useState(""); // add page change handlers for DataTablePagination const loadPaginatedData = () => { @@ -72,24 +82,36 @@ export default function DataTable({ getPaginationRowModel: getPaginationRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: fuzzyFilter, state: { sorting, pagination: { pageIndex: currentPageIndex, pageSize: 7 }, columnFilters, + globalFilter, }, }); return ( <>
- - table.getColumn("address")?.setFilterValue(event.target.value) - } - className="max-w-sm" - /> +
+ setGlobalFilter(event.target.value)} + className="w-64" + /> + {globalFilter && ( + + )} +
diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index 654c5504..58156b3d 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -17,10 +17,8 @@ import { FunnelIcon } from "@heroicons/react/24/outline"; import { formatNumber, getEpcColorClass, sapToEpc } from "@/app/utils"; import { cn } from "@/lib/utils"; import { PortfolioStatus } from "@/app/db/schema/portfolio"; -import { - PropertyToRecommendation, - PropertyWithRelations, -} from "@/app/db/schema/property"; +import { PropertyWithRelations } from "@/app/db/schema/property"; +import { X } from "lucide-react"; interface DataTableColumnHeaderProps extends React.HTMLAttributes { @@ -44,38 +42,65 @@ export function DataTableFilterHeader({ column, title, className, -}: DataTableColumnHeaderProps) { - if (!column.getCanSort()) { - return
{title}
; - } + options, + renderOption, +}: DataTableColumnHeaderProps & { + options: string[]; + renderOption?: (opt: string) => React.ReactNode; +}) { + const currentValue = column.getFilterValue() as string | undefined; return ( -
+
- - {[...PortfolioStatus, "ECO4", "GBIS"].map((status) => ( + + + {options.map((opt) => ( { - console.log("status filter:", status); - column.setFilterValue(status); - }} + key={opt} + onClick={() => + column.setFilterValue(currentValue === opt ? undefined : opt) + } + className={cn( + "cursor-pointer flex items-center gap-2 px-2 py-1.5", + currentValue === opt && "bg-accent" + )} > - {} + {renderOption ? renderOption(opt) : opt} ))} + + {currentValue && ( + + )}
); } @@ -83,6 +108,7 @@ export function DataTableFilterHeader({ export const columns: ColumnDef[] = [ { accessorKey: "address", + enableGlobalFilter: true, header: ({ column }) => { return (
); }, }, + { + accessorKey: "postcode", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.postcode} +
+ ), + }, { accessorKey: "status", // header: () =>
Status
, header: ({ column }) => { return (
- + ( + + )} + />
); }, cell: ({ row }) => { const status = row.getValue("status") ?? ""; - const plans = row.original.plans || []; - // Check if any plan has an ECO4 or GBIS funding package - const fundingScheme = plans.find((p) => { - const scheme = p?.fundingPackage?.scheme; - return scheme && ["ECO4", "GBIS"].includes(scheme.toUpperCase()); - })?.fundingPackage?.scheme; - - const effectiveStatus = fundingScheme - ? fundingScheme.toUpperCase() - : status; return (
- {effectiveStatus && ( - + {status && } +
+ ); + }, + }, + { + accessorKey: "fundingScheme", + header: ({ column }) => { + return ( +
+ ( + // handle status being null or undefined + + )} + /> +
+ ); + }, + cell: ({ row }) => { + // if the funding scheme is "none" we display nothing + const fundingScheme = row.getValue("fundingScheme") || ""; + // Check if any plan has an ECO4 or GBIS funding package + + return ( +
+ {fundingScheme && fundingScheme !== "none" && ( + )}
); @@ -163,27 +233,11 @@ export const columns: ColumnDef[] = [ accessorKey: "targetEpc", header: () =>
Expected EPC
, cell: ({ row }) => { - const recommendations = row.original.recommendations; + const currentSapPoints = row.original.currentSapPoints || 0; - const currentSapPoints = row.original.currentSapPoints; + const expectedSapPoints = row.original.totalRecommendationSapPoints || 0; - const expectedapPoints = recommendations.reduce( - (acc: number, rec: PropertyToRecommendation) => { - if (rec.sapPoints === null || rec.sapPoints === undefined) { - return acc; - } - return acc + rec.sapPoints; - }, - 0 - ); - if (currentSapPoints === null || currentSapPoints === undefined) { - return ( -
- {""} -
- ); - } - const expectedEpc = sapToEpc(currentSapPoints + expectedapPoints); + const expectedEpc = sapToEpc(currentSapPoints + expectedSapPoints); return (
@@ -196,17 +250,7 @@ export const columns: ColumnDef[] = [ accessorKey: "cost", header: () =>
Cost
, cell: ({ row }) => { - const recommendations = row.original.recommendations; - - const cost = recommendations.reduce( - (acc: number, rec: PropertyToRecommendation) => { - if (rec.estimatedCost === null || rec.estimatedCost === undefined) { - return acc; - } - return acc + rec.estimatedCost; - }, - 0 - ); + const cost = row.original.totalRecommendationCost; const creationStatus = row.original.creationStatus; if (creationStatus === "LOADING") { diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index f474e0b6..98507393 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -18,6 +18,7 @@ import { scenario, ScenarioSelect, } from "@/app/db/schema/recommendations"; +import { sql } from "drizzle-orm"; export interface PortfolioSettingsType { name: string; @@ -418,77 +419,55 @@ export async function getProperties( offset: number = 0 ): Promise { // We need to perform the query like this because the nested query is not supported in the ORM right now - const data: PropertyWithRelations[] = await db.query.property.findMany({ - limit: limit, - offset: offset, - columns: { - id: true, - portfolioId: true, - address: true, - postcode: true, - status: true, - creationStatus: true, - currentEpcRating: true, - currentSapPoints: true, - }, - where: eq(property.portfolioId, BigInt(portfolioId)), - with: { - target: { - columns: { - epc: true, - }, - }, - recommendations: { - columns: { - id: true, - estimatedCost: true, - sapPoints: true, - }, - where: and( - eq(recommendation.default, true), - inArray( - recommendation.id, - db - .select({ - recommendationId: planRecommendations.recommendationId, - }) - .from(planRecommendations) - .innerJoin(plan, eq(plan.id, planRecommendations.planId)) - .where(eq(plan.isDefault, true)) - ) - ), - }, - plans: { - columns: { - id: true, - }, - where: eq(plan.isDefault, true), - // Associate the funding information - with: { - fundingPackage: { - columns: { - scheme: true, - }, - }, - }, - }, - }, - }); - // override status to reflect ECO4/GBIS if present - const updated = data.map((p) => { - const fundingScheme = p.plans.find((pl) => { - const scheme = pl?.fundingPackage?.scheme; - return scheme && ["ECO4", "GBIS"].includes(scheme.toUpperCase()); - })?.fundingPackage?.scheme; + const result = + await db.execute(sql` + SELECT + p.id AS id, + p.portfolio_id AS "portfolioId", + p.address AS address, + p.postcode AS postcode, + p.status AS status, + p.creation_status AS "creationStatus", + p.current_epc_rating AS "currentEpcRating", + p.current_sap_points AS "currentSapPoints", + t.epc AS "targetEpc", + pl.id AS "planId", + fp.scheme AS "fundingScheme", + COALESCE(SUM(r.sap_points), 0) AS "totalRecommendationSapPoints", + COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost" + FROM property p + LEFT JOIN property_targets t + ON t.property_id = p.id + LEFT JOIN plan pl + ON pl.property_id = p.id + AND pl.is_default = true + LEFT JOIN funding_package fp + ON fp.plan_id = pl.id + LEFT JOIN plan_recommendations pr + ON pr.plan_id = pl.id + LEFT JOIN recommendation r + ON r.id = pr.recommendation_id + AND r.default = true + WHERE p.portfolio_id = ${portfolioId} + GROUP BY + p.id, + p.portfolio_id, + p.address, + p.postcode, + p.status, + p.creation_status, + p.current_epc_rating, + p.current_sap_points, + t.epc, + pl.id, + fp.scheme + LIMIT ${limit} OFFSET ${offset}; + `); - return { - ...p, - status: fundingScheme ? fundingScheme.toUpperCase() : p.status, - }; - }); + const data: PropertyWithRelations[] = result.rows; - return updated; + return data; } interface UnaggregatedPortfolioPlanRecommendation { From d8f647b8fa5fc2e89b6e7e66a54edcc802b067a3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 30 Oct 2025 20:36:54 +0000 Subject: [PATCH 04/20] made the search aligned --- src/app/portfolio/[slug]/components/propertyTable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/portfolio/[slug]/components/propertyTable.tsx b/src/app/portfolio/[slug]/components/propertyTable.tsx index 32ea3c2d..c6246e63 100644 --- a/src/app/portfolio/[slug]/components/propertyTable.tsx +++ b/src/app/portfolio/[slug]/components/propertyTable.tsx @@ -94,8 +94,8 @@ export default function DataTable({ return ( <> -
-
+
+
({ {headerGroup.headers.map((header) => { return ( - + {header.isPlaceholder ? null : flexRender( From 4d61224cf372e214481ccb1d0c14bbd0c7f7375b Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Fri, 31 Oct 2025 12:52:37 +0000 Subject: [PATCH 05/20] save --- src/app/components/portfolio/Toolbar.tsx | 2 +- .../(portfolio)/live-projects/Report.tsx | 145 ++++++++++++++---- .../[slug]/(portfolio)/live-projects/page.tsx | 3 +- 3 files changed, 114 insertions(+), 36 deletions(-) diff --git a/src/app/components/portfolio/Toolbar.tsx b/src/app/components/portfolio/Toolbar.tsx index 0d6226aa..7fcae070 100644 --- a/src/app/components/portfolio/Toolbar.tsx +++ b/src/app/components/portfolio/Toolbar.tsx @@ -50,7 +50,7 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) { } function handleClickProgressReport() { - router.push(`/portfolio/${portfolioId}/reports`); + router.push(`/portfolio/${portfolioId}/live-projects`); } const [modalIsOpen, setModalIsOpen] = useState(false); diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx index b2726c11..91bad8b3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx @@ -9,7 +9,10 @@ interface ReportsProps { deals: Record[]; } -export default function Reports({ deals }: ReportsProps) { +// 🟩 Stage mapping: β€œMajor Condition Issues” = dealstage 3061261536 +const MAJOR_CONDITION_STAGE_ID = "3061261536"; + +export default function LiveTracker({ deals }: ReportsProps) { const [openTable, setOpenTable] = useState<{ stage: string; data: any[]; @@ -39,46 +42,120 @@ export default function Reports({ deals }: ReportsProps) { const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]); const currentDeals = groupedDeals[currentProjectCode]; + // πŸ”Ή Compute overall summary (across all projects) + const totalProperties = deals.length; + const majorConditionDeals = deals.filter( + (d) => d.dealstage === MAJOR_CONDITION_STAGE_ID + ); + const majorIssues = majorConditionDeals.length; + const majorPercent = ((majorIssues / totalProperties) * 100).toFixed(1); + + // πŸ”Ή Click handlers + const handleTotalClick = () => { + console.log("Opening all deals (global)"); + handleOpenTable("All Properties", deals); + }; + + const handleMajorClick = () => { + console.log("Opening all Major Condition Issues (global)"); + handleOpenTable("Major Condition Issues", majorConditionDeals); + }; + return ( -
-{/* πŸ”Ή Centered Dropdown Selector for Projects */} -
-

- Select Project -

+
+ {/* πŸ”Ή Global Overview Row */} +
+

+ 🌍 Global Portfolio Overview +

-
- +
+ {/* Total Properties */} + - {/* Custom dropdown arrow */} -
- β–Ό -
-
-
+ {/* Major Condition Issues */} + - {/* Charts */} -
-
- -
-
- + {/* Project Dropdown Selector */} +
+ +
+ + + {/* Custom dropdown arrow */} +
+ β–Ό +
+
+
-
- Showing project {currentProjectCode} + {/* πŸ”Ή Project-Level Section */} +
+

+ πŸ“Š Project-Level Insights +

+

+ Showing data for{" "} + + {currentProjectCode} + +

+ +
+
+ +
+
+ +
+
{/* πŸ”Ή Modal Table */} diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx index db8e31c0..08a5a3b9 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx @@ -5,6 +5,7 @@ import { surveyDB } from "../../../../db/surveyDB/connection"; import { hubspotDealData } from "../../../../db/schema/crm/hubspot_deal_table"; import { eq } from "drizzle-orm"; import Reports from "./Report"; +import LiveTracker from "./Report"; const Demo = async () => { const user = await getServerSession(AuthOptions); @@ -25,7 +26,7 @@ const Demo = async () => { return ( <> - + ); }; From 6a98a530edd4d831c3338663a3ccee2927988a21 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 31 Oct 2025 19:07:22 +0000 Subject: [PATCH 06/20] functional display for project proposal ui --- src/app/components/StatusBadge.tsx | 1 - src/app/db/schema/recommendations.ts | 3 +- src/app/portfolio/[slug]/(portfolio)/page.tsx | 3 - .../temp-reporting/ProjectProposal.tsx | 169 ++++++++++++++++++ .../temp-reporting/ProposalColumns.tsx | 111 ++++++++++++ .../(portfolio)/temp-reporting/page.tsx | 28 +++ .../(portfolio)/temp-reporting/utils.ts | 69 +++++++ .../[slug]/components/propertyTable.tsx | 26 ++- 8 files changed, 389 insertions(+), 21 deletions(-) create mode 100644 src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProposalColumns.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts diff --git a/src/app/components/StatusBadge.tsx b/src/app/components/StatusBadge.tsx index f7d6ca08..c4eed14c 100644 --- a/src/app/components/StatusBadge.tsx +++ b/src/app/components/StatusBadge.tsx @@ -22,7 +22,6 @@ export default function StatusBadge({ isProperty?: boolean; }) { const statusConfig = statusColor[status]; - console.log("status", status, statusConfig); return ( diff --git a/src/app/db/schema/recommendations.ts b/src/app/db/schema/recommendations.ts index 8b378357..f355f98e 100644 --- a/src/app/db/schema/recommendations.ts +++ b/src/app/db/schema/recommendations.ts @@ -14,7 +14,6 @@ import { import { Material, material } from "./materials"; import { InferModel } from "drizzle-orm"; import { z } from "zod"; -import { readlink } from "fs"; export const recommendation = pgTable("recommendation", { id: bigserial("id", { mode: "bigint" }).primaryKey(), @@ -66,7 +65,7 @@ export const recommendationMaterials = pgTable("recommendation_materials", { }); // We create a plan type, for common plan types that we produce for clients -const PlanType: [string, ...string[]] = [ +export const PlanType: [string, ...string[]] = [ "solar_eco4", "solar_hhrsh_eco4", "empty_cavity_eco", diff --git a/src/app/portfolio/[slug]/(portfolio)/page.tsx b/src/app/portfolio/[slug]/(portfolio)/page.tsx index db09b4da..aec0b4d6 100644 --- a/src/app/portfolio/[slug]/(portfolio)/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/page.tsx @@ -73,14 +73,11 @@ export default async function Page(props: { ]; } - // Time how long this takes - console.time("getProperties3"); const properties: PropertyWithRelations[] = await getProperties( portfolioId, 1000, 0 ); - console.timeEnd("getProperties3"); return ( <> diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx new file mode 100644 index 00000000..a9ba8618 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { + Card, + CardHeader, + CardTitle, + CardContent, +} from "@/app/shadcn_components/ui/card"; +import { BarChart } from "@tremor/react"; +import { formatNumber } from "@/app/utils"; +import { Leaf, PoundSterling, Zap, FileSpreadsheet } from "lucide-react"; + +export function ProjectProposal({ plans }: { plans: any[] }) { + const [selectedType, setSelectedType] = useState(null); + + // Group by planType + const grouped = useMemo(() => { + const map: Record = {}; + for (const plan of plans) { + if (!plan.planType) continue; + if (!map[plan.planType]) map[plan.planType] = []; + map[plan.planType].push(plan); + } + + // Summaries for the chart + return Object.entries(map).map(([type, list]) => ({ + planType: type, + count: list.length, + avgClientContribution: + list.reduce((sum, p) => sum + (p.totalFunding ?? 0) * 0.1, 0) / + list.length, // placeholder calc + totalFunding: list.reduce((sum, p) => sum + (p.totalFunding ?? 0), 0), + totalCarbon: list.reduce( + (sum, p) => sum + (p.totalCarbonSavings ?? 0), + 0 + ), + totalBills: list.reduce((sum, p) => sum + (p.totalBillSavings ?? 0), 0), + })); + }, [plans]); + + const selectedData = selectedType + ? grouped.find((d) => d.planType === selectedType) + : null; + + return ( +
+ {/* Left: Chart */} + + + Plans by Work Type + + + v.toString()} + onValueChange={(v) => setSelectedType(String(v) || null)} + className="h-64" + /> + + + + {/* Right: Details */} + + + + {selectedType + ? selectedType.replaceAll("_", " ") + : "Select a work type"} + + + + {selectedType && selectedData ? ( + <> +
+ Average client contribution +
+
+ Β£{formatNumber(selectedData.avgClientContribution || 0)} +
+ +
Carbon savings
+
+ {(selectedData.totalCarbon * 1000).toFixed(2)} kgCOβ‚‚e +
+ +
Bill savings
+
+ Β£{formatNumber(selectedData.totalBills)} +
+ +
+ Total estimated contribution +
+
+ Β£ + {formatNumber( + selectedData.totalFunding + + (selectedType.includes("cavity") ? 1500 : 500) // example extra cost rule + )} +
+ + ) : ( +

Click a bar to view details

+ )} +
+
+
+ ); +} + +export function DashboardSummary({ plans }: { plans: any[] }) { + const totalFunding = plans.reduce((sum, p) => sum + (p.totalFunding || 0), 0); + const totalCarbonSavings = plans.reduce( + (sum, p) => sum + (p.totalCarbonSavings || 0), + 0 + ); + const totalBillSavings = plans.reduce( + (sum, p) => sum + (p.totalBillSavings || 0), + 0 + ); + const planCount = plans.length; + + const cards = [ + { + title: "Total Funding", + value: `Β£${formatNumber(totalFunding)}`, + icon: , + }, + { + title: "Total Carbon Savings", + value: `${(totalCarbonSavings * 1000).toFixed(2)} kgCOβ‚‚e`, + icon: , + }, + { + title: "Total Bill Savings", + value: `Β£${formatNumber(totalBillSavings)}`, + icon: , + }, + { + title: "Number of Plans", + value: planCount, + icon: , + }, + ]; + + return ( +
+ {cards.map((card) => ( + + + + {card.title} + + {card.icon} + + +
+ {card.value} +
+
+
+ ))} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProposalColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProposalColumns.tsx new file mode 100644 index 00000000..a53df76b --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProposalColumns.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { ArrowUpDown, Leaf, PoundSterling, Zap } from "lucide-react"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { formatNumber } from "@/app/utils"; +import StatusBadge from "@/app/components/StatusBadge"; +import { PlanWithTotals } from "./utils"; + +export const planColumns: ColumnDef[] = [ + { + accessorKey: "address", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{row.original.address || "β€”"}
+ ), + }, + { + accessorKey: "postcode", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{row.original.postcode || "β€”"}
+ ), + }, + + { + accessorKey: "fundingScheme", + header: () =>
Funding Scheme
, + cell: ({ row }) => ( +
+ {row.original.fundingScheme ? ( + + ) : ( + None + )} +
+ ), + }, + { + accessorKey: "planType", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {String(row.original.planType).replaceAll("_", " ")} +
+ ), + }, + { + accessorKey: "totalFunding", + header: () =>
Total Funding
, + cell: ({ row }) => ( +
+ + + Β£{formatNumber(row.original.totalFunding || 0)} + +
+ ), + }, + { + accessorKey: "totalCarbonSavings", + header: () =>
Carbon Savings
, + cell: ({ row }) => ( +
+ + + {((row.original.totalCarbonSavings || 0) * 1000).toFixed(2)} kgCOβ‚‚e + +
+ ), + }, + { + accessorKey: "totalBillSavings", + header: () =>
Bill Savings
, + cell: ({ row }) => ( +
+ + + Β£{formatNumber(row.original.totalBillSavings || 0)} + +
+ ), + }, +]; diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx new file mode 100644 index 00000000..dc03a3c1 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx @@ -0,0 +1,28 @@ +import { ProjectProposal, DashboardSummary } from "./ProjectProposal"; +import { getPlansWithTotals } from "./utils"; +import DataTable from "@/app/portfolio/[slug]/components/propertyTable"; +import { planColumns } from "./ProposalColumns"; + +export default async function YourProjectsPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug: portfolioId } = await params; + const latestPlans = await getPlansWithTotals(portfolioId); + + console.log("latestPlans", latestPlans); + + return ( +
+ + +
+

+ Plans Overview +

+ +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts new file mode 100644 index 00000000..ae2274aa --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts @@ -0,0 +1,69 @@ +import { db } from "@/app/db/db"; +import { sql } from "drizzle-orm"; + +export interface PlanWithTotals extends Record { + planId: string; + planType: string | null; + planName: string | null; + createdAt: string; + propertyId: number; + landlordPropertyId: string | null; + address: string | null; + postcode: string | null; + fundingScheme: string | null; + totalFunding: number | null; + totalCarbonSavings: number | null; + totalBillSavings: number | null; +} + +export async function getPlansWithTotals( + portfolioId: string +): Promise { + const result = await db.execute(sql` + SELECT + pl.id AS "planId", + pl.plan_type AS "planType", + pl.name AS "planName", + pl.created_at AS "createdAt", + pl.property_id AS "propertyId", + p.landlord_property_id AS "landlordPropertyId", + p.address AS "address", + p.postcode AS "postcode", + fp.scheme AS "fundingScheme", + COALESCE(SUM(r.estimated_cost), 0) AS "totalFunding", + COALESCE(SUM(r.co2_equivalent_savings), 0) AS "totalCarbonSavings", + COALESCE(SUM(r.energy_cost_savings), 0) AS "totalBillSavings", + COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost" + FROM plan pl + INNER JOIN property p + ON p.id = pl.property_id + LEFT JOIN funding_package fp + ON fp.plan_id = pl.id + LEFT JOIN plan_recommendations prx + ON prx.plan_id = pl.id + LEFT JOIN recommendation r + ON r.id = prx.recommendation_id + AND r.default = true + WHERE pl.portfolio_id = ${portfolioId} + AND pl.plan_type IN ( + 'solar_eco4', + 'solar_hhrsh_eco4', + 'empty_cavity_eco', + 'partial_cavity_eco', + 'extraction_eco' + ) + GROUP BY + pl.id, + pl.plan_type, + pl.name, + pl.created_at, + pl.property_id, + p.landlord_property_id, + p.address, + p.postcode, + fp.scheme + ORDER BY pl.created_at DESC; + `); + + return result.rows; +} diff --git a/src/app/portfolio/[slug]/components/propertyTable.tsx b/src/app/portfolio/[slug]/components/propertyTable.tsx index c6246e63..c15ff005 100644 --- a/src/app/portfolio/[slug]/components/propertyTable.tsx +++ b/src/app/portfolio/[slug]/components/propertyTable.tsx @@ -24,7 +24,6 @@ import { useState } from "react"; import { DataTablePagination } from "./propertyTablePagination"; import React from "react"; import { Input } from "@/app/shadcn_components/ui/input"; -import { PropertyWithRelations } from "@/app/db/schema/property"; import { rankItem } from "@tanstack/match-sorter-utils"; import { FilterFn } from "@tanstack/react-table"; @@ -35,24 +34,23 @@ const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { return itemRank.passed; }; -interface DataTableProps { - columns: ColumnDef[]; - data: PropertyWithRelations[]; +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; } -function fetchData(offset: number) { - // Because this is a client component, this will be handled with react query - let properties: PropertyWithRelations[] = []; - // TODO: implement this - return properties; +function fetchData(offset: number): TData[] { + // placeholder function for fetching + const data: TData[] = []; + return data; } -export default function DataTable({ +export default function DataTable>({ data, columns, -}: DataTableProps) { +}: DataTableProps) { const [sorting, setSorting] = useState([]); - const [tableData, setTableData] = useState(data); + const [tableData, setTableData] = useState(() => [...data]); const [offset, setOffset] = useState(0); const [currentPageIndex, setCurrentPageIndex] = useState(0); const [columnFilters, setColumnFilters] = React.useState( @@ -62,14 +60,12 @@ export default function DataTable({ // add page change handlers for DataTablePagination const loadPaginatedData = () => { - const newData = fetchData(offset); + const newData = fetchData(offset); if (newData) { - console.log("loadPaginatedData"); setTableData([...tableData, ...newData]); setOffset(offset + 1); return true; } - return false; }; From 031b5dacc1d18f16c26d00be730f3b689e326886 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 31 Oct 2025 21:48:43 +0000 Subject: [PATCH 07/20] Before Tailwind upgrade --- package-lock.json | 2 +- package.json | 2 +- .../portfolio/summary/EpcBarChart.tsx | 14 +- src/app/domna/financials.ts | 13 + src/app/globals.css | 132 +++++++++- src/app/layout.tsx | 1 - .../[slug]/(portfolio)/decent-homes/page.tsx | 18 +- .../temp-reporting/ProjectProposal.tsx | 234 +++++++++++------- .../temp-reporting/ProposalColumns.tsx | 50 +++- .../(portfolio)/temp-reporting/page.tsx | 22 +- .../(portfolio)/temp-reporting/utils.ts | 32 ++- .../[slug]/remote-assessment/page.tsx | 1 - src/lib/chartUtils.ts | 204 +++++++++++++++ src/lib/utils.ts | 46 +++- tailwind.config.js | 186 -------------- 15 files changed, 628 insertions(+), 329 deletions(-) create mode 100644 src/app/domna/financials.ts create mode 100644 src/lib/chartUtils.ts diff --git a/package-lock.json b/package-lock.json index a207d462..b35812e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-query": "^4.29.12", "@tanstack/react-table": "^8.9.3", - "@tremor/react": "^3.16.0", + "@tremor/react": "^3.18.7", "@types/node": "20.2.3", "@types/react": "18.3.1", "@types/react-dom": "18.3.1", diff --git a/package.json b/package.json index 4bbcb1e4..c43a2d8d 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-query": "^4.29.12", "@tanstack/react-table": "^8.9.3", - "@tremor/react": "^3.16.0", + "@tremor/react": "^3.18.7", "@types/node": "20.2.3", "@types/react": "18.3.1", "@types/react-dom": "18.3.1", diff --git a/src/app/components/portfolio/summary/EpcBarChart.tsx b/src/app/components/portfolio/summary/EpcBarChart.tsx index f111afa2..6c26bab5 100644 --- a/src/app/components/portfolio/summary/EpcBarChart.tsx +++ b/src/app/components/portfolio/summary/EpcBarChart.tsx @@ -23,13 +23,13 @@ const EpcBarChart = ({ index="name" categories={["G", "F", "E", "D", "C", "B", "A"]} // Each treated as a separate series colors={[ - "#e41e3b", // Color for 'G' - "#ef8026", // Color for 'F' - "#f3a96a", // Color for 'E' - "#f7cd14", // Color for 'D' - "#8dbd40", // Color for 'C' - "#2da55c", // Color for 'B' - "#117d58", // Color for 'A' + "epc_g", // Color for 'G' + "epc_f", // Color for 'F' + "epc_e", // Color for 'E' + "epc_d", // Color for 'D' + "epc_c", // Color for 'C' + "epc_b", // Color for 'B' + "epc_a", // Color for 'A' ]} valueFormatter={dataFormatter} yAxisWidth={48} diff --git a/src/app/domna/financials.ts b/src/app/domna/financials.ts new file mode 100644 index 00000000..538f8c1d --- /dev/null +++ b/src/app/domna/financials.ts @@ -0,0 +1,13 @@ +import { PlanTypeEnum } from "@/app/db/schema/recommendations"; + +// Fixed Domna costs per delivery type +export const DOMNA_COST_MAP: Record & { + default: number; +} = { + solar_eco4: 2250, + solar_hhrsh_eco4: 2250, + empty_cavity_eco: 800, + partial_cavity_eco: 800, + extraction_eco: 800, + default: 800, +}; diff --git a/src/app/globals.css b/src/app/globals.css index 804366f3..6d892a40 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,3 +1,5 @@ +@import "tailwindcss"; +@plugin "@tailwindcss/forms"; @tailwind base; @tailwind components; @tailwind utilities; @@ -76,7 +78,9 @@ } body { @apply bg-background text-foreground; - font-feature-settings: "rlig" 1, "calt" 1; + font-feature-settings: + "rlig" 1, + "calt" 1; } } @@ -116,3 +120,129 @@ .animate-spin { animation: spin 1s linear infinite; } + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --animate-hide: hide 150ms cubic-bezier(0.16, 1, 0.3, 1); + --animate-slide-down-and-fade: slideDownAndFade 150ms + cubic-bezier(0.16, 1, 0.3, 1); + --animate-slide-left-and-fade: slideLeftAndFade 150ms + cubic-bezier(0.16, 1, 0.3, 1); + --animate-slide-up-and-fade: slideUpAndFade 150ms + cubic-bezier(0.16, 1, 0.3, 1); + --animate-slide-right-and-fade: slideRightAndFade 150ms + cubic-bezier(0.16, 1, 0.3, 1); + --animate-accordion-open: accordionOpen 150ms cubic-bezier(0.87, 0, 0.13, 1); + --animate-accordion-close: accordionClose 150ms cubic-bezier(0.87, 0, 0.13, 1); + --animate-dialog-overlay-show: dialogOverlayShow 150ms + cubic-bezier(0.16, 1, 0.3, 1); + --animate-dialog-content-show: dialogContentShow 150ms + cubic-bezier(0.16, 1, 0.3, 1); + --animate-drawer-slide-left-and-fade: drawerSlideLeftAndFade 150ms + cubic-bezier(0.16, 1, 0.3, 1); + --animate-drawer-slide-right-and-fade: drawerSlideRightAndFade 150ms ease-in; + + @keyframes hide { + from { + opacity: 1; + } + to { + opacity: 0; + } + } + @keyframes slideDownAndFade { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + @keyframes slideLeftAndFade { + from { + opacity: 0; + transform: translateX(6px); + } + to { + opacity: 1; + transform: translateX(0); + } + } + @keyframes slideUpAndFade { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + @keyframes slideRightAndFade { + from { + opacity: 0; + transform: translateX(-6px); + } + to { + opacity: 1; + transform: translateX(0); + } + } + @keyframes accordionOpen { + from { + height: 0px; + } + to { + height: var(--radix-accordion-content-height); + } + } + @keyframes accordionClose { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0px; + } + } + @keyframes dialogOverlayShow { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + @keyframes dialogContentShow { + from { + opacity: 0; + transform: translate(-50%, -45%) scale(0.95); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + } + @keyframes drawerSlideLeftAndFade { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } + } + @keyframes drawerSlideRightAndFade { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(100%); + } + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index adc6ba69..c5f03c0b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,7 +8,6 @@ import { cache } from "react"; import { Inter } from "next/font/google"; import { Toaster } from "@/app/shadcn_components/ui/toaster"; import { SpeedInsights } from "@vercel/speed-insights/next"; -import { X } from "lucide-react"; // If loading a variable font, you don't need to specify the font weight const inter = Inter({ diff --git a/src/app/portfolio/[slug]/(portfolio)/decent-homes/page.tsx b/src/app/portfolio/[slug]/(portfolio)/decent-homes/page.tsx index 0e37944e..cde7d797 100644 --- a/src/app/portfolio/[slug]/(portfolio)/decent-homes/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/decent-homes/page.tsx @@ -3,23 +3,7 @@ import { property } from "@/app/db/schema/property"; import { inArray, eq, and } from "drizzle-orm"; import { surveyDB } from "@/app/db/surveyDB/connection"; import { uploadedFiles } from "@/app/db/surveyDB/schema/surveyDB"; -import { - getEnergyAssessmentFromS3, - getConditionReport, - getPropertyMeta, -} from "@/app/portfolio/[slug]/building-passport/[propertyId]/utils"; -import { - getAllRoomData, - getRoomsWithDamp, - getRoomsWithDefects, - getRoomsWithBadWindows, - areAllWindowsOk, - getElevationsWithIssues, - hasSufficientSpace, - meetsSapThreshold, - hasEfficientHeatingSystem, - isInsulationAdequate, -} from "@/app/portfolio/[slug]/building-passport/[propertyId]/assessment/decent_homes_utils"; +import { getEnergyAssessmentFromS3 } from "@/app/portfolio/[slug]/building-passport/[propertyId]/utils"; import DecentHomesDashboard from "./DecentHomesDashboard"; async function getPropertiesWithUprn( diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx index a9ba8618..c71a2732 100644 --- a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx @@ -7,14 +7,21 @@ import { CardTitle, CardContent, } from "@/app/shadcn_components/ui/card"; -import { BarChart } from "@tremor/react"; +import { BarChart, DonutChart } from "@tremor/react"; import { formatNumber } from "@/app/utils"; -import { Leaf, PoundSterling, Zap, FileSpreadsheet } from "lucide-react"; + +const mappedTitles: Record = { + solar_eco4: "Solar ECO4 project metrics", + solar_hhrsh_eco4: "Solar HHRSH ECO4 project metrics", + empty_cavity_eco: "Empty Cavity Insulation metrics", + partial_cavity_eco: "Partial Cavity Insulation metrics", + extraction_eco: "Extraction & Refill project metrics", + default: "Select a work type to view metrics", +}; export function ProjectProposal({ plans }: { plans: any[] }) { const [selectedType, setSelectedType] = useState(null); - // Group by planType const grouped = useMemo(() => { const map: Record = {}; for (const plan of plans) { @@ -23,89 +30,135 @@ export function ProjectProposal({ plans }: { plans: any[] }) { map[plan.planType].push(plan); } - // Summaries for the chart - return Object.entries(map).map(([type, list]) => ({ - planType: type, - count: list.length, - avgClientContribution: - list.reduce((sum, p) => sum + (p.totalFunding ?? 0) * 0.1, 0) / - list.length, // placeholder calc - totalFunding: list.reduce((sum, p) => sum + (p.totalFunding ?? 0), 0), - totalCarbon: list.reduce( + return Object.entries(map).map(([type, list]) => { + const totalFunding = list.reduce( + (sum, p) => sum + (p.totalFunding ?? 0), + 0 + ); + const totalClientContribution = list.reduce( + (sum, p) => sum + (p.clientContribution ?? 0), + 0 + ); + const totalCarbon = list.reduce( (sum, p) => sum + (p.totalCarbonSavings ?? 0), 0 - ), - totalBills: list.reduce((sum, p) => sum + (p.totalBillSavings ?? 0), 0), - })); + ); + const totalBills = list.reduce( + (sum, p) => sum + (p.totalBillSavings ?? 0), + 0 + ); + return { + planType: type, + count: list.length, + avgClientContribution: totalClientContribution / list.length, + totalClientContribution, + totalFunding, + totalCarbon, + totalBills, + }; + }); }, [plans]); - const selectedData = selectedType - ? grouped.find((d) => d.planType === selectedType) - : null; + useMemo(() => { + if (grouped.length === 1 && !selectedType) + setSelectedType(grouped[0].planType); + }, [grouped, selectedType]); + + const selectedData = + selectedType && grouped.length + ? grouped.find((d) => d.planType === selectedType) + : grouped.length === 1 + ? grouped[0] + : null; + + const domnaPalette = [ + "#14163d", // brandblue (deep navy) + "#2d348f", // midblue + "#3943b7", // brandmidblue + "#c4a47c", // brandbrown + "#d3b488", // brandtan + "#eff6fc", // brandlightblue (for subtle items) + ]; return ( -
- {/* Left: Chart */} - +
+ {/* Chart */} + - Plans by Work Type + + Homes by Work Type + - v.toString()} - onValueChange={(v) => setSelectedType(String(v) || null)} - className="h-64" - /> + {grouped.length > 1 ? ( + v.toString()} + onValueChange={(v) => + setSelectedType( + v && typeof v === "object" && "planType" in v + ? String((v as any).planType) + : null + ) + } + className="h-64" + /> + ) : ( + `${v} home${v === 1 ? "" : "s"}`} + /> + )} - {/* Right: Details */} - + {/* Metrics */} + - - {selectedType - ? selectedType.replaceAll("_", " ") - : "Select a work type"} + + {mappedTitles[selectedType || "default"]} - - {selectedType && selectedData ? ( - <> -
- Average client contribution -
-
- Β£{formatNumber(selectedData.avgClientContribution || 0)} -
+ +
+

+ Total client contribution +

+

+ Β£{formatNumber(selectedData?.totalClientContribution || 0)} +

+

+ Avg per home Β£ + {formatNumber(selectedData?.avgClientContribution || 0)} +

+
-
Carbon savings
-
- {(selectedData.totalCarbon * 1000).toFixed(2)} kgCOβ‚‚e -
- -
Bill savings
-
- Β£{formatNumber(selectedData.totalBills)} -
- -
- Total estimated contribution -
-
- Β£ - {formatNumber( - selectedData.totalFunding + - (selectedType.includes("cavity") ? 1500 : 500) // example extra cost rule - )} -
- - ) : ( -

Click a bar to view details

- )} +
+
+

Funding

+

+ Β£{formatNumber(selectedData?.totalFunding || 0)} +

+
+
+

Carbon

+

+ {((selectedData?.totalCarbon || 0) * 1000).toFixed(2)} kgCOβ‚‚e +

+
+
+

Bills

+

+ Β£{formatNumber(selectedData?.totalBills || 0)} +

+
+
@@ -114,11 +167,11 @@ export function ProjectProposal({ plans }: { plans: any[] }) { export function DashboardSummary({ plans }: { plans: any[] }) { const totalFunding = plans.reduce((sum, p) => sum + (p.totalFunding || 0), 0); - const totalCarbonSavings = plans.reduce( + const totalCarbon = plans.reduce( (sum, p) => sum + (p.totalCarbonSavings || 0), 0 ); - const totalBillSavings = plans.reduce( + const totalBills = plans.reduce( (sum, p) => sum + (p.totalBillSavings || 0), 0 ); @@ -128,39 +181,42 @@ export function DashboardSummary({ plans }: { plans: any[] }) { { title: "Total Funding", value: `Β£${formatNumber(totalFunding)}`, - icon: , + subtitle: "Domna will help you unlock this much funding.", }, { - title: "Total Carbon Savings", - value: `${(totalCarbonSavings * 1000).toFixed(2)} kgCOβ‚‚e`, - icon: , + title: "Carbon Savings", + value: `${(totalCarbon * 1000).toFixed(2)} kgCOβ‚‚e`, + subtitle: "Your projects’ total estimated COβ‚‚e savings.", }, { - title: "Total Bill Savings", - value: `Β£${formatNumber(totalBillSavings)}`, - icon: , + title: "Bill Savings", + value: `Β£${formatNumber(totalBills)}`, + subtitle: "Expected total bill reductions across all homes.", }, { - title: "Number of Plans", + title: "Number of Homes", value: planCount, - icon: , + subtitle: "Properties included across your project plans.", }, ]; return ( -
- {cards.map((card) => ( - - +
+ {cards.map((c) => ( + + - {card.title} + {c.title} - {card.icon} -
- {card.value} +
+ {c.value}
+

{c.subtitle}

))} diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProposalColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProposalColumns.tsx index a53df76b..b6949a79 100644 --- a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProposalColumns.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProposalColumns.tsx @@ -1,13 +1,30 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { ArrowUpDown, Leaf, PoundSterling, Zap } from "lucide-react"; +import { ArrowUpDown } from "lucide-react"; import { Button } from "@/app/shadcn_components/ui/button"; import { formatNumber } from "@/app/utils"; import StatusBadge from "@/app/components/StatusBadge"; import { PlanWithTotals } from "./utils"; export const planColumns: ColumnDef[] = [ + { + accessorKey: "landlordPropertyId", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.landlordPropertyId || "β€”"} +
+ ), + }, { accessorKey: "address", header: ({ column }) => ( @@ -20,7 +37,9 @@ export const planColumns: ColumnDef[] = [ ), cell: ({ row }) => ( -
{row.original.address || "β€”"}
+
+ {row.original.address || "β€”"} +
), }, { @@ -35,7 +54,9 @@ export const planColumns: ColumnDef[] = [ ), cell: ({ row }) => ( -
{row.original.postcode || "β€”"}
+
+ {row.original.postcode || "β€”"} +
), }, @@ -50,7 +71,7 @@ export const planColumns: ColumnDef[] = [ isProperty={false} /> ) : ( - None + None )}
), @@ -67,7 +88,7 @@ export const planColumns: ColumnDef[] = [ ), cell: ({ row }) => ( -
+
{String(row.original.planType).replaceAll("_", " ")}
), @@ -77,7 +98,6 @@ export const planColumns: ColumnDef[] = [ header: () =>
Total Funding
, cell: ({ row }) => (
- Β£{formatNumber(row.original.totalFunding || 0)} @@ -89,8 +109,7 @@ export const planColumns: ColumnDef[] = [ header: () =>
Carbon Savings
, cell: ({ row }) => (
- - + {((row.original.totalCarbonSavings || 0) * 1000).toFixed(2)} kgCOβ‚‚e
@@ -101,11 +120,22 @@ export const planColumns: ColumnDef[] = [ header: () =>
Bill Savings
, cell: ({ row }) => (
- - + Β£{formatNumber(row.original.totalBillSavings || 0)}
), }, + { + accessorKey: "clientContribution", + header: () =>
Investment
, + cell: ({ row }) => ( +
+ + Β£{formatNumber(row.original.clientContribution || 0)} + +
+ ), + sortingFn: "alphanumeric", + }, ]; diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx index dc03a3c1..e15eb0bc 100644 --- a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx @@ -11,18 +11,26 @@ export default async function YourProjectsPage({ const { slug: portfolioId } = await params; const latestPlans = await getPlansWithTotals(portfolioId); - console.log("latestPlans", latestPlans); - return ( -
+
+
+

+ Your Retrofit Projects +

+

+ Review project performance and funding insights across your portfolio. +

+
+ -
-

- Plans Overview + +
+

+ Your Homes

-

+
); } diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts index ae2274aa..8adb9074 100644 --- a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts +++ b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts @@ -1,5 +1,7 @@ import { db } from "@/app/db/db"; import { sql } from "drizzle-orm"; +import { DOMNA_COST_MAP } from "@/app/domna/financials"; +import { PlanTypeEnum } from "@/app/db/schema/recommendations"; export interface PlanWithTotals extends Record { planId: string; @@ -14,6 +16,9 @@ export interface PlanWithTotals extends Record { totalFunding: number | null; totalCarbonSavings: number | null; totalBillSavings: number | null; + totalRecommendationCost?: number | null; + surveyCost?: number; + clientContribution?: number; } export async function getPlansWithTotals( @@ -30,7 +35,7 @@ export async function getPlansWithTotals( p.address AS "address", p.postcode AS "postcode", fp.scheme AS "fundingScheme", - COALESCE(SUM(r.estimated_cost), 0) AS "totalFunding", + COALESCE(fp.project_funding, 0) AS "totalFunding", COALESCE(SUM(r.co2_equivalent_savings), 0) AS "totalCarbonSavings", COALESCE(SUM(r.energy_cost_savings), 0) AS "totalBillSavings", COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost" @@ -61,9 +66,30 @@ export async function getPlansWithTotals( p.landlord_property_id, p.address, p.postcode, - fp.scheme + fp.scheme, + fp.project_funding ORDER BY pl.created_at DESC; `); - return result.rows; + const data = result.rows.map((plan) => { + const planType = plan.planType as PlanTypeEnum | null; + + const surveyCost = planType + ? (DOMNA_COST_MAP[planType] ?? DOMNA_COST_MAP.default) + : DOMNA_COST_MAP.default; + + const totalCost = plan.totalRecommendationCost ?? 0; + const funding = plan.totalFunding ?? 0; + + const rawContribution = totalCost + surveyCost - funding; + const clientContribution = rawContribution > 0 ? rawContribution : 0; + + return { + ...plan, + surveyCost, + clientContribution, + }; + }); + + return data; } diff --git a/src/app/portfolio/[slug]/remote-assessment/page.tsx b/src/app/portfolio/[slug]/remote-assessment/page.tsx index 0491ad6e..01d75c44 100644 --- a/src/app/portfolio/[slug]/remote-assessment/page.tsx +++ b/src/app/portfolio/[slug]/remote-assessment/page.tsx @@ -10,7 +10,6 @@ export default async function RemoteAssessmentPage(props: { const params = await props.params; const portfolioId = params.slug; - // πŸ”Ή Replace this with your real Drizzle query const scenarios = await getPortfolioScenarios(portfolioId); return ( diff --git a/src/lib/chartUtils.ts b/src/lib/chartUtils.ts new file mode 100644 index 00000000..87bae050 --- /dev/null +++ b/src/lib/chartUtils.ts @@ -0,0 +1,204 @@ +// Tremor Raw chartColors [v0.1.0] + +export type ColorUtility = "bg" | "stroke" | "fill" | "text"; + +export const chartColors = { + blue: { + bg: "bg-blue-500", + stroke: "stroke-blue-500", + fill: "fill-blue-500", + text: "text-blue-500", + }, + emerald: { + bg: "bg-emerald-500", + stroke: "stroke-emerald-500", + fill: "fill-emerald-500", + text: "text-emerald-500", + }, + violet: { + bg: "bg-violet-500", + stroke: "stroke-violet-500", + fill: "fill-violet-500", + text: "text-violet-500", + }, + amber: { + bg: "bg-amber-500", + stroke: "stroke-amber-500", + fill: "fill-amber-500", + text: "text-amber-500", + }, + gray: { + bg: "bg-gray-500", + stroke: "stroke-gray-500", + fill: "fill-gray-500", + text: "text-gray-500", + }, + cyan: { + bg: "bg-cyan-500", + stroke: "stroke-cyan-500", + fill: "fill-cyan-500", + text: "text-cyan-500", + }, + pink: { + bg: "bg-pink-500", + stroke: "stroke-pink-500", + fill: "fill-pink-500", + text: "text-pink-500", + }, + lime: { + bg: "bg-lime-500", + stroke: "stroke-lime-500", + fill: "fill-lime-500", + text: "text-lime-500", + }, + fuchsia: { + bg: "bg-fuchsia-500", + stroke: "stroke-fuchsia-500", + fill: "fill-fuchsia-500", + text: "text-fuchsia-500", + }, + brandblue: { + bg: "bg-[#14163d]", + stroke: "stroke-[#14163d]", + fill: "fill-[#14163d]", + text: "text-[#14163d]", + }, + midblue: { + bg: "bg-[#2d348f]", + stroke: "stroke-[#2d348f]", + fill: "fill-[#2d348f]", + text: "text-[#2d348f]", + }, + brandmidblue: { + bg: "bg-[#3943b7]", + stroke: "stroke-[#3943b7]", + fill: "fill-[#3943b7]", + text: "text-[#3943b7]", + }, + brandbrown: { + bg: "bg-[#c4a47c]", + stroke: "stroke-[#c4a47c]", + fill: "fill-[#c4a47c]", + text: "text-[#c4a47c]", + }, + brandtan: { + bg: "bg-[#d3b488]", + stroke: "stroke-[#d3b488]", + fill: "fill-[#d3b488]", + text: "text-[#d3b488]", + }, + brandlightblue: { + bg: "bg-[#eff6fc]", + stroke: "stroke-[#eff6fc]", + fill: "fill-[#eff6fc]", + text: "text-[#eff6fc]", + }, + epc_a: { + bg: "bg-[#117d58]", + stroke: "stroke-[#117d58]", + fill: "fill-[#117d58]", + text: "text-[#117d58]", + }, + epc_b: { + bg: "bg-[#2da55c]", + stroke: "stroke-[#2da55c]", + fill: "fill-[#2da55c]", + text: "text-[#2da55c]", + }, + epc_c: { + bg: "bg-[#8dbd40]", + stroke: "stroke-[#8dbd40]", + fill: "fill-[#8dbd40]", + text: "text-[#8dbd40]", + }, + epc_d: { + bg: "bg-[#f7cd14]", + stroke: "stroke-[#f7cd14]", + fill: "fill-[#f7cd14]", + text: "text-[#f7cd14]", + }, + epc_e: { + bg: "bg-[#f3a96a]", + stroke: "stroke-[#f3a96a]", + fill: "fill-[#f3a96a]", + text: "text-[#f3a96a]", + }, + epc_f: { + bg: "bg-[#ef8026]", + stroke: "stroke-[#ef8026]", + fill: "fill-[#ef8026]", + text: "text-[#ef8026]", + }, + epc_g: { + bg: "bg-[#e41e3b]", + stroke: "stroke-[#e41e3b]", + fill: "fill-[#e41e3b]", + text: "text-[#e41e3b]", + }, +} as const satisfies { + [color: string]: { + [key in ColorUtility]: string; + }; +}; + +export type AvailableChartColorsKeys = keyof typeof chartColors; + +export const AvailableChartColors: AvailableChartColorsKeys[] = Object.keys( + chartColors +) as Array; + +export const constructCategoryColors = ( + categories: string[], + colors: AvailableChartColorsKeys[] +): Map => { + const categoryColors = new Map(); + categories.forEach((category, index) => { + categoryColors.set(category, colors[index % colors.length]); + }); + return categoryColors; +}; + +export const getColorClassName = ( + color: AvailableChartColorsKeys, + type: ColorUtility +): string => { + const fallbackColor = { + bg: "bg-gray-500", + stroke: "stroke-gray-500", + fill: "fill-gray-500", + text: "text-gray-500", + }; + return chartColors[color]?.[type] ?? fallbackColor[type]; +}; + +// Tremor Raw getYAxisDomain [v0.0.0] + +export const getYAxisDomain = ( + autoMinValue: boolean, + minValue: number | undefined, + maxValue: number | undefined +) => { + const minDomain = autoMinValue ? "auto" : (minValue ?? 0); + const maxDomain = maxValue ?? "auto"; + return [minDomain, maxDomain]; +}; + +// Tremor Raw hasOnlyOneValueForKey [v0.1.0] + +export function hasOnlyOneValueForKey( + array: any[], + keyToCheck: string +): boolean { + const val: any[] = []; + + for (const obj of array) { + if (Object.prototype.hasOwnProperty.call(obj, keyToCheck)) { + val.push(obj[keyToCheck]); + if (val.length > 1) { + return false; + } + } + } + + return true; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 6c7dcee6..9cf48c6e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,42 @@ -import { type ClassValue, clsx } from "clsx" -import { twMerge } from "tailwind-merge" - -export function cn (...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) +// Tremor Raw cx [v0.0.0] +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); } + +export function cx(...args: ClassValue[]) { + return twMerge(clsx(...args)); +} + +// Tremor focusInput [v0.0.2] + +export const focusInput = [ + // base + "focus:ring-2", + // ring color + "focus:ring-blue-200 dark:focus:ring-blue-700/30", + // border color + "focus:border-blue-500 dark:focus:border-blue-700", +]; + +// Tremor Raw focusRing [v0.0.1] + +export const focusRing = [ + // base + "outline outline-offset-2 outline-0 focus-visible:outline-2", + // outline color + "outline-blue-500 dark:outline-blue-500", +]; + +// Tremor Raw hasErrorInput [v0.0.1] + +export const hasErrorInput = [ + // base + "ring-2", + // border color + "border-red-500 dark:border-red-700", + // ring color + "ring-red-200 dark:ring-red-700/30", +]; diff --git a/tailwind.config.js b/tailwind.config.js index 30f16286..317049d6 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -28,64 +28,6 @@ module.exports = { "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", }, colors: { - tremor: { - brand: { - faint: "colors.blue[50]", - muted: "colors.blue[200]", - subtle: "colors.blue[400]", - DEFAULT: "colors.blue[500]", - emphasis: "colors.blue[700]", - inverted: "colors.white", - }, - background: { - muted: "colors.gray[50]", - subtle: "colors.gray[100]", - DEFAULT: "colors.white", - emphasis: "colors.gray[700]", - }, - border: { - DEFAULT: "colors.gray[200]", - }, - ring: { - DEFAULT: "colors.gray[200]", - }, - content: { - subtle: "colors.gray[400]", - DEFAULT: "colors.gray[500]", - emphasis: "colors.gray[700]", - strong: "colors.gray[900]", - inverted: "colors.white", - }, - }, - "dark-tremor": { - brand: { - faint: "#0B1229", - muted: "colors.blue[950]", - subtle: "colors.blue[800]", - DEFAULT: "colors.blue[500]", - emphasis: "colors.blue[400]", - inverted: "colors.blue[950]", - }, - background: { - muted: "#131A2B", - subtle: "colors.gray[800]", - DEFAULT: "colors.gray[900]", - emphasis: "colors.gray[300]", - }, - border: { - DEFAULT: "colors.gray[800]", - }, - ring: { - DEFAULT: "colors.gray[800]", - }, - content: { - subtle: "colors.gray[600]", - DEFAULT: "colors.gray[500]", - emphasis: "colors.gray[200]", - strong: "colors.gray[50]", - inverted: "colors.gray[950]", - }, - }, epc_a: "#117d58", epc_b: "#2da55c", epc_c: "#8dbd40", @@ -146,11 +88,6 @@ module.exports = { brandmidblue: "#3943b7", brandlightblue: "#00a9f4", }, - borderRadius: { - "tremor-small": "0.375rem", - "tremor-default": "0.5rem", - "tremor-full": "9999px", - }, fontFamily: { sans: ["var(--font-sans)", ...fontFamily.sans], }, @@ -197,44 +134,6 @@ module.exports = { maxWidth: { "8xl": "90rem", }, - boxShadow: { - "tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", - "tremor-card": - "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", - "tremor-dropdown": - "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", - "dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", - "dark-tremor-card": - "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", - "dark-tremor-dropdown": - "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", - }, - fontSize: { - "tremor-label": [ - "0.75rem", - { - lineHeight: "1rem", - }, - ], - "tremor-default": [ - "0.875rem", - { - lineHeight: "1.25rem", - }, - ], - "tremor-title": [ - "1.125rem", - { - lineHeight: "1.75rem", - }, - ], - "tremor-metric": [ - "1.875rem", - { - lineHeight: "2.25rem", - }, - ], - }, }, }, variants: { @@ -270,91 +169,6 @@ module.exports = { pattern: /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, }, - // This enables the EPC colours for tremor. They're listed from EPC G -> A - "bg-[#e41e3b]", - "border-[#e41e3b]", - "hover:bg-[#e41e3b]", - "hover:border-[#e41e3b]", - "hover:text-[#e41e3b]", - "fill-[#e41e3b]", - "ring-[#e41e3b]", - "stroke-[#e41e3b]", - "text-[#e41e3b]", - "ui-selected:bg-[#e41e3b]", - "ui-selected:border-[#e41e3b]", - "ui-selected:text-[#e41e3b]", - "bg-[#ef8026]", - "border-[#ef8026]", - "hover:bg-[#ef8026]", - "hover:border-[#ef8026]", - "hover:text-[#ef8026]", - "fill-[#ef8026]", - "ring-[#ef8026]", - "stroke-[#ef8026]", - "text-[#ef8026]", - "ui-selected:bg-[#ef8026]", - "ui-selected:border-[#ef8026]", - "ui-selected:text-[#ef8026]", - "bg-[#f3a96a]", - "border-[#f3a96a]", - "hover:bg-[#f3a96a]", - "hover:border-[#f3a96a]", - "hover:text-[#f3a96a]", - "fill-[#f3a96a]", - "ring-[#f3a96a]", - "stroke-[#f3a96a]", - "text-[#f3a96a]", - "ui-selected:bg-[#f3a96a]", - "ui-selected:border-[#f3a96a]", - "ui-selected:text-[#f3a96a]", - "bg-[#f7cd14]", - "border-[#f7cd14]", - "hover:bg-[#f7cd14]", - "hover:border-[#f7cd14]", - "hover:text-[#f7cd14]", - "fill-[#f7cd14]", - "ring-[#f7cd14]", - "stroke-[#f7cd14]", - "text-[#f7cd14]", - "ui-selected:bg-[#f7cd14]", - "ui-selected:border-[#f7cd14]", - "ui-selected:text-[#f7cd14]", - "bg-[#8dbd40]", - "border-[#8dbd40]", - "hover:bg-[#8dbd40]", - "hover:border-[#8dbd40]", - "hover:text-[#8dbd40]", - "fill-[#8dbd40]", - "ring-[#8dbd40]", - "stroke-[#8dbd40]", - "text-[#8dbd40]", - "ui-selected:bg-[#8dbd40]", - "ui-selected:border-[#8dbd40]", - "ui-selected:text-[#8dbd40]", - "bg-[#2da55c]", - "border-[#2da55c]", - "hover:bg-[#2da55c]", - "hover:border-[#2da55c]", - "hover:text-[#2da55c]", - "fill-[#2da55c]", - "ring-[#2da55c]", - "stroke-[#2da55c]", - "text-[#2da55c]", - "ui-selected:bg-[#2da55c]", - "ui-selected:border-[#2da55c]", - "ui-selected:text-[#2da55c]", - "bg-[#117d58]", - "border-[#117d58]", - "hover:bg-[#117d58]", - "hover:border-[#117d58]", - "hover:text-[#117d58]", - "fill-[#117d58]", - "ring-[#117d58]", - "stroke-[#117d58]", - "text-[#117d58]", - "ui-selected:bg-[#117d58]", - "ui-selected:border-[#117d58]", - "ui-selected:text-[#117d58]", ], plugins: [ function ({ addVariant }) { From 03c6425fd7aa590036f293bed2a912a6e8815948 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 31 Oct 2025 23:18:21 +0000 Subject: [PATCH 08/20] project ui live --- .../portfolio/summary/EpcBarChart.tsx | 14 +- src/app/globals.css | 128 ---------- .../temp-reporting/ProjectProposal.tsx | 93 ++++--- .../(portfolio)/temp-reporting/page.tsx | 19 +- tailwind.config.js | 226 ++++++++++++++++++ 5 files changed, 302 insertions(+), 178 deletions(-) diff --git a/src/app/components/portfolio/summary/EpcBarChart.tsx b/src/app/components/portfolio/summary/EpcBarChart.tsx index 6c26bab5..f111afa2 100644 --- a/src/app/components/portfolio/summary/EpcBarChart.tsx +++ b/src/app/components/portfolio/summary/EpcBarChart.tsx @@ -23,13 +23,13 @@ const EpcBarChart = ({ index="name" categories={["G", "F", "E", "D", "C", "B", "A"]} // Each treated as a separate series colors={[ - "epc_g", // Color for 'G' - "epc_f", // Color for 'F' - "epc_e", // Color for 'E' - "epc_d", // Color for 'D' - "epc_c", // Color for 'C' - "epc_b", // Color for 'B' - "epc_a", // Color for 'A' + "#e41e3b", // Color for 'G' + "#ef8026", // Color for 'F' + "#f3a96a", // Color for 'E' + "#f7cd14", // Color for 'D' + "#8dbd40", // Color for 'C' + "#2da55c", // Color for 'B' + "#117d58", // Color for 'A' ]} valueFormatter={dataFormatter} yAxisWidth={48} diff --git a/src/app/globals.css b/src/app/globals.css index 6d892a40..3b98d12f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,5 +1,3 @@ -@import "tailwindcss"; -@plugin "@tailwindcss/forms"; @tailwind base; @tailwind components; @tailwind utilities; @@ -120,129 +118,3 @@ .animate-spin { animation: spin 1s linear infinite; } - -@custom-variant dark (&:where(.dark, .dark *)); - -@theme { - --animate-hide: hide 150ms cubic-bezier(0.16, 1, 0.3, 1); - --animate-slide-down-and-fade: slideDownAndFade 150ms - cubic-bezier(0.16, 1, 0.3, 1); - --animate-slide-left-and-fade: slideLeftAndFade 150ms - cubic-bezier(0.16, 1, 0.3, 1); - --animate-slide-up-and-fade: slideUpAndFade 150ms - cubic-bezier(0.16, 1, 0.3, 1); - --animate-slide-right-and-fade: slideRightAndFade 150ms - cubic-bezier(0.16, 1, 0.3, 1); - --animate-accordion-open: accordionOpen 150ms cubic-bezier(0.87, 0, 0.13, 1); - --animate-accordion-close: accordionClose 150ms cubic-bezier(0.87, 0, 0.13, 1); - --animate-dialog-overlay-show: dialogOverlayShow 150ms - cubic-bezier(0.16, 1, 0.3, 1); - --animate-dialog-content-show: dialogContentShow 150ms - cubic-bezier(0.16, 1, 0.3, 1); - --animate-drawer-slide-left-and-fade: drawerSlideLeftAndFade 150ms - cubic-bezier(0.16, 1, 0.3, 1); - --animate-drawer-slide-right-and-fade: drawerSlideRightAndFade 150ms ease-in; - - @keyframes hide { - from { - opacity: 1; - } - to { - opacity: 0; - } - } - @keyframes slideDownAndFade { - from { - opacity: 0; - transform: translateY(-6px); - } - to { - opacity: 1; - transform: translateY(0); - } - } - @keyframes slideLeftAndFade { - from { - opacity: 0; - transform: translateX(6px); - } - to { - opacity: 1; - transform: translateX(0); - } - } - @keyframes slideUpAndFade { - from { - opacity: 0; - transform: translateY(6px); - } - to { - opacity: 1; - transform: translateY(0); - } - } - @keyframes slideRightAndFade { - from { - opacity: 0; - transform: translateX(-6px); - } - to { - opacity: 1; - transform: translateX(0); - } - } - @keyframes accordionOpen { - from { - height: 0px; - } - to { - height: var(--radix-accordion-content-height); - } - } - @keyframes accordionClose { - from { - height: var(--radix-accordion-content-height); - } - to { - height: 0px; - } - } - @keyframes dialogOverlayShow { - from { - opacity: 0; - } - to { - opacity: 1; - } - } - @keyframes dialogContentShow { - from { - opacity: 0; - transform: translate(-50%, -45%) scale(0.95); - } - to { - opacity: 1; - transform: translate(-50%, -50%) scale(1); - } - } - @keyframes drawerSlideLeftAndFade { - from { - opacity: 0; - transform: translateX(100%); - } - to { - opacity: 1; - transform: translateX(0); - } - } - @keyframes drawerSlideRightAndFade { - from { - opacity: 1; - transform: translateX(0); - } - to { - opacity: 0; - transform: translateX(100%); - } - } -} diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx index c71a2732..3a0be4e0 100644 --- a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx @@ -9,6 +9,8 @@ import { } from "@/app/shadcn_components/ui/card"; import { BarChart, DonutChart } from "@tremor/react"; import { formatNumber } from "@/app/utils"; +import { PoundSterling, Leaf, Zap, Home } from "lucide-react"; +import { motion } from "framer-motion"; const mappedTitles: Record = { solar_eco4: "Solar ECO4 project metrics", @@ -71,19 +73,10 @@ export function ProjectProposal({ plans }: { plans: any[] }) { ? grouped[0] : null; - const domnaPalette = [ - "#14163d", // brandblue (deep navy) - "#2d348f", // midblue - "#3943b7", // brandmidblue - "#c4a47c", // brandbrown - "#d3b488", // brandtan - "#eff6fc", // brandlightblue (for subtle items) - ]; - return ( -
+
{/* Chart */} - + Homes by Work Type @@ -95,7 +88,7 @@ export function ProjectProposal({ plans }: { plans: any[] }) { data={grouped} index="planType" categories={["count"]} - colors={domnaPalette} + colors={["#2d348f", "#14163d", "#3943b7", "#5d6be0"]} valueFormatter={(v) => v.toString()} onValueChange={(v) => setSelectedType( @@ -111,7 +104,7 @@ export function ProjectProposal({ plans }: { plans: any[] }) { data={grouped} category="count" index="planType" - colors={["midblue"]} + colors={["#2d348f", "#14163d", "#3943b7", "#5d6be0"]} valueFormatter={(v) => `${v} home${v === 1 ? "" : "s"}`} /> )} @@ -130,7 +123,7 @@ export function ProjectProposal({ plans }: { plans: any[] }) {

Total client contribution

-

+

Β£{formatNumber(selectedData?.totalClientContribution || 0)}

@@ -177,49 +170,81 @@ export function DashboardSummary({ plans }: { plans: any[] }) { ); const planCount = plans.length; - const cards = [ + const cards: { + title: string; + value: string | number; + subtitle: string; + icon: React.ElementType; + }[] = [ { title: "Total Funding", value: `Β£${formatNumber(totalFunding)}`, subtitle: "Domna will help you unlock this much funding.", + icon: PoundSterling, // βœ… no }, { title: "Carbon Savings", value: `${(totalCarbon * 1000).toFixed(2)} kgCOβ‚‚e`, subtitle: "Your projects’ total estimated COβ‚‚e savings.", + icon: Leaf, }, { title: "Bill Savings", value: `Β£${formatNumber(totalBills)}`, subtitle: "Expected total bill reductions across all homes.", + icon: Zap, }, { title: "Number of Homes", value: planCount, subtitle: "Properties included across your project plans.", + icon: Home, }, ]; return ( -

- {cards.map((c) => ( - - - - {c.title} - - - -
- {c.value} -
-

{c.subtitle}

-
-
- ))} +
+ {cards.map((c) => { + const Icon = c.icon; + return ( + + +
+ + + +
+ + {c.title} + +
+ + +
+ {c.value} +
+

{c.subtitle}

+
+
+ ); + })}
); } diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx index e15eb0bc..cef20f06 100644 --- a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx @@ -12,21 +12,22 @@ export default async function YourProjectsPage({ const latestPlans = await getPlansWithTotals(portfolioId); return ( -
-
-

- Your Retrofit Projects -

-

- Review project performance and funding insights across your portfolio. +

+
+
+ Project Overview +
+

+ Summary of funding, carbon savings, and household metrics.

-
+
+
-

+

Your Homes

diff --git a/tailwind.config.js b/tailwind.config.js index 317049d6..c977fab8 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -28,6 +28,64 @@ module.exports = { "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", }, colors: { + tremor: { + brand: { + faint: "colors.blue[50]", + muted: "colors.blue[200]", + subtle: "colors.blue[400]", + DEFAULT: "colors.blue[500]", + emphasis: "colors.blue[700]", + inverted: "colors.white", + }, + background: { + muted: "colors.gray[50]", + subtle: "colors.gray[100]", + DEFAULT: "colors.white", + emphasis: "colors.gray[700]", + }, + border: { + DEFAULT: "colors.gray[200]", + }, + ring: { + DEFAULT: "colors.gray[200]", + }, + content: { + subtle: "colors.gray[400]", + DEFAULT: "colors.gray[500]", + emphasis: "colors.gray[700]", + strong: "colors.gray[900]", + inverted: "colors.white", + }, + }, + "dark-tremor": { + brand: { + faint: "#0B1229", + muted: "colors.blue[950]", + subtle: "colors.blue[800]", + DEFAULT: "colors.blue[500]", + emphasis: "colors.blue[400]", + inverted: "colors.blue[950]", + }, + background: { + muted: "#131A2B", + subtle: "colors.gray[800]", + DEFAULT: "colors.gray[900]", + emphasis: "colors.gray[300]", + }, + border: { + DEFAULT: "colors.gray[800]", + }, + ring: { + DEFAULT: "colors.gray[800]", + }, + content: { + subtle: "colors.gray[600]", + DEFAULT: "colors.gray[500]", + emphasis: "colors.gray[200]", + strong: "colors.gray[50]", + inverted: "colors.gray[950]", + }, + }, epc_a: "#117d58", epc_b: "#2da55c", epc_c: "#8dbd40", @@ -88,6 +146,11 @@ module.exports = { brandmidblue: "#3943b7", brandlightblue: "#00a9f4", }, + borderRadius: { + "tremor-small": "0.375rem", + "tremor-default": "0.5rem", + "tremor-full": "9999px", + }, fontFamily: { sans: ["var(--font-sans)", ...fontFamily.sans], }, @@ -134,6 +197,44 @@ module.exports = { maxWidth: { "8xl": "90rem", }, + boxShadow: { + "tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "tremor-card": + "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + "tremor-dropdown": + "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + "dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "dark-tremor-card": + "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + "dark-tremor-dropdown": + "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + }, + fontSize: { + "tremor-label": [ + "0.75rem", + { + lineHeight: "1rem", + }, + ], + "tremor-default": [ + "0.875rem", + { + lineHeight: "1.25rem", + }, + ], + "tremor-title": [ + "1.125rem", + { + lineHeight: "1.75rem", + }, + ], + "tremor-metric": [ + "1.875rem", + { + lineHeight: "2.25rem", + }, + ], + }, }, }, variants: { @@ -169,6 +270,131 @@ module.exports = { pattern: /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, }, + // This enables the EPC colours for tremor. They're listed from EPC G -> A + "bg-[#e41e3b]", + "border-[#e41e3b]", + "hover:bg-[#e41e3b]", + "hover:border-[#e41e3b]", + "hover:text-[#e41e3b]", + "fill-[#e41e3b]", + "ring-[#e41e3b]", + "stroke-[#e41e3b]", + "text-[#e41e3b]", + "ui-selected:bg-[#e41e3b]", + "ui-selected:border-[#e41e3b]", + "ui-selected:text-[#e41e3b]", + "bg-[#ef8026]", + "border-[#ef8026]", + "hover:bg-[#ef8026]", + "hover:border-[#ef8026]", + "hover:text-[#ef8026]", + "fill-[#ef8026]", + "ring-[#ef8026]", + "stroke-[#ef8026]", + "text-[#ef8026]", + "ui-selected:bg-[#ef8026]", + "ui-selected:border-[#ef8026]", + "ui-selected:text-[#ef8026]", + "bg-[#f3a96a]", + "border-[#f3a96a]", + "hover:bg-[#f3a96a]", + "hover:border-[#f3a96a]", + "hover:text-[#f3a96a]", + "fill-[#f3a96a]", + "ring-[#f3a96a]", + "stroke-[#f3a96a]", + "text-[#f3a96a]", + "ui-selected:bg-[#f3a96a]", + "ui-selected:border-[#f3a96a]", + "ui-selected:text-[#f3a96a]", + "bg-[#f7cd14]", + "border-[#f7cd14]", + "hover:bg-[#f7cd14]", + "hover:border-[#f7cd14]", + "hover:text-[#f7cd14]", + "fill-[#f7cd14]", + "ring-[#f7cd14]", + "stroke-[#f7cd14]", + "text-[#f7cd14]", + "ui-selected:bg-[#f7cd14]", + "ui-selected:border-[#f7cd14]", + "ui-selected:text-[#f7cd14]", + "bg-[#8dbd40]", + "border-[#8dbd40]", + "hover:bg-[#8dbd40]", + "hover:border-[#8dbd40]", + "hover:text-[#8dbd40]", + "fill-[#8dbd40]", + "ring-[#8dbd40]", + "stroke-[#8dbd40]", + "text-[#8dbd40]", + "ui-selected:bg-[#8dbd40]", + "ui-selected:border-[#8dbd40]", + "ui-selected:text-[#8dbd40]", + "bg-[#2da55c]", + "border-[#2da55c]", + "hover:bg-[#2da55c]", + "hover:border-[#2da55c]", + "hover:text-[#2da55c]", + "fill-[#2da55c]", + "ring-[#2da55c]", + "stroke-[#2da55c]", + "text-[#2da55c]", + "ui-selected:bg-[#2da55c]", + "ui-selected:border-[#2da55c]", + "ui-selected:text-[#2da55c]", + "bg-[#117d58]", + "border-[#117d58]", + "hover:bg-[#117d58]", + "hover:border-[#117d58]", + "hover:text-[#117d58]", + "fill-[#117d58]", + "ring-[#117d58]", + "stroke-[#117d58]", + "text-[#117d58]", + "ui-selected:bg-[#117d58]", + "ui-selected:border-[#117d58]", + "ui-selected:text-[#117d58]", + + // blue colours for graphs - eff6fc + "bg-[#eff6fc]", + "border-[#eff6fc]", + "hover:bg-[#eff6fc]", + "hover:border-[#eff6fc]", + "hover:text-[#eff6fc]", + "fill-[#eff6fc]", + "ring-[#eff6fc]", + "stroke-[#eff6fc]", + "text-[#eff6fc]", + "ui-selected:bg-[#eff6fc]", + "ui-selected:border-[#eff6fc]", + "ui-selected:text-[#eff6fc]", + // brand blues for Tremor charts + "bg-[#14163d]", + "border-[#14163d]", + "fill-[#14163d]", + "stroke-[#14163d]", + "text-[#14163d]", + "bg-[#2d348f]", + "border-[#2d348f]", + "fill-[#2d348f]", + "stroke-[#2d348f]", + "text-[#2d348f]", + "bg-[#3943b7]", + "border-[#3943b7]", + "fill-[#3943b7]", + "stroke-[#3943b7]", + "text-[#3943b7]", + "bg-[#5d6be0]", + "border-[#5d6be0]", + "fill-[#5d6be0]", + "stroke-[#5d6be0]", + "text-[#5d6be0]", + "bg-[#1f3abdff]", + "border-[#1f3abdff]", + "fill-[#1f3abdff]", + "stroke-[#1f3abdff]", + "text-[#1f3abdff]", ], plugins: [ function ({ addVariant }) { From 5512b020700c2f32435f3fa98ac770402582aab9 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 31 Oct 2025 23:56:20 +0000 Subject: [PATCH 09/20] minor styling --- .../temp-reporting/ProjectProposal.tsx | 18 +++++++++--------- .../[slug]/(portfolio)/temp-reporting/utils.ts | 11 ++++++++--- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx index 3a0be4e0..7dbffd19 100644 --- a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx @@ -74,9 +74,9 @@ export function ProjectProposal({ plans }: { plans: any[] }) { : null; return ( -
+
{/* Chart */} - + Homes by Work Type @@ -112,7 +112,7 @@ export function ProjectProposal({ plans }: { plans: any[] }) { {/* Metrics */} - + {mappedTitles[selectedType || "default"]} @@ -135,19 +135,19 @@ export function ProjectProposal({ plans }: { plans: any[] }) {

Funding

-

+

Β£{formatNumber(selectedData?.totalFunding || 0)}

Carbon

-

+

{((selectedData?.totalCarbon || 0) * 1000).toFixed(2)} kgCOβ‚‚e

Bills

-

+

Β£{formatNumber(selectedData?.totalBills || 0)}

@@ -185,13 +185,13 @@ export function DashboardSummary({ plans }: { plans: any[] }) { { title: "Carbon Savings", value: `${(totalCarbon * 1000).toFixed(2)} kgCOβ‚‚e`, - subtitle: "Your projects’ total estimated COβ‚‚e savings.", + subtitle: "Your projects’ total estimated COβ‚‚e savings, per year.", icon: Leaf, }, { title: "Bill Savings", value: `Β£${formatNumber(totalBills)}`, - subtitle: "Expected total bill reductions across all homes.", + subtitle: "Expected total bill reductions across all homes, per year.", icon: Zap, }, { @@ -237,7 +237,7 @@ export function DashboardSummary({ plans }: { plans: any[] }) { -
+
{c.value}

{c.subtitle}

diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts index 8adb9074..70b1cb54 100644 --- a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts +++ b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts @@ -14,6 +14,7 @@ export interface PlanWithTotals extends Record { postcode: string | null; fundingScheme: string | null; totalFunding: number | null; + totalUplift: number | null; totalCarbonSavings: number | null; totalBillSavings: number | null; totalRecommendationCost?: number | null; @@ -36,6 +37,7 @@ export async function getPlansWithTotals( p.postcode AS "postcode", fp.scheme AS "fundingScheme", COALESCE(fp.project_funding, 0) AS "totalFunding", + COALESCE(fp.total_uplift, 0) AS "totalUplift", COALESCE(SUM(r.co2_equivalent_savings), 0) AS "totalCarbonSavings", COALESCE(SUM(r.energy_cost_savings), 0) AS "totalBillSavings", COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost" @@ -67,7 +69,8 @@ export async function getPlansWithTotals( p.address, p.postcode, fp.scheme, - fp.project_funding + fp.project_funding, + fp.total_uplift ORDER BY pl.created_at DESC; `); @@ -79,13 +82,15 @@ export async function getPlansWithTotals( : DOMNA_COST_MAP.default; const totalCost = plan.totalRecommendationCost ?? 0; - const funding = plan.totalFunding ?? 0; + const funding = (plan.totalFunding ?? 0) + (plan.totalUplift ?? 0); + const uplift = plan.totalUplift ?? 0; - const rawContribution = totalCost + surveyCost - funding; + const rawContribution = totalCost + surveyCost - funding - uplift; const clientContribution = rawContribution > 0 ? rawContribution : 0; return { ...plan, + totalFunding: funding, // overwrite surveyCost, clientContribution, }; From 7a6b6c12bb685d62e8ad6b74b37f7af273428c17 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 3 Nov 2025 09:13:19 +0000 Subject: [PATCH 10/20] gets portfolio id from db --- .devcontainer/post-install.sh | 2 +- .../db/schema/crm/hubspot_company_table.ts | 1 + .../live-projects/SurveyedResultsPieChart.tsx | 1 - .../[slug]/(portfolio)/live-projects/page.tsx | 46 +++++++++++++------ 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/.devcontainer/post-install.sh b/.devcontainer/post-install.sh index 9f018f38..c847f6d5 100644 --- a/.devcontainer/post-install.sh +++ b/.devcontainer/post-install.sh @@ -1 +1 @@ -npm install; \ No newline at end of file +npm install; diff --git a/src/app/db/schema/crm/hubspot_company_table.ts b/src/app/db/schema/crm/hubspot_company_table.ts index 71aa4193..618da191 100644 --- a/src/app/db/schema/crm/hubspot_company_table.ts +++ b/src/app/db/schema/crm/hubspot_company_table.ts @@ -16,3 +16,4 @@ export const hubspotCompanyData = pgTable("hubspot_company_data", { .$onUpdate(() => new Date()) .notNull(), }); + diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/SurveyedResultsPieChart.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/SurveyedResultsPieChart.tsx index 7cb0d3c0..d5919abc 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/SurveyedResultsPieChart.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/SurveyedResultsPieChart.tsx @@ -7,7 +7,6 @@ interface SurveyedPieChartProps { deals: Record[]; onOpenTable?: (outcome: string, filteredDeals: Record[]) => void; } - export default function SurveyedPieChart({ deals, onOpenTable, diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx index 08a5a3b9..7eb6dedf 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx @@ -3,11 +3,13 @@ import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { redirect } from "next/navigation"; import { surveyDB } from "../../../../db/surveyDB/connection"; import { hubspotDealData } from "../../../../db/schema/crm/hubspot_deal_table"; +import { hubspotCompanyData } from "@/app/db/schema/crm/hubspot_company_table"; import { eq } from "drizzle-orm"; -import Reports from "./Report"; import LiveTracker from "./Report"; -const Demo = async () => { +export default async function Demo(props: { + params: Promise<{ slug: string }>; +}) { const user = await getServerSession(AuthOptions); if (!user?.user) { @@ -15,20 +17,38 @@ const Demo = async () => { redirect("/"); } - // Abri company id - const companyId = "237615001799"; + const { slug: portfolioId } = await props.params; + // Fetch the single company + const [company] = await surveyDB + .select() + .from(hubspotCompanyData) + .where(eq(hubspotCompanyData.groupId, portfolioId)); + + if (!company) { + console.log("No company found for this portfolioId"); + return ( +
+ No information to show. +
+ ); + } + + // Fetch deals related to that company const deals = await surveyDB .select() .from(hubspotDealData) - .where(eq(hubspotDealData.companyId, companyId)); - console.log(deals); + .where(eq(hubspotDealData.companyId, company.companyId)); - return ( - <> - - - ); -}; + console.log("Deals:", deals); -export default Demo; \ No newline at end of file + if (!deals || deals.length === 0) { + return ( +
+ No information to show. +
+ ); + } + + return ; +} From 0003102ca152539933ca2291167436a0cd032fef Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 3 Nov 2025 09:21:01 +0000 Subject: [PATCH 11/20] save --- .../live-projects/SurveyedResultsPieChart.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/SurveyedResultsPieChart.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/SurveyedResultsPieChart.tsx index d5919abc..95582edf 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/SurveyedResultsPieChart.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/SurveyedResultsPieChart.tsx @@ -61,13 +61,15 @@ export default function SurveyedPieChart({ index="name" valueFormatter={(n) => `${n.toLocaleString()}`} colors={[ - "indigo", - "cyan", - "emerald", - "amber", - "rose", - "violet", - "gray", + "sky", // light airy blue + "cyan", // bright modern blue + "blue", // brand-level core blue + "indigo", // deeper professional tone + "violet", // slight bluish-purple contrast + "slate", // muted cool gray-blue + "lightBlue", // soft complementary blue + "navy", // deep accent + "azure", // fresh pop for clarity ]} className="w-64 h-64 cursor-pointer" onValueChange={handleClick} From b8caeca263e3da5b9e26e6e70646d332eaaf594c Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 3 Nov 2025 10:23:54 +0000 Subject: [PATCH 12/20] added the surveyor button changes --- .../components/building-passport/Toolbar.tsx | 4 +- .../live-projects/DealStageChart.tsx | 77 +++++------ .../(portfolio)/live-projects/Report.tsx | 126 ++++++------------ .../live-projects/SurveyedResultsPieChart.tsx | 41 +++--- .../(portfolio)/live-projects/TableViewer.tsx | 40 +++--- .../[slug]/(portfolio)/live-projects/page.tsx | 43 ++++-- .../[slug]/components/BookSurveyModal.tsx | 13 +- tailwind.config.js | 4 +- 8 files changed, 155 insertions(+), 193 deletions(-) diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index 966f2831..48f7d2e4 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -184,8 +184,8 @@ export function Toolbar({ setShowToast(false)} - message="Survey Booked Successfully!" - subtext="Your Survey Request is with Domna and we will be in contact. πŸŽ‰" + message="Survey Request Recieved!" + subtext="We'll be in contact soon. πŸŽ‰" /> ); diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/DealStageChart.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/DealStageChart.tsx index 4a6bd739..b4dec63c 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/DealStageChart.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/DealStageChart.tsx @@ -1,17 +1,16 @@ "use client"; -import { useState, useMemo } from "react"; +import { useMemo } from "react"; import { BarList, Card, Title } from "@tremor/react"; -import TableViewer from "./TableViewer"; const STAGE_ORDER = [ - "Initial Planning", // 0 - "Booking Team to contact Tenant", // 1 - "Survey in Progress", // 2 - "Not viable", // 3 - "Needs HA Support", // 4 - "Coordination + Design", // 5 - "Ready to be installed" //7 + "Initial Planning", + "Booking Team to contact Tenant", + "Survey in Progress", + "Not viable", + "Needs HA Support", + "Coordination + Design", + "Ready to be installed", ]; const STAGE_LABELS: Record = { @@ -36,37 +35,18 @@ const STAGE_LABELS: Record = { "3061261536": STAGE_ORDER[4], "2571417798": "[Ops] Surveyed under 2019 - Needs Re-survey", "1617223914": STAGE_ORDER[5], - "1887736000": "[Deprecated, please don't use] Files Missing From Assessor", + "1887736000": "[Deprecated] Files Missing From Assessor", "1617223916": "[Ops] Properties to Review Manually", "2628341989": STAGE_ORDER[2], - "3441170637": STAGE_ORDER[2], // check if assessor or coordination + "3441170637": STAGE_ORDER[2], "2628233422": STAGE_ORDER[5], "1887735999": STAGE_ORDER[4], - "1960060104": "[Ops] HA Informed", - "1960060105": "[Ops] HA Works Scheduled", - "1960060106": "[Ops] HA Works Complete", - "1668803772": "[Ops] ERF Delivered to HA", - "1668803773": "[Ops] ERF Signed", - "2769407183": "[Ops] PV - Needs Heating Upgrade (Pre EPR D)", - "2769407184": "[Ops] Talk to client, Needs Heating Upgrade (Pre EPR C)", "2702650617": STAGE_ORDER[5], "2473886962": STAGE_ORDER[5], "3016601828": STAGE_ORDER[4], - "3389868276": "[Engagement Team] Blocked - Needs Completion of Pilot", - "3389880508": "[Engagement Team] Blocked - Installer Negotiation", - "3399016689": "[Engagement Team] Eligible but blocked - part of incomplete flat", - "1668803774": STAGE_ORDER[6], // Ready for Invoicing - "3440363736": STAGE_ORDER[6], // [Finance] Needs Invoicing - Files Sent - "1618526429": "[Ops] Invoiced - Send Files to Installer", - "3080225005": "[Ops] Files Sent to Installer", - "1961258215": "[Ops] Installer Cancelled - Finalized", - "1961258214": "[Ops] Installer Cancelled - In Progress", - "1961258213": "[Ops] Install Scheduled", - "1617223918": "[Ops] Install Complete", - "1961258216": "[Compliance] Lodgement Complete", - "1961258217": "[Compliance] Documentation Sent to HA", - "3027432668": "[Team ???] Submitted to " -} + "1668803774": STAGE_ORDER[6], + "3440363736": STAGE_ORDER[6], +}; interface DealStageChartProps { deals: any[]; @@ -97,26 +77,35 @@ export function DealStageChart({ deals, onOpenTable }: DealStageChartProps) { }); }, [deals]); - // handle click event const handleBarClick = (value: { name: string; value: number }) => { const filtered = deals.filter((d) => { const stageId = d.dealstage || "unknown"; const stageName = STAGE_LABELS[stageId] || "Unknown Stage"; return stageName === value.name; }); - onOpenTable?.(value.name, filtered); }; return ( - - Project Progress - + +
+ + Project Progress by Stage + +

+ Click a bar to view related properties +

+
+ +
+ +
); -} \ No newline at end of file +} diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx index 91bad8b3..09c29993 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx @@ -9,32 +9,26 @@ interface ReportsProps { deals: Record[]; } -// 🟩 Stage mapping: β€œMajor Condition Issues” = dealstage 3061261536 const MAJOR_CONDITION_STAGE_ID = "3061261536"; export default function LiveTracker({ deals }: ReportsProps) { - const [openTable, setOpenTable] = useState<{ - stage: string; - data: any[]; - } | null>(null); + const [openTable, setOpenTable] = useState<{ stage: string; data: any[] } | null>(null); const handleOpenTable = (stage: string, filteredDeals: any[]) => { setOpenTable({ stage, data: filteredDeals }); }; - if (!deals || deals.length === 0) { + if (!deals?.length) { return ( -
+
No deal data available.
); } - // Group deals by projectCode const groupedDeals = deals.reduce((acc, deal) => { const project = deal.projectCode || "Unknown Project"; - if (!acc[project]) acc[project] = []; - acc[project].push(deal); + (acc[project] ||= []).push(deal); return acc; }, {} as Record); @@ -42,77 +36,59 @@ export default function LiveTracker({ deals }: ReportsProps) { const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]); const currentDeals = groupedDeals[currentProjectCode]; - // πŸ”Ή Compute overall summary (across all projects) const totalProperties = deals.length; - const majorConditionDeals = deals.filter( - (d) => d.dealstage === MAJOR_CONDITION_STAGE_ID - ); + const majorConditionDeals = deals.filter(d => d.dealstage === MAJOR_CONDITION_STAGE_ID); const majorIssues = majorConditionDeals.length; const majorPercent = ((majorIssues / totalProperties) * 100).toFixed(1); - // πŸ”Ή Click handlers - const handleTotalClick = () => { - console.log("Opening all deals (global)"); - handleOpenTable("All Properties", deals); - }; - - const handleMajorClick = () => { - console.log("Opening all Major Condition Issues (global)"); - handleOpenTable("Major Condition Issues", majorConditionDeals); - }; - return ( -
- {/* πŸ”Ή Global Overview Row */} -
-

+
+ {/* 🌍 Global Portfolio Overview */} +
+

🌍 Global Portfolio Overview

-
- {/* Total Properties */} +
+ {/* Total */} - {/* Major Condition Issues */} + {/* Major Issues */} - {/* Project Dropdown Selector */} -
+ {/* Project Selector */} +
-
+
- - {/* Custom dropdown arrow */} -
- β–Ό -
+
β–Ό
- {/* πŸ”Ή Project-Level Section */} -
-

+ {/* πŸ“Š Project Insights */} +
+

πŸ“Š Project-Level Insights

-

- Showing data for{" "} - - {currentProjectCode} - +

+ Showing data for {currentProjectCode}

-
-
- +
+
+
-
- +
+
- {/* πŸ”Ή Modal Table */} + {/* πŸ”Ή Modal */} {openTable && ( -
-
-

+
+
+

{openTable.stage} β€” {openTable.data.length} Properties

[]; onOpenTable?: (outcome: string, filteredDeals: Record[]) => void; } + export default function SurveyedPieChart({ deals, onOpenTable, }: SurveyedPieChartProps) { - const [selected, setSelected] = useState(null); - const surveyorOutcomes = [ "Surveyed", "Surveyed - Pending Upload", @@ -27,14 +26,12 @@ export default function SurveyedPieChart({ const data = useMemo(() => { const outcomeCounts: Record = {}; - deals.forEach((deal) => { const outcome = deal.outcome; if (outcome && surveyorOutcomes.includes(outcome)) { outcomeCounts[outcome] = (outcomeCounts[outcome] || 0) + 1; } }); - return Object.entries(outcomeCounts).map(([name, amount]) => ({ name, amount, @@ -42,18 +39,20 @@ export default function SurveyedPieChart({ }, [deals]); const handleClick = (value: { name: string; amount: number }) => { - if (!value) return; // guard clause + if (!value) return; const filteredDeals = deals.filter((d) => d.outcome === value.name); - setSelected(null); // remove highlight after click onOpenTable?.(value.name, filteredDeals); }; return ( - -
- - Surveyed Outcome + <Card className="max-w-lg mx-auto bg-white rounded-2xl shadow-md hover:shadow-lg transition-all duration-200 p-6"> + <div className="flex flex-col items-center space-y-4"> + <Title className="text-gray-800 text-lg font-semibold tracking-tight text-center"> + Survey Outcomes +

+ Click a segment to view filtered properties +

`${n.toLocaleString()}`} colors={[ - "sky", // light airy blue - "cyan", // bright modern blue - "blue", // brand-level core blue - "indigo", // deeper professional tone - "violet", // slight bluish-purple contrast - "slate", // muted cool gray-blue - "lightBlue", // soft complementary blue - "navy", // deep accent - "azure", // fresh pop for clarity + "sky", + "cyan", + "blue", + "indigo", + "violet", + "slate", + "lightBlue", + "navy", + "azure", ]} - className="w-64 h-64 cursor-pointer" + className="w-64 h-64 cursor-pointer transition-transform hover:scale-[1.03]" onValueChange={handleClick} />
diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/TableViewer.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/TableViewer.tsx index 04883320..c35a5865 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/TableViewer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/TableViewer.tsx @@ -4,13 +4,12 @@ import { useState, useMemo } from "react"; interface TableViewerProps { data: Record[]; - columns?: string[]; // optional: which columns to show - columnLabels?: Record; // πŸ‘ˆ map data keys to display names + columns?: string[]; + columnLabels?: Record; } export default function TableViewer({ data, columns, columnLabels }: TableViewerProps) { const [searchTerms, setSearchTerms] = useState>({}); - const visibleColumns = columns?.length ? columns : Object.keys(data?.[0] || {}); const filteredData = useMemo(() => { @@ -25,25 +24,20 @@ export default function TableViewer({ data, columns, columnLabels }: TableViewer }, [data, searchTerms, visibleColumns]); return ( -
+

- - + + {visibleColumns.map((col) => ( - {filteredData.length === 0 ? ( - ) : ( filteredData.map((row, i) => ( - + {visibleColumns.map((col) => ( - ))} diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx index 7eb6dedf..cc135b1c 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx @@ -19,36 +19,55 @@ export default async function Demo(props: { const { slug: portfolioId } = await props.params; - // Fetch the single company + // 🏒 Fetch the company const [company] = await surveyDB .select() .from(hubspotCompanyData) .where(eq(hubspotCompanyData.groupId, portfolioId)); if (!company) { - console.log("No company found for this portfolioId"); return ( -
- No information to show. -
+
+
+ No information to show. +
+
); } - // Fetch deals related to that company + // πŸ’Ό Fetch deals for that company const deals = await surveyDB .select() .from(hubspotDealData) .where(eq(hubspotDealData.companyId, company.companyId)); - console.log("Deals:", deals); - if (!deals || deals.length === 0) { return ( -
- No information to show. -
+
+
+ No information to show. +
+
); } - return ; + return ( +
+ {/* 🌊 Domna-inspired layered background */} +
+ + {/* ✨ Subtle translucent grid texture */} +
+ + {/* πŸ’‘ Optional soft light glow at top */} +
+ + {/* Main content */} +
+
+ +
+
+
+ ); } diff --git a/src/app/portfolio/[slug]/components/BookSurveyModal.tsx b/src/app/portfolio/[slug]/components/BookSurveyModal.tsx index 39f6651f..a3a7b521 100644 --- a/src/app/portfolio/[slug]/components/BookSurveyModal.tsx +++ b/src/app/portfolio/[slug]/components/BookSurveyModal.tsx @@ -39,7 +39,7 @@ export default function BookSurveyModal({ body: JSON.stringify({ dealName: address, pipelineId: "2400089278", - dealStageId: "3288115388", + dealStageId: "3660660975", propertyId: propertyId.toString(), portfolioId: portfolioId, }), @@ -68,8 +68,10 @@ export default function BookSurveyModal({ return ( - - Confirm Booking a Survey + + + Confirm and we’ll be in touch! +
@@ -79,14 +81,13 @@ export default function BookSurveyModal({ className="w-full" disabled={bookSurveyMutation.isPending} > - {bookSurveyMutation.isPending ? "Creating..." : "Submit"} + {bookSurveyMutation.isPending ? "Creating..." : "Confirm"}
- - ); + ); } diff --git a/tailwind.config.js b/tailwind.config.js index 30f16286..fdc4b232 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -26,7 +26,9 @@ module.exports = { "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", "gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", - }, + "domna-gradient": + "linear-gradient(135deg, #14163d 0%, #2d348f 45%, #3943b7 70%, #eff6fc 100%)", + }, colors: { tremor: { brand: { From 895b6e2e725cc82ed5e61426a0257f476ff167b2 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 3 Nov 2025 12:26:54 +0000 Subject: [PATCH 13/20] save without background color --- src/app/api/book-survey/route.ts | 1 + .../(portfolio)/live-projects/Report.tsx | 28 +++++++++---------- .../[slug]/(portfolio)/live-projects/page.tsx | 2 +- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/app/api/book-survey/route.ts b/src/app/api/book-survey/route.ts index 3340ddf0..7f029092 100644 --- a/src/app/api/book-survey/route.ts +++ b/src/app/api/book-survey/route.ts @@ -9,6 +9,7 @@ export async function POST(req: Request) { const { dealName, pipelineId, dealStageId, propertyId, portfolioId } = await req.json(); + // 1️⃣ Create HubSpot deal const hsRes = await fetch("https://api.hubapi.com/crm/v3/objects/deals", { method: "POST", diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx index 09c29993..4277eaa1 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx @@ -12,7 +12,20 @@ interface ReportsProps { const MAJOR_CONDITION_STAGE_ID = "3061261536"; export default function LiveTracker({ deals }: ReportsProps) { + const groupedDeals = deals.reduce((acc, deal) => { + const project = deal.projectCode || "Unknown Project"; + (acc[project] ||= []).push(deal); + return acc; + }, {} as Record); + const [openTable, setOpenTable] = useState<{ stage: string; data: any[] } | null>(null); + const projectCodes = Object.keys(groupedDeals); + const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]); + const currentDeals = groupedDeals[currentProjectCode]; + const totalProperties = deals.length; + const majorConditionDeals = deals.filter(d => d.dealstage === MAJOR_CONDITION_STAGE_ID); + const majorIssues = majorConditionDeals.length; + const majorPercent = ((majorIssues / totalProperties) * 100).toFixed(1); const handleOpenTable = (stage: string, filteredDeals: any[]) => { setOpenTable({ stage, data: filteredDeals }); @@ -26,21 +39,6 @@ export default function LiveTracker({ deals }: ReportsProps) { ); } - const groupedDeals = deals.reduce((acc, deal) => { - const project = deal.projectCode || "Unknown Project"; - (acc[project] ||= []).push(deal); - return acc; - }, {} as Record); - - const projectCodes = Object.keys(groupedDeals); - const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]); - const currentDeals = groupedDeals[currentProjectCode]; - - const totalProperties = deals.length; - const majorConditionDeals = deals.filter(d => d.dealstage === MAJOR_CONDITION_STAGE_ID); - const majorIssues = majorConditionDeals.length; - const majorPercent = ((majorIssues / totalProperties) * 100).toFixed(1); - return (
{/* 🌍 Global Portfolio Overview */} diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx index cc135b1c..d5e4e7cb 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx @@ -54,7 +54,7 @@ export default async function Demo(props: { return (
{/* 🌊 Domna-inspired layered background */} -
+ {/*
*/} {/* ✨ Subtle translucent grid texture */}
From 98d174d278e46af41f55c009c7c8233d2bedb576 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 3 Nov 2025 12:29:53 +0000 Subject: [PATCH 14/20] fix pipeline --- .../[slug]/(portfolio)/live-projects/DealStageChart.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/DealStageChart.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/DealStageChart.tsx index b4dec63c..4aec8d75 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/DealStageChart.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/DealStageChart.tsx @@ -33,10 +33,10 @@ const STAGE_LABELS: Record = { "1617223917": STAGE_ORDER[2], "1887735998": STAGE_ORDER[3], "3061261536": STAGE_ORDER[4], - "2571417798": "[Ops] Surveyed under 2019 - Needs Re-survey", + "2571417798": STAGE_ORDER[2], "1617223914": STAGE_ORDER[5], - "1887736000": "[Deprecated] Files Missing From Assessor", - "1617223916": "[Ops] Properties to Review Manually", + "1887736000": STAGE_ORDER[2], + "1617223916": STAGE_ORDER[2], "2628341989": STAGE_ORDER[2], "3441170637": STAGE_ORDER[2], "2628233422": STAGE_ORDER[5], From 3583b5ab537365e0d7cb9d9b199e814dda7376ee Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 3 Nov 2025 16:17:49 +0000 Subject: [PATCH 15/20] see fidd --- .../components/building-passport/Toolbar.tsx | 7 ++++++- .../building-passport/[propertyId]/layout.tsx | 1 + .../[slug]/components/BookSurveyModal.tsx | 20 ++++++++++++++++--- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index 48f7d2e4..e2d98430 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -70,6 +70,11 @@ export function Toolbar({ const [openModal, setOpenModal] = useState(false); const [showToast, setShowToast] = useState(false); + console.log(propertyId, "PropertyID") + console.log(portfolioId, "porfolio id") + console.log(propertyMeta, "property meta") + console.log(decentHomes, "decent homes") + function handleClickSettings() { console.log("Settings were clicked, implement me"); } @@ -175,7 +180,7 @@ export function Toolbar({ onOpenChange={setOpenModal} propertyId={BigInt(propertyId)} portfolioId={portfolioId} - address={propertyMeta.address} + propertyMeta={propertyMeta} onSuccess={() => setShowToast(true)} /> )} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx index 2c470411..ba669f7c 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx @@ -27,6 +27,7 @@ export default async function DashboardLayout(props: { const propertyId = params.propertyId ?? ""; const portfolioId = params.slug ?? ""; + // The layout is a server component by default so we can fetch meta data here const propertyMeta = await getPropertyMeta(params.propertyId); diff --git a/src/app/portfolio/[slug]/components/BookSurveyModal.tsx b/src/app/portfolio/[slug]/components/BookSurveyModal.tsx index a3a7b521..2b9deeb9 100644 --- a/src/app/portfolio/[slug]/components/BookSurveyModal.tsx +++ b/src/app/portfolio/[slug]/components/BookSurveyModal.tsx @@ -12,24 +12,38 @@ import { Input } from "@/app/shadcn_components/ui/input"; import { Label } from "@/app/shadcn_components/ui/label"; import { useState, useEffect } from "react"; import { useMutation } from "@tanstack/react-query"; +import { PropertyMeta } from "@/app/db/schema/property"; +import { cache } from "react"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; interface BookSurveyModalProps { open: boolean; onOpenChange: (open: boolean) => void; propertyId: bigint; portfolioId: string; - address: string; + propertyMeta: PropertyMeta; onSuccess?: () => void; // βœ… fix: properly declare optional callback } +const getSession = cache(async () => { + const session = await getServerSession(AuthOptions); + return session; +}); + export default function BookSurveyModal({ open, onOpenChange, propertyId, portfolioId, - address, + propertyMeta, onSuccess, // βœ… fix: remove β€œ?:” here, we already declared it optional in interface }: BookSurveyModalProps) { + + const user = getSession(); + console.log(user, "user details"); + + // 🧠 Simple mutation to call your HubSpot API const bookSurveyMutation = useMutation({ mutationFn: async () => { @@ -37,7 +51,7 @@ export default function BookSurveyModal({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - dealName: address, + dealName: propertyMeta.address, pipelineId: "2400089278", dealStageId: "3660660975", propertyId: propertyId.toString(), From 87655f7d0830beb736d282edc87db89208ab8e38 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 3 Nov 2025 18:49:25 +0000 Subject: [PATCH 16/20] added the live project tracking and project proposal --- src/app/components/portfolio/Toolbar.tsx | 144 ++++++----- .../(portfolio)/live-projects/Report.tsx | 156 ------------ .../(portfolio)/your-projects/TabLInk.tsx | 47 ++++ .../(portfolio)/your-projects/layout.tsx | 26 ++ .../live}/DealStageChart.tsx | 10 +- .../(portfolio)/your-projects/live/Report.tsx | 231 ++++++++++++++++++ .../live}/SurveyedResultsPieChart.tsx | 12 +- .../live}/TableViewer.tsx | 0 .../live}/page.tsx | 37 ++- .../proposal}/ProjectProposal.tsx | 4 +- .../proposal}/ProposalColumns.tsx | 42 +++- .../proposal}/page.tsx | 8 +- .../proposal}/utils.ts | 10 +- 13 files changed, 454 insertions(+), 273 deletions(-) delete mode 100644 src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/TabLInk.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/layout.tsx rename src/app/portfolio/[slug]/(portfolio)/{live-projects => your-projects/live}/DealStageChart.tsx (96%) create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/Report.tsx rename src/app/portfolio/[slug]/(portfolio)/{live-projects => your-projects/live}/SurveyedResultsPieChart.tsx (93%) rename src/app/portfolio/[slug]/(portfolio)/{live-projects => your-projects/live}/TableViewer.tsx (100%) rename src/app/portfolio/[slug]/(portfolio)/{live-projects => your-projects/live}/page.tsx (61%) rename src/app/portfolio/[slug]/(portfolio)/{temp-reporting => your-projects/proposal}/ProjectProposal.tsx (98%) rename src/app/portfolio/[slug]/(portfolio)/{temp-reporting => your-projects/proposal}/ProposalColumns.tsx (76%) rename src/app/portfolio/[slug]/(portfolio)/{temp-reporting => your-projects/proposal}/page.tsx (85%) rename src/app/portfolio/[slug]/(portfolio)/{temp-reporting => your-projects/proposal}/utils.ts (89%) diff --git a/src/app/components/portfolio/Toolbar.tsx b/src/app/components/portfolio/Toolbar.tsx index 7fcae070..a723b14b 100644 --- a/src/app/components/portfolio/Toolbar.tsx +++ b/src/app/components/portfolio/Toolbar.tsx @@ -5,6 +5,7 @@ import { BuildingOfficeIcon, ChartBarIcon, HomeModernIcon, + RocketLaunchIcon, } from "@heroicons/react/24/outline"; import { NavigationMenu, @@ -12,98 +13,90 @@ import { NavigationMenuList, } from "@/app/shadcn_components/ui/navigation-menu"; import AddNewDropDown from "./AddNew"; -import { cva } from "class-variance-authority"; import UploadCsvModal from "@/app/portfolio/[slug]/components/UploadCsvModal"; -import { useState } from "react"; -import { useRouter } from "next/navigation"; import { ScenarioSelect } from "@/app/db/schema/recommendations"; +import { useState } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; interface ToolbarProps { portfolioId: string; scenarios: ScenarioSelect[]; } -const navigationMenuTriggerStyle = cva( - "bg-gray-50 cursor-pointer group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-200 hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-gray-200 text-gray-900" -); - export function Toolbar({ portfolioId, scenarios }: ToolbarProps) { const router = useRouter(); - - function handleClickSettings() { - router.push(`/portfolio/${portfolioId}/settings`); - } - - function handleClickPortfolio() { - router.push(`/portfolio/${portfolioId}`); - } - - function handleClickSummary() { - router.push(`/portfolio/${portfolioId}/summary`); - } - - // function handleClickMeasures() { - // router.push(`/portfolio/${portfolioId}/measures`); - // } - function handleClickDecentHomes() { - router.push(`/portfolio/${portfolioId}/decent-homes`); - } - - function handleClickProgressReport() { - router.push(`/portfolio/${portfolioId}/live-projects`); - } - + const pathname = usePathname(); const [modalIsOpen, setModalIsOpen] = useState(false); const [isRemoteAssessmentOpen, setIsRemoteAssessmentOpen] = useState(false); + const navItems = [ + { + label: "Portfolio", + icon: BuildingOfficeIcon, + match: (p: string) => p === `/portfolio/${portfolioId}`, + href: `/portfolio/${portfolioId}`, + }, + { + label: "Retrofit Summary", + icon: ChartBarIcon, + match: (p: string) => p.startsWith(`/portfolio/${portfolioId}/summary`), + href: `/portfolio/${portfolioId}/summary`, + }, + { + label: "Decent Homes", + icon: HomeModernIcon, + match: (p: string) => + p.startsWith(`/portfolio/${portfolioId}/decent-homes`), + href: `/portfolio/${portfolioId}/decent-homes`, + }, + { + label: "Your Projects", + icon: RocketLaunchIcon, + match: (p: string) => + p.startsWith(`/portfolio/${portfolioId}/your-projects`), + href: `/portfolio/${portfolioId}/your-projects/proposal`, + }, + { + label: "Settings", + icon: Cog6ToothIcon, + match: (p: string) => p.startsWith(`/portfolio/${portfolioId}/settings`), + href: `/portfolio/${portfolioId}/settings`, + }, + ]; + return ( - - - Portfolio - + {navItems.map(({ label, icon: Icon, href, match }) => { + const isActive = match(pathname); - - - Retrofit Summary - - - - - Decent Homes - - - {/* - - Measures - */} - - - Live Projects - - - - Settings - + return ( + + + + ); + })} + []; -} - -const MAJOR_CONDITION_STAGE_ID = "3061261536"; - -export default function LiveTracker({ deals }: ReportsProps) { - const groupedDeals = deals.reduce((acc, deal) => { - const project = deal.projectCode || "Unknown Project"; - (acc[project] ||= []).push(deal); - return acc; - }, {} as Record); - - const [openTable, setOpenTable] = useState<{ stage: string; data: any[] } | null>(null); - const projectCodes = Object.keys(groupedDeals); - const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]); - const currentDeals = groupedDeals[currentProjectCode]; - const totalProperties = deals.length; - const majorConditionDeals = deals.filter(d => d.dealstage === MAJOR_CONDITION_STAGE_ID); - const majorIssues = majorConditionDeals.length; - const majorPercent = ((majorIssues / totalProperties) * 100).toFixed(1); - - const handleOpenTable = (stage: string, filteredDeals: any[]) => { - setOpenTable({ stage, data: filteredDeals }); - }; - - if (!deals?.length) { - return ( -
- No deal data available. -
- ); - } - - return ( -
- {/* 🌍 Global Portfolio Overview */} -
-

- 🌍 Global Portfolio Overview -

- -
- {/* Total */} - - - {/* Major Issues */} - - - {/* Project Selector */} -
- -
- -
β–Ό
-
-
-
-
- - {/* πŸ“Š Project Insights */} -
-

- πŸ“Š Project-Level Insights -

-

- Showing data for {currentProjectCode} -

- -
-
- -
-
- -
-
-
- - {/* πŸ”Ή Modal */} - {openTable && ( -
-
-

- {openTable.stage} β€” {openTable.data.length} Properties -

- -
- -
- -
- -
-
-
- )} -
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/TabLInk.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/TabLInk.tsx new file mode 100644 index 00000000..8476159d --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/TabLInk.tsx @@ -0,0 +1,47 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useTransition } from "react"; +import { cn } from "@/lib/utils"; + +export function TabLink({ + href, + children, +}: { + href: string; + children: React.ReactNode; +}) { + const pathname = usePathname(); + const router = useRouter(); + const isActive = pathname === href; + const [isPending, startTransition] = useTransition(); + + function handleClick(e: React.MouseEvent) { + e.preventDefault(); + if (isActive) return; + startTransition(() => router.push(href)); // triggers route change + } + + return ( + + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/layout.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/layout.tsx new file mode 100644 index 00000000..7fb552fb --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/layout.tsx @@ -0,0 +1,26 @@ +import { TabLink } from "./TabLInk"; + +export default async function Layout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + + return ( +
+
+ + Proposal + + + Live Reporting + +
+ +
{children}
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/DealStageChart.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DealStageChart.tsx similarity index 96% rename from src/app/portfolio/[slug]/(portfolio)/live-projects/DealStageChart.tsx rename to src/app/portfolio/[slug]/(portfolio)/your-projects/live/DealStageChart.tsx index 4aec8d75..1a388d7d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/DealStageChart.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DealStageChart.tsx @@ -4,12 +4,12 @@ import { useMemo } from "react"; import { BarList, Card, Title } from "@tremor/react"; const STAGE_ORDER = [ - "Initial Planning", - "Booking Team to contact Tenant", - "Survey in Progress", + "Initial planning", + "Booking team to contact tenant", + "Survey in progress", "Not viable", - "Needs HA Support", - "Coordination + Design", + "Needs support", + "Coordination + design", "Ready to be installed", ]; diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/Report.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/Report.tsx new file mode 100644 index 00000000..30b0fb13 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/Report.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useState } from "react"; +import { DealStageChart } from "./DealStageChart"; +import SurveyedPieChart from "./SurveyedResultsPieChart"; +import TableViewer from "./TableViewer"; +import { + Card, + CardHeader, + CardTitle, + CardContent, +} from "@/app/shadcn_components/ui/card"; +import { Home, AlertTriangle, BarChart3 } from "lucide-react"; +import { motion } from "framer-motion"; + +interface ReportsProps { + deals: Record[]; +} + +const MAJOR_CONDITION_STAGE_ID = "3061261536"; + +export default function LiveTracker({ deals }: ReportsProps) { + const groupedDeals = deals.reduce( + (acc, deal) => { + const project = deal.projectCode || "Unknown Project"; + (acc[project] ||= []).push(deal); + return acc; + }, + {} as Record + ); + + const [openTable, setOpenTable] = useState<{ + stage: string; + data: any[]; + } | null>(null); + const projectCodes = Object.keys(groupedDeals); + const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]); + const currentDeals = groupedDeals[currentProjectCode]; + const totalProperties = deals.length; + const majorConditionDeals = deals.filter( + (d) => d.dealstage === MAJOR_CONDITION_STAGE_ID + ); + const majorIssues = majorConditionDeals.length; + const majorPercent = ((majorIssues / totalProperties) * 100).toFixed(1); + + const handleOpenTable = (stage: string, filteredDeals: any[]) => { + setOpenTable({ stage, data: filteredDeals }); + }; + + if (!deals?.length) { + return ( + + +

No deal data available.

+
+
+ ); + } + + return ( +
+ {/* 🌍 Global Overview */} +
+ {/* Total Properties */} + handleOpenTable("All Properties", deals)} + accent="brandblue" + /> + + {/* Major Issues */} + + handleOpenTable("Major Condition Issues", majorConditionDeals) + } + accent="red" + /> + + {/* Project Selector */} + + + +

+ Select Project +

+
+
+ +
+ +
+ β–Ό +
+
+
+
+
+ + {/* πŸ“Š Project Insights */} + + + + Project-Level Insights β€” {currentProjectCode} + + + + + + + + + + + + + + + {/* πŸ”Ή Table Modal */} + {openTable && ( +
+
+

+ {openTable.stage} β€” {openTable.data.length} Properties +

+ +
+ +
+ +
+ +
+
+
+ )} +
+ ); +} + +/** πŸ”ΈSmall stat card to match DashboardSummary visuals */ +function StatCard({ + icon: Icon, + title, + value, + subtitle, + onClick, + accent = "brandblue", +}: { + icon: any; + title: string; + value: string | number; + subtitle?: string; + onClick: () => void; + accent?: "brandblue" | "red"; +}) { + const accentColor = + accent === "red" + ? "from-red-50 to-white text-red-600 hover:border-red-300" + : "from-brandlightblue/20 to-white text-brandblue hover:border-brandblue/40"; + + return ( + +
+
+

{title}

+

+ {value} + {subtitle && ( + + {subtitle} + + )} +

+
+ +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/SurveyedResultsPieChart.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyedResultsPieChart.tsx similarity index 93% rename from src/app/portfolio/[slug]/(portfolio)/live-projects/SurveyedResultsPieChart.tsx rename to src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyedResultsPieChart.tsx index 4a3e1681..d31bcd51 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/SurveyedResultsPieChart.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyedResultsPieChart.tsx @@ -60,12 +60,12 @@ export default function SurveyedPieChart({ index="name" valueFormatter={(n) => `${n.toLocaleString()}`} colors={[ - "sky", - "cyan", - "blue", - "indigo", - "violet", - "slate", + "#2d348f", + "#14163d", + "#3943b7", + "#5d6be0", + "black", + "#eff6fc", "lightBlue", "navy", "azure", diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/TableViewer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/TableViewer.tsx similarity index 100% rename from src/app/portfolio/[slug]/(portfolio)/live-projects/TableViewer.tsx rename to src/app/portfolio/[slug]/(portfolio)/your-projects/live/TableViewer.tsx diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx similarity index 61% rename from src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx rename to src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx index d5e4e7cb..92b15428 100644 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -1,15 +1,16 @@ import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { redirect } from "next/navigation"; -import { surveyDB } from "../../../../db/surveyDB/connection"; -import { hubspotDealData } from "../../../../db/schema/crm/hubspot_deal_table"; +import { surveyDB } from "../../../../../db/surveyDB/connection"; +import { hubspotDealData } from "../../../../../db/schema/crm/hubspot_deal_table"; import { hubspotCompanyData } from "@/app/db/schema/crm/hubspot_company_table"; import { eq } from "drizzle-orm"; import LiveTracker from "./Report"; -export default async function Demo(props: { +export default async function LiveReportingPage(props: { params: Promise<{ slug: string }>; }) { + const { slug: portfolioId } = await props.params; const user = await getServerSession(AuthOptions); if (!user?.user) { @@ -17,8 +18,6 @@ export default async function Demo(props: { redirect("/"); } - const { slug: portfolioId } = await props.params; - // 🏒 Fetch the company const [company] = await surveyDB .select() @@ -52,22 +51,18 @@ export default async function Demo(props: { } return ( -
- {/* 🌊 Domna-inspired layered background */} - {/*
*/} - - {/* ✨ Subtle translucent grid texture */} -
- - {/* πŸ’‘ Optional soft light glow at top */} -
- - {/* Main content */} -
-
- -
+
+
+
+ Live Projects +
+

+ Check in on your projects' progress with real-time data updates. +

+
-
+ + +
); } diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProjectProposal.tsx similarity index 98% rename from src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx rename to src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProjectProposal.tsx index 7dbffd19..faa4b2da 100644 --- a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProjectProposal.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProjectProposal.tsx @@ -120,9 +120,7 @@ export function ProjectProposal({ plans }: { plans: any[] }) {
-

- Total client contribution -

+

Total investment

Β£{formatNumber(selectedData?.totalClientContribution || 0)}

diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProposalColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProposalColumns.tsx similarity index 76% rename from src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProposalColumns.tsx rename to src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProposalColumns.tsx index b6949a79..94124418 100644 --- a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/ProposalColumns.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProposalColumns.tsx @@ -3,10 +3,22 @@ import { ColumnDef } from "@tanstack/react-table"; import { ArrowUpDown } from "lucide-react"; import { Button } from "@/app/shadcn_components/ui/button"; -import { formatNumber } from "@/app/utils"; +import { formatNumber, getEpcColorClass, sapToEpc } from "@/app/utils"; import StatusBadge from "@/app/components/StatusBadge"; import { PlanWithTotals } from "./utils"; +const EpcLetterBubble = ({ letter }: { letter: string }) => { + return ( +
+ {letter} +
+ ); +}; + export const planColumns: ColumnDef[] = [ { accessorKey: "landlordPropertyId", @@ -138,4 +150,32 @@ export const planColumns: ColumnDef[] = [ ), sortingFn: "alphanumeric", }, + { + accessorKey: "currentEpc", + header: () =>
Current EPC Rating
, + cell: ({ row }) => { + return ( +
+ {} +
+ ); + }, + }, + { + accessorKey: "targetEpc", + header: () =>
Expected EPC
, + cell: ({ row }) => { + const currentSapPoints = row.original.currentSapPoints || 0; + + const expectedSapPoints = row.original.totalRecommendationSapPoints || 0; + + const expectedEpc = sapToEpc(currentSapPoints + expectedSapPoints); + + return ( +
+ {} +
+ ); + }, + }, ]; diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/page.tsx similarity index 85% rename from src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx rename to src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/page.tsx index cef20f06..f0ee8686 100644 --- a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/page.tsx @@ -3,16 +3,14 @@ import { getPlansWithTotals } from "./utils"; import DataTable from "@/app/portfolio/[slug]/components/propertyTable"; import { planColumns } from "./ProposalColumns"; -export default async function YourProjectsPage({ - params, -}: { +export default async function ProjectProposalPage(props: { params: Promise<{ slug: string }>; }) { - const { slug: portfolioId } = await params; + const { slug: portfolioId } = await props.params; const latestPlans = await getPlansWithTotals(portfolioId); return ( -
+
Project Overview diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/utils.ts similarity index 89% rename from src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts rename to src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/utils.ts index 70b1cb54..db310d04 100644 --- a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/utils.ts @@ -12,6 +12,8 @@ export interface PlanWithTotals extends Record { landlordPropertyId: string | null; address: string | null; postcode: string | null; + currentSapPoints: number | null; + currentEpcRating: string | null; fundingScheme: string | null; totalFunding: number | null; totalUplift: number | null; @@ -20,6 +22,7 @@ export interface PlanWithTotals extends Record { totalRecommendationCost?: number | null; surveyCost?: number; clientContribution?: number; + totalRecommendationSapPoints: number | null; } export async function getPlansWithTotals( @@ -35,12 +38,15 @@ export async function getPlansWithTotals( p.landlord_property_id AS "landlordPropertyId", p.address AS "address", p.postcode AS "postcode", + p.current_sap_points AS "currentSapPoints", + p.current_epc_rating AS "currentEpcRating", fp.scheme AS "fundingScheme", COALESCE(fp.project_funding, 0) AS "totalFunding", COALESCE(fp.total_uplift, 0) AS "totalUplift", COALESCE(SUM(r.co2_equivalent_savings), 0) AS "totalCarbonSavings", COALESCE(SUM(r.energy_cost_savings), 0) AS "totalBillSavings", - COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost" + COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost", + COALESCE(SUM(r.sap_points), 0) AS "totalRecommendationSapPoints" FROM plan pl INNER JOIN property p ON p.id = pl.property_id @@ -68,6 +74,8 @@ export async function getPlansWithTotals( p.landlord_property_id, p.address, p.postcode, + p.current_sap_points, + p.current_epc_rating, fp.scheme, fp.project_funding, fp.total_uplift From d2d7c9576aec239393457772d8ebee9912a43f24 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Mon, 3 Nov 2025 19:08:05 +0000 Subject: [PATCH 17/20] push to develop --- src/app/api/book-survey/route.ts | 225 ++++++++++++++---- .../[slug]/components/BookSurveyModal.tsx | 15 +- 2 files changed, 184 insertions(+), 56 deletions(-) diff --git a/src/app/api/book-survey/route.ts b/src/app/api/book-survey/route.ts index 7f029092..995db4f2 100644 --- a/src/app/api/book-survey/route.ts +++ b/src/app/api/book-survey/route.ts @@ -1,40 +1,33 @@ -// app/api/book-survey/route.ts import { NextResponse } from "next/server"; import { db } from "@/app/db/db"; import { propertyStatusTracker } from "@/app/db/schema/crm/property_status_tracker"; import { eq, and } from "drizzle-orm"; +import { user } from "@/app/db/schema/users"; export async function POST(req: Request) { + console.log("πŸ“© Incoming POST /api/property-status request"); + try { - const { dealName, pipelineId, dealStageId, propertyId, portfolioId } = - await req.json(); + const { + pipelineId, + dealStageId, + propertyId, + portfolioId, + userInfo, + propertyMeta, + } = await req.json(); - - // 1️⃣ Create HubSpot deal - const hsRes = await fetch("https://api.hubapi.com/crm/v3/objects/deals", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${process.env.HUBSPOT_API_KEY}`, - }, - body: JSON.stringify({ - properties: { - dealname: dealName, - pipeline: pipelineId, - dealstage: dealStageId, - }, - }), + console.log("🧠 Parsed body:", { + pipelineId, + dealStageId, + propertyId, + portfolioId, + userInfo, + propertyMeta, }); - if (!hsRes.ok) { - const err = await hsRes.text(); - throw new Error(`HubSpot error: ${err}`); - } - - const hsData = await hsRes.json(); - const hubspotDealId = hsData.id; - - // 2️⃣ Check if record exists for property + portfolio + // 1️⃣ Check if record exists first + console.log("πŸ” Checking if record already exists in DB..."); const existing = await db .select() .from(propertyStatusTracker) @@ -45,32 +38,170 @@ export async function POST(req: Request) { ) ); + console.log("πŸ—ƒοΈ Existing record check result:", existing); + if (existing.length > 0) { - // 3️⃣ Update existing record - await db - .update(propertyStatusTracker) - .set({ - hubspotDealId, - updatedAt: new Date(), - }) - .where( - and( - eq(propertyStatusTracker.propertyId, propertyId), - eq(propertyStatusTracker.portfolioId, portfolioId) - ) - ); - } else { - // 4️⃣ Create new record - await db.insert(propertyStatusTracker).values({ - hubspotDealId: hubspotDealId, - propertyId: propertyId, - portfolioId: portfolioId, + console.log("⚠️ Record already exists, skipping deal creation"); + return NextResponse.json({ + message: "Record already exists, no new deal created", + dealId: existing[0].hubspotDealId, }); } + // 2️⃣ Create HubSpot deal + console.log("🧱 Creating HubSpot deal..."); + const dealRes = await fetch("https://api.hubapi.com/crm/v3/objects/deals", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.HUBSPOT_API_KEY}`, + }, + body: JSON.stringify({ + properties: { + dealname: propertyMeta?.address || "New Property Deal", + pipeline: pipelineId, + dealstage: dealStageId, + }, + }), + }); + + console.log("πŸ“‘ HubSpot deal response status:", dealRes.status); + + if (!dealRes.ok) { + const err = await dealRes.text(); + console.error("❌ HubSpot Deal creation failed:", err); + throw new Error(`HubSpot Deal Error: ${err}`); + } + + const dealData = await dealRes.json(); + const hubspotDealId = dealData.id; + console.log("βœ… Created HubSpot deal:", hubspotDealId); + + // 3️⃣ Retrieve user info from your DB + console.log("πŸ‘€ Fetching user info from DB..."); + const userProfile = await db + .select() + .from(user) + .where(eq(user.id, userInfo.dbId)) + .limit(1); + + const userInfoFromDb = userProfile[0]; + console.log("πŸ“‡ User info from DB:", userInfoFromDb); + + if (!userInfoFromDb?.email) { + console.error("❌ User email missing in DB for user:", userInfo.dbId); + throw new Error("User email not found; cannot create HubSpot contact."); + } + + // 4️⃣ Create or find contact in HubSpot + console.log("πŸ“ž Creating HubSpot contact..."); + const contactRes = await fetch("https://api.hubapi.com/crm/v3/objects/contacts", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.HUBSPOT_API_KEY}`, + }, + body: JSON.stringify({ + properties: { + email: userInfoFromDb.email, + }, + }), + }); + + let hubspotContactId: string | null = null; + + console.log("πŸ“‘ HubSpot contact response status:", contactRes.status); + + if (contactRes.ok) { + const contactData = await contactRes.json(); + hubspotContactId = contactData.id; + console.log("βœ… Created new HubSpot contact:", hubspotContactId); + } else { + console.warn("⚠️ HubSpot contact creation failed β€” checking if contact exists..."); + + // Check if contact already exists + const findContactRes = await fetch( + `https://api.hubapi.com/crm/v3/objects/contacts/search`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.HUBSPOT_API_KEY}`, + }, + body: JSON.stringify({ + filterGroups: [ + { + filters: [ + { + propertyName: "email", + operator: "EQ", + value: userInfoFromDb.email, + }, + ], + }, + ], + }), + } + ); + + console.log("πŸ“‘ HubSpot contact search response:", findContactRes.status); + + if (findContactRes.ok) { + const found = await findContactRes.json(); + console.log("πŸ”Ž Found contact results:", found.results); + if (found.results?.length > 0) { + hubspotContactId = found.results[0].id; + console.log("βœ… Found existing HubSpot contact:", hubspotContactId); + } else { + console.error("❌ HubSpot contact creation and lookup both failed"); + throw new Error("HubSpot contact creation and lookup both failed."); + } + } else { + const findErr = await findContactRes.text(); + console.error("❌ HubSpot contact search failed:", findErr); + throw new Error("HubSpot contact lookup request failed."); + } + } + + // 5️⃣ Associate contact with deal + if (hubspotContactId) { + console.log("πŸ”— Associating HubSpot deal and contact..."); + const assocUrl = `https://api.hubapi.com/crm/v3/objects/deals/${hubspotDealId}/associations/contacts/${hubspotContactId}/deal_to_contact`; + + const assocRes = await fetch(assocUrl, { + method: "PUT", + headers: { + Authorization: `Bearer ${process.env.HUBSPOT_API_KEY}`, + "Content-Type": "application/json", + }, + }); + + console.log("πŸ“‘ HubSpot association response:", assocRes.status); + + if (!assocRes.ok) { + const assocErr = await assocRes.text(); + console.warn("⚠️ HubSpot association failed:", assocErr); + } else { + console.log("βœ… Successfully associated contact with deal"); + } + } + + // 6️⃣ Create DB record + console.log("πŸ—„οΈ Inserting new tracker record into DB..."); + await db.insert(propertyStatusTracker).values({ + hubspotDealId, + propertyId, + portfolioId, + createdAt: new Date(), + updatedAt: new Date(), + }); + + console.log("βœ… All done β€” returning success response"); + return NextResponse.json({ - message: existing.length > 0 ? "Updated existing tracker" : "Created new tracker", + message: "Created new tracker, HubSpot deal, and linked contact", dealId: hubspotDealId, + contactId: hubspotContactId, }); } catch (error: any) { console.error("❌ Error creating or updating HubSpot deal:", error); diff --git a/src/app/portfolio/[slug]/components/BookSurveyModal.tsx b/src/app/portfolio/[slug]/components/BookSurveyModal.tsx index 2b9deeb9..2e727977 100644 --- a/src/app/portfolio/[slug]/components/BookSurveyModal.tsx +++ b/src/app/portfolio/[slug]/components/BookSurveyModal.tsx @@ -14,8 +14,8 @@ import { useState, useEffect } from "react"; import { useMutation } from "@tanstack/react-query"; import { PropertyMeta } from "@/app/db/schema/property"; import { cache } from "react"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { useSession } from "next-auth/react"; + interface BookSurveyModalProps { open: boolean; @@ -26,10 +26,6 @@ interface BookSurveyModalProps { onSuccess?: () => void; // βœ… fix: properly declare optional callback } -const getSession = cache(async () => { - const session = await getServerSession(AuthOptions); - return session; -}); export default function BookSurveyModal({ open, @@ -39,9 +35,9 @@ export default function BookSurveyModal({ propertyMeta, onSuccess, // βœ… fix: remove β€œ?:” here, we already declared it optional in interface }: BookSurveyModalProps) { + const { data: session, status } = useSession(); - const user = getSession(); - console.log(user, "user details"); + const user = session?.user; // 🧠 Simple mutation to call your HubSpot API @@ -51,11 +47,12 @@ export default function BookSurveyModal({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - dealName: propertyMeta.address, pipelineId: "2400089278", dealStageId: "3660660975", propertyId: propertyId.toString(), portfolioId: portfolioId, + userInfo: user, + propertyMeta: propertyMeta, }), }); From f873acd48a0d8ea5437285fd14b7d17ed403cd17 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 3 Nov 2025 19:17:49 +0000 Subject: [PATCH 18/20] fixed build --- src/app/db/schema/recommendations.ts | 6 ++++++ .../[slug]/(portfolio)/your-projects/live/page.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/db/schema/recommendations.ts b/src/app/db/schema/recommendations.ts index f355f98e..ef4f6c4e 100644 --- a/src/app/db/schema/recommendations.ts +++ b/src/app/db/schema/recommendations.ts @@ -72,6 +72,12 @@ export const PlanType: [string, ...string[]] = [ "partial_cavity_eco", "extraction_eco", ]; +export type PlanTypeEnum = + | "solar_eco4" + | "solar_hhrsh_eco4" + | "empty_cavity_eco" + | "partial_cavity_eco" + | "extraction_eco"; export const planTypeEnum = pgEnum("plan_type", PlanType); export const plan = pgTable("plan", { diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx index 92b15428..52b463a2 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -57,7 +57,7 @@ export default async function LiveReportingPage(props: { Live Projects

- Check in on your projects' progress with real-time data updates. + {`Check in on your projects' progress with real-time data updates.`}

From cf509fd474f88467bfa36cd5810c4d72f84ec3d3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 3 Nov 2025 19:46:47 +0000 Subject: [PATCH 19/20] updated loading state --- src/app/domna/financials.ts | 2 +- .../components/propertyTableColumns.tsx | 9 +++++++ src/app/utils.ts | 26 +++++++++---------- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/app/domna/financials.ts b/src/app/domna/financials.ts index 538f8c1d..a1019629 100644 --- a/src/app/domna/financials.ts +++ b/src/app/domna/financials.ts @@ -1,4 +1,4 @@ -import { PlanTypeEnum } from "@/app/db/schema/recommendations"; +import type { PlanTypeEnum } from "@/app/db/schema/recommendations"; // Fixed Domna costs per delivery type export const DOMNA_COST_MAP: Record & { diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index 58156b3d..3e274a30 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -142,6 +142,7 @@ export const columns: ColumnDef[] = [ }, { accessorKey: "postcode", + enableGlobalFilter: true, header: ({ column }) => (
-
- - {columnLabels?.[col] || col} - +
+
+ {columnLabels?.[col] || col} - setSearchTerms((prev) => ({ - ...prev, - [col]: e.target.value, - })) + setSearchTerms((prev) => ({ ...prev, [col]: e.target.value })) } />
@@ -54,18 +48,18 @@ export default function TableViewer({ data, columns, columnLabels }: TableViewer
+ No results found
+ {String(row[col] ?? "")}