From 6a98a530edd4d831c3338663a3ccee2927988a21 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 31 Oct 2025 19:07:22 +0000 Subject: [PATCH 1/5] 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 f7d6ca0..c4eed14 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 8b37835..f355f98 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 db09b4d..aec0b4d 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 0000000..a9ba861 --- /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 0000000..a53df76 --- /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 0000000..dc03a3c --- /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 0000000..ae2274a --- /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 c6246e6..c15ff00 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 2/5] 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 a207d46..b35812e 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 4bbcb1e..c43a2d8 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 f111afa..6c26bab 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 0000000..538f8c1 --- /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 804366f..6d892a4 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 adc6ba6..c5f03c0 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 0e37944..cde7d79 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 a9ba861..c71a273 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 a53df76..b6949a7 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 dc03a3c..e15eb0b 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 ae2274a..8adb907 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 0491ad6..01d75c4 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 0000000..87bae05 --- /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 6c7dcee..9cf48c6 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 30f1628..317049d 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 3/5] 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 6c26bab..f111afa 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 6d892a4..3b98d12 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 c71a273..3a0be4e 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 e15eb0b..cef20f0 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 317049d..c977fab 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 4/5] 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 3a0be4e..7dbffd1 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 8adb907..70b1cb5 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 87655f7d0830beb736d282edc87db89208ab8e38 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 3 Nov 2025 18:49:25 +0000 Subject: [PATCH 5/5] 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 7fcae07..a723b14 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 0000000..8476159 --- /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 0000000..7fb552f --- /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 4aec8d7..1a388d7 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 0000000..30b0fb1 --- /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 4a3e168..d31bcd5 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 d5e4e7c..92b1542 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 7dbffd1..faa4b2d 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 b6949a7..9412441 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 cef20f0..f0ee868 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 70b1cb5..db310d0 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