Files
leocrm/frontend/src/pages/Wiki.tsx
T
Agent Zero 94c7c8fff5
Check Cross-Plugin Imports / check (push) Has been cancelled
fix(UI-Overhaul-Phase1): 7 Bugs behoben
1.1 Contacts refresh: useUnifiedContacts mutations already invalidate (verified)
1.2 Contacts drag-drop: ContactList items already draggable + ContactFolderTree onDrop (verified)
1.3 Contacts move dialog: Added move-to-folder dropdown in ContactDetail
1.4 Wiki save refresh: Added refreshKey prop to WikiBrowser, triggers reload after save/delete
1.5 Calendar dialog close: Window.tsx now injects windowId into componentProps
1.6 Communication AI chat: Added metadata support to ConversationCreate schema + service,
    frontend passes conversation_type metadata for AI/system chats,
    categorization checks metadata first
1.7 Wiki duplicate menu: Removed hardcoded /wiki from Sidebar.tsx (plugin provides it dynamically)

Backend: kommunikation schema/routes/services updated for metadata support
Frontend: tsc clean, build successful
2026-08-21 13:17:35 +02:00

414 lines
15 KiB
TypeScript

/**
* Wiki page — knowledge articles with categories, Markdown editor, versioning.
*
* Combines WikiBrowser (category tree + article list + search), article detail
* with Markdown preview, create/edit modal, delete, and version history with
* restore.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { CalendarDays, History, Loader2, Pencil, Plus, Trash2, Undo2 } from 'lucide-react';
import {
createWikiArticle,
deleteWikiArticle,
fetchWikiArticle,
fetchWikiCategories,
fetchWikiVersions,
restoreWikiVersion,
updateWikiArticle,
type WikiArticle,
type WikiArticleStatus,
type WikiCategory,
type WikiVersion,
} from '@/api/knowledge';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Modal } from '@/components/ui/Modal';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState';
import { useToast } from '@/components/ui/Toast';
import { WikiBrowser } from '@/components/wiki/WikiBrowser';
import { WikiEditor } from '@/components/wiki/WikiEditor';
interface ArticleForm {
title: string;
slug: string;
content: string;
summary: string;
category_id: string;
tags: string;
status: WikiArticleStatus;
}
const emptyForm: ArticleForm = {
title: '',
slug: '',
content: '',
summary: '',
category_id: '',
tags: '',
status: 'draft',
};
function formatDate(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleString();
}
export function WikiPage() {
const { t } = useTranslation();
const toast = useToast();
const [selectedArticleId, setSelectedArticleId] = useState<string | null>(null);
const [article, setArticle] = useState<WikiArticle | null>(null);
const [loadingArticle, setLoadingArticle] = useState(false);
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
const [versions, setVersions] = useState<WikiVersion[]>([]);
const [showVersions, setShowVersions] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<ArticleForm>(emptyForm);
const [saving, setSaving] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<WikiArticle | null>(null);
const [deleting, setDeleting] = useState(false);
const [restoring, setRestoring] = useState(false);
const [browserRefreshKey, setBrowserRefreshKey] = useState(0);
const loadCategories = useCallback(async () => {
try {
const cats = await fetchWikiCategories();
setCategories(cats.map((c) => ({ id: c.id, name: c.name })));
} catch {
// Non-fatal — categories are optional in the editor
}
}, []);
useEffect(() => {
void loadCategories();
}, [loadCategories]);
const loadArticle = useCallback(async (id: string) => {
setLoadingArticle(true);
try {
const data = await fetchWikiArticle(id);
setArticle(data);
setSelectedArticleId(data.id);
setShowVersions(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('wiki.detail.loadError'));
} finally {
setLoadingArticle(false);
}
}, [t, toast]);
const handleSelectArticle = useCallback(
(a: WikiArticle) => {
void loadArticle(a.id);
},
[loadArticle]
);
const openCreate = useCallback(() => {
setEditingId(null);
setForm(emptyForm);
setEditorOpen(true);
}, []);
const openEdit = useCallback(() => {
if (!article) return;
setEditingId(article.id);
setForm({
title: article.title,
slug: article.slug,
content: article.content,
summary: article.summary ?? '',
category_id: article.category_id ?? '',
tags: article.tags.join(', '),
status: article.status,
});
setEditorOpen(true);
}, [article]);
const handleSave = useCallback(async () => {
if (!form.title.trim()) {
toast.error(t('wiki.editor.titleRequired'));
return;
}
setSaving(true);
try {
const payload = {
title: form.title.trim(),
slug: form.slug.trim() || undefined,
content: form.content,
summary: form.summary.trim() || null,
category_id: form.category_id || null,
tags: form.tags.split(',').map((s) => s.trim()).filter(Boolean),
status: form.status,
};
if (editingId) {
const updated = await updateWikiArticle(editingId, payload);
setArticle(updated);
toast.success(t('knowledge.editor.updated'));
} else {
const created = await createWikiArticle(payload);
setArticle(created);
setSelectedArticleId(created.id);
toast.success(t('knowledge.editor.created'));
}
setEditorOpen(false);
setBrowserRefreshKey((k) => k + 1);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('knowledge.editor.saveError'));
} finally {
setSaving(false);
}
}, [form, editingId, t, toast]);
const handleDelete = useCallback(async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await deleteWikiArticle(deleteTarget.id);
toast.success(t('knowledge.editor.deleted'));
setArticle(null);
setSelectedArticleId(null);
setDeleteTarget(null);
setBrowserRefreshKey((k) => k + 1);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('knowledge.editor.deleteError'));
} finally {
setDeleting(false);
}
}, [deleteTarget, t, toast]);
const loadVersions = useCallback(async () => {
if (!article) return;
try {
const data = await fetchWikiVersions(article.id);
setVersions(data);
setShowVersions(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('knowledge.versions.loadError'));
}
}, [article, t, toast]);
const handleRestore = useCallback(
async (version: number) => {
if (!article) return;
setRestoring(true);
try {
const restored = await restoreWikiVersion(article.id, version);
setArticle(restored);
setShowVersions(false);
toast.success(t('knowledge.versions.restored'));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('knowledge.versions.restoreError'));
} finally {
setRestoring(false);
}
},
[article, t, toast]
);
const statusBadge = useMemo(() => {
if (!article) return null;
const variant =
article.status === 'published' ? 'success' : article.status === 'archived' ? 'secondary' : 'warning';
return <Badge variant={variant}>{t(`wiki.status.${article.status}`)}</Badge>;
}, [article, t]);
return (
<div className="p-4 md:p-6 h-full">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-semibold text-secondary-900">{t('wiki.title')}</h1>
<p className="text-sm text-secondary-500 mt-1">{t('wiki.subtitle')}</p>
</div>
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" aria-hidden="true" />}>
{t('wiki.newArticle')}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 h-[calc(100vh-8rem)]">
{/* Browser sidebar */}
<div className="lg:col-span-1 border border-secondary-200 rounded-lg bg-white overflow-hidden">
<WikiBrowser selectedArticleId={selectedArticleId} onSelectArticle={handleSelectArticle} refreshKey={browserRefreshKey} />
</div>
{/* Article detail / editor */}
<div className="lg:col-span-3 overflow-y-auto">
{loadingArticle ? (
<div className="flex items-center justify-center py-20" role="status" aria-label={t('common.loading')}>
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
</div>
) : article ? (
<Card
title={article.title}
actions={
<div className="flex items-center gap-2">
{statusBadge}
<Button variant="ghost" size="sm" onClick={() => void loadVersions()} icon={<History className="h-4 w-4" aria-hidden="true" />}>
{t('knowledge.versions.title')}
</Button>
<Button variant="ghost" size="sm" onClick={openEdit} icon={<Pencil className="h-4 w-4" aria-hidden="true" />}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => setDeleteTarget(article)} icon={<Trash2 className="h-4 w-4" aria-hidden="true" />}>
{t('common.delete')}
</Button>
</div>
}
>
<div className="flex flex-wrap items-center gap-3 text-sm text-secondary-500 mb-4">
<span className="inline-flex items-center gap-1">
<CalendarDays className="h-4 w-4" aria-hidden="true" />
{formatDate(article.updated_at)}
</span>
<span>{t('knowledge.detail.version')}: {article.version}</span>
{article.tags.map((tag) => (
<span key={tag} className="text-xs text-secondary-400">#{tag}</span>
))}
</div>
{showVersions && (
<div className="mb-4 border border-secondary-200 rounded-lg p-3 bg-secondary-50">
<h4 className="text-sm font-semibold text-secondary-900 mb-2">{t('knowledge.versions.title')}</h4>
{versions.length === 0 ? (
<p className="text-sm text-secondary-500">{t('knowledge.versions.empty')}</p>
) : (
<ul className="space-y-2">
{versions.map((v) => (
<li key={v.id} className="flex items-center justify-between gap-2 text-sm">
<div>
<span className="font-medium text-secondary-800">v{v.version}</span>
<span className="text-secondary-400 ml-2">{formatDate(v.created_at)}</span>
{v.edit_comment && <span className="text-secondary-500 ml-2"> {v.edit_comment}</span>}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => void handleRestore(v.version)}
isLoading={restoring}
icon={<Undo2 className="h-4 w-4" aria-hidden="true" />}
>
{t('knowledge.versions.restore')}
</Button>
</li>
))}
</ul>
)}
</div>
)}
<div className="prose prose-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{article.content || t('wiki.detail.emptyContent')}</ReactMarkdown>
</div>
</Card>
) : (
<EmptyState
title={t('wiki.detail.noSelection')}
description={t('wiki.detail.noSelectionDescription')}
action={
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" aria-hidden="true" />}>
{t('wiki.newArticle')}
</Button>
}
/>
)}
</div>
</div>
{/* Create/Edit modal */}
<Modal
open={editorOpen}
onClose={() => setEditorOpen(false)}
title={editingId ? t('wiki.editor.editTitle') : t('wiki.editor.createTitle')}
size="xl"
>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t('wiki.editor.title')}
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
required
/>
<Input
label={t('wiki.editor.slug')}
value={form.slug}
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
placeholder={t('wiki.editor.slugPlaceholder')}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Select
label={t('wiki.editor.category')}
value={form.category_id}
onChange={(e) => setForm((f) => ({ ...f, category_id: e.target.value }))}
options={[
{ value: '', label: t('wiki.editor.noCategory') },
...categories.map((c) => ({ value: c.id, label: c.name })),
]}
/>
<Select
label={t('wiki.editor.status')}
value={form.status}
onChange={(e) => setForm((f) => ({ ...f, status: e.target.value as WikiArticleStatus }))}
options={[
{ value: 'draft', label: t('wiki.status.draft') },
{ value: 'published', label: t('wiki.status.published') },
{ value: 'archived', label: t('wiki.status.archived') },
]}
/>
<Input
label={t('wiki.editor.tags')}
value={form.tags}
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
placeholder={t('wiki.editor.tagsPlaceholder')}
/>
</div>
<Input
label={t('wiki.editor.summary')}
value={form.summary}
onChange={(e) => setForm((f) => ({ ...f, summary: e.target.value }))}
/>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('wiki.editor.content')}</label>
<WikiEditor
value={form.content}
onChange={(value) => setForm((f) => ({ ...f, content: value }))}
placeholder={t('wiki.editor.contentPlaceholder')}
/>
</div>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setEditorOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={() => void handleSave()} isLoading={saving}>
{t('common.save')}
</Button>
</div>
</div>
</Modal>
<ConfirmDialog
open={deleteTarget !== null}
title={t('wiki.delete.title')}
message={t('wiki.delete.message', { title: deleteTarget?.title ?? '' })}
confirmLabel={t('common.delete')}
variant="danger"
onConfirm={() => void handleDelete()}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}