revert(frontend): Frontend auf letzten funktionierenden Stand 5680179 zurückgesetzt — i18n-Massen-Batch brach Dashboard-Shell in Produktion; Render-Loop-Fix und DSGVO-Antrags-UI liegen sicher in Historie für kontrollierten Wiedereinspiel

This commit is contained in:
Agent Zero
2026-08-27 09:14:21 +02:00
parent bea479bfad
commit 66c11d3d64
119 changed files with 925 additions and 1477 deletions
+17 -20
View File
@@ -39,7 +39,6 @@ function ProviderTab() {
};
const handleDelete = async (id: string) => {
const { t } = useTranslation();
if (!confirm(t('aiSettings.confirmDeleteProvider'))) return;
try { await deleteProvider(id); await load(); } catch (e: unknown) { const errObj = asError(e); setError(errObj?.message || null); }
};
@@ -61,7 +60,7 @@ function ProviderTab() {
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="ollama">Ollama</option>
<option value="azure">{t('aISettings.azureopenai')}</option>
<option value="azure">Azure OpenAI</option>
<option value="huggingface">HuggingFace</option>
<option value="custom">Custom</option>
</select>
@@ -135,24 +134,24 @@ function PresetTab() {
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold">{t('aISettings.modellepresets')}</h2>
<h2 className="text-lg font-semibold">Modelle & Presets</h2>
<button onClick={() => { setShowForm(!showForm); setEditId(null); setForm({ name: '', model_id: '', provider_id: '', temperature: 0.7, max_tokens: 2048, top_p: 1.0, system_prompt: '' }); }} className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg">{t('aiSettings.add')} </button>
</div>
{error && <div className="text-sm text-red-600">{typeof error === "string" ? error : (error as any)?.message || "t('common.errorOccurred')"}</div>}
{showForm && (
<div className="border border-secondary-200 rounded-lg p-4 space-y-3 bg-secondary-50">
<div className="grid grid-cols-2 gap-3">
<input placeholder={t('aISettings.presetname')} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
<input placeholder={t('aISettings.modellidzbgpt4o')} value={form.model_id} onChange={(e) => setForm({ ...form, model_id: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
<input placeholder="Preset Name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
<input placeholder="Modell ID (z.B. gpt-4o-mini)" value={form.model_id} onChange={(e) => setForm({ ...form, model_id: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
<select value={form.provider_id} onChange={(e) => setForm({ ...form, provider_id: e.target.value })} className="border rounded-lg px-3 py-2 text-sm">
<option value="">{t('aISettings.anbieterwählen')}</option>
<option value="">Anbieter wählen...</option>
{providers.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<input type="number" step="0.1" min="0" max="2" placeholder={t('aISettings.temperature')} value={form.temperature} onChange={(e) => setForm({ ...form, temperature: parseFloat(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
<input type="number" placeholder={t('aISettings.maxtokens')} value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: parseInt(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
<input type="number" step="0.1" min="0" max="1" placeholder={t('aISettings.topp')} value={form.top_p} onChange={(e) => setForm({ ...form, top_p: parseFloat(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
<input type="number" step="0.1" min="0" max="2" placeholder="Temperature" value={form.temperature} onChange={(e) => setForm({ ...form, temperature: parseFloat(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
<input type="number" placeholder="Max Tokens" value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: parseInt(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
<input type="number" step="0.1" min="0" max="1" placeholder="Top P" value={form.top_p} onChange={(e) => setForm({ ...form, top_p: parseFloat(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
</div>
<textarea placeholder={t('aISettings.systempromptoptional')} value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} rows={3} className="w-full border rounded-lg px-3 py-2 text-sm" />
<textarea placeholder="System Prompt (optional)" value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} rows={3} className="w-full border rounded-lg px-3 py-2 text-sm" />
<div className="flex gap-2">
<button onClick={handleSave} className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg">{t('aiSettings.save')} </button>
<button onClick={() => setShowForm(false)} className="px-3 py-1.5 text-sm border rounded-lg">{t('aiSettings.cancel')} </button>
@@ -164,7 +163,7 @@ function PresetTab() {
<div key={p.id} className="border border-secondary-200 rounded-lg p-3 flex items-center justify-between">
<div>
<div className="font-medium text-sm">{p.name}</div>
<div className="text-xs text-secondary-500">{p.model_id} {t('aISettings.temp')}{p.temperature} {t('aISettings.maxtokens2')}{p.max_tokens}</div>
<div className="text-xs text-secondary-500">{p.model_id} · temp={p.temperature} · max_tokens={p.max_tokens}</div>
</div>
<div className="flex gap-2">
<button onClick={() => handleEdit(p)} className="text-sm text-primary-600 hover:underline">{t('aiSettings.edit')} </button>
@@ -214,7 +213,6 @@ function AgentTab() {
};
const toggleTool = (toolName: string) => {
const { t } = useTranslation();
setForm((prev) => ({
...prev,
tool_ids: prev.tool_ids.includes(toolName)
@@ -236,13 +234,13 @@ function AgentTab() {
<div className="border border-secondary-200 rounded-lg p-4 space-y-3 bg-secondary-50">
<div className="grid grid-cols-2 gap-3">
<input placeholder={t('aiSettings.name')} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
<input placeholder={t('aISettings.beschreibung')} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
<input placeholder="Beschreibung" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
</div>
<select value={form.preset_id} onChange={(e) => setForm({ ...form, preset_id: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm">
<option value="">{t('aISettings.presetwählen')}</option>
<option value="">Preset wählen...</option>
{presets.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.model_id})</option>)}
</select>
<textarea placeholder={t('aISettings.systemprompt')} value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} rows={4} className="w-full border rounded-lg px-3 py-2 text-sm" />
<textarea placeholder="System Prompt" value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} rows={4} className="w-full border rounded-lg px-3 py-2 text-sm" />
{tools.length > 0 && (
<div>
<div className="text-sm font-medium mb-2">Tools:</div>
@@ -296,11 +294,11 @@ function ToolsTab() {
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">{t('aISettings.verfügbaretools')}</h2>
<p className="text-sm text-secondary-500">{t('aISettings.diesetoolswerdenvonpluginsbereitgestellt')}</p>
<h2 className="text-lg font-semibold">Verfügbare Tools</h2>
<p className="text-sm text-secondary-500">Diese Tools werden von Plugins bereitgestellt und können Agenten zugewiesen werden.</p>
{error && <div className="text-sm text-red-600">{typeof error === "string" ? error : (error as any)?.message || "t('common.errorOccurred')"}</div>}
{tools.length === 0 ? (
<div className="text-sm text-secondary-400 py-8 text-center">{t('aISettings.keinetoolsverfügbarpluginskönnentools')}</div>
<div className="text-sm text-secondary-400 py-8 text-center">Keine Tools verfügbar. Plugins können Tools registrieren.</div>
) : (
<div className="space-y-2">
{tools.map((tool) => (
@@ -324,7 +322,6 @@ function ToolsTab() {
// ─── Main Settings Page ───
export function AISettingsPage() {
const { t } = useTranslation();
const tabs = [
{ key: 'providers', label: 'Anbieter', content: <ProviderTab /> },
{ key: 'presets', label: 'Modelle & Presets', content: <PresetTab /> },
@@ -334,7 +331,7 @@ export function AISettingsPage() {
return (
<div className="max-w-4xl">
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('aISettings.kiassistenteinstellungen')}</h1>
<h1 className="text-2xl font-bold text-secondary-900 mb-6">KI Assistent Einstellungen</h1>
<Tabs tabs={tabs} />
</div>
);
+4 -4
View File
@@ -184,7 +184,7 @@ function AgentForm({
onChange={(e) => updateField('name', e.target.value)}
required
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder={t('agentDashboard.myagent')}
placeholder="My Agent"
/>
</div>
<div>
@@ -196,7 +196,7 @@ function AgentForm({
onChange={(e) => updateField('description', e.target.value)}
rows={2}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder={t('agentDashboard.optionaldescription')}
placeholder="Optional description"
/>
</div>
</div>
@@ -213,7 +213,7 @@ function AgentForm({
onChange={(e) => updateField('model', e.target.value)}
list="model-suggestions"
className="flex-1 rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder={t('agentDashboard.gpt4')}
placeholder="gpt-4"
/>
<datalist id="model-suggestions">
{commonModels.map((m) => (
@@ -233,7 +233,7 @@ function AgentForm({
onChange={(e) => updateField('system_prompt', e.target.value)}
rows={4}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder={t('agentDashboard.youareahelpfulassistant')}
placeholder="You are a helpful assistant..."
/>
</div>
+3 -6
View File
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { useUIStore } from '@/store/uiStore';
import { ChevronRight, ChevronDown, Bot, Zap, Brain, Cpu, Settings2, Activity, MessageSquare, ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface AgentNode {
title: string;
@@ -24,7 +23,6 @@ const AGENT_TREE: AgentNode[] = [
];
function TreeItem({ node, depth }: { node: AgentNode; depth: number }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(depth < 1);
const hasChildren = node.children && node.children.length > 0;
const paddingLeft = depth * 16 + 12;
@@ -72,7 +70,6 @@ function TreeItem({ node, depth }: { node: AgentNode; depth: number }) {
}
export function AgentsPage() {
const { t } = useTranslation();
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
return (
@@ -83,14 +80,14 @@ export function AgentsPage() {
<button
onClick={() => window.location.href = '/start'}
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
aria-label={t('agents.zurückzurstartseite')}
title={t('agents.zurückzurstartseite')}
aria-label="Zurück zur Startseite"
title="Zurück zur Startseite"
>
<ArrowLeft className="w-4 h-4" />
</button>
<h1 className="text-xl font-bold text-secondary-900">Agenten</h1>
</div>
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label={t('agents.agentennavigation')}>
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label="Agenten Navigation">
{AGENT_TREE.map((node) => (
<TreeItem key={node.title} node={node} depth={0} />
))}
+3 -3
View File
@@ -115,19 +115,19 @@ export function AuditLogPage() {
label={t('auditLog.user')}
value={filterUser}
onChange={(e) => setFilterUser(e.target.value)}
placeholder={t('auditLog.annaschmidt')}
placeholder="anna.schmidt"
/>
<Input
label={t('auditLog.action')}
value={filterAction}
onChange={(e) => setFilterAction(e.target.value)}
placeholder={t('auditLog.createupdatedelete')}
placeholder="create, update, delete"
/>
<Input
label={t('auditLog.entity')}
value={filterEntity}
onChange={(e) => setFilterEntity(e.target.value)}
placeholder={t('auditLog.companycontact')}
placeholder="company, contact"
/>
<Input
label={t('auditLog.dateFrom')}
+3 -6
View File
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { useUIStore } from '@/store/uiStore';
import { ChevronRight, ChevronDown, Zap, Play, History, GitBranch, Settings2, Activity, Clock, ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface AutomationNode {
title: string;
@@ -33,7 +32,6 @@ const AUTOMATION_TREE: AutomationNode[] = [
];
function TreeItem({ node, depth }: { node: AutomationNode; depth: number }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(depth < 1);
const hasChildren = node.children && node.children.length > 0;
const paddingLeft = depth * 16 + 12;
@@ -81,7 +79,6 @@ function TreeItem({ node, depth }: { node: AutomationNode; depth: number }) {
}
export function AutomationPage() {
const { t } = useTranslation();
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
return (
@@ -92,14 +89,14 @@ export function AutomationPage() {
<button
onClick={() => window.location.href = '/start'}
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
aria-label={t('automation.zurückzurstartseite')}
title={t('automation.zurückzurstartseite')}
aria-label="Zurück zur Startseite"
title="Zurück zur Startseite"
>
<ArrowLeft className="w-4 h-4" />
</button>
<h1 className="text-xl font-bold text-secondary-900">Automation</h1>
</div>
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label={t('automation.automationnavigation')}>
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label="Automation Navigation">
{AUTOMATION_TREE.map((node) => (
<TreeItem key={node.title} node={node} depth={0} />
))}
+9 -9
View File
@@ -215,7 +215,7 @@ function AutomationForm({
onChange={(e) => updateField('name', e.target.value)}
required
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder={t('automationDashboard.myautomation')}
placeholder="My Automation"
/>
</div>
<div>
@@ -227,7 +227,7 @@ function AutomationForm({
onChange={(e) => updateField('description', e.target.value)}
rows={2}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder={t('automationDashboard.optionaldescription')}
placeholder="Optional description"
/>
</div>
</div>
@@ -255,7 +255,7 @@ function AutomationForm({
value={form.trigger_config.event_name || ''}
onChange={(e) => updateField('trigger_config', { ...form.trigger_config, event_name: e.target.value })}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder={t('automationDashboard.contactcreated')}
placeholder="contact.created"
/>
</div>
)}
@@ -271,7 +271,7 @@ function AutomationForm({
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="0 9 * * *"
/>
<p className="text-xs text-secondary-400 mt-1">{t('automationDashboard.cronexpressioneg09')}</p>
<p className="text-xs text-secondary-400 mt-1">Cron expression (e.g. 0 9 * * * for daily at 9am)</p>
</div>
)}
@@ -294,7 +294,7 @@ function AutomationForm({
type="text"
value={cond.field}
onChange={(e) => updateCondition(i, 'field', e.target.value)}
placeholder={t('automationDashboard.field')}
placeholder="Field"
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<Select
@@ -307,14 +307,14 @@ function AutomationForm({
type="text"
value={cond.value}
onChange={(e) => updateCondition(i, 'value', e.target.value)}
placeholder={t('automationDashboard.value')}
placeholder="Value"
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<button
type="button"
onClick={() => removeCondition(i)}
className="text-danger-500 hover:text-danger-700 p-1"
aria-label={t('automationDashboard.removecondition')}
aria-label="Remove condition"
>
<XCircle className="h-4 w-4" />
</button>
@@ -353,14 +353,14 @@ function AutomationForm({
// ignore invalid JSON while typing
}
}}
placeholder={t('automationDashboard.url')}
placeholder='{"url": "..."}'
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<button
type="button"
onClick={() => removeAction(i)}
className="text-danger-500 hover:text-danger-700 p-1"
aria-label={t('automationDashboard.removeaction')}
aria-label="Remove action"
>
<XCircle className="h-4 w-4" />
</button>
+6 -6
View File
@@ -148,7 +148,7 @@ export function AutomationSettingsPage() {
value={form.default_llm_model}
onChange={(e) => updateField('default_llm_model', e.target.value)}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder={t('automationSettings.gpt4')}
placeholder="gpt-4"
/>
<p className="text-xs text-secondary-400 mt-1">{t('automation.defaultLlmModelHint')}</p>
</div>
@@ -272,7 +272,7 @@ export function AutomationSettingsPage() {
value={miniAppForm.app_id}
onChange={(e) => setMiniAppForm({ ...miniAppForm, app_id: e.target.value })}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
placeholder={t('automationSettings.mycustomapp')}
placeholder="my_custom_app"
/>
</div>
<div>
@@ -282,7 +282,7 @@ export function AutomationSettingsPage() {
value={miniAppForm.name}
onChange={(e) => setMiniAppForm({ ...miniAppForm, name: e.target.value })}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
placeholder={t('automationSettings.mycustomapp2')}
placeholder="My Custom App"
/>
</div>
<div>
@@ -292,7 +292,7 @@ export function AutomationSettingsPage() {
value={miniAppForm.icon}
onChange={(e) => setMiniAppForm({ ...miniAppForm, icon: e.target.value })}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
placeholder={t('automationSettings.appwindow')}
placeholder="AppWindow"
/>
</div>
<div>
@@ -302,7 +302,7 @@ export function AutomationSettingsPage() {
onChange={(e) => setMiniAppForm({ ...miniAppForm, description: e.target.value })}
rows={2}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
placeholder={t('automationSettings.optionaldescription')}
placeholder="Optional description"
/>
</div>
<div>
@@ -312,7 +312,7 @@ export function AutomationSettingsPage() {
onChange={(e) => setMiniAppForm({ ...miniAppForm, render_schema: e.target.value })}
rows={4}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono"
placeholder={t('automationSettings.typeformfields')}
placeholder='{"type": "form", "fields": []}'
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
+10 -16
View File
@@ -12,7 +12,6 @@ import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
import { apiClient } from '@/api/client';
import { streamChat, fetchAgents, fetchMessages as fetchAiMessages, type AIAgent } from '@/api/ai';
import { useTranslation } from 'react-i18next';
// ─── Types ───
@@ -217,7 +216,6 @@ interface ConversationTreeProps {
}
function ConversationTree({ conversations, activeConvId, onSelect, onNewChat, loading }: ConversationTreeProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<Record<ConversationCategory, boolean>>({
system: true,
ai: true,
@@ -295,7 +293,7 @@ function ConversationTree({ conversations, activeConvId, onSelect, onNewChat, lo
</button>
))}
{expanded[section.category] && section.conversations.length === 0 && (
<div className="px-6 py-2 text-xs text-secondary-400">{t('communication.keinekonversationen')}</div>
<div className="px-6 py-2 text-xs text-secondary-400">Keine Konversationen</div>
)}
</div>
))}
@@ -313,7 +311,6 @@ interface ChatWindowProps {
}
function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
const { t } = useTranslation();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(true);
@@ -502,11 +499,11 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
<button
onClick={() => setShowMiniApps(!showMiniApps)}
className="p-1 hover:bg-secondary-100 rounded"
title={t('communication.miniapps')}
title="Mini-Apps"
>
<Sparkles className="w-4 h-4 text-secondary-400" />
</button>
<button className="p-1 hover:bg-secondary-100 rounded" title={t('communication.inneuemfenster')}>
<button className="p-1 hover:bg-secondary-100 rounded" title="In neuem Fenster">
<ExternalLink className="w-4 h-4 text-secondary-400" />
</button>
</div>
@@ -540,7 +537,7 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
{!loading && messages.length === 0 && !aiStreaming && (
<div className="text-center text-secondary-400 py-8">
<MessageSquare className="w-10 h-10 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t('communication.keinenachrichtenschreibedieerste')}</p>
<p className="text-sm">Keine Nachrichten. Schreibe die erste!</p>
</div>
)}
{messages.map(msg => (
@@ -552,7 +549,7 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
<Bot className="w-4 h-4 text-primary-600" />
</div>
<div className="flex-1">
<div className="text-xs text-secondary-500 mb-1">{t('communication.kiantwortet')}</div>
<div className="text-xs text-secondary-500 mb-1">KI antwortet...</div>
<div className="ai-markdown max-w-none break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{streamingContent || '...'}</ReactMarkdown>
</div>
@@ -564,10 +561,10 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
{/* Input */}
<div className="border-t border-secondary-200 p-3">
<div className="flex items-end gap-2">
<button className="p-2 hover:bg-secondary-100 rounded-lg" title={t('communication.dateianhängen')}>
<button className="p-2 hover:bg-secondary-100 rounded-lg" title="Datei anhängen">
<Paperclip className="w-4 h-4 text-secondary-400" />
</button>
<button className="p-2 hover:bg-secondary-100 rounded-lg" title={t('communication.emoji')}>
<button className="p-2 hover:bg-secondary-100 rounded-lg" title="Emoji">
<Smile className="w-4 h-4 text-secondary-400" />
</button>
<textarea
@@ -670,7 +667,6 @@ interface NewChatDialogProps {
}
function NewChatDialog({ category, onClose, onCreate }: NewChatDialogProps) {
const { t } = useTranslation();
const [title, setTitle] = useState('');
const [users, setUsers] = useState<{id: string; name: string; email: string}[]>([]);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
@@ -711,7 +707,7 @@ function NewChatDialog({ category, onClose, onCreate }: NewChatDialogProps) {
/>
{category === 'colleague' && (
<div>
<label className="text-sm font-medium text-secondary-700 mb-1 block">{t('communication.teilnehmerauswählen')}</label>
<label className="text-sm font-medium text-secondary-700 mb-1 block">Teilnehmer auswählen</label>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
@@ -749,14 +745,12 @@ function NewChatDialog({ category, onClose, onCreate }: NewChatDialogProps) {
// ─── Main Page ───
export function CommunicationPage() {
const { t } = useTranslation();
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConvId, setActiveConvId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [showNewChat, setShowNewChat] = useState<ConversationCategory | null>(null);
const [mobileView, setMobileView] = useState<'list' | 'chat'>('list');
const registerItems = usePluginToolbarStore((s) => s.registerItems);
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
const { registerItems, unregisterPlugin } = usePluginToolbarStore();
const loadConversations = useCallback(async () => {
try {
@@ -857,7 +851,7 @@ export function CommunicationPage() {
<div className="flex items-center justify-center h-full text-secondary-400">
<div className="text-center">
<MessageSquare className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>{t('communication.wähleeinekonversationaus')}</p>
<p>Wähle eine Konversation aus</p>
</div>
</div>
)}
+1 -140
View File
@@ -13,14 +13,9 @@ import {
type ComplianceIncident,
type RetentionPolicyEntry,
type IncidentCreate,
submitDsarRequest,
downloadDsgvoExport,
type DsarRequestResponse,
type DsarType,
} from '../api/compliance';
import { useUsers } from '../api/users';
type SubTab = 'registry' | 'incidents' | 'retention' | 'dsar';
type SubTab = 'registry' | 'incidents' | 'retention';
export function ComplianceTab() {
const { t } = useTranslation();
@@ -30,7 +25,6 @@ export function ComplianceTab() {
{ key: 'registry', label: t('compliance.aiRegistry', 'AI-Register') },
{ key: 'incidents', label: t('compliance.incidents', 'Vorfälle') },
{ key: 'retention', label: t('compliance.retention', 'Aufbewahrung') },
{ key: 'dsar', label: t('compliance.dsar.tab', 'DSGVO-Anfragen') },
];
return (
@@ -59,7 +53,6 @@ export function ComplianceTab() {
{subTab === 'registry' && <AIRegistryPanel />}
{subTab === 'incidents' && <IncidentsPanel />}
{subTab === 'retention' && <RetentionPanel />}
{subTab === 'dsar' && <DsarPanel />}
</div>
);
}
@@ -468,135 +461,3 @@ function RetentionPanel() {
</div>
);
}
// ─── DSAR Panel (GDPR Art. 15/17/20) ───
const DSAR_TYPES: { value: DsarType; labelKey: string; fallback: string }[] = [
{ value: 'access', labelKey: 'compliance.dsar.typeAccess', fallback: 'Auskunft (Art. 15)' },
{ value: 'deletion', labelKey: 'compliance.dsar.typeDeletion', fallback: 'Löschung (Art. 17)' },
{ value: 'rectification', labelKey: 'compliance.dsar.typeRectification', fallback: 'Berichtigung (Art. 16)' },
];
function DsarPanel() {
const { t } = useTranslation();
const [userId, setUserId] = useState('');
const [dsarType, setDsarType] = useState<DsarType>('access');
const [confirmDelete, setConfirmDelete] = useState(false);
const [lastJob, setLastJob] = useState<DsarRequestResponse | null>(null);
const [exportError, setExportError] = useState(false);
const { data: usersData, isLoading: usersLoading } = useUsers(1, 200);
const dsarMutation = useMutation({
mutationFn: () => submitDsarRequest(userId, dsarType),
onSuccess: (resp) => {
setLastJob(resp);
setConfirmDelete(false);
},
});
const selectedUser = usersData?.items.find((u) => u.id === userId);
const handleExportClick = async () => {
setExportError(false);
try {
await downloadDsgvoExport(userId, selectedUser?.name ?? undefined);
} catch {
setExportError(true);
}
};
const handleSubmit = () => {
if (!userId) return;
if (dsarType === 'deletion' && !confirmDelete) {
setConfirmDelete(true);
return;
}
dsarMutation.mutate();
};
return (
<div className="space-y-4 max-w-2xl">
<p className="text-sm text-secondary-600">{t('compliance.dsar.description', 'DSGVO-Anfrage für eine Person auslösen: Auskunft (Art. 15), Löschung (Art. 17) oder Berichtigung (Art. 16). Der Vorgang wird als Hintergrund-Job durch den Worker ausgeführt.')}</p>
<div>
<label htmlFor="dsar-user-select" className="block text-xs font-medium text-secondary-500 uppercase mb-1">{t('compliance.dsar.person', 'Person')}</label>
<select
id="dsar-user-select"
value={userId}
onChange={(e) => { setUserId(e.target.value); setConfirmDelete(false); }}
className="w-full max-w-md px-3 py-3 border border-secondary-300 rounded bg-white text-sm"
aria-label={t('compliance.dsar.person', 'Person')}
>
<option value="">{usersLoading ? t('common.loading', 'Laden...') : t('compliance.dsar.selectPerson', '-- Bitte wählen --')}</option>
{(usersData?.items ?? []).map((u) => (
<option key={u.id} value={u.id}>{u.name || u.email}</option>
))}
</select>
</div>
<fieldset>
<legend className="block text-xs font-medium text-secondary-500 uppercase mb-1">{t('compliance.dsar.requestType', 'Antragsart')}</legend>
<div className="space-y-1" role="radiogroup" aria-label={t('compliance.dsar.requestType', 'Antragsart')}>
{DSAR_TYPES.map(({ value, labelKey, fallback }) => (
<label
key={value}
className={`flex items-center gap-2 px-3 py-3 min-h-[44px] rounded cursor-pointer border ${
dsarType === value ? 'border-primary-500 bg-primary-50' : 'border-secondary-200 hover:bg-secondary-50'
}`}
>
<input
type="radio"
name="dsar-type"
checked={dsarType === value}
onChange={() => { setDsarType(value); setConfirmDelete(false); }}
aria-label={t(labelKey, fallback)}
/>
<span className="text-sm">{t(labelKey, fallback)}</span>
</label>
))}
</div>
</fieldset>
<div className="flex flex-wrap gap-2 items-center">
{dsarType === 'access' && (
<button
onClick={handleExportClick}
disabled={!userId}
className="px-4 py-3 min-h-[44px] text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded disabled:opacity-50"
aria-label={t('compliance.dsar.downloadExport', 'Datenexport herunterladen')}
>
{t('compliance.dsar.downloadExport', 'Datenexport herunterladen')}
</button>
)}
<button
onClick={handleSubmit}
disabled={!userId || dsarMutation.isPending}
className={`px-4 py-3 min-h-[44px] text-sm font-medium text-white rounded disabled:opacity-50 ${
dsarType === 'deletion' ? 'bg-red-600 hover:bg-red-700' : 'bg-primary-600 hover:bg-primary-700'
}`}
aria-label={t('compliance.dsar.submit', 'Anfrage stellen')}
>
{dsarType === 'deletion' && confirmDelete
? t('compliance.dsar.confirmDeletion', 'Wirklich löschen? Unwiderruflich!')
: t('compliance.dsar.submit', 'Anfrage stellen')}
</button>
{!userId && (
<span className="text-xs text-secondary-400">{t('compliance.dsar.chooseFirst', 'Bitte zuerst eine Person wählen.')}</span>
)}
</div>
{dsarMutation.isPending && <p className="text-sm text-secondary-500" aria-live="polite">{t('common.saving', 'Wird gesendet...')}</p>}
{dsarMutation.isError && <p className="text-sm text-red-600" aria-live="polite">{t('common.errorOccurred', 'Fehler beim Senden')}</p>}
{exportError && <p className="text-sm text-red-600" aria-live="polite">{t('common.errorOccurred', 'Fehler beim Erstellen des Exports')}</p>}
{lastJob && (
<div className="rounded border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800" role="status">
{t('compliance.dsar.queued', 'Anfrage eingereicht — Job')}{' '}
<code className="font-mono">{lastJob.job_id}</code>{' '}
{t('compliance.dsar.statusQueued', '(in Warteschlange). Die Bearbeitung erfolgt im Hintergrund.')}
</div>
)}
</div>
);
}
+4 -4
View File
@@ -537,13 +537,13 @@ export function ContactsListPage() {
{/* Placeholder for saved custom views */}
{savedViews.length === 0 ? (
<div className="px-2 py-3 text-center">
<p className="text-[11px] text-secondary-400 mb-2">{t('contactsList.keinebenutzerdefiniertenansichten')}</p>
<p className="text-[11px] text-secondary-400 mb-2">Keine benutzerdefinierten Ansichten</p>
<button
onClick={handleSaveView}
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-primary-600 hover:bg-primary-50 rounded transition-colors"
>
<Plus className="w-3 h-3" />
{t('contactsList.aktuelleansichtspeichern')}
Aktuelle Ansicht speichern
</button>
</div>
) : (
@@ -572,7 +572,7 @@ export function ContactsListPage() {
className="w-full flex items-center gap-1 px-2 py-1.5 rounded text-xs text-primary-600 hover:bg-primary-50 transition-colors"
>
<Plus className="w-3 h-3" />
{t('contactsList.aktuelleansichtspeichern')}
Aktuelle Ansicht speichern
</button>
</>
)}
@@ -756,7 +756,7 @@ export function ContactsListPage() {
{/* Saved Filters Modal */}
{savedFiltersOpen && (
<Modal open={savedFiltersOpen} onClose={() => setSavedFiltersOpen(false)} title={t('contactsList.gespeichertefilter')}>
<Modal open={savedFiltersOpen} onClose={() => setSavedFiltersOpen(false)} title="Gespeicherte Filter">
<SavedFilters
entityType="contacts"
currentCriteria={{ search: debouncedSearch, type: contactType, sortBy, sortOrder, folderId }}
+3 -3
View File
@@ -394,7 +394,7 @@ export function CustomFieldsPage() {
required
value={form.label}
onChange={(e) => handleLabelChange(e.target.value)}
placeholder={t('customFields.zbbrancheabteilunggeburtsdatum')}
placeholder="z.B. Branche, Abteilung, Geburtsdatum"
/>
{/* Name (auto-generated) */}
@@ -404,7 +404,7 @@ export function CustomFieldsPage() {
value={form.name}
onChange={(e) => handleNameChange(e.target.value)}
helperText="Wird automatisch aus der Bezeichnung generiert"
placeholder={t('customFields.zbbrancheabteilunggeburtsdatum2')}
placeholder="z.B. branche, abteilung, geburtsdatum"
/>
{/* Field type */}
@@ -423,7 +423,7 @@ export function CustomFieldsPage() {
required
value={form.optionsStr}
onChange={(e) => setForm((prev) => ({ ...prev, optionsStr: e.target.value }))}
placeholder={t('customFields.option1option2option3')}
placeholder="Option 1, Option 2, Option 3"
helperText="Trennen Sie die Optionen mit Kommas"
/>
)}
+3 -3
View File
@@ -121,21 +121,21 @@ export function DashboardPage() {
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
<div className="flex items-center gap-2 mb-2">
<DollarSign className="w-4 h-4 text-warning-600" />
<span className="text-sm font-medium text-secondary-900">{t('dashboard.llmcost24h')}</span>
<span className="text-sm font-medium text-secondary-900">LLM Cost (24h)</span>
</div>
<p className="text-2xl font-bold text-secondary-900">${systemData.llm.last_24h_cost?.toFixed(2) ?? '0.00'}</p>
</div>
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-4 h-4 text-primary-600" />
<span className="text-sm font-medium text-secondary-900">{t('dashboard.llmtokens24h')}</span>
<span className="text-sm font-medium text-secondary-900">LLM Tokens (24h)</span>
</div>
<p className="text-2xl font-bold text-secondary-900">{systemData.llm.last_24h_tokens?.toLocaleString() ?? '0'}</p>
</div>
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-primary-600" />
<span className="text-sm font-medium text-secondary-900">{t('dashboard.activeplugins')}</span>
<span className="text-sm font-medium text-secondary-900">Active Plugins</span>
</div>
<p className="text-2xl font-bold text-secondary-900">{systemData.plugins?.active_plugins?.length ?? '—'}</p>
</div>
+1 -3
View File
@@ -2,10 +2,8 @@
// Guest contacts page now redirects to normal contacts page
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export function GuestContactsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
useEffect(() => {
@@ -15,7 +13,7 @@ export function GuestContactsPage() {
return (
<div className="flex items-center justify-center min-h-screen">
<p className="text-gray-500">{t('guestContacts.weiterleitungzukontakten')}</p>
<p className="text-gray-500">Weiterleitung zu Kontakten...</p>
</div>
);
}
+1 -3
View File
@@ -2,10 +2,8 @@
// Guest login page now redirects to normal login
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export function GuestLoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
useEffect(() => {
@@ -15,7 +13,7 @@ export function GuestLoginPage() {
return (
<div className="flex items-center justify-center min-h-screen">
<p className="text-gray-500">{t('guestLogin.weiterleitungzumlogin')}</p>
<p className="text-gray-500">Weiterleitung zum Login...</p>
</div>
);
}
+3 -3
View File
@@ -142,14 +142,14 @@ export function HelpPage() {
<button
onClick={() => window.location.href = '/start'}
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
aria-label={t('help.zurückzurstartseite')}
title={t('help.zurückzurstartseite')}
aria-label="Zurück zur Startseite"
title="Zurück zur Startseite"
>
<ArrowLeft className="w-4 h-4" />
</button>
<h1 className="text-xl font-bold text-secondary-900">Hilfe</h1>
</div>
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label={t('help.hilfenavigation')}>
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label="Hilfe Navigation">
{HELP_TREE.map((node) => (
<TreeItem key={node.title} node={node} depth={0} />
))}
+1 -1
View File
@@ -38,7 +38,7 @@ export function ImportExportPage() {
{/* Tab navigation */}
<div className="border-b border-secondary-200">
<nav className="flex gap-1" aria-label={t('importExport.tabs')}>
<nav className="flex gap-1" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.key}
+3 -6
View File
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { useUIStore } from '@/store/uiStore';
import { ChevronRight, ChevronDown, ScrollText, FileText, AlertTriangle, ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface LogNode {
title: string;
@@ -41,7 +40,6 @@ const LOG_TREE: LogNode[] = [
];
function TreeItem({ node, depth }: { node: LogNode; depth: number }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(depth < 1);
const hasChildren = node.children && node.children.length > 0;
const paddingLeft = depth * 16 + 12;
@@ -89,7 +87,6 @@ function TreeItem({ node, depth }: { node: LogNode; depth: number }) {
}
export function LogsPage() {
const { t } = useTranslation();
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
return (
@@ -100,14 +97,14 @@ export function LogsPage() {
<button
onClick={() => window.location.href = '/start'}
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
aria-label={t('logs.zurückzurstartseite')}
title={t('logs.zurückzurstartseite')}
aria-label="Zurück zur Startseite"
title="Zurück zur Startseite"
>
<ArrowLeft className="w-4 h-4" />
</button>
<h1 className="text-xl font-bold text-secondary-900">Logs</h1>
</div>
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label={t('logs.logsnavigation')}>
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label="Logs Navigation">
{LOG_TREE.map((node) => (
<TreeItem key={node.title} node={node} depth={0} />
))}
+2 -2
View File
@@ -970,7 +970,7 @@ export function MailPage() {
<button
onClick={() => setActiveView('folders')}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label={t('mail.zurückzuordnern')}
aria-label="Zurück zu Ordnern"
data-testid="mobile-back-to-folders"
>
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
@@ -1003,7 +1003,7 @@ export function MailPage() {
<button
onClick={() => setActiveView('list')}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label={t('mail.zurückzurliste')}
aria-label="Zurück zur Liste"
data-testid="mobile-back-to-list"
>
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
+7 -7
View File
@@ -221,25 +221,25 @@ export function MailSettingsPage() {
label={t('mail.email')}
{...registerAccount('email')}
error={accountErrors.email?.message === 'required' ? t('validation.required') : accountErrors.email?.message === 'invalidEmail' ? t('validation.email') : undefined}
placeholder={t('mailSettings.userexamplecom')}
placeholder="user@example.com"
required
/>
<Input
label={t('mail.displayName')}
{...registerAccount('display_name')}
placeholder={t('mailSettings.johndoe')}
placeholder="John Doe"
/>
<Input
label="Benutzername (IMAP/SMTP)"
{...registerAccount('username')}
placeholder={t('mailSettings.leerlassenfüremailadresse')}
placeholder="Leer lassen für E-Mail-Adresse"
/>
<div className="grid grid-cols-2 gap-3">
<Input
label={t('mail.imapHost')}
{...registerAccount('imap_host')}
error={accountErrors.imap_host?.message === 'required' ? t('validation.required') : undefined}
placeholder={t('mailSettings.imapexamplecom')}
placeholder="imap.example.com"
/>
<Input
label={t('mail.imapPort')}
@@ -253,7 +253,7 @@ export function MailSettingsPage() {
label={t('mail.smtpHost')}
{...registerAccount('smtp_host')}
error={accountErrors.smtp_host?.message === 'required' ? t('validation.required') : undefined}
placeholder={t('mailSettings.smtpexamplecom')}
placeholder="smtp.example.com"
/>
<Input
label={t('mail.smtpPort')}
@@ -275,7 +275,7 @@ export function MailSettingsPage() {
{...registerAccount('is_shared')}
className="rounded"
/>
{t('mailSettings.geteiltespostfachfüralletenantbenutzer')}
Geteiltes Postfach (für alle Tenant-Benutzer sichtbar)
</label>
<div className="flex gap-2">
<Button type="submit" isLoading={accountSubmitting} size="sm">{t('common.save')}</Button>
@@ -392,7 +392,7 @@ export function MailSettingsPage() {
className="text-xs text-secondary-400 hover:text-secondary-600"
data-testid={`folder-mapping-btn-${acc.id}`}
>
{t('mailSettings.ordnerzuordnungbearbeiten')}
Ordner-Zuordnung bearbeiten
</button>
</div>
)}
+4 -5
View File
@@ -1,22 +1,21 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ShieldX } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export function NoAccessPage() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-secondary-50 p-4">
<ShieldX className="w-16 h-16 text-secondary-400 mb-4" strokeWidth={1.5} />
<h1 className="text-2xl font-bold text-secondary-700 mb-2">{t('noAccessPage.keinzugriff')}</h1>
<h1 className="text-2xl font-bold text-secondary-700 mb-2">Kein Zugriff</h1>
<p className="text-secondary-500 mb-6 text-center max-w-md">
{t('noAccessPage.siehabenkeineberechtigungaufdiese')}
Sie haben keine Berechtigung, auf diese Seite zuzugreifen.
Bitte wenden Sie sich an einen Administrator, falls Sie Zugriff benötigen.
</p>
<Link
to="/dashboard"
className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
>
{t('noAccessPage.zumdashboard')}
Zum Dashboard
</Link>
</div>
);
+2 -3
View File
@@ -60,8 +60,7 @@ interface DownloadEntry {
export function ReportsPage() {
const { t } = useTranslation();
const toast = useToast();
const registerItems = usePluginToolbarStore((s) => s.registerItems);
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
const { registerItems, unregisterPlugin } = usePluginToolbarStore();
// Data hooks
const { data: templates = [], isLoading: templatesLoading } = useReportTemplates();
@@ -398,7 +397,7 @@ export function ReportsPage() {
<textarea
value={jsonData}
onChange={(e) => setJsonData(e.target.value)}
placeholder={t('reports.keyvalue')}
placeholder='{"key": "value"}'
className="w-full text-xs font-mono border border-secondary-300 rounded px-2 py-1.5 h-40 resize-none"
spellCheck={false}
data-testid="textarea-json-data"
+2 -2
View File
@@ -74,8 +74,8 @@ export function SettingsPage() {
<button
onClick={() => window.location.href = '/start'}
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
aria-label={t('settings.zurückzurstartseite')}
title={t('settings.zurückzurstartseite')}
aria-label="Zurück zur Startseite"
title="Zurück zur Startseite"
>
<ArrowLeft className="w-4 h-4" />
</button>
+1 -1
View File
@@ -167,7 +167,7 @@ function RestoreModal({ open, backup, onConfirm, onCancel, isRestoring }: Restor
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={t('settingsBackup.restore')}
placeholder="RESTORE"
className={clsx(
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
'focus:outline-none focus:ring-2 focus:ring-danger-500 focus:border-danger-500',
+1 -1
View File
@@ -135,7 +135,7 @@ export function SettingsMcpPage() {
onChange={(e) => setExecuteToolName(e.target.value)}
className="border border-secondary-300 rounded px-3 py-1.5 text-sm"
>
<option value="">{t('settingsMcp.select')}</option>
<option value="">-- Select --</option>
{toolsData?.tools.map((tool) => (
<option key={tool.name} value={tool.name}>{tool.name}</option>
))}
+1 -1
View File
@@ -149,7 +149,7 @@ function InstallPluginSection() {
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t('settingsPlugins.httpsexamplecompluginzip')}
placeholder="https://example.com/plugin.zip"
className="flex-1 rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
data-testid="plugin-url-input"
/>
+6 -6
View File
@@ -198,7 +198,7 @@ function FreigabenTab() {
<button
onClick={() => setConfirmDelete(row)}
className="p-1 rounded hover:bg-danger-50 text-danger-600"
aria-label={t('settingsRechte.löschen')}
aria-label="Löschen"
>
<Trash2 className="w-4 h-4" />
</button>
@@ -217,7 +217,7 @@ function FreigabenTab() {
return (
<div className="space-y-4">
<Card title={t('settingsRechte.freigabenübersicht')}>
<Card title="Freigaben Übersicht">
<div className="flex gap-4 mb-4">
<div className="w-64">
<Select
@@ -233,7 +233,7 @@ function FreigabenTab() {
<div className="w-64">
<Input
label="Nach Principal suchen"
placeholder={t('settingsRechte.nameoderid')}
placeholder="Name oder ID..."
value={filterPrincipal}
onChange={(e) => setFilterPrincipal(e.target.value)}
/>
@@ -253,7 +253,7 @@ function FreigabenTab() {
open={!!confirmDelete}
onCancel={() => setConfirmDelete(null)}
onConfirm={handleDelete}
title={t('settingsRechte.berechtigunglöschen')}
title="Berechtigung löschen"
message={`Soll die Berechtigung für ${entityTypeMap.get(confirmDelete.entity_type) || confirmDelete.entity_type} wirklich gelöscht werden?`}
confirmLabel={deleting ? 'Wird gelöscht...' : 'Löschen'}
variant="danger"
@@ -325,7 +325,7 @@ function AuditTab() {
return (
<div className="space-y-4">
<Card title={t('settingsRechte.auditlogfürberechtigungen')}>
<Card title="Audit-Log für Berechtigungen">
<Table
columns={columns}
data={data?.items || []}
@@ -384,7 +384,7 @@ export function SettingsRechtePage() {
<h1 className="text-2xl font-bold text-secondary-900">Rechteverwaltung</h1>
<div className="border-b border-secondary-200">
<nav className="flex gap-4" role="tablist" aria-label={t('settingsRechte.rechteverwaltungtabs')}>
<nav className="flex gap-4" role="tablist" aria-label="Rechteverwaltung Tabs">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
+1 -1
View File
@@ -127,7 +127,7 @@ export function SettingsSequencesPage() {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('sequences.prefix')}</label>
<input type="text" {...register('prefix')} placeholder={t('settingsSequences.re')} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
<input type="text" {...register('prefix')} placeholder="RE-" className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
{errors.prefix && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.prefix.message)}</p>}
</div>
<div>
+9 -9
View File
@@ -167,10 +167,10 @@ function AdressenTab() {
{
key: 'actions', header: '', render: (row) => (
<div className="flex gap-2">
<button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label={t('settingsStammdaten.bearbeiten')}>
<button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label="Bearbeiten">
<Pencil className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label={t('settingsStammdaten.löschen')}>
<button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label="Löschen">
<Trash2 className="w-4 h-4" />
</button>
</div>
@@ -180,14 +180,14 @@ function AdressenTab() {
return (
<div className="space-y-4">
<Card title={t('settingsStammdaten.adressverwaltung')} actions={
<Card title="Adressverwaltung" actions={
<Button size="sm" onClick={handleAdd}>
<Plus className="w-4 h-4 mr-1" />
{t('common.add', 'Hinzufügen')}
</Button>
}>
{loading ? (
<div className="text-center py-4 text-secondary-500">{t('settingsStammdaten.ladeadressen')}</div>
<div className="text-center py-4 text-secondary-500">Lade Adressen...</div>
) : (
<Table columns={columns} data={addresses} rowKey={(row) => row.id} emptyMessage="Noch keine Adressen angelegt" />
)}
@@ -352,10 +352,10 @@ function KontenTab() {
{
key: 'actions', header: '', render: (row) => (
<div className="flex gap-2">
<button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label={t('settingsStammdaten.bearbeiten')}>
<button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label="Bearbeiten">
<Pencil className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label={t('settingsStammdaten.löschen')}>
<button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label="Löschen">
<Trash2 className="w-4 h-4" />
</button>
</div>
@@ -365,14 +365,14 @@ function KontenTab() {
return (
<div className="space-y-4">
<Card title={t('settingsStammdaten.bankkontenverwaltung')} actions={
<Card title="Bankkontenverwaltung" actions={
<Button size="sm" onClick={handleAdd}>
<Plus className="w-4 h-4 mr-1" />
{t('common.add', 'Hinzufügen')}
</Button>
}>
{loading ? (
<div className="text-center py-4 text-secondary-500">{t('settingsStammdaten.ladekonten')}</div>
<div className="text-center py-4 text-secondary-500">Lade Konten...</div>
) : (
<Table columns={columns} data={accounts} rowKey={(row) => row.id} emptyMessage="Noch keine Konten angelegt" />
)}
@@ -447,7 +447,7 @@ export function SettingsStammdatenPage() {
<h1 className="text-2xl font-bold text-secondary-900">Stammdaten</h1>
<div className="border-b border-secondary-200">
<nav className="flex gap-4" role="tablist" aria-label={t('settingsStammdaten.stammdatentabs')}>
<nav className="flex gap-4" role="tablist" aria-label="Stammdaten Tabs">
{tabs.map((tab) => (
<button
key={tab.key}
+1 -1
View File
@@ -22,7 +22,7 @@ export function SettingsSystemPage() {
<h1 className="text-2xl font-bold text-secondary-900">System</h1>
<div className="border-b border-secondary-200">
<nav className="flex gap-4" role="tablist" aria-label={t('settingsSystem.systemtabs')}>
<nav className="flex gap-4" role="tablist" aria-label="System Tabs">
{tabs.map((tab) => (
<button
key={tab.key}
+1 -1
View File
@@ -135,7 +135,7 @@ export function SettingsTaxesPage() {
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('taxes.country')}</label>
<input type="text" {...register('country')} maxLength={2} placeholder={t('settingsTaxes.de')} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
<input type="text" {...register('country')} maxLength={2} placeholder="DE" className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
{errors.country && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.country.message)}</p>}
</div>
</div>
+8 -8
View File
@@ -217,7 +217,7 @@ export function SettingsThemePage() {
value={primaryColor}
onChange={(e) => handlePrimaryChange(e.target.value)}
className="flex-1"
placeholder={t('settingsTheme.2563eb')}
placeholder="#2563eb"
/>
</div>
</div>
@@ -239,7 +239,7 @@ export function SettingsThemePage() {
value={accentColor}
onChange={(e) => handleAccentChange(e.target.value)}
className="flex-1"
placeholder={t('settingsTheme.d946ef')}
placeholder="#d946ef"
/>
</div>
</div>
@@ -302,10 +302,10 @@ export function SettingsThemePage() {
<div className="space-y-4">
{/* Buttons */}
<div className="flex flex-wrap gap-2">
<Button variant="primary" size="sm">{t('settingsTheme.primarybutton')}</Button>
<Button variant="secondary" size="sm">{t('settingsTheme.secondarybutton')}</Button>
<Button variant="danger" size="sm">{t('settingsTheme.dangerbutton')}</Button>
<Button variant="ghost" size="sm">{t('settingsTheme.ghostbutton')}</Button>
<Button variant="primary" size="sm">Primary Button</Button>
<Button variant="secondary" size="sm">Secondary Button</Button>
<Button variant="danger" size="sm">Danger Button</Button>
<Button variant="ghost" size="sm">Ghost Button</Button>
</div>
{/* Badges */}
<div className="flex flex-wrap gap-2">
@@ -317,11 +317,11 @@ export function SettingsThemePage() {
</div>
{/* Input preview */}
<div className="max-w-xs">
<Input label="Beispiel-Input" placeholder={t('settingsTheme.texteingeben')} />
<Input label="Beispiel-Input" placeholder="Text eingeben..." />
</div>
{/* Card preview */}
<div className="bg-white rounded-lg border border-secondary-200 p-4 shadow-sm">
<p className="text-sm text-secondary-700">{t('settingsTheme.diesisteinebeispielkartemit')}</p>
<p className="text-sm text-secondary-700">Dies ist eine Beispiel-Karte mit dem aktuellen Theme.</p>
</div>
</div>
</Card>
@@ -19,7 +19,7 @@ export function SettingsUserManagementPage() {
<h1 className="text-2xl font-bold text-secondary-900">Nutzerverwaltung</h1>
<div className="border-b border-secondary-200">
<nav className="flex gap-4" role="tablist" aria-label={t('settingsUserManagement.nutzerverwaltungtabs')}>
<nav className="flex gap-4" role="tablist" aria-label="Nutzerverwaltung Tabs">
{tabs.map((tab) => (
<button
key={tab.key}
+1 -1
View File
@@ -260,7 +260,7 @@ export function SettingsUsersPage() {
type="email"
{...register('email')}
error={errorMsg(errors.email?.message)}
placeholder={t('settingsUsers.neumitarbeiterfirmade')}
placeholder="neu.mitarbeiter@firma.de"
data-testid="invite-email"
/>
<Input
+4 -4
View File
@@ -64,8 +64,8 @@ export function StartPage() {
<button
onClick={() => navigate('/start')}
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('startPage.zurückzurstartseite')}
title={t('startPage.zurückzurstartseite')}
aria-label="Zurück zur Startseite"
title="Zurück zur Startseite"
>
<ArrowLeft className="w-4 h-4" />
</button>
@@ -101,7 +101,7 @@ export function StartPage() {
<div className="max-w-5xl mx-auto p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-secondary-900">Willkommen{user?.first_name ? `, ${user.first_name}` : ''}!</h1>
<p className="text-sm text-secondary-500 mt-1">{t('startPage.wähleeinenworkspaceaus')}</p>
<p className="text-sm text-secondary-500 mt-1">Wähle einen Workspace aus</p>
</div>
{/* Dashboard stats */}
@@ -171,7 +171,7 @@ export function StartPage() {
>
<Plus className="w-8 h-8 text-secondary-300 group-hover:text-primary-400 transition-colors" />
<span className="text-sm text-secondary-400 group-hover:text-primary-600 mt-2 transition-colors">
{t('startPage.workspacehinzufügen')}
Workspace hinzufügen
</span>
</button>
</div>
+6 -7
View File
@@ -116,18 +116,18 @@ function TaskTree({ tasks, selectedTaskId, onSelect, filter, setFilter }: TaskTr
<button
onClick={() => setTreeMode('status')}
className={`px-2 py-1 text-xs rounded ${treeMode === 'status' ? 'bg-primary-100 text-primary-700' : 'text-secondary-500 hover:bg-secondary-100'}`}
>{t('tasks.nachstatus')}</button>
>Nach Status</button>
<button
onClick={() => setTreeMode('priority')}
className={`px-2 py-1 text-xs rounded ${treeMode === 'priority' ? 'bg-primary-100 text-primary-700' : 'text-secondary-500 hover:bg-secondary-100'}`}
>{t('tasks.nachpriorität')}</button>
>Nach Priorität</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
<button
onClick={() => setFilter({})}
className={`w-full text-left px-3 py-2 rounded-md text-sm font-medium min-h-touch hover:bg-secondary-100 ${!filter.status && !filter.priority ? 'bg-primary-50 text-primary-700' : 'text-secondary-700'}`}
>{t('tasks.alletasks')}{tasks.length})</button>
>Alle Tasks ({tasks.length})</button>
{grouped.map(group => (
<div key={group.key} className="mt-1">
<div
@@ -201,7 +201,7 @@ function TaskDetail({ task, onEdit, onDelete, onStatusChange }: TaskDetailProps)
<div><span className="font-medium text-secondary-500">Status:</span>
<select value={task.status} onChange={(e) => onStatusChange(e.target.value as TaskStatus)} className="ml-2 text-sm border border-secondary-200 rounded px-2 py-1">
<option value="open">Offen</option>
<option value="in_progress">{t('tasks.inbearbeitung')}</option>
<option value="in_progress">In Bearbeitung</option>
<option value="review">Review</option>
<option value="done">Erledigt</option>
<option value="cancelled">Abgebrochen</option>
@@ -220,8 +220,7 @@ function TaskDetail({ task, onEdit, onDelete, onStatusChange }: TaskDetailProps)
export function TasksPage() {
const { t } = useTranslation();
const toast = useToast();
const registerItems = usePluginToolbarStore((s) => s.registerItems);
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
const { registerItems, unregisterPlugin } = usePluginToolbarStore();
const [page, setPage] = useState(1);
const [filter, setFilter] = useState<TaskFilter>({});
@@ -371,7 +370,7 @@ export function TasksPage() {
</div>
</div>
))}
{col.items.length === 0 && <p className="text-xs text-secondary-400 text-center py-4">{t('tasks.keinetasks')}</p>}
{col.items.length === 0 && <p className="text-xs text-secondary-400 text-center py-4">Keine Tasks</p>}
</div>
</div>
))}
+8 -8
View File
@@ -92,7 +92,7 @@ export function WorkflowsPage() {
{t('workflows.title', 'Workflows')}
</h1>
<p className="text-sm text-secondary-500 mt-1">
{t('workflows.definierenundverwaltensieautomatisiertew')}
Definieren und verwalten Sie automatisierte Workflows
</p>
</div>
{activeTab === 'definitions' && (
@@ -147,9 +147,9 @@ export function WorkflowsPage() {
<Card className="p-6">
<div className="flex items-center gap-3 text-danger-600">
<AlertCircle className="h-5 w-5" />
<span>{t('workflows.fehlerbeimladenderworkflows')}</span>
<span>Fehler beim Laden der Workflows</span>
<Button size="sm" variant="secondary" onClick={() => refetch()}>
{t('workflows.erneutversuchen')}
Erneut versuchen
</Button>
</div>
</Card>
@@ -157,12 +157,12 @@ export function WorkflowsPage() {
{!isLoading && !isError && workflows.length === 0 && (
<EmptyState
title={t('workflows.keineworkflows')}
title="Keine Workflows"
description="Erstellen Sie Ihren ersten Workflow, um automatisierte Prozesse zu definieren."
icon={<WorkflowIcon className="h-8 w-8" />}
action={
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
{t('workflows.workflowerstellen')}
Workflow erstellen
</Button>
}
/>
@@ -216,7 +216,7 @@ export function WorkflowsPage() {
size="sm"
variant="ghost"
onClick={() => openEdit(wf)}
title={t('workflows.bearbeiten')}
title="Bearbeiten"
>
<Settings2 className="h-4 w-4" />
</Button>
@@ -224,7 +224,7 @@ export function WorkflowsPage() {
size="sm"
variant="ghost"
onClick={() => setConfirmDelete(wf)}
title={t('workflows.loeschen')}
title="Loeschen"
>
<Trash2 className="h-4 w-4 text-danger-500" />
</Button>
@@ -269,7 +269,7 @@ export function WorkflowsPage() {
open={!!confirmDelete}
onCancel={() => setConfirmDelete(null)}
onConfirm={handleDelete}
title={t('workflows.workflowloeschen')}
title="Workflow loeschen"
message={`Moechten Sie den Workflow "${confirmDelete?.name}" wirklich loeschen?`}
confirmLabel="Loeschen"
variant="danger"
@@ -40,11 +40,7 @@ vi.mock('react-i18next', () => ({
// Mock toast
vi.mock('@/components/ui/Toast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
remove: vi.fn(),
toast: { success: vi.fn(), error: vi.fn() },
}),
}));
@@ -38,11 +38,7 @@ vi.mock('react-i18next', () => ({
// Mock toast
vi.mock('@/components/ui/Toast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
remove: vi.fn(),
toast: { success: vi.fn(), error: vi.fn() },
}),
}));
+2 -2
View File
@@ -20,9 +20,9 @@ export function AgentsOverviewPage() {
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">{t('agentsOverview.agentenübersicht')}</h1>
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Agenten Übersicht</h1>
<p className="text-secondary-600 mb-6">
{t('agentsOverview.verwaltensiekiagentenführensie')}
Verwalten Sie KI-Agenten, führen Sie diese aus und überwachen Sie deren Ausführungen.
</p>
{/* Loading */}
@@ -1,13 +1,11 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export function AgentsPlaceholderPage() {
const { t } = useTranslation();
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Agenten</h1>
<p className="text-secondary-600">
{t('agentsPlaceholder.dieseseitewirdgeradeerstelltwählen')}
Diese Seite wird gerade erstellt. Wählen Sie ein Thema aus dem Menü links.
</p>
</div>
);
@@ -1,13 +1,11 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export function AutomationOverviewPage() {
const { t } = useTranslation();
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">{t('automationOverview.automationübersicht')}</h1>
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Automation Übersicht</h1>
<p className="text-secondary-600 mb-6">
{t('automationOverview.erstellenundverwaltensieautomatisiertewo')}
Erstellen und verwalten Sie automatisierte Workflows. Definieren Sie Trigger, Bedingungen und Aktionen.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-6 bg-white rounded-xl border border-secondary-200">
@@ -15,14 +13,14 @@ export function AutomationOverviewPage() {
<svg className="w-6 h-6 text-warning-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
</div>
<h3 className="font-semibold text-secondary-900">Automations</h3>
<p className="text-sm text-secondary-500 mt-1">{t('automationOverview.workflowserstellenundverwalten')}</p>
<p className="text-sm text-secondary-500 mt-1">Workflows erstellen und verwalten</p>
</div>
<div className="p-6 bg-white rounded-xl border border-secondary-200">
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</div>
<h3 className="font-semibold text-secondary-900">Einstellungen</h3>
<p className="text-sm text-secondary-500 mt-1">{t('automationOverview.triggeraktionenundbedingungenkonfigurier')}</p>
<p className="text-sm text-secondary-500 mt-1">Trigger, Aktionen und Bedingungen konfigurieren</p>
</div>
</div>
</div>
@@ -1,13 +1,11 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export function AutomationPlaceholderPage() {
const { t } = useTranslation();
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Automation</h1>
<p className="text-secondary-600">
{t('automationPlaceholder.dieseseitewirdgeradeerstelltwählen')}
Diese Seite wird gerade erstellt. Wählen Sie ein Thema aus dem Menü links.
</p>
</div>
);
+11 -13
View File
@@ -1,33 +1,31 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export function HelpApiDocsPage() {
const { t } = useTranslation();
return (
<div className="max-w-4xl mx-auto prose prose-secondary">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">{t('helpApiDocs.apidokumentation')}</h1>
<h1 className="text-2xl font-bold text-secondary-900 mb-4">API Dokumentation</h1>
<p className="text-secondary-600 mb-4">
{t('helpApiDocs.dievollständigeapidokumentationfindensie')} <a href="/docs" target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">/docs</a> {t('helpApiDocs.swaggerui')}
Die vollständige API-Dokumentation finden Sie unter <a href="/docs" target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">/docs</a> (Swagger UI).
</p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Übersicht</h2>
<p className="text-secondary-600 mb-4">
{t('helpApiDocs.leocrmbieteteinerestapimit')}
LeoCRM bietet eine REST-API mit über 224 Endpoints. Die API verwendet JSON für Request- und Response-Bodies.
</p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Authentifizierung</h2>
<p className="text-secondary-600 mb-4">
{t('helpApiDocs.dieapiverwendetsessionbasierteauthentifi')} <code className="bg-secondary-100 px-1.5 py-0.5 rounded text-sm">{t('helpApiDocs.postapiv1authlogin')}</code> {t('helpApiDocs.wirdeinsessioncookiegesetzt')}
Die API verwendet Session-basierte Authentifizierung mit HttpOnly-Cookies. Nach dem Login über <code className="bg-secondary-100 px-1.5 py-0.5 rounded text-sm">POST /api/v1/auth/login</code> wird ein Session-Cookie gesetzt.
</p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">{t('helpApiDocs.wichtigeendpoints')}</h2>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Wichtige Endpoints</h2>
<ul className="list-disc list-inside text-secondary-600 space-y-1">
<li><code className="bg-secondary-100 px-1.5 py-0.5 rounded text-sm">{t('helpApiDocs.getapiv1contacts')}</code> {t('helpApiDocs.kontakteabrufen')}</li>
<li><code className="bg-secondary-100 px-1.5 py-0.5 rounded text-sm">{t('helpApiDocs.postapiv1contacts')}</code> {t('helpApiDocs.kontakterstellen')}</li>
<li><code className="bg-secondary-100 px-1.5 py-0.5 rounded text-sm">{t('helpApiDocs.getapiv1calendarentries')}</code> {t('helpApiDocs.kalendereinträge')}</li>
<li><code className="bg-secondary-100 px-1.5 py-0.5 rounded text-sm">{t('helpApiDocs.getapiv1mailaccounts')}</code> {t('helpApiDocs.mailkonten')}</li>
<li><code className="bg-secondary-100 px-1.5 py-0.5 rounded text-sm">{t('helpApiDocs.getapiv1pluginsactivemanifests')}</code> {t('helpApiDocs.pluginmanifeste')}</li>
<li><code className="bg-secondary-100 px-1.5 py-0.5 rounded text-sm">GET /api/v1/contacts</code> Kontakte abrufen</li>
<li><code className="bg-secondary-100 px-1.5 py-0.5 rounded text-sm">POST /api/v1/contacts</code> Kontakt erstellen</li>
<li><code className="bg-secondary-100 px-1.5 py-0.5 rounded text-sm">GET /api/v1/calendar/entries</code> Kalendereinträge</li>
<li><code className="bg-secondary-100 px-1.5 py-0.5 rounded text-sm">GET /api/v1/mail/accounts</code> Mail-Konten</li>
<li><code className="bg-secondary-100 px-1.5 py-0.5 rounded text-sm">GET /api/v1/plugins/active-manifests</code> Plugin-Manifeste</li>
</ul>
<div className="mt-6 p-4 bg-primary-50 rounded-lg border border-primary-200">
<p className="text-sm text-primary-700">
📖 <strong>{t('helpApiDocs.vollständigedoku')}</strong> <a href="/docs" target="_blank" rel="noopener noreferrer" className="underline">{t('helpApiDocs.swaggeruiöffnen')}</a>
📖 <strong>Vollständige Doku:</strong> <a href="/docs" target="_blank" rel="noopener noreferrer" className="underline">Swagger UI öffnen</a>
</p>
</div>
</div>
+6 -8
View File
@@ -1,26 +1,24 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export function HelpContactsPage() {
const { t } = useTranslation();
return (
<div className="max-w-3xl mx-auto prose prose-secondary">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">{t('helpContacts.kontakteverwalten')}</h1>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">{t('helpContacts.kontakteerstellen')}</h2>
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Kontakte verwalten</h1>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Kontakte erstellen</h2>
<p className="text-secondary-600 mb-4">
{t('helpContacts.gehensiezukontakteundklicken')}
Gehen Sie zu Kontakte und klicken Sie auf "Neuer Kontakt". Füllen Sie die Felder aus und speichern Sie. Sie können Firmen und Personen anlegen.
</p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Kontaktpersonen</h2>
<p className="text-secondary-600 mb-4">
{t('helpContacts.jederfirmakönnenmehrerekontaktpersonenzu')}
Jeder Firma können mehrere Kontaktpersonen zugeordnet werden. Öffnen Sie eine Firma und fügen Sie Personen hinzu.
</p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Tags</h2>
<p className="text-secondary-600 mb-4">
{t('helpContacts.verwendensietagsumkontaktezu')}
Verwenden Sie Tags um Kontakte zu kategorisieren. Tags können frei vergeben werden und helfen bei der Filterung.
</p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Ordner</h2>
<p className="text-secondary-600 mb-4">
{t('helpContacts.organisierensiekontakteinordnernordner')}
Organisieren Sie Kontakte in Ordnern. Ordner können verschachtelt werden und eigene Berechtigungen haben.
</p>
</div>
);
+7 -9
View File
@@ -1,24 +1,22 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export function HelpLoginPage() {
const { t } = useTranslation();
return (
<div className="max-w-3xl mx-auto prose prose-secondary">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">{t('helpLogin.loginanmeldung')}</h1>
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Login & Anmeldung</h1>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Anmeldung</h2>
<p className="text-secondary-600 mb-4">
{t('helpLogin.rufensiedieleocrmurlauf')}
Rufen Sie die LeoCRM-URL auf (z.B. https://crm.media-on.de) und melden Sie sich mit Ihrer E-Mail-Adresse und Ihrem Passwort an.
</p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">{t('helpLogin.passwortvergessen')}</h2>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Passwort vergessen?</h2>
<p className="text-secondary-600 mb-4">
{t('helpLogin.klickensieaufderloginseite')}
Klicken Sie auf der Login-Seite auf "Passwort vergessen". Sie erhalten eine E-Mail mit einem Link zum Zurücksetzen Ihres Passworts.
</p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Sicherheit</h2>
<ul className="list-disc list-inside text-secondary-600 space-y-1">
<li>{t('helpLogin.ihresitzungwirdübereinsicheres')}</li>
<li>{t('helpLogin.nachinaktivitätwirddiesitzungautomatisch')}</li>
<li>{t('helpLogin.passwörterwerdenmitbcryptcost12')}</li>
<li>Ihre Sitzung wird über ein sicheres HttpOnly-Cookie verwaltet</li>
<li>Nach Inaktivität wird die Sitzung automatisch beendet</li>
<li>Passwörter werden mit bcrypt (cost=12) verschlüsselt gespeichert</li>
</ul>
</div>
);
+10 -12
View File
@@ -1,26 +1,24 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export function HelpMailSetupPage() {
const { t } = useTranslation();
return (
<div className="max-w-3xl mx-auto prose prose-secondary">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">{t('helpMailSetup.postfacheinrichten')}</h1>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">{t('helpMailSetup.imapkontohinzufügen')}</h2>
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Postfach einrichten</h1>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">IMAP-Konto hinzufügen</h2>
<p className="text-secondary-600 mb-4">
{t('helpMailSetup.gehensiezueinstellungenemailund')}
Gehen Sie zu Einstellungen Email und klicken Sie auf "Konto hinzufügen". Geben Sie Ihre IMAP- und SMTP-Serverdaten ein.
</p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">{t('helpMailSetup.benötigtedaten')}</h2>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Benötigte Daten</h2>
<ul className="list-disc list-inside text-secondary-600 space-y-1">
<li>{t('helpMailSetup.imapserverzbimapexample')}</li>
<li>{t('helpMailSetup.imapportmeist993fürssl')}</li>
<li>{t('helpMailSetup.smtpserverzbsmtpexample')}</li>
<li>{t('helpMailSetup.smtpportmeist587fürtls')}</li>
<li>{t('helpMailSetup.emailadresseundpasswort')}</li>
<li>IMAP-Server (z.B. imap.example.com)</li>
<li>IMAP-Port (meist 993 für SSL)</li>
<li>SMTP-Server (z.B. smtp.example.com)</li>
<li>SMTP-Port (meist 587 für TLS)</li>
<li>E-Mail-Adresse und Passwort</li>
</ul>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Synchronisation</h2>
<p className="text-secondary-600 mb-4">
{t('helpMailSetup.nachdemeinrichtenwirdihrpostfach')}
Nach dem Einrichten wird Ihr Postfach automatisch synchronisiert. Neue E-Mails werden im Hintergrund abgerufen.
</p>
</div>
);
+6 -8
View File
@@ -1,30 +1,28 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export function HelpNavigationPage() {
const { t } = useTranslation();
return (
<div className="max-w-3xl mx-auto prose prose-secondary">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Navigation</h1>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Startseite</h2>
<p className="text-secondary-600 mb-4">
{t('helpNavigation.nachdemlogingelangensiezur')}
Nach dem Login gelangen Sie zur Startseite. Hier können Sie einen Workspace auswählen oder zu den Einstellungen und der Hilfe navigieren.
</p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Workspace</h2>
<p className="text-secondary-600 mb-4">
{t('helpNavigation.einworkspaceistihrarbeitsbereichmit')}
Ein Workspace ist Ihr Arbeitsbereich mit Sidebar-Navigation. Hier finden Sie Kontakte, Kalender, E-Mail und alle anderen Module.
</p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Sidebar</h2>
<p className="text-secondary-600 mb-4">
{t('helpNavigation.dashamburgermenüobenlinksblendet')}
Das Hamburger-Menü oben links blendet die Seitenleiste ein und aus. Die Sidebar zeigt alle verfügbaren Module.
</p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Zurück-Pfeil</h2>
<p className="text-secondary-600 mb-4">
{t('helpNavigation.rechtsnebendemhamburgermenüfinden')}
Rechts neben dem Hamburger-Menü finden Sie einen Zurück-Pfeil, der Sie zurück zur Startseite bringt.
</p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">{t('helpNavigation.globalesuche')}</h2>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Globale Suche</h2>
<p className="text-secondary-600 mb-4">
{t('helpNavigation.verwendensiedielupeobenoder')}
Verwenden Sie die Lupe oben oder Strg+K um das Kommando-Palette zu öffnen und schnell nach Kontakten, Mails oder Dateien zu suchen.
</p>
</div>
);
+2 -4
View File
@@ -1,18 +1,16 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export function HelpPlaceholderPage() {
const { t } = useTranslation();
const params = useParams();
return (
<div className="max-w-3xl mx-auto prose prose-secondary">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Hilfe</h1>
<p className="text-secondary-600">
{t('helpPlaceholder.diesehilfeseite')}{params['*'] || 'unbekannt'}{t('helpPlaceholder.wirdgeradeerstelltschauensiespäter')}
Diese Hilfeseite ({params['*'] || 'unbekannt'}) wird gerade erstellt. Schauen Sie später wieder vorbei.
</p>
<p className="text-secondary-400 text-sm mt-4">
{t('helpPlaceholder.wählensieeinthemaausdem')}
Wählen Sie ein Thema aus dem Menü links um weitere Hilfe-Artikel zu lesen.
</p>
</div>
);
+14 -16
View File
@@ -1,33 +1,31 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export function HelpWelcomePage() {
const { t } = useTranslation();
return (
<div className="max-w-3xl mx-auto prose prose-secondary">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">{t('helpWelcome.willkommenbeileocrm')}</h1>
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Willkommen bei LeoCRM</h1>
<p className="text-secondary-600 mb-4">
{t('helpWelcome.leocrmisteinselbstgehostetescrm')}
LeoCRM ist ein selbst-gehostetes CRM-System für kleine Vertriebsteams. Es bietet Kontakte, Kalender, E-Mail, Dateiverwaltung und mehr alles in einer Anwendung.
</p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Hauptfunktionen</h2>
<ul className="list-disc list-inside text-secondary-600 space-y-1">
<li><strong>{t('helpWelcome.kontakteverwalten')}</strong> {t('helpWelcome.firmenpersonenkontaktdatenzentralspeiche')}</li>
<li><strong>Kalender</strong> {t('helpWelcome.termineaufgabenunderinnerungen')}</li>
<li><strong>E-Mail</strong> {t('helpWelcome.imappostfächersynchronisierendirektantwo')}</li>
<li><strong>{t('helpWelcome.dateiendms')}</strong> {t('helpWelcome.dokumentehochladenteilenundverwalten')}</li>
<li><strong>KI-Assistent</strong> {t('helpWelcome.intelligentehilfebeiderarbeit')}</li>
<li><strong>Workflows</strong> {t('helpWelcome.automatisierteprozesse')}</li>
<li><strong>Kontakte verwalten</strong> Firmen, Personen, Kontaktdaten zentral speichern</li>
<li><strong>Kalender</strong> Termine, Aufgaben und Erinnerungen</li>
<li><strong>E-Mail</strong> IMAP-Postfächer synchronisieren, direkt antworten</li>
<li><strong>Dateien (DMS)</strong> Dokumente hochladen, teilen und verwalten</li>
<li><strong>KI-Assistent</strong> Intelligente Hilfe bei der Arbeit</li>
<li><strong>Workflows</strong> Automatisierte Prozesse</li>
</ul>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">{t('helpWelcome.ersteschritte')}</h2>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Erste Schritte</h2>
<ol className="list-decimal list-inside text-secondary-600 space-y-1">
<li>{t('helpWelcome.meldensiesichmitihrenzugangsdaten')}</li>
<li>{t('helpWelcome.wählensieeinenworkspaceaufder')}</li>
<li>{t('helpWelcome.beginnensiemitdemanlegenvon')}</li>
<li>{t('helpWelcome.richtensieihremailpostfach')}</li>
<li>Melden Sie sich mit Ihren Zugangsdaten an</li>
<li>Wählen Sie einen Workspace auf der Startseite</li>
<li>Beginnen Sie mit dem Anlegen von Kontakten</li>
<li>Richten Sie Ihr E-Mail-Postfach unter Einstellungen Email ein</li>
</ol>
<div className="mt-6 p-4 bg-primary-50 rounded-lg border border-primary-200">
<p className="text-sm text-primary-700">
💡 <strong>Tipp:</strong> {t('helpWelcome.nutzensiedieglobalesuchelupe')}
💡 <strong>Tipp:</strong> Nutzen Sie die globale Suche (Lupe oben) oder das Kommando-Palette (Strg+K) um schnell zu finden was Sie brauchen.
</p>
</div>
</div>
+5 -7
View File
@@ -1,13 +1,11 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export function LogsOverviewPage() {
const { t } = useTranslation();
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">{t('logsOverview.logsübersicht')}</h1>
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Logs Übersicht</h1>
<p className="text-secondary-600 mb-6">
{t('logsOverview.systemundauditlogseinsehenfiltern')}
System- und Audit-Logs einsehen, filtern und exportieren.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-6 bg-white rounded-xl border border-secondary-200">
@@ -15,21 +13,21 @@ export function LogsOverviewPage() {
<svg className="w-6 h-6 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
</div>
<h3 className="font-semibold text-secondary-900">Audit-Log</h3>
<p className="text-sm text-secondary-500 mt-1">{t('logsOverview.alleänderungennachverfolgen')}</p>
<p className="text-sm text-secondary-500 mt-1">Alle Änderungen nachverfolgen</p>
</div>
<div className="p-6 bg-white rounded-xl border border-secondary-200">
<div className="w-12 h-12 bg-warning-100 rounded-lg flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-warning-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
</div>
<h3 className="font-semibold text-secondary-900">System-Logs</h3>
<p className="text-sm text-secondary-500 mt-1">{t('logsOverview.containerworkerdatenbank')}</p>
<p className="text-sm text-secondary-500 mt-1">Container, Worker, Datenbank</p>
</div>
<div className="p-6 bg-white rounded-xl border border-secondary-200">
<div className="w-12 h-12 bg-danger-100 rounded-lg flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-danger-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
<h3 className="font-semibold text-secondary-900">Fehler</h3>
<p className="text-sm text-secondary-500 mt-1">{t('logsOverview.apiundpluginfehler')}</p>
<p className="text-sm text-secondary-500 mt-1">API- und Plugin-Fehler</p>
</div>
</div>
</div>
+1 -3
View File
@@ -1,13 +1,11 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export function LogsPlaceholderPage() {
const { t } = useTranslation();
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Logs</h1>
<p className="text-secondary-600">
{t('logsPlaceholder.dieseseitewirdgeradeerstelltwählen')}
Diese Seite wird gerade erstellt. Wählen Sie ein Thema aus dem Menü links.
</p>
</div>
);