mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #160 from Hestia-Homes/feature/make_delete_work
Feature/make delete work
This commit is contained in:
commit
04df686c3d
33 changed files with 2559 additions and 1411 deletions
25
.github/workflows/nextjs-build.yml
vendored
Normal file
25
.github/workflows/nextjs-build.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
name: Next.js Build Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**" # all branches
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Next.js app
|
||||
run: npm run build
|
||||
26
.vscode/settings.json
vendored
Normal file
26
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
|
||||
// Hot reload setting that needs to be in user settings
|
||||
// "jupyter.runStartupCommands": [
|
||||
// "%load_ext autoreload", "%autoreload 2"
|
||||
// ]
|
||||
// --- VIM SETTINGS ---
|
||||
// "vim.useSystemClipboard": true,
|
||||
"vim.enableNeovim": false,
|
||||
|
||||
// Allow VSCode native keybindings to override Vim when needed
|
||||
"vim.handleKeys": {
|
||||
"<C-p>": false,
|
||||
"<C-P>": false,
|
||||
"<C-S-p>": false,
|
||||
"<C-c>": false,
|
||||
"<C-v>": false,
|
||||
"<C-S-v>": false,
|
||||
"<C-S-e>": false,
|
||||
"<C-b>": false,
|
||||
"<C-j>": false,
|
||||
"<C-S-c>": false,
|
||||
"<C-k>": false
|
||||
},
|
||||
|
||||
}
|
||||
|
|
@ -132,5 +132,4 @@ the permission set to access the bucket, `rerofit-plan-inputs-<stage>`. The name
|
|||
|
||||
Quick wins:
|
||||
|
||||
- [] Sign in butotn in microsoft disappear!
|
||||
- [] Frequently asked questions page
|
||||
|
|
|
|||
2062
package-lock.json
generated
2062
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -14,10 +14,11 @@
|
|||
"create_user": "tsx src/app/db/create_user.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.971.0",
|
||||
"@aws-sdk/client-sqs": "^3.864.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.927.0",
|
||||
"@headlessui/react": "^2.2.7",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@hubspot/api-client": "^13.4.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
|
|
@ -40,13 +41,11 @@
|
|||
"@tanstack/react-table": "^8.9.3",
|
||||
"@tremor/react": "^3.18.7",
|
||||
"@types/node": "20.2.3",
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vercel/speed-insights": "^1.2.0",
|
||||
"autoprefixer": "10.4.14",
|
||||
"aws-sdk": "^2.1415.0",
|
||||
"class-variance-authority": "^0.6.1",
|
||||
"client-s3": "github:aws-sdk/client-s3",
|
||||
"clsx": "^1.2.1",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"esbuild": "^0.25.8",
|
||||
|
|
@ -76,8 +75,10 @@
|
|||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@testing-library/cypress": "^10.0.3",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/nodemailer": "^7.0.2",
|
||||
"@types/pg": "^8.10.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"cypress": "^14.5.3",
|
||||
"cypress-social-logins": "^1.14.1",
|
||||
"dotenv": "^16.3.1",
|
||||
|
|
|
|||
30
src/app/api/plan/[id]/delete/confirm/route.ts
Normal file
30
src/app/api/plan/[id]/delete/confirm/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { deletePlan } from "@/lib/services/propertyDeletion";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const planId = Number(id);
|
||||
|
||||
if (Number.isNaN(planId)) {
|
||||
return NextResponse.json({ error: "Invalid plan id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { confirm } = await req.json();
|
||||
|
||||
if (confirm !== true) {
|
||||
return NextResponse.json(
|
||||
{ error: "Explicit confirmation required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await deletePlan(planId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
dryRun: true,
|
||||
});
|
||||
}
|
||||
13
src/app/api/plan/[id]/delete/preview/route.ts
Normal file
13
src/app/api/plan/[id]/delete/preview/route.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { previewPlanDeletion } 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 planId = Number(id);
|
||||
const preview = await previewPlanDeletion(planId);
|
||||
|
||||
return NextResponse.json({ preview });
|
||||
}
|
||||
25
src/app/api/properties/route.ts
Normal file
25
src/app/api/properties/route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getProperties } from "@/app/portfolio/[slug]/utils";
|
||||
import { PropertyFilter } from "@/app/utils/propertyFilters";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
|
||||
const portfolioId = body.portfolioId;
|
||||
const filters: PropertyFilter[] = body.filters ?? [];
|
||||
|
||||
if (!portfolioId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing portfolioId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
console.log("filters", filters);
|
||||
const properties = await getProperties(
|
||||
portfolioId,
|
||||
1000,
|
||||
0,
|
||||
filters
|
||||
);
|
||||
return NextResponse.json(properties);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { Fragment, useState } from "react";
|
||||
import React, { Fragment, useState } from "react";
|
||||
import PoundIconSvg from "./PoundIconSvg";
|
||||
import LightBulbSvg from "./LightBulbSvg";
|
||||
import CarbonIcon from "./CarbonIcon";
|
||||
|
|
@ -21,12 +21,12 @@ const selectedIconClasses =
|
|||
const deSelectedIconClasses =
|
||||
"bg-gray-200 w-1/5 rounded-md h-1/5 cursor-pointer";
|
||||
|
||||
interface iconComponentsType {
|
||||
[key: string]: JSX.Element;
|
||||
interface IconComponentsType {
|
||||
[key: string]: React.ReactElement;
|
||||
}
|
||||
|
||||
const Icon: React.FC<IconProps> = ({ name, selected, onSelect }: IconProps) => {
|
||||
const iconComponents: iconComponentsType = {
|
||||
const iconComponents: IconComponentsType = {
|
||||
"Valuation Improvement": <PoundIconSvg fill="white" />,
|
||||
"Energy Savings": <LightBulbSvg fill="white" />,
|
||||
// add more mappings here if needed
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { ReactNode, forwardRef } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const baseStyles = {
|
||||
solid:
|
||||
|
|
@ -39,27 +40,23 @@ const variantStyles: VariantStylesType = {
|
|||
};
|
||||
|
||||
type ButtonInput = {
|
||||
variant: "solid" | "outline";
|
||||
variant?: "solid" | "outline";
|
||||
color: "cyan" | "white" | "gray";
|
||||
href: string;
|
||||
} & JSX.IntrinsicElements["button"];
|
||||
href?: string;
|
||||
} & ComponentPropsWithoutRef<"button">;
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
}
|
||||
export type Ref = HTMLButtonElement;
|
||||
|
||||
// write me a forwardRef to use the Button component
|
||||
const Button = forwardRef<Ref, ButtonInput>(
|
||||
({ variant = "solid", color, className, href, ...props }, ref) => {
|
||||
className = clsx(
|
||||
({ variant = "solid", color, className, ...props }, ref) => {
|
||||
const classes = clsx(
|
||||
baseStyles[variant],
|
||||
variantStyles[variant][color],
|
||||
className
|
||||
className,
|
||||
);
|
||||
|
||||
return <button ref={ref} className={className} {...props} />;
|
||||
}
|
||||
return <button ref={ref} className={classes} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
|
|
|||
|
|
@ -222,3 +222,8 @@
|
|||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,60 @@
|
|||
const HelpPage = () => {
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
const FAQItem = ({ question, answer }: { question: string; answer: string }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Help Page</h1>
|
||||
<p> All the help you could ever need </p>
|
||||
<div className="border-b border-zinc-300 py-4">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex justify-between items-center text-left"
|
||||
>
|
||||
<span className="font-medium text-lg">{question}</span>
|
||||
<span className="text-zinc-500">{open ? "−" : "+"}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`transition-all overflow-hidden ${
|
||||
open ? "max-h-40 mt-2" : "max-h-0"
|
||||
}`}
|
||||
>
|
||||
<p className="text-zinc-600">{answer}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HelpPage = () => {
|
||||
const faqs = [
|
||||
{
|
||||
question: "Question 1",
|
||||
answer:
|
||||
"Answer 1",
|
||||
},
|
||||
{
|
||||
question: "Question 2",
|
||||
answer:
|
||||
"Answer 2",
|
||||
},
|
||||
{
|
||||
question: "Question 3",
|
||||
answer:
|
||||
"Answer 3",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto mt-12 px-4">
|
||||
<h1 className="text-3xl font-bold mb-6">Help & FAQ</h1>
|
||||
<p className="text-zinc-600 mb-8">
|
||||
Answers to common questions below.
|
||||
</p>
|
||||
|
||||
{faqs.map((f, i) => (
|
||||
<FAQItem key={i} question={f.question} answer={f.answer} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,28 +1,14 @@
|
|||
import { HomeIcon } from "@heroicons/react/24/outline";
|
||||
import { getPortfolio, getPortfolioPerformance, getProperties } from "../utils";
|
||||
import DataTable from "@/app/portfolio/[slug]/components/propertyTable";
|
||||
import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
|
||||
import DataTable from "@/app/portfolio/[slug]/components/dataTable";
|
||||
import { PropertyWithRelations } from "@/app/db/schema/property";
|
||||
import PropertyTable from "../components/PropertyTable";
|
||||
|
||||
import SummaryBox from "@/app/components/portfolio/SummaryBox";
|
||||
import { Component } from "lucide-react";
|
||||
|
||||
// We enfore caching of data for 60 seconds
|
||||
export const revalidate = 60;
|
||||
|
||||
function EmptyPropertyState() {
|
||||
return (
|
||||
<div className="flex justify-center h-1/2">
|
||||
<div className="bg-white rounded-lg w-full">
|
||||
<p className="text-center text-gray-400 pt-6">
|
||||
Hover over "New Property" to start adding properties to your
|
||||
Portfolio
|
||||
<HomeIcon className="h-20 w-20 mx-auto mt-4 text-gray-200" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{
|
||||
|
|
@ -72,25 +58,9 @@ export default async function Page(props: {
|
|||
];
|
||||
}
|
||||
|
||||
const properties: PropertyWithRelations[] = await getProperties(
|
||||
portfolioId,
|
||||
1000,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<div className="grid grid-cols-11 w-full max-w-8xl">
|
||||
<div className="col-span-11 bg-white">
|
||||
{properties.length === 0 ? (
|
||||
<EmptyPropertyState />
|
||||
) : (
|
||||
<DataTable data={properties} columns={columns} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<PropertyTable portfolioId={portfolioId}/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -281,18 +281,28 @@ export default function PortfolioSettings({
|
|||
setIsDeleteModalOpen(true);
|
||||
}
|
||||
|
||||
function handleDeleteConfirmation() {
|
||||
if (deleteConfirmationByName === portfolioSettingsData.name) {
|
||||
mutateDelete({
|
||||
async function handleDeleteConfirmation() {
|
||||
if (deleteConfirmationByName !== portfolioSettingsData.name) {
|
||||
console.warn("Delete confirmation name does not match");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[DELETE] starting delete mutation");
|
||||
|
||||
await mutateDelete({
|
||||
userId,
|
||||
portfolioId,
|
||||
});
|
||||
console.log("succesfully called the mututate function");
|
||||
|
||||
console.log("[DELETE] mutation completed successfully");
|
||||
// Refresh table / page data
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error("[DELETE] mutation failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
// RENAMING FUNCTIONS
|
||||
|
||||
// Change NAME functionality - changing state
|
||||
|
||||
function handlePortfolioNameChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
|
|
@ -460,7 +470,7 @@ export default function PortfolioSettings({
|
|||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<UsersPermissionsCard portfolioId={portfolioId}/>
|
||||
<UsersPermissionsCard portfolioId={portfolioId} />
|
||||
<div className="rounded-md border border-red-500 mt-2">
|
||||
<Table>
|
||||
<TableHead className="text-lg text-brandblue">Danger Zone:</TableHead>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ProjectProposal, DashboardSummary } from "./ProjectProposal";
|
||||
import { getPlansWithTotals } from "./utils";
|
||||
import DataTable from "@/app/portfolio/[slug]/components/propertyTable";
|
||||
import DataTable from "@/app/portfolio/[slug]/components/dataTable";
|
||||
import { planColumns } from "./ProposalColumns";
|
||||
|
||||
export default async function ProjectProposalPage(props: {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
getPropertyMeta,
|
||||
getDocument,
|
||||
|
|
@ -309,7 +311,7 @@ function ReplacementsContent({
|
|||
|
||||
// sort within each group
|
||||
Object.values(groups).forEach((comps) =>
|
||||
comps.sort((a, b) => a.expiry.getTime() - b.expiry.getTime())
|
||||
comps.sort((a, b) => a.expiry.getTime() - b.expiry.getTime()),
|
||||
);
|
||||
|
||||
const groupOrder: (keyof typeof groups)[] = [
|
||||
|
|
@ -320,7 +322,7 @@ function ReplacementsContent({
|
|||
];
|
||||
|
||||
// urgency → card highlight color + icon
|
||||
const cardStyles: Record<string, { border: string; icon: JSX.Element }> = {
|
||||
const cardStyles: Record<string, { border: string; icon: ReactNode }> = {
|
||||
Overdue: {
|
||||
border: "border-l-4 border-red-600",
|
||||
icon: <AlertTriangle className="w-4 h-4 text-red-600" />,
|
||||
|
|
@ -385,7 +387,7 @@ function ReplacementsContent({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
) : null,
|
||||
)}
|
||||
</CardContent>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-gray-100 to-transparent pointer-events-none" />
|
||||
|
|
@ -434,7 +436,7 @@ function DecentHomesSummary({
|
|||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const criteriaGroups: Record<
|
||||
|
|
@ -581,15 +583,15 @@ export default async function DecentHomesPage(props: {
|
|||
!decentPropertyMeta.s3JsonUri
|
||||
) {
|
||||
throw new Error(
|
||||
`Decent Homes data is missing for uprn ${propertyMeta.uprn}`
|
||||
`Decent Homes data is missing for uprn ${propertyMeta.uprn}`,
|
||||
);
|
||||
}
|
||||
|
||||
const decentHomesMeta = await getEnergyAssessmentFromS3(
|
||||
decentPropertyMeta.s3JsonUri
|
||||
decentPropertyMeta.s3JsonUri,
|
||||
);
|
||||
const decentHomes = await getEnergyAssessmentFromS3(
|
||||
decentHomesSummary.s3JsonUri
|
||||
decentHomesSummary.s3JsonUri,
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import EpcCard from "@/app/components/building-passport/EpcCard";
|
||||
import GoToPlanButton from "@/app/components/building-passport/GoToPlanButton";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/app/shadcn_components/ui/card";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader as ModalHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/app/shadcn_components/ui/dialog";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/app/shadcn_components/ui/table";
|
||||
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { formatNumber } from "@/app/utils";
|
||||
|
||||
/* ----------------------------------------
|
||||
Types
|
||||
----------------------------------------- */
|
||||
type DeletionPreviewRow = {
|
||||
table: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/* ----------------------------------------
|
||||
Fetchers
|
||||
----------------------------------------- */
|
||||
async function fetchPlanDeletionPreview(
|
||||
planId: string
|
||||
): Promise<DeletionPreviewRow[]> {
|
||||
const res = await fetch(`/api/plan/${planId}/delete/preview`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to load deletion preview");
|
||||
|
||||
const json = await res.json();
|
||||
return json.preview;
|
||||
}
|
||||
|
||||
async function confirmPlanDeletion(planId: string): Promise<void> {
|
||||
const res = await fetch(`/api/plan/${planId}/delete/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => "");
|
||||
throw new Error(msg || "Failed to delete plan");
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------
|
||||
Component
|
||||
----------------------------------------- */
|
||||
export default function PlanCard({
|
||||
expectedEpcRating,
|
||||
createdAt,
|
||||
totalEstimatedCost,
|
||||
totalSapPoints,
|
||||
planName,
|
||||
planId,
|
||||
}: {
|
||||
expectedEpcRating: string;
|
||||
createdAt: Date;
|
||||
totalEstimatedCost: number;
|
||||
totalSapPoints: number;
|
||||
planName: string | null;
|
||||
planId: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
/* -------- Preview query -------- */
|
||||
const {
|
||||
data: preview = [],
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["planDeletionPreview", planId],
|
||||
queryFn: () => fetchPlanDeletionPreview(planId),
|
||||
enabled: open, // only fetch when modal opens
|
||||
});
|
||||
|
||||
/* -------- Delete mutation -------- */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => confirmPlanDeletion(planId),
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="relative flex items-start">
|
||||
{/* Delete button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="
|
||||
absolute top-3 right-3
|
||||
rounded-md p-1.5
|
||||
text-gray-400
|
||||
hover:text-red-600 hover:bg-red-50
|
||||
focus:outline-none focus:ring-2 focus:ring-red-400/40
|
||||
transition
|
||||
"
|
||||
aria-label="Delete plan"
|
||||
title="Delete plan"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* EPC */}
|
||||
<div className="flex-none w-1/5">
|
||||
<EpcCard epcRating={expectedEpcRating} fullMargin expected />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-grow pl-4 flex flex-col justify-between">
|
||||
<CardHeader className="flex justify-end items-start">
|
||||
{planName && (
|
||||
<div className="text-lg font-bold mb-2 text-gray-900">
|
||||
{planName}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="flex justify-between mb-2">
|
||||
<span>Total cost:</span>
|
||||
<span>£{formatNumber(totalEstimatedCost)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Total SAP points:</span>
|
||||
<span>
|
||||
{Math.round((totalSapPoints + Number.EPSILON) * 100) / 100}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="flex flex-col justify-end mr-2 self-stretch w-1/5">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<GoToPlanButton planId={planId} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ----------------------------------------
|
||||
Delete preview modal
|
||||
----------------------------------------- */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<ModalHeader>
|
||||
<DialogTitle className="text-red-600">Delete plan</DialogTitle>
|
||||
</ModalHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading deletion preview…</p>
|
||||
) : isError ? (
|
||||
<p className="text-sm text-red-600">
|
||||
Failed to load deletion preview
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-gray-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Table</TableHead>
|
||||
<TableHead className="text-right">Rows deleted</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{preview.map((row) => (
|
||||
<TableRow key={row.table}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{row.table}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-semibold">
|
||||
{row.count}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? "Deleting…" : "Delete plan"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,65 +1,6 @@
|
|||
import { getPlans, getPropertyMeta } from "../utils";
|
||||
import { formatDateTime, formatNumber, sapToEpc } from "@/app/utils";
|
||||
import EpcCard from "@/app/components/building-passport/EpcCard";
|
||||
import { Card, CardContent, CardHeader } from "@/app/shadcn_components/ui/card";
|
||||
import GoToPlanButton from "@/app/components/building-passport/GoToPlanButton";
|
||||
|
||||
function PlanCard({
|
||||
expectedEpcRating,
|
||||
createdAt,
|
||||
totalEstimatedCost,
|
||||
totalSapPoints,
|
||||
planName,
|
||||
planId,
|
||||
isDefault,
|
||||
}: {
|
||||
expectedEpcRating: string;
|
||||
createdAt: Date;
|
||||
totalEstimatedCost: number;
|
||||
totalSapPoints: number;
|
||||
planName: string | null;
|
||||
planId: string;
|
||||
isDefault: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className="flex items-start">
|
||||
<div className="flex-none w-1/5">
|
||||
<EpcCard
|
||||
epcRating={expectedEpcRating}
|
||||
fullMargin={true}
|
||||
expected={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow pl-4 flex flex-col justify-between">
|
||||
<CardHeader className="flex justify-end items-start">
|
||||
{planName && (
|
||||
<div className="text-lg font-bold mb-2 text-gray-900">
|
||||
{planName}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-between mb-2">
|
||||
<span>Total cost:</span>
|
||||
<span>£{formatNumber(totalEstimatedCost)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Total SAP points:</span>
|
||||
<span>
|
||||
{Math.round((totalSapPoints + Number.EPSILON) * 100) / 100}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between mr-2 self-stretch w-1/5">
|
||||
<div className="text-xs text-gray-400 p-0 text-end">
|
||||
Created: {formatDateTime(createdAt)}
|
||||
</div>
|
||||
<GoToPlanButton planId={planId} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
import { sapToEpc } from "@/app/utils";
|
||||
import PlanCard from "./PlanCard";
|
||||
|
||||
export default async function RecommendationPlans(props: {
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
|
|
@ -68,23 +9,18 @@ export default async function RecommendationPlans(props: {
|
|||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
const plans = await getPlans(params.propertyId);
|
||||
|
||||
// TODO: We don't currently have any visual identification of plans that have been set as default vs not
|
||||
|
||||
return (
|
||||
<div className="leading-loose tracking-wider">
|
||||
<div className="flex py-8 text-lg">Retrofit Plans</div>
|
||||
|
||||
<div className="max-w-3xl">
|
||||
{plans.map((plan, index) => {
|
||||
// We accumulate the cost and the sap points for only the default recommendations
|
||||
|
||||
{plans.map((plan) => {
|
||||
const totalEstimatedCost = plan.costOfWorks || 0;
|
||||
|
||||
const totalSapPoints =
|
||||
(plan.postSapPoints || propertyMeta.currentSapPoints) -
|
||||
propertyMeta.currentSapPoints;
|
||||
|
||||
// Placeholder while we return 999 for all sap points
|
||||
const expectedSapPoints = Math.min(
|
||||
propertyMeta.currentSapPoints + totalSapPoints,
|
||||
100
|
||||
|
|
@ -93,16 +29,14 @@ export default async function RecommendationPlans(props: {
|
|||
const expectedEpcRating = sapToEpc(expectedSapPoints);
|
||||
|
||||
return (
|
||||
<div key={index} className="mb-4">
|
||||
<div key={plan.id} className="mb-4">
|
||||
<PlanCard
|
||||
key={index}
|
||||
expectedEpcRating={expectedEpcRating}
|
||||
createdAt={plan.createdAt}
|
||||
totalEstimatedCost={totalEstimatedCost}
|
||||
totalSapPoints={totalSapPoints}
|
||||
planName={plan.name}
|
||||
planId={String(plan.id)}
|
||||
isDefault={plan.isDefault}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
185
src/app/portfolio/[slug]/components/PropertyFilters.tsx
Normal file
185
src/app/portfolio/[slug]/components/PropertyFilters.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export type PropertyFilterValues = {
|
||||
address: string;
|
||||
postcode: string;
|
||||
current_epc_at_most: "" | "C" | "D" | "E" | "F" | "G";
|
||||
expected_epc_at_least: "" | "A" | "B" | "C" | "D";
|
||||
};
|
||||
|
||||
const EPC_ORDER = ["A", "B", "C", "D", "E", "F", "G"] as const;
|
||||
|
||||
const epcIndex = (epc: string) =>
|
||||
EPC_ORDER.indexOf(epc as (typeof EPC_ORDER)[number]);
|
||||
|
||||
export default function PropertyFilters({
|
||||
onApply,
|
||||
}: {
|
||||
onApply: (filters: PropertyFilterValues) => void;
|
||||
}) {
|
||||
const [address, setAddress] = useState("");
|
||||
const [postcode, setPostcode] = useState("");
|
||||
const [currentEpc, setCurrentEpc] =
|
||||
useState<PropertyFilterValues["current_epc_at_most"]>("");
|
||||
const [expectedEpc, setExpectedEpc] =
|
||||
useState<PropertyFilterValues["expected_epc_at_least"]>("");
|
||||
|
||||
/* ----------------------------------------
|
||||
Change handlers (no useEffect)
|
||||
----------------------------------------- */
|
||||
function handleCurrentEpcChange(
|
||||
value: PropertyFilterValues["current_epc_at_most"],
|
||||
) {
|
||||
setCurrentEpc(value);
|
||||
|
||||
if (value && expectedEpc && epcIndex(expectedEpc) >= epcIndex(value)) {
|
||||
setExpectedEpc("");
|
||||
}
|
||||
}
|
||||
|
||||
function handleExpectedEpcChange(
|
||||
value: PropertyFilterValues["expected_epc_at_least"],
|
||||
) {
|
||||
setExpectedEpc(value);
|
||||
|
||||
if (value && currentEpc && epcIndex(value) >= epcIndex(currentEpc)) {
|
||||
setCurrentEpc("");
|
||||
}
|
||||
}
|
||||
|
||||
function apply() {
|
||||
onApply({
|
||||
address,
|
||||
postcode,
|
||||
current_epc_at_most: currentEpc,
|
||||
expected_epc_at_least: expectedEpc,
|
||||
});
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setAddress("");
|
||||
setPostcode("");
|
||||
setCurrentEpc("");
|
||||
setExpectedEpc("");
|
||||
|
||||
onApply({
|
||||
address: "",
|
||||
postcode: "",
|
||||
current_epc_at_most: "",
|
||||
expected_epc_at_least: "",
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
apply();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b bg-white">
|
||||
<div className="grid grid-cols-12 gap-4 p-4" onKeyDown={handleKeyDown}>
|
||||
{/* Address */}
|
||||
<div className="col-span-4">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Address
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-black/10"
|
||||
placeholder="Contains…"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Postcode */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Postcode
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-black/10"
|
||||
placeholder="e.g. E17"
|
||||
value={postcode}
|
||||
onChange={(e) => setPostcode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Current EPC */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Current EPC
|
||||
</label>
|
||||
<select
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm
|
||||
bg-white focus:outline-none focus:ring-2 focus:ring-black/10"
|
||||
value={currentEpc}
|
||||
onChange={(e) => handleCurrentEpcChange(e.target.value as any)}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{["C", "D", "E", "F", "G"].map((epc) => (
|
||||
<option
|
||||
key={epc}
|
||||
value={epc}
|
||||
disabled={
|
||||
expectedEpc !== "" && epcIndex(epc) <= epcIndex(expectedEpc)
|
||||
}
|
||||
>
|
||||
{epc} or below
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Expected EPC */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Expected EPC
|
||||
</label>
|
||||
<select
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm
|
||||
bg-white focus:outline-none focus:ring-2 focus:ring-black/10"
|
||||
value={expectedEpc}
|
||||
onChange={(e) => handleExpectedEpcChange(e.target.value as any)}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{["A", "B", "C", "D"].map((epc) => (
|
||||
<option
|
||||
key={epc}
|
||||
value={epc}
|
||||
disabled={
|
||||
expectedEpc !== "" && epcIndex(epc) <= epcIndex(expectedEpc)
|
||||
}
|
||||
>
|
||||
{epc} or above
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="col-span-2 flex items-end gap-2">
|
||||
<button
|
||||
onClick={apply}
|
||||
className="h-10 w-full rounded-md bg-black text-sm font-medium text-white
|
||||
hover:bg-black/90 transition"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={clear}
|
||||
className="h-10 px-3 text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// #Test git with khalimsdsadsaasdsasdfdsertrsadfsdsadfdssdfds
|
||||
247
src/app/portfolio/[slug]/components/PropertyTable.tsx
Normal file
247
src/app/portfolio/[slug]/components/PropertyTable.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/app/shadcn_components/ui/dialog";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
|
||||
/* ----------------------------------------
|
||||
Filter parsing
|
||||
----------------------------------------- */
|
||||
export function parsePropertyFilters(
|
||||
filters: PropertyFilterValues
|
||||
): PropertyFilter[] {
|
||||
const parsed: PropertyFilter[] = [];
|
||||
|
||||
if (filters.address) {
|
||||
parsed.push({
|
||||
field: "address",
|
||||
operator: "contains",
|
||||
value: filters.address,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.postcode) {
|
||||
parsed.push({
|
||||
field: "postcode",
|
||||
operator: "starts_with",
|
||||
value: filters.postcode,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.current_epc_at_most) {
|
||||
parsed.push({
|
||||
field: "currentEpc",
|
||||
operator: "epc_at_most",
|
||||
value: filters.current_epc_at_most,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.expected_epc_at_least) {
|
||||
parsed.push({
|
||||
field: "expectedEpc",
|
||||
operator: "epc_at_least",
|
||||
value: filters.expected_epc_at_least,
|
||||
});
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/* ----------------------------------------
|
||||
Empty portfolio state
|
||||
----------------------------------------- */
|
||||
function EmptyPropertyState() {
|
||||
return (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="text-center text-gray-400">
|
||||
<HomeIcon className="h-16 w-16 mx-auto mb-4 text-gray-200" />
|
||||
<p>
|
||||
Hover over <strong>“New Property”</strong> to start adding properties
|
||||
to your portfolio.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------------------
|
||||
Main table
|
||||
----------------------------------------- */
|
||||
export default function PropertyTable({
|
||||
portfolioId,
|
||||
}: {
|
||||
portfolioId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const [filters, setFilters] = useState<PropertyFilterValues>({
|
||||
address: "",
|
||||
postcode: "",
|
||||
current_epc_at_most: "",
|
||||
expected_epc_at_least: "",
|
||||
});
|
||||
|
||||
const parsedFilters = useMemo(() => parsePropertyFilters(filters), [filters]);
|
||||
const hasActiveFilters = parsedFilters.length > 0;
|
||||
|
||||
const {
|
||||
data = [],
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useProperties({
|
||||
portfolioId,
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="grid grid-cols-11 w-full max-w-8xl">
|
||||
<div className="col-span-11 bg-white rounded-md border">
|
||||
<PropertyFilters onApply={setFilters} />
|
||||
|
||||
{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]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActiveFilters && !isFetching && (
|
||||
<div className="px-4 py-2 text-xs text-gray-500 border-b">
|
||||
Filters applied ({parsedFilters.length})
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-gray-400">Loading properties…</div>
|
||||
) : isError ? (
|
||||
<div className="p-6 text-red-500">Failed to load properties.</div>
|
||||
) : data.length === 0 && hasActiveFilters ? (
|
||||
<div className="p-10 text-center text-gray-500">
|
||||
<p>No properties match your filters.</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
address: "",
|
||||
postcode: "",
|
||||
current_epc_at_most: "",
|
||||
expected_epc_at_least: "",
|
||||
})
|
||||
}
|
||||
className="mt-3 text-sm text-black underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<EmptyPropertyState />
|
||||
) : (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
onDeleteProperty={(id) => setDeletePropertyId(id)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{previewError && (
|
||||
<p className="text-sm text-red-600">{previewError}</p>
|
||||
)}
|
||||
|
||||
{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 ? "Deleting…" : "Confirm delete"}
|
||||
</Button> */}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
src/app/portfolio/[slug]/components/dataTable.tsx
Normal file
137
src/app/portfolio/[slug]/components/dataTable.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
PaginationState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
FilterFn,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/app/shadcn_components/ui/table";
|
||||
|
||||
import { useState } from "react";
|
||||
import { DataTablePagination } from "./propertyTablePagination";
|
||||
import { rankItem } from "@tanstack/match-sorter-utils";
|
||||
|
||||
/* ----------------------------------------
|
||||
Optional fuzzy global filter
|
||||
----------------------------------------- */
|
||||
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
|
||||
const itemRank = rankItem(String(row.getValue(columnId) ?? ""), value);
|
||||
addMeta?.({ itemRank });
|
||||
return itemRank.passed;
|
||||
};
|
||||
|
||||
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>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
|
||||
// ✅ REQUIRED pagination state (fixes TS error)
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 7,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
onPaginationChange: setPagination,
|
||||
|
||||
globalFilterFn: fuzzyFilter,
|
||||
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
},
|
||||
|
||||
meta: {
|
||||
onDeleteProperty,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} className="h-14 py-2">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="mb-2">
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/app/shadcn_components/ui/table";
|
||||
import { useState } from "react";
|
||||
import { DataTablePagination } from "./propertyTablePagination";
|
||||
import React from "react";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import { rankItem } from "@tanstack/match-sorter-utils";
|
||||
import { FilterFn } from "@tanstack/react-table";
|
||||
|
||||
// Optional: Fuzzy global filter
|
||||
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
|
||||
const itemRank = rankItem(String(row.getValue(columnId) ?? ""), value);
|
||||
addMeta?.({ itemRank });
|
||||
return itemRank.passed;
|
||||
};
|
||||
|
||||
interface DataTableProps<TData> {
|
||||
columns: ColumnDef<TData, any>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
function fetchData<TData>(offset: number): TData[] {
|
||||
// placeholder function for fetching
|
||||
const data: TData[] = [];
|
||||
return data;
|
||||
}
|
||||
|
||||
export default function DataTable<TData extends Record<string, any>>({
|
||||
data,
|
||||
columns,
|
||||
}: DataTableProps<TData>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [tableData, setTableData] = useState(() => [...data]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [currentPageIndex, setCurrentPageIndex] = useState(0);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
);
|
||||
const [globalFilter, setGlobalFilter] = React.useState("");
|
||||
|
||||
// add page change handlers for DataTablePagination
|
||||
const loadPaginatedData = () => {
|
||||
const newData = fetchData<TData>(offset);
|
||||
if (newData) {
|
||||
setTableData([...tableData, ...newData]);
|
||||
setOffset(offset + 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const table = useReactTable({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
globalFilterFn: fuzzyFilter,
|
||||
state: {
|
||||
sorting,
|
||||
pagination: { pageIndex: currentPageIndex, pageSize: 7 },
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center py-2">
|
||||
<div className="flex items-center py-2">
|
||||
<Input
|
||||
placeholder="Search address or postcode..."
|
||||
value={globalFilter ?? ""}
|
||||
onChange={(event) => setGlobalFilter(event.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
{globalFilter && (
|
||||
<button
|
||||
onClick={() => setGlobalFilter("")}
|
||||
className="ml-4 text-gray-500 hover:text-gray-800"
|
||||
title="Clear search"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} className="h-14 py-2">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mb-2">
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
loadPaginatedData={loadPaginatedData}
|
||||
currentPageIndex={currentPageIndex}
|
||||
setCurrentPageIndex={setCurrentPageIndex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,9 +318,7 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
|
|||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-500 focus:text-red-700 cursor-pointer">
|
||||
Delete Property
|
||||
</DropdownMenuItem>
|
||||
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,96 +5,62 @@ import {
|
|||
ChevronDoubleLeftIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
loadPaginatedData: () => void;
|
||||
currentPageIndex: number;
|
||||
setCurrentPageIndex: (index: number) => void;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
loadPaginatedData,
|
||||
currentPageIndex,
|
||||
setCurrentPageIndex,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
// Check if the user has reached the last page
|
||||
|
||||
const requestMoreData = () => {
|
||||
// Check if the next page is the last one
|
||||
if (
|
||||
table.getState().pagination.pageIndex + 1 ===
|
||||
table.getPageCount() - 1
|
||||
) {
|
||||
console.log("requesting more data");
|
||||
loadPaginatedData();
|
||||
}
|
||||
};
|
||||
|
||||
const goToFinalPage = () => {
|
||||
console.log("Go to final page");
|
||||
// Check if the next page is the last one
|
||||
loadPaginatedData();
|
||||
};
|
||||
const { pageIndex } = table.getState().pagination;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end px-2">
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
Page{" "}
|
||||
{table.getPageCount() === 0
|
||||
? 0
|
||||
: table.getState().pagination.pageIndex + 1}{" "}
|
||||
of {table.getPageCount()}
|
||||
<div className="flex w-[120px] items-center justify-center text-sm font-medium">
|
||||
Page {pageIndex + 1} of {table.getPageCount()}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* First page */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => {
|
||||
setCurrentPageIndex(0);
|
||||
}}
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<ChevronDoubleLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Previous */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
setCurrentPageIndex(currentPageIndex - 1);
|
||||
}}
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Next */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
requestMoreData();
|
||||
setCurrentPageIndex(currentPageIndex + 1);
|
||||
}}
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Last */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => {
|
||||
goToFinalPage();
|
||||
setCurrentPageIndex(table.getPageCount() - 1);
|
||||
}}
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<ChevronDoubleRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
36
src/app/portfolio/[slug]/components/useProperties.ts
Normal file
36
src/app/portfolio/[slug]/components/useProperties.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { PropertyWithRelations } from "@/app/db/schema/property";
|
||||
import { PropertyFilter } from "@/app/utils/propertyFilters";
|
||||
|
||||
interface Params {
|
||||
portfolioId: string;
|
||||
filters: PropertyFilter[];
|
||||
}
|
||||
|
||||
export function useProperties({ portfolioId, filters }: Params) {
|
||||
return useQuery<PropertyWithRelations[]>({
|
||||
queryKey: ["properties", portfolioId, filters],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/properties", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
portfolioId,
|
||||
filters,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to fetch properties");
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
cacheTime: 1000 * 60 * 30, // 30 minutes
|
||||
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ import {
|
|||
ScenarioSelect,
|
||||
} from "@/app/db/schema/recommendations";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { PropertyFilter } from "@/app/utils/propertyFilters";
|
||||
import { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/app/utils/epc";
|
||||
|
||||
export interface PortfolioSettingsType {
|
||||
name: string;
|
||||
|
|
@ -416,10 +418,67 @@ export async function getNonDefaultPortfolioScenarios(
|
|||
export async function getProperties(
|
||||
portfolioId: string,
|
||||
limit: number = 1000,
|
||||
offset: number = 0
|
||||
offset: number = 0,
|
||||
filters: PropertyFilter[] = []
|
||||
): Promise<PropertyWithRelations[]> {
|
||||
// We need to perform the query like this because the nested query is not supported in the ORM right now
|
||||
|
||||
const whereClauses: any[] = [];
|
||||
|
||||
for (const filter of filters) {
|
||||
switch (filter.field) {
|
||||
case "address":
|
||||
if (filter.operator === "contains") {
|
||||
whereClauses.push(
|
||||
sql`p.address ILIKE ${"%" + filter.value + "%"}`
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "postcode":
|
||||
if (filter.operator === "starts_with") {
|
||||
whereClauses.push(
|
||||
sql`p.postcode ILIKE ${filter.value + "%"}`
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "currentEpc": {
|
||||
console.log("EPC at most", filter.value)
|
||||
const maxSap =
|
||||
EPC_TO_SAP_MAX[filter.value as keyof typeof EPC_TO_SAP_MAX];
|
||||
if (maxSap === undefined) break;
|
||||
|
||||
if (filter.operator === "epc_at_most") {
|
||||
whereClauses.push(
|
||||
sql`p.current_sap_points <= ${maxSap}`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "expectedEpc": {
|
||||
const minSap =
|
||||
EPC_TO_SAP_MIN[filter.value as keyof typeof EPC_TO_SAP_MIN];
|
||||
if (minSap === undefined) break;
|
||||
|
||||
if (filter.operator === "epc_at_least") {
|
||||
whereClauses.push(
|
||||
sql`
|
||||
pl.post_sap_points IS NOT NULL
|
||||
AND pl.post_sap_points >= ${minSap}
|
||||
`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const combinedWhere =
|
||||
whereClauses.length > 0
|
||||
? sql`AND (${sql.join(whereClauses, sql` AND `)})`
|
||||
: sql``;
|
||||
|
||||
const result =
|
||||
await db.execute<PropertyWithRelations>(sql<PropertyWithRelations>`
|
||||
SELECT
|
||||
|
|
@ -451,6 +510,7 @@ export async function getProperties(
|
|||
AND r.default = true
|
||||
and r.already_installed = false
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
${combinedWhere}
|
||||
GROUP BY
|
||||
p.id,
|
||||
p.portfolio_id,
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
25
src/app/utils/epc.ts
Normal file
25
src/app/utils/epc.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export const EPC_TO_SAP_MIN: Record<
|
||||
"A" | "B" | "C" | "D" | "E" | "F" | "G",
|
||||
number
|
||||
> = {
|
||||
A: 92,
|
||||
B: 81,
|
||||
C: 69,
|
||||
D: 55,
|
||||
E: 39,
|
||||
F: 21,
|
||||
G: 0,
|
||||
};
|
||||
|
||||
export const EPC_TO_SAP_MAX: Record<
|
||||
"A" | "B" | "C" | "D" | "E" | "F" | "G",
|
||||
number
|
||||
> = {
|
||||
A: 100,
|
||||
B: 91,
|
||||
C: 80,
|
||||
D: 68,
|
||||
E: 54,
|
||||
F: 38,
|
||||
G: 20,
|
||||
};
|
||||
18
src/app/utils/propertyFilters.ts
Normal file
18
src/app/utils/propertyFilters.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export type FilterField =
|
||||
| "address"
|
||||
| "postcode"
|
||||
| "currentEpc"
|
||||
| "expectedEpc";
|
||||
|
||||
export type FilterOperator =
|
||||
| "contains"
|
||||
| "starts_with"
|
||||
| "equals"
|
||||
| "epc_at_least"
|
||||
| "epc_at_most";
|
||||
|
||||
export interface PropertyFilter {
|
||||
field: FilterField;
|
||||
operator: FilterOperator;
|
||||
value: string;
|
||||
}
|
||||
|
|
@ -22,8 +22,11 @@ let cachedQueueUrl: string | null = null;
|
|||
export async function getQueueUrl(queueName: string): Promise<string> {
|
||||
if (cachedQueueUrl) return cachedQueueUrl;
|
||||
|
||||
const resp = await sqsClient.send(new GetQueueUrlCommand({ QueueName: queueName }));
|
||||
if (!resp.QueueUrl) throw new Error(`Could not resolve SQS URL for queue: ${queueName}`);
|
||||
const resp = await sqsClient.send(
|
||||
new GetQueueUrlCommand({ QueueName: queueName })
|
||||
);
|
||||
if (!resp.QueueUrl)
|
||||
throw new Error(`Could not resolve SQS URL for queue: ${queueName}`);
|
||||
cachedQueueUrl = resp.QueueUrl;
|
||||
return cachedQueueUrl;
|
||||
}
|
||||
|
|
@ -42,8 +45,10 @@ export async function sendToQueue(
|
|||
messageBody: unknown,
|
||||
opts: SendOptions = {}
|
||||
): Promise<SendMessageCommandOutput> {
|
||||
const queueName = opts.queueName ?? (process.env.AWS_SQS_QUEUE_NAME as string);
|
||||
if (!queueName) throw new Error("Missing AWS_SQS_QUEUE_NAME or sendToQueue opts.queueName");
|
||||
const queueName =
|
||||
opts.queueName ?? (process.env.AWS_SQS_QUEUE_NAME as string);
|
||||
if (!queueName)
|
||||
throw new Error("Missing AWS_SQS_QUEUE_NAME or sendToQueue opts.queueName");
|
||||
|
||||
const queueUrl = await getQueueUrl(queueName);
|
||||
|
||||
|
|
@ -56,7 +61,8 @@ export async function sendToQueue(
|
|||
const isFifo = queueUrl.endsWith(".fifo");
|
||||
if (isFifo) {
|
||||
params.MessageGroupId = opts.groupId ?? "default-group";
|
||||
if (opts.deduplicationId) params.MessageDeduplicationId = opts.deduplicationId;
|
||||
if (opts.deduplicationId)
|
||||
params.MessageDeduplicationId = opts.deduplicationId;
|
||||
}
|
||||
|
||||
if (typeof opts.delaySeconds === "number") {
|
||||
|
|
@ -71,8 +77,8 @@ export async function sendToQueue(
|
|||
* Optionally filter by name prefix.
|
||||
*/
|
||||
export async function listQueues(prefix?: string): Promise<string[]> {
|
||||
const resp = await sqsClient.send(new ListQueuesCommand(
|
||||
prefix ? { QueueNamePrefix: prefix } : {}
|
||||
));
|
||||
const resp = await sqsClient.send(
|
||||
new ListQueuesCommand(prefix ? { QueueNamePrefix: prefix } : {})
|
||||
);
|
||||
return resp.QueueUrls ?? [];
|
||||
}
|
||||
|
|
|
|||
302
src/lib/services/propertyDeletion.ts
Normal file
302
src/lib/services/propertyDeletion.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
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`
|
||||
-- ---------------------------------
|
||||
-- Recommendation chain
|
||||
-- ---------------------------------
|
||||
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 'recommendation', COUNT(*)
|
||||
FROM recommendation
|
||||
WHERE property_id = ${id}
|
||||
|
||||
-- ---------------------------------
|
||||
-- Funding / plan chain
|
||||
-- ---------------------------------
|
||||
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 'funding_package', COUNT(*)
|
||||
FROM funding_package fp
|
||||
JOIN plan p ON fp.plan_id = p.id
|
||||
WHERE p.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 'plan', COUNT(*)
|
||||
FROM plan
|
||||
WHERE property_id = ${id}
|
||||
|
||||
-- ---------------------------------
|
||||
-- Property direct children
|
||||
-- ---------------------------------
|
||||
UNION ALL
|
||||
SELECT 'property_details_epc', COUNT(*)
|
||||
FROM property_details_epc
|
||||
WHERE property_id = ${id}
|
||||
|
||||
UNION ALL
|
||||
SELECT 'property_targets', COUNT(*)
|
||||
FROM property_targets
|
||||
WHERE property_id = ${id}
|
||||
|
||||
UNION ALL
|
||||
SELECT 'property_status_tracker', COUNT(*)
|
||||
FROM property_status_tracker
|
||||
WHERE property_id = ${id}
|
||||
|
||||
UNION ALL
|
||||
SELECT 'inspections', COUNT(*)
|
||||
FROM inspections
|
||||
WHERE property_id = ${id}
|
||||
|
||||
UNION ALL
|
||||
SELECT 'files_from_surveyor', COUNT(*)
|
||||
FROM files_from_surveyor
|
||||
WHERE property_id = ${id}
|
||||
|
||||
-- ---------------------------------
|
||||
-- Root
|
||||
-- ---------------------------------
|
||||
UNION ALL
|
||||
SELECT 'property', COUNT(*)
|
||||
FROM property
|
||||
WHERE id = ${id};
|
||||
`);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function deleteProperty(propertyId: number) {
|
||||
await db.transaction(async (tx) => {
|
||||
// -----------------------------
|
||||
// LEAF TABLES FIRST
|
||||
// -----------------------------
|
||||
|
||||
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 recommendation_materials rm
|
||||
USING recommendation r
|
||||
WHERE rm.recommendation_id = r.id
|
||||
AND r.property_id = ${propertyId};
|
||||
`);
|
||||
|
||||
// -----------------------------
|
||||
// PLAN CHILDREN
|
||||
// -----------------------------
|
||||
|
||||
await tx.execute(sql`
|
||||
DELETE FROM funding_package fp
|
||||
USING plan p
|
||||
WHERE fp.plan_id = p.id
|
||||
AND p.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};
|
||||
`);
|
||||
|
||||
// -----------------------------
|
||||
// PROPERTY DIRECT CHILDREN
|
||||
// -----------------------------
|
||||
|
||||
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 property_status_tracker
|
||||
WHERE property_id = ${propertyId};
|
||||
`);
|
||||
|
||||
await tx.execute(sql`
|
||||
DELETE FROM inspections
|
||||
WHERE property_id = ${propertyId};
|
||||
`);
|
||||
|
||||
await tx.execute(sql`
|
||||
DELETE FROM files_from_surveyor
|
||||
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};
|
||||
`);
|
||||
|
||||
// -----------------------------
|
||||
// PROPERTY LAST (ALWAYS)
|
||||
// -----------------------------
|
||||
|
||||
await tx.execute(sql`
|
||||
DELETE FROM property
|
||||
WHERE id = ${propertyId};
|
||||
`);
|
||||
});
|
||||
}
|
||||
export async function previewPlanDeletion(planId: number) {
|
||||
const result = await db.execute(sql`
|
||||
-- ---------------------------------
|
||||
-- Recommendation materials
|
||||
-- ---------------------------------
|
||||
SELECT 'recommendation_materials' AS table, COUNT(*)::int AS count
|
||||
FROM recommendation_materials rm
|
||||
JOIN recommendation r ON rm.recommendation_id = r.id
|
||||
JOIN plan_recommendations pr ON pr.recommendation_id = r.id
|
||||
WHERE pr.plan_id = ${planId}
|
||||
|
||||
UNION ALL
|
||||
-- ---------------------------------
|
||||
-- Recommendations
|
||||
-- ---------------------------------
|
||||
SELECT 'recommendation', COUNT(*)::int
|
||||
FROM recommendation r
|
||||
JOIN plan_recommendations pr ON pr.recommendation_id = r.id
|
||||
WHERE pr.plan_id = ${planId}
|
||||
|
||||
UNION ALL
|
||||
-- ---------------------------------
|
||||
-- Plan recommendations
|
||||
-- ---------------------------------
|
||||
SELECT 'plan_recommendations', COUNT(*)::int
|
||||
FROM plan_recommendations
|
||||
WHERE plan_id = ${planId}
|
||||
|
||||
UNION ALL
|
||||
-- ---------------------------------
|
||||
-- Funding chain
|
||||
-- ---------------------------------
|
||||
SELECT 'funding_package_measures', COUNT(*)::int
|
||||
FROM funding_package_measures fpm
|
||||
JOIN funding_package fp ON fpm.funding_package_id = fp.id
|
||||
WHERE fp.plan_id = ${planId}
|
||||
|
||||
UNION ALL
|
||||
SELECT 'funding_package', COUNT(*)::int
|
||||
FROM funding_package
|
||||
WHERE plan_id = ${planId}
|
||||
|
||||
UNION ALL
|
||||
-- ---------------------------------
|
||||
-- Root
|
||||
-- ---------------------------------
|
||||
SELECT 'plan', COUNT(*)::int
|
||||
FROM plan
|
||||
WHERE id = ${planId};
|
||||
`);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
export async function deletePlan(planId: number) {
|
||||
await db.transaction(async (tx) => {
|
||||
// ---------------------------------
|
||||
// Recommendation materials (LEAF)
|
||||
// ---------------------------------
|
||||
await tx.execute(sql`
|
||||
DELETE FROM recommendation_materials rm
|
||||
USING recommendation r, plan_recommendations pr
|
||||
WHERE rm.recommendation_id = r.id
|
||||
AND r.id = pr.recommendation_id
|
||||
AND pr.plan_id = ${planId};
|
||||
`);
|
||||
|
||||
// ---------------------------------
|
||||
// Plan recommendations (FK OWNER)
|
||||
// ---------------------------------
|
||||
await tx.execute(sql`
|
||||
DELETE FROM plan_recommendations
|
||||
WHERE plan_id = ${planId};
|
||||
`);
|
||||
|
||||
// ---------------------------------
|
||||
// Recommendations (NOW SAFE)
|
||||
// ---------------------------------
|
||||
await tx.execute(sql`
|
||||
DELETE FROM recommendation r
|
||||
WHERE r.id NOT IN (
|
||||
SELECT recommendation_id FROM plan_recommendations
|
||||
)
|
||||
AND r.id IN (
|
||||
SELECT recommendation_id
|
||||
FROM plan_recommendations
|
||||
WHERE plan_id = ${planId}
|
||||
);
|
||||
`);
|
||||
|
||||
// ---------------------------------
|
||||
// Funding chain
|
||||
// ---------------------------------
|
||||
await tx.execute(sql`
|
||||
DELETE FROM funding_package_measures fpm
|
||||
USING funding_package fp
|
||||
WHERE fpm.funding_package_id = fp.id
|
||||
AND fp.plan_id = ${planId};
|
||||
`);
|
||||
|
||||
await tx.execute(sql`
|
||||
DELETE FROM funding_package
|
||||
WHERE plan_id = ${planId};
|
||||
`);
|
||||
|
||||
// ---------------------------------
|
||||
// Root (LAST)
|
||||
// ---------------------------------
|
||||
await tx.execute(sql`
|
||||
DELETE FROM plan
|
||||
WHERE id = ${planId};
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
// Find All foregin keys used for 'property' table with id via this command
|
||||
// SELECT
|
||||
// tc.table_name,
|
||||
// kcu.column_name,
|
||||
// ccu.table_name AS foreign_table_name,
|
||||
// ccu.column_name AS foreign_column_name
|
||||
// FROM information_schema.table_constraints AS tc
|
||||
// JOIN information_schema.key_column_usage AS kcu
|
||||
// ON tc.constraint_name = kcu.constraint_name
|
||||
// JOIN information_schema.constraint_column_usage AS ccu
|
||||
// ON ccu.constraint_name = tc.constraint_name
|
||||
// WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
// AND ccu.table_name = 'property';
|
||||
6
src/types/next-auth.d.ts
vendored
6
src/types/next-auth.d.ts
vendored
|
|
@ -13,3 +13,9 @@ declare module "next-auth" {
|
|||
onboarded: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
declare module "next-auth/react" {
|
||||
// Use “any” to satisfy TS — next-auth does not export full types here
|
||||
export * from "next-auth";
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue