adding missing files

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-16 21:13:35 +00:00
parent 6e8cd56159
commit c6bd99c980
7 changed files with 1341 additions and 0 deletions

View file

@ -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<bigint | null> {
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<boolean> {
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<string, string[]> = {};
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<typeof bodySchema>;
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 },
);
}
}

View file

@ -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<typeof bodySchema>;
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<typeof bodySchema>;
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 },
);
}
}

View file

@ -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<typeof bodySchema>;
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 },
);
}
}

View file

@ -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<typeof dealApprovals, "select">;
export type NewDealApproval = InferModel<typeof dealApprovals, "insert">;
export type DealApprovedMeasure = InferModel<
typeof dealApprovedMeasures,
"select"
>;

View file

@ -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<string, { name: string | null; email: string; capabilities: Capability[] }>;
async function getCapabilities(portfolioId: string): Promise<CapabilityEntry[]> {
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<void> {
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<void> {
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 (
<div className="rounded-md border border-gray-700 mt-4">
<Table>
<TableBody>
<TableRow>
<TableHead className="text-brandblue">
Project Roles:
<p className="text-xs text-gray-500">
Assign approver or contractor capabilities to users
</p>
</TableHead>
</TableRow>
<TableRow>
<TableCell colSpan={3}>
<div className="rounded-md border border-gray-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Approver</TableHead>
<TableHead>Contractor</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-sm text-gray-500">
Loading
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-sm text-gray-500">
No collaborators yet. Add users in the section above first.
</TableCell>
</TableRow>
) : (
rows.map(([userId, { name, email, capabilities }]) => (
<TableRow key={userId}>
<TableCell>{name || "—"}</TableCell>
<TableCell className="text-sm text-gray-600">{email}</TableCell>
<TableCell>
<CapabilityToggle
has={capabilities.includes("approver")}
capability="approver"
isPending={toggleMutation.isPending}
onToggle={(has) =>
toggleMutation.mutate({ userId, capability: "approver", has })
}
/>
</TableCell>
<TableCell>
<CapabilityToggle
has={capabilities.includes("contractor")}
capability="contractor"
isPending={toggleMutation.isPending}
onToggle={(has) =>
toggleMutation.mutate({ userId, capability: "contractor", has })
}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
);
}
function CapabilityToggle({
has,
capability,
isPending,
onToggle,
}: {
has: boolean;
capability: Capability;
isPending: boolean;
onToggle: (has: boolean) => void;
}) {
return (
<Button
size="sm"
variant={has ? "default" : "outline"}
disabled={isPending}
onClick={() => onToggle(has)}
className={has ? "bg-brandblue text-white" : ""}
>
{has ? (
<Badge className="bg-transparent text-white p-0 shadow-none">
{capability === "approver" ? "Approver" : "Contractor"}
</Badge>
) : (
<span className="text-gray-500">
Add {capability === "approver" ? "Approver" : "Contractor"}
</span>
)}
</Button>
);
}

View file

@ -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<string> {
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<File | null>(null);
const [selectedMeasure, setSelectedMeasure] = useState<string>(
measures[0] ?? "",
);
const [selectedDocType, setSelectedDocType] = useState<string>("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<Dialog open onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Upload Install Documentation</DialogTitle>
<DialogDescription>
Upload a document for{" "}
<strong>{deal.dealname ?? deal.landlordPropertyId ?? deal.dealId}</strong>.
Select the relevant measure and document type before uploading.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Measure selector */}
{measures.length > 0 && (
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700">
Measure
</label>
<Select value={selectedMeasure} onValueChange={setSelectedMeasure}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select measure…" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{measures.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
)}
{/* Document type selector */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700">
Document Type <span className="text-red-500">*</span>
</label>
<Select value={selectedDocType} onValueChange={setSelectedDocType}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select document type…" />
</SelectTrigger>
<SelectContent>
{FILE_TYPE_GROUPS.map((group) => {
const items = FILE_TYPE_OPTIONS.filter(
(o) => o.group === group,
);
if (!items.length) return null;
return (
<SelectGroup key={group}>
<SelectLabel className="text-xs text-gray-400 uppercase tracking-wide">
{group}
</SelectLabel>
{items.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</Select>
</div>
{/* File picker */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700">
File <span className="text-red-500">*</span>
</label>
<Input
type="file"
accept=".pdf,.jpg,.jpeg,.png"
multiple={false}
className="cursor-pointer"
onChange={handleFileChange}
/>
<p className="text-xs text-gray-400">
PDF, JPG, or PNG accepted.
</p>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{error}
</p>
)}
</div>
<DialogFooter>
<Button variant="secondary" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button onClick={handleUpload} disabled={!canUpload}>
{submitting ? "Uploading…" : "Upload"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -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 (
<Badge className="bg-amber-50 text-amber-700 border border-amber-200 text-xs">
Pending
</Badge>
);
}
if (approvedCount === proposed.length) {
return (
<Badge className="bg-emerald-50 text-emerald-700 border border-emerald-200 text-xs">
Fully Approved
</Badge>
);
}
return (
<Badge className="bg-blue-50 text-blue-700 border border-blue-200 text-xs">
{approvedCount}/{proposed.length} Approved
</Badge>
);
}
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<string, Set<string>>
>({});
const [uploadDeal, setUploadDeal] = useState<ClassifiedDeal | null>(null);
const [savedApprovals, setSavedApprovals] =
useState<ApprovalsByDeal>(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 (
<div className="rounded-xl border border-gray-100 bg-white p-12 text-center">
<p className="text-sm text-gray-400">
No properties with proposed measures found in this project.
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="relative flex-1 min-w-48 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search address or measure…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 h-9 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">
{filtered.length} of {dealsWithMeasures.length} properties
</span>
{userCapability === "approver" && hasPendingChanges && (
<Button
size="sm"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="bg-brandblue text-white gap-1.5"
>
<Save className="h-3.5 w-3.5" />
{saveMutation.isPending
? "Saving…"
: `Save approvals (${Object.keys(pendingChanges).length})`}
</Button>
)}
</div>
</div>
{/* Table */}
<div className="rounded-xl border border-gray-100 overflow-hidden bg-white">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 border-b border-gray-100">
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Address
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Stage
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Proposed Measures
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Status
</TableHead>
{userCapability === "contractor" && (
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide text-right">
Actions
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow
key={deal.dealId}
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${hasPending ? "bg-amber-50/30" : ""}`}
>
{/* Address */}
<TableCell className="py-3">
<div className="font-medium text-sm text-gray-800">
{deal.dealname ?? "—"}
</div>
{deal.landlordPropertyId && (
<div className="text-xs text-gray-400 mt-0.5">
{deal.landlordPropertyId}
</div>
)}
</TableCell>
{/* Stage */}
<TableCell className="py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium border ${stageColor.bg} ${stageColor.text} ${stageColor.border}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${stageColor.dot}`}
/>
{deal.displayStage}
</span>
</TableCell>
{/* Proposed measures with approval checkboxes */}
<TableCell className="py-3">
<div className="flex flex-wrap gap-1.5">
{proposed.map((measure) => {
const isApproved = approvedSet.has(measure);
if (userCapability === "approver") {
return (
<label
key={measure}
className={`flex items-center gap-1.5 cursor-pointer px-2 py-1 rounded-full text-xs border transition-colors ${
isApproved
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: "bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100"
}`}
>
<Checkbox
checked={isApproved}
onCheckedChange={() =>
toggleMeasure(
deal.dealId,
measure,
savedApprovals[deal.dealId] ?? [],
)
}
className="h-3 w-3"
/>
{measure}
</label>
);
}
return (
<span
key={measure}
className={`px-2 py-1 rounded-full text-xs border ${
isApproved
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: "bg-gray-50 border-gray-200 text-gray-600"
}`}
>
{measure}
</span>
);
})}
</div>
</TableCell>
{/* Status badge */}
<TableCell className="py-3">
<ApprovalStatus
proposed={proposed}
approved={approvedForDeal}
/>
</TableCell>
{/* Contractor upload button */}
{userCapability === "contractor" && (
<TableCell className="py-3 text-right">
<Button
size="sm"
variant="outline"
className="gap-1.5 text-xs"
onClick={() => setUploadDeal(deal)}
>
<Upload className="h-3 w-3" />
Upload Docs
</Button>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* Install upload modal */}
{uploadDeal && (
<InstallUploadModal
deal={uploadDeal}
onClose={() => setUploadDeal(null)}
/>
)}
</div>
);
}