mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-19 17:02:59 +00:00
feat(portfolio): match unmatched properties to a residential UPRN
Turns the "Needs attention" tab into an actionable per-property match flow: edit the address, confirm the postcode, search Ordnance Survey, and pick from the RESIDENTIAL addresses in that postcode. Every candidate is a real OS address, so picking one assigns a guaranteed valid, residential UPRN — the property then leaves the tab and joins the main table. - MatchAddress dialog: postcode search + residential-only candidate list (non-residential filtered out; already-in-portfolio shown but non-selectable, each row shows its UPRN). Replaces the edit-only EditAddress. - PATCH /properties/[propertyId]: assigns uprn + address + postcode (user input kept as the fallback); (portfolio_id, uprn) clash → friendly 409. - client.ts: searchAddresses() + useAssignUprn(). Reuses the existing /properties/search endpoint, which read-through caches OS Places results per postcode in postcode_search (30-day TTL) — repeat searches of the same postcode hit our DB, not the OS API (surfaced as a "from cache" hint in the dialog). No backend trigger needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aab3984b5c
commit
3f8920acd0
5 changed files with 339 additions and 157 deletions
|
|
@ -6,18 +6,26 @@ 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",
|
||||
});
|
||||
// Assign a chosen Ordnance Survey address (from the postcode search) to an
|
||||
// existing property: writes the canonical uprn/address/postcode and keeps what
|
||||
// the user typed as the user-inputted fallback. Keyed by (property, portfolio)
|
||||
// so a caller can only touch a property in a portfolio they addressed. The
|
||||
// candidate is guaranteed residential + UPRN-bearing by the picker UI.
|
||||
const patchSchema = z.object({
|
||||
uprn: z.string().regex(/^\d+$/, "UPRN must be numeric"),
|
||||
address: z.string().trim().min(1, "Address is required"),
|
||||
postcode: z.string().trim().min(1, "Postcode is required"),
|
||||
// What the user typed in the edit fields — kept as the raw fallback, mirroring
|
||||
// how the bulk finaliser stores user_inputted_* alongside the matched values.
|
||||
userInputtedAddress: z.string().trim().optional(),
|
||||
userInputtedPostcode: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
const code = (err as { code?: string })?.code;
|
||||
const message = (err as { message?: string })?.message ?? "";
|
||||
return code === "23505" || message.includes("uq_property_portfolio_uprn");
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
|
|
@ -40,25 +48,43 @@ export async function PATCH(
|
|||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const { address, postcode } = parsed.data;
|
||||
const { uprn, address, postcode, userInputtedAddress, userInputtedPostcode } =
|
||||
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 });
|
||||
try {
|
||||
const updated = await db
|
||||
.update(property)
|
||||
.set({
|
||||
uprn: BigInt(uprn),
|
||||
address,
|
||||
postcode,
|
||||
...(userInputtedAddress !== undefined ? { userInputtedAddress } : {}),
|
||||
...(userInputtedPostcode !== undefined ? { userInputtedPostcode } : {}),
|
||||
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 });
|
||||
if (updated.length === 0) {
|
||||
return NextResponse.json({ error: "Property not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"That UPRN is already assigned to another property in this portfolio.",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
console.error("PATCH /properties/[propertyId] error:", err);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
239
src/app/portfolio/[slug]/components/MatchAddress.tsx
Normal file
239
src/app/portfolio/[slug]/components/MatchAddress.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
CheckCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/app/shadcn_components/ui/dialog";
|
||||
import { describeCandidate } from "@/lib/postcodeSearch/model";
|
||||
import {
|
||||
searchAddresses,
|
||||
useAssignUprn,
|
||||
type SearchCandidate,
|
||||
} from "@/lib/properties/client";
|
||||
import type { UnmatchedProperty } from "@/lib/properties/unmatched";
|
||||
|
||||
// Row action for an unmatched property: edit the address, confirm the postcode,
|
||||
// search Ordnance Survey, and pick from the RESIDENTIAL addresses in that
|
||||
// postcode. Picking assigns that candidate's UPRN — every candidate is a real
|
||||
// OS address, so this guarantees a valid, residential UPRN. On success the
|
||||
// property leaves the "Needs attention" tab (the page re-fetches).
|
||||
export default function MatchAddress({
|
||||
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 ?? "");
|
||||
// null = not searched yet; [] = searched, no residential matches.
|
||||
const [residential, setResidential] = useState<SearchCandidate[] | null>(null);
|
||||
const [selected, setSelected] = useState<SearchCandidate | null>(null);
|
||||
const [source, setSource] = useState<"cache" | "live" | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
|
||||
const assign = useAssignUprn(portfolioId, property.id);
|
||||
|
||||
async function runSearch() {
|
||||
setSearching(true);
|
||||
setSearchError(null);
|
||||
setResidential(null);
|
||||
setSelected(null);
|
||||
try {
|
||||
const result = await searchAddresses(portfolioId, postcode);
|
||||
// Residential only — we never assign a non-residential UPRN.
|
||||
setResidential(result.candidates.filter((c) => c.selectable));
|
||||
setSource(result.source);
|
||||
} catch (e) {
|
||||
setSearchError(e instanceof Error ? e.message : "Search failed.");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onAssign() {
|
||||
if (!selected) return;
|
||||
assign.mutate(
|
||||
{
|
||||
uprn: selected.uprn,
|
||||
address: selected.address,
|
||||
postcode: selected.postcode,
|
||||
userInputtedAddress: address.trim() || undefined,
|
||||
userInputtedPostcode: postcode.trim() || undefined,
|
||||
},
|
||||
{
|
||||
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"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-3.5 w-3.5" />
|
||||
Find UPRN
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Match to a UPRN</DialogTitle>
|
||||
<DialogDescription>
|
||||
Confirm the postcode and pick the matching address. Only
|
||||
residential addresses from Ordnance Survey are shown, so the UPRN
|
||||
is always valid.
|
||||
</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 className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<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" && postcode.trim() && !searching)
|
||||
runSearch();
|
||||
}}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runSearch}
|
||||
disabled={!postcode.trim() || searching}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4" />
|
||||
{searching ? "Searching…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searchError && <p className="text-xs text-red-600">{searchError}</p>}
|
||||
|
||||
{residential && (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-gray-600">
|
||||
Residential addresses
|
||||
</p>
|
||||
{source === "cache" && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-gray-400">
|
||||
from cache
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto rounded-lg border border-gray-100">
|
||||
{residential.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-gray-400">
|
||||
No residential addresses found for that postcode.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-50">
|
||||
{residential.map((c) => {
|
||||
const { label } = describeCandidate(c);
|
||||
const disabled = c.alreadyInPortfolio;
|
||||
const isSelected = selected?.uprn === c.uprn;
|
||||
return (
|
||||
<li key={c.uprn}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setSelected(c)}
|
||||
className={`flex w-full items-start gap-2 px-3 py-2 text-left text-sm transition-colors ${
|
||||
disabled
|
||||
? "cursor-not-allowed text-gray-400"
|
||||
: isSelected
|
||||
? "bg-amber-50"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<CheckCircleIcon
|
||||
className={`mt-0.5 h-4 w-4 shrink-0 ${
|
||||
isSelected ? "text-amber-600" : "text-gray-200"
|
||||
}`}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-gray-800">
|
||||
{c.address}
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-500">
|
||||
UPRN {c.uprn} · {label}
|
||||
{c.alreadyInPortfolio
|
||||
? " · already in portfolio"
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{assign.error && (
|
||||
<p className="mr-auto self-center text-xs text-red-600">
|
||||
{assign.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={onAssign}
|
||||
disabled={!selected || assign.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"
|
||||
>
|
||||
{assign.isPending ? "Assigning…" : "Assign UPRN"}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import {
|
|||
CheckCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { UnmatchedProperty } from "@/lib/properties/unmatched";
|
||||
import EditAddress from "./EditAddress";
|
||||
import MatchAddress from "./MatchAddress";
|
||||
|
||||
// The "Needs attention" tab: properties with no UPRN, separated out of the main
|
||||
// table until they're matched. Each row lets the user correct the address /
|
||||
|
|
@ -81,7 +81,7 @@ export default function UnmatchedProperties({
|
|||
{p.postcode ?? <span className="text-gray-400">—</span>}
|
||||
</p>
|
||||
<div className="col-span-3 flex justify-end">
|
||||
<EditAddress property={p} portfolioId={portfolioId} />
|
||||
<MatchAddress property={p} portfolioId={portfolioId} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,51 @@ async function parseError(res: Response, fallback: string): Promise<Error> {
|
|||
return new Error(body?.error ?? fallback);
|
||||
}
|
||||
|
||||
export interface UpdateAddressInput {
|
||||
// One address candidate returned by the portfolio postcode search, plus the
|
||||
// server-computed flag for candidates already assigned in this portfolio.
|
||||
export interface SearchCandidate {
|
||||
uprn: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
classificationCode: string | null;
|
||||
selectable: boolean; // residential (per OS classification)
|
||||
propertyType: string | null;
|
||||
builtForm: string | null;
|
||||
alreadyInPortfolio: boolean;
|
||||
}
|
||||
|
||||
// Save an edited address / postcode onto a property.
|
||||
export function useUpdateAddress(portfolioId: string, propertyId: string) {
|
||||
return useMutation<{ ok: true }, Error, UpdateAddressInput>({
|
||||
export interface AddressSearchResult {
|
||||
postcode: string;
|
||||
source: "cache" | "live";
|
||||
candidates: SearchCandidate[];
|
||||
}
|
||||
|
||||
// Run the postcode → address-candidate search. Reuses the add-properties
|
||||
// endpoint, which read-through caches OS Places results per postcode in the
|
||||
// postcode_search table (30-day TTL) — so repeat searches of the same postcode
|
||||
// hit our DB, not the OS API. Throws with the server's { error } on failure.
|
||||
export async function searchAddresses(
|
||||
portfolioId: string,
|
||||
postcode: string,
|
||||
): Promise<AddressSearchResult> {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/properties/search?postcode=${encodeURIComponent(postcode)}`,
|
||||
);
|
||||
if (!res.ok) throw await parseError(res, "Address search failed.");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface AssignUprnInput {
|
||||
uprn: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
userInputtedAddress?: string;
|
||||
userInputtedPostcode?: string;
|
||||
}
|
||||
|
||||
// Assign a chosen candidate's UPRN + address to a property.
|
||||
export function useAssignUprn(portfolioId: string, propertyId: string) {
|
||||
return useMutation<{ ok: true }, Error, AssignUprnInput>({
|
||||
mutationFn: async (input) => {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/properties/${propertyId}`,
|
||||
|
|
@ -24,7 +61,7 @@ export function useUpdateAddress(portfolioId: string, propertyId: string) {
|
|||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw await parseError(res, "Failed to save address.");
|
||||
if (!res.ok) throw await parseError(res, "Failed to assign UPRN.");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue