Redesign registrations list: client-side table with copy/share links, sorted newest first

This commit is contained in:
SJCoetz 2026-07-30 20:38:24 +02:00
parent 45e017c7c3
commit 68ce2bd586
2 changed files with 112 additions and 36 deletions

View file

@ -18,12 +18,15 @@ export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const q = searchParams.get("q") || "";
const fields = searchParams.get("fields") || "id,reference_name,status,proposed_name_1";
const fields = searchParams.get("fields") || "id,reference_name,status,proposed_name_1,trading_address,created";
const perPage = Math.min(Number(searchParams.get("perPage")) || 500, 500);
const sort = searchParams.get("sort") || "-created";
const data = await fetchCollection<RegItem>("ysterbaard_company_registrations", {
page: 1,
perPage: 20,
perPage,
fields,
sort,
});
let items = data.items;
@ -38,7 +41,7 @@ export async function GET(req: NextRequest) {
);
}
// Sort newest first
// Default sort: newest first (nanoid is time-sortable)
items.sort((a, b) => b.id.localeCompare(a.id));
// Return lightweight response

View file

@ -1,38 +1,84 @@
// app/registrations/page.tsx
// Server component. Lists submitted company registrations.
// Client component. Lists submitted company registrations.
// Uses /api/register/search for data (client-safe).
import { fetchCollection } from "@/lib/pb";
import type { PBList, PBRecord } from "@/lib/pb";
"use client";
import { useState, useEffect } from "react";
import PageHeader from "@/components/PageHeader";
import Link from "next/link";
export const dynamic = "force-dynamic";
interface RegItem extends PBRecord {
interface RegItem {
id: string;
reference_name: string;
status: string;
proposed_name_1: string;
trading_address: string;
created: string;
}
export default async function RegistrationsPage() {
const data = await fetchCollection<RegItem>("ysterbaard_company_registrations", {
page: 1,
perPage: 200,
fields: "id,reference_name,status,proposed_name_1,trading_address",
function formatDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-ZA", {
day: "2-digit", month: "short", year: "numeric",
});
}
const items = data.items.sort((a, b) => {
// Use registration ID as proxy for recency (nanoid is time-sortable)
return b.id.localeCompare(a.id);
});
const statusLabel: Record<string, string> = {
pending: "Pending",
submitted: "Submitted",
approved: "Approved",
rejected: "Rejected",
draft: "Draft",
};
const statusLabel: Record<string, string> = {
pending: "Pending",
submitted: "Submitted",
approved: "Approved",
rejected: "Rejected",
const statusStyle: Record<string, string> = {
pending: "bg-surface border border-border text-muted",
draft: "bg-surface border border-border text-muted",
submitted: "bg-accent/10 text-accent border border-accent/20",
approved: "bg-success/10 text-success border border-success/20",
rejected: "bg-danger/10 text-danger border border-danger/20",
};
export default function RegistrationsPage() {
const [items, setItems] = useState<RegItem[]>([]);
const [loading, setLoading] = useState(true);
const [copiedId, setCopiedId] = useState<string | null>(null);
useEffect(() => {
fetch("/api/register/search?q=&fields=id,reference_name,status,proposed_name_1,trading_address,created&sort=-created")
.then((r) => r.json())
.then((d) => {
setItems(d.items || []);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const handleCopyLink = (id: string) => {
const url = `${window.location.origin}/register/${id}`;
const el = document.createElement("textarea");
el.value = url;
el.style.position = "fixed";
el.style.opacity = "0";
document.body.appendChild(el);
el.focus();
el.select();
document.execCommand("copy");
document.body.removeChild(el);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const canShare = typeof navigator !== "undefined" && !!navigator.share;
const handleShare = (item: RegItem) => {
const url = `${window.location.origin}/register/${item.id}`;
navigator.share({
title: "Company Registration",
text: `Registration: ${item.reference_name || item.id}`,
url,
}).catch(() => {});
};
return (
@ -43,7 +89,9 @@ export default async function RegistrationsPage() {
subtitle="Client submissions from the registration wizard."
/>
<div className="container-x py-10">
{items.length === 0 ? (
{loading ? (
<div className="text-center py-20 text-muted">Loading...</div>
) : items.length === 0 ? (
<div className="bg-surface-warm border border-border rounded-lg p-12 text-center">
<p className="text-muted">No registrations yet.</p>
<p className="text-sm text-muted mt-1">
@ -56,20 +104,15 @@ export default async function RegistrationsPage() {
<thead>
<tr className="border-b border-border bg-surface-warm text-left">
<th className="px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wider">Reference</th>
<th className="px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wider">Company name</th>
<th className="px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wider">Address</th>
<th className="px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wider hidden sm:table-cell">Company</th>
<th className="px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wider hidden md:table-cell">Date</th>
<th className="px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wider">Status</th>
<th className="px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((r) => {
const status = r.status || "pending";
const statusStyle: Record<string, string> = {
pending: "bg-surface border border-border text-muted",
submitted: "bg-accent/10 text-accent border border-accent/20",
approved: "bg-success/10 text-success border border-success/20",
rejected: "bg-danger/10 text-danger border border-danger/20",
};
const status = r.status || "draft";
return (
<tr key={r.id} className="hover:bg-surface-warm transition-colors">
<td className="px-5 py-4">
@ -80,13 +123,43 @@ export default async function RegistrationsPage() {
{r.reference_name || r.id}
</Link>
</td>
<td className="px-5 py-4 text-muted">{r.proposed_name_1 || "—"}</td>
<td className="px-5 py-4 text-muted max-w-xs truncate">{r.trading_address || "—"}</td>
<td className="px-5 py-4 text-muted hidden sm:table-cell max-w-xs truncate">
{r.proposed_name_1 || "—"}
</td>
<td className="px-5 py-4 text-muted hidden md:table-cell text-xs tabular-nums">
{formatDate(r.created)}
</td>
<td className="px-5 py-4">
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-semibold ${statusStyle[status] || statusStyle.pending}`}>
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-semibold ${statusStyle[status] || statusStyle.draft}`}>
{statusLabel[status] || status}
</span>
</td>
<td className="px-5 py-4">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleCopyLink(r.id)}
className="px-3 py-1.5 bg-surface border border-border rounded text-xs font-semibold text-fg hover:border-accent hover:text-accent transition-colors whitespace-nowrap"
title="Copy client wizard link"
>
{copiedId === r.id ? "✓ Copied" : "Copy link"}
</button>
{canShare && (
<button
onClick={() => handleShare(r)}
className="px-3 py-1.5 bg-surface border border-border rounded text-xs font-semibold text-fg hover:border-accent hover:text-accent transition-colors"
title="Share link"
>
Share
</button>
)}
<Link
href={`/registrations/${r.id}`}
className="px-3 py-1.5 bg-accent text-accent-on rounded text-xs font-semibold hover:opacity-90 no-underline transition-opacity"
>
View →
</Link>
</div>
</td>
</tr>
);
})}