mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
added delete
This commit is contained in:
parent
0bb2ed3499
commit
8f78f64de6
8 changed files with 350 additions and 10 deletions
30
src/app/api/properties/[id]/delete/confirm/route.ts
Normal file
30
src/app/api/properties/[id]/delete/confirm/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
13
src/app/api/properties/[id]/delete/preview/route.ts
Normal file
13
src/app/api/properties/[id]/delete/preview/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
7
src/app/types/tanstack-table.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import "@tanstack/react-table";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
interface TableMeta<TData> {
|
||||
onDeleteProperty?: (propertyId: number) => void;
|
||||
}
|
||||
}
|
||||
104
src/lib/services/propertyDeletion.ts
Normal file
104
src/lib/services/propertyDeletion.ts
Normal 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};
|
||||
`);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue