mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Merge pull request #118 from Hestia-Homes/main
Going live with proposals and live tracking
This commit is contained in:
commit
bcfdbef50e
39 changed files with 2157 additions and 360 deletions
2
.db-env
Normal file
2
.db-env
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
PGADMIN_DEFAULT_EMAIL=junte@domna.homes
|
||||
PGADMIN_DEFAULT_PASSWORD=makingwarmhomes
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
frontend:
|
||||
|
|
@ -14,6 +14,17 @@ services:
|
|||
networks:
|
||||
- frontend-net
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4
|
||||
hostname: pgadmin
|
||||
ports:
|
||||
- 5556:80
|
||||
env_file:
|
||||
- ../.db-env
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- frontend-net
|
||||
|
||||
networks:
|
||||
frontend-net:
|
||||
driver: bridge
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
npm install;
|
||||
npm install;
|
||||
|
|
|
|||
27
package-lock.json
generated
27
package-lock.json
generated
|
|
@ -28,9 +28,10 @@
|
|||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@tanstack/match-sorter-utils": "^8.19.4",
|
||||
"@tanstack/react-query": "^4.29.12",
|
||||
"@tanstack/react-table": "^8.9.3",
|
||||
"@tremor/react": "^3.16.0",
|
||||
"@tremor/react": "^3.18.7",
|
||||
"@types/node": "20.2.3",
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-dom": "18.3.1",
|
||||
|
|
@ -64,7 +65,7 @@
|
|||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@testing-library/cypress": "^10.0.3",
|
||||
"@types/nodemailer": "^7.0.2",
|
||||
"@types/pg": "^8.10.2",
|
||||
|
|
@ -5549,6 +5550,22 @@
|
|||
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/match-sorter-utils": {
|
||||
"version": "8.19.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz",
|
||||
"integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"remove-accents": "0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "4.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.40.0.tgz",
|
||||
|
|
@ -12985,6 +13002,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/remove-accents": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
|
||||
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/request-progress": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -34,9 +34,10 @@
|
|||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@tanstack/match-sorter-utils": "^8.19.4",
|
||||
"@tanstack/react-query": "^4.29.12",
|
||||
"@tanstack/react-table": "^8.9.3",
|
||||
"@tremor/react": "^3.16.0",
|
||||
"@tremor/react": "^3.18.7",
|
||||
"@types/node": "20.2.3",
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-dom": "18.3.1",
|
||||
|
|
@ -70,7 +71,7 @@
|
|||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@testing-library/cypress": "^10.0.3",
|
||||
"@types/nodemailer": "^7.0.2",
|
||||
"@types/pg": "^8.10.2",
|
||||
|
|
|
|||
|
|
@ -1,39 +1,33 @@
|
|||
// app/api/book-survey/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/app/db/db";
|
||||
import { propertyStatusTracker } from "@/app/db/schema/crm/property_status_tracker";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { user } from "@/app/db/schema/users";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { dealName, pipelineId, dealStageId, propertyId, portfolioId } =
|
||||
await req.json();
|
||||
console.log("📩 Incoming POST /api/property-status request");
|
||||
|
||||
// 1️⃣ Create HubSpot deal
|
||||
const hsRes = await fetch("https://api.hubapi.com/crm/v3/objects/deals", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.HUBSPOT_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
properties: {
|
||||
dealname: dealName,
|
||||
pipeline: pipelineId,
|
||||
dealstage: dealStageId,
|
||||
},
|
||||
}),
|
||||
try {
|
||||
const {
|
||||
pipelineId,
|
||||
dealStageId,
|
||||
propertyId,
|
||||
portfolioId,
|
||||
userInfo,
|
||||
propertyMeta,
|
||||
} = await req.json();
|
||||
|
||||
console.log("🧠 Parsed body:", {
|
||||
pipelineId,
|
||||
dealStageId,
|
||||
propertyId,
|
||||
portfolioId,
|
||||
userInfo,
|
||||
propertyMeta,
|
||||
});
|
||||
|
||||
if (!hsRes.ok) {
|
||||
const err = await hsRes.text();
|
||||
throw new Error(`HubSpot error: ${err}`);
|
||||
}
|
||||
|
||||
const hsData = await hsRes.json();
|
||||
const hubspotDealId = hsData.id;
|
||||
|
||||
// 2️⃣ Check if record exists for property + portfolio
|
||||
// 1️⃣ Check if record exists first
|
||||
console.log("🔍 Checking if record already exists in DB...");
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(propertyStatusTracker)
|
||||
|
|
@ -44,32 +38,170 @@ export async function POST(req: Request) {
|
|||
)
|
||||
);
|
||||
|
||||
console.log("🗃️ Existing record check result:", existing);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// 3️⃣ Update existing record
|
||||
await db
|
||||
.update(propertyStatusTracker)
|
||||
.set({
|
||||
hubspotDealId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(propertyStatusTracker.propertyId, propertyId),
|
||||
eq(propertyStatusTracker.portfolioId, portfolioId)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// 4️⃣ Create new record
|
||||
await db.insert(propertyStatusTracker).values({
|
||||
hubspotDealId: hubspotDealId,
|
||||
propertyId: propertyId,
|
||||
portfolioId: portfolioId,
|
||||
console.log("⚠️ Record already exists, skipping deal creation");
|
||||
return NextResponse.json({
|
||||
message: "Record already exists, no new deal created",
|
||||
dealId: existing[0].hubspotDealId,
|
||||
});
|
||||
}
|
||||
|
||||
// 2️⃣ Create HubSpot deal
|
||||
console.log("🧱 Creating HubSpot deal...");
|
||||
const dealRes = await fetch("https://api.hubapi.com/crm/v3/objects/deals", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.HUBSPOT_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
properties: {
|
||||
dealname: propertyMeta?.address || "New Property Deal",
|
||||
pipeline: pipelineId,
|
||||
dealstage: dealStageId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("📡 HubSpot deal response status:", dealRes.status);
|
||||
|
||||
if (!dealRes.ok) {
|
||||
const err = await dealRes.text();
|
||||
console.error("❌ HubSpot Deal creation failed:", err);
|
||||
throw new Error(`HubSpot Deal Error: ${err}`);
|
||||
}
|
||||
|
||||
const dealData = await dealRes.json();
|
||||
const hubspotDealId = dealData.id;
|
||||
console.log("✅ Created HubSpot deal:", hubspotDealId);
|
||||
|
||||
// 3️⃣ Retrieve user info from your DB
|
||||
console.log("👤 Fetching user info from DB...");
|
||||
const userProfile = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, userInfo.dbId))
|
||||
.limit(1);
|
||||
|
||||
const userInfoFromDb = userProfile[0];
|
||||
console.log("📇 User info from DB:", userInfoFromDb);
|
||||
|
||||
if (!userInfoFromDb?.email) {
|
||||
console.error("❌ User email missing in DB for user:", userInfo.dbId);
|
||||
throw new Error("User email not found; cannot create HubSpot contact.");
|
||||
}
|
||||
|
||||
// 4️⃣ Create or find contact in HubSpot
|
||||
console.log("📞 Creating HubSpot contact...");
|
||||
const contactRes = await fetch("https://api.hubapi.com/crm/v3/objects/contacts", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.HUBSPOT_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
properties: {
|
||||
email: userInfoFromDb.email,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
let hubspotContactId: string | null = null;
|
||||
|
||||
console.log("📡 HubSpot contact response status:", contactRes.status);
|
||||
|
||||
if (contactRes.ok) {
|
||||
const contactData = await contactRes.json();
|
||||
hubspotContactId = contactData.id;
|
||||
console.log("✅ Created new HubSpot contact:", hubspotContactId);
|
||||
} else {
|
||||
console.warn("⚠️ HubSpot contact creation failed — checking if contact exists...");
|
||||
|
||||
// Check if contact already exists
|
||||
const findContactRes = await fetch(
|
||||
`https://api.hubapi.com/crm/v3/objects/contacts/search`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.HUBSPOT_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filterGroups: [
|
||||
{
|
||||
filters: [
|
||||
{
|
||||
propertyName: "email",
|
||||
operator: "EQ",
|
||||
value: userInfoFromDb.email,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
console.log("📡 HubSpot contact search response:", findContactRes.status);
|
||||
|
||||
if (findContactRes.ok) {
|
||||
const found = await findContactRes.json();
|
||||
console.log("🔎 Found contact results:", found.results);
|
||||
if (found.results?.length > 0) {
|
||||
hubspotContactId = found.results[0].id;
|
||||
console.log("✅ Found existing HubSpot contact:", hubspotContactId);
|
||||
} else {
|
||||
console.error("❌ HubSpot contact creation and lookup both failed");
|
||||
throw new Error("HubSpot contact creation and lookup both failed.");
|
||||
}
|
||||
} else {
|
||||
const findErr = await findContactRes.text();
|
||||
console.error("❌ HubSpot contact search failed:", findErr);
|
||||
throw new Error("HubSpot contact lookup request failed.");
|
||||
}
|
||||
}
|
||||
|
||||
// 5️⃣ Associate contact with deal
|
||||
if (hubspotContactId) {
|
||||
console.log("🔗 Associating HubSpot deal and contact...");
|
||||
const assocUrl = `https://api.hubapi.com/crm/v3/objects/deals/${hubspotDealId}/associations/contacts/${hubspotContactId}/deal_to_contact`;
|
||||
|
||||
const assocRes = await fetch(assocUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.HUBSPOT_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("📡 HubSpot association response:", assocRes.status);
|
||||
|
||||
if (!assocRes.ok) {
|
||||
const assocErr = await assocRes.text();
|
||||
console.warn("⚠️ HubSpot association failed:", assocErr);
|
||||
} else {
|
||||
console.log("✅ Successfully associated contact with deal");
|
||||
}
|
||||
}
|
||||
|
||||
// 6️⃣ Create DB record
|
||||
console.log("🗄️ Inserting new tracker record into DB...");
|
||||
await db.insert(propertyStatusTracker).values({
|
||||
hubspotDealId,
|
||||
propertyId,
|
||||
portfolioId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
console.log("✅ All done — returning success response");
|
||||
|
||||
return NextResponse.json({
|
||||
message: existing.length > 0 ? "Updated existing tracker" : "Created new tracker",
|
||||
message: "Created new tracker, HubSpot deal, and linked contact",
|
||||
dealId: hubspotDealId,
|
||||
contactId: hubspotContactId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ Error creating or updating HubSpot deal:", error);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ import {
|
|||
HoverCardTrigger,
|
||||
} from "@/app/shadcn_components/ui/hover-card";
|
||||
|
||||
type ExtendedStatus = (typeof PortfolioStatus)[number] | "ECO4" | "GBIS";
|
||||
type ExtendedStatus =
|
||||
| (typeof PortfolioStatus)[number]
|
||||
| "ECO4"
|
||||
| "GBIS"
|
||||
| "NONE";
|
||||
|
||||
export default function StatusBadge({
|
||||
status,
|
||||
|
|
@ -129,4 +133,10 @@ const statusColor: {
|
|||
hoverText: "This property is funded under the GBIS scheme",
|
||||
propertyHoverText: "This property is funded under the GBIS scheme",
|
||||
},
|
||||
NONE: {
|
||||
class: "bg-gray-400 hover:bg-gray-400",
|
||||
text: "No Funding",
|
||||
hoverText: "This property has no funding scheme applied",
|
||||
propertyHoverText: "This property has no funding scheme applied",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ interface RecommendationContainerProps {
|
|||
recommendations: Recommendation[];
|
||||
propertyMeta: PropertyMeta;
|
||||
planMeta: Plan;
|
||||
funding: FundingPackageWithMeasures[]
|
||||
funding: FundingPackageWithMeasures[];
|
||||
}
|
||||
|
||||
const typeToCategoryMap: { [key in RecommendationType]?: RecommendationType } =
|
||||
|
|
@ -56,19 +56,22 @@ export default function RecommendationContainer({
|
|||
recommendations,
|
||||
propertyMeta,
|
||||
planMeta,
|
||||
funding
|
||||
funding,
|
||||
}: RecommendationContainerProps) {
|
||||
const categorizedRecommendations = recommendations.reduce((acc, curr) => {
|
||||
const typeKey = curr.type as RecommendationType;
|
||||
const category = typeToCategoryMap[typeKey] ?? typeKey;
|
||||
const categorizedRecommendations = recommendations.reduce(
|
||||
(acc, curr) => {
|
||||
const typeKey = curr.type as RecommendationType;
|
||||
const category = typeToCategoryMap[typeKey] ?? typeKey;
|
||||
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(curr);
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(curr);
|
||||
|
||||
return acc;
|
||||
}, {} as Record<RecommendationType, (typeof recommendations)[0][]>);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<RecommendationType, (typeof recommendations)[0][]>
|
||||
);
|
||||
|
||||
const defaultWallsRecommendations =
|
||||
categorizedRecommendations.wall_insulation?.find(
|
||||
|
|
@ -319,8 +322,13 @@ export default function RecommendationContainer({
|
|||
if (funding.length > 1) {
|
||||
console.warn("Multiple funding packages found, using the first one.");
|
||||
}
|
||||
// Sum up project funding and uplift
|
||||
|
||||
const [totalFunding, setTotalFunding] = useState(funding[0]?.projectFunding || 0)
|
||||
const [totalFunding, setTotalFunding] = useState(
|
||||
funding[0]
|
||||
? (funding[0].projectFunding ?? 0) + (funding[0].totalUplift ?? 0)
|
||||
: 0
|
||||
);
|
||||
|
||||
const currentEpcRating = propertyMeta.currentEpcRating;
|
||||
const currentSapPoints = propertyMeta.currentSapPoints;
|
||||
|
|
|
|||
|
|
@ -70,6 +70,11 @@ export function Toolbar({
|
|||
const [openModal, setOpenModal] = useState(false);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
console.log(propertyId, "PropertyID")
|
||||
console.log(portfolioId, "porfolio id")
|
||||
console.log(propertyMeta, "property meta")
|
||||
console.log(decentHomes, "decent homes")
|
||||
|
||||
function handleClickSettings() {
|
||||
console.log("Settings were clicked, implement me");
|
||||
}
|
||||
|
|
@ -124,6 +129,8 @@ export function Toolbar({
|
|||
</NavigationMenuLink>
|
||||
);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
|
|
@ -145,7 +152,6 @@ export function Toolbar({
|
|||
{solarAnalysisButton}
|
||||
{recommendationsButton}
|
||||
{documentsButton}
|
||||
|
||||
<NavigationMenuItem
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
onClick={handleClickSettings}
|
||||
|
|
@ -174,7 +180,7 @@ export function Toolbar({
|
|||
onOpenChange={setOpenModal}
|
||||
propertyId={BigInt(propertyId)}
|
||||
portfolioId={portfolioId}
|
||||
address={propertyMeta.address}
|
||||
propertyMeta={propertyMeta}
|
||||
onSuccess={() => setShowToast(true)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -183,8 +189,8 @@ export function Toolbar({
|
|||
<BookingSuccessToast
|
||||
show={showToast}
|
||||
onClose={() => setShowToast(false)}
|
||||
message="Survey Booked Successfully!"
|
||||
subtext="Your Survey Request is with Domna and we will be in contact. 🎉"
|
||||
message="Survey Request Recieved!"
|
||||
subtext="We'll be in contact soon. 🎉"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
BuildingOfficeIcon,
|
||||
ChartBarIcon,
|
||||
HomeModernIcon,
|
||||
RocketLaunchIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
NavigationMenu,
|
||||
|
|
@ -12,88 +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`);
|
||||
}
|
||||
|
||||
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={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}
|
||||
|
|
@ -103,6 +106,7 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
|
|||
setIsRemoteAssessmentOpen={setIsRemoteAssessmentOpen}
|
||||
/>
|
||||
</NavigationMenuList>
|
||||
|
||||
<UploadCsvModal
|
||||
isOpen={modalIsOpen}
|
||||
setIsOpen={setModalIsOpen}
|
||||
|
|
|
|||
19
src/app/db/schema/crm/hubspot_company_table.ts
Normal file
19
src/app/db/schema/crm/hubspot_company_table.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
export const hubspotCompanyData = pgTable("hubspot_company_data", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
companyId: text("company_id").notNull(),
|
||||
companyName: text("company_name"),
|
||||
groupId: text("group_id"),
|
||||
|
||||
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
|
||||
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
27
src/app/db/schema/crm/hubspot_deal_table.ts
Normal file
27
src/app/db/schema/crm/hubspot_deal_table.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { InferModel } from "drizzle-orm";
|
||||
|
||||
export const hubspotDealData = pgTable("hubspot_deal_data", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
dealId: text("deal_id").notNull(),
|
||||
dealname: text("dealname"),
|
||||
dealstage: text("dealstage"),
|
||||
companyId: text("company_id"),
|
||||
projectCode: text("project_code"),
|
||||
|
||||
landlordPropertyId: text("landlord_property_id"),
|
||||
uprn: text("uprn"),
|
||||
outcome: text("outcome"),
|
||||
outcomeNotes: text("outcome_notes"),
|
||||
|
||||
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
|
||||
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
|
|
@ -257,25 +257,20 @@ export interface PropertyToRecommendation {
|
|||
sapPoints?: number | null;
|
||||
}
|
||||
|
||||
export interface PropertyWithRelations {
|
||||
status: string | null;
|
||||
id: bigint;
|
||||
portfolioId: bigint;
|
||||
creationStatus: string;
|
||||
export interface PropertyWithRelations extends Record<string, unknown> {
|
||||
id: number | string | bigint;
|
||||
portfolioId: number | string | bigint;
|
||||
address: string | null;
|
||||
postcode: string | null;
|
||||
target: { epc?: string | null; heatDemand?: number | null };
|
||||
recommendations: PropertyToRecommendation[];
|
||||
cost?: number | null;
|
||||
status: string | null;
|
||||
creationStatus: string | null;
|
||||
currentEpcRating: string | null;
|
||||
currentSapPoints: number | null;
|
||||
plans: {
|
||||
id: bigint;
|
||||
isDefault?: boolean;
|
||||
fundingPackage?: {
|
||||
scheme: string | null;
|
||||
} | null;
|
||||
}[];
|
||||
targetEpc: string | null;
|
||||
planId: number | null;
|
||||
fundingScheme: string | null;
|
||||
totalRecommendationSapPoints: number | null;
|
||||
totalRecommendationCost: number | null;
|
||||
}
|
||||
|
||||
export type NonIntrusiveSurveyNotes = InferModel<
|
||||
|
|
|
|||
|
|
@ -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,13 +65,19 @@ 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",
|
||||
"partial_cavity_eco",
|
||||
"extraction_eco",
|
||||
];
|
||||
export type PlanTypeEnum =
|
||||
| "solar_eco4"
|
||||
| "solar_hhrsh_eco4"
|
||||
| "empty_cavity_eco"
|
||||
| "partial_cavity_eco"
|
||||
| "extraction_eco";
|
||||
export const planTypeEnum = pgEnum("plan_type", PlanType);
|
||||
|
||||
export const plan = pgTable("plan", {
|
||||
|
|
@ -260,7 +265,7 @@ export const measuresDisplayLabels = {
|
|||
suspended_floor_insulation: "Suspended Floor Insulation",
|
||||
solid_floor_insulation: "Solid Floor Insulation",
|
||||
boiler_upgrade: "Boiler Upgrade",
|
||||
high_heat_retention_storage_heater: "High Heat Retention Storage Heater",
|
||||
high_heat_retention_storage_heaters: "High Heat Retention Storage Heater",
|
||||
air_source_heat_pump: "Air Source Heat Pump",
|
||||
secondary_heating: "Secondary Heating",
|
||||
solar_pv: "Solar PV",
|
||||
|
|
|
|||
13
src/app/domna/financials.ts
Normal file
13
src/app/domna/financials.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { 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,
|
||||
};
|
||||
|
|
@ -76,7 +76,9 @@
|
|||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
font-feature-settings:
|
||||
"rlig" 1,
|
||||
"calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { BarList, Card, Title } from "@tremor/react";
|
||||
|
||||
const STAGE_ORDER = [
|
||||
"Initial planning",
|
||||
"Booking team to contact tenant",
|
||||
"Survey in progress",
|
||||
"Not viable",
|
||||
"Needs support",
|
||||
"Coordination + design",
|
||||
"Ready to be installed",
|
||||
];
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
"1617223910": STAGE_ORDER[0],
|
||||
"3583836399": STAGE_ORDER[0],
|
||||
"3589581001": STAGE_ORDER[1],
|
||||
"3569878239": STAGE_ORDER[1],
|
||||
"1617223911": STAGE_ORDER[1],
|
||||
"1984184569": STAGE_ORDER[1],
|
||||
"3569572028": STAGE_ORDER[1],
|
||||
"3570936026": STAGE_ORDER[1],
|
||||
"2663668937": STAGE_ORDER[1],
|
||||
"1984401629": STAGE_ORDER[1],
|
||||
"1617223912": STAGE_ORDER[2],
|
||||
"1617223913": STAGE_ORDER[2],
|
||||
"2558220518": STAGE_ORDER[1],
|
||||
"3474594026": STAGE_ORDER[1],
|
||||
"3206388924": STAGE_ORDER[2],
|
||||
"1617223915": STAGE_ORDER[2],
|
||||
"1617223917": STAGE_ORDER[2],
|
||||
"1887735998": STAGE_ORDER[3],
|
||||
"3061261536": STAGE_ORDER[4],
|
||||
"2571417798": STAGE_ORDER[2],
|
||||
"1617223914": STAGE_ORDER[5],
|
||||
"1887736000": STAGE_ORDER[2],
|
||||
"1617223916": STAGE_ORDER[2],
|
||||
"2628341989": STAGE_ORDER[2],
|
||||
"3441170637": STAGE_ORDER[2],
|
||||
"2628233422": STAGE_ORDER[5],
|
||||
"1887735999": STAGE_ORDER[4],
|
||||
"2702650617": STAGE_ORDER[5],
|
||||
"2473886962": STAGE_ORDER[5],
|
||||
"3016601828": STAGE_ORDER[4],
|
||||
"1668803774": STAGE_ORDER[6],
|
||||
"3440363736": STAGE_ORDER[6],
|
||||
};
|
||||
|
||||
interface DealStageChartProps {
|
||||
deals: any[];
|
||||
onOpenTable?: (stageName: string, filteredDeals: any[]) => void;
|
||||
}
|
||||
|
||||
export function DealStageChart({ deals, onOpenTable }: DealStageChartProps) {
|
||||
const data = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
deals.forEach((d) => {
|
||||
const stageId = d.dealstage || "unknown";
|
||||
const stageName = STAGE_LABELS[stageId] || "Unknown Stage";
|
||||
counts[stageName] = (counts[stageName] || 0) + 1;
|
||||
});
|
||||
|
||||
const unsorted = Object.entries(counts).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
}));
|
||||
|
||||
return unsorted.sort((a, b) => {
|
||||
const aIndex = STAGE_ORDER.indexOf(a.name);
|
||||
const bIndex = STAGE_ORDER.indexOf(b.name);
|
||||
return (
|
||||
(aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) -
|
||||
(bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex)
|
||||
);
|
||||
});
|
||||
}, [deals]);
|
||||
|
||||
const handleBarClick = (value: { name: string; value: number }) => {
|
||||
const filtered = deals.filter((d) => {
|
||||
const stageId = d.dealstage || "unknown";
|
||||
const stageName = STAGE_LABELS[stageId] || "Unknown Stage";
|
||||
return stageName === value.name;
|
||||
});
|
||||
onOpenTable?.(value.name, filtered);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg mx-auto bg-white rounded-2xl shadow-md hover:shadow-lg transition-all duration-200 p-6">
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<Title className="text-gray-800 text-lg font-semibold tracking-tight text-center">
|
||||
Project Progress by Stage
|
||||
</Title>
|
||||
<p className="text-sm text-gray-500 text-center mt-1">
|
||||
Click a bar to view related properties
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<BarList
|
||||
data={data}
|
||||
color="blue"
|
||||
sortOrder="none"
|
||||
className="cursor-pointer"
|
||||
onValueChange={handleBarClick}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import { DonutChart, Card, Title } from "@tremor/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface SurveyedPieChartProps {
|
||||
deals: Record<string, any>[];
|
||||
onOpenTable?: (outcome: string, filteredDeals: Record<string, any>[]) => void;
|
||||
}
|
||||
|
||||
export default function SurveyedPieChart({
|
||||
deals,
|
||||
onOpenTable,
|
||||
}: SurveyedPieChartProps) {
|
||||
const surveyorOutcomes = [
|
||||
"Surveyed",
|
||||
"Surveyed - Pending Upload",
|
||||
"Tenant Refusal",
|
||||
"Other",
|
||||
"Not Viable",
|
||||
"Not Attempted",
|
||||
"No Answer",
|
||||
"Cancelled / No Show",
|
||||
"Rescheduled",
|
||||
];
|
||||
|
||||
const data = useMemo(() => {
|
||||
const outcomeCounts: Record<string, number> = {};
|
||||
deals.forEach((deal) => {
|
||||
const outcome = deal.outcome;
|
||||
if (outcome && surveyorOutcomes.includes(outcome)) {
|
||||
outcomeCounts[outcome] = (outcomeCounts[outcome] || 0) + 1;
|
||||
}
|
||||
});
|
||||
return Object.entries(outcomeCounts).map(([name, amount]) => ({
|
||||
name,
|
||||
amount,
|
||||
}));
|
||||
}, [deals]);
|
||||
|
||||
const handleClick = (value: { name: string; amount: number }) => {
|
||||
if (!value) return;
|
||||
const filteredDeals = deals.filter((d) => d.outcome === value.name);
|
||||
onOpenTable?.(value.name, filteredDeals);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg mx-auto bg-white rounded-2xl shadow-md hover:shadow-lg transition-all duration-200 p-6">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Title className="text-gray-800 text-lg font-semibold tracking-tight text-center">
|
||||
Survey Outcomes
|
||||
</Title>
|
||||
<p className="text-sm text-gray-500 text-center -mt-2">
|
||||
Click a segment to view filtered properties
|
||||
</p>
|
||||
|
||||
<DonutChart
|
||||
data={data}
|
||||
category="amount"
|
||||
index="name"
|
||||
valueFormatter={(n) => `${n.toLocaleString()}`}
|
||||
colors={[
|
||||
"#2d348f",
|
||||
"#14163d",
|
||||
"#3943b7",
|
||||
"#5d6be0",
|
||||
"black",
|
||||
"#eff6fc",
|
||||
"lightBlue",
|
||||
"navy",
|
||||
"azure",
|
||||
]}
|
||||
className="w-64 h-64 cursor-pointer transition-transform hover:scale-[1.03]"
|
||||
onValueChange={handleClick}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
interface TableViewerProps {
|
||||
data: Record<string, any>[];
|
||||
columns?: string[];
|
||||
columnLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export default function TableViewer({ data, columns, columnLabels }: TableViewerProps) {
|
||||
const [searchTerms, setSearchTerms] = useState<Record<string, string>>({});
|
||||
const visibleColumns = columns?.length ? columns : Object.keys(data?.[0] || {});
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return data.filter((row) =>
|
||||
visibleColumns.every((col) => {
|
||||
const term = searchTerms[col]?.toLowerCase() || "";
|
||||
if (!term) return true;
|
||||
const value = String(row[col] ?? "").toLowerCase();
|
||||
return value.includes(term);
|
||||
})
|
||||
);
|
||||
}, [data, searchTerms, visibleColumns]);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto border rounded-xl shadow-lg bg-white">
|
||||
<table className="min-w-full text-sm border-collapse">
|
||||
<thead className="bg-gray-100 sticky top-0">
|
||||
<tr>
|
||||
{visibleColumns.map((col) => (
|
||||
<th key={col} className="border-b p-3 text-left text-gray-700 font-semibold">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{columnLabels?.[col] || col}</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="p-1 border border-gray-300 rounded text-xs focus:ring-1 focus:ring-blue-400 outline-none"
|
||||
onChange={(e) =>
|
||||
setSearchTerms((prev) => ({ ...prev, [col]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={visibleColumns.length} className="text-center py-6 text-gray-400">
|
||||
No results found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredData.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="odd:bg-white even:bg-gray-50 hover:bg-blue-50 transition"
|
||||
>
|
||||
{visibleColumns.map((col) => (
|
||||
<td key={col} className="border-b p-3 text-gray-700">
|
||||
{String(row[col] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
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 { hubspotCompanyData } from "@/app/db/schema/crm/hubspot_company_table";
|
||||
import { eq } from "drizzle-orm";
|
||||
import LiveTracker from "./Report";
|
||||
|
||||
export default async function LiveReportingPage(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug: portfolioId } = await props.params;
|
||||
const user = await getServerSession(AuthOptions);
|
||||
|
||||
if (!user?.user) {
|
||||
console.error("User not found");
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
// 🏢 Fetch the company
|
||||
const [company] = await surveyDB
|
||||
.select()
|
||||
.from(hubspotCompanyData)
|
||||
.where(eq(hubspotCompanyData.groupId, portfolioId));
|
||||
|
||||
if (!company) {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#14163d] via-[#2d348f] to-[#3943b7] text-white">
|
||||
<div className="text-center bg-white/10 backdrop-blur-md text-gray-200 p-8 rounded-2xl shadow-2xl border border-white/10">
|
||||
No information to show.
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// 💼 Fetch deals for that company
|
||||
const deals = await surveyDB
|
||||
.select()
|
||||
.from(hubspotDealData)
|
||||
.where(eq(hubspotDealData.companyId, company.companyId));
|
||||
|
||||
if (!deals || deals.length === 0) {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#14163d] via-[#2d348f] to-[#3943b7] text-white">
|
||||
<div className="text-center bg-white/10 backdrop-blur-md text-gray-200 p-8 rounded-2xl shadow-2xl border border-white/10">
|
||||
No information to show.
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
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>
|
||||
|
||||
<LiveTracker deals={deals} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
"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(0)}{" "}
|
||||
<span className="text-sm text-gray-600 align-top">kgCO₂e</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)} 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">
|
||||
<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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ import { Toolbar } from "@/app/components/building-passport/Toolbar";
|
|||
import { getPropertyMeta, getDocument } from "./utils";
|
||||
import BackToPortfolioButton from "@/app/components/building-passport/BackToPortfolioButton";
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/24/outline";
|
||||
// import "@tremor/react/dist/esm/tremor.css";
|
||||
|
||||
|
||||
function EstimatedDataNotification() {
|
||||
return (
|
||||
|
|
@ -25,6 +27,7 @@ export default async function DashboardLayout(props: {
|
|||
|
||||
const propertyId = params.propertyId ?? "";
|
||||
const portfolioId = params.slug ?? "";
|
||||
|
||||
|
||||
// The layout is a server component by default so we can fetch meta data here
|
||||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
import RecommendationContainer from "@/app/components/building-passport/RecommendationContainer";
|
||||
import { getPropertyMeta, getRecommendations, getPlanMeta, getPlanFunding } from "../../utils";
|
||||
import {
|
||||
getPropertyMeta,
|
||||
getRecommendations,
|
||||
getPlanMeta,
|
||||
getPlanFunding,
|
||||
} from "../../utils";
|
||||
|
||||
export default async function Recommendations(
|
||||
props: {
|
||||
params: Promise<{ slug: string; propertyId: string; planId: string }>;
|
||||
}
|
||||
) {
|
||||
export default async function Recommendations(props: {
|
||||
params: Promise<{ slug: string; propertyId: string; planId: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
const recommendations = await getRecommendations(params.planId);
|
||||
const planMeta = await getPlanMeta(params.planId);
|
||||
const funding = await getPlanFunding(params.planId);
|
||||
|
||||
console.log(funding)
|
||||
console.log("funding", funding);
|
||||
|
||||
return (
|
||||
<div className="leading-loose tracking-wider">
|
||||
|
|
|
|||
|
|
@ -12,24 +12,34 @@ import { Input } from "@/app/shadcn_components/ui/input";
|
|||
import { Label } from "@/app/shadcn_components/ui/label";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { PropertyMeta } from "@/app/db/schema/property";
|
||||
import { cache } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
|
||||
interface BookSurveyModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
propertyId: bigint;
|
||||
portfolioId: string;
|
||||
address: string;
|
||||
propertyMeta: PropertyMeta;
|
||||
onSuccess?: () => void; // ✅ fix: properly declare optional callback
|
||||
}
|
||||
|
||||
|
||||
export default function BookSurveyModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
propertyId,
|
||||
portfolioId,
|
||||
address,
|
||||
propertyMeta,
|
||||
onSuccess, // ✅ fix: remove “?:” here, we already declared it optional in interface
|
||||
}: BookSurveyModalProps) {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
const user = session?.user;
|
||||
|
||||
|
||||
// 🧠 Simple mutation to call your HubSpot API
|
||||
const bookSurveyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
|
|
@ -37,11 +47,12 @@ export default function BookSurveyModal({
|
|||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dealName: address,
|
||||
pipelineId: "2400089278",
|
||||
dealStageId: "3288115388",
|
||||
dealStageId: "3660660975",
|
||||
propertyId: propertyId.toString(),
|
||||
portfolioId: portfolioId,
|
||||
userInfo: user,
|
||||
propertyMeta: propertyMeta,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -68,8 +79,10 @@ export default function BookSurveyModal({
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Booking a Survey</DialogTitle>
|
||||
<DialogHeader className="text-center">
|
||||
<DialogTitle className="text-center">
|
||||
Confirm and we’ll be in touch!
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
|
@ -79,14 +92,13 @@ export default function BookSurveyModal({
|
|||
className="w-full"
|
||||
disabled={bookSurveyMutation.isPending}
|
||||
>
|
||||
{bookSurveyMutation.isPending ? "Creating..." : "Submit"}
|
||||
{bookSurveyMutation.isPending ? "Creating..." : "Confirm"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,42 +24,48 @@ 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";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<PropertyWithRelations>[];
|
||||
data: PropertyWithRelations[];
|
||||
// Optional: Fuzzy global filter
|
||||
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
|
||||
const itemRank = rankItem(String(row.getValue(columnId) ?? ""), value);
|
||||
addMeta?.({ itemRank });
|
||||
return itemRank.passed;
|
||||
};
|
||||
|
||||
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>(
|
||||
[]
|
||||
);
|
||||
const [globalFilter, setGlobalFilter] = React.useState("");
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
|
|
@ -72,24 +78,36 @@ export default function DataTable<TData, TValue>({
|
|||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
globalFilterFn: fuzzyFilter,
|
||||
state: {
|
||||
sorting,
|
||||
pagination: { pageIndex: currentPageIndex, pageSize: 7 },
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center py-4">
|
||||
<Input
|
||||
placeholder="Filter address"
|
||||
value={(table.getColumn("address")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("address")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="flex items-center py-2">
|
||||
<div className="flex items-center py-2">
|
||||
<Input
|
||||
placeholder="Search address or postcode..."
|
||||
value={globalFilter ?? ""}
|
||||
onChange={(event) => setGlobalFilter(event.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
{globalFilter && (
|
||||
<button
|
||||
onClick={() => setGlobalFilter("")}
|
||||
className="ml-4 text-gray-500 hover:text-gray-800"
|
||||
title="Clear search"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
|
|
@ -98,7 +116,7 @@ export default function DataTable<TData, TValue>({
|
|||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} className="h-14 py-4">
|
||||
<TableHead key={header.id} className="h-14 py-2">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
|
|
|
|||
|
|
@ -17,10 +17,8 @@ import { FunnelIcon } from "@heroicons/react/24/outline";
|
|||
import { formatNumber, getEpcColorClass, sapToEpc } from "@/app/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PortfolioStatus } from "@/app/db/schema/portfolio";
|
||||
import {
|
||||
PropertyToRecommendation,
|
||||
PropertyWithRelations,
|
||||
} from "@/app/db/schema/property";
|
||||
import { PropertyWithRelations } from "@/app/db/schema/property";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
|
|
@ -44,38 +42,65 @@ export function DataTableFilterHeader<TData, TValue>({
|
|||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>;
|
||||
}
|
||||
options,
|
||||
renderOption,
|
||||
}: DataTableColumnHeaderProps<TData, TValue> & {
|
||||
options: string[];
|
||||
renderOption?: (opt: string) => React.ReactNode;
|
||||
}) {
|
||||
const currentValue = column.getFilterValue() as string | undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center space-x-2", className)}>
|
||||
<div className={cn("flex items-center space-x-1", className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant={currentValue ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="-ml-3 h-8 data-[state=open]:bg-accent"
|
||||
className={cn(
|
||||
"-ml-2 h-8",
|
||||
currentValue && "text-accent-foreground bg-accent"
|
||||
)}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<FunnelIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{[...PortfolioStatus, "ECO4", "GBIS"].map((status) => (
|
||||
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="max-h-80 overflow-y-auto min-w-[10rem]"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<DropdownMenuItem
|
||||
key={status}
|
||||
onClick={() => {
|
||||
console.log("status filter:", status);
|
||||
column.setFilterValue(status);
|
||||
}}
|
||||
key={opt}
|
||||
onClick={() =>
|
||||
column.setFilterValue(currentValue === opt ? undefined : opt)
|
||||
}
|
||||
className={cn(
|
||||
"cursor-pointer flex items-center gap-2 px-2 py-1.5",
|
||||
currentValue === opt && "bg-accent"
|
||||
)}
|
||||
>
|
||||
{<StatusBadge status={status} isProperty={false} />}
|
||||
{renderOption ? renderOption(opt) : opt}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{currentValue && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-6 w-6 text-gray-500 hover:text-gray-800 transition-opacity duration-150",
|
||||
currentValue ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
onClick={() => column.setFilterValue(undefined)}
|
||||
title="Clear filter"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -83,6 +108,7 @@ export function DataTableFilterHeader<TData, TValue>({
|
|||
export const columns: ColumnDef<PropertyWithRelations>[] = [
|
||||
{
|
||||
accessorKey: "address",
|
||||
enableGlobalFilter: true,
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
|
|
@ -96,7 +122,6 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
|
|||
},
|
||||
cell: ({ row }) => {
|
||||
const address = String(row.getValue("address"));
|
||||
const postcode = String(row.original.postcode);
|
||||
const propertyId = row.original.id;
|
||||
const portfolioId = row.original.portfolioId;
|
||||
|
||||
|
|
@ -110,39 +135,85 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
|
|||
>
|
||||
{address}
|
||||
</a>
|
||||
<a className="text-sm text-gray-500">{postcode}</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "postcode",
|
||||
enableGlobalFilter: true,
|
||||
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 font-medium flex justify-center">
|
||||
{row.original.postcode}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
// header: () => <div className="flex justify-center">Status</div>,
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<DataTableFilterHeader column={column} title="Status" />
|
||||
<DataTableFilterHeader
|
||||
column={column}
|
||||
title="Status"
|
||||
options={PortfolioStatus}
|
||||
renderOption={(status) => (
|
||||
<StatusBadge status={status} isProperty={false} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") ?? "";
|
||||
const plans = row.original.plans || [];
|
||||
// Check if any plan has an ECO4 or GBIS funding package
|
||||
const fundingScheme = plans.find((p) => {
|
||||
const scheme = p?.fundingPackage?.scheme;
|
||||
return scheme && ["ECO4", "GBIS"].includes(scheme.toUpperCase());
|
||||
})?.fundingPackage?.scheme;
|
||||
|
||||
const effectiveStatus = fundingScheme
|
||||
? fundingScheme.toUpperCase()
|
||||
: status;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
{effectiveStatus && (
|
||||
<StatusBadge status={String(effectiveStatus)} isProperty={true} />
|
||||
{status && <StatusBadge status={String(status)} isProperty={true} />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "fundingScheme",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<DataTableFilterHeader
|
||||
column={column}
|
||||
title="Funding Scheme"
|
||||
options={["ECO4", "GBIS", "NONE"]}
|
||||
renderOption={(status) => (
|
||||
// handle status being null or undefined
|
||||
<StatusBadge status={status ?? "none"} isProperty={false} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
// if the funding scheme is "none" we display nothing
|
||||
const fundingScheme = row.getValue("fundingScheme") || "";
|
||||
// Check if any plan has an ECO4 or GBIS funding package
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
{fundingScheme && fundingScheme !== "none" && (
|
||||
<StatusBadge
|
||||
status={String(fundingScheme).toUpperCase()}
|
||||
isProperty={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -163,27 +234,19 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
|
|||
accessorKey: "targetEpc",
|
||||
header: () => <div className="flex justify-center">Expected EPC</div>,
|
||||
cell: ({ row }) => {
|
||||
const recommendations = row.original.recommendations;
|
||||
const currentSapPoints = row.original.currentSapPoints || 0;
|
||||
|
||||
const currentSapPoints = row.original.currentSapPoints;
|
||||
const expectedSapPoints = row.original.totalRecommendationSapPoints || 0;
|
||||
|
||||
const expectedapPoints = recommendations.reduce(
|
||||
(acc: number, rec: PropertyToRecommendation) => {
|
||||
if (rec.sapPoints === null || rec.sapPoints === undefined) {
|
||||
return acc;
|
||||
}
|
||||
return acc + rec.sapPoints;
|
||||
},
|
||||
0
|
||||
);
|
||||
if (currentSapPoints === null || currentSapPoints === undefined) {
|
||||
// if currentSapPoints + expectedSapPoint is 0 expected EPC is ""
|
||||
if (currentSapPoints + expectedSapPoints === 0) {
|
||||
return (
|
||||
<div className="text-gray-700 font-medium flex justify-center">
|
||||
{""}
|
||||
{<EpcLetterBubble letter={""} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const expectedEpc = sapToEpc(currentSapPoints + expectedapPoints);
|
||||
const expectedEpc = sapToEpc(currentSapPoints + expectedSapPoints);
|
||||
|
||||
return (
|
||||
<div className="text-gray-700 font-medium flex justify-center">
|
||||
|
|
@ -196,17 +259,7 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
|
|||
accessorKey: "cost",
|
||||
header: () => <div className="flex justify-center">Cost</div>,
|
||||
cell: ({ row }) => {
|
||||
const recommendations = row.original.recommendations;
|
||||
|
||||
const cost = recommendations.reduce(
|
||||
(acc: number, rec: PropertyToRecommendation) => {
|
||||
if (rec.estimatedCost === null || rec.estimatedCost === undefined) {
|
||||
return acc;
|
||||
}
|
||||
return acc + rec.estimatedCost;
|
||||
},
|
||||
0
|
||||
);
|
||||
const cost = row.original.totalRecommendationCost;
|
||||
|
||||
const creationStatus = row.original.creationStatus;
|
||||
if (creationStatus === "LOADING") {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
scenario,
|
||||
ScenarioSelect,
|
||||
} from "@/app/db/schema/recommendations";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export interface PortfolioSettingsType {
|
||||
name: string;
|
||||
|
|
@ -418,77 +419,55 @@ export async function getProperties(
|
|||
offset: number = 0
|
||||
): Promise<PropertyWithRelations[]> {
|
||||
// We need to perform the query like this because the nested query is not supported in the ORM right now
|
||||
const data: PropertyWithRelations[] = await db.query.property.findMany({
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
columns: {
|
||||
id: true,
|
||||
portfolioId: true,
|
||||
address: true,
|
||||
postcode: true,
|
||||
status: true,
|
||||
creationStatus: true,
|
||||
currentEpcRating: true,
|
||||
currentSapPoints: true,
|
||||
},
|
||||
where: eq(property.portfolioId, BigInt(portfolioId)),
|
||||
with: {
|
||||
target: {
|
||||
columns: {
|
||||
epc: true,
|
||||
},
|
||||
},
|
||||
recommendations: {
|
||||
columns: {
|
||||
id: true,
|
||||
estimatedCost: true,
|
||||
sapPoints: true,
|
||||
},
|
||||
where: and(
|
||||
eq(recommendation.default, true),
|
||||
inArray(
|
||||
recommendation.id,
|
||||
db
|
||||
.select({
|
||||
recommendationId: planRecommendations.recommendationId,
|
||||
})
|
||||
.from(planRecommendations)
|
||||
.innerJoin(plan, eq(plan.id, planRecommendations.planId))
|
||||
.where(eq(plan.isDefault, true))
|
||||
)
|
||||
),
|
||||
},
|
||||
plans: {
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
where: eq(plan.isDefault, true),
|
||||
// Associate the funding information
|
||||
with: {
|
||||
fundingPackage: {
|
||||
columns: {
|
||||
scheme: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// override status to reflect ECO4/GBIS if present
|
||||
const updated = data.map((p) => {
|
||||
const fundingScheme = p.plans.find((pl) => {
|
||||
const scheme = pl?.fundingPackage?.scheme;
|
||||
return scheme && ["ECO4", "GBIS"].includes(scheme.toUpperCase());
|
||||
})?.fundingPackage?.scheme;
|
||||
const result =
|
||||
await db.execute<PropertyWithRelations>(sql<PropertyWithRelations>`
|
||||
SELECT
|
||||
p.id AS id,
|
||||
p.portfolio_id AS "portfolioId",
|
||||
p.address AS address,
|
||||
p.postcode AS postcode,
|
||||
p.status AS status,
|
||||
p.creation_status AS "creationStatus",
|
||||
p.current_epc_rating AS "currentEpcRating",
|
||||
p.current_sap_points AS "currentSapPoints",
|
||||
t.epc AS "targetEpc",
|
||||
pl.id AS "planId",
|
||||
fp.scheme AS "fundingScheme",
|
||||
COALESCE(SUM(r.sap_points), 0) AS "totalRecommendationSapPoints",
|
||||
COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost"
|
||||
FROM property p
|
||||
LEFT JOIN property_targets t
|
||||
ON t.property_id = p.id
|
||||
LEFT JOIN plan pl
|
||||
ON pl.property_id = p.id
|
||||
AND pl.is_default = true
|
||||
LEFT JOIN funding_package fp
|
||||
ON fp.plan_id = pl.id
|
||||
LEFT JOIN plan_recommendations pr
|
||||
ON pr.plan_id = pl.id
|
||||
LEFT JOIN recommendation r
|
||||
ON r.id = pr.recommendation_id
|
||||
AND r.default = true
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
GROUP BY
|
||||
p.id,
|
||||
p.portfolio_id,
|
||||
p.address,
|
||||
p.postcode,
|
||||
p.status,
|
||||
p.creation_status,
|
||||
p.current_epc_rating,
|
||||
p.current_sap_points,
|
||||
t.epc,
|
||||
pl.id,
|
||||
fp.scheme
|
||||
LIMIT ${limit} OFFSET ${offset};
|
||||
`);
|
||||
|
||||
return {
|
||||
...p,
|
||||
status: fundingScheme ? fundingScheme.toUpperCase() : p.status,
|
||||
};
|
||||
});
|
||||
const data: PropertyWithRelations[] = result.rows;
|
||||
|
||||
return updated;
|
||||
return data;
|
||||
}
|
||||
|
||||
interface UnaggregatedPortfolioPlanRecommendation {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import { Rating } from "./db/schema/property";
|
||||
import { KeyboardEvent} from "react";
|
||||
import { KeyboardEvent } from "react";
|
||||
|
||||
export function handleNumericKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
export function handleNumericKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
/**
|
||||
* Allowing: Integers | Backspace | Tab | Delete | Left & Right arrow keys
|
||||
**/
|
||||
|
||||
/**
|
||||
* Allowing: Integers | Backspace | Tab | Delete | Left & Right arrow keys
|
||||
**/
|
||||
|
||||
const regex = new RegExp(/(^\d*$)|(Backspace|Tab|Delete|ArrowLeft|ArrowRight|ArrowUp|ArrowDown)/);
|
||||
|
||||
return !event.key.match(regex) && event.preventDefault();
|
||||
}
|
||||
const regex = new RegExp(
|
||||
/(^\d*$)|(Backspace|Tab|Delete|ArrowLeft|ArrowRight|ArrowUp|ArrowDown)/
|
||||
);
|
||||
|
||||
return !event.key.match(regex) && event.preventDefault();
|
||||
}
|
||||
|
||||
export function convertDaysToWorkingWeeks(days: number | null) {
|
||||
if (days === null) {
|
||||
|
|
@ -92,7 +93,7 @@ export const serializeBigInt = (_: any, value: any): string | any => {
|
|||
};
|
||||
|
||||
export function sapToEpc(sapPoints: number): string {
|
||||
if (sapPoints <= 0) {
|
||||
if (sapPoints < 0) {
|
||||
throw new Error("SAP points should be above 0.");
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +152,6 @@ export function formatNumber(number: number): string {
|
|||
return formatted + suffixes[suffixIndex];
|
||||
}
|
||||
|
||||
|
||||
export function roundToDecimalPlaces(
|
||||
number: number,
|
||||
decimalPlaces: number
|
||||
|
|
@ -159,5 +159,3 @@ export function roundToDecimalPlaces(
|
|||
const factor = 10 ** decimalPlaces;
|
||||
return Math.round(number * factor) / factor;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
204
src/lib/chartUtils.ts
Normal file
204
src/lib/chartUtils.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -1,6 +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",
|
||||
];
|
||||
|
||||
// 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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ module.exports = {
|
|||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||
"gradient-conic":
|
||||
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
|
||||
},
|
||||
"domna-gradient":
|
||||
"linear-gradient(135deg, #14163d 0%, #2d348f 45%, #3943b7 70%, #eff6fc 100%)",
|
||||
},
|
||||
colors: {
|
||||
tremor: {
|
||||
brand: {
|
||||
|
|
@ -355,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 }) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue