added delete

This commit is contained in:
Jun-te Kim 2026-01-07 15:15:21 +00:00
parent 0bb2ed3499
commit 8f78f64de6
8 changed files with 350 additions and 10 deletions

View file

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

View file

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

View file

@ -23,8 +23,8 @@ export const MenuButton: React.FC<Props> = ({ onView, onDelete }) => {
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem onClick={onView}>View</DropdownMenuItem>
<DropdownMenuItem
onClick={onDelete}
className="text-red-600 focus:text-red-600"
onClick={onDelete}
>
Delete
</DropdownMenuItem>

View file

@ -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<number | null>(null);
const [deletePreview, setDeletePreview] = useState<
{ table: string; count: number }[] | null
>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(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 (
<div className="flex justify-center">
<div className="grid grid-cols-11 w-full max-w-8xl">
@ -105,7 +189,7 @@ export default function PropertyTable({
{/* Filters */}
<PropertyFilters onApply={setFilters} />
{/* Loading bar (HubSpot-style) */}
{/* Loading bar */}
{isFetching && (
<div className="h-1 w-full bg-gray-100 overflow-hidden">
<div className="h-full w-1/3 bg-black animate-[loading_1.2s_infinite]" />
@ -144,10 +228,97 @@ export default function PropertyTable({
) : data.length === 0 ? (
<EmptyPropertyState />
) : (
<DataTable data={data} columns={columns} />
<DataTable
data={data}
columns={columns}
onDeleteProperty={(propertyId) => {
console.log("[META] onDeleteProperty fired:", propertyId);
setDeletePropertyId(propertyId);
}}
/>
)}
</div>
</div>
{/* ----------------------------------------
Delete preview modal
----------------------------------------- */}
<Dialog
open={deletePropertyId !== null}
onOpenChange={(open) => {
if (!open) {
setDeletePropertyId(null);
setDeletePreview(null);
setPreviewError(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete property?</DialogTitle>
<DialogDescription>
{previewLoading
? "Calculating what will be deleted…"
: "This action is permanent. Review the impact below."}
</DialogDescription>
</DialogHeader>
{/* Loading */}
{previewLoading && (
<div className="flex items-center gap-3 py-6">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted border-t-foreground" />
<span className="text-sm text-muted-foreground">
Calculating deletion impact
</span>
</div>
)}
{/* Error */}
{previewError && (
<p className="text-sm text-red-600">{previewError}</p>
)}
{/* Preview result */}
{deletePreview && !previewLoading && (
<ul className="space-y-1 text-sm">
{deletePreview.map((row) => (
<li key={row.table} className="flex justify-between">
<span className="capitalize">
{row.table.replace(/_/g, " ")}
</span>
<span className="font-medium">{row.count}</span>
</li>
))}
</ul>
)}
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
setDeletePropertyId(null);
setDeletePreview(null);
}}
>
Cancel
</Button>
<Button
variant="destructive"
disabled={!deletePreview || previewLoading || deleteLoading}
onClick={handleConfirmDelete}
>
{deleteLoading ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
Deleting
</span>
) : (
"Confirm delete"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -39,11 +39,13 @@ const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[];
data: TData[];
onDeleteProperty?: (propertyId: number) => void;
}
export default function DataTable<TData extends Record<string, any>>({
data,
columns,
onDeleteProperty,
}: DataTableProps<TData>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@ -77,6 +79,10 @@ export default function DataTable<TData extends Record<string, any>>({
globalFilter,
pagination,
},
meta: {
onDeleteProperty,
},
});
return (

View file

@ -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<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
interface DataTableColumnHeaderProps<
TData,
TValue,
> extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
}
@ -277,7 +279,7 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
},
{
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<PropertyWithRelations>[] = [
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-500 focus:text-red-700 cursor-pointer">
<DropdownMenuItem
className="text-red-500 focus:text-red-700 cursor-pointer"
onClick={() => {
console.log("[UI] Delete clicked for:", propertyId);
table.options.meta?.onDeleteProperty?.(propertyId);
}}
>
Delete Property
</DropdownMenuItem>
</DropdownMenuContent>

7
src/app/types/tanstack-table.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
import "@tanstack/react-table";
declare module "@tanstack/react-table" {
interface TableMeta<TData> {
onDeleteProperty?: (propertyId: number) => void;
}
}

View file

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