initial set up for installer and client input capabilities

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-16 21:12:12 +00:00
parent a35a19770c
commit 6e8cd56159
9 changed files with 198 additions and 11 deletions

View file

@ -1193,4 +1193,4 @@
"breakpoints": true
}
]
}
}

View file

@ -7,6 +7,7 @@ import {
pgEnum,
integer,
bigint,
unique,
} from "drizzle-orm/pg-core";
import { user } from "./users";
import { InferModel } from "drizzle-orm";
@ -124,7 +125,43 @@ export const portfolioUsers = pgTable("portfolioUsers", {
.notNull(),
});
export const PortfolioCapability: [string, ...string[]] = [
"approver",
"contractor",
];
export type PortfolioCapabilityType = "approver" | "contractor";
export const portfolioCapabilityEnum = pgEnum(
"portfolio_capability",
PortfolioCapability as [string, ...string[]],
);
export const portfolioCapabilities = pgTable(
"portfolio_capabilities",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
userId: bigint("user_id", { mode: "bigint" })
.notNull()
.references(() => user.id),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id),
capability: portfolioCapabilityEnum("capability").notNull(),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [unique().on(table.userId, table.portfolioId, table.capability)],
);
export type Portfolio = InferModel<typeof portfolio, "select">;
export type NewPortfolio = InferModel<typeof portfolio, "insert">;
export type PortfolioUsers = InferModel<typeof portfolioUsers, "select">;
export type NewPortfolioUsers = InferModel<typeof portfolioUsers, "insert">;
export type PortfolioCapabilities = InferModel<
typeof portfolioCapabilities,
"select"
>;

View file

