Merge pull request #111 from Hestia-Homes/feature/try_different_styles

Feature/try different styles
This commit is contained in:
KhalimCK 2025-11-03 12:38:16 +00:00 committed by GitHub
commit d62fd23e74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 596 additions and 17 deletions

2
.db-env Normal file
View file

@ -0,0 +1,2 @@
PGADMIN_DEFAULT_EMAIL=junte@domna.homes
PGADMIN_DEFAULT_PASSWORD=makingwarmhomes

View file

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

View file

@ -1 +1 @@
npm install;
npm install;

4
package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -124,6 +124,8 @@ export function Toolbar({
</NavigationMenuLink>
);
return (
<>
<div className="flex items-center justify-between w-full">
@ -145,7 +147,6 @@ export function Toolbar({
{solarAnalysisButton}
{recommendationsButton}
{documentsButton}
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickSettings}
@ -183,8 +184,8 @@ export function Toolbar({
<BookingSuccessToast
show={showToast}
onClose={() => setShowToast(false)}
message="Survey Booked Successfully!"
subtext="Your Survey Request is with Domna and we will be in contact. 🎉"
message="Survey Request Recieved!"
subtext="We'll be in contact soon. 🎉"
/>
</>
);

View file

@ -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) {
<WrenchScrewdriverIcon className="h-4 w-4 mr-2" />
Measures
</NavigationMenuItem> */}
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickProgressReport}
>
<ChartBarIcon className="h-4 w-4 mr-2" />
Live Projects
</NavigationMenuItem>
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickSettings}

View file

@ -0,0 +1,19 @@
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
export const hubspotCompanyData = pgTable("hubspot_company_data", {
id: uuid("id").defaultRandom().primaryKey(),
companyId: text("company_id").notNull(),
companyName: text("company_name"),
groupId: text("group_id"),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});

View file

@ -0,0 +1,27 @@
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
export const hubspotDealData = pgTable("hubspot_deal_data", {
id: uuid("id").defaultRandom().primaryKey(),
dealId: text("deal_id").notNull(),
dealname: text("dealname"),
dealstage: text("dealstage"),
companyId: text("company_id"),
projectCode: text("project_code"),
landlordPropertyId: text("landlord_property_id"),
uprn: text("uprn"),
outcome: text("outcome"),
outcomeNotes: text("outcome_notes"),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});

View file

@ -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<string, string> = {
"1617223910": STAGE_ORDER[0],
"3583836399": STAGE_ORDER[0],
"3589581001": STAGE_ORDER[1],
"3569878239": STAGE_ORDER[1],
"1617223911": STAGE_ORDER[1],
"1984184569": STAGE_ORDER[1],
"3569572028": STAGE_ORDER[1],
"3570936026": STAGE_ORDER[1],
"2663668937": STAGE_ORDER[1],
"1984401629": STAGE_ORDER[1],
"1617223912": STAGE_ORDER[2],
"1617223913": STAGE_ORDER[2],
"2558220518": STAGE_ORDER[1],
"3474594026": STAGE_ORDER[1],
"3206388924": STAGE_ORDER[2],
"1617223915": STAGE_ORDER[2],
"1617223917": STAGE_ORDER[2],
"1887735998": STAGE_ORDER[3],
"3061261536": STAGE_ORDER[4],
"2571417798": STAGE_ORDER[2],
"1617223914": STAGE_ORDER[5],
"1887736000": STAGE_ORDER[2],
"1617223916": STAGE_ORDER[2],
"2628341989": STAGE_ORDER[2],
"3441170637": STAGE_ORDER[2],
"2628233422": STAGE_ORDER[5],
"1887735999": STAGE_ORDER[4],
"2702650617": STAGE_ORDER[5],
"2473886962": STAGE_ORDER[5],
"3016601828": STAGE_ORDER[4],
"1668803774": STAGE_ORDER[6],
"3440363736": STAGE_ORDER[6],
};
interface DealStageChartProps {
deals: any[];
onOpenTable?: (stageName: string, filteredDeals: any[]) => void;
}
export function DealStageChart({ deals, onOpenTable }: DealStageChartProps) {
const data = useMemo(() => {
const counts: Record<string, number> = {};
deals.forEach((d) => {
const stageId = d.dealstage || "unknown";
const stageName = STAGE_LABELS[stageId] || "Unknown Stage";
counts[stageName] = (counts[stageName] || 0) + 1;
});
const unsorted = Object.entries(counts).map(([name, value]) => ({
name,
value,
}));
return unsorted.sort((a, b) => {
const aIndex = STAGE_ORDER.indexOf(a.name);
const bIndex = STAGE_ORDER.indexOf(b.name);
return (
(aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) -
(bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex)
);
});
}, [deals]);
const handleBarClick = (value: { name: string; value: number }) => {
const filtered = deals.filter((d) => {
const stageId = d.dealstage || "unknown";
const stageName = STAGE_LABELS[stageId] || "Unknown Stage";
return stageName === value.name;
});
onOpenTable?.(value.name, filtered);
};
return (
<Card className="max-w-lg mx-auto bg-white rounded-2xl shadow-md hover:shadow-lg transition-all duration-200 p-6">
<div className="flex flex-col items-center mb-4">
<Title className="text-gray-800 text-lg font-semibold tracking-tight text-center">
Project Progress by Stage
</Title>
<p className="text-sm text-gray-500 text-center mt-1">
Click a bar to view related properties
</p>
</div>
<div className="w-full">
<BarList
data={data}
color="blue"
sortOrder="none"
className="cursor-pointer"
onValueChange={handleBarClick}
/>
</div>
</Card>
);
}

View file

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

View file

@ -0,0 +1,79 @@
"use client";
import { DonutChart, Card, Title } from "@tremor/react";
import { useMemo } from "react";
interface SurveyedPieChartProps {
deals: Record<string, any>[];
onOpenTable?: (outcome: string, filteredDeals: Record<string, any>[]) => void;
}
export default function SurveyedPieChart({
deals,
onOpenTable,
}: SurveyedPieChartProps) {
const surveyorOutcomes = [
"Surveyed",
"Surveyed - Pending Upload",
"Tenant Refusal",
"Other",
"Not Viable",
"Not Attempted",
"No Answer",
"Cancelled / No Show",
"Rescheduled",
];
const data = useMemo(() => {
const outcomeCounts: Record<string, number> = {};
deals.forEach((deal) => {
const outcome = deal.outcome;
if (outcome && surveyorOutcomes.includes(outcome)) {
outcomeCounts[outcome] = (outcomeCounts[outcome] || 0) + 1;
}
});
return Object.entries(outcomeCounts).map(([name, amount]) => ({
name,
amount,
}));
}, [deals]);
const handleClick = (value: { name: string; amount: number }) => {
if (!value) return;
const filteredDeals = deals.filter((d) => d.outcome === value.name);
onOpenTable?.(value.name, filteredDeals);
};
return (
<Card className="max-w-lg mx-auto bg-white rounded-2xl shadow-md hover:shadow-lg transition-all duration-200 p-6">
<div className="flex flex-col items-center space-y-4">
<Title className="text-gray-800 text-lg font-semibold tracking-tight text-center">
Survey Outcomes
</Title>
<p className="text-sm text-gray-500 text-center -mt-2">
Click a segment to view filtered properties
</p>
<DonutChart
data={data}
category="amount"
index="name"
valueFormatter={(n) => `${n.toLocaleString()}`}
colors={[
"sky",
"cyan",
"blue",
"indigo",
"violet",
"slate",
"lightBlue",
"navy",
"azure",
]}
className="w-64 h-64 cursor-pointer transition-transform hover:scale-[1.03]"
onValueChange={handleClick}
/>
</div>
</Card>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import { useState, useMemo } from "react";
interface TableViewerProps {
data: Record<string, any>[];
columns?: string[];
columnLabels?: Record<string, string>;
}
export default function TableViewer({ data, columns, columnLabels }: TableViewerProps) {
const [searchTerms, setSearchTerms] = useState<Record<string, string>>({});
const visibleColumns = columns?.length ? columns : Object.keys(data?.[0] || {});
const filteredData = useMemo(() => {
return data.filter((row) =>
visibleColumns.every((col) => {
const term = searchTerms[col]?.toLowerCase() || "";
if (!term) return true;
const value = String(row[col] ?? "").toLowerCase();
return value.includes(term);
})
);
}, [data, searchTerms, visibleColumns]);
return (
<div className="overflow-x-auto border rounded-xl shadow-lg bg-white">
<table className="min-w-full text-sm border-collapse">
<thead className="bg-gray-100 sticky top-0">
<tr>
{visibleColumns.map((col) => (
<th key={col} className="border-b p-3 text-left text-gray-700 font-semibold">
<div className="flex flex-col gap-1">
<span>{columnLabels?.[col] || col}</span>
<input
type="text"
placeholder="Search..."
className="p-1 border border-gray-300 rounded text-xs focus:ring-1 focus:ring-blue-400 outline-none"
onChange={(e) =>
setSearchTerms((prev) => ({ ...prev, [col]: e.target.value }))
}
/>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{filteredData.length === 0 ? (
<tr>
<td colSpan={visibleColumns.length} className="text-center py-6 text-gray-400">
No results found
</td>
</tr>
) : (
filteredData.map((row, i) => (
<tr
key={i}
className="odd:bg-white even:bg-gray-50 hover:bg-blue-50 transition"
>
{visibleColumns.map((col) => (
<td key={col} className="border-b p-3 text-gray-700">
{String(row[col] ?? "")}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}

View file

@ -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 (
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#14163d] via-[#2d348f] to-[#3943b7] text-white">
<div className="text-center bg-white/10 backdrop-blur-md text-gray-200 p-8 rounded-2xl shadow-2xl border border-white/10">
No information to show.
</div>
</main>
);
}
// 💼 Fetch deals for that company
const deals = await surveyDB
.select()
.from(hubspotDealData)
.where(eq(hubspotDealData.companyId, company.companyId));
if (!deals || deals.length === 0) {
return (
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#14163d] via-[#2d348f] to-[#3943b7] text-white">
<div className="text-center bg-white/10 backdrop-blur-md text-gray-200 p-8 rounded-2xl shadow-2xl border border-white/10">
No information to show.
</div>
</main>
);
}
return (
<main className="relative min-h-screen overflow-hidden">
{/* 🌊 Domna-inspired layered background */}
{/* <div className="absolute inset-0 -z-10 bg-[linear-gradient(135deg,#14163d_0%,#2d348f_45%,#3943b7_70%,#eff6fc_100%)]" /> */}
{/* ✨ Subtle translucent grid texture */}
<div className="absolute inset-0 -z-0 opacity-10 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.7)_1px,transparent_0)] bg-[length:40px_40px]" />
{/* 💡 Optional soft light glow at top */}
<div className="absolute top-0 inset-x-0 h-[40vh] bg-gradient-to-b from-white/20 via-transparent to-transparent opacity-30" />
{/* Main content */}
<div className="relative z-10 p-6 md:p-10">
<div className="max-w-7xl mx-auto animate-fadeIn">
<LiveTracker deals={deals} />
</div>
</div>
</main>
);
}

View file

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

View file

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Confirm Booking a Survey</DialogTitle>
<DialogHeader className="text-center">
<DialogTitle className="text-center">
Confirm and well be in touch!
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
@ -79,14 +81,13 @@ export default function BookSurveyModal({
className="w-full"
disabled={bookSurveyMutation.isPending}
>
{bookSurveyMutation.isPending ? "Creating..." : "Submit"}
{bookSurveyMutation.isPending ? "Creating..." : "Confirm"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
);
}

View file

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

View file

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