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

Click a bar to view details

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

+ Plans Overview +

+ +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts new file mode 100644 index 00000000..ae2274aa --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts @@ -0,0 +1,69 @@ +import { db } from "@/app/db/db"; +import { sql } from "drizzle-orm"; + +export interface PlanWithTotals extends Record { + planId: string; + planType: string | null; + planName: string | null; + createdAt: string; + propertyId: number; + landlordPropertyId: string | null; + address: string | null; + postcode: string | null; + fundingScheme: string | null; + totalFunding: number | null; + totalCarbonSavings: number | null; + totalBillSavings: number | null; +} + +export async function getPlansWithTotals( + portfolioId: string +): Promise { + const result = await db.execute(sql` + SELECT + pl.id AS "planId", + pl.plan_type AS "planType", + pl.name AS "planName", + pl.created_at AS "createdAt", + pl.property_id AS "propertyId", + p.landlord_property_id AS "landlordPropertyId", + p.address AS "address", + p.postcode AS "postcode", + fp.scheme AS "fundingScheme", + COALESCE(SUM(r.estimated_cost), 0) AS "totalFunding", + COALESCE(SUM(r.co2_equivalent_savings), 0) AS "totalCarbonSavings", + COALESCE(SUM(r.energy_cost_savings), 0) AS "totalBillSavings", + COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost" + FROM plan pl + INNER JOIN property p + ON p.id = pl.property_id + LEFT JOIN funding_package fp + ON fp.plan_id = pl.id + LEFT JOIN plan_recommendations prx + ON prx.plan_id = pl.id + LEFT JOIN recommendation r + ON r.id = prx.recommendation_id + AND r.default = true + WHERE pl.portfolio_id = ${portfolioId} + AND pl.plan_type IN ( + 'solar_eco4', + 'solar_hhrsh_eco4', + 'empty_cavity_eco', + 'partial_cavity_eco', + 'extraction_eco' + ) + GROUP BY + pl.id, + pl.plan_type, + pl.name, + pl.created_at, + pl.property_id, + p.landlord_property_id, + p.address, + p.postcode, + fp.scheme + ORDER BY pl.created_at DESC; + `); + + return result.rows; +} diff --git a/src/app/portfolio/[slug]/components/propertyTable.tsx b/src/app/portfolio/[slug]/components/propertyTable.tsx index c6246e63..c15ff005 100644 --- a/src/app/portfolio/[slug]/components/propertyTable.tsx +++ b/src/app/portfolio/[slug]/components/propertyTable.tsx @@ -24,7 +24,6 @@ import { useState } from "react"; import { DataTablePagination } from "./propertyTablePagination"; import React from "react"; import { Input } from "@/app/shadcn_components/ui/input"; -import { PropertyWithRelations } from "@/app/db/schema/property"; import { rankItem } from "@tanstack/match-sorter-utils"; import { FilterFn } from "@tanstack/react-table"; @@ -35,24 +34,23 @@ const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { return itemRank.passed; }; -interface DataTableProps { - columns: ColumnDef[]; - data: PropertyWithRelations[]; +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; } -function fetchData(offset: number) { - // Because this is a client component, this will be handled with react query - let properties: PropertyWithRelations[] = []; - // TODO: implement this - return properties; +function fetchData(offset: number): TData[] { + // placeholder function for fetching + const data: TData[] = []; + return data; } -export default function DataTable({ +export default function DataTable>({ data, columns, -}: DataTableProps) { +}: DataTableProps) { const [sorting, setSorting] = useState([]); - const [tableData, setTableData] = useState(data); + const [tableData, setTableData] = useState(() => [...data]); const [offset, setOffset] = useState(0); const [currentPageIndex, setCurrentPageIndex] = useState(0); const [columnFilters, setColumnFilters] = React.useState( @@ -62,14 +60,12 @@ export default function DataTable({ // add page change handlers for DataTablePagination const loadPaginatedData = () => { - const newData = fetchData(offset); + const newData = fetchData(offset); if (newData) { - console.log("loadPaginatedData"); setTableData([...tableData, ...newData]); setOffset(offset + 1); return true; } - return false; };