mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-12 13:28:55 +00:00
functional display for project proposal ui
This commit is contained in:
parent
f354d55881
commit
6a98a530ed
8 changed files with 389 additions and 21 deletions
|
|
@ -22,7 +22,6 @@ export default function StatusBadge({
|
|||
isProperty?: boolean;
|
||||
}) {
|
||||
const statusConfig = statusColor[status];
|
||||
console.log("status", status, statusConfig);
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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)} kgCO₂e
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)} kgCO₂e
|
||||
</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>
|
||||
),
|
||||
},
|
||||
];
|
||||
28
src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx
Normal file
28
src/app/portfolio/[slug]/(portfolio)/temp-reporting/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts
Normal file
69
src/app/portfolio/[slug]/(portfolio)/temp-reporting/utils.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue