diff --git a/src/app/api/properties/[id]/delete/confirm/route.ts b/src/app/api/properties/[id]/delete/confirm/route.ts new file mode 100644 index 0000000..39191ca --- /dev/null +++ b/src/app/api/properties/[id]/delete/confirm/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { deleteProperty } from "@/lib/services/propertyDeletion"; + +export async function POST( + req: Request, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params; + const propertyId = Number(id); + + if (Number.isNaN(propertyId)) { + return NextResponse.json({ error: "Invalid property id" }, { status: 400 }); + } + + const { confirm } = await req.json(); + + if (confirm !== true) { + return NextResponse.json( + { error: "Explicit confirmation required" }, + { status: 400 } + ); + } + + await deleteProperty(propertyId); + + return NextResponse.json({ + success: true, + dryRun: true, + }); +} diff --git a/src/app/api/properties/[id]/delete/preview/route.ts b/src/app/api/properties/[id]/delete/preview/route.ts new file mode 100644 index 0000000..108b65b --- /dev/null +++ b/src/app/api/properties/[id]/delete/preview/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { previewPropertyDeletion } from "@/lib/services/propertyDeletion"; + +export async function POST( + _req: Request, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params; // 👈 THIS IS THE FIX + const propertyId = Number(id); + const preview = await previewPropertyDeletion(propertyId); + + return NextResponse.json({ preview }); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/MenuButton.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/MenuButton.tsx index 16fbe11..758a4af 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/MenuButton.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/MenuButton.tsx @@ -23,8 +23,8 @@ export const MenuButton: React.FC = ({ onView, onDelete }) => { View Delete diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 12f7b01..dd6ba60 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -1,13 +1,22 @@ "use client"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { useProperties } from "./useProperties"; import DataTable from "./dataTable"; import PropertyFilters, { PropertyFilterValues } from "./PropertyFilters"; import { PropertyFilter } from "@/app/utils/propertyFilters"; import { HomeIcon } from "@heroicons/react/24/outline"; import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns"; -import clsx from "clsx"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/app/shadcn_components/ui/dialog"; +import { Button } from "@/app/shadcn_components/ui/button"; /* ---------------------------------------- Filter parsing @@ -85,7 +94,6 @@ export default function PropertyTable({ }); const parsedFilters = useMemo(() => parsePropertyFilters(filters), [filters]); - const hasActiveFilters = parsedFilters.length > 0; const { @@ -98,6 +106,82 @@ export default function PropertyTable({ filters: parsedFilters, }); + /* ---------------------------------------- + Delete preview state + ----------------------------------------- */ + const [deletePropertyId, setDeletePropertyId] = useState(null); + const [deletePreview, setDeletePreview] = useState< + { table: string; count: number }[] | null + >(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); + const [deleteLoading, setDeleteLoading] = useState(false); + + /* ---------------------------------------- + Fetch delete preview + ----------------------------------------- */ + useEffect(() => { + if (!deletePropertyId) return; + + console.log("[PREVIEW] fetching delete preview for:", deletePropertyId); + + setPreviewLoading(true); + setPreviewError(null); + setDeletePreview(null); + + fetch(`/api/properties/${deletePropertyId}/delete/preview`, { + method: "POST", + }) + .then(async (res) => { + if (!res.ok) { + throw new Error("Failed to fetch delete preview"); + } + return res.json(); + }) + .then((data) => { + console.log("[PREVIEW] result:", data.preview); + setDeletePreview(data.preview); + }) + .catch((err) => { + console.error("[PREVIEW] error:", err); + setPreviewError(err.message); + }) + .finally(() => { + setPreviewLoading(false); + }); + }, [deletePropertyId]); + + const handleConfirmDelete = async () => { + if (!deletePropertyId) return; + + setDeleteLoading(true); + + try { + console.log("[CONFIRM DELETE] sending request…"); + + const res = await fetch( + `/api/properties/${deletePropertyId}/delete/confirm`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: true }), + } + ); + + const data = await res.json(); + + console.log("[CONFIRM DELETE] response:", data); + + // TEMP: just close modal after backend confirms + setDeletePropertyId(null); + setDeletePreview(null); + } catch (err) { + console.error("[CONFIRM DELETE] error:", err); + } finally { + setDeleteLoading(false); + } + }; + return (
@@ -105,7 +189,7 @@ export default function PropertyTable({ {/* Filters */} - {/* Loading bar (HubSpot-style) */} + {/* Loading bar */} {isFetching && (
@@ -144,10 +228,97 @@ export default function PropertyTable({ ) : data.length === 0 ? ( ) : ( - + { + console.log("[META] onDeleteProperty fired:", propertyId); + setDeletePropertyId(propertyId); + }} + /> )}
+ + {/* ---------------------------------------- + Delete preview modal + ----------------------------------------- */} + { + if (!open) { + setDeletePropertyId(null); + setDeletePreview(null); + setPreviewError(null); + } + }} + > + + + Delete property? + + {previewLoading + ? "Calculating what will be deleted…" + : "This action is permanent. Review the impact below."} + + + + {/* Loading */} + {previewLoading && ( +
+
+ + Calculating deletion impact… + +
+ )} + + {/* Error */} + {previewError && ( +

{previewError}

+ )} + + {/* Preview result */} + {deletePreview && !previewLoading && ( +
    + {deletePreview.map((row) => ( +
  • + + {row.table.replace(/_/g, " ")} + + {row.count} +
  • + ))} +
+ )} + + + + + + +
); } diff --git a/src/app/portfolio/[slug]/components/dataTable.tsx b/src/app/portfolio/[slug]/components/dataTable.tsx index 142cfc2..bdc81da 100644 --- a/src/app/portfolio/[slug]/components/dataTable.tsx +++ b/src/app/portfolio/[slug]/components/dataTable.tsx @@ -39,11 +39,13 @@ const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { interface DataTableProps { columns: ColumnDef[]; data: TData[]; + onDeleteProperty?: (propertyId: number) => void; } export default function DataTable>({ data, columns, + onDeleteProperty, }: DataTableProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); @@ -77,6 +79,10 @@ export default function DataTable>({ globalFilter, pagination, }, + + meta: { + onDeleteProperty, + }, }); return ( diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index 3e274a3..8e26407 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -20,8 +20,10 @@ import { PortfolioStatus } from "@/app/db/schema/portfolio"; import { PropertyWithRelations } from "@/app/db/schema/property"; import { X } from "lucide-react"; -interface DataTableColumnHeaderProps - extends React.HTMLAttributes { +interface DataTableColumnHeaderProps< + TData, + TValue, +> extends React.HTMLAttributes { column: Column; title: string; } @@ -277,7 +279,7 @@ export const columns: ColumnDef[] = [ }, { id: "actions", - cell: ({ row }) => { + cell: ({ row, table }) => { const property = row.original; const propertyId = property.id; const portfolioId = property.portfolioId; @@ -316,7 +318,14 @@ export const columns: ColumnDef[] = [ Settings - + { + console.log("[UI] Delete clicked for:", propertyId); + + table.options.meta?.onDeleteProperty?.(propertyId); + }} + > Delete Property diff --git a/src/app/types/tanstack-table.d.ts b/src/app/types/tanstack-table.d.ts new file mode 100644 index 0000000..5347987 --- /dev/null +++ b/src/app/types/tanstack-table.d.ts @@ -0,0 +1,7 @@ +import "@tanstack/react-table"; + +declare module "@tanstack/react-table" { + interface TableMeta { + onDeleteProperty?: (propertyId: number) => void; + } +} diff --git a/src/lib/services/propertyDeletion.ts b/src/lib/services/propertyDeletion.ts new file mode 100644 index 0000000..83cb837 --- /dev/null +++ b/src/lib/services/propertyDeletion.ts @@ -0,0 +1,104 @@ +import { db } from "@/app/db/db"; +import { sql } from "drizzle-orm"; + +export async function previewPropertyDeletion(propertyId: number) { + const id = sql`${propertyId}`; + + const result = await db.execute(sql` + SELECT 'recommendation_materials' AS table, COUNT(*)::int AS count + FROM recommendation_materials rm + JOIN recommendation r ON rm.recommendation_id = r.id + WHERE r.property_id = ${id} + + UNION ALL + SELECT 'plan_recommendations', COUNT(*) + FROM plan_recommendations pr + JOIN plan p ON pr.plan_id = p.id + WHERE p.property_id = ${id} + + UNION ALL + SELECT 'funding_package_measures', COUNT(*) + FROM funding_package_measures fpm + JOIN funding_package fp ON fpm.funding_package_id = fp.id + JOIN plan p ON fp.plan_id = p.id + WHERE p.property_id = ${id} + + UNION ALL + SELECT 'inspections', COUNT(*) + FROM inspections + WHERE property_id = ${id} + + UNION ALL + SELECT 'recommendation', COUNT(*) + FROM recommendation + WHERE property_id = ${id} + + UNION ALL + SELECT 'plan', COUNT(*) + FROM plan + WHERE property_id = ${id} + + UNION ALL + SELECT 'property', COUNT(*) + FROM property + WHERE id = ${id}; + `); + + return result.rows; +} + +export async function deleteProperty(propertyId: number) { + await db.transaction(async (tx) => { + await tx.execute(sql` + DELETE FROM property_details_epc + WHERE property_id = ${propertyId}; + `); + + await tx.execute(sql` + DELETE FROM property_targets + WHERE property_id = ${propertyId}; + `); + + await tx.execute(sql` + DELETE FROM recommendation_materials rm + USING recommendation r + WHERE rm.recommendation_id = r.id + AND r.property_id = ${propertyId}; + `); + + await tx.execute(sql` + DELETE FROM plan_recommendations pr + USING plan p + WHERE pr.plan_id = p.id + AND p.property_id = ${propertyId}; + `); + + await tx.execute(sql` + DELETE FROM funding_package_measures fpm + USING funding_package fp, plan p + WHERE fpm.funding_package_id = fp.id + AND fp.plan_id = p.id + AND p.property_id = ${propertyId}; + `); + + await tx.execute(sql` + DELETE FROM inspections + WHERE property_id = ${propertyId}; + `); + + await tx.execute(sql` + DELETE FROM recommendation + WHERE property_id = ${propertyId}; + `); + + await tx.execute(sql` + DELETE FROM plan + WHERE property_id = ${propertyId}; + `); + + await tx.execute(sql` + DELETE FROM property + WHERE id = ${propertyId}; + `); + }); +}