WORKFLOW-APP/components/ResolutionExplorer.tsx

379 lines
18 KiB
TypeScript

// components/ResolutionExplorer.tsx
// Client Component. 4-section menu for company registration,
// director resignation, director appointment, and resolutions.
// Only Company Registration is active; others are placeholders.
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import type { ComponentProps } from "react";
import type {
PBList,
YsterbaardResolution,
ResolutionStatus,
} from "@/lib/types";
import StatusBadge from "@/components/StatusBadge";
import { useRouter } from "next/navigation";
type Section = "company_registration" | "director_resignation" | "director_appointment" | "resolution_appointment";
const SECTIONS: { id: Section; label: string }[] = [
{ id: "company_registration", label: "1 — Company registration" },
{ id: "director_resignation", label: "2 — Director resignation" },
{ id: "director_appointment", label: "3 — Director appointment acceptance" },
{ id: "resolution_appointment", label: "4 — Resolution appointment and resignation" },
];
type BadgeVariant = ComponentProps<typeof StatusBadge>["variant"];
function statusVariant(s: ResolutionStatus): BadgeVariant {
switch (s) {
case "draft": return "muted";
case "pending": return "warn";
case "completed": return "success";
case "archived": return "muted";
}
}
function formatDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-ZA", {
day: "2-digit", month: "short", year: "numeric",
});
}
interface Props {
resolutions: PBList<YsterbaardResolution>;
}
interface RegSearchItem {
id: string;
reference_name: string;
status: string;
proposed_name_1: string;
}
export default function ResolutionExplorer({ resolutions }: Props) {
const router = useRouter();
const [activeSection, setActiveSection] = useState<Section>("company_registration");
const [showNewDialog, setShowNewDialog] = useState(false);
const [newRegName, setNewRegName] = useState("");
const [newRegLoading, setNewRegLoading] = useState(false);
const [generatedLink, setGeneratedLink] = useState<string | null>(null);
const [dialogError, setDialogError] = useState<string | null>(null);
const [linkCopied, setLinkCopied] = useState(false);
// Search state
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<RegSearchItem[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
const [searchFocused, setSearchFocused] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const filteredResolutions = resolutions.items
.filter((r) => r.type === activeSection)
.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
// Close search dropdown when clicking outside
useEffect(() => {
const handler = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setSearchFocused(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
// Search registrations as user types
const doSearch = useCallback(async (q: string) => {
if (q.trim().length === 0) { setSearchResults([]); setSearchLoading(false); return; }
setSearchLoading(true);
try {
const res = await fetch(`/api/register/search?q=${encodeURIComponent(q.trim())}&fields=id,reference_name,status,proposed_name_1`);
if (!res.ok) throw new Error();
const data = await res.json();
setSearchResults(data.items || []);
} catch { setSearchResults([]); }
finally { setSearchLoading(false); }
}, []);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const q = e.target.value;
setSearchQuery(q);
if (searchTimeout.current) clearTimeout(searchTimeout.current);
if (!q.trim()) { setSearchResults([]); setSearchLoading(false); return; }
setSearchLoading(true);
searchTimeout.current = setTimeout(() => doSearch(q), 300);
};
const handleSelectExisting = (reg: RegSearchItem) => {
setSearchFocused(false);
setShowNewDialog(false);
router.push(`/register/${reg.id}?prefill=1`);
};
const handleCreateRegistration = async () => {
if (!newRegName.trim()) return;
setNewRegLoading(true);
setDialogError(null);
try {
const res = await fetch("/api/register/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reference_name: newRegName.trim() }),
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
setGeneratedLink(`${window.location.origin}/register/${data.id}`);
} catch (err: unknown) {
setDialogError(err instanceof Error ? err.message : "Failed to create link");
} finally {
setNewRegLoading(false);
}
};
const openNewDialog = () => {
setNewRegName(""); setGeneratedLink(null); setDialogError(null);
setSearchQuery(""); setSearchResults([]);
setShowNewDialog(true);
};
return (
<div className="container-x py-8 space-y-6">
{/* 4 Section Buttons */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{SECTIONS.map((sec) => {
const isActive = activeSection === sec.id;
const isDisabled = sec.id !== "company_registration";
return (
<button
key={sec.id}
onClick={() => { if (!isDisabled) setActiveSection(sec.id); }}
className={
"px-4 py-5 rounded-lg border text-sm font-semibold text-center transition-all " +
(isDisabled
? "bg-surface border-border text-muted cursor-not-allowed opacity-50"
: isActive
? "bg-accent border-accent text-accent-on shadow-sm"
: "bg-surface border-border text-fg hover:border-accent hover:text-accent")
}
>
{sec.label}
</button>
);
})}
</div>
{/* ── Company Registration ── */}
{activeSection === "company_registration" && (
<div className="space-y-6">
{/* Header + New button */}
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-fg">
{filteredResolutions.length === 0 ? "No registrations yet" : `${filteredResolutions.length} registration${filteredResolutions.length === 1 ? "" : "s"}`}
</p>
<button
onClick={openNewDialog}
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm hover:opacity-90 transition-opacity"
>
+ New registration
</button>
</div>
{/* Registration search bar */}
<div ref={searchRef} className="relative">
<label className="block text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">
Search submitted registrations
</label>
<div className="flex gap-2">
<div className="relative flex-1">
<input
className="w-full bg-surface border border-border rounded px-3 py-2.5 pr-10 text-sm text-fg placeholder-muted focus:outline-none focus:border-accent transition-colors"
placeholder="Search by reference or company name..."
value={searchQuery}
onChange={handleSearchChange}
onFocus={() => setSearchFocused(true)}
/>
{searchLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<svg className="w-4 h-4 animate-spin text-muted" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
</div>
<a
href="/registrations"
className="flex-shrink-0 px-4 py-2 bg-surface border border-border rounded text-sm font-semibold text-fg hover:border-accent hover:text-accent no-underline transition-colors text-center"
>
View all →
</a>
</div>
{/* Search results dropdown */}
{searchFocused && searchResults.length > 0 && (
<div className="absolute z-10 top-full left-0 right-0 mt-1 bg-surface border border-border rounded-lg shadow-xl overflow-hidden max-h-72 overflow-y-auto">
{searchResults.map((reg) => (
<button
key={reg.id}
onClick={() => handleSelectExisting(reg)}
className="w-full text-left px-4 py-3 hover:bg-surface-warm transition-colors border-b border-border last:border-b-0"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-fg truncate">{reg.reference_name || reg.id}</p>
<p className="text-xs text-muted truncate">{reg.proposed_name_1 || "No company name"}</p>
</div>
<span className={`flex-shrink-0 inline-flex px-2 py-0.5 rounded text-xs font-semibold ${
reg.status === "submitted"
? "bg-accent/10 text-accent"
: "bg-surface border border-border text-muted"
}`}>
{reg.status === "submitted" ? "Submitted" : "Draft"}
</span>
</div>
</button>
))}
</div>
)}
{searchFocused && !searchLoading && searchQuery.trim() && searchResults.length === 0 && (
<div className="absolute z-10 top-full left-0 right-0 mt-1 bg-surface border border-border rounded-lg shadow-xl px-4 py-3 text-sm text-muted">
No registrations found for &ldquo;{searchQuery}&rdquo;
</div>
)}
</div>
{/* Resolutions list (draft/pending resolutions for company_registration) */}
{filteredResolutions.length > 0 && (
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-warm">
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide">Title</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide hidden md:table-cell">Client</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide hidden lg:table-cell">ID / Ref</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide">Status</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-muted uppercase tracking-wide hidden sm:table-cell">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{filteredResolutions.map((r) => (
<tr key={r.id} className="hover:bg-surface-warm transition-colors">
<td className="px-5 py-4"><span className="font-semibold text-fg">{r.title ?? "—"}</span></td>
<td className="px-5 py-4 hidden md:table-cell"><span className="text-fg-2">{r.client_name ?? "—"}</span></td>
<td className="px-5 py-4 hidden lg:table-cell"><span className="font-mono text-xs text-muted">{r.client_id_no ?? "—"}</span></td>
<td className="px-5 py-4"><StatusBadge variant={statusVariant(r.status)}>{r.status}</StatusBadge></td>
<td className="px-5 py-4 hidden sm:table-cell"><span className="text-muted tabular-nums text-xs">{formatDate(r.resolution_date ?? r.created)}</span></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* ── Other sections (placeholder) ── */}
{activeSection !== "company_registration" && (
<div className="bg-surface border border-border rounded-lg p-12 text-center text-muted text-sm">
This section is under development.
</div>
)}
{/* New Registration Dialog */}
{showNewDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
<div className="border-b border-border bg-surface-warm px-6 py-4">
<h2 className="text-fg font-semibold text-lg">New registration</h2>
<p className="text-muted text-sm mt-0.5">Give this registration a reference name so you can find it later.</p>
</div>
<div className="p-6 space-y-4">
{!generatedLink ? (
<>
<div>
<label className="block text-sm font-semibold text-fg mb-1.5">Reference name</label>
<input
className="w-full bg-surface border border-border rounded px-3 py-2.5 text-sm text-fg placeholder-muted focus:outline-none focus:border-accent transition-colors"
placeholder="e.g. Company A — Smith family"
value={newRegName}
onChange={(e) => setNewRegName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCreateRegistration(); }}
autoFocus
/>
</div>
{dialogError && <p className="text-danger text-sm">{dialogError}</p>}
</>
) : (
<div className="space-y-3">
<p className="text-sm text-fg font-semibold">Share this link with your client:</p>
<div className="flex gap-2">
<input readOnly className="flex-1 bg-surface border border-border rounded px-3 py-2 text-sm text-fg font-mono" value={generatedLink} />
<button
onClick={() => {
const el = document.createElement("textarea");
el.value = generatedLink ?? "";
el.style.position = "fixed";
el.style.opacity = "0";
document.body.appendChild(el);
el.focus();
el.select();
const ok = document.execCommand("copy");
document.body.removeChild(el);
if (ok) { setLinkCopied(true); setTimeout(() => setLinkCopied(false), 2000); }
}}
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm flex-shrink-0"
>
{linkCopied ? "✓ Copied" : "Copy"}
</button>
{typeof navigator !== "undefined" && navigator.share ? (
<button
onClick={() => {
navigator.share({
title: "Company Registration",
text: "Please complete your company registration here:",
url: generatedLink ?? "",
}).catch(() => {});
}}
className="px-4 py-2 bg-surface border border-border text-fg rounded font-semibold text-sm flex-shrink-0 hover:bg-surface-warm transition-colors"
>
Share
</button>
) : null}
</div>
<p className="text-xs text-muted">
Send this link to your client. They will fill in the director, company and shareholder details. You will be notified on Telegram when they submit.
</p>
</div>
)}
</div>
<div className="border-t border-border px-6 py-4 flex justify-end gap-3">
{!generatedLink ? (
<>
<button onClick={() => setShowNewDialog(false)} className="px-4 py-2 text-sm font-semibold text-muted hover:text-fg transition-colors">Cancel</button>
<button
onClick={handleCreateRegistration}
disabled={!newRegName.trim() || newRegLoading}
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm disabled:opacity-40 hover:opacity-90 transition-opacity"
>
{newRegLoading ? "Creating..." : "Create link"}
</button>
</>
) : (
<button onClick={() => setShowNewDialog(false)} className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm hover:opacity-90 transition-opacity">
Done
</button>
)}
</div>
</div>
</div>
)}
</div>
);
}