241 lines
9 KiB
TypeScript
241 lines
9 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
// components/DocumentTypeManager.tsx
|
|||
|
|
// Modal for managing document types: rename name/description.
|
|||
|
|
// Located under the filters bar on the Directors/Companies tab pages.
|
|||
|
|
|
|||
|
|
import { useState, useEffect, useRef } from "react";
|
|||
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
|
|
import {
|
|||
|
|
fetchDocumentTypes,
|
|||
|
|
updateDocumentType,
|
|||
|
|
deleteDocumentType,
|
|||
|
|
type DocType,
|
|||
|
|
} from "@/lib/api";
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
context: "director" | "company";
|
|||
|
|
open: boolean;
|
|||
|
|
onClose: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function DocumentTypeManager({ context, open, onClose }: Props) {
|
|||
|
|
const qc = useQueryClient();
|
|||
|
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|||
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|||
|
|
const [editName, setEditName] = useState("");
|
|||
|
|
const [editDesc, setEditDesc] = useState("");
|
|||
|
|
const [saving, setSaving] = useState(false);
|
|||
|
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|||
|
|
const [deleting, setDeleting] = useState(false);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
|
|||
|
|
const { data, isLoading } = useQuery({
|
|||
|
|
queryKey: ["document-types", context],
|
|||
|
|
queryFn: () => fetchDocumentTypes(context),
|
|||
|
|
enabled: open,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!open) {
|
|||
|
|
setEditingId(null);
|
|||
|
|
setDeleteId(null);
|
|||
|
|
setError(null);
|
|||
|
|
}
|
|||
|
|
}, [open]);
|
|||
|
|
|
|||
|
|
if (!open) return null;
|
|||
|
|
|
|||
|
|
const types: DocType[] = data?.items ?? [];
|
|||
|
|
|
|||
|
|
const handleOverlayClick = (e: React.MouseEvent) => {
|
|||
|
|
if (e.target === overlayRef.current) onClose();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const startEdit = (t: DocType) => {
|
|||
|
|
setEditingId(t.id);
|
|||
|
|
setEditName(t.name);
|
|||
|
|
setEditDesc(t.description ?? "");
|
|||
|
|
setError(null);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleSaveEdit = async () => {
|
|||
|
|
if (!editName.trim()) return;
|
|||
|
|
setSaving(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
await updateDocumentType(editingId!, {
|
|||
|
|
name: editName.trim(),
|
|||
|
|
description: editDesc.trim() || undefined,
|
|||
|
|
});
|
|||
|
|
await qc.invalidateQueries({ queryKey: ["document-types", context] });
|
|||
|
|
setEditingId(null);
|
|||
|
|
} catch (err) {
|
|||
|
|
setError(err instanceof Error ? err.message : "Save failed");
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDelete = async () => {
|
|||
|
|
if (!deleteId) return;
|
|||
|
|
setDeleting(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
await deleteDocumentType(deleteId);
|
|||
|
|
await qc.invalidateQueries({ queryKey: ["document-types", context] });
|
|||
|
|
setDeleteId(null);
|
|||
|
|
} catch (err) {
|
|||
|
|
setError(err instanceof Error ? err.message : "Delete failed");
|
|||
|
|
} finally {
|
|||
|
|
setDeleting(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
ref={overlayRef}
|
|||
|
|
onClick={handleOverlayClick}
|
|||
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
|||
|
|
role="dialog"
|
|||
|
|
aria-modal="true"
|
|||
|
|
aria-label="Manage document types"
|
|||
|
|
>
|
|||
|
|
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-lg flex flex-col max-h-[85vh]">
|
|||
|
|
{/* Header */}
|
|||
|
|
<div className="px-6 pt-6 pb-4 border-b border-border flex-shrink-0">
|
|||
|
|
<div className="flex items-start justify-between">
|
|||
|
|
<div>
|
|||
|
|
<h2 className="text-lg font-bold text-fg">Document types</h2>
|
|||
|
|
<p className="text-sm text-muted mt-0.5">
|
|||
|
|
Rename or delete types. Docs using a deleted type will show the old type name.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
onClick={onClose}
|
|||
|
|
className="ml-4 text-muted hover:text-fg text-xl leading-none p-1 rounded transition-colors"
|
|||
|
|
>
|
|||
|
|
×
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Body */}
|
|||
|
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
|||
|
|
{error && (
|
|||
|
|
<div className="mb-4 bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 text-sm text-red-400">
|
|||
|
|
{error}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{isLoading ? (
|
|||
|
|
<p className="text-center text-muted py-8">Loading…</p>
|
|||
|
|
) : types.length === 0 ? (
|
|||
|
|
<p className="text-center text-muted py-8">
|
|||
|
|
No {context} document types yet. Create one when you upload a document.
|
|||
|
|
</p>
|
|||
|
|
) : (
|
|||
|
|
<ul className="space-y-2">
|
|||
|
|
{types.map((t) => (
|
|||
|
|
<li key={t.id} className="border border-border rounded-lg p-4">
|
|||
|
|
{editingId === t.id ? (
|
|||
|
|
/* Edit form */
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={editName}
|
|||
|
|
onChange={(e) => setEditName(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"
|
|||
|
|
placeholder="Type name"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={editDesc}
|
|||
|
|
onChange={(e) => setEditDesc(e.target.value)}
|
|||
|
|
className="w-full rounded border border-border bg-surface-warm px-3 py-2 text-sm text-fg placeholder:text-muted focus:outline-none focus:border-accent"
|
|||
|
|
placeholder="Description (optional)"
|
|||
|
|
/>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<button
|
|||
|
|
onClick={() => setEditingId(null)}
|
|||
|
|
className="flex-1 px-3 py-2 rounded border border-border bg-surface-warm text-xs font-semibold text-fg hover:bg-surface"
|
|||
|
|
>
|
|||
|
|
Cancel
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={handleSaveEdit}
|
|||
|
|
disabled={saving || !editName.trim()}
|
|||
|
|
className="flex-1 px-3 py-2 rounded bg-accent text-accent-on text-xs font-bold hover:bg-accent-hover disabled:opacity-60"
|
|||
|
|
>
|
|||
|
|
{saving ? "Saving..." : "Save"}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : deleteId === t.id ? (
|
|||
|
|
/* Delete confirm */
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<p className="text-sm text-fg">
|
|||
|
|
Delete type <strong>"{t.name}"</strong>? Existing documents will keep this name.
|
|||
|
|
</p>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<button
|
|||
|
|
onClick={() => setDeleteId(null)}
|
|||
|
|
className="flex-1 px-3 py-2 rounded border border-border bg-surface-warm text-xs font-semibold text-fg hover:bg-surface"
|
|||
|
|
>
|
|||
|
|
Cancel
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={handleDelete}
|
|||
|
|
disabled={deleting}
|
|||
|
|
className="flex-1 px-3 py-2 rounded bg-red-600 text-white text-xs font-bold hover:bg-red-700 disabled:opacity-60"
|
|||
|
|
>
|
|||
|
|
{deleting ? "Deleting..." : "Yes, delete"}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
/* Display row */
|
|||
|
|
<div className="flex items-center justify-between gap-3">
|
|||
|
|
<div className="min-w-0">
|
|||
|
|
<p className="text-sm font-semibold text-fg">{t.name}</p>
|
|||
|
|
{t.description ? (
|
|||
|
|
<p className="text-xs text-muted mt-0.5">{t.description}</p>
|
|||
|
|
) : null}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|||
|
|
<button
|
|||
|
|
onClick={() => startEdit(t)}
|
|||
|
|
className="px-3 py-1.5 rounded border border-border bg-surface-warm text-xs font-semibold text-fg hover:border-accent hover:text-accent transition-colors"
|
|||
|
|
>
|
|||
|
|
Rename
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setDeleteId(t.id)}
|
|||
|
|
className="px-3 py-1.5 rounded border border-border bg-surface-warm text-xs font-semibold text-muted hover:border-red-500 hover:text-red-500 transition-colors"
|
|||
|
|
>
|
|||
|
|
Delete
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Footer */}
|
|||
|
|
<div className="px-6 pb-6 pt-4 border-t border-border flex-shrink-0">
|
|||
|
|
<button
|
|||
|
|
onClick={onClose}
|
|||
|
|
className="w-full px-4 py-2.5 rounded-lg border border-border bg-surface-warm text-sm font-semibold text-fg hover:bg-surface transition-colors"
|
|||
|
|
>
|
|||
|
|
Done
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|