fix(UI-Overhaul-Phase1): 7 Bugs behoben
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
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
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { asError } from '@/utils/errorTypes';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
@@ -8,7 +8,7 @@ import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
import { Loader2, FileText, FolderOpen, Mail, Calendar, Link, Tag } from 'lucide-react';
|
||||
import { Loader2, FileText, FolderOpen, Mail, Calendar, Link, Tag, FolderInput } from 'lucide-react';
|
||||
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
FileText,
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
useUpdateContactPerson,
|
||||
useDeleteContactPerson,
|
||||
} from '@/api/hooks';
|
||||
import { useContactFolders, useMoveContactToFolder } from '@/api/contacts';
|
||||
import type { ContactFolder } from '@/api/contactFolders';
|
||||
|
||||
export interface ContactDetailProps {
|
||||
contact: UnifiedContact | null;
|
||||
@@ -178,6 +180,11 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted, dataTestId
|
||||
const createPersonMutation = useCreateContactPerson();
|
||||
const updatePersonMutation = useUpdateContactPerson();
|
||||
const deletePersonMutation = useDeleteContactPerson();
|
||||
const moveContactMut = useMoveContactToFolder();
|
||||
const { data: folderData } = useContactFolders();
|
||||
const folders: ContactFolder[] = folderData ?? [];
|
||||
const [moveFolderOpen, setMoveFolderOpen] = useState(false);
|
||||
const moveFolderRef = useRef<HTMLDivElement>(null);
|
||||
const [personModalOpen, setPersonModalOpen] = useState(false);
|
||||
const [editingPerson, setEditingPerson] = useState<ContactPerson | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
@@ -199,6 +206,35 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted, dataTestId
|
||||
}
|
||||
}, [aiActiveTab]);
|
||||
|
||||
// Close move folder dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!moveFolderOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (moveFolderRef.current && !moveFolderRef.current.contains(e.target as Node)) {
|
||||
setMoveFolderOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [moveFolderOpen]);
|
||||
|
||||
const handleMoveToFolder = (folderId: string | null) => {
|
||||
if (!contact) return;
|
||||
moveContactMut.mutate(
|
||||
{ contactId: contact.id, folderId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(folderId ? t('contacts.movedToFolder') : t('contacts.removedFromFolder'));
|
||||
setMoveFolderOpen(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const errObj = asError(err);
|
||||
toast.error(errObj.message || t('common.error'));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Sync modal from AI UI Control (Phase 4.7)
|
||||
useEffect(() => {
|
||||
if (aiActiveModal === 'edit' || aiActiveModal === 'create') {
|
||||
@@ -308,6 +344,42 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted, dataTestId
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{canAccess('contacts:write') && (
|
||||
<div ref={moveFolderRef} className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setMoveFolderOpen(!moveFolderOpen)}
|
||||
icon={<FolderInput className="h-4 w-4" aria-hidden="true" />}
|
||||
>
|
||||
{t('contacts.moveFolder', 'Verschieben')}
|
||||
</Button>
|
||||
{moveFolderOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-white border border-secondary-200 rounded-lg shadow-lg z-20 min-w-[200px] max-h-[300px] overflow-y-auto">
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 border-b border-secondary-100"
|
||||
onClick={() => handleMoveToFolder(null)}
|
||||
>
|
||||
{t('contacts.noFolder', 'Kein Ordner')}
|
||||
</button>
|
||||
{folders.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 border-b border-secondary-100 last:border-b-0"
|
||||
style={{ paddingLeft: `${(f.parent_id ? 24 : 12)}px` }}
|
||||
onClick={() => handleMoveToFolder(f.id)}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 inline mr-1.5 text-primary-500" />
|
||||
{f.name}
|
||||
</button>
|
||||
))}
|
||||
{folders.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-secondary-400">{t('contacts.noFolders', 'Keine Ordner')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canAccess('contacts:write') && onEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={onEdit}>{t('common.edit')}</Button>
|
||||
)}
|
||||
|
||||
@@ -54,7 +54,6 @@ function getIcon(name: string): React.ReactNode {
|
||||
const singleItems: NavSingleItem[] = [
|
||||
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: <Home className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 0 },
|
||||
{ to: '/contacts', labelKey: 'nav.contacts', icon: <Users className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 10 },
|
||||
{ to: '/wiki', labelKey: 'nav.wiki', icon: <BookOpen className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 30 },
|
||||
{ to: '/system-dashboard', labelKey: 'nav.systemDashboard', icon: <Activity className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 90 },
|
||||
];
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { EmptyState } from '@/components/ui/EmptyState';
|
||||
interface WikiBrowserProps {
|
||||
selectedArticleId: string | null;
|
||||
onSelectArticle: (article: WikiArticle) => void;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
interface CategoryNode {
|
||||
@@ -110,7 +111,7 @@ function CategoryNodeItem({
|
||||
);
|
||||
}
|
||||
|
||||
export function WikiBrowser({ selectedArticleId, onSelectArticle }: WikiBrowserProps) {
|
||||
export function WikiBrowser({ selectedArticleId, onSelectArticle, refreshKey }: WikiBrowserProps) {
|
||||
const { t } = useTranslation();
|
||||
const [categories, setCategories] = useState<WikiCategory[]>([]);
|
||||
const [articles, setArticles] = useState<WikiArticle[]>([]);
|
||||
@@ -135,7 +136,7 @@ export function WikiBrowser({ selectedArticleId, onSelectArticle }: WikiBrowserP
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedCategoryId, search, t]);
|
||||
}, [selectedCategoryId, search, t, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
|
||||
@@ -154,7 +154,7 @@ export function Window({ window: win }: WindowProps) {
|
||||
win.aiChatVisible && 'border-r border-secondary-200'
|
||||
)}
|
||||
>
|
||||
<ContentComponent {...win.componentProps} />
|
||||
<ContentComponent {...win.componentProps} windowId={win.id} />
|
||||
</div>
|
||||
|
||||
{/* AI Chat Panel */}
|
||||
|
||||
@@ -89,6 +89,10 @@ type ConversationCategory = 'system' | 'ai' | 'colleague';
|
||||
// ─── Helpers ───
|
||||
|
||||
function categorizeConversation(conv: Conversation): ConversationCategory {
|
||||
// Check metadata first (most reliable for AI/system chats)
|
||||
const convType = conv.metadata?.conversation_type;
|
||||
if (convType === 'system') return 'system';
|
||||
if (convType === 'ai') return 'ai';
|
||||
// System messages: created by system participant type
|
||||
if (conv.participants.some(p => p.participant_type === 'system')) return 'system';
|
||||
// AI conversations: have an AI participant
|
||||
@@ -136,12 +140,13 @@ async function sendMessage(convId: string, content: string, replyToId?: string):
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async function createConversation(title: string | null, participantIds: string[], isDirect: boolean, initialMessage?: string): Promise<Conversation> {
|
||||
async function createConversation(title: string | null, participantIds: string[], isDirect: boolean, initialMessage?: string, metadata?: Record<string, unknown>): Promise<Conversation> {
|
||||
const r = await apiClient.post('/comm/conversations', {
|
||||
title,
|
||||
participant_ids: participantIds,
|
||||
is_direct: isDirect,
|
||||
initial_message: initialMessage,
|
||||
metadata: metadata || {},
|
||||
});
|
||||
return r.data;
|
||||
}
|
||||
@@ -781,7 +786,11 @@ export function CommunicationPage() {
|
||||
try {
|
||||
const category = showNewChat;
|
||||
const isDirect = category === 'colleague' && participantIds.length === 1;
|
||||
const conv = await createConversation(title || null, participantIds, isDirect);
|
||||
const metadata: Record<string, unknown> =
|
||||
category === 'ai' ? { conversation_type: 'ai' } :
|
||||
category === 'system' ? { conversation_type: 'system' } :
|
||||
{};
|
||||
const conv = await createConversation(title || null, participantIds, isDirect, undefined, metadata);
|
||||
setConversations(prev => [...prev, conv]);
|
||||
setActiveConvId(conv.id);
|
||||
setMobileView('chat');
|
||||
|
||||
@@ -80,6 +80,7 @@ export function WikiPage() {
|
||||
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 {
|
||||
@@ -163,6 +164,7 @@ export function WikiPage() {
|
||||
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 {
|
||||
@@ -179,6 +181,7 @@ export function WikiPage() {
|
||||
setArticle(null);
|
||||
setSelectedArticleId(null);
|
||||
setDeleteTarget(null);
|
||||
setBrowserRefreshKey((k) => k + 1);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('knowledge.editor.deleteError'));
|
||||
} finally {
|
||||
@@ -237,7 +240,7 @@ export function WikiPage() {
|
||||
<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} />
|
||||
<WikiBrowser selectedArticleId={selectedArticleId} onSelectArticle={handleSelectArticle} refreshKey={browserRefreshKey} />
|
||||
</div>
|
||||
|
||||
{/* Article detail / editor */}
|
||||
|
||||
Reference in New Issue
Block a user