mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
4fefec7cca
commit
23fba68727
5 changed files with 224 additions and 18 deletions
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ export default async function Page(props: {
|
|||
|
||||
return (
|
||||
<>
|
||||
<UnmatchedProperties properties={unmatched} />
|
||||
<UnmatchedProperties properties={unmatched} portfolioId={portfolioId} />
|
||||
<PropertyTable portfolioId={portfolioId} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
120
src/app/portfolio/[slug]/components/EditAddress.tsx
Normal file
120
src/app/portfolio/[slug]/components/EditAddress.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
31
src/lib/properties/client.ts
Normal file
31
src/lib/properties/client.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue