Merge pull request #232 from Hestia-Homes/feature/installer-interaction

Feature/installer interaction
This commit is contained in:
Jun-te Kim 2026-04-20 16:03:13 +01:00 committed by GitHub
commit 4010cf2cbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 238 additions and 61 deletions

View file

@ -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,

View file

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

View file

@ -0,0 +1 @@
ALTER TABLE "property_removal_requests" ADD COLUMN IF NOT EXISTS "original_batch" text;

View file

@ -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),

View file

@ -1,5 +1,16 @@
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";
@ -7,6 +18,7 @@ export async function syncRemovalRequestToHubSpot(params: {
reason: string;
requestedByEmail: string;
reviewedByEmail?: string | null;
batchValue?: string | null;
}): Promise<void> {
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<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,
@ -51,19 +74,32 @@ export async function syncRemovalRequestToHubSpot(params: {
export async function syncContractorDocUploadToHubSpot(params: {
hubspotDealId: string;
}): Promise<void> {
try {
const client = getHubSpotClient();
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: {
contractor_document_upload_log: "Documents available - uploaded by contractor",
},
});
} catch (err) {
console.error("[HubSpot] syncContractorDocUploadToHubSpot failed", {
dealId: params.hubspotDealId,
error: err,
});
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;
}
}
}

View file

@ -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>
))

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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,
},

View file

@ -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 ?? ""}

View file

@ -375,7 +375,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] {
// -----------------------------------------------------------------------
export function computeLiveTrackerData(
rawDeals: HubspotDeal[]
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "portfolioId" | "userRole" | "userEmail"> {
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "removalStatusByDeal" | "portfolioId" | "userRole" | "userEmail"> {
// Classify all deals (add displayStage field)
const classified = classifyDeals(rawDeals);

View file

@ -169,6 +169,9 @@ 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
// -----------------------------------------------------------------------
@ -194,6 +197,7 @@ export type LiveTrackerProps = {
docStatusMap: DocStatusMap;
userCapability: PortfolioCapabilityType;
approvalsByDeal: ApprovalsByDeal;
removalStatusByDeal: RemovalStatusByDeal;
portfolioId: string;
userRole: string;
userEmail: string;

View file

@ -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);