mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
initial set up for installer and client input capabilities
This commit is contained in:
parent
a35a19770c
commit
6e8cd56159
9 changed files with 198 additions and 11 deletions
|
|
@ -1193,4 +1193,4 @@
|
|||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────── */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue