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/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 ( + + + + ); + })} + & { + 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..3b98d12 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -76,7 +76,9 @@ } body { @apply bg-background text-foreground; - font-feature-settings: "rlig" 1, "calt" 1; + font-feature-settings: + "rlig" 1, + "calt" 1; } } 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)/live-projects/Report.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx deleted file mode 100644 index 4277eaa..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { DealStageChart } from "./DealStageChart"; -import SurveyedPieChart from "./SurveyedResultsPieChart"; -import TableViewer from "./TableViewer"; - -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 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)/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)/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)/your-projects/proposal/ProjectProposal.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProjectProposal.tsx new file mode 100644 index 0000000..faa4b2d --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProjectProposal.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { + Card, + CardHeader, + CardTitle, + CardContent, +} 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", + 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); + + 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); + } + + 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 + ); + 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]); + + 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; + + return ( +
+ {/* Chart */} + + + + Homes by Work Type + + + + {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"}`} + /> + )} + + + + {/* Metrics */} + + + + {mappedTitles[selectedType || "default"]} + + + +
+

Total investment

+

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

+

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

+
+ +
+
+

Funding

+

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

+
+
+

Carbon

+

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

+
+
+

Bills

+

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

+
+
+
+
+
+ ); +} + +export function DashboardSummary({ plans }: { plans: any[] }) { + const totalFunding = plans.reduce((sum, p) => sum + (p.totalFunding || 0), 0); + const totalCarbon = plans.reduce( + (sum, p) => sum + (p.totalCarbonSavings || 0), + 0 + ); + const totalBills = plans.reduce( + (sum, p) => sum + (p.totalBillSavings || 0), + 0 + ); + const planCount = plans.length; + + 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, per year.", + icon: Leaf, + }, + { + title: "Bill Savings", + value: `£${formatNumber(totalBills)}`, + subtitle: "Expected total bill reductions across all homes, per year.", + icon: Zap, + }, + { + title: "Number of Homes", + value: planCount, + subtitle: "Properties included across your project plans.", + icon: Home, + }, + ]; + + return ( +
+ {cards.map((c) => { + const Icon = c.icon; + return ( + + +
+ + + +
+ + {c.title} + +
+ + +
+ {c.value} +
+

{c.subtitle}

+
+
+ ); + })} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProposalColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProposalColumns.tsx new file mode 100644 index 0000000..9412441 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProposalColumns.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { ArrowUpDown } from "lucide-react"; +import { Button } from "@/app/shadcn_components/ui/button"; +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", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.landlordPropertyId || "—"} +
+ ), + }, + { + 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)} + +
+ ), + }, + { + accessorKey: "clientContribution", + header: () =>
Investment
, + cell: ({ row }) => ( +
+ + £{formatNumber(row.original.clientContribution || 0)} + +
+ ), + 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)/your-projects/proposal/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/page.tsx new file mode 100644 index 0000000..f0ee868 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/page.tsx @@ -0,0 +1,35 @@ +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 ProjectProposalPage(props: { + params: Promise<{ slug: string }>; +}) { + const { slug: portfolioId } = await props.params; + const latestPlans = await getPlansWithTotals(portfolioId); + + return ( +
+
+
+ Project Overview +
+

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

+
+
+ + + + +
+

+ Your Homes +

+ +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/utils.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/utils.ts new file mode 100644 index 0000000..db310d0 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/utils.ts @@ -0,0 +1,108 @@ +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; + planType: string | null; + planName: string | null; + createdAt: string; + propertyId: number; + landlordPropertyId: string | null; + address: string | null; + postcode: string | null; + currentSapPoints: number | null; + currentEpcRating: string | null; + fundingScheme: string | null; + totalFunding: number | null; + totalUplift: number | null; + totalCarbonSavings: number | null; + totalBillSavings: number | null; + totalRecommendationCost?: number | null; + surveyCost?: number; + clientContribution?: number; + totalRecommendationSapPoints: 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", + 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.sap_points), 0) AS "totalRecommendationSapPoints" + 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, + p.current_sap_points, + p.current_epc_rating, + fp.scheme, + fp.project_funding, + fp.total_uplift + ORDER BY pl.created_at DESC; + `); + + 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) + (plan.totalUplift ?? 0); + const uplift = plan.totalUplift ?? 0; + + const rawContribution = totalCost + surveyCost - funding - uplift; + const clientContribution = rawContribution > 0 ? rawContribution : 0; + + return { + ...plan, + totalFunding: funding, // overwrite + surveyCost, + clientContribution, + }; + }); + + return data; +} 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; }; 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 dc8e111..9cf48c6 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,17 +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", -] +]; -export function cx(...args: ClassValue[]) { - return twMerge(clsx(...args)) -} \ No newline at end of file +// 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 fdc4b23..f9cbfaa 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -357,6 +357,46 @@ module.exports = { "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 }) {