Adds the full Next.js app (app/, components/, lib/, scripts/, assets/, docs/, Dockerfile, compose, configs) to version control. Security: - CIPC BizPortal customer-code logins (Login 1 + Login 3) now read CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE / CIPC_LOGIN3_PASSWORD from .env.local instead of source. - BizPortal personal login in apply-bizportal route reads BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD. - Added .env.example as the committed template. .gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright storage_state.json (live CIPC cookies), scratch dirs, client PDFs and debug screenshots.
559 lines
24 KiB
TypeScript
559 lines
24 KiB
TypeScript
// app/registrations/page.tsx
|
|
// Client component. Lists ALL fields of every record in
|
|
// ysterbaard_company_registrations, with search and per-row view links.
|
|
|
|
"use client";
|
|
|
|
import { useState, useEffect, useMemo } from "react";
|
|
import PageHeader from "@/components/PageHeader";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import ActionsDropdown from "@/components/ActionsDropdown";
|
|
|
|
interface RegItem {
|
|
id: string;
|
|
[k: string]: unknown;
|
|
}
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
draft: "Draft",
|
|
pending_registration: "Pending Client",
|
|
submitted: "Step 1 Done",
|
|
registered: "Step 2 — Registered",
|
|
bizportal_in_progress: "Bizportal submission in progress",
|
|
bizportal_done: "Bizportal received application",
|
|
name_approved: "Name approved by Bizportal",
|
|
step3: "Awaiting client approval",
|
|
name_reserved: "Name reserved (workflow done)",
|
|
client_requested_changes: "Step 2 — Client requested changes",
|
|
resignation_sent: "Step 3 — Resignation Sent",
|
|
appointment_accepted: "Step 4 — Appointment",
|
|
cancelled: "Cancelled",
|
|
// legacy values
|
|
pending: "Pending",
|
|
approved: "Approved",
|
|
rejected: "Rejected",
|
|
};
|
|
|
|
const STATUS_STYLE: Record<string, string> = {
|
|
draft: "bg-surface border border-border text-muted",
|
|
pending_registration: "bg-surface border border-border text-muted",
|
|
submitted: "bg-blue-100 text-blue-700 border border-blue-300",
|
|
registered: "bg-success/10 text-success border border-success/30",
|
|
bizportal_in_progress: "bg-blue-500/10 text-blue-700 border border-blue-500/30",
|
|
bizportal_done: "bg-yellow-500/10 text-yellow-700 border border-yellow-500/30",
|
|
name_approved: "bg-success/10 text-success border border-success/30",
|
|
step3: "bg-purple-500/10 text-purple-700 border border-purple-500/30",
|
|
name_reserved: "bg-purple-500/10 text-purple-700 border border-purple-500/30",
|
|
client_requested_changes: "bg-yellow-500/10 text-yellow-700 border border-yellow-500/30",
|
|
resignation_sent: "bg-purple-100 text-purple-700 border border-purple-300",
|
|
appointment_accepted: "bg-accent/10 text-accent border border-accent/30",
|
|
cancelled: "bg-danger/10 text-danger border border-danger/30 line-through",
|
|
pending: "bg-surface border border-border text-muted",
|
|
approved: "bg-success/10 text-success border border-success/20",
|
|
rejected: "bg-danger/10 text-danger border border-danger/20",
|
|
};
|
|
|
|
const STEP_NUMBER: Record<string, number> = {
|
|
draft: 0,
|
|
pending_registration: 1,
|
|
submitted: 1,
|
|
registered: 2,
|
|
bizportal_in_progress: 2,
|
|
bizportal_done: 2,
|
|
name_approved: 2,
|
|
step3: 3,
|
|
name_reserved: 3,
|
|
client_requested_changes: 2,
|
|
resignation_sent: 3,
|
|
appointment_accepted: 4,
|
|
cancelled: -1,
|
|
};
|
|
|
|
const HIDDEN_SYSTEM_FIELDS = new Set([
|
|
"collectionId", "collectionName",
|
|
"expand", // relation expansions, not user-relevant here
|
|
]);
|
|
|
|
// Columns shown in the table (in order). Everything else is shown in the View modal.
|
|
const TABLE_COLUMNS: { key: string; label: string }[] = [
|
|
{ key: "reference_name", label: "Reference" },
|
|
{ key: "proposed_name_1", label: "Proposed Name" },
|
|
{ key: "client_submitted_at", label: "Submitted" },
|
|
{ key: "_step", label: "Step" },
|
|
{ key: "status", label: "Status" },
|
|
{ key: "_actions", label: "Actions" },
|
|
{ key: "company_email", label: "Email" },
|
|
{ key: "company_cell", label: "Cell" },
|
|
{ key: "trading_address", label: "Trading Address" },
|
|
{ key: "financial_year_end", label: "FYE" },
|
|
{ key: "authorised_shares", label: "Auth Shares" },
|
|
{ key: "registration_id", label: "Reg ID" },
|
|
];
|
|
|
|
function formatValue(v: unknown): string {
|
|
if (v === null || v === undefined || v === "") return "—";
|
|
if (typeof v === "boolean") return v ? "✓" : "✗";
|
|
if (typeof v === "string") {
|
|
// Render ISO date strings as "YYYY-MM-DD HH:mm" (SAST) for compact table display.
|
|
if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(v)) {
|
|
const d = new Date(v);
|
|
if (!isNaN(d.getTime())) {
|
|
const yyyy = d.getUTCFullYear();
|
|
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
const dd = String(d.getUTCDate()).padStart(2, "0");
|
|
const hh = String(d.getUTCHours()).padStart(2, "0");
|
|
const min = String(d.getUTCMinutes()).padStart(2, "0");
|
|
return `${yyyy}-${mm}-${dd} ${hh}:${min}`;
|
|
}
|
|
}
|
|
return v;
|
|
}
|
|
return JSON.stringify(v);
|
|
}
|
|
|
|
function formatDate(iso: string | null | undefined): string {
|
|
if (!iso) return "—";
|
|
try {
|
|
return new Date(iso).toLocaleString("en-ZA", {
|
|
day: "2-digit", month: "short", year: "numeric",
|
|
hour: "2-digit", minute: "2-digit",
|
|
});
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
export default function RegistrationsPage() {
|
|
const [items, setItems] = useState<RegItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState("");
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
const [reopeningId, setReopeningId] = useState<string | null>(null);
|
|
const [reopenMsg, setReopenMsg] = useState<string>("");
|
|
const [showCancelled, setShowCancelled] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetch("/api/register/all?perPage=1000")
|
|
.then((r) => r.json())
|
|
.then((d) => {
|
|
setItems(d.items || []);
|
|
setLoading(false);
|
|
})
|
|
.catch(() => setLoading(false));
|
|
}, []);
|
|
|
|
// Manual refresh — pulled out of useEffect so the button can call it
|
|
async function refresh() {
|
|
setLoading(true);
|
|
try {
|
|
const r = await fetch("/api/register/all?perPage=1000");
|
|
const d = await r.json();
|
|
setItems(d.items || []);
|
|
} catch {
|
|
// swallow — keep existing items
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
// Auto-refresh every 30s while the page is open
|
|
useEffect(() => {
|
|
const id = setInterval(refresh, 30_000);
|
|
return () => clearInterval(id);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const filtered = useMemo(() => {
|
|
let list = items;
|
|
if (!showCancelled) {
|
|
list = list.filter(it => (it.status || "") !== "cancelled");
|
|
}
|
|
if (!search.trim()) return list;
|
|
const ql = search.toLowerCase();
|
|
return list.filter((it) =>
|
|
Object.entries(it).some(([k, v]) => {
|
|
if (HIDDEN_SYSTEM_FIELDS.has(k)) return false;
|
|
if (typeof v !== "string") return false;
|
|
return v.toLowerCase().includes(ql);
|
|
})
|
|
);
|
|
}, [items, search, showCancelled]);
|
|
|
|
const cancelledCount = useMemo(() => items.filter(it => it.status === "cancelled").length, [items]);
|
|
|
|
const handleCopyLink = (id: string) => {
|
|
// Plain link for the client — fills form blank, submits, stays on success screen
|
|
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(() => {});
|
|
};
|
|
|
|
async function handleReopen(item: RegItem, withPrefill: boolean) {
|
|
setReopeningId(item.id);
|
|
setReopenMsg("");
|
|
try {
|
|
const res = await fetch(`/api/register/${item.id}/reopen`, { method: "POST" });
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setReopenMsg(`❌ Reopen failed: ${data.error || res.status}`);
|
|
return;
|
|
}
|
|
// Update local state
|
|
setItems(prev => prev.map(i => i.id === item.id ? { ...i, status: "submitted" } : i));
|
|
setReopenMsg(`✅ Reopened — opening wizard${withPrefill ? " with existing data" : ""}…`);
|
|
// Open the wizard in a new tab
|
|
const url = `${window.location.origin}/register/${item.id}${withPrefill ? "?prefill=1" : ""}`;
|
|
window.open(url, "_blank");
|
|
} catch (err: any) {
|
|
setReopenMsg(`❌ Error: ${err.message}`);
|
|
} finally {
|
|
setReopeningId(null);
|
|
setTimeout(() => setReopenMsg(""), 4000);
|
|
}
|
|
}
|
|
|
|
async function handleStatusAction(item: RegItem, action: "approve" | "cancel" | "reopen" | "back_to_step1") {
|
|
setReopeningId(item.id);
|
|
setReopenMsg("");
|
|
try {
|
|
const res = await fetch(`/api/register/${item.id}/status?action=${action}`, { method: "POST" });
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setReopenMsg(`❌ ${action} failed: ${data.error || res.status}`);
|
|
return;
|
|
}
|
|
// Update local state
|
|
setItems(prev => prev.map(i => i.id === item.id ? { ...i, status: data.status } : i));
|
|
if (action === "approve") setReopenMsg(`✅ Approved.`);
|
|
} catch (err: any) {
|
|
setReopenMsg(`❌ Error: ${err.message}`);
|
|
} finally {
|
|
setReopeningId(null);
|
|
setTimeout(() => setReopenMsg(""), 4000);
|
|
}
|
|
}
|
|
|
|
function exportCSV() {
|
|
// Union of all keys across all records
|
|
const keySet = new Set<string>();
|
|
items.forEach(it => Object.keys(it).forEach(k => { if (!HIDDEN_SYSTEM_FIELDS.has(k)) keySet.add(k); }));
|
|
const headers = ["id", ...Array.from(keySet)];
|
|
const rows = filtered.map(it => headers.map(h => formatValue(it[h])));
|
|
const csv = [headers, ...rows]
|
|
.map(r => r.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(","))
|
|
.join("\n");
|
|
const blob = new Blob([csv], { type: "text/csv" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `registrations-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
eyebrow="Workflow"
|
|
title="Company Registrations"
|
|
subtitle="Client submissions from the registration wizard — all data from ysterbaard_company_registrations."
|
|
action={
|
|
<div className="flex items-center gap-2">
|
|
<Link
|
|
href="/resolutions"
|
|
className="px-4 py-2.5 rounded-md border border-border bg-surface text-fg text-sm font-semibold no-underline hover:bg-surface-warm transition-colors"
|
|
>
|
|
← Back to Resolutions
|
|
</Link>
|
|
<button
|
|
onClick={refresh}
|
|
disabled={loading}
|
|
className="px-4 py-2.5 rounded-md border border-border bg-surface text-fg text-sm font-semibold hover:border-accent hover:text-accent disabled:opacity-50 transition-colors"
|
|
title="Reload from PocketBase (auto-refreshes every 30s)"
|
|
>
|
|
{loading ? "⟳ Loading…" : "⟳ Refresh"}
|
|
</button>
|
|
<button
|
|
onClick={exportCSV}
|
|
disabled={filtered.length === 0}
|
|
className="px-4 py-2.5 rounded-md border border-border bg-surface text-fg text-sm font-semibold hover:border-accent hover:text-accent disabled:opacity-50 transition-colors"
|
|
>
|
|
⬇ Export CSV
|
|
</button>
|
|
</div>
|
|
}
|
|
/>
|
|
<div className="container-x py-10 space-y-4">
|
|
{/* Search bar + counts */}
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<div className="flex-1 min-w-64 relative">
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Search across all fields…"
|
|
className="w-full px-4 py-2.5 rounded-md border border-border bg-surface text-fg text-sm placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent"
|
|
/>
|
|
{search && (
|
|
<button
|
|
onClick={() => setSearch("")}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-fg text-xs"
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
{cancelledCount > 0 && (
|
|
<label className="flex items-center gap-2 text-sm text-muted whitespace-nowrap cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={showCancelled}
|
|
onChange={(e) => setShowCancelled(e.target.checked)}
|
|
className="w-4 h-4 accent-red-500"
|
|
/>
|
|
Show cancelled ({cancelledCount})
|
|
</label>
|
|
)}
|
|
<p className="text-sm text-muted whitespace-nowrap">
|
|
<span className="font-semibold text-fg">{filtered.length}</span> of <span className="font-semibold text-fg">{items.length}</span> record{items.length === 1 ? "" : "s"}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Reopen status message */}
|
|
{reopenMsg && (
|
|
<div className="text-sm px-4 py-3 rounded bg-surface-warm border border-border text-fg">
|
|
{reopenMsg}
|
|
</div>
|
|
)}
|
|
|
|
{/* Body */}
|
|
{loading ? (
|
|
<div className="bg-surface border border-border rounded-lg p-12 text-center text-muted">
|
|
Loading registrations…
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="bg-surface border border-border rounded-lg p-12 text-center text-muted">
|
|
{items.length === 0
|
|
? "No registrations yet. Create a new registration from the Resolutions tab."
|
|
: `No registrations match "${search}".`}
|
|
</div>
|
|
) : (
|
|
<div className="bg-surface border border-border rounded-lg overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border bg-surface-warm text-left">
|
|
{TABLE_COLUMNS.map(c => (
|
|
<th key={c.key} className={`px-3 py-3 text-xs font-semibold text-muted uppercase tracking-wider whitespace-nowrap ${c.key === "_actions" ? "text-right" : "text-left"}`}>
|
|
{c.label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{filtered.map((r) => {
|
|
const status = (r.status as string) || "draft";
|
|
return (
|
|
<tr key={r.id} className="hover:bg-surface-warm transition-colors">
|
|
{TABLE_COLUMNS.map(c => {
|
|
const v = r[c.key];
|
|
if (c.key === "_step") {
|
|
const step = STEP_NUMBER[status] ?? 0;
|
|
const stepLabel = step < 0 ? "—" : `${step}/4`;
|
|
const stepClass = step < 0
|
|
? "bg-danger/10 text-danger border border-danger/30"
|
|
: step === 0
|
|
? "bg-surface border border-border text-muted"
|
|
: step === 1
|
|
? "bg-blue-100 text-blue-700 border border-blue-300"
|
|
: step === 2
|
|
? "bg-success/10 text-success border border-success/30"
|
|
: step === 3
|
|
? "bg-purple-100 text-purple-700 border border-purple-300"
|
|
: "bg-accent/10 text-accent border border-accent/30";
|
|
return (
|
|
<td key={c.key} className="px-3 py-3">
|
|
<span className={`text-xs px-2 py-0.5 rounded font-semibold ${stepClass}`}>
|
|
{stepLabel}
|
|
</span>
|
|
</td>
|
|
);
|
|
}
|
|
if (c.key === "status") {
|
|
// For bizportal_done, pick the right sub-label based on cipc_decision
|
|
// so the table mirrors the Step 2 banner wording.
|
|
let statusKey = status;
|
|
if (status === "bizportal_done") {
|
|
const dec = String(r.cipc_decision || "").toLowerCase();
|
|
if (dec === "approved") statusKey = "bizportal_done_approved";
|
|
else if (dec === "rejected") statusKey = "bizportal_done_rejected";
|
|
}
|
|
return (
|
|
<td key={c.key} className="px-3 py-3 whitespace-nowrap">
|
|
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-semibold ${STATUS_STYLE[statusKey] || STATUS_STYLE.draft}`}>
|
|
{STATUS_LABELS[statusKey] || status}
|
|
</span>
|
|
</td>
|
|
);
|
|
}
|
|
if (c.key === "_actions") {
|
|
return (
|
|
<td key={c.key} className="px-3 py-3 whitespace-nowrap">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<ActionsDropdown id={r.id} name={String(r.reference_name ?? "")} status={status} />
|
|
|
|
{/* Reopen client wizard — works from any non-cancelled state */}
|
|
{status !== "cancelled" && (
|
|
<button
|
|
onClick={() => handleStatusAction(r, "reopen")}
|
|
disabled={reopeningId === r.id}
|
|
title="Reset status so the wizard can be re-opened"
|
|
className="px-2.5 py-1 bg-yellow-500/10 border border-yellow-500/30 rounded text-xs font-semibold text-yellow-700 hover:bg-yellow-500/20 transition-colors whitespace-nowrap disabled:opacity-50"
|
|
>
|
|
🔓
|
|
</button>
|
|
)}
|
|
{/* Cancel — works from any non-cancelled state */}
|
|
{status !== "cancelled" && (
|
|
<button
|
|
onClick={() => {
|
|
if (confirm(`Cancel this registration for "${formatValue(r.reference_name)}"?\n\nThe data will be kept in PocketBase for later use.`)) {
|
|
handleStatusAction(r, "cancel");
|
|
}
|
|
}}
|
|
disabled={reopeningId === r.id}
|
|
title="Cancel — keeps data in PB but hides from active list"
|
|
className="px-2.5 py-1 bg-red-500/10 border border-red-500/30 rounded text-xs font-semibold text-red-600 hover:bg-red-500/20 transition-colors whitespace-nowrap disabled:opacity-50"
|
|
>
|
|
✕ Cancel
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => handleCopyLink(r.id)}
|
|
className="px-2.5 py-1 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 ? "✓" : "🔗"}
|
|
</button>
|
|
<Link
|
|
href={`/step2/${r.id}`}
|
|
className="px-2.5 py-1 border border-accent text-accent rounded text-xs font-semibold hover:bg-accent/10 no-underline transition-colors"
|
|
>
|
|
Step 2 →
|
|
</Link>
|
|
<Link
|
|
href={`/registrations/${r.id}`}
|
|
className="px-3 py-1 bg-accent text-accent-on rounded text-xs font-semibold hover:opacity-90 no-underline transition-opacity"
|
|
>
|
|
View →
|
|
</Link>
|
|
</div>
|
|
</td>
|
|
);
|
|
}
|
|
if (c.key === "reference_name") {
|
|
return (
|
|
<td key={c.key} className="px-3 py-3 whitespace-nowrap">
|
|
<button
|
|
onClick={() => setExpandedId(expandedId === r.id ? null : r.id)}
|
|
className="font-medium text-fg hover:text-accent text-left"
|
|
>
|
|
{formatValue(v)}
|
|
</button>
|
|
</td>
|
|
);
|
|
}
|
|
return (
|
|
<td key={c.key} className="px-3 py-3 max-w-xs truncate text-fg-2" title={formatValue(v)}>
|
|
{formatValue(v)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Expanded detail panel */}
|
|
{expandedId && (
|
|
<ExpandedDetail
|
|
record={items.find(i => i.id === expandedId)!}
|
|
onClose={() => setExpandedId(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ExpandedDetail({ record, onClose }: { record: RegItem; onClose: () => void }) {
|
|
const entries = Object.entries(record).filter(([k]) => !HIDDEN_SYSTEM_FIELDS.has(k));
|
|
const status = String(record.status ?? "");
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
|
|
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-3xl max-h-[85vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
|
<div className="sticky top-0 bg-surface border-b border-border px-6 py-4 flex items-center justify-between gap-4">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.08em] text-muted font-semibold">Registration Detail</p>
|
|
<h3 className="text-lg font-bold text-fg mt-1">{String(record.reference_name ?? record.id)}</h3>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<ActionsDropdown id={record.id} name={String(record.reference_name ?? record.id)} status={status} />
|
|
<button onClick={onClose} className="text-muted hover:text-fg text-xl font-bold">✕</button>
|
|
</div>
|
|
</div>
|
|
<dl className="px-6 py-4 grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-3">
|
|
{entries.map(([k, v]) => (
|
|
<div key={k} className="border-b border-border-soft pb-2">
|
|
<dt className="text-xs font-semibold text-muted uppercase tracking-wide">{k.replace(/_/g, " ")}</dt>
|
|
<dd className="text-sm text-fg mt-1 break-words font-mono">
|
|
{typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v) ? formatDate(v) : formatValue(v)}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
<div className="px-6 py-4 border-t border-border flex justify-end gap-3">
|
|
<Link
|
|
href={`/registrations/${record.id}`}
|
|
className="px-4 py-2 bg-accent text-accent-on rounded text-sm font-semibold no-underline hover:opacity-90"
|
|
>
|
|
Full View →
|
|
</Link>
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 border border-border rounded text-sm font-semibold text-fg hover:bg-surface-warm"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|