Merge pull request #112 from Hestia-Homes/eco-project-data

Eco project data
This commit is contained in:
KhalimCK 2025-11-03 18:50:03 +00:00 committed by GitHub
commit 092ce207e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1279 additions and 313 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

@ -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 (
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickPortfolio}
>
<BuildingOfficeIcon className="h-4 w-4 mr-2" />
Portfolio
</NavigationMenuItem>
{navItems.map(({ label, icon: Icon, href, match }) => {
const isActive = match(pathname);
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickSummary}
>
<ChartBarIcon className="h-4 w-4 mr-2" />
Retrofit Summary
</NavigationMenuItem>
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickDecentHomes}
>
<HomeModernIcon className="h-4 w-4 mr-2" />
Decent Homes
</NavigationMenuItem>
{/* <NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickMeasures}
>
<WrenchScrewdriverIcon className="h-4 w-4 mr-2" />
Measures
</NavigationMenuItem> */}
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickProgressReport}
>
<ChartBarIcon className="h-4 w-4 mr-2" />
Live Projects
</NavigationMenuItem>
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickSettings}
>
<Cog6ToothIcon className="h-4 w-4 mr-2" />
Settings
</NavigationMenuItem>
return (
<NavigationMenuItem key={label} className="mx-1">
<button
onClick={() => router.push(href)}
className={cn(
"relative flex items-center justify-center rounded-md text-sm font-medium transition-all duration-300 p-[3px]",
isActive
? "bg-gradient-to-r from-brandblue via-brandbrown to-brandblue"
: ""
)}
>
<div
className={cn(
"flex h-full w-full items-center justify-center rounded-md px-4 py-2 transition-colors duration-300",
isActive
? "bg-white text-brandblue shadow-sm"
: "bg-gray-50 text-gray-800 hover:text-brandblue hover:bg-midblue hover:text-gray-100"
)}
>
<Icon className="h-4 w-4 mr-2" />
{label}
</div>
</button>
</NavigationMenuItem>
);
})}
<AddNewDropDown
portfolioId={portfolioId}
@ -113,6 +106,7 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
setIsRemoteAssessmentOpen={setIsRemoteAssessmentOpen}
/>
</NavigationMenuList>
<UploadCsvModal
isOpen={modalIsOpen}
setIsOpen={setModalIsOpen}

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

@ -0,0 +1,13 @@
import { PlanTypeEnum } from "@/app/db/schema/recommendations";
// Fixed Domna costs per delivery type
export const DOMNA_COST_MAP: Record<PlanTypeEnum, number> & {
default: number;
} = {
solar_eco4: 2250,
solar_hhrsh_eco4: 2250,
empty_cavity_eco: 800,
partial_cavity_eco: 800,
extraction_eco: 800,
default: 800,
};

View file

@ -76,7 +76,9 @@
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
}

View file

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

View file

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

View file

