From 254125ddb72e74da6212206503d1782e29d269ee Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 20 Apr 2026 12:21:00 +0000 Subject: [PATCH 1/5] adding batch push up when removal requests are made --- .../[portfolioId]/removal-requests/route.ts | 36 +++++++++++++++-- .../0181_removal_request_original_batch.sql | 1 + src/app/db/schema/removal_requests.ts | 1 + src/app/lib/hubspot/dealSync.ts | 39 +++++++++++++++---- 4 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 src/app/db/migrations/0181_removal_request_original_batch.sql diff --git a/src/app/api/portfolio/[portfolioId]/removal-requests/route.ts b/src/app/api/portfolio/[portfolioId]/removal-requests/route.ts index dea8755..67d9ecc 100644 --- a/src/app/api/portfolio/[portfolioId]/removal-requests/route.ts +++ b/src/app/api/portfolio/[portfolioId]/removal-requests/route.ts @@ -7,7 +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 } from "@/app/lib/hubspot/dealSync"; +import { syncRemovalRequestToHubSpot, getDealBatch } from "@/app/lib/hubspot/dealSync"; const WRITE_ROLES = ["creator", "admin", "write"] as const; @@ -317,22 +317,52 @@ 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: target[0].hubspotDealId, - type: (target[0].type ?? "removal") as "removal" | "re_addition", + hubspotDealId: dealId, + type: requestType, status: action, reason: target[0].reason, requestedByEmail: target[0].requestedByEmail, reviewedByEmail: requestingUser.email, + batchValue, }); return NextResponse.json({ success: true }); diff --git a/src/app/db/migrations/0181_removal_request_original_batch.sql b/src/app/db/migrations/0181_removal_request_original_batch.sql new file mode 100644 index 0000000..e9d3d85 --- /dev/null +++ b/src/app/db/migrations/0181_removal_request_original_batch.sql @@ -0,0 +1 @@ +ALTER TABLE "property_removal_requests" ADD COLUMN IF NOT EXISTS "original_batch" text; diff --git a/src/app/db/schema/removal_requests.ts b/src/app/db/schema/removal_requests.ts index ff03de2..a535536 100644 --- a/src/app/db/schema/removal_requests.ts +++ b/src/app/db/schema/removal_requests.ts @@ -35,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), diff --git a/src/app/lib/hubspot/dealSync.ts b/src/app/lib/hubspot/dealSync.ts index 06a6b78..8a5120d 100644 --- a/src/app/lib/hubspot/dealSync.ts +++ b/src/app/lib/hubspot/dealSync.ts @@ -1,5 +1,16 @@ import { getHubSpotClient } from "./client"; +export async function getDealBatch(hubspotDealId: string): Promise { + 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"; @@ -7,6 +18,7 @@ export async function syncRemovalRequestToHubSpot(params: { reason: string; requestedByEmail: string; reviewedByEmail?: string | null; + batchValue?: string | null; }): Promise { try { const client = getHubSpotClient(); @@ -30,16 +42,27 @@ export async function syncRemovalRequestToHubSpot(params: { let log = `Requested by: ${params.requestedByEmail}\nReason: ${params.reason}`; if (params.reviewedByEmail) { - const action = params.status === "approved" ? "Approved" : "Declined"; - log += `\n${action} by: ${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}`; + } } - await client.crm.deals.basicApi.update(params.hubspotDealId, { - properties: { - project_removal_status: statusLabel, - project_removal_request_log: log, - }, - }); + const properties: Record = { + 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, From c64c7a7bdff2bea3bebc6ff7cf420663e7e48b91 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 20 Apr 2026 12:56:46 +0000 Subject: [PATCH 2/5] added pending removal ui --- .../your-projects/live/LiveTracker.tsx | 26 ++++++++++- .../your-projects/live/PropertyTable.tsx | 46 ++++++++++++++++--- .../live/PropertyTableColumns.tsx | 42 ++++++++++------- .../(portfolio)/your-projects/live/page.tsx | 34 +++++++++++++- .../(portfolio)/your-projects/live/types.ts | 4 ++ 5 files changed, 126 insertions(+), 26 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index 7311c30..8dd230f 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -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(null); @@ -117,6 +125,14 @@ export default function LiveTracker({ > Properties + 0 ? "opacity-100" : "opacity-0 pointer-events-none" + }`} + aria-hidden={pendingRemovalCount === 0} + > + {pendingRemovalCount || ""} + )} +
+ + + {pendingRemovalCount}{" "} + {pendingRemovalCount === 1 ? "property has" : "properties have"} an outstanding removal request + +
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx index db9c867..595106f 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx @@ -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 = { @@ -58,6 +58,7 @@ const COLUMN_LABELS: Record = { }; 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("all"); const [docFilter, setDocFilter] = useState("all"); + const [removalFilter, setRemovalFilter] = useState("all"); const [sorting, setSorting] = useState([]); const [pagination, setPagination] = useState({ 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 + {/* Removal status filter */} + + {/* Docs filter */} {showDocuments && (