Workflow-App/Workflow/components/GroupsExplorer.tsx
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
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.
2026-10-18 15:21:45 +02:00

341 lines
14 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 &ldquo;New Group&rdquo; 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">
<a
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
</a>
<a
href={`/api/group-report/${g.id}/pdf`}
target="_blank"
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"
>
View PDF
</a>
<button
onClick={() => {
const email = prompt("Enter email address to send the group PDF to:");
if (!email) return;
if (!confirm(`Send group PDF for "${g.name}" to ${email}?`)) return;
fetch(`/api/group-report/${g.id}/email`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ to: email }),
})
.then(r => r.json())
.then(d => d.ok ? alert(`Sent to ${email}`) : alert(`Failed: ${d.error}`))
.catch(e => alert(`Error: ${e}`));
}}
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"
>
Mail PDF
</button>
<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>
);
}