diff --git a/src/app/db/migrations/meta/_journal.json b/src/app/db/migrations/meta/_journal.json index 9436ff4e..9309f692 100644 --- a/src/app/db/migrations/meta/_journal.json +++ b/src/app/db/migrations/meta/_journal.json @@ -1193,4 +1193,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/app/db/schema/portfolio.ts b/src/app/db/schema/portfolio.ts index d424634b..8e231bc8 100644 --- a/src/app/db/schema/portfolio.ts +++ b/src/app/db/schema/portfolio.ts @@ -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; export type NewPortfolio = InferModel; export type PortfolioUsers = InferModel; export type NewPortfolioUsers = InferModel; +export type PortfolioCapabilities = InferModel< + typeof portfolioCapabilities, + "select" +>; diff --git a/src/app/db/schema/recommendations.ts b/src/app/db/schema/recommendations.ts index 3ffdff24..2cef2bd3 100644 --- a/src/app/db/schema/recommendations.ts +++ b/src/app/db/schema/recommendations.ts @@ -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( diff --git a/src/app/db/schema/uploaded_files.ts b/src/app/db/schema/uploaded_files.ts index 18d99a07..abf2fa1d 100644 --- a/src/app/db/schema/uploaded_files.ts +++ b/src/app/db/schema/uploaded_files.ts @@ -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), } ); \ No newline at end of file diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/user-access/page.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/user-access/page.tsx index 5d749abd..ae98d36d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/user-access/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/user-access/page.tsx @@ -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 (
+
); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index 502bd71d..a9138abf 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -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({
setActiveTab(v as "analytics" | "properties" | "documents")} + onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents" | "measures")} > {/* Tab bar */} @@ -119,6 +123,13 @@ export default function LiveTracker({ Document Management + + + Measures + {/* Analytics tab */} @@ -207,6 +218,40 @@ export default function LiveTracker({ />
+ + {/* Measures tab */} + +
+ {projects.length > 1 && ( +
+ Project: + +
+ )} + +
+
{/* ── Drill-down table modal ─────────────────────────────────────── */} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx index f7002fc1..1a8fc5ef 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -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 (
{pageHeader} - +
); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts index fe1da877..b829c914 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts @@ -375,7 +375,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] { // ----------------------------------------------------------------------- export function computeLiveTrackerData( rawDeals: HubspotDeal[] -): Omit { +): Omit { // Classify all deals (add displayStage field) const classified = classifyDeals(rawDeals); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts index 40fa764f..ac808ce4 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -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; + // ----------------------------------------------------------------------- // 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; }; // -----------------------------------------------------------------------