From c6bd99c980df72aa11e1b35ba7f23a645c71f5db Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 16 Apr 2026 21:13:35 +0000 Subject: [PATCH] adding missing files --- .../[portfolioId]/approvals/route.ts | 191 ++++++++++ .../[portfolioId]/capabilities/route.ts | 171 +++++++++ .../api/upload/contractor-install/route.ts | 68 ++++ src/app/db/schema/approvals.ts | 54 +++ .../(portfolio)/settings/CapabilitiesCard.tsx | 230 ++++++++++++ .../your-projects/live/InstallUploadModal.tsx | 272 ++++++++++++++ .../your-projects/live/MeasuresTable.tsx | 355 ++++++++++++++++++ 7 files changed, 1341 insertions(+) create mode 100644 src/app/api/portfolio/[portfolioId]/approvals/route.ts create mode 100644 src/app/api/portfolio/[portfolioId]/capabilities/route.ts create mode 100644 src/app/api/upload/contractor-install/route.ts create mode 100644 src/app/db/schema/approvals.ts create mode 100644 src/app/portfolio/[slug]/(portfolio)/settings/CapabilitiesCard.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/InstallUploadModal.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx diff --git a/src/app/api/portfolio/[portfolioId]/approvals/route.ts b/src/app/api/portfolio/[portfolioId]/approvals/route.ts new file mode 100644 index 00000000..4ca7dac1 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/approvals/route.ts @@ -0,0 +1,191 @@ +import { db } from "@/app/db/db"; +import { NextRequest, NextResponse } from "next/server"; +import { dealApprovals, dealApprovedMeasures } from "@/app/db/schema/approvals"; +import { + portfolioCapabilities, + portfolioUsers, +} from "@/app/db/schema/portfolio"; +import { user } from "@/app/db/schema/users"; +import { and, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; + +async function getRequestingUserId(email: string): Promise { + const rows = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, email)) + .limit(1); + return rows[0]?.id ?? null; +} + +async function hasApproverCapability( + portfolioId: bigint, + userId: bigint, +): Promise { + const rows = await db + .select({ id: portfolioCapabilities.id }) + .from(portfolioCapabilities) + .where( + and( + eq(portfolioCapabilities.portfolioId, portfolioId), + eq(portfolioCapabilities.userId, userId), + eq(portfolioCapabilities.capability, "approver"), + ), + ) + .limit(1); + return rows.length > 0; +} + +// GET — return all approved measures grouped by hubspot deal id +// Query param: dealIds (comma-separated) +export async function GET( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await props.params; + const url = new URL(req.url); + const dealIdsParam = url.searchParams.get("dealIds"); + + if (!dealIdsParam) { + return NextResponse.json({}); + } + + const dealIds = dealIdsParam.split(",").filter(Boolean); + if (dealIds.length === 0) { + return NextResponse.json({}); + } + + try { + // Fetch most recent approval per deal + its measures + const approvals = await db + .select({ + id: dealApprovals.id, + hubspotDealId: dealApprovals.hubspotDealId, + approvedAt: dealApprovals.approvedAt, + }) + .from(dealApprovals) + .where(inArray(dealApprovals.hubspotDealId, dealIds)); + + if (approvals.length === 0) { + return NextResponse.json({}); + } + + const approvalIds = approvals.map((a) => a.id); + const measures = await db + .select({ + dealApprovalId: dealApprovedMeasures.dealApprovalId, + measureName: dealApprovedMeasures.measureName, + }) + .from(dealApprovedMeasures) + .where(inArray(dealApprovedMeasures.dealApprovalId, approvalIds)); + + // Build map: dealId -> approved measure names[] + const approvalById = new Map(approvals.map((a) => [a.id.toString(), a])); + const result: Record = {}; + + for (const m of measures) { + const approval = approvalById.get(m.dealApprovalId.toString()); + if (!approval) continue; + const dealId = approval.hubspotDealId; + if (!result[dealId]) result[dealId] = []; + result[dealId].push(m.measureName); + } + + return NextResponse.json(result); + } catch (err) { + console.error("GET /approvals error:", err); + return NextResponse.json( + { error: "Failed to fetch approvals" }, + { status: 500 }, + ); + } +} + +// POST — save approvals for one or more deals (replaces previous approvals) +export async function POST( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + const { portfolioId } = await props.params; + const pId = BigInt(portfolioId); + + const userId = await getRequestingUserId(session.user.email); + if (!userId) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const isApprover = await hasApproverCapability(pId, userId); + if (!isApprover) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const bodySchema = z.object({ + deals: z.array( + z.object({ + hubspotDealId: z.string(), + approvedMeasures: z.array(z.string()), + }), + ), + }); + + let body: z.infer; + try { + body = bodySchema.parse(await req.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + try { + for (const deal of body.deals) { + // Delete previous approvals for this deal (replace approach) + const existing = await db + .select({ id: dealApprovals.id }) + .from(dealApprovals) + .where(eq(dealApprovals.hubspotDealId, deal.hubspotDealId)); + + if (existing.length > 0) { + const existingIds = existing.map((e) => e.id); + await db + .delete(dealApprovedMeasures) + .where(inArray(dealApprovedMeasures.dealApprovalId, existingIds)); + await db + .delete(dealApprovals) + .where(inArray(dealApprovals.id, existingIds)); + } + + if (deal.approvedMeasures.length === 0) continue; + + // Insert new approval session + const [approval] = await db + .insert(dealApprovals) + .values({ + hubspotDealId: deal.hubspotDealId, + approvedBy: userId, + }) + .returning({ id: dealApprovals.id }); + + // Insert individual approved measures (stored as free text matching HubSpot proposedMeasures) + await db.insert(dealApprovedMeasures).values( + deal.approvedMeasures.map((m) => ({ + dealApprovalId: approval.id, + measureName: m, + })), + ); + } + + return NextResponse.json({ success: true }); + } catch (err) { + console.error("POST /approvals error:", err); + return NextResponse.json( + { error: "Failed to save approvals" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/portfolio/[portfolioId]/capabilities/route.ts b/src/app/api/portfolio/[portfolioId]/capabilities/route.ts new file mode 100644 index 00000000..8f7f8b02 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/capabilities/route.ts @@ -0,0 +1,171 @@ +import { db } from "@/app/db/db"; +import { NextRequest, NextResponse } from "next/server"; +import { + portfolioUsers, + portfolioCapabilities, + PortfolioCapabilityType, +} from "@/app/db/schema/portfolio"; +import { user } from "@/app/db/schema/users"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; + +const CAPABILITY_OPTIONS = ["approver", "contractor"] as const; + +async function getRequestingUserRole(portfolioId: bigint, email: string) { + const rows = await db + .select({ role: portfolioUsers.role }) + .from(portfolioUsers) + .innerJoin(user, eq(user.id, portfolioUsers.userId)) + .where( + and( + eq(portfolioUsers.portfolioId, portfolioId), + eq(user.email, email), + ), + ) + .limit(1); + return rows[0]?.role ?? null; +} + +// GET — list all capability assignments for this portfolio +export async function GET( + _req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await props.params; + + try { + const rows = await db + .select({ + id: portfolioCapabilities.id, + userId: portfolioCapabilities.userId, + capability: portfolioCapabilities.capability, + name: user.firstName, + email: user.email, + }) + .from(portfolioCapabilities) + .leftJoin(user, eq(user.id, portfolioCapabilities.userId)) + .where(eq(portfolioCapabilities.portfolioId, BigInt(portfolioId))); + + return NextResponse.json( + rows.map((r) => ({ + id: r.id?.toString(), + userId: r.userId?.toString(), + capability: r.capability, + name: r.name ?? null, + email: r.email ?? "", + })), + ); + } catch (err) { + console.error("GET /capabilities error:", err); + return NextResponse.json( + { error: "Failed to fetch capabilities" }, + { status: 500 }, + ); + } +} + +// POST — assign a capability to a user +export async function POST( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + const { portfolioId } = await props.params; + const pId = BigInt(portfolioId); + + const requestingRole = await getRequestingUserRole(pId, session.user.email); + if (requestingRole !== "admin" && requestingRole !== "creator") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const bodySchema = z.object({ + userId: z.string(), + capability: z.enum(CAPABILITY_OPTIONS), + }); + + let body: z.infer; + try { + body = bodySchema.parse(await req.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + try { + await db + .insert(portfolioCapabilities) + .values({ + portfolioId: pId, + userId: BigInt(body.userId), + capability: body.capability as PortfolioCapabilityType, + }) + .onConflictDoNothing(); + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error("POST /capabilities error:", err); + return NextResponse.json( + { error: "Failed to assign capability" }, + { status: 500 }, + ); + } +} + +// DELETE — remove a capability from a user +export async function DELETE( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + const { portfolioId } = await props.params; + const pId = BigInt(portfolioId); + + const requestingRole = await getRequestingUserRole(pId, session.user.email); + if (requestingRole !== "admin" && requestingRole !== "creator") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const bodySchema = z.object({ + userId: z.string(), + capability: z.enum(CAPABILITY_OPTIONS), + }); + + let body: z.infer; + try { + body = bodySchema.parse(await req.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + try { + await db + .delete(portfolioCapabilities) + .where( + and( + eq(portfolioCapabilities.portfolioId, pId), + eq(portfolioCapabilities.userId, BigInt(body.userId)), + eq( + portfolioCapabilities.capability, + body.capability as PortfolioCapabilityType, + ), + ), + ); + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error("DELETE /capabilities error:", err); + return NextResponse.json( + { error: "Failed to remove capability" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/upload/contractor-install/route.ts b/src/app/api/upload/contractor-install/route.ts new file mode 100644 index 00000000..ea05d4bc --- /dev/null +++ b/src/app/api/upload/contractor-install/route.ts @@ -0,0 +1,68 @@ +import { db } from "@/app/db/db"; +import { NextRequest, NextResponse } from "next/server"; +import { uploadedFiles } from "@/app/db/schema/uploaded_files"; +import { user } from "@/app/db/schema/users"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; + +// POST — record a contractor install document in uploaded_files +export async function POST(req: NextRequest) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + const bodySchema = z.object({ + s3FileKey: z.string(), + s3FileBucket: z.string(), + fileType: z.string(), + measureName: z.string().optional(), + uprn: z.string().optional(), + hubspotDealId: z.string().optional(), + landlordPropertyId: z.string().optional(), + }); + + let body: z.infer; + try { + body = bodySchema.parse(await req.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + try { + // Resolve uploader's user ID + const userRow = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, session.user.email)) + .limit(1); + + const uploadedBy = userRow[0]?.id ?? null; + + const [inserted] = await db + .insert(uploadedFiles) + .values({ + s3FileBucket: body.s3FileBucket, + s3FileKey: body.s3FileKey, + s3UploadTimestamp: new Date(), + fileType: body.fileType as any, + source: "contractor", + measureName: body.measureName ?? null, + uploadedBy: uploadedBy ?? undefined, + uprn: body.uprn ? BigInt(body.uprn) : undefined, + hubsotDealId: body.hubspotDealId ?? null, + landlordPropertyId: body.landlordPropertyId ?? null, + }) + .returning({ id: uploadedFiles.id }); + + return NextResponse.json({ id: inserted.id.toString() }, { status: 201 }); + } catch (err) { + console.error("POST /upload/contractor-install error:", err); + return NextResponse.json( + { error: "Failed to record upload" }, + { status: 500 }, + ); + } +} diff --git a/src/app/db/schema/approvals.ts b/src/app/db/schema/approvals.ts new file mode 100644 index 00000000..92031500 --- /dev/null +++ b/src/app/db/schema/approvals.ts @@ -0,0 +1,54 @@ +import { + bigserial, + text, + timestamp, + pgTable, + bigint, + index, +} from "drizzle-orm/pg-core"; +import { user } from "./users"; +import { InferModel } from "drizzle-orm"; + +export const dealApprovals = pgTable( + "deal_approvals", + { + id: bigserial("id", { mode: "bigint" }).primaryKey(), + hubspotDealId: text("hubspot_deal_id").notNull(), + approvedBy: bigint("approved_by", { mode: "bigint" }) + .notNull() + .references(() => user.id), + approvedAt: timestamp("approved_at", { withTimezone: true }) + .defaultNow() + .notNull(), + notes: text("notes"), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [index("idx_deal_approvals_deal_id").on(table.hubspotDealId)], +); + +export const dealApprovedMeasures = pgTable( + "deal_approved_measures", + { + id: bigserial("id", { mode: "bigint" }).primaryKey(), + dealApprovalId: bigint("deal_approval_id", { mode: "bigint" }) + .notNull() + .references(() => dealApprovals.id, { onDelete: "cascade" }), + // Stored as text to match free-text proposedMeasures from HubSpot + measureName: text("measure_name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + index("idx_deal_approved_measures_approval_id").on(table.dealApprovalId), + ], +); + +export type DealApproval = InferModel; +export type NewDealApproval = InferModel; +export type DealApprovedMeasure = InferModel< + typeof dealApprovedMeasures, + "select" +>; diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/CapabilitiesCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/CapabilitiesCard.tsx new file mode 100644 index 00000000..533c2e3c --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/CapabilitiesCard.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/app/shadcn_components/ui/table"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { Badge } from "@/app/shadcn_components/ui/badge"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; + +type Capability = "approver" | "contractor"; + +type CapabilityEntry = { + id: string; + userId: string; + capability: Capability; + name: string | null; + email: string; +}; + +type CapabilityMap = Record; + +async function getCapabilities(portfolioId: string): Promise { + const res = await fetch(`/api/portfolio/${portfolioId}/capabilities`); + if (!res.ok) throw new Error("Failed to fetch capabilities"); + return res.json(); +} + +async function getCollaborators( + portfolioId: string, +): Promise<{ userId: string; name: string | null; email: string }[]> { + const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`); + if (!res.ok) throw new Error("Failed to fetch collaborators"); + const json = await res.json(); + const users = Array.isArray(json) ? json : json.users ?? []; + return users.map((u: any) => ({ + userId: String(u.userId), + name: u.name ?? null, + email: u.email ?? "", + })); +} + +async function assignCapability( + portfolioId: string, + userId: string, + capability: Capability, +): Promise { + const res = await fetch(`/api/portfolio/${portfolioId}/capabilities`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, capability }), + }); + if (!res.ok) throw new Error("Failed to assign capability"); +} + +async function removeCapability( + portfolioId: string, + userId: string, + capability: Capability, +): Promise { + const res = await fetch(`/api/portfolio/${portfolioId}/capabilities`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, capability }), + }); + if (!res.ok) throw new Error("Failed to remove capability"); +} + +export function CapabilitiesCard({ portfolioId }: { portfolioId: string }) { + const queryClient = useQueryClient(); + const queryKey = ["portfolioCapabilities", portfolioId]; + + const { data: entries = [], isLoading: loadingCaps } = useQuery({ + queryKey, + queryFn: () => getCapabilities(portfolioId), + enabled: !!portfolioId, + refetchOnWindowFocus: false, + }); + + const { data: collaborators = [], isLoading: loadingCollabs } = useQuery({ + queryKey: ["portfolioUsers", portfolioId], + queryFn: () => getCollaborators(portfolioId), + enabled: !!portfolioId, + refetchOnWindowFocus: false, + }); + + const isLoading = loadingCaps || loadingCollabs; + + // Build a map: userId -> { capabilities: [] } + const capMap: CapabilityMap = {}; + for (const c of collaborators) { + capMap[c.userId] = { name: c.name, email: c.email, capabilities: [] }; + } + for (const e of entries) { + if (capMap[e.userId]) { + capMap[e.userId].capabilities.push(e.capability); + } + } + + const toggleMutation = useMutation({ + mutationFn: ({ + userId, + capability, + has, + }: { + userId: string; + capability: Capability; + has: boolean; + }) => + has + ? removeCapability(portfolioId, userId, capability) + : assignCapability(portfolioId, userId, capability), + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const rows = Object.entries(capMap); + + return ( +
+ + + + + Project Roles: +

