feat(portfolio): edit address/postcode on unmatched properties

Each unmatched-property row now opens a small dialog to correct its address and
postcode, saved via a new endpoint. UPRN matching (the address search) is left
for a later change — this only fixes the address text so the record is right
before matching.

- PATCH /api/portfolio/[portfolioId]/properties/[propertyId]: auth + zod,
  updates address/postcode scoped by (property, portfolio).
- properties/client.ts: useUpdateAddress() mutation.
- EditAddress: the edit dialog; on save the page re-fetches (router.refresh)
  so the row reflects the new values.
- UnmatchedProperties: row action swapped from the placeholder to the live
  Edit dialog; portfolioId threaded through from the page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-06 18:41:19 +00:00
parent 4fefec7cca
commit 23fba68727
5 changed files with 224 additions and 18 deletions

View file

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

View file

@ -17,7 +17,7 @@ export default async function Page(props: {
return (
<>
<UnmatchedProperties properties={unmatched} />
<UnmatchedProperties properties={unmatched} portfolioId={portfolioId} />
<PropertyTable portfolioId={portfolioId} />
</>
);

View file

@ -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 (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-800 transition-colors hover:bg-amber-100"
>
<PencilSquareIcon className="h-3.5 w-3.5" />
Edit address
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit address</DialogTitle>
<DialogDescription>
Correct the address and postcode for this property.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-semibold text-gray-600">
Address
</label>
<input
value={address}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-gray-600">
Postcode
</label>
<input
value={postcode}
onChange={(e) => 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"
/>
</div>
</div>
<DialogFooter>
{update.error && (
<p className="mr-auto self-center text-xs text-red-600">
{update.error.message}
</p>
)}
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg px-4 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-100"
>
Cancel
</button>
<button
type="button"
onClick={onSave}
disabled={update.isPending}
className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
>
{update.isPending ? "Saving…" : "Save"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -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 ?? <span className="text-gray-400"></span>}
</p>
<div className="col-span-3 flex justify-end">
<button
type="button"
disabled
title="Advanced ARA search — coming soon"
className="inline-flex cursor-not-allowed items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-400"
>
<MagnifyingGlassIcon className="h-3.5 w-3.5" />
Search UPRN
</button>
<EditAddress property={p} portfolioId={portfolioId} />
</div>
</li>
))}

View file

@ -0,0 +1,31 @@
"use client";
import { useMutation } from "@tanstack/react-query";
async function parseError(res: Response, fallback: string): Promise<Error> {
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();
},
});
}