316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
// components/GroupsExplorer.tsx
|
|
// Client component: list groups, create/edit/delete.
|
|
|
|
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import Link from "next/link";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { pbList, pbPatch, pbCreate, pbDelete } from "@/lib/api";
|
|
import type { YsterbaardGroup, PBList } from "@/lib/types";
|
|
|
|
interface Props {
|
|
initial: PBList<YsterbaardGroup>;
|
|
}
|
|
|
|
interface EditForm {
|
|
id?: string;
|
|
name: string;
|
|
description: string;
|
|
}
|
|
|
|
export default function GroupsExplorer({ initial }: Props) {
|
|
const qc = useQueryClient();
|
|
const [groups, setGroups] = useState<YsterbaardGroup[]>(initial.items);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
const [form, setForm] = useState<EditForm>({ name: "", description: "" });
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [deleteSaving, setDeleteSaving] = useState(false);
|
|
|
|
// Reset form when closing create
|
|
useEffect(() => {
|
|
if (!showCreate) setForm({ name: "", description: "" });
|
|
}, [showCreate]);
|
|
|
|
// Populate form for editing
|
|
function startEdit(g: YsterbaardGroup) {
|
|
setEditingId(g.id);
|
|
setForm({ name: g.name, description: g.description ?? "" });
|
|
}
|
|
|
|
function cancelEdit() {
|
|
setEditingId(null);
|
|
setForm({ name: "", description: "" });
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!form.name.trim()) {
|
|
setError("Group name is required.");
|
|
return;
|
|
}
|
|
setError(null);
|
|
setSaving(true);
|
|
try {
|
|
if (editingId) {
|
|
// Update existing
|
|
await pbPatch({
|
|
collection: "groups",
|
|
id: editingId,
|
|
fields: {
|
|
name: form.name.trim(),
|
|
description: form.description.trim() || "",
|
|
},
|
|
});
|
|
setGroups((prev) =>
|
|
prev.map((g) =>
|
|
g.id === editingId
|
|
? { ...g, name: form.name.trim(), description: form.description.trim() || null }
|
|
: g
|
|
)
|
|
);
|
|
cancelEdit();
|
|
} else {
|
|
// Create new
|
|
const created = await pbCreate({
|
|
collection: "groups",
|
|
fields: {
|
|
name: form.name.trim(),
|
|
description: form.description.trim() || "",
|
|
},
|
|
});
|
|
const newGroup = (created as YsterbaardGroup);
|
|
setGroups((prev) => [...prev, newGroup].sort((a, b) => a.name.localeCompare(b.name)));
|
|
setShowCreate(false);
|
|
}
|
|
await qc.invalidateQueries({ queryKey: ["pb-list", "groups"] });
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Save failed.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!deleteId) return;
|
|
setDeleteSaving(true);
|
|
try {
|
|
await pbDelete({ collection: "groups", id: deleteId });
|
|
setGroups((prev) => prev.filter((g) => g.id !== deleteId));
|
|
setDeleteId(null);
|
|
await qc.invalidateQueries({ queryKey: ["pb-list", "groups"] });
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Delete failed.");
|
|
} finally {
|
|
setDeleteSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
|
|
{/* Create button */}
|
|
{!showCreate && editingId === null ? (
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={() => setShowCreate(true)}
|
|
className="px-4 py-2.5 rounded-lg bg-accent text-accent-on text-sm font-bold hover:bg-accent-hover transition-colors"
|
|
>
|
|
+ New Group
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Create form */}
|
|
{showCreate ? (
|
|
<div className="bg-surface border border-border rounded-lg p-6">
|
|
<h3 className="text-base font-semibold text-fg mb-4">Create Group</h3>
|
|
{error && (
|
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm text-red-400">
|
|
{error}
|
|
</div>
|
|
)}
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="group-name">
|
|
Group Name <span className="text-red-400">*</span>
|
|
</label>
|
|
<input
|
|
id="group-name"
|
|
type="text"
|
|
value={form.name}
|
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value.toUpperCase() }))}
|
|
placeholder="e.g. CNL"
|
|
autoFocus
|
|
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-semibold text-fg mb-1.5" htmlFor="group-desc">
|
|
Description <span className="text-xs text-muted font-normal">(optional)</span>
|
|
</label>
|
|
<input
|
|
id="group-desc"
|
|
type="text"
|
|
value={form.description}
|
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
|
placeholder="e.g. CNL Group of Companies"
|
|
className="w-full rounded-lg border border-border bg-surface-warm px-3 py-2.5 text-sm text-fg focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex gap-3">
|
|
<button
|
|
onClick={() => { setShowCreate(false); setError(null); }}
|
|
className="px-4 py-2.5 rounded-lg border border-border bg-surface-warm text-sm font-semibold text-fg hover:bg-surface"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className="px-4 py-2.5 rounded-lg bg-accent text-accent-on text-sm font-bold hover:bg-accent-hover transition-colors disabled:opacity-60"
|
|
>
|
|
{saving ? "Saving..." : "Create Group"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Error banner */}
|
|
{error && !showCreate ? (
|
|
<div className="px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm text-red-400">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Groups list */}
|
|
{groups.length === 0 && !showCreate ? (
|
|
<div className="bg-surface border border-border rounded-lg p-12 text-center text-muted">
|
|
No groups yet. Click “New Group” to create one.
|
|
</div>
|
|
) : (
|
|
<div className="bg-surface border border-border rounded-lg overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-surface-warm text-muted">
|
|
<tr>
|
|
<th className="text-left font-semibold px-5 py-3">Group Name</th>
|
|
<th className="text-left font-semibold px-5 py-3">Description</th>
|
|
<th className="px-5 py-3"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{groups.map((g) => (
|
|
<tr key={g.id} className="border-t border-border hover:bg-surface-warm/50 transition-colors">
|
|
{editingId === g.id ? (
|
|
// Editing row
|
|
<>
|
|
<td className="px-5 py-3">
|
|
<input
|
|
type="text"
|
|
value={form.name}
|
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value.toUpperCase() }))}
|
|
className="w-full rounded border border-border bg-surface-warm px-3 py-2 text-sm text-fg focus:outline-none focus:border-accent"
|
|
autoFocus
|
|
/>
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
<input
|
|
type="text"
|
|
value={form.description}
|
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
|
className="w-full rounded border border-border bg-surface-warm px-3 py-2 text-sm text-fg focus:outline-none focus:border-accent"
|
|
/>
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
onClick={cancelEdit}
|
|
className="text-xs px-3 py-1.5 rounded border border-border bg-surface-warm text-fg hover:bg-surface"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className="text-xs px-3 py-1.5 rounded bg-accent text-accent-on font-semibold hover:bg-accent-hover disabled:opacity-60"
|
|
>
|
|
{saving ? "..." : "Save"}
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</>
|
|
) : (
|
|
// Display row
|
|
<>
|
|
<td className="px-5 py-3">
|
|
<span className="font-semibold text-fg">{g.name}</span>
|
|
</td>
|
|
<td className="px-5 py-3 text-fg-2">
|
|
{g.description || <span className="text-muted italic">—</span>}
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
<div className="flex gap-2 justify-end">
|
|
<Link
|
|
href={`/groups/${g.id}/report`}
|
|
className="text-xs px-3 py-1.5 rounded border border-border bg-surface-warm text-fg no-underline hover:border-accent hover:text-accent transition-colors"
|
|
>
|
|
Report
|
|
</Link>
|
|
<button
|
|
onClick={() => startEdit(g)}
|
|
className="text-xs px-3 py-1.5 rounded border border-border bg-surface-warm text-fg hover:border-accent hover:text-accent transition-colors"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => setDeleteId(g.id)}
|
|
className="text-xs px-3 py-1.5 rounded border border-red-500/30 bg-red-500/5 text-red-400 hover:bg-red-500/15 transition-colors"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete confirmation modal */}
|
|
{deleteId ? (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
|
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-sm p-6">
|
|
<h3 className="text-base font-bold text-fg mb-2">Delete Group?</h3>
|
|
<p className="text-sm text-fg-2 mb-1">
|
|
This will <strong>remove this group from all companies</strong> that are linked to it.
|
|
</p>
|
|
<p className="text-sm text-muted mb-5">
|
|
The group record will be deleted. Companies are not deleted.
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => { setDeleteId(null); setError(null); }}
|
|
className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-surface-warm text-sm font-semibold text-fg hover:bg-surface"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleDelete}
|
|
disabled={deleteSaving}
|
|
className="flex-1 px-4 py-2.5 rounded-lg bg-red-600 text-white text-sm font-bold hover:bg-red-700 disabled:opacity-60"
|
|
>
|
|
{deleteSaving ? "Deleting..." : "Delete Group"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|