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