@ -58,6 +58,13 @@ export const measureTypeEnum = pgEnum("measure_type", [
// Other fabric / hot water
"hot_water_tank_insulation",
"sealing_open_fireplace",
// Contractor workflow measures
"damp_mould",
"door_undercut",
"extractor_fan",
"loft_board",
"trickle_vent",
]);
export const recommendation = pgTable(

View file

@ -1,6 +1,8 @@
import { bigint, bigserial, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { user } from "./users";
export const fileType = pgEnum("file_type", [
// Survey documents (existing)
"photo_pack",
"site_note",
"rd_sap_site_note",
@ -12,14 +14,33 @@ export const fileType = pgEnum("file_type", [
"pas_2023_occupancy",
"ecmk_site_note",
"ecmk_rd_sap_site_note",
"ecmk_survey_xml"
"ecmk_survey_xml",
// Contractor install documentation
"pre_photo",
"mid_photo",
"post_photo",
"pre_installation_building_inspection",
"claim_of_compliance",
"handover_pack",
"insurance_guarantee",
"installer_qualifications",
"mcs_compliance_certificate",
"minor_works_electrical_certificate",
"point_of_work_risk_assessment",
"installer_feedback",
"workmanship_warranty",
"g98_notification",
"certificate_of_conformity",
"ventilation_assessment_checklist",
"contractor_other",
]);
export const fileSource = pgEnum("file_source", [
"pas hub",
"sharepoint",
"hubspot",
"ecmk"
"ecmk",
"contractor",
]);
export const uploadedFiles = pgTable(
@ -36,6 +57,8 @@ export const uploadedFiles = pgTable(
hubsotDealId: text("hubspot_deal_id"),
hubspotListingId: bigint("hubspot_listing_id", { mode: "bigint" }),
fileType: fileType("file_type"),
source: fileSource("file_source")
source: fileSource("file_source"),
measureName: text("measure_name"),
uploadedBy: bigint("uploaded_by", { mode: "bigint" }).references(() => user.id),
}
);

View file

@ -1,4 +1,5 @@
import { UsersPermissionsCard } from "../UsersPermissionsCard";
import { CapabilitiesCard } from "../CapabilitiesCard";
export default async function UserAccessPage(props: {
params: Promise<{ slug: string }>;
@ -8,6 +9,7 @@ export default async function UserAccessPage(props: {
return (
<div>
<UsersPermissionsCard portfolioId={slug} />
<CapabilitiesCard portfolioId={slug} />
</div>
);
}

View file

@ -9,10 +9,11 @@ import {
TabsTrigger,
} from "@/app/shadcn_components/ui/tabs";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { BarChart2, Table2, FolderOpen } from "lucide-react";
import { BarChart2, Table2, FolderOpen, Wrench } from "lucide-react";
import DrillDownTable from "./DrillDownTable";
import PropertyTable from "./PropertyTable";
import DocumentTable from "./DocumentTable";
import MeasuresTable from "./MeasuresTable";
import type { HubspotDeal } from "./types";
import PropertyDrawer from "./PropertyDrawer";
import PropertyDetailDrawer from "./PropertyDetailDrawer";
@ -30,9 +31,12 @@ export default function LiveTracker({
totalDeals,
majorConditionDeals,
docStatusMap,
userCapability,
approvalsByDeal,
portfolioId,
}: LiveTrackerProps) {
// ── Tab state ────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents">(
const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents" | "measures">(
"analytics",
);
@ -94,7 +98,7 @@ export default function LiveTracker({
<div className="space-y-4 w-full">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents")}
onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents" | "measures")}
>
{/* Tab bar */}
<TabsList className="h-10 p-1 bg-brandlightblue/10 border border-brandblue/10 rounded-xl mb-6">
@ -119,6 +123,13 @@ export default function LiveTracker({
<FolderOpen className="h-3.5 w-3.5" />
Document Management
</TabsTrigger>
<TabsTrigger
value="measures"
className="flex items-center gap-2 rounded-lg text-sm font-medium px-4 data-[state=active]:bg-white data-[state=active]:text-brandblue data-[state=active]:shadow-sm transition-all"
>
<Wrench className="h-3.5 w-3.5" />
Measures
</TabsTrigger>
</TabsList>
{/* Analytics tab */}
@ -207,6 +218,40 @@ export default function LiveTracker({
/>
</div>
</TabsContent>
{/* Measures tab */}
<TabsContent value="measures" className="mt-0">
<div className="space-y-4">
{projects.length > 1 && (
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500 shrink-0">Project:</span>
<select
value={currentProjectCode}
onChange={(e) => setCurrentProjectCode(e.target.value)}
className="px-3 py-1.5 border border-brandblue/20 rounded-lg bg-white text-sm text-gray-800 font-medium focus:ring-2 focus:ring-brandblue focus:border-brandblue focus:outline-none transition-all appearance-none pr-8"
>
{projectCodes.map((code) =>
code === "__ALL__" ? (
<option key="__ALL__" value="__ALL__" style={{ fontWeight: 700 }}>
All Projects
</option>
) : (
<option key={code} value={code}>
{code}
</option>
),
)}
</select>
</div>
)}
<MeasuresTable
data={currentProject?.allDeals ?? []}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
portfolioId={portfolioId}
/>
</div>
</TabsContent>
</Tabs>
{/* ── Drill-down table modal ─────────────────────────────────────── */}

View file

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { redirect } from "next/navigation";
import { eq, inArray } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import LiveTracker from "./LiveTracker";
import { computeLiveTrackerData } from "./transforms";
import { db } from "@/app/db/db";
@ -9,7 +9,10 @@ import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
import { organisation } from "@/app/db/schema/organisation";
import type { HubspotDeal, DocStatusMap, DocStatus } from "./types";
import { portfolioCapabilities } from "@/app/db/schema/portfolio";
import { dealApprovals, dealApprovedMeasures } from "@/app/db/schema/approvals";
import { user as userTable } from "@/app/db/schema/users";
import type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal } from "./types";
import { EXPECTED_SURVEY_DOC_TYPES } from "./types";
import type { InferSelectModel } from "drizzle-orm";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
@ -120,6 +123,59 @@ export default async function LiveReportingPage(props: {
const deals = rawDeals.map(mapDbRowToHubspotDeal);
const trackerData = computeLiveTrackerData(deals);
// Fetch current user's portfolio capability (approver / contractor)
let userCapability: PortfolioCapabilityType = null;
const userEmail = user?.user?.email;
if (userEmail) {
const userRow = await db
.select({ id: userTable.id })
.from(userTable)
.where(eq(userTable.email, userEmail))
.limit(1);
if (userRow[0]) {
const capRow = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
)
.limit(1);
userCapability = (capRow[0]?.capability as PortfolioCapabilityType) ?? null;
}
}
// Fetch deal approvals for all deals in scope
const approvalsByDeal: ApprovalsByDeal = {};
const dealIds = deals.map((d) => d.dealId).filter(Boolean);
if (dealIds.length > 0) {
const approvalRows = await db
.select({ id: dealApprovals.id, hubspotDealId: dealApprovals.hubspotDealId })
.from(dealApprovals)
.where(inArray(dealApprovals.hubspotDealId, dealIds));
if (approvalRows.length > 0) {
const approvalIds = approvalRows.map((a) => a.id);
const measureRows = await db
.select({
dealApprovalId: dealApprovedMeasures.dealApprovalId,
measureName: dealApprovedMeasures.measureName,
})
.from(dealApprovedMeasures)
.where(inArray(dealApprovedMeasures.dealApprovalId, approvalIds));
const approvalById = new Map(approvalRows.map((a) => [a.id.toString(), a.hubspotDealId]));
for (const m of measureRows) {
const dealId = approvalById.get(m.dealApprovalId.toString());
if (!dealId) continue;
(approvalsByDeal[dealId] ??= []).push(m.measureName);
}
}
}
// Fetch survey document status for all properties
const uprnList = deals
.map((d) => d.uprn)
@ -158,7 +214,13 @@ export default async function LiveReportingPage(props: {
return (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
{pageHeader}
<LiveTracker {...trackerData} docStatusMap={docStatusMap} />
<LiveTracker
{...trackerData}
docStatusMap={docStatusMap}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
portfolioId={portfolioId}
/>
</div>
);
}

View file

@ -375,7 +375,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] {
// -----------------------------------------------------------------------
export function computeLiveTrackerData(
rawDeals: HubspotDeal[]
): Omit<LiveTrackerProps, "docStatusMap"> {
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "portfolioId"> {
// Classify all deals (add displayStage field)
const classified = classifyDeals(rawDeals);

View file

@ -161,6 +161,14 @@ export type ProjectData = {
allDeals: ClassifiedDeal[]; // for table drill-downs within project
};
// -----------------------------------------------------------------------
// Portfolio capability for the current viewing user
// -----------------------------------------------------------------------
export type PortfolioCapabilityType = "approver" | "contractor" | null;
// Approved measure names per HubSpot deal ID
export type ApprovalsByDeal = Record<string, string[]>;
// -----------------------------------------------------------------------
// Top-level props for LiveTracker (client root)
// -----------------------------------------------------------------------
@ -169,6 +177,9 @@ export type LiveTrackerProps = {
totalDeals: number;
majorConditionDeals: ClassifiedDeal[]; // for Awaab's Law card
docStatusMap: DocStatusMap;
userCapability: PortfolioCapabilityType;
approvalsByDeal: ApprovalsByDeal;
portfolioId: string;
};
// -----------------------------------------------------------------------