mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
save latest changes and pulled from main
This commit is contained in:
commit
119a800995
31 changed files with 21446 additions and 95 deletions
|
|
@ -29,6 +29,7 @@ export async function GET(req: Request) {
|
|||
s3FileBucket: uploadedFiles.s3FileBucket,
|
||||
s3UploadTimestamp: uploadedFiles.s3UploadTimestamp,
|
||||
fileType: uploadedFiles.fileType,
|
||||
source: uploadedFiles.source,
|
||||
uprn: uploadedFiles.uprn,
|
||||
landlordPropertyId: uploadedFiles.landlordPropertyId,
|
||||
measureName: uploadedFiles.measureName,
|
||||
|
|
@ -41,6 +42,7 @@ export async function GET(req: Request) {
|
|||
s3FileKey: row.s3FileKey,
|
||||
s3FileBucket: row.s3FileBucket,
|
||||
docType: row.fileType ?? "unknown",
|
||||
source: row.source ?? null,
|
||||
s3UploadTimestamp: row.s3UploadTimestamp.toISOString(),
|
||||
uprn: row.uprn !== null ? String(row.uprn) : null,
|
||||
landlordPropertyId: row.landlordPropertyId,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { and, eq, inArray, sql } from "drizzle-orm";
|
|||
import { z } from "zod";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { syncMeasureApprovalsToHubSpot } from "@/app/lib/hubspot/dealSync";
|
||||
|
||||
async function getRequestingUserId(email: string): Promise<bigint | null> {
|
||||
const rows = await db
|
||||
|
|
@ -204,6 +205,33 @@ export async function POST(
|
|||
});
|
||||
}
|
||||
|
||||
const affectedDealIds = [...new Set(body.changes.map((c) => c.hubspotDealId))];
|
||||
for (const dealId of affectedDealIds) {
|
||||
const approvalRows = await db
|
||||
.select({
|
||||
measureName: dealMeasureApprovals.measureName,
|
||||
approvedByEmail: user.email,
|
||||
})
|
||||
.from(dealMeasureApprovals)
|
||||
.leftJoin(user, eq(user.id, dealMeasureApprovals.approvedBy))
|
||||
.where(
|
||||
and(
|
||||
eq(dealMeasureApprovals.hubspotDealId, dealId),
|
||||
eq(dealMeasureApprovals.isApproved, true),
|
||||
),
|
||||
);
|
||||
|
||||
void syncMeasureApprovalsToHubSpot({
|
||||
hubspotDealId: dealId,
|
||||
approvedMeasures: approvalRows.map((r) => ({
|
||||
measureName: r.measureName,
|
||||
approvedByEmail: r.approvedByEmail ?? "unknown",
|
||||
})),
|
||||
actedByEmail: session.user.email,
|
||||
actedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("POST /approvals error:", err);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { and, eq, desc } from "drizzle-orm";
|
|||
import { z } from "zod";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { syncRemovalRequestToHubSpot, getDealBatch } from "@/app/lib/hubspot/dealSync";
|
||||
|
||||
const WRITE_ROLES = ["creator", "admin", "write"] as const;
|
||||
|
||||
|
|
@ -70,6 +71,7 @@ export async function GET(
|
|||
.select({
|
||||
id: propertyRemovalRequests.id,
|
||||
hubspotDealId: propertyRemovalRequests.hubspotDealId,
|
||||
type: propertyRemovalRequests.type,
|
||||
reason: propertyRemovalRequests.reason,
|
||||
status: propertyRemovalRequests.status,
|
||||
requestedAt: propertyRemovalRequests.requestedAt,
|
||||
|
|
@ -110,6 +112,7 @@ export async function GET(
|
|||
return {
|
||||
id: String(row.id),
|
||||
hubspotDealId: row.hubspotDealId,
|
||||
type: row.type,
|
||||
reason: row.reason,
|
||||
status: row.status,
|
||||
requestedByEmail: row.requestedByEmail,
|
||||
|
|
@ -130,6 +133,7 @@ export async function GET(
|
|||
const postSchema = z.object({
|
||||
hubspotDealId: z.string().min(1),
|
||||
reason: z.string().min(1, "Reason is required"),
|
||||
type: z.enum(["removal", "re_addition"]).default("removal"),
|
||||
});
|
||||
|
||||
// POST /api/portfolio/[portfolioId]/removal-requests
|
||||
|
|
@ -170,40 +174,71 @@ export async function POST(
|
|||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
const { hubspotDealId, reason } = parsed.data;
|
||||
const { hubspotDealId, reason, type } = parsed.data;
|
||||
|
||||
try {
|
||||
// Block if there's already a pending request for this deal
|
||||
const existing = await db
|
||||
.select({ id: propertyRemovalRequests.id })
|
||||
// Fetch the most recent request for this deal to determine current state
|
||||
const [latest] = await db
|
||||
.select({
|
||||
status: propertyRemovalRequests.status,
|
||||
type: propertyRemovalRequests.type,
|
||||
})
|
||||
.from(propertyRemovalRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(propertyRemovalRequests.hubspotDealId, hubspotDealId),
|
||||
eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)),
|
||||
eq(propertyRemovalRequests.status, "pending"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(propertyRemovalRequests.requestedAt))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
if (latest?.status === "pending") {
|
||||
return NextResponse.json(
|
||||
{ error: "A pending removal request already exists for this property" },
|
||||
{ error: "A pending request already exists for this property" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "removal" && latest?.type === "removal" && latest?.status === "approved") {
|
||||
return NextResponse.json(
|
||||
{ error: "This property has already been removed" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "re_addition") {
|
||||
const isRemoved =
|
||||
(latest?.type === "removal" && latest?.status === "approved") ||
|
||||
(latest?.type === "re_addition" && latest?.status === "declined");
|
||||
if (!isRemoved) {
|
||||
return NextResponse.json(
|
||||
{ error: "This property is not currently removed" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(propertyRemovalRequests)
|
||||
.values({
|
||||
hubspotDealId,
|
||||
portfolioId: BigInt(portfolioId),
|
||||
reason,
|
||||
type,
|
||||
status: "pending",
|
||||
requestedBy: requestingUser.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
void syncRemovalRequestToHubSpot({
|
||||
hubspotDealId,
|
||||
type,
|
||||
status: "pending",
|
||||
reason,
|
||||
requestedByEmail: requestingUser.email,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, id: String(inserted.id) });
|
||||
} catch (err) {
|
||||
console.error("[removal-requests POST]", err);
|
||||
|
|
@ -258,8 +293,16 @@ export async function PATCH(
|
|||
|
||||
try {
|
||||
const target = await db
|
||||
.select({ id: propertyRemovalRequests.id, status: propertyRemovalRequests.status })
|
||||
.select({
|
||||
id: propertyRemovalRequests.id,
|
||||
type: propertyRemovalRequests.type,
|
||||
status: propertyRemovalRequests.status,
|
||||
hubspotDealId: propertyRemovalRequests.hubspotDealId,
|
||||
reason: propertyRemovalRequests.reason,
|
||||
requestedByEmail: user.email,
|
||||
})
|
||||
.from(propertyRemovalRequests)
|
||||
.innerJoin(user, eq(user.id, propertyRemovalRequests.requestedBy))
|
||||
.where(eq(propertyRemovalRequests.id, BigInt(requestId)))
|
||||
.limit(1);
|
||||
|
||||
|
|
@ -274,15 +317,54 @@ export async function PATCH(
|
|||
);
|
||||
}
|
||||
|
||||
const requestType = (target[0].type ?? "removal") as "removal" | "re_addition";
|
||||
const dealId = target[0].hubspotDealId;
|
||||
|
||||
// When approving a removal: fetch the current batch value and store it
|
||||
// When approving a re-addition: find the stored batch from the original removal
|
||||
let originalBatch: string | null = null;
|
||||
let batchValue: string | null | undefined = undefined;
|
||||
|
||||
if (action === "approved") {
|
||||
if (requestType === "removal") {
|
||||
originalBatch = await getDealBatch(dealId);
|
||||
} else if (requestType === "re_addition") {
|
||||
const [originalRemoval] = await db
|
||||
.select({ originalBatch: propertyRemovalRequests.originalBatch })
|
||||
.from(propertyRemovalRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(propertyRemovalRequests.hubspotDealId, dealId),
|
||||
eq(propertyRemovalRequests.type, "removal"),
|
||||
eq(propertyRemovalRequests.status, "approved"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(propertyRemovalRequests.reviewedAt))
|
||||
.limit(1);
|
||||
batchValue = originalRemoval?.originalBatch ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(propertyRemovalRequests)
|
||||
.set({
|
||||
status: action,
|
||||
reviewedBy: requestingUser.id,
|
||||
reviewedAt: new Date(),
|
||||
...(requestType === "removal" && action === "approved" ? { originalBatch } : {}),
|
||||
})
|
||||
.where(eq(propertyRemovalRequests.id, BigInt(requestId)));
|
||||
|
||||
void syncRemovalRequestToHubSpot({
|
||||
hubspotDealId: dealId,
|
||||
type: requestType,
|
||||
status: action,
|
||||
reason: target[0].reason,
|
||||
requestedByEmail: target[0].requestedByEmail,
|
||||
reviewedByEmail: requestingUser.email,
|
||||
batchValue,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[removal-requests PATCH]", err);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { eq, inArray } from "drizzle-orm";
|
|||
import { z } from "zod";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { syncContractorDocUploadToHubSpot } from "@/app/lib/hubspot/dealSync";
|
||||
|
||||
// POST — record a contractor install document in uploaded_files (fileType optional — can be classified later)
|
||||
export async function POST(req: NextRequest) {
|
||||
|
|
@ -56,6 +57,12 @@ export async function POST(req: NextRequest) {
|
|||
})
|
||||
.returning({ id: uploadedFiles.id });
|
||||
|
||||
if (body.hubspotDealId) {
|
||||
void syncContractorDocUploadToHubSpot({
|
||||
hubspotDealId: body.hubspotDealId,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ id: inserted.id.toString() }, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error("POST /upload/contractor-install error:", err);
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export default function AddNew({
|
|||
className="
|
||||
inline-flex items-center gap-1 px-4 py-2 rounded-md
|
||||
bg-gray-50 text-gray-900 hover:bg-midblue hover:text-gray-100
|
||||
transition-colors text-sm font-medium
|
||||
transition-colors text-xs font-medium
|
||||
"
|
||||
>
|
||||
<DocumentPlusIcon className="h-4 w-4 mr-2" />
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export default function YourProjectsDropdown({
|
|||
className="
|
||||
inline-flex items-center gap-1 px-4 py-2 rounded-md
|
||||
bg-gray-50 text-gray-900 hover:bg-midblue hover:text-gray-100
|
||||
transition-colors text-sm font-medium
|
||||
transition-colors text-xs font-medium
|
||||
"
|
||||
>
|
||||
<RocketLaunchIcon className="h-4 w-4 mr-2" />
|
||||
|
|
|
|||
1
src/app/db/migrations/0180_ambitious_jane_foster.sql
Normal file
1
src/app/db/migrations/0180_ambitious_jane_foster.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "property_removal_requests" ADD COLUMN "type" text DEFAULT 'removal' NOT NULL;
|
||||
1
src/app/db/migrations/0180_removal_request_type.sql
Normal file
1
src/app/db/migrations/0180_removal_request_type.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "property_removal_requests" ADD COLUMN IF NOT EXISTS "type" text NOT NULL DEFAULT 'removal';
|
||||
5
src/app/db/migrations/0181_concerned_electro.sql
Normal file
5
src/app/db/migrations/0181_concerned_electro.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE "hubspot_deal_data" ADD COLUMN "batch" text;--> statement-breakpoint
|
||||
ALTER TABLE "hubspot_deal_data" ADD COLUMN "block_reference" text;--> statement-breakpoint
|
||||
ALTER TABLE "hubspot_deal_data" ADD COLUMN "epc_prn" text;--> statement-breakpoint
|
||||
ALTER TABLE "hubspot_deal_data" ADD COLUMN "potential_post_sap_score_dropdown" text;--> statement-breakpoint
|
||||
ALTER TABLE "property_removal_requests" ADD COLUMN "original_batch" text;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "property_removal_requests" ADD COLUMN IF NOT EXISTS "original_batch" text;
|
||||
4
src/app/db/migrations/0182_messy_calypso.sql
Normal file
4
src/app/db/migrations/0182_messy_calypso.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE "hubspot_deal_data" ADD COLUMN "ei_score" text;--> statement-breakpoint
|
||||
ALTER TABLE "hubspot_deal_data" ADD COLUMN "ei_score__potential_" text;--> statement-breakpoint
|
||||
ALTER TABLE "hubspot_deal_data" ADD COLUMN "epc_sap_score" text;--> statement-breakpoint
|
||||
ALTER TABLE "hubspot_deal_data" ADD COLUMN "epc_sap_score__potential_" text;
|
||||
6917
src/app/db/migrations/meta/0180_snapshot.json
Normal file
6917
src/app/db/migrations/meta/0180_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6947
src/app/db/migrations/meta/0181_snapshot.json
Normal file
6947
src/app/db/migrations/meta/0181_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6971
src/app/db/migrations/meta/0182_snapshot.json
Normal file
6971
src/app/db/migrations/meta/0182_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1254,6 +1254,34 @@
|
|||
"when": 1776458454019,
|
||||
"tag": "0178_parched_midnight",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 179,
|
||||
"version": "7",
|
||||
"when": 1776459924335,
|
||||
"tag": "0179_mighty_cardiac",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 180,
|
||||
"version": "7",
|
||||
"when": 1776683523665,
|
||||
"tag": "0180_ambitious_jane_foster",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 181,
|
||||
"version": "7",
|
||||
"when": 1776697748194,
|
||||
"tag": "0181_concerned_electro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 182,
|
||||
"version": "7",
|
||||
"when": 1776699608018,
|
||||
"tag": "0182_messy_calypso",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -46,6 +46,14 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
|
|||
coordination_comments: text("coordination_comments"),
|
||||
surveyor: text("surveyor"),
|
||||
damnpMouldAndRepairComments: text("damp_mould_and_repairs_comments"),
|
||||
batch: text("batch"),
|
||||
blockReference: text("block_reference"),
|
||||
epcPrn: text("epc_prn"),
|
||||
potentialPostSapScoreDropdown: text("potential_post_sap_score_dropdown"),
|
||||
eiScore: text("ei_score"),
|
||||
eiScorePotential: text("ei_score__potential_"),
|
||||
epcSapScore: text("epc_sap_score"),
|
||||
epcSapScorePotential: text("epc_sap_score__potential_"),
|
||||
confirmedSurveyDate: timestamp("confirmed_survey_date", { precision: 6, withTimezone: true }),
|
||||
confirmedSurveyTime: text("confirmed_survey_time"),
|
||||
surveyedDate: timestamp("surveyed_date", { precision: 6, withTimezone: true }),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export const propertyRemovalRequests = pgTable(
|
|||
.notNull()
|
||||
.references(() => portfolio.id),
|
||||
reason: text("reason").notNull(),
|
||||
// 'removal' | 're_addition'
|
||||
type: text("type").notNull().default("removal"),
|
||||
// 'pending' | 'approved' | 'declined'
|
||||
status: text("status").notNull().default("pending"),
|
||||
requestedBy: bigint("requested_by", { mode: "bigint" })
|
||||
|
|
@ -33,6 +35,7 @@ export const propertyRemovalRequests = pgTable(
|
|||
() => user.id,
|
||||
),
|
||||
reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
|
||||
originalBatch: text("original_batch"),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_removal_requests_deal_id").on(table.hubspotDealId),
|
||||
|
|
|
|||
14
src/app/lib/hubspot/client.ts
Normal file
14
src/app/lib/hubspot/client.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Client } from "@hubspot/api-client";
|
||||
|
||||
let _client: Client | null = null;
|
||||
|
||||
export function getHubSpotClient(): Client {
|
||||
if (!_client) {
|
||||
const accessToken = process.env.HUBSPOT_API_KEY;
|
||||
if (!accessToken) {
|
||||
throw new Error("HUBSPOT_API_KEY environment variable is not set");
|
||||
}
|
||||
_client = new Client({ accessToken });
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
157
src/app/lib/hubspot/dealSync.ts
Normal file
157
src/app/lib/hubspot/dealSync.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { getHubSpotClient } from "./client";
|
||||
|
||||
export async function getDealBatch(hubspotDealId: string): Promise<string | null> {
|
||||
try {
|
||||
const client = getHubSpotClient();
|
||||
const deal = await client.crm.deals.basicApi.getById(hubspotDealId, ["batch"]);
|
||||
return (deal.properties["batch"] as string | null | undefined) ?? null;
|
||||
} catch (err) {
|
||||
console.error("[HubSpot] getDealBatch failed", { dealId: hubspotDealId, error: err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncRemovalRequestToHubSpot(params: {
|
||||
hubspotDealId: string;
|
||||
type: "removal" | "re_addition";
|
||||
status: "pending" | "approved" | "declined";
|
||||
reason: string;
|
||||
requestedByEmail: string;
|
||||
reviewedByEmail?: string | null;
|
||||
batchValue?: string | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const client = getHubSpotClient();
|
||||
|
||||
let statusLabel: string;
|
||||
if (params.type === "removal") {
|
||||
statusLabel =
|
||||
params.status === "pending"
|
||||
? "Removal Request In Progress"
|
||||
: params.status === "approved"
|
||||
? "Removed From Project"
|
||||
: "";
|
||||
} else {
|
||||
statusLabel =
|
||||
params.status === "pending"
|
||||
? "Re-addition Requested"
|
||||
: params.status === "approved"
|
||||
? ""
|
||||
: "Removed From Project";
|
||||
}
|
||||
|
||||
let log = `Requested by: ${params.requestedByEmail}\nReason: ${params.reason}`;
|
||||
if (params.reviewedByEmail) {
|
||||
if (params.type === "re_addition" && params.status === "approved") {
|
||||
log += `\nRe-added to project by: ${params.reviewedByEmail}`;
|
||||
} else {
|
||||
const action = params.status === "approved" ? "Approved" : "Declined";
|
||||
log += `\n${action} by: ${params.reviewedByEmail}`;
|
||||
}
|
||||
}
|
||||
|
||||
const properties: Record<string, string> = {
|
||||
project_removal_status: statusLabel,
|
||||
project_removal_request_log: log,
|
||||
};
|
||||
|
||||
// Set batch when approving removal; restore it when approving re-addition (if we have a value)
|
||||
if (params.type === "removal" && params.status === "approved") {
|
||||
properties["batch"] = "Removed from Program";
|
||||
} else if (params.type === "re_addition" && params.status === "approved" && params.batchValue != null) {
|
||||
properties["batch"] = params.batchValue;
|
||||
}
|
||||
|
||||
await client.crm.deals.basicApi.update(params.hubspotDealId, { properties });
|
||||
} catch (err) {
|
||||
console.error("[HubSpot] syncRemovalRequestToHubSpot failed", {
|
||||
dealId: params.hubspotDealId,
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncContractorDocUploadToHubSpot(params: {
|
||||
hubspotDealId: string;
|
||||
}): Promise<void> {
|
||||
const maxAttempts = 3;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const client = getHubSpotClient();
|
||||
await client.crm.deals.basicApi.update(params.hubspotDealId, {
|
||||
properties: {
|
||||
contractor_document_upload_log: "Documents available - uploaded by contractor",
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
const isReset =
|
||||
err instanceof Error &&
|
||||
"code" in err &&
|
||||
(err as NodeJS.ErrnoException).code === "ECONNRESET";
|
||||
if (isReset && attempt < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
|
||||
continue;
|
||||
}
|
||||
console.error("[HubSpot] syncContractorDocUploadToHubSpot failed", {
|
||||
dealId: params.hubspotDealId,
|
||||
attempt,
|
||||
error: err,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncMeasureApprovalsToHubSpot(params: {
|
||||
hubspotDealId: string;
|
||||
approvedMeasures: Array<{ measureName: string; approvedByEmail: string }>;
|
||||
actedByEmail: string;
|
||||
actedAt: Date;
|
||||
}): Promise<void> {
|
||||
const dateStr = params.actedAt.toLocaleString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: "UTC",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
|
||||
const log =
|
||||
params.approvedMeasures.length === 0
|
||||
? `All measures unapproved by ${params.actedByEmail} on ${dateStr}`
|
||||
: [
|
||||
`Approved measures (updated by ${params.actedByEmail} on ${dateStr}):`,
|
||||
...params.approvedMeasures.map((m) => `- ${m.measureName} (approved by ${m.approvedByEmail})`),
|
||||
].join("\r\n");
|
||||
|
||||
const maxAttempts = 3;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const client = getHubSpotClient();
|
||||
await client.crm.deals.basicApi.update(params.hubspotDealId, {
|
||||
properties: {
|
||||
client_measures_approval_log: log,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
const isReset =
|
||||
err instanceof Error &&
|
||||
"code" in err &&
|
||||
(err as NodeJS.ErrnoException).code === "ECONNRESET";
|
||||
if (isReset && attempt < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
|
||||
continue;
|
||||
}
|
||||
console.error("[HubSpot] syncMeasureApprovalsToHubSpot failed", {
|
||||
dealId: params.hubspotDealId,
|
||||
attempt,
|
||||
error: err,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -73,7 +73,7 @@ export function BreakdownChart({
|
|||
}
|
||||
|
||||
return rows;
|
||||
}, [selected, epcBands, ageBands, propertyTypes, scenarioEpcBands, friendlyKeys.actual, friendlyKeys.estimated, friendlyKeys.scenario]);
|
||||
}, [selected, epcBands, ageBands, propertyTypes, scenarioEpcBands]);
|
||||
|
||||
const categories =
|
||||
selected === "epc"
|
||||
|
|
|
|||
|
|
@ -27,9 +27,7 @@ async function getPortfolioUsers(portfolioId: string): Promise<Collaborator[]> {
|
|||
const users = Array.isArray(json) ? json : json.users; // support both shapes
|
||||
// Guard + shape to Collaborator[]
|
||||
return Array.isArray(users)
|
||||
? users
|
||||
.filter((u: any) => u.role !== "creator") // 👈 filter out creator
|
||||
.map((u: any) => ({
|
||||
? users.map((u: any) => ({
|
||||
portfolioUserId: String(u.portfolioUserId),
|
||||
userId: String(u.userId),
|
||||
name: u.name ?? null,
|
||||
|
|
@ -251,12 +249,20 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
|
|||
<TableCell>{c.name || "—"}</TableCell>
|
||||
<TableCell>{c.email}</TableCell>
|
||||
<TableCell className="min-w-40">
|
||||
<RoleDropdown value={c.role} onChange={(r) => onChangeRole(c.portfolioUserId, r)} />
|
||||
{c.role === "creator" || c.role === "admin" ? (
|
||||
<span className="text-xs font-medium text-gray-500 px-2 py-1 bg-gray-100 rounded-md capitalize">
|
||||
{c.role}
|
||||
</span>
|
||||
) : (
|
||||
<RoleDropdown value={c.role as "read" | "write"} onChange={(r) => onChangeRole(c.portfolioUserId, r)} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.portfolioUserId)}>
|
||||
Remove
|
||||
</Button>
|
||||
{c.role !== "creator" && (
|
||||
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.portfolioUserId)}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export type Collaborator = {
|
|||
userId: string;
|
||||
name?: string | null;
|
||||
email: string;
|
||||
role: Role;
|
||||
role: Role | "creator" | "admin";
|
||||
};
|
||||
|
||||
// Small role dropdown using shadcn Select
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { UsersPermissionsCard } from "../UsersPermissionsCard";
|
||||
import { CapabilitiesCard } from "../CapabilitiesCard";
|
||||
|
||||
export default async function UserAccessPage(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
|
|
@ -8,6 +9,7 @@ export default async function UserAccessPage(props: {
|
|||
return (
|
||||
<div>
|
||||
<UsersPermissionsCard portfolioId={slug} />
|
||||
<CapabilitiesCard portfolioId={slug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
TabsTrigger,
|
||||
} from "@/app/shadcn_components/ui/tabs";
|
||||
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
|
||||
import { BarChart2, Table2, FolderOpen, Wrench } from "lucide-react";
|
||||
import { BarChart2, Table2, FolderOpen, Wrench, AlertTriangle } from "lucide-react";
|
||||
import DrillDownTable from "./DrillDownTable";
|
||||
import PropertyTable from "./PropertyTable";
|
||||
import DocumentTable from "./DocumentTable";
|
||||
|
|
@ -24,6 +24,7 @@ import type {
|
|||
ClassifiedDeal,
|
||||
DocumentDrawerState,
|
||||
DocStatusMap,
|
||||
RemovalStatusByDeal,
|
||||
} from "./types";
|
||||
|
||||
export default function LiveTracker({
|
||||
|
|
@ -33,6 +34,7 @@ export default function LiveTracker({
|
|||
docStatusMap,
|
||||
userCapability,
|
||||
approvalsByDeal,
|
||||
removalStatusByDeal,
|
||||
portfolioId,
|
||||
userRole,
|
||||
userEmail,
|
||||
|
|
@ -49,6 +51,12 @@ export default function LiveTracker({
|
|||
(p) => p.projectCode === currentProjectCode,
|
||||
);
|
||||
|
||||
// ── Pending removal count for current project ────────────────────────
|
||||
const pendingRemovalCount = (currentProject?.allDeals ?? []).filter((d) => {
|
||||
const state = d.dealId ? removalStatusByDeal[d.dealId] : undefined;
|
||||
return state === "pending_removal" || state === "pending_re_addition";
|
||||
}).length;
|
||||
|
||||
// ── Drill-down table modal (used by AnalyticsView) ───────────────────
|
||||
const [openTable, setOpenTable] = useState<TableModal | null>(null);
|
||||
|
||||
|
|
@ -117,6 +125,14 @@ export default function LiveTracker({
|
|||
>
|
||||
<Table2 className="h-3.5 w-3.5" />
|
||||
Properties
|
||||
<span
|
||||
className={`ml-1 min-w-[18px] h-[18px] px-1 rounded-full bg-amber-500 text-white text-[10px] font-bold flex items-center justify-center leading-none transition-opacity ${
|
||||
pendingRemovalCount > 0 ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
aria-hidden={pendingRemovalCount === 0}
|
||||
>
|
||||
{pendingRemovalCount || ""}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="documents"
|
||||
|
|
@ -180,11 +196,19 @@ export default function LiveTracker({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex items-center gap-2.5 px-4 py-3 rounded-xl border border-amber-200 bg-amber-50 text-amber-800 text-sm ${pendingRemovalCount === 0 ? "hidden" : ""}`}>
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
|
||||
<span>
|
||||
<span className="font-semibold">{pendingRemovalCount}</span>{" "}
|
||||
{pendingRemovalCount === 1 ? "property has" : "properties have"} an outstanding removal request
|
||||
</span>
|
||||
</div>
|
||||
<PropertyTable
|
||||
data={currentProject?.allDeals ?? []}
|
||||
onOpenDrawer={handleOpenDrawer}
|
||||
onOpenDetail={setDetailDeal}
|
||||
docStatusMap={docStatusMap}
|
||||
removalStatusByDeal={removalStatusByDeal}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
@ -217,6 +241,8 @@ export default function LiveTracker({
|
|||
data={currentProject?.allDeals ?? []}
|
||||
onOpenDrawer={handleOpenDrawer}
|
||||
docStatusMap={docStatusMap}
|
||||
portfolioId={portfolioId}
|
||||
userCapability={userCapability}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2 } from "lucide-react";
|
||||
import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
|
|
@ -43,7 +43,7 @@ function RemovalRequestSection({
|
|||
userCapability: PortfolioCapabilityType;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogType, setDialogType] = useState<"removal" | "re_addition" | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [reviewing, setReviewing] = useState(false);
|
||||
|
|
@ -64,26 +64,45 @@ function RemovalRequestSection({
|
|||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const pendingRequest = data?.requests?.find((r) => r.status === "pending") ?? null;
|
||||
const latestResolvedRequest = data?.requests?.find((r) => r.status !== "pending") ?? null;
|
||||
const latest = data?.requests?.[0] ?? null;
|
||||
|
||||
// Derive effective state from the most recent request
|
||||
type EffectiveState = "active" | "pending_removal" | "removed" | "pending_re_addition";
|
||||
const effectiveState: EffectiveState = (() => {
|
||||
if (!latest) return "active";
|
||||
if (latest.status === "pending") {
|
||||
return latest.type === "re_addition" ? "pending_re_addition" : "pending_removal";
|
||||
}
|
||||
if (latest.type === "removal" && latest.status === "approved") return "removed";
|
||||
if (latest.type === "re_addition" && latest.status === "declined") return "removed";
|
||||
return "active";
|
||||
})();
|
||||
|
||||
const pendingRequest = latest?.status === "pending" ? latest : null;
|
||||
const latestResolvedRequest = latest?.status !== "pending" ? latest : null;
|
||||
|
||||
function closeDialog() {
|
||||
setDialogType(null);
|
||||
setReason("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!reason.trim()) return;
|
||||
if (!reason.trim() || !dialogType) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ hubspotDealId: dealId, reason: reason.trim() }),
|
||||
body: JSON.stringify({ hubspotDealId: dealId, reason: reason.trim(), type: dialogType }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
setError(json.error ?? "Failed to submit request");
|
||||
return;
|
||||
}
|
||||
setDialogOpen(false);
|
||||
setReason("");
|
||||
closeDialog();
|
||||
queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
|
|
@ -110,6 +129,13 @@ function RemovalRequestSection({
|
|||
}
|
||||
}
|
||||
|
||||
function resolvedLabel(req: RemovalRequest): string {
|
||||
if (req.type === "re_addition") {
|
||||
return req.status === "approved" ? "Re-addition Approved" : "Re-addition Declined";
|
||||
}
|
||||
return req.status === "approved" ? "Removal Approved" : "Removal Declined";
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-xs text-gray-400 py-2">Loading…</p>;
|
||||
}
|
||||
|
|
@ -125,7 +151,7 @@ function RemovalRequestSection({
|
|||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3.5 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-amber-700 bg-amber-100 px-2 py-0.5 rounded-full border border-amber-200">
|
||||
Pending Removal Request
|
||||
{pendingRequest.type === "re_addition" ? "Pending Re-addition Request" : "Pending Removal Request"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 leading-relaxed">{pendingRequest.reason}</p>
|
||||
|
|
@ -134,7 +160,6 @@ function RemovalRequestSection({
|
|||
{" · "}
|
||||
{formatDateTime(pendingRequest.requestedAt)}
|
||||
</p>
|
||||
{/* Approver actions */}
|
||||
{isApprover && (
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
|
|
@ -142,7 +167,7 @@ function RemovalRequestSection({
|
|||
disabled={reviewing}
|
||||
className="flex-1 text-xs font-medium px-3 py-1.5 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Approve Removal
|
||||
{pendingRequest.type === "re_addition" ? "Approve Re-addition" : "Approve Removal"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReview(pendingRequest.id, "declined")}
|
||||
|
|
@ -157,7 +182,7 @@ function RemovalRequestSection({
|
|||
)}
|
||||
|
||||
{/* Most recent resolved request */}
|
||||
{!pendingRequest && latestResolvedRequest && (
|
||||
{latestResolvedRequest && (
|
||||
<div className={`rounded-xl border p-3.5 space-y-1.5 ${
|
||||
latestResolvedRequest.status === "approved"
|
||||
? "border-emerald-200 bg-emerald-50"
|
||||
|
|
@ -169,7 +194,7 @@ function RemovalRequestSection({
|
|||
? "text-emerald-700 bg-emerald-100 border-emerald-200"
|
||||
: "text-gray-600 bg-gray-100 border-gray-200"
|
||||
}`}>
|
||||
{latestResolvedRequest.status === "approved" ? "Removal Approved" : "Removal Declined"}
|
||||
{resolvedLabel(latestResolvedRequest)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 leading-relaxed">{latestResolvedRequest.reason}</p>
|
||||
|
|
@ -188,50 +213,84 @@ function RemovalRequestSection({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Request button — only shown when no pending request exists */}
|
||||
{/* Action buttons — shown when no pending request */}
|
||||
{!pendingRequest && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block w-full">
|
||||
<button
|
||||
onClick={() => { if (canRequest) setDialogOpen(true); }}
|
||||
disabled={!canRequest}
|
||||
className={`w-full flex items-center justify-center gap-2 text-xs font-medium px-3 py-2 rounded-lg border transition-colors ${
|
||||
canRequest
|
||||
? "border-red-200 text-red-600 hover:bg-red-50 bg-white"
|
||||
: "border-gray-100 text-gray-300 bg-gray-50 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Request Removal from Project
|
||||
</button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!canRequest && (
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Not available with read-only permissions
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<>
|
||||
{effectiveState === "active" && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block w-full">
|
||||
<button
|
||||
onClick={() => { if (canRequest) setDialogType("removal"); }}
|
||||
disabled={!canRequest}
|
||||
className={`w-full flex items-center justify-center gap-2 text-xs font-medium px-3 py-2 rounded-lg border transition-colors ${
|
||||
canRequest
|
||||
? "border-red-200 text-red-600 hover:bg-red-50 bg-white"
|
||||
: "border-gray-100 text-gray-300 bg-gray-50 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Request Removal from Project
|
||||
</button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!canRequest && (
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Not available with read-only permissions
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{effectiveState === "removed" && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block w-full">
|
||||
<button
|
||||
onClick={() => { if (canRequest) setDialogType("re_addition"); }}
|
||||
disabled={!canRequest}
|
||||
className={`w-full flex items-center justify-center gap-2 text-xs font-medium px-3 py-2 rounded-lg border transition-colors ${
|
||||
canRequest
|
||||
? "border-blue-200 text-blue-600 hover:bg-blue-50 bg-white"
|
||||
: "border-gray-100 text-gray-300 bg-gray-50 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Request Re-addition to Project
|
||||
</button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!canRequest && (
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Not available with read-only permissions
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reason dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={(v) => { if (!v) { setDialogOpen(false); setReason(""); setError(null); } }}>
|
||||
{/* Shared dialog for removal and re-addition requests */}
|
||||
<Dialog open={dialogType !== null} onOpenChange={(v) => { if (!v) closeDialog(); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold text-gray-800">
|
||||
Request Removal from Project
|
||||
{dialogType === "re_addition" ? "Request Re-addition to Project" : "Request Removal from Project"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
Please provide a reason why this property should be removed from the project. This will be recorded for audit purposes.
|
||||
{dialogType === "re_addition"
|
||||
? "Please provide a reason why this property should be re-added to the project. This will be recorded for audit purposes."
|
||||
: "Please provide a reason why this property should be removed from the project. This will be recorded for audit purposes."}
|
||||
</p>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-200 focus:border-red-300 resize-none"
|
||||
placeholder="Reason for removal…"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300 resize-none"
|
||||
placeholder={dialogType === "re_addition" ? "Reason for re-addition…" : "Reason for removal…"}
|
||||
rows={4}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
|
|
@ -240,7 +299,7 @@ function RemovalRequestSection({
|
|||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<button
|
||||
onClick={() => { setDialogOpen(false); setReason(""); setError(null); }}
|
||||
onClick={closeDialog}
|
||||
className="text-xs font-medium px-4 py-2 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
|
|
@ -248,7 +307,11 @@ function RemovalRequestSection({
|
|||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!reason.trim() || submitting}
|
||||
className="text-xs font-medium px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||
className={`text-xs font-medium px-4 py-2 rounded-lg text-white disabled:opacity-50 transition-colors ${
|
||||
dialogType === "re_addition"
|
||||
? "bg-blue-600 hover:bg-blue-700"
|
||||
: "bg-red-600 hover:bg-red-700"
|
||||
}`}
|
||||
>
|
||||
{submitting ? "Submitting…" : "Submit Request"}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ import {
|
|||
import { Search, SlidersHorizontal, ChevronLeft, ChevronRight, Download } from "lucide-react";
|
||||
import { createPropertyTableColumns } from "./PropertyTableColumns";
|
||||
import { STAGE_ORDER } from "./types";
|
||||
import type { ClassifiedDeal, DocStatusMap } from "./types";
|
||||
import type { ClassifiedDeal, DocStatusMap, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
|
||||
|
||||
// Human-readable labels for toggle dropdown
|
||||
const COLUMN_LABELS: Record<string, string> = {
|
||||
|
|
@ -58,6 +58,7 @@ const COLUMN_LABELS: Record<string, string> = {
|
|||
};
|
||||
|
||||
type DocFilter = "all" | "has_docs" | "incomplete" | "none";
|
||||
type RemovalFilter = "all" | "pending_removal" | "removed" | "pending_re_addition";
|
||||
|
||||
interface PropertyTableProps {
|
||||
data: ClassifiedDeal[];
|
||||
|
|
@ -65,6 +66,7 @@ interface PropertyTableProps {
|
|||
onOpenDetail?: (deal: ClassifiedDeal) => void;
|
||||
showDocuments?: boolean;
|
||||
docStatusMap?: DocStatusMap;
|
||||
removalStatusByDeal?: RemovalStatusByDeal;
|
||||
}
|
||||
|
||||
const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [
|
||||
|
|
@ -96,10 +98,11 @@ function escapeCell(value: unknown): string {
|
|||
: str;
|
||||
}
|
||||
|
||||
export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDocuments = false, docStatusMap = {} }: PropertyTableProps) {
|
||||
export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDocuments = false, docStatusMap = {}, removalStatusByDeal = {} }: PropertyTableProps) {
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [stageFilter, setStageFilter] = useState<string>("all");
|
||||
const [docFilter, setDocFilter] = useState<DocFilter>("all");
|
||||
const [removalFilter, setRemovalFilter] = useState<RemovalFilter>("all");
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
|
|
@ -117,7 +120,7 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
|
|||
fullLodgementDate: false,
|
||||
});
|
||||
|
||||
// Pre-filter by stage and doc status before TanStack gets it
|
||||
// Pre-filter by stage, doc status, and removal status before TanStack gets it
|
||||
const filteredData = useMemo(() => {
|
||||
let result = data;
|
||||
if (stageFilter !== "all") {
|
||||
|
|
@ -132,12 +135,18 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
|
|||
return true;
|
||||
});
|
||||
}
|
||||
if (removalFilter !== "all") {
|
||||
result = result.filter((d) => {
|
||||
const state: EffectiveRemovalState = (d.dealId ? removalStatusByDeal[d.dealId] : undefined) ?? "none";
|
||||
return state === removalFilter;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [data, stageFilter, docFilter, docStatusMap]);
|
||||
}, [data, stageFilter, docFilter, docStatusMap, removalFilter, removalStatusByDeal]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap, onOpenDetail),
|
||||
[onOpenDrawer, showDocuments, docStatusMap, onOpenDetail]
|
||||
() => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap, onOpenDetail, removalStatusByDeal),
|
||||
[onOpenDrawer, showDocuments, docStatusMap, onOpenDetail, removalStatusByDeal]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
|
|
@ -227,6 +236,31 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Removal status filter */}
|
||||
<Select
|
||||
value={removalFilter}
|
||||
onValueChange={(v) => {
|
||||
setRemovalFilter(v as RemovalFilter);
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[180px] text-sm border-gray-200 shrink-0">
|
||||
{removalFilter === "all"
|
||||
? "All properties"
|
||||
: removalFilter === "pending_removal"
|
||||
? "Pending removal"
|
||||
: removalFilter === "removed"
|
||||
? "Removed"
|
||||
: "Pending re-addition"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All properties</SelectItem>
|
||||
<SelectItem value="pending_removal">Pending removal</SelectItem>
|
||||
<SelectItem value="removed">Removed</SelectItem>
|
||||
<SelectItem value="pending_re_addition">Pending re-addition</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Docs filter */}
|
||||
{showDocuments && (
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
|
||||
import { STAGE_COLORS } from "./types";
|
||||
import type { ClassifiedDeal, DisplayStage, DocStatusMap } from "./types";
|
||||
import type { ClassifiedDeal, DisplayStage, DocStatusMap, RemovalStatusByDeal } from "./types";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Stage badge — consistent pill rendering
|
||||
|
|
@ -49,6 +49,7 @@ export function createPropertyTableColumns(
|
|||
showDocuments: boolean = false,
|
||||
docStatusMap: DocStatusMap = {},
|
||||
onOpenDetail?: (deal: ClassifiedDeal) => void,
|
||||
removalStatusByDeal: RemovalStatusByDeal = {},
|
||||
): ColumnDef<ClassifiedDeal>[] {
|
||||
const columns: ColumnDef<ClassifiedDeal>[] = [
|
||||
// ── Address ──────────────────────────────────────────────────────────
|
||||
|
|
@ -56,22 +57,29 @@ export function createPropertyTableColumns(
|
|||
accessorKey: "dealname",
|
||||
id: "dealname",
|
||||
header: ({ column }) => <SortableHeader label="Address" column={column as any} />,
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[220px]">
|
||||
{onOpenDetail ? (
|
||||
<button
|
||||
onClick={() => onOpenDetail(row.original)}
|
||||
className="text-sm font-medium text-brandblue hover:text-brandmidblue hover:underline underline-offset-2 leading-tight text-left truncate w-full transition-colors"
|
||||
>
|
||||
{row.original.dealname ?? "—"}
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-gray-900 leading-tight truncate">
|
||||
{row.original.dealname ?? "—"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const removalState = row.original.dealId ? removalStatusByDeal[row.original.dealId] : undefined;
|
||||
const hasPending = removalState === "pending_removal" || removalState === "pending_re_addition";
|
||||
return (
|
||||
<div className="max-w-[220px] flex items-center gap-1.5">
|
||||
{hasPending && (
|
||||
<span className="shrink-0 w-2 h-2 rounded-full bg-amber-400" title="Outstanding removal request" />
|
||||
)}
|
||||
{onOpenDetail ? (
|
||||
<button
|
||||
onClick={() => onOpenDetail(row.original)}
|
||||
className="text-sm font-medium text-brandblue hover:text-brandmidblue hover:underline underline-offset-2 leading-tight text-left truncate transition-colors"
|
||||
>
|
||||
{row.original.dealname ?? "—"}
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-gray-900 leading-tight truncate">
|
||||
{row.original.dealname ?? "—"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { redirect } from "next/navigation";
|
||||
import { eq, inArray, and } from "drizzle-orm";
|
||||
import { eq, inArray, and, desc } from "drizzle-orm";
|
||||
import LiveTracker from "./LiveTracker";
|
||||
import { computeLiveTrackerData } from "./transforms";
|
||||
import { db } from "@/app/db/db";
|
||||
|
|
@ -11,8 +11,9 @@ import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
|
|||
import { organisation } from "@/app/db/schema/organisation";
|
||||
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
|
||||
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
|
||||
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
|
||||
import { user as userTable } from "@/app/db/schema/users";
|
||||
import type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal } from "./types";
|
||||
import type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
|
||||
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
|
||||
|
|
@ -195,6 +196,34 @@ export default async function LiveReportingPage(props: {
|
|||
}
|
||||
}
|
||||
|
||||
// Compute effective removal state per deal
|
||||
const removalStatusByDeal: RemovalStatusByDeal = {};
|
||||
const removalRows = await db
|
||||
.select({
|
||||
hubspotDealId: propertyRemovalRequests.hubspotDealId,
|
||||
type: propertyRemovalRequests.type,
|
||||
status: propertyRemovalRequests.status,
|
||||
})
|
||||
.from(propertyRemovalRequests)
|
||||
.where(eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)))
|
||||
.orderBy(desc(propertyRemovalRequests.requestedAt));
|
||||
|
||||
// Keep only the most recent row per deal, then derive effective state
|
||||
const seenDeals = new Set<string>();
|
||||
for (const row of removalRows) {
|
||||
if (seenDeals.has(row.hubspotDealId)) continue;
|
||||
seenDeals.add(row.hubspotDealId);
|
||||
let state: EffectiveRemovalState = "none";
|
||||
if (row.status === "pending") {
|
||||
state = row.type === "re_addition" ? "pending_re_addition" : "pending_removal";
|
||||
} else if (row.type === "removal" && row.status === "approved") {
|
||||
state = "removed";
|
||||
} else if (row.type === "re_addition" && row.status === "declined") {
|
||||
state = "removed";
|
||||
}
|
||||
if (state !== "none") removalStatusByDeal[row.hubspotDealId] = state;
|
||||
}
|
||||
|
||||
// Fetch survey document status for all properties
|
||||
const uprnList = deals
|
||||
.map((d) => d.uprn)
|
||||
|
|
@ -273,6 +302,7 @@ export default async function LiveReportingPage(props: {
|
|||
docStatusMap={docStatusMap}
|
||||
userCapability={userCapability}
|
||||
approvalsByDeal={approvalsByDeal}
|
||||
removalStatusByDeal={removalStatusByDeal}
|
||||
portfolioId={portfolioId}
|
||||
userRole={userRole}
|
||||
userEmail={userEmail ?? ""}
|
||||
|
|
|
|||
|
|
@ -375,7 +375,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] {
|
|||
// -----------------------------------------------------------------------
|
||||
export function computeLiveTrackerData(
|
||||
rawDeals: HubspotDeal[]
|
||||
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "portfolioId"> {
|
||||
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "removalStatusByDeal" | "portfolioId" | "userRole" | "userEmail"> {
|
||||
// Classify all deals (add displayStage field)
|
||||
const classified = classifyDeals(rawDeals);
|
||||
|
||||
|
|
|
|||
|
|
@ -169,12 +169,16 @@ export type PortfolioCapabilityType = ("approver" | "contractor")[];
|
|||
// Approved measure names per HubSpot deal ID
|
||||
export type ApprovalsByDeal = Record<string, string[]>;
|
||||
|
||||
export type EffectiveRemovalState = "none" | "pending_removal" | "removed" | "pending_re_addition";
|
||||
export type RemovalStatusByDeal = Record<string, EffectiveRemovalState>;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Removal request record returned by the API
|
||||
// -----------------------------------------------------------------------
|
||||
export type RemovalRequest = {
|
||||
id: string;
|
||||
hubspotDealId: string;
|
||||
type: "removal" | "re_addition";
|
||||
status: "pending" | "approved" | "declined";
|
||||
reason: string;
|
||||
requestedByEmail: string;
|
||||
|
|
@ -193,6 +197,7 @@ export type LiveTrackerProps = {
|
|||
docStatusMap: DocStatusMap;
|
||||
userCapability: PortfolioCapabilityType;
|
||||
approvalsByDeal: ApprovalsByDeal;
|
||||
removalStatusByDeal: RemovalStatusByDeal;
|
||||
portfolioId: string;
|
||||
userRole: string;
|
||||
userEmail: string;
|
||||
|
|
|
|||
|
|
@ -59,8 +59,9 @@ export async function uploadFileToS3({
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Upload failed response:", response);
|
||||
throw new Error("Network response was not ok");
|
||||
const body = await response.text().catch(() => "(unreadable)");
|
||||
console.error("Upload failed", { status: response.status, statusText: response.statusText, body });
|
||||
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Upload error:", error);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue