mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
adding missing files
This commit is contained in:
parent
6e8cd56159
commit
c6bd99c980
7 changed files with 1341 additions and 0 deletions
191
src/app/api/portfolio/[portfolioId]/approvals/route.ts
Normal file
191
src/app/api/portfolio/[portfolioId]/approvals/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
171
src/app/api/portfolio/[portfolioId]/capabilities/route.ts
Normal file
171
src/app/api/portfolio/[portfolioId]/capabilities/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
68
src/app/api/upload/contractor-install/route.ts
Normal file
68
src/app/api/upload/contractor-install/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
54
src/app/db/schema/approvals.ts
Normal file
54
src/app/db/schema/approvals.ts
Normal 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"
|
||||
>;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue