diff --git a/src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts b/src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts new file mode 100644 index 00000000..4bf3ecbe --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/properties/[propertyId]/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { z } from "zod"; +import { db } from "@/app/db/db"; +import { property } from "@/app/db/schema/property"; +import { and, eq } from "drizzle-orm"; + +// Edit an unmatched property's address / postcode. Keyed by (property, +// portfolio) so a caller can only touch a property in a portfolio they +// addressed. UPRN assignment (via the address search) is deliberately out of +// scope here — this only corrects the address text. +const patchSchema = z + .object({ + address: z.string().trim().optional(), + postcode: z.string().trim().optional(), + }) + .refine((v) => v.address !== undefined || v.postcode !== undefined, { + message: "Nothing to update", + }); + +export async function PATCH( + request: NextRequest, + props: { params: Promise<{ portfolioId: string; propertyId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + const { portfolioId, propertyId } = await props.params; + if (!/^\d+$/.test(portfolioId) || !/^\d+$/.test(propertyId)) { + return NextResponse.json({ error: "Invalid id" }, { status: 400 }); + } + + const parsed = patchSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? "Invalid request" }, + { status: 400 }, + ); + } + const { address, postcode } = parsed.data; + + const updated = await db + .update(property) + .set({ + ...(address !== undefined ? { address } : {}), + ...(postcode !== undefined ? { postcode } : {}), + updatedAt: new Date(), + }) + .where( + and( + eq(property.id, BigInt(propertyId)), + eq(property.portfolioId, BigInt(portfolioId)), + ), + ) + .returning({ id: property.id }); + + if (updated.length === 0) { + return NextResponse.json({ error: "Property not found" }, { status: 404 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/page.tsx b/src/app/portfolio/[slug]/(portfolio)/page.tsx index d4514980..9af81277 100644 --- a/src/app/portfolio/[slug]/(portfolio)/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/page.tsx @@ -17,7 +17,7 @@ export default async function Page(props: { return ( <> - + ); diff --git a/src/app/portfolio/[slug]/components/EditAddress.tsx b/src/app/portfolio/[slug]/components/EditAddress.tsx new file mode 100644 index 00000000..087d1252 --- /dev/null +++ b/src/app/portfolio/[slug]/components/EditAddress.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { PencilSquareIcon } from "@heroicons/react/24/outline"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/app/shadcn_components/ui/dialog"; +import { useUpdateAddress } from "@/lib/properties/client"; +import type { UnmatchedProperty } from "@/lib/properties/unmatched"; + +// Row action for an unmatched property: correct its address / postcode. UPRN +// matching is a later step — this just saves the text. On success the page +// re-fetches so the row shows the updated values. +export default function EditAddress({ + property, + portfolioId, +}: { + property: UnmatchedProperty; + portfolioId: string; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [address, setAddress] = useState(property.address ?? ""); + const [postcode, setPostcode] = useState(property.postcode ?? ""); + + const update = useUpdateAddress(portfolioId, property.id); + + function onSave() { + update.mutate( + { address: address.trim(), postcode: postcode.trim() }, + { + onSuccess: () => { + setOpen(false); + router.refresh(); + }, + }, + ); + } + + return ( + <> + + + + + + Edit address + + Correct the address and postcode for this property. + + + +
+
+ + setAddress(e.target.value)} + placeholder="e.g. 20 Brenchley Road" + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400" + /> +
+
+ + setPostcode(e.target.value)} + placeholder="e.g. BR5 2TD" + onKeyDown={(e) => { + if (e.key === "Enter" && !update.isPending) onSave(); + }} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400" + /> +
+
+ + + {update.error && ( +

+ {update.error.message} +

+ )} + + +
+
+
+ + ); +} diff --git a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx index 9d6ef33e..60a1dcdf 100644 --- a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx +++ b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx @@ -1,18 +1,17 @@ -import { - ExclamationTriangleIcon, - MagnifyingGlassIcon, -} from "@heroicons/react/24/outline"; +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import type { UnmatchedProperty } from "@/lib/properties/unmatched"; +import EditAddress from "./EditAddress"; // The "needs attention" workspace that separates properties with no UPRN from -// the matched portfolio table. Read-only for now; the per-row action is where -// the advanced ARA search (edit address/postcode → search UPRN) will hang off. -// Renders nothing when every property is matched, so a healthy portfolio stays -// uncluttered. +// the matched portfolio table. Each row lets the user correct the address / +// postcode (UPRN matching is a later step). Renders nothing when every property +// is matched, so a healthy portfolio stays uncluttered. export default function UnmatchedProperties({ properties, + portfolioId, }: { properties: UnmatchedProperty[]; + portfolioId: string; }) { if (properties.length === 0) return null; @@ -67,15 +66,7 @@ export default function UnmatchedProperties({ {p.postcode ?? }

- +
))} diff --git a/src/lib/properties/client.ts b/src/lib/properties/client.ts new file mode 100644 index 00000000..44848424 --- /dev/null +++ b/src/lib/properties/client.ts @@ -0,0 +1,31 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; + +async function parseError(res: Response, fallback: string): Promise { + const body = await res.json().catch(() => ({})); + return new Error(body?.error ?? fallback); +} + +export interface UpdateAddressInput { + address: string; + postcode: string; +} + +// Save an edited address / postcode onto a property. +export function useUpdateAddress(portfolioId: string, propertyId: string) { + return useMutation<{ ok: true }, Error, UpdateAddressInput>({ + mutationFn: async (input) => { + const res = await fetch( + `/api/portfolio/${portfolioId}/properties/${propertyId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }, + ); + if (!res.ok) throw await parseError(res, "Failed to save address."); + return res.json(); + }, + }); +}