functional display for project proposal ui

This commit is contained in:
Khalim Conn-Kowlessar 2025-10-31 19:07:22 +00:00
parent f354d55881
commit 6a98a530ed
8 changed files with 389 additions and 21 deletions

View file

@ -22,7 +22,6 @@ export default function StatusBadge({
isProperty?: boolean;
}) {
const statusConfig = statusColor[status];
console.log("status", status, statusConfig);
return (
<HoverCard>

View file

@ -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",

View file

@ -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 (
<>

View file

@ -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<string | null>(null);
// Group by planType
const grouped = useMemo(() => {
const map: Record<string, any[]> = {};
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 (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 my-8">
{/* Left: Chart */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Plans by Work Type</CardTitle>
</CardHeader>
<CardContent>
<BarChart
data={grouped}
index="planType"
categories={["count"]}
colors={["blue"]}
valueFormatter={(v) => v.toString()}
onValueChange={(v) => setSelectedType(String(v) || null)}
className="h-64"
/>
</CardContent>
</Card>
{/* Right: Details */}
<Card>
<CardHeader>
<CardTitle>
{selectedType
? selectedType.replaceAll("_", " ")
: "Select a work type"}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{selectedType && selectedData ? (
<>
<div className="text-sm text-gray-600">
Average client contribution
</div>
<div className="text-xl font-semibold">
£{formatNumber(selectedData.avgClientContribution || 0)}
</div>
<div className="text-sm text-gray-600">Carbon savings</div>
<div className="text-xl font-semibold">
{(selectedData.totalCarbon * 1000).toFixed(2)} kgCOe
</div>
<div className="text-sm text-gray-600">Bill savings</div>
<div className="text-xl font-semibold">
£{formatNumber(selectedData.totalBills)}
</div>
<div className="text-sm text-gray-600">
Total estimated contribution
</div>
<div className="text-xl font-semibold">
£
{formatNumber(
selectedData.totalFunding +
(selectedType.includes("cavity") ? 1500 : 500) // example extra cost rule
)}
</div>
</>
) : (
<p className="text-gray-500 italic">Click a bar to view details</p>
)}
</CardContent>
</Card>
</div>
);
}
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: <PoundSterling className="h-6 w-6 text-brandblue" />,
},
{
title: "Total Carbon Savings",
value: `${(totalCarbonSavings * 1000).toFixed(2)} kgCO₂e`,
icon: <Leaf className="h-6 w-6 text-green-600" />,
},
{
title: "Total Bill Savings",
value: `£${formatNumber(totalBillSavings)}`,
icon: <Zap className="h-6 w-6 text-yellow-500" />,
},
{
title: "Number of Plans",
value: planCount,
icon: <FileSpreadsheet className="h-6 w-6 text-brandbrown" />,
},
];
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 my-6">
{cards.map((card) => (
<Card key={card.title} className="shadow-sm border border-gray-200">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-gray-600">
{card.title}
</CardTitle>
{card.icon}
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold text-gray-900">
{card.value}
</div>
</CardContent>
</Card>
))}
</div>
);
}

View file

@ -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<PlanWithTotals>[] = [
{
accessorKey: "address",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<div className="text-gray-700">{row.original.address || "—"}</div>
),
},
{
accessorKey: "postcode",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Postcode
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<div className="text-gray-700">{row.original.postcode || "—"}</div>
),
},
{
accessorKey: "fundingScheme",
header: () => <div className="text-center">Funding Scheme</div>,
cell: ({ row }) => (
<div className="flex justify-center">
{row.original.fundingScheme ? (
<StatusBadge
status={String(row.original.fundingScheme).toUpperCase()}
isProperty={false}
/>
) : (
<span className="text-gray-500">None</span>
)}
</div>
),
},
{
accessorKey: "planType",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Work Type
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<div className="font-medium text-gray-800">
{String(row.original.planType).replaceAll("_", " ")}
</div>
),
},
{
accessorKey: "totalFunding",
header: () => <div className="text-center">Total Funding</div>,
cell: ({ row }) => (
<div className="flex items-center justify-center gap-1">
<PoundSterling className="h-4 w-4 text-brandblue" />
<span className="font-medium">
£{formatNumber(row.original.totalFunding || 0)}
</span>
</div>
),
},
{
accessorKey: "totalCarbonSavings",
header: () => <div className="text-center">Carbon Savings</div>,
cell: ({ row }) => (
<div className="flex items-center justify-center gap-1">
<Leaf className="h-4 w-4 text-green-600" />
<span className="font-medium">
{((row.original.totalCarbonSavings || 0) * 1000).toFixed(2)} kgCOe
</span>
</div>
),
},
{
accessorKey: "totalBillSavings",
header: () => <div className="text-center">Bill Savings</div>,
cell: ({ row }) => (
<div className="flex items-center justify-center gap-1">
<Zap className="h-4 w-4 text-yellow-500" />
<span className="font-medium">
£{formatNumber(row.original.totalBillSavings || 0)}
</span>
</div>
),
},
];

View file

@ -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 (
<div className="container mx-auto px-4 py-6 space-y-8">
<DashboardSummary plans={latestPlans} />
<ProjectProposal plans={latestPlans} />
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-2">
Plans Overview
</h2>
<DataTable data={latestPlans} columns={planColumns} />
</div>
</div>
);
}

View file

@ -0,0 +1,69 @@
import { db } from "@/app/db/db";
import { sql } from "drizzle-orm";
export interface PlanWithTotals extends Record<string, unknown> {
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<PlanWithTotals[]> {
const result = await db.execute<PlanWithTotals>(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;
}

View file

@ -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<any> = (row, columnId, value, addMeta) => {
return itemRank.passed;
};
interface DataTableProps<TData, TValue> {
columns: ColumnDef<PropertyWithRelations>[];
data: PropertyWithRelations[];
interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[];
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<TData>(offset: number): TData[] {
// placeholder function for fetching
const data: TData[] = [];
return data;
}
export default function DataTable<TData, TValue>({
export default function DataTable<TData extends Record<string, any>>({
data,
columns,
}: DataTableProps<TData, TValue>) {
}: DataTableProps<TData>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [tableData, setTableData] = useState<PropertyWithRelations[]>(data);
const [tableData, setTableData] = useState(() => [...data]);
const [offset, setOffset] = useState(0);
const [currentPageIndex, setCurrentPageIndex] = useState(0);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
@ -62,14 +60,12 @@ export default function DataTable<TData, TValue>({
// add page change handlers for DataTablePagination
const loadPaginatedData = () => {
const newData = fetchData(offset);
const newData = fetchData<TData>(offset);
if (newData) {
console.log("loadPaginatedData");
setTableData([...tableData, ...newData]);
setOffset(offset + 1);
return true;
}
return false;
};