@ -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<string, any>[];
}
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<string, any[]>);
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 (
<div className="p-6 text-center text-gray-500 border rounded-2xl shadow-sm bg-white">
No deal data available.
</div>
);
}
return (
<div className="p-6 space-y-10">
{/* 🌍 Global Portfolio Overview */}
<div className="border rounded-2xl bg-gradient-to-br from-gray-50 to-white shadow-lg p-8 space-y-6">
<h2 className="text-center text-xl font-semibold text-gray-800 tracking-tight">
🌍 Global Portfolio Overview
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-center">
{/* Total */}
<button
onClick={() => handleOpenTable("All Properties", deals)}
className="group transition rounded-xl border bg-white shadow-sm hover:shadow-md hover:border-blue-400 p-5"
>
<p className="text-sm text-gray-500 uppercase tracking-wide">Total Properties</p>
<p className="text-3xl font-bold text-gray-800 group-hover:text-blue-600">
{totalProperties}
</p>
</button>
{/* Major Issues */}
<button
onClick={() => handleOpenTable("Major Condition Issues", majorConditionDeals)}
className="group transition rounded-xl border bg-white shadow-sm hover:shadow-md hover:border-red-400 p-5"
>
<p className="text-sm text-gray-500 uppercase tracking-wide">Major Condition Issues</p>
<p className="text-3xl font-bold text-red-600 group-hover:text-red-700">
{majorIssues}{" "}
<span className="text-gray-500 text-base font-medium">
({majorPercent}%)
</span>
</p>
</button>
{/* Project Selector */}
<div className="flex flex-col justify-center items-center">
<label
htmlFor="projectSelect"
className="block text-sm font-medium text-gray-700 mb-2"
>
Select Project
</label>
<div className="relative w-60">
<select
id="projectSelect"
value={currentProjectCode}
onChange={(e) => setCurrentProjectCode(e.target.value)}
className="w-full appearance-none px-4 py-2 pr-10 border rounded-lg shadow-sm bg-white text-gray-800 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{projectCodes.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</select>
<div className="absolute right-3 top-2.5 text-gray-500 pointer-events-none"></div>
</div>
</div>
</div>
</div>
{/* 📊 Project Insights */}
<div className="border rounded-2xl bg-gradient-to-br from-gray-50 to-white shadow-lg p-8 space-y-6">
<h2 className="text-center text-xl font-semibold text-gray-800 tracking-tight">
📊 Project-Level Insights
</h2>
<p className="text-center text-gray-500 text-sm">
Showing data for <span className="font-medium text-gray-700">{currentProjectCode}</span>
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="border rounded-xl p-5 shadow-md bg-white hover:shadow-lg transition">
<DealStageChart deals={currentDeals} onOpenTable={handleOpenTable} />
</div>
<div className="border rounded-xl p-5 shadow-md bg-white hover:shadow-lg transition">
<SurveyedPieChart deals={currentDeals} onOpenTable={handleOpenTable} />
</div>
</div>
</div>
{/* 🔹 Modal */}
{openTable && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm transition-opacity">
<div className="bg-white rounded-2xl shadow-2xl p-6 w-full max-w-6xl h-[90vh] flex flex-col animate-fadeIn">
<h2 className="text-2xl font-semibold mb-4 text-center text-gray-800">
{openTable.stage} {openTable.data.length} Properties
</h2>
<div className="flex-1 overflow-auto">
<TableViewer
data={openTable.data}
columns={["dealname", "landlordPropertyId", "outcome", "outcomeNotes"]}
columnLabels={{
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
outcome: "Outcome",
outcomeNotes: "Notes from Surveyor",
}}
/>
</div>
<div className="mt-4 flex justify-center">
<button
onClick={() => setOpenTable(null)}
className="px-6 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg transition"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}

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,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 (
<button
onClick={handleClick}
disabled={isPending}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
isActive
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-primary"
)}
>
{isPending ? (
<span className="flex items-center gap-2">
<span className="animate-spin h-3 w-3 border-2 border-primary border-t-transparent rounded-full" />
{children}
</span>
) : (
children
)}
</button>
);
}

View file

@ -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 (
<section>
<div className="flex justify-center border-b mb-6 max-w-8xl mx-auto">
<TabLink href={`/portfolio/${slug}/your-projects/proposal`}>
Proposal
</TabLink>
<TabLink href={`/portfolio/${slug}/your-projects/live`}>
Live Reporting
</TabLink>
</div>
<div>{children}</div>
</section>
);
}

View file

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

View file

