diff --git a/.db-env b/.db-env new file mode 100644 index 00000000..d30a6431 --- /dev/null +++ b/.db-env @@ -0,0 +1,2 @@ +PGADMIN_DEFAULT_EMAIL=junte@domna.homes +PGADMIN_DEFAULT_PASSWORD=makingwarmhomes \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 9fdf7ebc..b6b65093 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -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 diff --git a/.devcontainer/post-install.sh b/.devcontainer/post-install.sh index 9f018f38..c847f6d5 100644 --- a/.devcontainer/post-install.sh +++ b/.devcontainer/post-install.sh @@ -1 +1 @@ -npm install; \ No newline at end of file +npm install; diff --git a/package-lock.json b/package-lock.json index 76492d3f..9812056a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 922bc3e2..f8d0fe24 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/book-survey/route.ts b/src/app/api/book-survey/route.ts index 3340ddf0..995db4f2 100644 --- a/src/app/api/book-survey/route.ts +++ b/src/app/api/book-survey/route.ts @@ -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); diff --git a/src/app/components/StatusBadge.tsx b/src/app/components/StatusBadge.tsx index e2bca7e2..c4eed14c 100644 --- a/src/app/components/StatusBadge.tsx +++ b/src/app/components/StatusBadge.tsx @@ -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", + }, }; diff --git a/src/app/components/building-passport/RecommendationContainer.tsx b/src/app/components/building-passport/RecommendationContainer.tsx index 37d09016..c383620d 100644 --- a/src/app/components/building-passport/RecommendationContainer.tsx +++ b/src/app/components/building-passport/RecommendationContainer.tsx @@ -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); + return acc; + }, + {} as Record + ); 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; diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index 20aa8443..e2d98430 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -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({ ); + + return ( <>
@@ -145,7 +152,6 @@ export function Toolbar({ {solarAnalysisButton} {recommendationsButton} {documentsButton} - setShowToast(true)} /> )} @@ -183,8 +189,8 @@ export function Toolbar({ 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. 🎉" /> ); diff --git a/src/app/components/portfolio/Toolbar.tsx b/src/app/components/portfolio/Toolbar.tsx index 5d57c6e6..a723b14b 100644 --- a/src/app/components/portfolio/Toolbar.tsx +++ b/src/app/components/portfolio/Toolbar.tsx @@ -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 ( - - - Portfolio - + {navItems.map(({ label, icon: Icon, href, match }) => { + const isActive = match(pathname); - - - Retrofit Summary - - - - - Decent Homes - - - {/* - - Measures - */} - - - - Settings - + return ( + + + + ); + })} + new Date()) + .notNull(), +}); + diff --git a/src/app/db/schema/crm/hubspot_deal_table.ts b/src/app/db/schema/crm/hubspot_deal_table.ts new file mode 100644 index 00000000..753a7370 --- /dev/null +++ b/src/app/db/schema/crm/hubspot_deal_table.ts @@ -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(), +}); + diff --git a/src/app/db/schema/property.ts b/src/app/db/schema/property.ts index 410fdfa2..cb68b2d2 100644 --- a/src/app/db/schema/property.ts +++ b/src/app/db/schema/property.ts @@ -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 { + 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< diff --git a/src/app/db/schema/recommendations.ts b/src/app/db/schema/recommendations.ts index 8b378357..507c5ab6 100644 --- a/src/app/db/schema/recommendations.ts +++ b/src/app/db/schema/recommendations.ts @@ -14,7 +14,6 @@ import { import { Material, material } from "./materials"; import { InferModel } from "drizzle-orm"; import { z } from "zod"; -import { readlink } from "fs"; export const recommendation = pgTable("recommendation", { id: bigserial("id", { mode: "bigint" }).primaryKey(), @@ -66,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", diff --git a/src/app/domna/financials.ts b/src/app/domna/financials.ts new file mode 100644 index 00000000..a1019629 --- /dev/null +++ b/src/app/domna/financials.ts @@ -0,0 +1,13 @@ +import type { PlanTypeEnum } from "@/app/db/schema/recommendations"; + +// Fixed Domna costs per delivery type +export const DOMNA_COST_MAP: Record & { + default: number; +} = { + solar_eco4: 2250, + solar_hhrsh_eco4: 2250, + empty_cavity_eco: 800, + partial_cavity_eco: 800, + extraction_eco: 800, + default: 800, +}; diff --git a/src/app/globals.css b/src/app/globals.css index 804366f3..3b98d12f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -76,7 +76,9 @@ } body { @apply bg-background text-foreground; - font-feature-settings: "rlig" 1, "calt" 1; + font-feature-settings: + "rlig" 1, + "calt" 1; } } diff --git a/src/app/portfolio/[slug]/(portfolio)/decent-homes/page.tsx b/src/app/portfolio/[slug]/(portfolio)/decent-homes/page.tsx index 0e37944e..cde7d797 100644 --- a/src/app/portfolio/[slug]/(portfolio)/decent-homes/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/decent-homes/page.tsx @@ -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( diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/TabLInk.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/TabLInk.tsx new file mode 100644 index 00000000..8476159d --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/TabLInk.tsx @@ -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 ( + + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/layout.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/layout.tsx new file mode 100644 index 00000000..7fb552fb --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/layout.tsx @@ -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 ( +
+
+ + Proposal + + + Live Reporting + +
+ +
{children}
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DealStageChart.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DealStageChart.tsx new file mode 100644 index 00000000..1a388d7d --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DealStageChart.tsx @@ -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 = { + "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 = {}; + 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 ( + +
+ + Project Progress by Stage + +

+ Click a bar to view related properties +

+
+ +
+ +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/Report.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/Report.tsx new file mode 100644 index 00000000..30b0fb13 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/Report.tsx @@ -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[]; +} + +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 + ); + + 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 ( + + +

No deal data available.

+
+
+ ); + } + + return ( +
+ {/* 🌍 Global Overview */} +
+ {/* Total Properties */} + handleOpenTable("All Properties", deals)} + accent="brandblue" + /> + + {/* Major Issues */} + + handleOpenTable("Major Condition Issues", majorConditionDeals) + } + accent="red" + /> + + {/* Project Selector */} + + + +

+ Select Project +

+
+
+ +
+ +
+ ▼ +
+
+
+
+
+ + {/* 📊 Project Insights */} + + + + Project-Level Insights — {currentProjectCode} + + + + + + + + + + + + + + + {/* 🔹 Table Modal */} + {openTable && ( +
+
+

+ {openTable.stage} — {openTable.data.length} Properties +

+ +
+ +
+ +
+ +
+
+
+ )} +
+ ); +} + +/** 🔸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 ( + +
+
+

{title}

+

+ {value} + {subtitle && ( + + {subtitle} + + )} +

+
+ +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyedResultsPieChart.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyedResultsPieChart.tsx new file mode 100644 index 00000000..d31bcd51 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyedResultsPieChart.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { DonutChart, Card, Title } from "@tremor/react"; +import { useMemo } from "react"; + +interface SurveyedPieChartProps { + deals: Record[]; + onOpenTable?: (outcome: string, filteredDeals: Record[]) => 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 = {}; + 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 ( + +
+ + Survey Outcomes + +

+ Click a segment to view filtered properties +

+ + `${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} + /> +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/TableViewer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/TableViewer.tsx new file mode 100644 index 00000000..c35a5865 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/TableViewer.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState, useMemo } from "react"; + +interface TableViewerProps { + data: Record[]; + columns?: string[]; + columnLabels?: Record; +} + +export default function TableViewer({ data, columns, columnLabels }: TableViewerProps) { + const [searchTerms, setSearchTerms] = useState>({}); + 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 ( +
+ + + + {visibleColumns.map((col) => ( + + ))} + + + + {filteredData.length === 0 ? ( + + + + ) : ( + filteredData.map((row, i) => ( + + {visibleColumns.map((col) => ( + + ))} + + )) + )} + +
+
+ {columnLabels?.[col] || col} + + setSearchTerms((prev) => ({ ...prev, [col]: e.target.value })) + } + /> +
+
+ No results found +
+ {String(row[col] ?? "")} +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx new file mode 100644 index 00000000..52b463a2 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -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 ( +
+
+ No information to show. +
+
+ ); + } + + // 💼 Fetch deals for that company + const deals = await surveyDB + .select() + .from(hubspotDealData) + .where(eq(hubspotDealData.companyId, company.companyId)); + + if (!deals || deals.length === 0) { + return ( +
+
+ No information to show. +
+
+ ); + } + + return ( +
+
+
+ Live Projects +
+

+ {`Check in on your projects' progress with real-time data updates.`} +

+
+
+ + +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProjectProposal.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProjectProposal.tsx new file mode 100644 index 00000000..39945347 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProjectProposal.tsx @@ -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 = { + 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(null); + + const grouped = useMemo(() => { + const map: Record = {}; + for (const plan of plans) { + if (!plan.planType) continue; + if (!map[plan.planType]) map[plan.planType] = []; + map[plan.planType].push(plan); + } + + 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 ( +
+ {/* Chart */} + + + + Homes by Work Type + + + + {grouped.length > 1 ? ( + v.toString()} + onValueChange={(v) => + setSelectedType( + v && typeof v === "object" && "planType" in v + ? String((v as any).planType) + : null + ) + } + className="h-64" + /> + ) : ( + `${v} home${v === 1 ? "" : "s"}`} + /> + )} + + + + {/* Metrics */} + + + + {mappedTitles[selectedType || "default"]} + + + +
+

