feat: window management system with minimize, fullscreen, and AI chat split

- Window manager store (Zustand) for managing open/minimized/fullscreen windows
- Window component with header controls: KI-Chat toggle, fullscreen, minimize, close
- AI chat panel with streaming chat via existing /ai/sessions API
- WindowContainer renders all non-minimized windows
- TopBar shows minimized windows as clickable pills
- ContactEditForm extracted from ContactEditModal for window rendering
- ContactsList and ContactDetailPage use openWindow() instead of modal state
- Draggable windows with z-index management
This commit is contained in:
Agent Zero
2026-07-24 23:42:53 +02:00
parent c1d49e4dfe
commit 35e87b5d51
9 changed files with 936 additions and 39 deletions
@@ -0,0 +1,341 @@
import React, { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Button } from '@/components/ui/Button';
import { useToast } from '@/components/ui/Toast';
import {
type UnifiedContact,
useCreateUnifiedContact,
useUpdateUnifiedContact,
} from '@/api/hooks';
import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer';
import { useCustomFields, useUpdateCustomFields } from '@/api/customFields';
import { usePluginStore } from '@/store/pluginStore';
export interface ContactEditFormProps {
onClose: () => void;
contact?: UnifiedContact | null;
onSaved?: (id: string) => void;
}
// ── Zod Schema ──
const phoneRegex = /^[+]?[\d\s\-().]{6,20}$/;
const contactSchema = z.object({
type: z.enum(['company', 'person']),
name: z.string().optional().default(''),
firstname: z.string().optional().default(''),
surname: z.string().optional().default(''),
code: z.string().optional().default(''),
email_1: z.string().email('Invalid email').or(z.literal('')).optional().default(''),
email_2: z.string().email('Invalid email').or(z.literal('')).optional().default(''),
phone_1: z.string().regex(phoneRegex, 'Invalid phone').or(z.literal('')).optional().default(''),
phone_2: z.string().regex(phoneRegex, 'Invalid phone').or(z.literal('')).optional().default(''),
website: z.string().optional().default(''),
mailing_street: z.string().optional().default(''),
mailing_number: z.string().optional().default(''),
mailing_postalcode: z.string().optional().default(''),
mailing_city: z.string().optional().default(''),
mailing_country: z.string().optional().default(''),
vat_code: z.string().optional().default(''),
fiscal_code: z.string().optional().default(''),
commerce_code: z.string().optional().default(''),
bic: z.string().optional().default(''),
bank_account: z.string().optional().default(''),
tags: z.string().optional().default(''),
projectnote: z.string().optional().default(''),
contact_warning: z.string().optional().default(''),
}).superRefine((data, ctx) => {
if (data.type === 'company' && !data.name?.trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['name'] });
}
if (data.type === 'person' && !data.firstname?.trim() && !data.surname?.trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['firstname'] });
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['surname'] });
}
});
type ContactFormData = z.infer<typeof contactSchema>;
export function ContactEditForm({ onClose, contact, onSaved }: ContactEditFormProps) {
const { t } = useTranslation();
const toast = useToast();
const createMutation = useCreateUnifiedContact();
const updateMutation = useUpdateUnifiedContact();
const isEdit = !!contact;
const {
register,
handleSubmit,
reset,
watch,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: {
type: 'company',
name: '',
firstname: '',
surname: '',
code: '',
email_1: '',
email_2: '',
phone_1: '',
phone_2: '',
website: '',
mailing_street: '',
mailing_number: '',
mailing_postalcode: '',
mailing_city: '',
mailing_country: '',
vat_code: '',
fiscal_code: '',
commerce_code: '',
bic: '',
bank_account: '',
tags: '',
projectnote: '',
contact_warning: '',
},
});
const currentType = watch('type');
// Custom fields
const manifests = usePluginStore(s => s.manifests);
const customFieldDefs = useMemo(
() => manifests
.flatMap((m) => m.custom_fields || [])
.filter((cf) => cf.entity === 'contact'),
[manifests]
);
const { data: customFieldsData } = useCustomFields(contact?.id);
const updateCustomFields = useUpdateCustomFields();
const [customValues, setCustomValues] = React.useState<Record<string, any>>({});
React.useEffect(() => {
if (customFieldsData?.fields) {
const vals: Record<string, any> = {};
for (const f of customFieldsData.fields) {
vals[f.name] = f.value ?? f.default_value ?? null;
}
setCustomValues(vals);
}
}, [customFieldsData]);
const handleCustomFieldChange = (name: string, value: any) => {
setCustomValues(prev => ({ ...prev, [name]: value }));
};
// Reset form when contact changes
useEffect(() => {
reset({
type: (contact?.type as 'company' | 'person') || 'company',
name: contact?.name || '',
firstname: contact?.firstname || '',
surname: contact?.surname || '',
code: contact?.code || '',
email_1: contact?.email_1 || '',
email_2: contact?.email_2 || '',
phone_1: contact?.phone_1 || '',
phone_2: contact?.phone_2 || '',
website: contact?.website || '',
mailing_street: contact?.mailing_street || '',
mailing_number: contact?.mailing_number || '',
mailing_postalcode: contact?.mailing_postalcode || '',
mailing_city: contact?.mailing_city || '',
mailing_country: contact?.mailing_country || '',
vat_code: contact?.vat_code || '',
fiscal_code: contact?.fiscal_code || '',
commerce_code: contact?.commerce_code || '',
bic: contact?.bic || '',
bank_account: contact?.bank_account || '',
tags: contact?.tags || '',
projectnote: contact?.projectnote || '',
contact_warning: contact?.contact_warning || '',
});
}, [contact, reset]);
const onSubmit = async (formData: ContactFormData) => {
const data: Partial<UnifiedContact> = {
type: formData.type,
name: formData.type === 'company' ? formData.name || null : null,
firstname: formData.type === 'person' ? formData.firstname || null : null,
surname: formData.type === 'person' ? formData.surname || null : null,
code: formData.code || null,
email_1: formData.email_1 || null,
email_2: formData.email_2 || null,
phone_1: formData.phone_1 || null,
phone_2: formData.phone_2 || null,
website: formData.website || null,
mailing_street: formData.mailing_street || null,
mailing_number: formData.mailing_number || null,
mailing_postalcode: formData.mailing_postalcode || null,
mailing_city: formData.mailing_city || null,
mailing_country: formData.mailing_country || null,
vat_code: formData.vat_code || null,
fiscal_code: formData.fiscal_code || null,
commerce_code: formData.commerce_code || null,
bic: formData.bic || null,
bank_account: formData.bank_account || null,
tags: formData.tags || null,
projectnote: formData.projectnote || null,
contact_warning: formData.contact_warning || null,
};
try {
let savedId: string | undefined;
if (isEdit && contact) {
await updateMutation.mutateAsync({ id: contact.id, data });
savedId = contact.id;
toast.success(t('contacts.updated'));
onSaved?.(contact.id);
} else {
const result = await createMutation.mutateAsync(data) as { id: string };
savedId = result.id;
toast.success(t('contacts.created'));
onSaved?.(result.id);
}
// Save custom fields if any definitions exist and we have a contact ID
if (savedId && customFieldDefs.length > 0 && Object.keys(customValues).length > 0) {
try {
await updateCustomFields.mutateAsync({ contactId: savedId, values: customValues });
} catch (cfErr: any) {
console.error('Custom fields save failed:', cfErr);
}
}
onClose();
} catch (err: any) {
toast.error(err.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed')));
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 p-4">
{/* Type */}
<Select
label={t('contacts.type')}
{...register('type')}
options={[
{ value: 'company', label: t('contacts.companies') },
{ value: 'person', label: t('contacts.persons') },
]}
data-testid="contact-type-select"
/>
{/* Name fields */}
{currentType === 'company' ? (
<Input
label={t('contacts.name')}
{...register('name')}
error={errors.name?.message}
required
data-testid="contact-name-input"
placeholder="TechCorp GmbH"
/>
) : (
<div className="grid grid-cols-2 gap-3">
<Input
label={t('contacts.firstName')}
{...register('firstname')}
error={errors.firstname?.message}
data-testid="contact-first-name-input"
/>
<Input
label={t('contacts.lastName')}
{...register('surname')}
error={errors.surname?.message}
data-testid="contact-last-name-input"
/>
</div>
)}
{/* Code */}
<Input label={t('contacts.code')} {...register('code')} placeholder="K-00123" />
{/* Communication */}
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.communication')}</h3>
<div className="grid grid-cols-2 gap-3">
<Input label={t('contacts.email') + ' 1'} type="email" {...register('email_1')} error={errors.email_1?.message} />
<Input label={t('contacts.email') + ' 2'} type="email" {...register('email_2')} error={errors.email_2?.message} />
<Input label={t('contacts.phone') + ' 1'} {...register('phone_1')} error={errors.phone_1?.message} />
<Input label={t('contacts.phone') + ' 2'} {...register('phone_2')} error={errors.phone_2?.message} />
<Input label={t('contacts.website')} {...register('website')} />
</div>
</div>
{/* Mailing Address */}
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.mailingAddress')}</h3>
<div className="grid grid-cols-2 gap-3">
<Input label={t('address.street')} {...register('mailing_street')} />
<Input label={t('address.streetNumber')} {...register('mailing_number')} />
<Input label={t('address.zip')} {...register('mailing_postalcode')} />
<Input label={t('address.city')} {...register('mailing_city')} />
<Input label={t('address.country')} {...register('mailing_country')} />
</div>
</div>
{/* Financial */}
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.financial')}</h3>
<div className="grid grid-cols-2 gap-3">
<Input label={t('contacts.vatCode')} {...register('vat_code')} />
<Input label={t('contacts.fiscalCode')} {...register('fiscal_code')} />
<Input label={t('contacts.commerceCode')} {...register('commerce_code')} />
<Input label={t('contacts.bic')} {...register('bic')} />
<Input label={t('contacts.bankAccount')} {...register('bank_account')} />
</div>
</div>
{/* Notes */}
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.notes')}</h3>
<div className="space-y-3">
<Input label={t('contacts.tags')} {...register('tags')} placeholder="tag1, tag2" />
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.projectnote')}</label>
<textarea
{...register('projectnote')}
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.contactWarning')}</label>
<textarea
{...register('contact_warning')}
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
/>
</div>
</div>
</div>
{/* Custom Fields */}
{customFieldDefs.length > 0 && (
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.customFields')}</h3>
<CustomFieldRenderer
fields={customFieldsData?.fields || customFieldDefs.map(d => ({ ...d, value: d.default_value, plugin: '' }))}
mode="edit"
values={customValues}
onChange={handleCustomFieldChange}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button type="submit" isLoading={isSubmitting || createMutation.isPending || updateMutation.isPending} data-testid="contact-submit-btn">
{isEdit ? t('common.save') : t('common.create')}
</Button>
</div>
</form>
);
}
@@ -9,6 +9,7 @@ import { useAIContext } from '@/hooks/useAIContext';
import { useAIUIControl } from '@/hooks/useAIUIControl';
import { PluginRegistry } from '@/components/plugins/PluginRegistry';
import { AIUIControlIndicator } from '@/components/ai-ui-control/AIUIControlIndicator';
import { WindowContainer } from '@/components/window/WindowContainer';
export function AppShell() {
const location = useLocation();
@@ -44,6 +45,7 @@ export function AppShell() {
{/* Message Sidebar — full height, right of TopBar and Toolbar */}
{showMessageSidebar && <MessageSidebar />}
<AIUIControlIndicator />
<WindowContainer />
<ToastContainer />
</div>
);
+20 -1
View File
@@ -8,7 +8,8 @@ import { useLogout } from '@/api/hooks';
import { Avatar } from '@/components/ui/Avatar';
import { SearchDropdown } from '@/components/shared/SearchDropdown';
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
import { Building, ChevronDown, Menu, Zap, Bot } from 'lucide-react';
import { Building, ChevronDown, Menu, Zap, Bot, Layers } from 'lucide-react';
import { useWindowStore } from '@/store/windowStore';
export function TopBar() {
const { t } = useTranslation();
@@ -17,6 +18,8 @@ export function TopBar() {
const tenants = user?.tenants || [];
const { toggleSidebar, toggleMessageSidebar } = useUIStore();
const logoutMutation = useLogout();
const minimizedWindows = useWindowStore((s) => s.windows.filter((w) => w.state === 'minimized'));
const restoreWindow = useWindowStore((s) => s.restoreWindow);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const userRef = useRef<HTMLDivElement>(null);
@@ -77,6 +80,22 @@ export function TopBar() {
</div>
<div className="flex items-center gap-2">
{/* Minimized windows */}
{minimizedWindows.length > 0 && (
<div className="flex items-center gap-1.5">
{minimizedWindows.map((win) => (
<button
key={win.id}
onClick={() => restoreWindow(win.id)}
className="flex items-center gap-1.5 bg-secondary-100 text-secondary-700 px-3 py-1 rounded-md text-sm hover:bg-secondary-200"
title={win.title}
>
<Layers className="w-3.5 h-3.5" />
<span className="max-w-32 truncate">{win.title}</span>
</button>
))}
</div>
)}
{/* User menu */}
<div ref={userRef} className="relative">
<button
@@ -0,0 +1,210 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Sparkles, Send } from 'lucide-react';
import {
streamChat,
createSession,
fetchMessages,
type ChatMessage,
} from '@/api/ai';
interface AiChatPanelProps {
windowTitle: string;
windowType: string;
}
interface SimpleMessage {
id: string;
role: string;
content: string;
}
export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
const [messages, setMessages] = useState<SimpleMessage[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [error, setError] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [loadingSession, setLoadingSession] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const msgIdCounter = useRef(0);
// Create a sidebar session on mount
useEffect(() => {
let cancelled = false;
setLoadingSession(true);
createSession({ title: `KI: ${windowTitle}`, is_sidebar: true })
.then((session) => {
if (cancelled) return;
setSessionId(session.id);
return fetchMessages(session.id).then((msgs: ChatMessage[]) => {
if (cancelled) return;
setMessages(
msgs.map((m) => ({ id: m.id, role: m.role, content: m.content }))
);
});
})
.catch((e) => {
if (!cancelled) {
console.error('AI session creation failed:', e);
setError('KI Chat ist in diesem Kontext nicht verfügbar');
}
})
.finally(() => {
if (!cancelled) setLoadingSession(false);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, streamingContent]);
const handleSend = useCallback(async () => {
const content = input.trim();
if (!content || isStreaming || !sessionId) return;
setInput('');
setIsStreaming(true);
setStreamingContent('');
setError(null);
const userMsg: SimpleMessage = {
id: `msg-${++msgIdCounter.current}`,
role: 'user',
content,
};
setMessages((prev) => [...prev, userMsg]);
try {
const stream = streamChat(sessionId, content);
let accumulated = '';
for await (const event of stream) {
if (event.type === 'token' && event.content) {
accumulated += event.content;
setStreamingContent(accumulated);
} else if (event.type === 'done') {
const msgs = await fetchMessages(sessionId);
setMessages(
msgs.map((m) => ({ id: m.id, role: m.role, content: m.content }))
);
setStreamingContent('');
} else if (event.type === 'error') {
setError(event.content || 'Ein Fehler ist aufgetreten');
}
}
} catch (e: any) {
setError(e?.message || 'KI Chat nicht verfügbar');
} finally {
setIsStreaming(false);
}
}, [input, isStreaming, sessionId]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
if (loadingSession) {
return (
<div className="flex flex-col h-full">
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary-500" />
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span>
</div>
<div className="flex-1 flex items-center justify-center text-sm text-secondary-400">
Verbinde mit KI...
</div>
</div>
);
}
if (error && !sessionId) {
return (
<div className="flex flex-col h-full">
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary-500" />
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span>
</div>
<div className="flex-1 flex items-center justify-center p-4 text-center">
<div>
<Sparkles className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
<p className="text-sm text-secondary-500">{error}</p>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary-500" />
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span>
</div>
{/* Context info */}
<div className="px-3 py-1.5 bg-primary-50 border-b border-primary-100 text-xs text-primary-700">
Kontext: {windowTitle}
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-2">
{messages.length === 0 && !streamingContent && (
<p className="text-sm text-secondary-400 text-center mt-4">
Stelle eine Frage zum aktuellen Fenster...
</p>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={
msg.role === 'user'
? 'ml-8 bg-primary-100 text-primary-900 rounded-lg px-3 py-2 text-sm'
: 'mr-8 bg-secondary-100 text-secondary-900 rounded-lg px-3 py-2 text-sm'
}
>
<div className="whitespace-pre-wrap break-words">{msg.content}</div>
</div>
))}
{streamingContent && (
<div className="mr-8 bg-secondary-100 text-secondary-900 rounded-lg px-3 py-2 text-sm">
<div className="whitespace-pre-wrap break-words">{streamingContent}</div>
</div>
)}
{error && (
<div className="text-xs text-danger-600 px-2 py-1">{error}</div>
)}
</div>
{/* Input */}
<div className="border-t border-secondary-200 p-3">
<div className="flex items-end gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Nachricht eingeben..."
rows={1}
className="flex-1 px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
disabled={isStreaming}
/>
<button
onClick={handleSend}
disabled={!input.trim() || isStreaming}
className="p-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Senden"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
import React, { useRef, useState, useCallback } from 'react';
import clsx from 'clsx';
import { Sparkles, Maximize2, Minimize2, Minus, X } from 'lucide-react';
import { useWindowStore, type WindowState } from '@/store/windowStore';
import { AiChatPanel } from './AiChatPanel';
interface WindowProps {
window: WindowState;
}
export function Window({ window: win }: WindowProps) {
const {
closeWindow,
minimizeWindow,
toggleFullscreen,
toggleAiChat,
setActiveWindow,
updateWindowPosition,
} = useWindowStore();
const headerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0, posX: 0, posY: 0 });
const ContentComponent = win.component;
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (win.state === 'fullscreen') return;
// Only drag from header, not from buttons
if ((e.target as HTMLElement).closest('button')) return;
setIsDragging(true);
dragStart.current = {
x: e.clientX,
y: e.clientY,
posX: win.position.x,
posY: win.position.y,
};
setActiveWindow(win.id);
},
[win.id, win.state, win.position, setActiveWindow]
);
React.useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
const newX = dragStart.current.posX + dx;
const newY = Math.max(0, dragStart.current.posY + dy);
updateWindowPosition(win.id, { x: newX, y: newY });
};
const handleMouseUp = () => setIsDragging(false);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, win.id, updateWindowPosition]);
if (win.state === 'minimized') return null;
const isFullscreen = win.state === 'fullscreen';
const containerClass = clsx(
'fixed flex flex-col bg-white shadow-2xl rounded-lg overflow-hidden',
isFullscreen
? 'inset-2 rounded-lg'
: 'rounded-lg',
isDragging && 'cursor-grabbing'
);
const containerStyle: React.CSSProperties = isFullscreen
? { zIndex: win.zIndex }
: {
left: win.position.x,
top: win.position.y,
width: win.size.width,
height: win.size.height,
zIndex: win.zIndex,
};
return (
<div
className={containerClass}
style={containerStyle}
onMouseDown={() => setActiveWindow(win.id)}
role="dialog"
aria-label={win.title}
>
{/* Header */}
<div
ref={headerRef}
onMouseDown={handleMouseDown}
className="bg-secondary-50 border-b border-secondary-200 px-4 py-2 flex items-center justify-between cursor-grab select-none flex-shrink-0"
>
<span className="text-sm font-semibold text-secondary-700 truncate">
{win.title}
</span>
<div className="flex items-center gap-1">
{/* AI Chat Toggle */}
<button
onClick={() => toggleAiChat(win.id)}
className={clsx(
'p-1.5 rounded hover:bg-secondary-200',
win.aiChatVisible && 'bg-primary-100 text-primary-600'
)}
aria-label="KI Chat ein/aus"
title="KI Chat"
>
<Sparkles className="w-4 h-4" />
</button>
{/* Fullscreen Toggle */}
<button
onClick={() => toggleFullscreen(win.id)}
className="p-1.5 rounded hover:bg-secondary-200"
aria-label={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
title={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
>
{isFullscreen ? (
<Minimize2 className="w-4 h-4" />
) : (
<Maximize2 className="w-4 h-4" />
)}
</button>
{/* Minimize */}
<button
onClick={() => minimizeWindow(win.id)}
className="p-1.5 rounded hover:bg-secondary-200"
aria-label="Minimieren"
title="Minimieren"
>
<Minus className="w-4 h-4" />
</button>
{/* Close */}
<button
onClick={() => closeWindow(win.id)}
className="p-1.5 rounded hover:bg-danger-100 hover:text-danger-600"
aria-label="Schließen"
title="Schließen"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Content Area */}
<div className="flex-1 flex overflow-hidden">
{/* Main Content */}
<div
className={clsx(
'flex-1 overflow-y-auto',
win.aiChatVisible && 'border-r border-secondary-200'
)}
>
<ContentComponent {...win.componentProps} />
</div>
{/* AI Chat Panel */}
{win.aiChatVisible && (
<div className="w-1/2 flex flex-col">
<AiChatPanel windowTitle={win.title} windowType={win.type} />
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,19 @@
import React from 'react';
import { useWindowStore } from '@/store/windowStore';
import { Window } from './Window';
export function WindowContainer() {
const windows = useWindowStore((s) => s.windows);
const visibleWindows = windows.filter((w) => w.state !== 'minimized');
if (visibleWindows.length === 0) return null;
return (
<>
{visibleWindows.map((win) => (
<Window key={win.id} window={win} />
))}
</>
);
}
+13 -16
View File
@@ -6,7 +6,8 @@ import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ContactDetail } from '@/components/contacts/ContactDetail';
import { ContactEditModal } from '@/components/contacts/ContactEditModal';
import { ContactEditForm } from '@/components/contacts/ContactEditForm';
import { useWindowStore } from '@/store/windowStore';
import { useUnifiedContact, type UnifiedContact } from '@/api/hooks';
import { Button } from '@/components/ui/Button';
import { ChevronLeft } from 'lucide-react';
@@ -16,13 +17,20 @@ export function ContactDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: contact, isLoading } = useUnifiedContact(id);
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [editingContact, setEditingContact] = React.useState<UnifiedContact | null>(null);
const openWindow = useWindowStore((s) => s.openWindow);
const handleEdit = () => {
if (contact) {
setEditingContact(contact);
setEditModalOpen(true);
openWindow({
title: t('contacts.edit'),
type: 'contact-edit',
component: ContactEditForm,
componentProps: {
contact,
onClose: () => {},
onSaved: () => {},
},
});
}
};
@@ -30,11 +38,6 @@ export function ContactDetailPage() {
navigate('/contacts');
};
const handleSaved = () => {
setEditModalOpen(false);
setEditingContact(null);
};
return (
<div className="flex flex-col h-full" data-testid="contact-detail-page">
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-200 bg-white">
@@ -55,12 +58,6 @@ export function ContactDetailPage() {
onDeleted={handleDeleted}
/>
</div>
<ContactEditModal
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
contact={editingContact}
onSaved={handleSaved}
/>
</div>
);
}
+31 -22
View File
@@ -15,7 +15,8 @@ import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
import { ContactFolderTree, type ContactFilter } from '@/components/contacts/ContactFolderTree';
import { ContactList, type ContactViewMode } from '@/components/contacts/ContactList';
import { ContactDetail } from '@/components/contacts/ContactDetail';
import { ContactEditModal } from '@/components/contacts/ContactEditModal';
import { ContactEditForm } from '@/components/contacts/ContactEditForm';
import { useWindowStore } from '@/store/windowStore';
import { SavedFilters } from '@/components/SavedFilters';
import { ArrowDownAZ, ArrowUpZA, ChevronLeft, LayoutGrid, List, Plus } from 'lucide-react';
import {
@@ -39,8 +40,7 @@ export function ContactsListPage() {
const [viewMode, setViewMode] = useState<ContactViewMode>('list');
const [selectedContactId, setSelectedContactId] = useState<string | null>(null);
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingContact, setEditingContact] = useState<UnifiedContact | null>(null);
const openWindow = useWindowStore((s) => s.openWindow);
// Debounce search
useEffect(() => {
@@ -134,19 +134,40 @@ export function ContactsListPage() {
setPage(1);
}, []);
// Handle edit modal saved
const handleSaved = useCallback((id: string) => {
setSelectedContactId(id);
setActiveView('detail');
}, []);
// Handle create
const handleCreate = useCallback(() => {
setEditingContact(null);
setEditModalOpen(true);
}, []);
openWindow({
title: t('contacts.create'),
type: 'contact-create',
component: ContactEditForm,
componentProps: {
onClose: () => {},
onSaved: handleSaved,
},
});
}, [openWindow, t, handleSaved]);
// Handle edit
const handleEdit = useCallback(() => {
if (selectedContact) {
setEditingContact(selectedContact);
setEditModalOpen(true);
openWindow({
title: t('contacts.edit'),
type: 'contact-edit',
component: ContactEditForm,
componentProps: {
contact: selectedContact,
onClose: () => {},
onSaved: handleSaved,
},
});
}
}, [selectedContact]);
}, [selectedContact, openWindow, t, handleSaved]);
// Handle delete (from detail)
const handleDeleted = useCallback(() => {
@@ -154,12 +175,6 @@ export function ContactsListPage() {
setActiveView('list');
}, []);
// Handle edit modal saved
const handleSaved = useCallback((id: string) => {
setSelectedContactId(id);
setActiveView('detail');
}, []);
// Register toolbar items
const registerItems = usePluginToolbarStore((s) => s.registerItems);
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
@@ -458,13 +473,7 @@ export function ContactsListPage() {
)}
</div>
{/* Edit/Create Modal */}
<ContactEditModal
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
contact={editingContact}
onSaved={handleSaved}
/>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { create } from 'zustand';
import type { ComponentType } from 'react';
export interface WindowState {
id: string;
title: string;
type: string; // 'contact-create', 'contact-edit', etc.
component: ComponentType<any>;
componentProps: Record<string, any>;
state: 'open' | 'minimized' | 'fullscreen';
aiChatVisible: boolean;
zIndex: number;
position: { x: number; y: number };
size: { width: number; height: number };
}
export interface WindowManagerStore {
windows: WindowState[];
activeWindowId: string | null;
nextZIndex: number;
openWindow: (config: Omit<WindowState, 'id' | 'state' | 'aiChatVisible' | 'zIndex' | 'position' | 'size'>) => string;
closeWindow: (id: string) => void;
minimizeWindow: (id: string) => void;
restoreWindow: (id: string) => void;
toggleFullscreen: (id: string) => void;
toggleAiChat: (id: string) => void;
setActiveWindow: (id: string) => void;
updateWindowPosition: (id: string, pos: { x: number; y: number }) => void;
updateWindowSize: (id: string, size: { width: number; height: number }) => void;
}
let windowIdCounter = 0;
const DEFAULT_SIZE = { width: 800, height: 600 };
const DEFAULT_POSITION = { x: 120, y: 80 };
export const useWindowStore = create<WindowManagerStore>((set, get) => ({
windows: [],
activeWindowId: null,
nextZIndex: 100,
openWindow: (config) => {
const id = `win-${++windowIdCounter}`;
const z = get().nextZIndex;
const offset = get().windows.length * 24;
const newWindow: WindowState = {
...config,
id,
state: 'open',
aiChatVisible: false,
zIndex: z,
position: { x: DEFAULT_POSITION.x + offset, y: DEFAULT_POSITION.y + offset },
size: { ...DEFAULT_SIZE },
};
set((s) => ({
windows: [...s.windows, newWindow],
activeWindowId: id,
nextZIndex: z + 1,
}));
return id;
},
closeWindow: (id) =>
set((s) => ({
windows: s.windows.filter((w) => w.id !== id),
activeWindowId: s.activeWindowId === id ? null : s.activeWindowId,
})),
minimizeWindow: (id) =>
set((s) => ({
windows: s.windows.map((w) =>
w.id === id ? { ...w, state: 'minimized' as const } : w
),
activeWindowId: s.activeWindowId === id ? null : s.activeWindowId,
})),
restoreWindow: (id) => {
const z = get().nextZIndex;
set((s) => ({
windows: s.windows.map((w) =>
w.id === id
? { ...w, state: 'open' as const, zIndex: z }
: w
),
activeWindowId: id,
nextZIndex: z + 1,
}));
},
toggleFullscreen: (id) =>
set((s) => ({
windows: s.windows.map((w) =>
w.id === id
? { ...w, state: w.state === 'fullscreen' ? 'open' as const : 'fullscreen' as const }
: w
),
})),
toggleAiChat: (id) =>
set((s) => ({
windows: s.windows.map((w) =>
w.id === id ? { ...w, aiChatVisible: !w.aiChatVisible } : w
),
})),
setActiveWindow: (id) => {
const z = get().nextZIndex;
set((s) => ({
windows: s.windows.map((w) =>
w.id === id ? { ...w, zIndex: z } : w
),
activeWindowId: id,
nextZIndex: z + 1,
}));
},
updateWindowPosition: (id, pos) =>
set((s) => ({
windows: s.windows.map((w) =>
w.id === id ? { ...w, position: pos } : w
),
})),
updateWindowSize: (id, size) =>
set((s) => ({
windows: s.windows.map((w) =>
w.id === id ? { ...w, size } : w
),
})),
}));