Merge pull request #160 from Hestia-Homes/feature/make_delete_work

Feature/make delete work
This commit is contained in:
KhalimCK 2026-01-19 18:13:57 +00:00 committed by GitHub
commit 04df686c3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2559 additions and 1411 deletions

25
.github/workflows/nextjs-build.yml vendored Normal file
View 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
View 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
},
}

View file

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

File diff suppressed because it is too large Load diff

View file

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

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

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

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

View file

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

View file

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

View file

@ -222,3 +222,8 @@
display: none !important;
}
}
@keyframes loading {
0% { transform: translateX(-100%); }
100% { transform: translateX(300%); }
}

View file

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

View file

@ -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 &quot;New Property&quot; 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}/>
</>
);
}
}

View file

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

View file

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

View file

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

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

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

View file

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

View 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

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

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

View file

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

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

View file

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

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

View file

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

View 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;
}

View file

@ -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 ?? [];
}

View 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';

View file

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