+ Assign approver or contractor capabilities to users +

+
+
+ + +
+
+ + + Name + Email + Approver + Contractor + + + + {isLoading ? ( + + + Loading… + + + ) : rows.length === 0 ? ( + + + No collaborators yet. Add users in the section above first. + + + ) : ( + rows.map(([userId, { name, email, capabilities }]) => ( + + {name || "—"} + {email} + + + toggleMutation.mutate({ userId, capability: "approver", has }) + } + /> + + + + toggleMutation.mutate({ userId, capability: "contractor", has }) + } + /> + + + )) + )} + +
+
+ + + + + + ); +} + +function CapabilityToggle({ + has, + capability, + isPending, + onToggle, +}: { + has: boolean; + capability: Capability; + isPending: boolean; + onToggle: (has: boolean) => void; +}) { + return ( + + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/InstallUploadModal.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/InstallUploadModal.tsx new file mode 100644 index 00000000..d7c83bf1 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/InstallUploadModal.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/app/shadcn_components/ui/dialog"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { Input } from "@/app/shadcn_components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/app/shadcn_components/ui/select"; +import { uploadFileToS3 } from "@/app/utils/s3"; +import type { ClassifiedDeal } from "./types"; + +type Props = { + deal: ClassifiedDeal; + onClose: () => void; +}; + +// Contractor-specific file type options grouped for the UI +const FILE_TYPE_OPTIONS: { value: string; label: string; group: string }[] = [ + // Install photos + { value: "pre_photo", label: "Pre Photo", group: "Install Photos" }, + { value: "mid_photo", label: "Mid Photo", group: "Install Photos" }, + { value: "post_photo", label: "Post Photo", group: "Install Photos" }, + // Pre-installation + { value: "pre_installation_building_inspection", label: "Pre-Installation Building Inspection (PIBI)", group: "Pre-Installation" }, + { value: "point_of_work_risk_assessment", label: "Point of Work Risk Assessment", group: "Pre-Installation" }, + // Compliance + { value: "claim_of_compliance", label: "Claim of Compliance (PAS 2030)", group: "Compliance" }, + { value: "mcs_compliance_certificate", label: "MCS Compliance Certificate", group: "Compliance" }, + { value: "certificate_of_conformity", label: "Certificate of Conformity", group: "Compliance" }, + { value: "minor_works_electrical_certificate", label: "Minor Works Electrical Certificate", group: "Compliance" }, + // Handover + { value: "handover_pack", label: "Handover Documents / Pack", group: "Handover" }, + { value: "workmanship_warranty", label: "Workmanship Warranty", group: "Handover" }, + { value: "insurance_guarantee", label: "Insurance Backed Guarantee (IBG)", group: "Handover" }, + { value: "g98_notification", label: "G98 / G99 Notification", group: "Handover" }, + { value: "ventilation_assessment_checklist", label: "Ventilation Assessment Checklist", group: "Handover" }, + // Qualifications + { value: "installer_qualifications", label: "Installer Qualifications", group: "Qualifications" }, + { value: "installer_feedback", label: "Installer Feedback", group: "Other" }, + { value: "contractor_other", label: "Other", group: "Other" }, +]; + +const FILE_TYPE_GROUPS = [ + "Install Photos", + "Pre-Installation", + "Compliance", + "Handover", + "Qualifications", + "Other", +]; + +function parseMeasures(raw: string | null | undefined): string[] { + if (!raw) return []; + return raw.split(",").map((m) => m.trim()).filter(Boolean); +} + +function contentTypeFor(ext: string): string { + const e = ext.toLowerCase(); + if (e === "pdf") return "application/pdf"; + if (e === "xml") return "application/xml"; + if (["jpg", "jpeg"].includes(e)) return "image/jpeg"; + if (e === "png") return "image/png"; + return "application/octet-stream"; +} + +async function getPresignedUrl(path: string, contentType: string): Promise { + const res = await fetch("/api/upload/retrofit-energy-assessments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, contentType, expiresInSeconds: 300 }), + }); + if (!res.ok) throw new Error("Failed to get presigned URL"); + const { url } = await res.json(); + return url; +} + +async function recordUpload(payload: { + s3FileKey: string; + s3FileBucket: string; + fileType: string; + measureName?: string; + uprn?: string; + hubspotDealId?: string; + landlordPropertyId?: string; +}) { + const res = await fetch("/api/upload/contractor-install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error("Failed to record upload"); +} + +export default function InstallUploadModal({ deal, onClose }: Props) { + const measures = parseMeasures(deal.proposedMeasures); + const [file, setFile] = useState(null); + const [selectedMeasure, setSelectedMeasure] = useState( + measures[0] ?? "", + ); + const [selectedDocType, setSelectedDocType] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + function handleFileChange(e: React.ChangeEvent) { + const f = e.target.files?.[0] ?? null; + setFile(f); + setError(null); + } + + async function handleUpload() { + if (!file || !selectedDocType) return; + setSubmitting(true); + setError(null); + + try { + const timestamp = new Date() + .toISOString() + .replace(/[-:]/g, "") + .replace("T", "_") + .split(".")[0]; + const ext = (file.name.split(".").pop() || "bin").toLowerCase(); + const ct = contentTypeFor(ext); + + const s3Key = `contractor-install/${deal.dealId}/${selectedDocType}/${timestamp}.${ext}`; + + const presignedUrl = await getPresignedUrl(s3Key, ct); + + await uploadFileToS3({ presignedUrl, file, contentType: ct }); + + // Extract bucket from presigned URL (format: https://bucket.s3.region.amazonaws.com/...) + const urlObj = new URL(presignedUrl); + const bucket = urlObj.hostname.split(".")[0]; + + await recordUpload({ + s3FileKey: s3Key, + s3FileBucket: bucket, + fileType: selectedDocType, + measureName: selectedMeasure || undefined, + uprn: deal.uprn ?? undefined, + hubspotDealId: deal.dealId, + landlordPropertyId: deal.landlordPropertyId ?? undefined, + }); + + onClose(); + } catch (err) { + console.error(err); + setError("Upload failed. Please try again."); + } finally { + setSubmitting(false); + } + } + + const canUpload = !!file && !!selectedDocType && !submitting; + + return ( + + + + Upload Install Documentation + + Upload a document for{" "} + {deal.dealname ?? deal.landlordPropertyId ?? deal.dealId}. + Select the relevant measure and document type before uploading. + + + +
+ {/* Measure selector */} + {measures.length > 0 && ( +
+ + +
+ )} + + {/* Document type selector */} +
+ + +
+ + {/* File picker */} +
+ + +

+ PDF, JPG, or PNG accepted. +

+
+ + {error && ( +

+ {error} +

+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx new file mode 100644 index 00000000..2aa61f5f --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx @@ -0,0 +1,355 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/app/shadcn_components/ui/table"; +import { Input } from "@/app/shadcn_components/ui/input"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { Badge } from "@/app/shadcn_components/ui/badge"; +import { Checkbox } from "@/app/shadcn_components/ui/checkbox"; +import { Search, Upload, Save } from "lucide-react"; +import { STAGE_COLORS } from "./types"; +import type { ClassifiedDeal, PortfolioCapabilityType, ApprovalsByDeal } from "./types"; +import InstallUploadModal from "./InstallUploadModal"; + +type Props = { + data: ClassifiedDeal[]; + userCapability: PortfolioCapabilityType; + approvalsByDeal: ApprovalsByDeal; + portfolioId: string; +}; + +function parseMeasures(raw: string | null | undefined): string[] { + if (!raw) return []; + return raw + .split(",") + .map((m) => m.trim()) + .filter(Boolean); +} + +function ApprovalStatus({ + proposed, + approved, +}: { + proposed: string[]; + approved: string[]; +}) { + if (proposed.length === 0) return null; + const approvedSet = new Set(approved); + const approvedCount = proposed.filter((m) => approvedSet.has(m)).length; + + if (approvedCount === 0) { + return ( + + Pending + + ); + } + if (approvedCount === proposed.length) { + return ( + + Fully Approved + + ); + } + return ( + + {approvedCount}/{proposed.length} Approved + + ); +} + +async function saveApprovals( + portfolioId: string, + deals: { hubspotDealId: string; approvedMeasures: string[] }[], +) { + const res = await fetch(`/api/portfolio/${portfolioId}/approvals`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deals }), + }); + if (!res.ok) throw new Error("Failed to save approvals"); +} + +export default function MeasuresTable({ + data, + userCapability, + approvalsByDeal, + portfolioId, +}: Props) { + const [search, setSearch] = useState(""); + const [pendingChanges, setPendingChanges] = useState< + Record> + >({}); + const [uploadDeal, setUploadDeal] = useState(null); + const [savedApprovals, setSavedApprovals] = + useState(approvalsByDeal); + + // Filter to only properties with proposed measures + const dealsWithMeasures = useMemo( + () => data.filter((d) => d.proposedMeasures), + [data], + ); + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + if (!q) return dealsWithMeasures; + return dealsWithMeasures.filter( + (d) => + d.dealname?.toLowerCase().includes(q) || + d.landlordPropertyId?.toLowerCase().includes(q) || + d.proposedMeasures?.toLowerCase().includes(q), + ); + }, [dealsWithMeasures, search]); + + const hasPendingChanges = Object.keys(pendingChanges).length > 0; + + const saveMutation = useMutation({ + mutationFn: () => { + const deals = Object.entries(pendingChanges).map(([dealId, measures]) => ({ + hubspotDealId: dealId, + approvedMeasures: Array.from(measures), + })); + return saveApprovals(portfolioId, deals); + }, + onSuccess: () => { + // Merge pending changes into saved approvals + setSavedApprovals((prev) => { + const next = { ...prev }; + for (const [dealId, measures] of Object.entries(pendingChanges)) { + next[dealId] = Array.from(measures); + } + return next; + }); + setPendingChanges({}); + }, + }); + + function toggleMeasure(dealId: string, measure: string, currentApproved: string[]) { + setPendingChanges((prev) => { + // Start from either pending state or the saved state + const current = + prev[dealId] !== undefined + ? new Set(prev[dealId]) + : new Set(currentApproved); + + if (current.has(measure)) { + current.delete(measure); + } else { + current.add(measure); + } + + // If the pending set equals the saved set, remove from pending + const saved = new Set(savedApprovals[dealId] ?? []); + const setsEqual = + current.size === saved.size && [...current].every((m) => saved.has(m)); + + const next = { ...prev }; + if (setsEqual) { + delete next[dealId]; + } else { + next[dealId] = current; + } + return next; + }); + } + + if (dealsWithMeasures.length === 0) { + return ( +
+

+ No properties with proposed measures found in this project. +

+
+ ); + } + + return ( +
+ {/* Toolbar */} +
+
+ + setSearch(e.target.value)} + className="pl-9 h-9 text-sm" + /> +
+
+ + {filtered.length} of {dealsWithMeasures.length} properties + + {userCapability === "approver" && hasPendingChanges && ( + + )} +
+
+ + {/* Table */} +
+ + + + + Address + + + Stage + + + Proposed Measures + + + Status + + {userCapability === "contractor" && ( + + Actions + + )} + + + + {filtered.map((deal) => { + const proposed = parseMeasures(deal.proposedMeasures); + const approvedForDeal = + pendingChanges[deal.dealId] !== undefined + ? Array.from(pendingChanges[deal.dealId]) + : (savedApprovals[deal.dealId] ?? []); + const approvedSet = new Set(approvedForDeal); + const stageColor = STAGE_COLORS[deal.displayStage]; + const hasPending = pendingChanges[deal.dealId] !== undefined; + + return ( + + {/* Address */} + +
+ {deal.dealname ?? "—"} +
+ {deal.landlordPropertyId && ( +
+ {deal.landlordPropertyId} +
+ )} +
+ + {/* Stage */} + + + + {deal.displayStage} + + + + {/* Proposed measures with approval checkboxes */} + +
+ {proposed.map((measure) => { + const isApproved = approvedSet.has(measure); + if (userCapability === "approver") { + return ( + + ); + } + return ( + + {measure} + + ); + })} +
+
+ + {/* Status badge */} + + + + + {/* Contractor upload button */} + {userCapability === "contractor" && ( + + + + )} +
+ ); + })} +
+
+
+ + {/* Install upload modal */} + {uploadDeal && ( + setUploadDeal(null)} + /> + )} +
+ ); +}