Total investment

+

+ £{formatNumber(selectedData?.totalClientContribution || 0)} +

+

+ Avg per home £ + {formatNumber(selectedData?.avgClientContribution || 0)} +

+
+ +
+
+

Funding

+

+ £{formatNumber(selectedData?.totalFunding || 0)} +

+
+
+

Carbon

+

+ {((selectedData?.totalCarbon || 0) * 1000).toFixed(0)}{" "} + kgCO₂e +

+
+
+

Bills

+

+ £{formatNumber(selectedData?.totalBills || 0)} +

+
+
+
+
+
+ ); +} + +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 + }, + { + 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 ( +
+ {cards.map((c) => { + const Icon = c.icon; + return ( + + +
+ + + +
+ + {c.title} + +
+ + +
+ {c.value} +
+

{c.subtitle}

+
+
+ ); + })} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProposalColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProposalColumns.tsx new file mode 100644 index 00000000..94124418 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/ProposalColumns.tsx @@ -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 ( +
+ {letter} +
+ ); +}; + +export const planColumns: ColumnDef[] = [ + { + accessorKey: "landlordPropertyId", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.landlordPropertyId || "—"} +
+ ), + }, + { + accessorKey: "address", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.address || "—"} +
+ ), + }, + { + accessorKey: "postcode", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.postcode || "—"} +
+ ), + }, + + { + accessorKey: "fundingScheme", + header: () =>
Funding Scheme
, + cell: ({ row }) => ( +
+ {row.original.fundingScheme ? ( + + ) : ( + None + )} +
+ ), + }, + { + accessorKey: "planType", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {String(row.original.planType).replaceAll("_", " ")} +
+ ), + }, + { + accessorKey: "totalFunding", + header: () =>
Total Funding
, + cell: ({ row }) => ( +
+ + £{formatNumber(row.original.totalFunding || 0)} + +
+ ), + }, + { + accessorKey: "totalCarbonSavings", + header: () =>
Carbon Savings
, + cell: ({ row }) => ( +
+ + {((row.original.totalCarbonSavings || 0) * 1000).toFixed(2)} kgCO₂e + +
+ ), + }, + { + accessorKey: "totalBillSavings", + header: () =>
Bill Savings
, + cell: ({ row }) => ( +
+ + £{formatNumber(row.original.totalBillSavings || 0)} + +
+ ), + }, + { + accessorKey: "clientContribution", + header: () =>
Investment
, + cell: ({ row }) => ( +
+ + £{formatNumber(row.original.clientContribution || 0)} + +
+ ), + sortingFn: "alphanumeric", + }, + { + accessorKey: "currentEpc", + header: () =>
Current EPC Rating
, + cell: ({ row }) => { + return ( +
+ {} +
+ ); + }, + }, + { + accessorKey: "targetEpc", + header: () =>
Expected EPC
, + cell: ({ row }) => { + const currentSapPoints = row.original.currentSapPoints || 0; + + const expectedSapPoints = row.original.totalRecommendationSapPoints || 0; + + const expectedEpc = sapToEpc(currentSapPoints + expectedSapPoints); + + return ( +
+ {} +
+ ); + }, + }, +]; diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/page.tsx new file mode 100644 index 00000000..f0ee8686 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/page.tsx @@ -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 ( +
+
+
+ Project Overview +
+

+ Summary of funding, carbon savings, and household metrics. +

+
+
+ + + + +
+

+ Your Homes +

+ +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/utils.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/utils.ts new file mode 100644 index 00000000..db310d04 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/proposal/utils.ts @@ -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 { + 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 { + const result = await db.execute(sql` + SELECT + pl.id AS "planId", + pl.plan_type AS "planType", + pl.name AS "planName", + pl.created_at AS "createdAt", + pl.property_id AS "propertyId", + p.landlord_property_id AS "landlordPropertyId", + p.address AS "address", + p.postcode AS "postcode", + 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; +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx index 11358e3b..ba669f7c 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx @@ -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); diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx index 3c270be5..cf98d396 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx @@ -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 (
diff --git a/src/app/portfolio/[slug]/components/BookSurveyModal.tsx b/src/app/portfolio/[slug]/components/BookSurveyModal.tsx index 39f6651f..2e727977 100644 --- a/src/app/portfolio/[slug]/components/BookSurveyModal.tsx +++ b/src/app/portfolio/[slug]/components/BookSurveyModal.tsx @@ -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 ( - - Confirm Booking a Survey + + + Confirm and we’ll be in touch! +
@@ -79,14 +92,13 @@ export default function BookSurveyModal({ className="w-full" disabled={bookSurveyMutation.isPending} > - {bookSurveyMutation.isPending ? "Creating..." : "Submit"} + {bookSurveyMutation.isPending ? "Creating..." : "Confirm"}
- - ); + ); } diff --git a/src/app/portfolio/[slug]/components/propertyTable.tsx b/src/app/portfolio/[slug]/components/propertyTable.tsx index bc13b0d3..c15ff005 100644 --- a/src/app/portfolio/[slug]/components/propertyTable.tsx +++ b/src/app/portfolio/[slug]/components/propertyTable.tsx @@ -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 { - columns: ColumnDef[]; - data: PropertyWithRelations[]; +// Optional: Fuzzy global filter +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + const itemRank = rankItem(String(row.getValue(columnId) ?? ""), value); + addMeta?.({ itemRank }); + return itemRank.passed; +}; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; } -function fetchData(offset: number) { - // Because this is a client component, this will be handled with react query - let properties: PropertyWithRelations[] = []; - // TODO: implement this - return properties; +function fetchData(offset: number): TData[] { + // placeholder function for fetching + const data: TData[] = []; + return data; } -export default function DataTable({ +export default function DataTable>({ data, columns, -}: DataTableProps) { +}: DataTableProps) { const [sorting, setSorting] = useState([]); - const [tableData, setTableData] = useState(data); + const [tableData, setTableData] = useState(() => [...data]); const [offset, setOffset] = useState(0); const [currentPageIndex, setCurrentPageIndex] = useState(0); const [columnFilters, setColumnFilters] = React.useState( [] ); + const [globalFilter, setGlobalFilter] = React.useState(""); // add page change handlers for DataTablePagination const loadPaginatedData = () => { - const newData = fetchData(offset); + const newData = fetchData(offset); if (newData) { - console.log("loadPaginatedData"); setTableData([...tableData, ...newData]); setOffset(offset + 1); return true; } - return false; }; @@ -72,24 +78,36 @@ export default function DataTable({ getPaginationRowModel: getPaginationRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: fuzzyFilter, state: { sorting, pagination: { pageIndex: currentPageIndex, pageSize: 7 }, columnFilters, + globalFilter, }, }); return ( <> -
- - table.getColumn("address")?.setFilterValue(event.target.value) - } - className="max-w-sm" - /> +
+
+ setGlobalFilter(event.target.value)} + className="w-64" + /> + {globalFilter && ( + + )} +
@@ -98,7 +116,7 @@ export default function DataTable({ {headerGroup.headers.map((header) => { return ( - + {header.isPlaceholder ? null : flexRender( diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index 654c5504..3e274a30 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -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 extends React.HTMLAttributes { @@ -44,38 +42,65 @@ export function DataTableFilterHeader({ column, title, className, -}: DataTableColumnHeaderProps) { - if (!column.getCanSort()) { - return
{title}
; - } + options, + renderOption, +}: DataTableColumnHeaderProps & { + options: string[]; + renderOption?: (opt: string) => React.ReactNode; +}) { + const currentValue = column.getFilterValue() as string | undefined; return ( -
+
- - {[...PortfolioStatus, "ECO4", "GBIS"].map((status) => ( + + + {options.map((opt) => ( { - 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" + )} > - {} + {renderOption ? renderOption(opt) : opt} ))} + + {currentValue && ( + + )}
); } @@ -83,6 +108,7 @@ export function DataTableFilterHeader({ export const columns: ColumnDef[] = [ { accessorKey: "address", + enableGlobalFilter: true, header: ({ column }) => { return (
); }, }, + { + accessorKey: "postcode", + enableGlobalFilter: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.postcode} +
+ ), + }, { accessorKey: "status", // header: () =>
Status
, header: ({ column }) => { return (
- + ( + + )} + />
); }, 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 (
- {effectiveStatus && ( - + {status && } +
+ ); + }, + }, + { + accessorKey: "fundingScheme", + header: ({ column }) => { + return ( +
+ ( + // handle status being null or undefined + + )} + /> +
+ ); + }, + 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 ( +
+ {fundingScheme && fundingScheme !== "none" && ( + )}
); @@ -163,27 +234,19 @@ export const columns: ColumnDef[] = [ accessorKey: "targetEpc", header: () =>
Expected EPC
, 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 (
- {""} + {}
); } - const expectedEpc = sapToEpc(currentSapPoints + expectedapPoints); + const expectedEpc = sapToEpc(currentSapPoints + expectedSapPoints); return (
@@ -196,17 +259,7 @@ export const columns: ColumnDef[] = [ accessorKey: "cost", header: () =>
Cost
, 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") { diff --git a/src/app/portfolio/[slug]/remote-assessment/page.tsx b/src/app/portfolio/[slug]/remote-assessment/page.tsx index 0491ad6e..01d75c44 100644 --- a/src/app/portfolio/[slug]/remote-assessment/page.tsx +++ b/src/app/portfolio/[slug]/remote-assessment/page.tsx @@ -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 ( diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index f474e0b6..98507393 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -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 { // 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(sql` + 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 { diff --git a/src/app/utils.ts b/src/app/utils.ts index b2f37050..d3c58906 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -1,16 +1,17 @@ import { Rating } from "./db/schema/property"; -import { KeyboardEvent} from "react"; +import { KeyboardEvent } from "react"; - export function handleNumericKeyDown(event: KeyboardEvent) { +export function handleNumericKeyDown(event: KeyboardEvent) { + /** + * 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; } - - diff --git a/src/lib/chartUtils.ts b/src/lib/chartUtils.ts new file mode 100644 index 00000000..87bae050 --- /dev/null +++ b/src/lib/chartUtils.ts @@ -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; + +export const constructCategoryColors = ( + categories: string[], + colors: AvailableChartColorsKeys[] +): Map => { + const categoryColors = new Map(); + 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; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 6c7dcee6..9cf48c6e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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", +]; diff --git a/tailwind.config.js b/tailwind.config.js index 30f16286..f9cbfaa6 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -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 }) {