@ -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<string, any>[];
}
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<string, any[]>
);
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 (
<Card className="p-8 text-center bg-gradient-to-br from-white to-gray-50 border border-gray-100 shadow-sm">
<CardContent>
<p className="text-gray-500 text-sm">No deal data available.</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4 w-full">
{/* 🌍 Global Overview */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* Total Properties */}
<StatCard
icon={Home}
title="Total Properties"
value={totalProperties}
onClick={() => handleOpenTable("All Properties", deals)}
accent="brandblue"
/>
{/* Major Issues */}
<StatCard
icon={AlertTriangle}
title="Major Condition Issues"
value={`${majorIssues} `}
subtitle={`(${majorPercent}%)`}
onClick={() =>
handleOpenTable("Major Condition Issues", majorConditionDeals)
}
accent="red"
/>
{/* Project Selector */}
<Card className="flex flex-col justify-center items-center border border-gray-100 bg-gradient-to-br from-white to-gray-50 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="font-normal">
<p className="text-xs uppercase text-gray-500 mb-1">
Select Project
</p>
</CardTitle>
</CardHeader>
<CardContent>
<div className="relative w-56">
<select
id="projectSelect"
value={currentProjectCode}
onChange={(e) => setCurrentProjectCode(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-white text-gray-800 focus:ring-2 focus:ring-brandblue focus:outline-none"
>
{projectCodes.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</select>
<div className="absolute right-3 top-2.5 text-gray-400 pointer-events-none">
</div>
</div>
</CardContent>
</Card>
</div>
{/* 📊 Project Insights */}
<Card className="border border-gray-100 bg-gradient-to-br from-white to-gray-50 shadow-md">
<CardHeader>
<CardTitle className="text-center text-lg font-semibold text-brandblue tracking-tight">
Project-Level Insights {currentProjectCode}
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-6">
<motion.div
whileHover={{ scale: 1.01 }}
className="border rounded-xl p-5 bg-white shadow-sm hover:shadow-md transition"
>
<DealStageChart
deals={currentDeals}
onOpenTable={handleOpenTable}
/>
</motion.div>
<motion.div
whileHover={{ scale: 1.01 }}
className="border rounded-xl p-5 bg-white shadow-sm hover:shadow-md transition"
>
<SurveyedPieChart
deals={currentDeals}
onOpenTable={handleOpenTable}
/>
</motion.div>
</CardContent>
</Card>
{/* 🔹 Table Modal */}
{openTable && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm transition-opacity">
<div className="bg-white rounded-2xl shadow-2xl p-6 w-full max-w-6xl h-[90vh] flex flex-col animate-fadeIn">
<h2 className="text-2xl font-semibold mb-4 text-center text-gray-800">
{openTable.stage} {openTable.data.length} Properties
</h2>
<div className="flex-1 overflow-auto">
<TableViewer
data={openTable.data}
columns={[
"dealname",
"landlordPropertyId",
"outcome",
"outcomeNotes",
]}
columnLabels={{
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
outcome: "Outcome",
outcomeNotes: "Notes from Surveyor",
}}
/>
</div>
<div className="mt-4 flex justify-center">
<button
onClick={() => setOpenTable(null)}
className="px-6 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg transition"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}
/** 🔸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 (
<motion.button
onClick={onClick}
whileHover={{ scale: 1.02 }}
className={`group relative text-left border rounded-xl bg-gradient-to-br ${accentColor} transition-all duration-200 shadow-sm hover:shadow-md p-5`}
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase text-gray-500 mb-1">{title}</p>
<p className="text-3xl font-bold text-gray-800 group-hover:text-inherit">
{value}
{subtitle && (
<span className="text-base font-medium text-gray-500 ml-1">
{subtitle}
</span>
)}
</p>
</div>
<Icon className="h-6 w-6 opacity-50 group-hover:opacity-100 transition" />
</div>
</motion.button>
);
}

View file

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

View file

@ -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 (
<main className="relative min-h-screen overflow-hidden">
{/* 🌊 Domna-inspired layered background */}
{/* <div className="absolute inset-0 -z-10 bg-[linear-gradient(135deg,#14163d_0%,#2d348f_45%,#3943b7_70%,#eff6fc_100%)]" /> */}
{/* ✨ Subtle translucent grid texture */}
<div className="absolute inset-0 -z-0 opacity-10 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.7)_1px,transparent_0)] bg-[length:40px_40px]" />
{/* 💡 Optional soft light glow at top */}
<div className="absolute top-0 inset-x-0 h-[40vh] bg-gradient-to-b from-white/20 via-transparent to-transparent opacity-30" />
{/* Main content */}
<div className="relative z-10 p-6 md:p-10">
<div className="max-w-7xl mx-auto animate-fadeIn">
<LiveTracker deals={deals} />
</div>
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
<div className="mb-6">
<header className="text-3xl font-semibold text-brandblue">
Live Projects
</header>
<p className="text-sm text-gray-500">
Check in on your projects' progress with real-time data updates.
</p>
<div className="h-px bg-gray-200 mt-2" />
</div>
</main>
<LiveTracker deals={deals} />
</div>
);
}

View file

@ -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<string, string> = {
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<string | null>(null);
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);
}
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 (
<div className="grid grid-cols-1 grid-cols-5 gap-4">
{/* Chart */}
<Card className="col-span-3 border border-gray-100 bg-gradient-to-br from-white to-gray-50/40 shadow-sm">
<CardHeader>
<CardTitle className="text-base font-medium text-brandblue">
Homes by Work Type
</CardTitle>
</CardHeader>
<CardContent>
{grouped.length > 1 ? (
<BarChart
data={grouped}
index="planType"
categories={["count"]}
colors={["#2d348f", "#14163d", "#3943b7", "#5d6be0"]}
valueFormatter={(v) => v.toString()}
onValueChange={(v) =>
setSelectedType(
v && typeof v === "object" && "planType" in v
? String((v as any).planType)
: null
)
}
className="h-64"
/>
) : (
<DonutChart
data={grouped}
category="count"
index="planType"
colors={["#2d348f", "#14163d", "#3943b7", "#5d6be0"]}
valueFormatter={(v) => `${v} home${v === 1 ? "" : "s"}`}
/>
)}
</CardContent>
</Card>
{/* Metrics */}
<Card className="col-span-2 border border-gray-200 bg-white">
<CardHeader>
<CardTitle className="text-lg font-semibold text-brandblue">
{mappedTitles[selectedType || "default"]}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<p className="text-sm text-gray-500 mb-1">Total investment</p>
<p className="text-2xl font-semibold text-brandbrown">
£{formatNumber(selectedData?.totalClientContribution || 0)}
</p>
<p className="text-xs text-gray-500">
Avg per home £
{formatNumber(selectedData?.avgClientContribution || 0)}
</p>
</div>
<div className="grid grid-cols-3 gap-4 text-center border-t pt-4">
<div>
<p className="text-xs text-gray-500 mb-1">Funding</p>
<p className="text-md font-medium text-brandblue">
£{formatNumber(selectedData?.totalFunding || 0)}
</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Carbon</p>
<p className="text-md font-medium text-brandblue">
{((selectedData?.totalCarbon || 0) * 1000).toFixed(2)} kgCOe
</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Bills</p>
<p className="text-md font-medium text-brandblue">
£{formatNumber(selectedData?.totalBills || 0)}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
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 <PoundSterling />
},
{
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 (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((c) => {
const Icon = c.icon;
return (
<Card
key={c.title}
className="border border-gray-100 bg-gradient-to-br from-white to-brandlightblue/10 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300"
>
<CardHeader className="flex flex-row items-center gap-2 pb-1">
<div className="p-1.5 rounded-md bg-brandlightblue/40">
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
className="p-1.5 rounded-md bg-brandlightblue/40"
>
<Icon
className={`h-4 w-4 ${
c.title.includes("Funding")
? "text-brandbrown"
: c.title.includes("Carbon")
? "text-emerald-600"
: c.title.includes("Bill")
? "text-amber-600"
: "text-brandblue"
}`}
/>
</motion.div>
</div>
<CardTitle className="text-lg font-medium text-gray-600">
{c.title}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-semibold text-transparent bg-clip-text bg-gradient-to-r from-brandblue to-midblue mb-1 pb-2">
{c.value}
</div>
<p className="text-xs text-gray-500">{c.subtitle}</p>
</CardContent>
</Card>
);
})}
</div>
);
}

View file

@ -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 (
<div
className={`inline-flex items-center justify-center w-6 h-6 rounded-full ${getEpcColorClass(
letter
)} text-white text-m font-bold shadow-outline-black`}
>
{letter}
</div>
);
};
export const planColumns: ColumnDef<PlanWithTotals>[] = [
{
accessorKey: "landlordPropertyId",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Property Reference
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<div className="text-gray-700 text-center">
{row.original.landlordPropertyId || "—"}
</div>
),
},
{
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 text-center text-sm">
{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 text-center">
{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 text-center">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 text-center">
{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">
<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">
<span className="font-medium text-center">
{((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">
<span className="font-medium text-center">
£{formatNumber(row.original.totalBillSavings || 0)}
</span>
</div>
),
},
{
accessorKey: "clientContribution",
header: () => <div className="text-center">Investment</div>,
cell: ({ row }) => (
<div className="flex items-center justify-center gap-1">
<span className="font-semibold text-center">
£{formatNumber(row.original.clientContribution || 0)}
</span>
</div>
),
sortingFn: "alphanumeric",
},
{
accessorKey: "currentEpc",
header: () => <div className="flex justify-center">Current EPC Rating</div>,
cell: ({ row }) => {
return (
<div className="text-gray-700 font-medium flex justify-center">
{<EpcLetterBubble letter={row.original.currentEpcRating || ""} />}
</div>
);
},
},
{
accessorKey: "targetEpc",
header: () => <div className="flex justify-center">Expected EPC</div>,
cell: ({ row }) => {
const currentSapPoints = row.original.currentSapPoints || 0;
const expectedSapPoints = row.original.totalRecommendationSapPoints || 0;
const expectedEpc = sapToEpc(currentSapPoints + expectedSapPoints);
return (
<div className="text-gray-700 font-medium flex justify-center">
{<EpcLetterBubble letter={expectedEpc || ""} />}
</div>
);
},
},
];

View file

@ -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 (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
<div className="mb-6">
<header className="text-3xl font-semibold text-brandblue">
Project Overview
</header>
<p className="text-sm text-gray-500">
Summary of funding, carbon savings, and household metrics.
</p>
<div className="h-px bg-gray-200 mt-2" />
</div>
<DashboardSummary plans={latestPlans} />
<ProjectProposal plans={latestPlans} />
<section>
<h2 className="text-xl font-semibold text-brandblue mb-1">
Your Homes
</h2>
<DataTable data={latestPlans} columns={planColumns} />
</section>
</div>
);
}

View file

@ -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<string, unknown> {
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<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",
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;
}

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;
};

View file

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

204
src/lib/chartUtils.ts Normal file
View file

@ -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<AvailableChartColorsKeys>;
export const constructCategoryColors = (
categories: string[],
colors: AvailableChartColorsKeys[]
): Map<string, AvailableChartColorsKeys> => {
const categoryColors = new Map<string, AvailableChartColorsKeys>();
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;
}

View file

@ -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))
}
// 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",
];

View file

@ -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 }) {