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/package-lock.json b/package-lock.json index 76492d3f..cc71fb58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "@remixicon/react": "^4.2.0", "@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 +64,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 922bc3e2..fcb44cc9 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@remixicon/react": "^4.2.0", "@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 +70,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/db/schema/crm/hubspot_company_table.ts b/src/app/db/schema/crm/hubspot_company_table.ts new file mode 100644 index 00000000..71aa4193 --- /dev/null +++ b/src/app/db/schema/crm/hubspot_company_table.ts @@ -0,0 +1,18 @@ +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(), +}); 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/portfolio/[slug]/(portfolio)/reports/DealStageChart.tsx b/src/app/portfolio/[slug]/(portfolio)/reports/DealStageChart.tsx new file mode 100644 index 00000000..4a6bd739 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reports/DealStageChart.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { BarList, Card, Title } from "@tremor/react"; +import TableViewer from "./TableViewer"; + +const STAGE_ORDER = [ + "Initial Planning", // 0 + "Booking Team to contact Tenant", // 1 + "Survey in Progress", // 2 + "Not viable", // 3 + "Needs HA Support", // 4 + "Coordination + Design", // 5 + "Ready to be installed" //7 +]; + +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": "[Ops] Surveyed under 2019 - Needs Re-survey", + "1617223914": STAGE_ORDER[5], + "1887736000": "[Deprecated, please don't use] Files Missing From Assessor", + "1617223916": "[Ops] Properties to Review Manually", + "2628341989": STAGE_ORDER[2], + "3441170637": STAGE_ORDER[2], // check if assessor or coordination + "2628233422": STAGE_ORDER[5], + "1887735999": STAGE_ORDER[4], + "1960060104": "[Ops] HA Informed", + "1960060105": "[Ops] HA Works Scheduled", + "1960060106": "[Ops] HA Works Complete", + "1668803772": "[Ops] ERF Delivered to HA", + "1668803773": "[Ops] ERF Signed", + "2769407183": "[Ops] PV - Needs Heating Upgrade (Pre EPR D)", + "2769407184": "[Ops] Talk to client, Needs Heating Upgrade (Pre EPR C)", + "2702650617": STAGE_ORDER[5], + "2473886962": STAGE_ORDER[5], + "3016601828": STAGE_ORDER[4], + "3389868276": "[Engagement Team] Blocked - Needs Completion of Pilot", + "3389880508": "[Engagement Team] Blocked - Installer Negotiation", + "3399016689": "[Engagement Team] Eligible but blocked - part of incomplete flat", + "1668803774": STAGE_ORDER[6], // Ready for Invoicing + "3440363736": STAGE_ORDER[6], // [Finance] Needs Invoicing - Files Sent + "1618526429": "[Ops] Invoiced - Send Files to Installer", + "3080225005": "[Ops] Files Sent to Installer", + "1961258215": "[Ops] Installer Cancelled - Finalized", + "1961258214": "[Ops] Installer Cancelled - In Progress", + "1961258213": "[Ops] Install Scheduled", + "1617223918": "[Ops] Install Complete", + "1961258216": "[Compliance] Lodgement Complete", + "1961258217": "[Compliance] Documentation Sent to HA", + "3027432668": "[Team ???] Submitted to " +} + +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]); + + // handle click event + 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 + + + ); +} \ No newline at end of file diff --git a/src/app/portfolio/[slug]/(portfolio)/reports/Report.tsx b/src/app/portfolio/[slug]/(portfolio)/reports/Report.tsx new file mode 100644 index 00000000..b2726c11 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reports/Report.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import { DealStageChart } from "./DealStageChart"; +import SurveyedPieChart from "./SurveyedResultsPieChart"; +import TableViewer from "./TableViewer"; + +interface ReportsProps { + deals: Record[]; +} + +export default function Reports({ deals }: ReportsProps) { + const [openTable, setOpenTable] = useState<{ + stage: string; + data: any[]; + } | null>(null); + + const handleOpenTable = (stage: string, filteredDeals: any[]) => { + setOpenTable({ stage, data: filteredDeals }); + }; + + if (!deals || deals.length === 0) { + return ( +
+ No deal data available. +
+ ); + } + + // Group deals by projectCode + const groupedDeals = deals.reduce((acc, deal) => { + const project = deal.projectCode || "Unknown Project"; + if (!acc[project]) acc[project] = []; + acc[project].push(deal); + return acc; + }, {} as Record); + + const projectCodes = Object.keys(groupedDeals); + const [currentProjectCode, setCurrentProjectCode] = useState(projectCodes[0]); + const currentDeals = groupedDeals[currentProjectCode]; + + return ( +
+{/* 🔹 Centered Dropdown Selector for Projects */} +
+

+ Select Project +

+ +
+ + + {/* Custom dropdown arrow */} +
+ â–¼ +
+
+
+ + {/* Charts */} +
+
+ +
+
+ +
+
+ +
+ Showing project {currentProjectCode} +
+ + {/* 🔹 Modal Table */} + {openTable && ( +
+
+

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

+ +
+ +
+ +
+ +
+
+
+ )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reports/SurveyedResultsPieChart.tsx b/src/app/portfolio/[slug]/(portfolio)/reports/SurveyedResultsPieChart.tsx new file mode 100644 index 00000000..7cb0d3c0 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reports/SurveyedResultsPieChart.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { DonutChart, Card, Title } from "@tremor/react"; +import { useMemo, useState } from "react"; + +interface SurveyedPieChartProps { + deals: Record[]; + onOpenTable?: (outcome: string, filteredDeals: Record[]) => void; +} + +export default function SurveyedPieChart({ + deals, + onOpenTable, +}: SurveyedPieChartProps) { + const [selected, setSelected] = useState(null); + + 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; // guard clause + const filteredDeals = deals.filter((d) => d.outcome === value.name); + setSelected(null); // remove highlight after click + onOpenTable?.(value.name, filteredDeals); + }; + + return ( + +
+ + Surveyed Outcome + + + `${n.toLocaleString()}`} + colors={[ + "indigo", + "cyan", + "emerald", + "amber", + "rose", + "violet", + "gray", + ]} + className="w-64 h-64 cursor-pointer" + onValueChange={handleClick} + /> +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reports/TableViewer.tsx b/src/app/portfolio/[slug]/(portfolio)/reports/TableViewer.tsx new file mode 100644 index 00000000..04883320 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reports/TableViewer.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState, useMemo } from "react"; + +interface TableViewerProps { + data: Record[]; + columns?: string[]; // optional: which columns to show + columnLabels?: Record; // 👈 map data keys to display names +} + +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)/reports/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reports/page.tsx new file mode 100644 index 00000000..30dacdf5 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reports/page.tsx @@ -0,0 +1,34 @@ +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 { eq } from "drizzle-orm"; +import Reports from "./Report"; + +const Demo = async () => { + const user = await getServerSession(AuthOptions); + + if (!user?.user) { + console.error("User not found"); + redirect("/"); + } + + // Abri company id + const companyId = "237615001799"; + + const deals = await surveyDB + .select() + .from(hubspotDealData) + .where(eq(hubspotDealData.companyId, companyId)); + console.log(deals); + + return ( + <> +

hello reports

+ {/* */} + + ); +}; + +export default Demo; \ No newline at end of file diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx index 11358e3b..2c470411 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/lib/utils.ts b/src/lib/utils.ts index 6c7dcee6..dc8e111b 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