diff --git a/.db-env b/.db-env new file mode 100644 index 0000000..d30a643 --- /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 9fdf7eb..b6b6509 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 9f018f3..c847f6d 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 a207d46..9812056 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "@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", @@ -65,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", diff --git a/package.json b/package.json index 4bbcb1e..f8d0fe2 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@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", @@ -71,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 3340ddf..7f02909 100644 --- a/src/app/api/book-survey/route.ts +++ b/src/app/api/book-survey/route.ts @@ -9,6 +9,7 @@ export async function POST(req: Request) { const { dealName, pipelineId, dealStageId, propertyId, portfolioId } = await req.json(); + // 1️⃣ Create HubSpot deal const hsRes = await fetch("https://api.hubapi.com/crm/v3/objects/deals", { method: "POST", diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index 20aa844..48f7d2e 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -124,6 +124,8 @@ export function Toolbar({ ); + + return ( <>
@@ -145,7 +147,6 @@ export function Toolbar({ {solarAnalysisButton} {recommendationsButton} {documentsButton} - 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 5d57c6e..7fcae07 100644 --- a/src/app/components/portfolio/Toolbar.tsx +++ b/src/app/components/portfolio/Toolbar.tsx @@ -49,6 +49,10 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) { router.push(`/portfolio/${portfolioId}/decent-homes`); } + function handleClickProgressReport() { + router.push(`/portfolio/${portfolioId}/live-projects`); + } + const [modalIsOpen, setModalIsOpen] = useState(false); const [isRemoteAssessmentOpen, setIsRemoteAssessmentOpen] = useState(false); @@ -86,7 +90,13 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) { Measures */} - + + + Live Projects + 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 0000000..753a737 --- /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/portfolio/[slug]/(portfolio)/live-projects/DealStageChart.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/DealStageChart.tsx new file mode 100644 index 0000000..4aec8d7 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/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 HA 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)/live-projects/Report.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx new file mode 100644 index 0000000..4277eaa --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/Report.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState } from "react"; +import { DealStageChart } from "./DealStageChart"; +import SurveyedPieChart from "./SurveyedResultsPieChart"; +import TableViewer from "./TableViewer"; + +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 Portfolio Overview */} +
+

+ 🌍 Global Portfolio Overview +

+ +
+ {/* Total */} + + + {/* Major Issues */} + + + {/* Project Selector */} +
+ +
+ +
+
+
+
+
+ + {/* 📊 Project Insights */} +
+

+ 📊 Project-Level Insights +

+

+ Showing data for {currentProjectCode} +

+ +
+
+ +
+
+ +
+
+
+ + {/* 🔹 Modal */} + {openTable && ( +
+
+

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

+ +
+ +
+ +
+ +
+
+
+ )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/live-projects/SurveyedResultsPieChart.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/SurveyedResultsPieChart.tsx new file mode 100644 index 0000000..4a3e168 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/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={[ + "sky", + "cyan", + "blue", + "indigo", + "violet", + "slate", + "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)/live-projects/TableViewer.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/TableViewer.tsx new file mode 100644 index 0000000..c35a586 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/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)/live-projects/page.tsx b/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx new file mode 100644 index 0000000..d5e4e7c --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/live-projects/page.tsx @@ -0,0 +1,73 @@ +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 Demo(props: { + params: Promise<{ slug: string }>; +}) { + const user = await getServerSession(AuthOptions); + + if (!user?.user) { + console.error("User not found"); + redirect("/"); + } + + const { slug: portfolioId } = await props.params; + + // 🏢 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 ( +
+ {/* 🌊 Domna-inspired layered background */} + {/*
*/} + + {/* ✨ Subtle translucent grid texture */} +
+ + {/* 💡 Optional soft light glow at top */} +
+ + {/* Main content */} +
+
+ +
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx index 11358e3..2c47041 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 ( diff --git a/src/app/portfolio/[slug]/components/BookSurveyModal.tsx b/src/app/portfolio/[slug]/components/BookSurveyModal.tsx index 39f6651..a3a7b52 100644 --- a/src/app/portfolio/[slug]/components/BookSurveyModal.tsx +++ b/src/app/portfolio/[slug]/components/BookSurveyModal.tsx @@ -39,7 +39,7 @@ export default function BookSurveyModal({ body: JSON.stringify({ dealName: address, pipelineId: "2400089278", - dealStageId: "3288115388", + dealStageId: "3660660975", propertyId: propertyId.toString(), portfolioId: portfolioId, }), @@ -68,8 +68,10 @@ export default function BookSurveyModal({ return ( - - Confirm Booking a Survey + + + Confirm and we’ll be in touch! +
@@ -79,14 +81,13 @@ export default function BookSurveyModal({ className="w-full" disabled={bookSurveyMutation.isPending} > - {bookSurveyMutation.isPending ? "Creating..." : "Submit"} + {bookSurveyMutation.isPending ? "Creating..." : "Confirm"}
- - ); + ); } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 6c7dcee..dc8e111 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,14 @@ import { twMerge } from "tailwind-merge" export function cn (...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export const focusRing = [ + // base + "outline outline-offset-2 outline-0 focus-visible:outline-2", + // outline color + "outline-blue-500 dark:outline-blue-500", +] + +export function cx(...args: ClassValue[]) { + return twMerge(clsx(...args)) +} \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index 30f1628..fdc4b23 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: {