feat(i18n): migrate hardcoded German strings to t() across 104 components/pages — AST-based batch with re-parse gate, 423 new de.json keys; tsc clean; vitest failures byte-identical to clean-tree baseline (pre-existing)

This commit is contained in:
Agent Zero
2026-08-27 01:43:06 +02:00
parent 5680179260
commit 4cb5298768
105 changed files with 1204 additions and 459 deletions
+6 -2
View File
@@ -8,9 +8,11 @@ import { useThemeStore } from '@/store/themeStore';
import { ErrorBoundary } from '@/components/common/ErrorBoundary'; import { ErrorBoundary } from '@/components/common/ErrorBoundary';
import { useToast } from '@/components/ui/Toast'; import { useToast } from '@/components/ui/Toast';
import { useOnlineStatus } from '@/hooks/useOnlineStatus'; import { useOnlineStatus } from '@/hooks/useOnlineStatus';
import { useTranslation } from 'react-i18next';
function QueryClientWrapper({ children }: { children: React.ReactNode }) { function QueryClientWrapper({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const [queryClient] = React.useState(() => new QueryClient({ const [queryClient] = React.useState(() => new QueryClient({
@@ -40,18 +42,20 @@ function QueryClientWrapper({ children }: { children: React.ReactNode }) {
} }
function OfflineBanner() { function OfflineBanner() {
const { t } = useTranslation();
const isOnline = useOnlineStatus(); const isOnline = useOnlineStatus();
if (isOnline) return null; if (isOnline) return null;
return ( return (
<div className="fixed top-0 left-0 right-0 z-[200] bg-warning-500 text-white text-center py-2 px-4 text-sm font-medium shadow-md"> <div className="fixed top-0 left-0 right-0 z-[200] bg-warning-500 text-white text-center py-2 px-4 text-sm font-medium shadow-md">
Sie sind offline. Änderungen werden gespeichert wenn die Verbindung wiederhergestellt ist. {t('app.siesindofflineänderungenwerdengespeicher')}
</div> </div>
); );
} }
export default function App() { export default function App() {
const { t } = useTranslation();
const { logout } = useAuthStore(); const { logout } = useAuthStore();
const loadThemeFromStorage = useThemeStore((s) => s.loadFromStorage); const loadThemeFromStorage = useThemeStore((s) => s.loadFromStorage);
const toast = useToast(); const toast = useToast();
@@ -76,7 +80,7 @@ export default function App() {
href="#main-content" href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[300] focus:px-4 focus:py-2 focus:bg-primary-600 focus:text-white focus:rounded-md" className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[300] focus:px-4 focus:py-2 focus:bg-primary-600 focus:text-white focus:rounded-md"
> >
Zum Hauptinhalt springen {t('app.zumhauptinhaltspringen')}
</a> </a>
<ErrorBoundary> <ErrorBoundary>
<AppRouter /> <AppRouter />
@@ -80,7 +80,7 @@ export function ActivityFilter({ onFilter, initialValues }: ActivityFilterProps)
type="text" type="text"
value={user} value={user}
onChange={(e) => setUser(e.target.value)} onChange={(e) => setUser(e.target.value)}
placeholder="Benutzername" placeholder={t('activityFilter.benutzername')}
className="block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch text-secondary-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500" className="block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch text-secondary-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/> />
</div> </div>
@@ -6,10 +6,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { useSignals, useCollectSignals, usePatterns, useDetectPatterns, useProposals, useEvaluateProposal, useActivateProposal, useRollbackProposal, useMeasureImpact } from '@/api/improvement'; import { useSignals, useCollectSignals, usePatterns, useDetectPatterns, useProposals, useEvaluateProposal, useActivateProposal, useRollbackProposal, useMeasureImpact } from '@/api/improvement';
import { TrendingUp, AlertCircle, CheckCircle, RefreshCw, Play, RotateCcw, BarChart3 } from 'lucide-react'; import { TrendingUp, AlertCircle, CheckCircle, RefreshCw, Play, RotateCcw, BarChart3 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type SubView = 'signals' | 'patterns' | 'proposals'; type SubView = 'signals' | 'patterns' | 'proposals';
export function ImprovementPanel() { export function ImprovementPanel() {
const { t } = useTranslation();
const [subView, setSubView] = useState<SubView>('signals'); const [subView, setSubView] = useState<SubView>('signals');
return ( return (
@@ -47,6 +49,7 @@ export function ImprovementPanel() {
} }
function SignalsView() { function SignalsView() {
const { t } = useTranslation();
const { data, isLoading } = useSignals(1, 20); const { data, isLoading } = useSignals(1, 20);
const collectMut = useCollectSignals(); const collectMut = useCollectSignals();
const signals = data?.items ?? []; const signals = data?.items ?? [];
@@ -59,7 +62,7 @@ function SignalsView() {
onClick={() => collectMut.mutate({ limit: 100 })} onClick={() => collectMut.mutate({ limit: 100 })}
disabled={collectMut.isPending} disabled={collectMut.isPending}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-primary-600 hover:bg-primary-50 disabled:opacity-50 min-h-touch" className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-primary-600 hover:bg-primary-50 disabled:opacity-50 min-h-touch"
aria-label="Signale sammeln" aria-label={t('improvementPanel.signalesammeln')}
> >
<RefreshCw className={`w-3 h-3 ${collectMut.isPending ? 'animate-spin' : ''}`} aria-hidden="true" strokeWidth={2} /> <RefreshCw className={`w-3 h-3 ${collectMut.isPending ? 'animate-spin' : ''}`} aria-hidden="true" strokeWidth={2} />
Sammeln Sammeln
@@ -68,7 +71,7 @@ function SignalsView() {
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>} {isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
{!isLoading && signals.length === 0 && ( {!isLoading && signals.length === 0 && (
<p className="text-sm text-secondary-400 text-center py-4">Keine Signale. Klicken Sie auf Sammeln" um zu starten.</p> <p className="text-sm text-secondary-400 text-center py-4">{t('improvementPanel.keinesignaleklickensieaufsammeln')}</p>
)} )}
{signals.map(s => ( {signals.map(s => (
@@ -92,6 +95,7 @@ function SignalsView() {
} }
function PatternsView() { function PatternsView() {
const { t } = useTranslation();
const { data, isLoading } = usePatterns(1, 20); const { data, isLoading } = usePatterns(1, 20);
const detectMut = useDetectPatterns(); const detectMut = useDetectPatterns();
const patterns = data?.items ?? []; const patterns = data?.items ?? [];
@@ -104,7 +108,7 @@ function PatternsView() {
onClick={() => detectMut.mutate({ min_occurrences: 2 })} onClick={() => detectMut.mutate({ min_occurrences: 2 })}
disabled={detectMut.isPending} disabled={detectMut.isPending}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-primary-600 hover:bg-primary-50 disabled:opacity-50 min-h-touch" className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-primary-600 hover:bg-primary-50 disabled:opacity-50 min-h-touch"
aria-label="Muster erkennen" aria-label={t('improvementPanel.mustererkennen')}
> >
<TrendingUp className={`w-3 h-3 ${detectMut.isPending ? 'animate-pulse' : ''}`} aria-hidden="true" strokeWidth={2} /> <TrendingUp className={`w-3 h-3 ${detectMut.isPending ? 'animate-pulse' : ''}`} aria-hidden="true" strokeWidth={2} />
Erkennen Erkennen
@@ -113,7 +117,7 @@ function PatternsView() {
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>} {isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
{!isLoading && patterns.length === 0 && ( {!isLoading && patterns.length === 0 && (
<p className="text-sm text-secondary-400 text-center py-4">Keine Muster. Sammeln Sie zuerst Signale und klicken Sie dann auf „Erkennen".</p> <p className="text-sm text-secondary-400 text-center py-4">{t('improvementPanel.keinemustersammelnsiezuerstsignale')}</p>
)} )}
{patterns.map(p => ( {patterns.map(p => (
@@ -143,6 +147,7 @@ function PatternsView() {
} }
function ProposalsView() { function ProposalsView() {
const { t } = useTranslation();
const { data, isLoading } = useProposals(1, 20); const { data, isLoading } = useProposals(1, 20);
const evalMut = useEvaluateProposal(); const evalMut = useEvaluateProposal();
const activateMut = useActivateProposal(); const activateMut = useActivateProposal();
@@ -168,7 +173,7 @@ function ProposalsView() {
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>} {isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
{!isLoading && proposals.length === 0 && ( {!isLoading && proposals.length === 0 && (
<p className="text-sm text-secondary-400 text-center py-4">Keine Vorschläge.</p> <p className="text-sm text-secondary-400 text-center py-4">{t('improvementPanel.keinevorschläge')}</p>
)} )}
{proposals.map(p => ( {proposals.map(p => (
@@ -191,7 +196,7 @@ function ProposalsView() {
onClick={() => evalMut.mutate(p.id)} onClick={() => evalMut.mutate(p.id)}
disabled={evalMut.isPending} disabled={evalMut.isPending}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-blue-600 hover:bg-blue-50 disabled:opacity-50 min-h-touch" className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-blue-600 hover:bg-blue-50 disabled:opacity-50 min-h-touch"
aria-label="Evaluieren" aria-label={t('improvementPanel.evaluieren')}
> >
<Play className="w-3 h-3" aria-hidden="true" strokeWidth={2} /> <Play className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
Evaluieren Evaluieren
@@ -202,7 +207,7 @@ function ProposalsView() {
onClick={() => activateMut.mutate(p.id)} onClick={() => activateMut.mutate(p.id)}
disabled={activateMut.isPending} disabled={activateMut.isPending}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-green-600 hover:bg-green-50 disabled:opacity-50 min-h-touch" className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-green-600 hover:bg-green-50 disabled:opacity-50 min-h-touch"
aria-label="Aktivieren" aria-label={t('improvementPanel.aktivieren')}
> >
<CheckCircle className="w-3 h-3" aria-hidden="true" strokeWidth={2} /> <CheckCircle className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
Aktivieren Aktivieren
@@ -214,7 +219,7 @@ function ProposalsView() {
onClick={() => rollbackMut.mutate({ proposalId: p.id, reason: 'Manual rollback' })} onClick={() => rollbackMut.mutate({ proposalId: p.id, reason: 'Manual rollback' })}
disabled={rollbackMut.isPending} disabled={rollbackMut.isPending}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-danger-600 hover:bg-danger-50 disabled:opacity-50 min-h-touch" className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-danger-600 hover:bg-danger-50 disabled:opacity-50 min-h-touch"
aria-label="Rollback" aria-label={t('improvementPanel.rollback')}
> >
<RotateCcw className="w-3 h-3" aria-hidden="true" strokeWidth={2} /> <RotateCcw className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
Rollback Rollback
@@ -223,7 +228,7 @@ function ProposalsView() {
onClick={() => measureMut.mutate(p.id)} onClick={() => measureMut.mutate(p.id)}
disabled={measureMut.isPending} disabled={measureMut.isPending}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-purple-600 hover:bg-purple-50 disabled:opacity-50 min-h-touch" className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-purple-600 hover:bg-purple-50 disabled:opacity-50 min-h-touch"
aria-label="Impact messen" aria-label={t('improvementPanel.impactmessen')}
> >
<BarChart3 className="w-3 h-3" aria-hidden="true" strokeWidth={2} /> <BarChart3 className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
Messen Messen
@@ -1,11 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
import { useTranslation } from 'react-i18next';
interface SuggestionBadgeProps { interface SuggestionBadgeProps {
onClick: () => void; onClick: () => void;
} }
export function SuggestionBadge({ onClick }: SuggestionBadgeProps) { export function SuggestionBadge({ onClick }: SuggestionBadgeProps) {
const { t } = useTranslation();
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const [pulsing, setPulsing] = useState(false); const [pulsing, setPulsing] = useState(false);
@@ -31,7 +33,7 @@ export function SuggestionBadge({ onClick }: SuggestionBadgeProps) {
<button <button
onClick={onClick} onClick={onClick}
className="relative p-2 text-gray-500 hover:text-gray-700 transition-colors min-h-touch min-w-touch" className="relative p-2 text-gray-500 hover:text-gray-700 transition-colors min-h-touch min-w-touch"
title="KI Vorschläge" title={t('suggestionBadge.kivorschläge')}
> >
🤖 🤖
</button> </button>
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import type { Suggestion } from '@/api/aiProactive'; import type { Suggestion } from '@/api/aiProactive';
import { useTranslation } from 'react-i18next';
const typeConfig = { const typeConfig = {
info: { icon: '💡', color: 'blue', bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', bar: 'bg-blue-500' }, info: { icon: '💡', color: 'blue', bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', bar: 'bg-blue-500' },
@@ -15,6 +16,7 @@ interface SuggestionCardProps {
} }
export function SuggestionCard({ suggestion, onDismiss, onAct }: SuggestionCardProps) { export function SuggestionCard({ suggestion, onDismiss, onAct }: SuggestionCardProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const config = typeConfig[suggestion.suggestion_type] || typeConfig.info; const config = typeConfig[suggestion.suggestion_type] || typeConfig.info;
const confidencePercent = Math.round(suggestion.confidence * 100); const confidencePercent = Math.round(suggestion.confidence * 100);
@@ -32,7 +34,7 @@ export function SuggestionCard({ suggestion, onDismiss, onAct }: SuggestionCardP
<button <button
onClick={() => onDismiss(suggestion.id)} onClick={() => onDismiss(suggestion.id)}
className="text-gray-400 hover:text-gray-600 transition-colors" className="text-gray-400 hover:text-gray-600 transition-colors"
title="Ignorieren" title={t('suggestionCard.ignorieren')}
> >
</button> </button>
@@ -86,7 +88,7 @@ export function SuggestionCard({ suggestion, onDismiss, onAct }: SuggestionCardP
{/* Acted upon badge */} {/* Acted upon badge */}
{suggestion.is_acted_upon && ( {suggestion.is_acted_upon && (
<div className="mt-2 text-xs text-green-600 font-medium flex items-center gap-1"> <div className="mt-2 text-xs text-green-600 font-medium flex items-center gap-1">
Ausgeführt {t('suggestionCard.ausgeführt')}
</div> </div>
)} )}
</div> </div>
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useSuggestions } from '@/api/aiProactive'; import { useSuggestions } from '@/api/aiProactive';
import { SuggestionCard } from '@/components/ai/SuggestionCard'; import { SuggestionCard } from '@/components/ai/SuggestionCard';
import { useTranslation } from 'react-i18next';
interface SuggestionSidebarProps { interface SuggestionSidebarProps {
isOpen: boolean; isOpen: boolean;
@@ -20,6 +21,7 @@ const filterOptions = [
* Used inside the AISidebar proactive tab. * Used inside the AISidebar proactive tab.
*/ */
export function SuggestionList() { export function SuggestionList() {
const { t } = useTranslation();
const { suggestions, connected, dismiss, act } = useSuggestions(); const { suggestions, connected, dismiss, act } = useSuggestions();
const [filter, setFilter] = useState<string>('all'); const [filter, setFilter] = useState<string>('all');
@@ -64,8 +66,8 @@ export function SuggestionList() {
{filteredSuggestions.length === 0 ? ( {filteredSuggestions.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-secondary-400"> <div className="flex flex-col items-center justify-center h-full text-secondary-400">
<div className="text-4xl mb-3">🤖</div> <div className="text-4xl mb-3">🤖</div>
<p className="text-sm">Keine Vorschläge vorhanden</p> <p className="text-sm">{t('suggestionSidebar.keinevorschlägevorhanden')}</p>
<p className="text-xs mt-1">Die KI analysiert deinen Kontext...</p> <p className="text-xs mt-1">{t('suggestionSidebar.diekianalysiertdeinenkontext')}</p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@@ -89,6 +91,7 @@ export function SuggestionList() {
* Uses SuggestionList internally. * Uses SuggestionList internally.
*/ */
export function SuggestionSidebar({ isOpen, onClose }: SuggestionSidebarProps) { export function SuggestionSidebar({ isOpen, onClose }: SuggestionSidebarProps) {
const { t } = useTranslation();
return ( return (
<> <>
{/* Overlay */} {/* Overlay */}
@@ -108,7 +111,7 @@ export function SuggestionSidebar({ isOpen, onClose }: SuggestionSidebarProps) {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200"> <div className="flex items-center justify-between p-4 border-b border-gray-200">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="font-semibold text-gray-800">KI Vorschläge</h2> <h2 className="font-semibold text-gray-800">{t('suggestionSidebar.kivorschläge')}</h2>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
@@ -8,12 +8,14 @@ import VideoBlock from './VideoBlock';
import FileBlock from './FileBlock'; import FileBlock from './FileBlock';
import { getBlockComponent } from './registry'; import { getBlockComponent } from './registry';
import './registrations'; // plugin-contributed block registrations import './registrations'; // plugin-contributed block registrations
import { useTranslation } from 'react-i18next';
interface BlockRendererProps { interface BlockRendererProps {
blocks: MessageBlock[]; blocks: MessageBlock[];
} }
const BlockRenderer: React.FC<BlockRendererProps> = ({ blocks }) => { const BlockRenderer: React.FC<BlockRendererProps> = ({ blocks }) => {
const { t } = useTranslation();
if (!blocks || blocks.length === 0) { if (!blocks || blocks.length === 0) {
return null; return null;
} }
@@ -55,7 +57,7 @@ const BlockRenderer: React.FC<BlockRendererProps> = ({ blocks }) => {
// Fallback for unknown block types // Fallback for unknown block types
return ( return (
<div className="text-xs text-secondary-400 italic p-2 rounded bg-secondary-50"> <div className="text-xs text-secondary-400 italic p-2 rounded bg-secondary-50">
Unbekannter Block-Typ: {block.block_type} {t('blockRenderer.unbekannterblocktyp')} {block.block_type}
</div> </div>
); );
} }
@@ -1,12 +1,14 @@
import React from 'react'; import React from 'react';
import type { MessageBlock } from '@/store/commStore'; import type { MessageBlock } from '@/store/commStore';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface ContactCardBlockProps { interface ContactCardBlockProps {
block: MessageBlock; block: MessageBlock;
} }
const ContactCardBlock: React.FC<ContactCardBlockProps> = ({ block }) => { const ContactCardBlock: React.FC<ContactCardBlockProps> = ({ block }) => {
const { t } = useTranslation();
const { contact_id, name } = block.block_data; const { contact_id, name } = block.block_data;
const contactName: string = name || 'Unbekannter Kontakt'; const contactName: string = name || 'Unbekannter Kontakt';
@@ -30,7 +32,7 @@ const ContactCardBlock: React.FC<ContactCardBlockProps> = ({ block }) => {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-secondary-700 truncate">{contactName}</p> <p className="text-sm font-medium text-secondary-700 truncate">{contactName}</p>
<p className="text-xs text-secondary-400">Kontakt anzeigen</p> <p className="text-xs text-secondary-400">{t('contactCardBlock.kontaktanzeigen')}</p>
</div> </div>
<ChevronRight className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> <ChevronRight className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
</a> </a>
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import type { MessageBlock } from '@/store/commStore'; import type { MessageBlock } from '@/store/commStore';
import { AppWindow, Loader2 } from 'lucide-react'; import { AppWindow, Loader2 } from 'lucide-react';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
import { useTranslation } from 'react-i18next';
interface MiniAppBlockProps { interface MiniAppBlockProps {
block: MessageBlock; block: MessageBlock;
@@ -17,6 +18,7 @@ interface MiniAppDef {
} }
const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => { const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
const { t } = useTranslation();
const { app_id, config } = block.block_data; const { app_id, config } = block.block_data;
const [appDef, setAppDef] = useState<MiniAppDef | null>(null); const [appDef, setAppDef] = useState<MiniAppDef | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -88,7 +90,7 @@ const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
)} )}
{!hasConfig && !hasSchema && ( {!hasConfig && !hasSchema && (
<p className="text-xs text-secondary-400 text-center py-2">Keine Konfiguration</p> <p className="text-xs text-secondary-400 text-center py-2">{t('miniAppBlock.keinekonfiguration')}</p>
)} )}
</div> </div>
); );
@@ -217,8 +217,9 @@ function TimelineEntry({
* Loading skeleton — 3 placeholder entries. * Loading skeleton — 3 placeholder entries.
*/ */
function LoadingSkeleton() { function LoadingSkeleton() {
const { t } = useTranslation();
return ( return (
<div className="space-y-4 animate-pulse" aria-label="Loading history"> <div className="space-y-4 animate-pulse" aria-label={t('entityHistoryPanel.loadinghistory')}>
{[0, 1, 2].map(i => ( {[0, 1, 2].map(i => (
<div key={i} className="flex gap-3"> <div key={i} className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-secondary-200 flex-shrink-0" /> <div className="w-8 h-8 rounded-full bg-secondary-200 flex-shrink-0" />
@@ -7,6 +7,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { Printer, FileDown, ChevronDown } from 'lucide-react'; import { Printer, FileDown, ChevronDown } from 'lucide-react';
import { printElement, printCurrentPage, exportToPDF } from '@/utils/print'; import { printElement, printCurrentPage, exportToPDF } from '@/utils/print';
import { useTranslation } from 'react-i18next';
export interface PrintButtonProps { export interface PrintButtonProps {
/** Element id to print. If omitted, prints the whole page. */ /** Element id to print. If omitted, prints the whole page. */
@@ -22,6 +23,7 @@ export function PrintButton({
filename = 'export', filename = 'export',
className, className,
}: PrintButtonProps) { }: PrintButtonProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -79,8 +81,8 @@ export function PrintButton({
)} )}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} aria-expanded={open}
aria-label="Drucken oder als PDF exportieren" aria-label={t('printButton.druckenoderalspdfexportieren')}
title="Drucken / PDF" title={t('printButton.druckenpdf')}
data-testid="print-button-trigger" data-testid="print-button-trigger"
> >
<Printer className="w-4 h-4" aria-hidden="true" strokeWidth={2} /> <Printer className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
@@ -128,7 +130,7 @@ export function PrintButton({
data-testid="print-button-pdf" data-testid="print-button-pdf"
> >
<FileDown className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} /> <FileDown className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} />
<span>Als PDF</span> <span>{t('printButton.alspdf')}</span>
</button> </button>
</div> </div>
)} )}
@@ -28,6 +28,7 @@ export interface SaveFilterDialogProps {
* Empty / null / undefined values are omitted. * Empty / null / undefined values are omitted.
*/ */
function CriteriaSummary({ criteria }: { criteria: Record<string, any> }) { function CriteriaSummary({ criteria }: { criteria: Record<string, any> }) {
const { t } = useTranslation();
const entries = Object.entries(criteria).filter( const entries = Object.entries(criteria).filter(
([, v]) => v !== null && v !== undefined && v !== '' ([, v]) => v !== null && v !== undefined && v !== ''
); );
@@ -35,7 +36,7 @@ function CriteriaSummary({ criteria }: { criteria: Record<string, any> }) {
if (entries.length === 0) { if (entries.length === 0) {
return ( return (
<p className="text-sm text-secondary-400 italic"> <p className="text-sm text-secondary-400 italic">
Keine aktiven Filterkriterien {t('saveFilterDialog.keineaktivenfilterkriterien')}
</p> </p>
); );
} }
@@ -85,6 +86,7 @@ export function SaveFilterDialog({
); );
const handleSave = async () => { const handleSave = async () => {
const { t } = useTranslation();
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) return; if (!trimmed) return;
setSubmitting(true); setSubmitting(true);
+12 -11
View File
@@ -25,6 +25,7 @@ import {
import { useUsers } from '@/api/users'; import { useUsers } from '@/api/users';
import { useGroups } from '@/api/groups'; import { useGroups } from '@/api/groups';
import type { EntityPermission, PermissionLevel } from '@/api/entityPermissions'; import type { EntityPermission, PermissionLevel } from '@/api/entityPermissions';
import { useTranslation } from 'react-i18next';
interface ShareDialogProps { interface ShareDialogProps {
entityType: string; entityType: string;
@@ -51,6 +52,7 @@ function permLabel(level: string) {
} }
export function ShareDialog({ entityType, entityId, entityName, onClose }: ShareDialogProps) { export function ShareDialog({ entityType, entityId, entityName, onClose }: ShareDialogProps) {
const { t } = useTranslation();
const { data: permData, isLoading } = useEntityPermissions(entityType, entityId); const { data: permData, isLoading } = useEntityPermissions(entityType, entityId);
const { data: accessData } = useEntityAccess(entityType, entityId); const { data: accessData } = useEntityAccess(entityType, entityId);
const { data: usersData } = useUsers(1, 100); const { data: usersData } = useUsers(1, 100);
@@ -133,7 +135,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
<button <button
onClick={onClose} onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 p-1 rounded-md hover:bg-secondary-100" className="text-secondary-400 hover:text-secondary-600 p-1 rounded-md hover:bg-secondary-100"
aria-label="Schließen" aria-label={t('shareDialog.schließen')}
> >
<X className="w-5 h-5" strokeWidth={2} /> <X className="w-5 h-5" strokeWidth={2} />
</button> </button>
@@ -143,10 +145,9 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
<div className="flex-1 overflow-y-auto px-5 py-4"> <div className="flex-1 overflow-y-auto px-5 py-4">
{/* Info banner */} {/* Info banner */}
<div className="mb-4 p-3 bg-primary-50 border border-primary-200 rounded-lg text-sm text-primary-700"> <div className="mb-4 p-3 bg-primary-50 border border-primary-200 rounded-lg text-sm text-primary-700">
<p className="font-medium mb-1">Element teilen</p> <p className="font-medium mb-1">{t('shareDialog.elementteilen')}</p>
<p className="text-primary-600"> <p className="text-primary-600">
Gewähre Benutzern oder Gruppen Zugriff auf dieses Element. Die Berechtigungsstufe bestimmt, {t('shareDialog.gewährebenutzernodergruppenzugriffauf')}
welche Aktionen durchgeführt werden können.
</p> </p>
</div> </div>
@@ -168,7 +169,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
<div className="text-sm text-secondary-400 py-4 text-center">Laden</div> <div className="text-sm text-secondary-400 py-4 text-center">Laden</div>
) : permissions.length === 0 ? ( ) : permissions.length === 0 ? (
<div className="text-sm text-secondary-400 py-4 text-center"> <div className="text-sm text-secondary-400 py-4 text-center">
Noch keine Berechtigungen vergeben. Dieses Element ist nur für den Besitzer sichtbar. {t('shareDialog.nochkeineberechtigungenvergebendiesesele')}
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@@ -207,7 +208,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
value={perm.expires_at ? perm.expires_at.split('T')[0] : ''} value={perm.expires_at ? perm.expires_at.split('T')[0] : ''}
onChange={(e) => handleUpdateExpiry(perm, e.target.value || '')} onChange={(e) => handleUpdateExpiry(perm, e.target.value || '')}
className="text-xs border border-secondary-200 rounded px-1 py-0.5 bg-transparent focus:outline-none focus:ring-1 focus:ring-primary-500 text-secondary-500" className="text-xs border border-secondary-200 rounded px-1 py-0.5 bg-transparent focus:outline-none focus:ring-1 focus:ring-primary-500 text-secondary-500"
title="Ablaufdatum setzen" title={t('shareDialog.ablaufdatumsetzen')}
/> />
</div> </div>
</div> </div>
@@ -229,8 +230,8 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
<button <button
onClick={() => handleDelete(perm)} onClick={() => handleDelete(perm)}
className="text-secondary-400 hover:text-red-600 p-1.5 rounded-md hover:bg-red-50 flex-shrink-0" className="text-secondary-400 hover:text-red-600 p-1.5 rounded-md hover:bg-red-50 flex-shrink-0"
title="Entfernen" title={t('shareDialog.entfernen')}
aria-label="Berechtigung entfernen" aria-label={t('shareDialog.berechtigungentfernen')}
> >
<Trash2 className="w-4 h-4" strokeWidth={2} /> <Trash2 className="w-4 h-4" strokeWidth={2} />
</button> </button>
@@ -245,7 +246,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
<div className="mt-4 p-4 border-2 border-primary-200 rounded-lg bg-primary-50/50"> <div className="mt-4 p-4 border-2 border-primary-200 rounded-lg bg-primary-50/50">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Plus className="w-4 h-4 text-primary-600" strokeWidth={2} /> <Plus className="w-4 h-4 text-primary-600" strokeWidth={2} />
<span className="text-sm font-medium text-secondary-700">Neue Berechtigung</span> <span className="text-sm font-medium text-secondary-700">{t('shareDialog.neueberechtigung')}</span>
</div> </div>
{/* Type toggle */} {/* Type toggle */}
@@ -330,7 +331,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
<div className="mb-3"> <div className="mb-3">
<label className="flex items-center gap-2 text-sm text-secondary-600 mb-1"> <label className="flex items-center gap-2 text-sm text-secondary-600 mb-1">
<Calendar className="w-4 h-4" strokeWidth={2} /> <Calendar className="w-4 h-4" strokeWidth={2} />
Ablaufdatum (optional) {t('shareDialog.ablaufdatumoptional')}
</label> </label>
<input <input
type="date" type="date"
@@ -364,7 +365,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
className="mt-4 w-full flex items-center justify-center gap-2 py-2.5 text-sm text-primary-600 border-2 border-dashed border-primary-200 rounded-lg hover:bg-primary-50 hover:border-primary-300 transition-colors" className="mt-4 w-full flex items-center justify-center gap-2 py-2.5 text-sm text-primary-600 border-2 border-dashed border-primary-200 rounded-lg hover:bg-primary-50 hover:border-primary-300 transition-colors"
> >
<Plus className="w-4 h-4" strokeWidth={2} /> <Plus className="w-4 h-4" strokeWidth={2} />
Berechtigung hinzufügen {t('shareDialog.berechtigunghinzufügen')}
</button> </button>
)} )}
</div> </div>
@@ -237,7 +237,7 @@ export function ContactEditForm({ onClose, contact, onSaved }: ContactEditFormPr
error={errors.name?.message} error={errors.name?.message}
required required
data-testid="contact-name-input" data-testid="contact-name-input"
placeholder="TechCorp GmbH" placeholder={t('contactEditForm.techcorpgmbh')}
/> />
) : ( ) : (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -257,7 +257,7 @@ export function ContactEditForm({ onClose, contact, onSaved }: ContactEditFormPr
)} )}
{/* Code */} {/* Code */}
<Input label={t('contacts.code')} {...register('code')} placeholder="K-00123" /> <Input label={t('contacts.code')} {...register('code')} placeholder={t('contactEditForm.k00123')} />
{/* Communication */} {/* Communication */}
<div className="border border-secondary-200 rounded-lg p-3"> <div className="border border-secondary-200 rounded-lg p-3">
@@ -299,7 +299,7 @@ export function ContactEditForm({ onClose, contact, onSaved }: ContactEditFormPr
<div className="border border-secondary-200 rounded-lg p-3"> <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> <h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.notes')}</h3>
<div className="space-y-3"> <div className="space-y-3">
<Input label={t('contacts.tags')} {...register('tags')} placeholder="tag1, tag2" /> <Input label={t('contacts.tags')} {...register('tags')} placeholder={t('contactEditForm.tag1tag2')} />
<div> <div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.projectnote')}</label> <label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.projectnote')}</label>
<textarea <textarea
@@ -162,6 +162,7 @@ function FolderTreeItem({
multiSelectedFolders?: string[]; multiSelectedFolders?: string[];
onToggleMultiSelect?: (folderId: string) => void; onToggleMultiSelect?: (folderId: string) => void;
}) { }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const folderKey = `folder:${node.id}` as ContactFilter; const folderKey = `folder:${node.id}` as ContactFilter;
const isActive = selectedFilter === folderKey; const isActive = selectedFilter === folderKey;
@@ -228,8 +229,8 @@ function FolderTreeItem({
type="button" type="button"
onClick={(e) => { e.stopPropagation(); e.preventDefault(); onMoreClick(e, node.id); }} onClick={(e) => { e.stopPropagation(); e.preventDefault(); onMoreClick(e, node.id); }}
className="flex-shrink-0 text-secondary-400 hover:text-primary-600 p-1.5 rounded hover:bg-secondary-100 transition-colors touch-manipulation" className="flex-shrink-0 text-secondary-400 hover:text-primary-600 p-1.5 rounded hover:bg-secondary-100 transition-colors touch-manipulation"
title="Optionen" title={t('contactFolderTree.optionen')}
aria-label="Optionen" aria-label={t('contactFolderTree.optionen')}
> >
{icon(ICONS.more, 'w-4 h-4')} {icon(ICONS.more, 'w-4 h-4')}
</button> </button>
@@ -446,8 +447,8 @@ export function ContactFolderTree({
<button <button
onClick={handleToggleMultiSelectMode} onClick={handleToggleMultiSelectMode}
className={`text-secondary-400 hover:text-primary-600 p-0.5 ${multiSelectMode ? 'text-primary-600 bg-primary-50 rounded' : ''}`} className={`text-secondary-400 hover:text-primary-600 p-0.5 ${multiSelectMode ? 'text-primary-600 bg-primary-50 rounded' : ''}`}
title="Mehrere Ordner auswählen" title={t('contactFolderTree.mehrereordnerauswählen')}
aria-label="Mehrere Ordner auswählen" aria-label={t('contactFolderTree.mehrereordnerauswählen')}
> >
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7" rx="1" /> <rect x="3" y="3" width="7" height="7" rx="1" />
@@ -461,8 +462,8 @@ export function ContactFolderTree({
<button <button
onClick={handleNewFolder} onClick={handleNewFolder}
className="text-secondary-400 hover:text-primary-600 p-0.5" className="text-secondary-400 hover:text-primary-600 p-0.5"
title="Neuer Ordner" title={t('contactFolderTree.neuerordner')}
aria-label="Neuer Ordner" aria-label={t('contactFolderTree.neuerordner')}
> >
{icon(ICONS.plus, 'w-3.5 h-3.5')} {icon(ICONS.plus, 'w-3.5 h-3.5')}
</button> </button>
@@ -512,7 +513,7 @@ export function ContactFolderTree({
)} )}
{tree.length === 0 && !foldersLoading && !loading && ( {tree.length === 0 && !foldersLoading && !loading && (
<div className="px-2 py-1 text-xs text-secondary-400">Keine Ordner vorhanden</div> <div className="px-2 py-1 text-xs text-secondary-400">{t('contactFolderTree.keineordnervorhanden')}</div>
)} )}
</div> </div>
@@ -574,7 +575,7 @@ export function ContactFolderTree({
<> <>
<div className="fixed inset-0 z-[9998]" onClick={() => setColorPicker(null)} /> <div className="fixed inset-0 z-[9998]" onClick={() => setColorPicker(null)} />
<div className="fixed z-[9999] bg-white border border-secondary-200 rounded-lg shadow-lg p-3 min-w-[200px]" style={{ top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}> <div className="fixed z-[9999] bg-white border border-secondary-200 rounded-lg shadow-lg p-3 min-w-[200px]" style={{ top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
<div className="text-sm font-semibold text-secondary-700 mb-2">Farbe wählen</div> <div className="text-sm font-semibold text-secondary-700 mb-2">{t('contactFolderTree.farbewählen')}</div>
<div className="grid grid-cols-6 gap-1.5 mb-3"> <div className="grid grid-cols-6 gap-1.5 mb-3">
{['#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', '#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#64748b', '#475569'].map((c) => ( {['#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', '#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#64748b', '#475569'].map((c) => (
<button <button
@@ -969,7 +969,7 @@ export function ContactList({
onClick={() => setBulkFolderOpen(!bulkFolderOpen)} onClick={() => setBulkFolderOpen(!bulkFolderOpen)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors" className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
> >
Ordner zuweisen {t('contactList.ordnerzuweisen')}
</button> </button>
{bulkFolderOpen && ( {bulkFolderOpen && (
<div className="absolute right-0 top-full mt-1 bg-white text-secondary-800 rounded-lg shadow-lg border border-secondary-200 max-h-60 overflow-y-auto min-w-[200px] z-30"> <div className="absolute right-0 top-full mt-1 bg-white text-secondary-800 rounded-lg shadow-lg border border-secondary-200 max-h-60 overflow-y-auto min-w-[200px] z-30">
@@ -987,7 +987,7 @@ export function ContactList({
</button> </button>
))} ))}
{folderList.length === 0 && ( {folderList.length === 0 && (
<div className="px-3 py-2 text-xs text-secondary-400">Keine Ordner</div> <div className="px-3 py-2 text-xs text-secondary-400">{t('contactList.keineordner')}</div>
)} )}
</div> </div>
)} )}
@@ -999,7 +999,7 @@ export function ContactList({
onClick={() => setBulkTagsOpen(!bulkTagsOpen)} onClick={() => setBulkTagsOpen(!bulkTagsOpen)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors" className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
> >
Tags hinzufügen {t('contactList.tagshinzufügen')}
</button> </button>
{bulkTagsOpen && ( {bulkTagsOpen && (
<div className="absolute right-0 top-full mt-1 bg-white text-secondary-800 rounded-lg shadow-lg border border-secondary-200 p-2 z-30"> <div className="absolute right-0 top-full mt-1 bg-white text-secondary-800 rounded-lg shadow-lg border border-secondary-200 p-2 z-30">
@@ -1007,7 +1007,7 @@ export function ContactList({
type="text" type="text"
value={bulkTagsInput} value={bulkTagsInput}
onChange={(e) => setBulkTagsInput(e.target.value)} onChange={(e) => setBulkTagsInput(e.target.value)}
placeholder="tag1, tag2, ..." placeholder={t('contactList.tag1tag2')}
className="w-48 px-2 py-1 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400" className="w-48 px-2 py-1 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400"
/> />
<button <button
@@ -1032,7 +1032,7 @@ export function ContactList({
onClick={clearSelection} onClick={clearSelection}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors" className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
> >
Auswahl aufheben {t('contactList.auswahlaufheben')}
</button> </button>
</div> </div>
)} )}
@@ -1041,9 +1041,9 @@ export function ContactList({
{bulkDeleteConfirm && ( {bulkDeleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-sm"> <div className="bg-white rounded-lg shadow-xl p-6 max-w-sm">
<h3 className="text-lg font-semibold text-secondary-900 mb-2">Löschen bestätigen</h3> <h3 className="text-lg font-semibold text-secondary-900 mb-2">{t('contactList.löschenbestätigen')}</h3>
<p className="text-sm text-secondary-600 mb-4"> <p className="text-sm text-secondary-600 mb-4">
{selectedContactIds?.size || 0} Kontakt(e) wirklich löschen? {selectedContactIds?.size || 0} {t('contactList.kontaktewirklichlöschen')}
</p> </p>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
@@ -1073,7 +1073,7 @@ export function ContactList({
{isCustomSortActive && customOrder.length > 0 && ( {isCustomSortActive && customOrder.length > 0 && (
<div className="flex items-center gap-1.5 px-3 py-1 bg-amber-50 border-b border-amber-200 text-xs text-amber-700"> <div className="flex items-center gap-1.5 px-3 py-1 bg-amber-50 border-b border-amber-200 text-xs text-amber-700">
<Info className="w-3 h-3" /> <Info className="w-3 h-3" />
<span>Custom Sortierung aktiv Drag-and-Drop zum Umsortieren</span> <span>{t('contactList.customsortierungaktivdraganddrop')}</span>
</div> </div>
)} )}
@@ -1138,15 +1138,15 @@ export function ContactList({
<div ref={colMenuRef} className="relative"> <div ref={colMenuRef} className="relative">
<button <button
onClick={() => setColMenuOpen(!colMenuOpen)} onClick={() => setColMenuOpen(!colMenuOpen)}
title="Spalten verwalten" title={t('contactList.spaltenverwalten')}
aria-label="Spalten verwalten" aria-label={t('contactList.spaltenverwalten')}
className="p-1 rounded hover:bg-secondary-100 text-secondary-500 hover:text-secondary-700 transition-colors" className="p-1 rounded hover:bg-secondary-100 text-secondary-500 hover:text-secondary-700 transition-colors"
> >
<Settings className="w-3.5 h-3.5" /> <Settings className="w-3.5 h-3.5" />
</button> </button>
{colMenuOpen && ( {colMenuOpen && (
<div className="absolute right-0 top-full mt-1 bg-white rounded-lg shadow-lg border border-secondary-200 max-h-80 overflow-y-auto min-w-[200px] z-30"> <div className="absolute right-0 top-full mt-1 bg-white rounded-lg shadow-lg border border-secondary-200 max-h-80 overflow-y-auto min-w-[200px] z-30">
<div className="px-3 py-2 text-xs font-semibold text-secondary-700 border-b border-secondary-100">Spalten verwalten</div> <div className="px-3 py-2 text-xs font-semibold text-secondary-700 border-b border-secondary-100">{t('contactList.spaltenverwalten')}</div>
{ALL_COLUMNS.map((col) => ( {ALL_COLUMNS.map((col) => (
<label <label
key={col.key} key={col.key}
@@ -8,6 +8,7 @@ import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Filter, Plus, X, ChevronDown, Bookmark } from 'lucide-react'; import { Filter, Plus, X, ChevronDown, Bookmark } from 'lucide-react';
import type { UnifiedContact } from '@/api/unifiedContacts'; import type { UnifiedContact } from '@/api/unifiedContacts';
import { useCustomFieldDefinitions, type CustomFieldDefinition } from '@/api/customFieldDefinitions'; import { useCustomFieldDefinitions, type CustomFieldDefinition } from '@/api/customFieldDefinitions';
import { useTranslation } from 'react-i18next';
// ─── Field definitions ─────────────────────────────────────────────────────── // ─── Field definitions ───────────────────────────────────────────────────────
@@ -245,6 +246,7 @@ function newConditionId() {
} }
export function FilterPanel({ filters, onFiltersChange, contactType, savedFilters = [], onSaveFilter, onLoadFilter, onDeleteFilter }: FilterPanelProps) { export function FilterPanel({ filters, onFiltersChange, contactType, savedFilters = [], onSaveFilter, onLoadFilter, onDeleteFilter }: FilterPanelProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLDivElement>(null); const btnRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
@@ -373,8 +375,8 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
<div ref={btnRef} className="relative flex-shrink-0"> <div ref={btnRef} className="relative flex-shrink-0">
<button <button
onClick={handleToggle} onClick={handleToggle}
title="Filter" title={t('filterPanel.filter')}
aria-label="Filter" aria-label={t('filterPanel.filter')}
className={` className={`
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
transition-colors duration-100 cursor-pointer relative transition-colors duration-100 cursor-pointer relative
@@ -415,7 +417,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
onClick={clearAll} onClick={clearAll}
className="text-xs text-secondary-500 hover:text-red-600 transition-colors" className="text-xs text-secondary-500 hover:text-red-600 transition-colors"
> >
Alle löschen {t('filterPanel.allelöschen')}
</button> </button>
)} )}
<button <button
@@ -430,7 +432,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
{/* Logic toggle */} {/* Logic toggle */}
{activeCount > 0 && ( {activeCount > 0 && (
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-100"> <div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-100">
<span className="text-xs text-secondary-500">Bedingungen verknüpfen:</span> <span className="text-xs text-secondary-500">{t('filterPanel.bedingungenverknüpfen')}</span>
<button <button
onClick={toggleLogic} onClick={toggleLogic}
className={` className={`
@@ -483,7 +485,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
{/* Saved filters */} {/* Saved filters */}
{savedFilters.length > 0 && ( {savedFilters.length > 0 && (
<div className="px-4 py-2 border-b border-secondary-100"> <div className="px-4 py-2 border-b border-secondary-100">
<div className="text-[10px] font-semibold uppercase tracking-wide text-secondary-400 mb-1.5">Gespeicherte Filter</div> <div className="text-[10px] font-semibold uppercase tracking-wide text-secondary-400 mb-1.5">{t('filterPanel.gespeichertefilter')}</div>
<div className="space-y-0.5"> <div className="space-y-0.5">
{savedFilters.map((sf) => ( {savedFilters.map((sf) => (
<div key={sf.id} className="flex items-center gap-1 group"> <div key={sf.id} className="flex items-center gap-1 group">
@@ -510,7 +512,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
<div className="px-4 py-3 space-y-2"> <div className="px-4 py-3 space-y-2">
{filters.conditions.length === 0 && ( {filters.conditions.length === 0 && (
<div className="text-center py-6 text-xs text-secondary-400"> <div className="text-center py-6 text-xs text-secondary-400">
Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen. {t('filterPanel.keinefilteraktivklickeuntenum')}
</div> </div>
)} )}
{filters.conditions.map((cond, idx) => { {filters.conditions.map((cond, idx) => {
@@ -570,7 +572,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
onChange={(e) => updateCondition(cond.id, { value: e.target.value })} onChange={(e) => updateCondition(cond.id, { value: e.target.value })}
className="px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400" className="px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400"
> >
<option value=""> wählen </option> <option value="">{t('filterPanel.wählen')}</option>
{def.options?.map((opt) => ( {def.options?.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option> <option key={opt.value} value={opt.value}>{opt.label}</option>
))} ))}
@@ -587,7 +589,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
type="text" type="text"
value={cond.value} value={cond.value}
onChange={(e) => updateCondition(cond.id, { value: e.target.value })} onChange={(e) => updateCondition(cond.id, { value: e.target.value })}
placeholder="Wert…" placeholder={t('filterPanel.wert')}
className="w-24 px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400" className="w-24 px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400"
/> />
) )
@@ -615,7 +617,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors" className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
Bedingung hinzufügen {t('filterPanel.bedingunghinzufügen')}
</button> </button>
{activeCount > 0 && onSaveFilter && ( {activeCount > 0 && onSaveFilter && (
<button <button
@@ -623,7 +625,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors" className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"
> >
<Bookmark className="w-3.5 h-3.5" /> <Bookmark className="w-3.5 h-3.5" />
Filter speichern {t('filterPanel.filterspeichern')}
</button> </button>
)} )}
</div> </div>
@@ -11,6 +11,7 @@ import {
import { useUsers } from '@/api/users'; import { useUsers } from '@/api/users';
import { useGroups } from '@/api/groups'; import { useGroups } from '@/api/groups';
import type { FolderPermission } from '@/api/contactFolders'; import type { FolderPermission } from '@/api/contactFolders';
import { useTranslation } from 'react-i18next';
interface FolderPermissionDialogProps { interface FolderPermissionDialogProps {
folderId: string; folderId: string;
@@ -36,6 +37,7 @@ function permLabel(level: string) {
} }
export function FolderPermissionDialog({ folderId, folderName, onClose }: FolderPermissionDialogProps) { export function FolderPermissionDialog({ folderId, folderName, onClose }: FolderPermissionDialogProps) {
const { t } = useTranslation();
const { data: permData, isLoading } = useFolderPermissions(folderId); const { data: permData, isLoading } = useFolderPermissions(folderId);
const { data: usersData } = useUsers(1, 100); const { data: usersData } = useUsers(1, 100);
const { data: groupsData } = useGroups(); const { data: groupsData } = useGroups();
@@ -109,7 +111,7 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
<button <button
onClick={onClose} onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 p-1 rounded-md hover:bg-secondary-100" className="text-secondary-400 hover:text-secondary-600 p-1 rounded-md hover:bg-secondary-100"
aria-label="Schließen" aria-label={t('folderPermissionDialog.schließen')}
> >
<X className="w-5 h-5" strokeWidth={2} /> <X className="w-5 h-5" strokeWidth={2} />
</button> </button>
@@ -119,9 +121,9 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
<div className="flex-1 overflow-y-auto px-5 py-4"> <div className="flex-1 overflow-y-auto px-5 py-4">
{/* Info banner */} {/* Info banner */}
<div className="mb-4 p-3 bg-primary-50 border border-primary-200 rounded-lg text-sm text-primary-700"> <div className="mb-4 p-3 bg-primary-50 border border-primary-200 rounded-lg text-sm text-primary-700">
<p className="font-medium mb-1">Ordner teilen</p> <p className="font-medium mb-1">{t('folderPermissionDialog.ordnerteilen')}</p>
<p className="text-primary-600"> <p className="text-primary-600">
Gewähre Benutzern oder Gruppen Zugriff auf diesen Ordner. Mit Vererben" gelten die Rechte auch für alle Unterordner. {t('folderPermissionDialog.gewährebenutzernodergruppenzugriffauf')}
</p> </p>
</div> </div>
@@ -130,7 +132,7 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
<div className="text-sm text-secondary-400 py-4 text-center">Laden</div> <div className="text-sm text-secondary-400 py-4 text-center">Laden</div>
) : permissions.length === 0 ? ( ) : permissions.length === 0 ? (
<div className="text-sm text-secondary-400 py-4 text-center"> <div className="text-sm text-secondary-400 py-4 text-center">
Noch keine Berechtigungen vergeben. Dieser Ordner ist nur für den Besitzer sichtbar. {t('folderPermissionDialog.nochkeineberechtigungenvergebendieserord')}
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@@ -176,8 +178,8 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
<button <button
onClick={() => handleDelete(perm)} onClick={() => handleDelete(perm)}
className="text-secondary-400 hover:text-red-600 p-1.5 rounded-md hover:bg-red-50 flex-shrink-0" className="text-secondary-400 hover:text-red-600 p-1.5 rounded-md hover:bg-red-50 flex-shrink-0"
title="Entfernen" title={t('folderPermissionDialog.entfernen')}
aria-label="Berechtigung entfernen" aria-label={t('folderPermissionDialog.berechtigungentfernen')}
> >
<Trash2 className="w-4 h-4" strokeWidth={2} /> <Trash2 className="w-4 h-4" strokeWidth={2} />
</button> </button>
@@ -192,7 +194,7 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
<div className="mt-4 p-4 border-2 border-primary-200 rounded-lg bg-primary-50/50"> <div className="mt-4 p-4 border-2 border-primary-200 rounded-lg bg-primary-50/50">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Plus className="w-4 h-4 text-primary-600" strokeWidth={2} /> <Plus className="w-4 h-4 text-primary-600" strokeWidth={2} />
<span className="text-sm font-medium text-secondary-700">Neue Berechtigung</span> <span className="text-sm font-medium text-secondary-700">{t('folderPermissionDialog.neueberechtigung')}</span>
</div> </div>
{/* Type toggle */} {/* Type toggle */}
@@ -264,7 +266,7 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
onChange={(e) => setAddInherit(e.target.checked)} onChange={(e) => setAddInherit(e.target.checked)}
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500" className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/> />
Auf Unterordner vererben {t('folderPermissionDialog.aufunterordnervererben')}
</label> </label>
{/* Actions */} {/* Actions */}
@@ -290,7 +292,7 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
className="mt-4 w-full flex items-center justify-center gap-2 py-2.5 text-sm text-primary-600 border-2 border-dashed border-primary-200 rounded-lg hover:bg-primary-50 hover:border-primary-300 transition-colors" className="mt-4 w-full flex items-center justify-center gap-2 py-2.5 text-sm text-primary-600 border-2 border-dashed border-primary-200 rounded-lg hover:bg-primary-50 hover:border-primary-300 transition-colors"
> >
<Plus className="w-4 h-4" strokeWidth={2} /> <Plus className="w-4 h-4" strokeWidth={2} />
Berechtigung hinzufügen {t('folderPermissionDialog.berechtigunghinzufügen')}
</button> </button>
)} )}
</div> </div>
@@ -8,6 +8,7 @@ import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Group as GroupIcon, Plus, X } from 'lucide-react'; import { Group as GroupIcon, Plus, X } from 'lucide-react';
import type { UnifiedContact } from '@/api/unifiedContacts'; import type { UnifiedContact } from '@/api/unifiedContacts';
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions'; import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
import { useTranslation } from 'react-i18next';
// ─── Field definitions ──────────────────────────────────────────────────────── // ─── Field definitions ────────────────────────────────────────────────────────
@@ -172,6 +173,7 @@ function newGroupId() {
} }
export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPanelProps) { export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPanelProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLDivElement>(null); const btnRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
@@ -289,8 +291,8 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
<div ref={btnRef} className="relative flex-shrink-0"> <div ref={btnRef} className="relative flex-shrink-0">
<button <button
onClick={handleToggle} onClick={handleToggle}
title="Gruppierung" title={t('groupPanel.gruppierung')}
aria-label="Gruppierung" aria-label={t('groupPanel.gruppierung')}
className={` className={`
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
transition-colors duration-100 cursor-pointer relative transition-colors duration-100 cursor-pointer relative
@@ -331,7 +333,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
onClick={clearAll} onClick={clearAll}
className="text-xs text-secondary-500 hover:text-red-600 transition-colors" className="text-xs text-secondary-500 hover:text-red-600 transition-colors"
> >
Alle löschen {t('groupPanel.allelöschen')}
</button> </button>
)} )}
<button <button
@@ -347,7 +349,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
{activeCount === 0 && ( {activeCount === 0 && (
<div className="px-4 py-3 border-b border-secondary-100"> <div className="px-4 py-3 border-b border-secondary-100">
<div className="text-center py-4 text-xs text-secondary-400"> <div className="text-center py-4 text-xs text-secondary-400">
Keine Gruppierung aktiv. Alle Datensätze werden in einer flachen Liste angezeigt. {t('groupPanel.keinegruppierungaktivalledatensätzewerde')}
</div> </div>
</div> </div>
)} )}
@@ -435,7 +437,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors" className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
Gruppierung hinzufügen {t('groupPanel.gruppierunghinzufügen')}
</button> </button>
<button <button
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
@@ -4,6 +4,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Bookmark, Check, Folder, Filter, Group as GroupIcon, ArrowDownAZ, LayoutGrid } from 'lucide-react'; import { Bookmark, Check, Folder, Filter, Group as GroupIcon, ArrowDownAZ, LayoutGrid } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export interface SaveViewSelection { export interface SaveViewSelection {
folder: boolean; folder: boolean;
@@ -32,6 +33,7 @@ const defaultSelection: SaveViewSelection = {
}; };
export function SaveViewDialog({ open, onClose, onSave, hasFilter, hasGroup, hasSort, hasFolder }: SaveViewDialogProps) { export function SaveViewDialog({ open, onClose, onSave, hasFilter, hasGroup, hasSort, hasFolder }: SaveViewDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [selection, setSelection] = useState<SaveViewSelection>(defaultSelection); const [selection, setSelection] = useState<SaveViewSelection>(defaultSelection);
@@ -66,19 +68,19 @@ export function SaveViewDialog({ open, onClose, onSave, hasFilter, hasGroup, has
{/* Header */} {/* Header */}
<div className="flex items-center gap-2 px-5 py-4 border-b border-secondary-100"> <div className="flex items-center gap-2 px-5 py-4 border-b border-secondary-100">
<Bookmark className="w-5 h-5 text-primary-600" strokeWidth={2} /> <Bookmark className="w-5 h-5 text-primary-600" strokeWidth={2} />
<h2 className="text-base font-semibold text-secondary-800">Ansicht speichern</h2> <h2 className="text-base font-semibold text-secondary-800">{t('saveViewDialog.ansichtspeichern')}</h2>
</div> </div>
{/* Body */} {/* Body */}
<div className="px-5 py-4 space-y-4"> <div className="px-5 py-4 space-y-4">
{/* Name input */} {/* Name input */}
<div> <div>
<label className="block text-xs font-medium text-secondary-600 mb-1">Name der Ansicht</label> <label className="block text-xs font-medium text-secondary-600 mb-1">{t('saveViewDialog.namederansicht')}</label>
<input <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="z.B. Meine Firmen-Kontakte" placeholder={t('saveViewDialog.zbmeinefirmenkontakte')}
autoFocus autoFocus
onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }} onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }}
className="w-full px-3 py-2 text-sm border border-secondary-200 rounded-md focus:outline-none focus:border-primary-400 focus:ring-1 focus:ring-primary-300" className="w-full px-3 py-2 text-sm border border-secondary-200 rounded-md focus:outline-none focus:border-primary-400 focus:ring-1 focus:ring-primary-300"
@@ -87,7 +89,7 @@ export function SaveViewDialog({ open, onClose, onSave, hasFilter, hasGroup, has
{/* Component selection */} {/* Component selection */}
<div> <div>
<label className="block text-xs font-medium text-secondary-600 mb-2">Was soll gespeichert werden?</label> <label className="block text-xs font-medium text-secondary-600 mb-2">{t('saveViewDialog.wassollgespeichertwerden')}</label>
<div className="space-y-1.5"> <div className="space-y-1.5">
{options.map((opt) => ( {options.map((opt) => (
<label <label
@@ -112,7 +114,7 @@ export function SaveViewDialog({ open, onClose, onSave, hasFilter, hasGroup, has
</div> </div>
</span> </span>
{!opt.available && ( {!opt.available && (
<span className="text-[10px] text-secondary-400 italic">nicht aktiv</span> <span className="text-[10px] text-secondary-400 italic">{t('saveViewDialog.nichtaktiv')}</span>
)} )}
</label> </label>
))} ))}
@@ -8,6 +8,7 @@ import React, { useState, useRef, useEffect, useMemo } from 'react';
import { ArrowDownAZ, ArrowUpZA, Plus, X } from 'lucide-react'; import { ArrowDownAZ, ArrowUpZA, Plus, X } from 'lucide-react';
import type { UnifiedContact } from '@/api/unifiedContacts'; import type { UnifiedContact } from '@/api/unifiedContacts';
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions'; import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
import { useTranslation } from 'react-i18next';
// ─── Field definitions (reuse from FilterPanel) ──────────────────────────────── // ─── Field definitions (reuse from FilterPanel) ────────────────────────────────
@@ -164,6 +165,7 @@ function newSortId() {
} }
export function SortPanel({ sortState, onSortChange, contactType }: SortPanelProps) { export function SortPanel({ sortState, onSortChange, contactType }: SortPanelProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLDivElement>(null); const btnRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
@@ -282,8 +284,8 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
<div ref={btnRef} className="relative flex-shrink-0"> <div ref={btnRef} className="relative flex-shrink-0">
<button <button
onClick={handleToggle} onClick={handleToggle}
title="Sortieren" title={t('sortPanel.sortieren')}
aria-label="Sortieren" aria-label={t('sortPanel.sortieren')}
className={` className={`
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
transition-colors duration-100 cursor-pointer relative transition-colors duration-100 cursor-pointer relative
@@ -324,7 +326,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
onClick={clearAll} onClick={clearAll}
className="text-xs text-secondary-500 hover:text-red-600 transition-colors" className="text-xs text-secondary-500 hover:text-red-600 transition-colors"
> >
Alle löschen {t('sortPanel.allelöschen')}
</button> </button>
)} )}
<button <button
@@ -340,7 +342,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
{activeCount === 0 && ( {activeCount === 0 && (
<div className="px-4 py-3 border-b border-secondary-100"> <div className="px-4 py-3 border-b border-secondary-100">
<div className="text-center py-4 text-xs text-secondary-400"> <div className="text-center py-4 text-xs text-secondary-400">
Keine Sortierung aktiv. Datensätze können per Drag-and-Drop umsortiert werden. {t('sortPanel.keinesortierungaktivdatensätzekönnenper')}
</div> </div>
</div> </div>
)} )}
@@ -448,7 +450,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors" className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
Sortierung hinzufügen {t('sortPanel.sortierunghinzufügen')}
</button> </button>
<button <button
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
@@ -9,6 +9,7 @@ import { Select } from '@/components/ui/Select';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import type { CustomFieldDefinition } from '@/api/customFieldDefinitions'; import type { CustomFieldDefinition } from '@/api/customFieldDefinitions';
import { useTranslation } from 'react-i18next';
export interface CustomFieldRendererProps { export interface CustomFieldRendererProps {
definition: CustomFieldDefinition; definition: CustomFieldDefinition;
@@ -17,6 +18,7 @@ export interface CustomFieldRendererProps {
} }
export function CustomFieldRenderer({ definition, value, onChange }: CustomFieldRendererProps) { export function CustomFieldRenderer({ definition, value, onChange }: CustomFieldRendererProps) {
const { t } = useTranslation();
const generatedId = useId(); const generatedId = useId();
const fieldId = `cf-${definition.id || generatedId}`; const fieldId = `cf-${definition.id || generatedId}`;
const { field_type, options, required } = definition; const { field_type, options, required } = definition;
@@ -39,7 +41,7 @@ export function CustomFieldRenderer({ definition, value, onChange }: CustomField
/> />
<span> <span>
{definition.label} {definition.label}
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>} {required && <span className="text-danger-500 ml-1" aria-label={t('customFieldRenderer.required')}>*</span>}
</span> </span>
</label> </label>
</div> </div>
@@ -73,7 +75,7 @@ export function CustomFieldRenderer({ definition, value, onChange }: CustomField
<div className="w-full"> <div className="w-full">
<label className="block text-sm font-medium text-secondary-700 mb-1"> <label className="block text-sm font-medium text-secondary-700 mb-1">
{definition.label} {definition.label}
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>} {required && <span className="text-danger-500 ml-1" aria-label={t('customFieldRenderer.required')}>*</span>}
</label> </label>
{selectedValues.length > 0 && ( {selectedValues.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2"> <div className="flex flex-wrap gap-1.5 mb-2">
@@ -106,9 +108,9 @@ export function CustomFieldRenderer({ definition, value, onChange }: CustomField
))} ))}
</div> </div>
) : availableOptions.length === 0 ? ( ) : availableOptions.length === 0 ? (
<p className="text-sm text-secondary-400">Keine Optionen verfügbar</p> <p className="text-sm text-secondary-400">{t('customFieldRenderer.keineoptionenverfügbar')}</p>
) : ( ) : (
<p className="text-sm text-secondary-400">Alle Optionen ausgewählt</p> <p className="text-sm text-secondary-400">{t('customFieldRenderer.alleoptionenausgewählt')}</p>
)} )}
</div> </div>
); );
@@ -123,7 +125,7 @@ export function CustomFieldRenderer({ definition, value, onChange }: CustomField
label={definition.label} label={definition.label}
required={required} required={required}
options={selectOptions} options={selectOptions}
placeholder="— Bitte wählen —" placeholder={t('customFieldRenderer.bittewählen')}
value={value ?? ''} value={value ?? ''}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
+10 -8
View File
@@ -44,10 +44,11 @@ interface TabDef {
} }
function ChatPanel() { function ChatPanel() {
const { t } = useTranslation();
return ( return (
<div className="flex flex-col h-full" data-testid="chat-panel"> <div className="flex flex-col h-full" data-testid="chat-panel">
<div className="flex-1 overflow-y-auto p-3"> <div className="flex-1 overflow-y-auto p-3">
<p className="text-sm text-secondary-400 text-center py-8">Chat-Sidebar - Coming Soon</p> <p className="text-sm text-secondary-400 text-center py-8">{t('aISidebar.chatsidebarcomingsoon')}</p>
</div> </div>
</div> </div>
); );
@@ -103,6 +104,7 @@ export function AISidebar() {
} }
const renderTabContent = () => { const renderTabContent = () => {
const { t } = useTranslation();
if (aiSidebarTab === 'proactive') { if (aiSidebarTab === 'proactive') {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -117,14 +119,14 @@ export function AISidebar() {
return ( return (
<div className="flex flex-col h-full overflow-y-auto p-3 gap-2" data-testid="notification-list"> <div className="flex flex-col h-full overflow-y-auto p-3 gap-2" data-testid="notification-list">
{notifications.length === 0 && ( {notifications.length === 0 && (
<p className="text-sm text-secondary-400 text-center py-4">Keine Benachrichtigungen</p> <p className="text-sm text-secondary-400 text-center py-4">{t('aISidebar.keinebenachrichtigungen')}</p>
)} )}
{notifications.map((msg, i) => ( {notifications.map((msg, i) => (
<div key={i} className="flex items-start justify-between gap-2 px-3 py-2 rounded-lg border border-secondary-200 bg-secondary-50 hover:border-secondary-300 hover:bg-secondary-100 transition-colors text-sm text-secondary-700 group"> <div key={i} className="flex items-start justify-between gap-2 px-3 py-2 rounded-lg border border-secondary-200 bg-secondary-50 hover:border-secondary-300 hover:bg-secondary-100 transition-colors text-sm text-secondary-700 group">
<span className="flex-1 leading-snug">{msg}</span> <span className="flex-1 leading-snug">{msg}</span>
<button <button
className="p-1 rounded text-secondary-300 hover:text-danger-600 hover:bg-danger-50 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 mt-0.5" className="p-1 rounded text-secondary-300 hover:text-danger-600 hover:bg-danger-50 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 mt-0.5"
aria-label="Benachrichtigung löschen" aria-label={t('aISidebar.benachrichtigunglöschen')}
onClick={() => removeNotification(i)} onClick={() => removeNotification(i)}
> >
<X className="w-3.5 h-3.5" aria-hidden="true" strokeWidth={2} /> <X className="w-3.5 h-3.5" aria-hidden="true" strokeWidth={2} />
@@ -150,13 +152,13 @@ export function AISidebar() {
return ( return (
<div className="flex flex-col items-center justify-center h-full text-center px-4 gap-3"> <div className="flex flex-col items-center justify-center h-full text-center px-4 gap-3">
<p className="text-sm text-secondary-500"> <p className="text-sm text-secondary-500">
Der volle KI-Chat ist auf der AI-Assistant-Seite verfügbar. {t('aISidebar.dervollekichatistauf')}
</p> </p>
<Link <Link
to="/ai-assistant" to="/ai-assistant"
className="inline-flex items-center px-4 py-2 rounded-md bg-primary-600 text-white text-sm font-medium hover:bg-primary-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 min-h-touch" className="inline-flex items-center px-4 py-2 rounded-md bg-primary-600 text-white text-sm font-medium hover:bg-primary-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 min-h-touch"
> >
AI Assistant öffnen {t('aISidebar.aiassistantöffnen')}
</Link> </Link>
</div> </div>
); );
@@ -190,8 +192,8 @@ export function AISidebar() {
<button <button
onClick={toggleAISidebar} onClick={toggleAISidebar}
className="p-1.5 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center" className="p-1.5 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center"
title="Einklappen" title={t('aISidebar.einklappen')}
aria-label="KI Assistent einklappen" aria-label={t('aISidebar.kiassistenteinklappen')}
> >
{chevronRightIcon} {chevronRightIcon}
</button> </button>
@@ -210,7 +212,7 @@ export function AISidebar() {
<button <button
onClick={toggleAISidebar} onClick={toggleAISidebar}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch" 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="Zurück" aria-label={t('aISidebar.zurück')}
data-testid="ai-sidebar-back" data-testid="ai-sidebar-back"
> >
<ChevronLeft className="w-5 h-5" aria-hidden="true" strokeWidth={2} /> <ChevronLeft className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
@@ -128,6 +128,7 @@ function MessageFeed({
messages: Message[]; messages: Message[];
loading: boolean; loading: boolean;
}) { }) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -147,7 +148,7 @@ function MessageFeed({
if (messages.length === 0) { if (messages.length === 0) {
return ( return (
<div className="flex items-center justify-center h-full text-sm text-secondary-400"> <div className="flex items-center justify-center h-full text-sm text-secondary-400">
Keine Nachrichten {t('messageSidebar.keinenachrichten')}
</div> </div>
); );
} }
@@ -205,6 +206,7 @@ function MessageInput({
onSend: (text: string) => void; onSend: (text: string) => void;
disabled: boolean; disabled: boolean;
}) { }) {
const { t } = useTranslation();
const [text, setText] = useState(''); const [text, setText] = useState('');
const handleSend = () => { const handleSend = () => {
@@ -236,7 +238,7 @@ function MessageInput({
onClick={handleSend} onClick={handleSend}
disabled={disabled || !text.trim()} disabled={disabled || !text.trim()}
className="p-2 rounded-lg bg-primary-500 text-white hover:bg-primary-600 disabled:bg-secondary-200 disabled:text-secondary-400 transition-colors min-h-touch min-w-touch flex items-center justify-center" className="p-2 rounded-lg bg-primary-500 text-white hover:bg-primary-600 disabled:bg-secondary-200 disabled:text-secondary-400 transition-colors min-h-touch min-w-touch flex items-center justify-center"
aria-label="Senden" aria-label={t('messageSidebar.senden')}
data-testid="message-send-btn" data-testid="message-send-btn"
> >
{sendIcon} {sendIcon}
@@ -386,6 +388,7 @@ export function MessageSidebar() {
// Determine which pinned conv to select when a quick-access button is clicked // Determine which pinned conv to select when a quick-access button is clicked
const handleQuickAccess = (qa: QuickAccessDef) => { const handleQuickAccess = (qa: QuickAccessDef) => {
const { t } = useTranslation();
setCurrentView(qa.view); setCurrentView(qa.view);
if (qa.view === 'conversations' && qa.filterPinned) { if (qa.view === 'conversations' && qa.filterPinned) {
// Find pinned conversation matching the filter by title // Find pinned conversation matching the filter by title
@@ -480,8 +483,8 @@ export function MessageSidebar() {
<button <button
onClick={toggleMessageSidebar} onClick={toggleMessageSidebar}
className="p-1.5 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center" className="p-1.5 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center"
title="Einklappen" title={t('messageSidebar.einklappen')}
aria-label="Messaging einklappen" aria-label={t('messageSidebar.messagingeinklappen')}
> >
{chevronRightIcon} {chevronRightIcon}
</button> </button>
@@ -498,7 +501,7 @@ export function MessageSidebar() {
<p className="text-sm text-red-500 text-center py-2">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</p> <p className="text-sm text-red-500 text-center py-2">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</p>
)} )}
{!loading && conversations.length === 0 && !error && ( {!loading && conversations.length === 0 && !error && (
<p className="text-sm text-secondary-400 text-center py-4">Keine Konversationen</p> <p className="text-sm text-secondary-400 text-center py-4">{t('messageSidebar.keinekonversationen')}</p>
)} )}
{/* Pinned conversations */} {/* Pinned conversations */}
{pinnedConversations.length > 0 && ( {pinnedConversations.length > 0 && (
@@ -537,6 +540,7 @@ export function MessageSidebar() {
// ─── Main Content Area ─── // ─── Main Content Area ───
const renderContent = () => { const renderContent = () => {
const { t } = useTranslation();
if (currentView === 'team') { if (currentView === 'team') {
return <TeamPanel onStartDirectChat={handleStartDirectChat} />; return <TeamPanel onStartDirectChat={handleStartDirectChat} />;
} }
@@ -564,7 +568,7 @@ export function MessageSidebar() {
)} )}
{isSystemLocked && ( {isSystemLocked && (
<div className="p-3 border-t border-secondary-200 text-center"> <div className="p-3 border-t border-secondary-200 text-center">
<span className="text-xs text-secondary-400">System-Konversation (schreibgeschützt)</span> <span className="text-xs text-secondary-400">{t('messageSidebar.systemkonversationschreibgeschützt')}</span>
</div> </div>
)} )}
</div> </div>
@@ -584,7 +588,7 @@ export function MessageSidebar() {
<button <button
onClick={toggleMessageSidebar} onClick={toggleMessageSidebar}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch" 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="Zurück" aria-label={t('messageSidebar.zurück')}
data-testid="message-sidebar-back" data-testid="message-sidebar-back"
> >
<ChevronLeft className="w-5 h-5" aria-hidden="true" strokeWidth={2} /> <ChevronLeft className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
@@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { usePluginToolbarStore, type ToolbarItem } from '@/store/pluginToolbarStore'; import { usePluginToolbarStore, type ToolbarItem } from '@/store/pluginToolbarStore';
import { useTranslation } from 'react-i18next';
function ToolbarButton({ item }: { item: ToolbarItem }) { function ToolbarButton({ item }: { item: ToolbarItem }) {
const { t } = useTranslation();
return ( return (
<button <button
onClick={item.onClick} onClick={item.onClick}
@@ -21,6 +23,7 @@ function ToolbarButton({ item }: { item: ToolbarItem }) {
} }
function ToolbarSearch({ item }: { item: ToolbarItem }) { function ToolbarSearch({ item }: { item: ToolbarItem }) {
const { t } = useTranslation();
const [expanded, setExpanded] = React.useState(false); const [expanded, setExpanded] = React.useState(false);
const [value, setValue] = React.useState(''); const [value, setValue] = React.useState('');
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
@@ -74,7 +77,7 @@ function ToolbarSearch({ item }: { item: ToolbarItem }) {
<button <button
onClick={handleToggle} onClick={handleToggle}
className="ml-1 p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600 transition-colors" className="ml-1 p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600 transition-colors"
aria-label="Suche schließen" aria-label={t('pluginToolbar.sucheschließen')}
> >
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<line x1="18" y1="6" x2="6" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" />
+4 -4
View File
@@ -182,21 +182,21 @@ export function Sidebar() {
'flex flex-col transition-transform motion-safe:duration-300', 'flex flex-col transition-transform motion-safe:duration-300',
sidebarOpen ? 'translate-x-0' : '-translate-x-full md:hidden' sidebarOpen ? 'translate-x-0' : '-translate-x-full md:hidden'
)} )}
aria-label="Seitenleiste Navigation" aria-label={t('sidebar.seitenleistenavigation')}
data-testid="sidebar" data-testid="sidebar"
> >
<div className="h-[58px] flex items-center gap-2 px-4 border-b border-secondary-700"> <div className="h-[58px] flex items-center gap-2 px-4 border-b border-secondary-700">
<button <button
onClick={() => navigate('/start')} onClick={() => navigate('/start')}
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-700 hover:text-white flex items-center justify-center focus:outline-none" className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-700 hover:text-white flex items-center justify-center focus:outline-none"
aria-label="Zurück zur Startseite" aria-label={t('sidebar.zurückzurstartseite')}
title="Zurück zur Startseite" title={t('sidebar.zurückzurstartseite')}
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</button> </button>
<span className="text-xl font-bold text-white">leocrm</span> <span className="text-xl font-bold text-white">leocrm</span>
</div> </div>
<nav className="flex-1 overflow-y-auto py-4" aria-label="Hauptnavigation"> <nav className="flex-1 overflow-y-auto py-4" aria-label={t('sidebar.hauptnavigation')}>
<ul className="space-y-1 px-2"> <ul className="space-y-1 px-2">
{(() => { {(() => {
const groups = new Map<string, typeof allMenuItems>(); const groups = new Map<string, typeof allMenuItems>();
@@ -27,6 +27,7 @@ import {
Workflow, Workflow,
} from 'lucide-react'; } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
// Curated icon map — avoids `import * as LucideIcons` which loads ALL icons // Curated icon map — avoids `import * as LucideIcons` which loads ALL icons
// and causes OOM in tests (ARCH-063). // and causes OOM in tests (ARCH-063).
@@ -69,6 +70,7 @@ function getIcon(name: string): React.ReactNode {
} }
export function SortableMenuItem({ id, label, icon, isGroup }: SortableMenuItemProps) { export function SortableMenuItem({ id, label, icon, isGroup }: SortableMenuItemProps) {
const { t } = useTranslation();
const { const {
attributes, attributes,
listeners, listeners,
@@ -99,7 +101,7 @@ export function SortableMenuItem({ id, label, icon, isGroup }: SortableMenuItemP
className="cursor-grab active:cursor-grabbing text-secondary-400 hover:text-secondary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded" className="cursor-grab active:cursor-grabbing text-secondary-400 hover:text-secondary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded"
{...attributes} {...attributes}
{...listeners} {...listeners}
aria-label="Ziehen zum Sortieren" aria-label={t('sortableMenuItem.ziehenzumsortieren')}
type="button" type="button"
> >
<GripVertical className="w-4 h-4" /> <GripVertical className="w-4 h-4" />
+1 -1
View File
@@ -63,7 +63,7 @@ export function TopBar() {
<button <button
onClick={toggleSidebar} onClick={toggleSidebar}
className="p-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500" className="p-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label="Seitenleiste ein-/ausklappen" aria-label={t('topBar.seitenleisteeinausklappen')}
aria-expanded={true} aria-expanded={true}
> >
<Menu className="w-4 h-4" aria-hidden="true" strokeWidth={2} /> <Menu className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
@@ -303,7 +303,7 @@ export function ComposeModal({
label={t('mail.to')} label={t('mail.to')}
{...register('to')} {...register('to')}
error={errorMsg(errors.to?.message)} error={errorMsg(errors.to?.message)}
placeholder="recipient@example.com" placeholder={t('composeModal.recipientexamplecom')}
required required
data-testid="compose-to" data-testid="compose-to"
/> />
@@ -322,13 +322,13 @@ export function ComposeModal({
label={t('mail.cc')} label={t('mail.cc')}
{...register('cc')} {...register('cc')}
error={errorMsg(errors.cc?.message)} error={errorMsg(errors.cc?.message)}
placeholder="cc@example.com" placeholder={t('composeModal.ccexamplecom')}
/> />
<Input <Input
label={t('mail.bcc')} label={t('mail.bcc')}
{...register('bcc')} {...register('bcc')}
error={errorMsg(errors.bcc?.message)} error={errorMsg(errors.bcc?.message)}
placeholder="bcc@example.com" placeholder={t('composeModal.bccexamplecom')}
/> />
</div> </div>
)} )}
@@ -376,7 +376,7 @@ export function ComposeModal({
> >
{t('mail.addAttachment')} {t('mail.addAttachment')}
</Button> </Button>
<span className="text-xs text-secondary-400">Max 25 MB per file</span> <span className="text-xs text-secondary-400">{t('composeModal.max25mbperfile')}</span>
</div> </div>
{attachments.length > 0 && ( {attachments.length > 0 && (
<ul className="mt-2 space-y-1"> <ul className="mt-2 space-y-1">
@@ -304,7 +304,7 @@ export function MailComposeForm({
label={t('mail.to')} label={t('mail.to')}
{...register('to')} {...register('to')}
error={errorMsg(errors.to?.message)} error={errorMsg(errors.to?.message)}
placeholder="recipient@example.com" placeholder={t('mailComposeForm.recipientexamplecom')}
required required
data-testid="compose-to" data-testid="compose-to"
/> />
@@ -323,13 +323,13 @@ export function MailComposeForm({
label={t('mail.cc')} label={t('mail.cc')}
{...register('cc')} {...register('cc')}
error={errorMsg(errors.cc?.message)} error={errorMsg(errors.cc?.message)}
placeholder="cc@example.com" placeholder={t('mailComposeForm.ccexamplecom')}
/> />
<Input <Input
label={t('mail.bcc')} label={t('mail.bcc')}
{...register('bcc')} {...register('bcc')}
error={errorMsg(errors.bcc?.message)} error={errorMsg(errors.bcc?.message)}
placeholder="bcc@example.com" placeholder={t('mailComposeForm.bccexamplecom')}
/> />
</div> </div>
)} )}
@@ -377,7 +377,7 @@ export function MailComposeForm({
> >
{t('mail.addAttachment')} {t('mail.addAttachment')}
</Button> </Button>
<span className="text-xs text-secondary-400">Max 25 MB per file</span> <span className="text-xs text-secondary-400">{t('mailComposeForm.max25mbperfile')}</span>
</div> </div>
{attachments.length > 0 && ( {attachments.length > 0 && (
<ul className="mt-2 space-y-1"> <ul className="mt-2 space-y-1">
@@ -7,6 +7,7 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Filter, Plus, X, Bookmark } from 'lucide-react'; import { Filter, Plus, X, Bookmark } from 'lucide-react';
import { useTranslation } from 'react-i18next';
// ─── Field definitions ─────────────────────────────────────────────────────── // ─── Field definitions ───────────────────────────────────────────────────────
@@ -183,6 +184,7 @@ function newConditionId() {
} }
export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], onSaveFilter, onLoadFilter, onDeleteFilter }: MailFilterPanelProps) { export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], onSaveFilter, onLoadFilter, onDeleteFilter }: MailFilterPanelProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLDivElement>(null); const btnRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
@@ -272,8 +274,8 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
<div ref={btnRef} className="relative flex-shrink-0"> <div ref={btnRef} className="relative flex-shrink-0">
<button <button
onClick={handleToggle} onClick={handleToggle}
title="Filter" title={t('mailFilterPanel.filter')}
aria-label="Filter" aria-label={t('mailFilterPanel.filter')}
className={` className={`
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
transition-colors duration-100 cursor-pointer relative transition-colors duration-100 cursor-pointer relative
@@ -311,7 +313,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{activeCount > 0 && ( {activeCount > 0 && (
<button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors"> <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">
Alle löschen {t('mailFilterPanel.allelöschen')}
</button> </button>
)} )}
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600"> <button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600">
@@ -323,7 +325,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
{/* Logic toggle */} {/* Logic toggle */}
{activeCount > 0 && ( {activeCount > 0 && (
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-100"> <div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-100">
<span className="text-xs text-secondary-500">Bedingungen verknüpfen:</span> <span className="text-xs text-secondary-500">{t('mailFilterPanel.bedingungenverknüpfen')}</span>
<button onClick={toggleLogic} className={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${filters.logic === 'AND' ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'}`}>UND</button> <button onClick={toggleLogic} className={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${filters.logic === 'AND' ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'}`}>UND</button>
<button onClick={toggleLogic} className={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${filters.logic === 'OR' ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'}`}>ODER</button> <button onClick={toggleLogic} className={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${filters.logic === 'OR' ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'}`}>ODER</button>
</div> </div>
@@ -348,7 +350,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
{/* Saved filters */} {/* Saved filters */}
{(savedFilters?.length ?? 0) > 0 && ( {(savedFilters?.length ?? 0) > 0 && (
<div className="px-4 py-2 border-b border-secondary-100"> <div className="px-4 py-2 border-b border-secondary-100">
<div className="text-[10px] font-semibold uppercase tracking-wide text-secondary-400 mb-1.5">Gespeicherte Filter</div> <div className="text-[10px] font-semibold uppercase tracking-wide text-secondary-400 mb-1.5">{t('mailFilterPanel.gespeichertefilter')}</div>
<div className="space-y-0.5"> <div className="space-y-0.5">
{savedFilters.map((sf) => ( {savedFilters.map((sf) => (
<div key={sf.id} className="flex items-center gap-1 group"> <div key={sf.id} className="flex items-center gap-1 group">
@@ -369,7 +371,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
<div className="px-4 py-3 space-y-2"> <div className="px-4 py-3 space-y-2">
{(filters.conditions?.length ?? 0) === 0 && ( {(filters.conditions?.length ?? 0) === 0 && (
<div className="text-center py-6 text-xs text-secondary-400"> <div className="text-center py-6 text-xs text-secondary-400">
Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen. {t('mailFilterPanel.keinefilteraktivklickeuntenum')}
</div> </div>
)} )}
{filters.conditions.map((cond, idx) => { {filters.conditions.map((cond, idx) => {
@@ -409,13 +411,13 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
{currentOp?.needsValue ? ( {currentOp?.needsValue ? (
def?.type === 'select' ? ( def?.type === 'select' ? (
<select value={cond.value} onChange={(e) => updateCondition(cond.id, { value: e.target.value })} className="px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400"> <select value={cond.value} onChange={(e) => updateCondition(cond.id, { value: e.target.value })} className="px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400">
<option value=""> wählen </option> <option value="">{t('mailFilterPanel.wählen')}</option>
{def.options?.map((opt) => (<option key={opt.value} value={opt.value}>{opt.label}</option>))} {def.options?.map((opt) => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
</select> </select>
) : def?.type === 'date' ? ( ) : def?.type === 'date' ? (
<input type="date" value={cond.value} onChange={(e) => updateCondition(cond.id, { value: e.target.value })} className="px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400" /> <input type="date" value={cond.value} onChange={(e) => updateCondition(cond.id, { value: e.target.value })} className="px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400" />
) : ( ) : (
<input type="text" value={cond.value} onChange={(e) => updateCondition(cond.id, { value: e.target.value })} placeholder="Wert…" className="w-24 px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400" /> <input type="text" value={cond.value} onChange={(e) => updateCondition(cond.id, { value: e.target.value })} placeholder={t('mailFilterPanel.wert')} className="w-24 px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400" />
) )
) : ( ) : (
<div className="w-24" /> <div className="w-24" />
@@ -434,12 +436,12 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"> <button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors">
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
Bedingung hinzufügen {t('mailFilterPanel.bedingunghinzufügen')}
</button> </button>
{activeCount > 0 && onSaveFilter && ( {activeCount > 0 && onSaveFilter && (
<button onClick={handleSaveFilter} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"> <button onClick={handleSaveFilter} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors">
<Bookmark className="w-3.5 h-3.5" /> <Bookmark className="w-3.5 h-3.5" />
Filter speichern {t('mailFilterPanel.filterspeichern')}
</button> </button>
)} )}
</div> </div>
@@ -116,6 +116,7 @@ function ContextMenu({
onClose: () => void; onClose: () => void;
onEmptyFolder: (folderId: string, folderName: string) => void; onEmptyFolder: (folderId: string, folderName: string) => void;
}) { }) {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -136,7 +137,7 @@ function ContextMenu({
onClick={() => { onEmptyFolder(state.folderId, state.folderName); onClose(); }} onClick={() => { onEmptyFolder(state.folderId, state.folderName); onClose(); }}
className="w-full text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-50" className="w-full text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-50"
> >
Ordner leeren {t('mailFolderTree.ordnerleeren')}
</button> </button>
</div> </div>
); );
@@ -7,6 +7,7 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Group as GroupIcon, Plus, X } from 'lucide-react'; import { Group as GroupIcon, Plus, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type FieldType = 'text' | 'number' | 'date'; type FieldType = 'text' | 'number' | 'date';
@@ -114,6 +115,7 @@ interface MailGroupPanelProps {
} }
export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProps) { export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLDivElement>(null); const btnRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
@@ -168,8 +170,8 @@ export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProp
<div ref={btnRef} className="relative flex-shrink-0"> <div ref={btnRef} className="relative flex-shrink-0">
<button <button
onClick={handleToggle} onClick={handleToggle}
title="Gruppierung" title={t('mailGroupPanel.gruppierung')}
aria-label="Gruppierung" aria-label={t('mailGroupPanel.gruppierung')}
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors duration-100 cursor-pointer relative ${open || activeCount > 0 ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}`} className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors duration-100 cursor-pointer relative ${open || activeCount > 0 ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}`}
> >
<GroupIcon className="w-3.5 h-3.5" strokeWidth={2} /> <GroupIcon className="w-3.5 h-3.5" strokeWidth={2} />
@@ -184,12 +186,12 @@ export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProp
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-100"> <div className="flex items-center justify-between px-4 py-3 border-b border-secondary-100">
<span className="text-sm font-semibold text-secondary-800">Gruppierung</span> <span className="text-sm font-semibold text-secondary-800">Gruppierung</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{activeCount > 0 && <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">Alle löschen</button>} {activeCount > 0 && <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">{t('mailGroupPanel.allelöschen')}</button>}
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600"><X className="w-3.5 h-3.5" /></button> <button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600"><X className="w-3.5 h-3.5" /></button>
</div> </div>
</div> </div>
<div className="px-4 py-3 space-y-2"> <div className="px-4 py-3 space-y-2">
{groupState.conditions.length === 0 && <div className="text-center py-6 text-xs text-secondary-400">Keine Gruppierung aktiv. Klicke unten um ein Feld hinzuzufügen.</div>} {groupState.conditions.length === 0 && <div className="text-center py-6 text-xs text-secondary-400">{t('mailGroupPanel.keinegruppierungaktivklickeuntenum')}</div>}
{groupState.conditions.map((cond, idx) => ( {groupState.conditions.map((cond, idx) => (
<div key={cond.id} className="flex items-center gap-1.5"> <div key={cond.id} className="flex items-center gap-1.5">
<span className="text-[10px] font-bold text-secondary-400 w-6 text-center">{idx + 1}.</span> <span className="text-[10px] font-bold text-secondary-400 w-6 text-center">{idx + 1}.</span>
@@ -203,7 +205,7 @@ export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProp
))} ))}
</div> </div>
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-100"> <div className="flex items-center justify-between px-4 py-3 border-t border-secondary-100">
<button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"><Plus className="w-3.5 h-3.5" />Feld hinzufügen</button> <button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"><Plus className="w-3.5 h-3.5" />{t('mailGroupPanel.feldhinzufügen')}</button>
<button onClick={() => setOpen(false)} className="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors">Fertig</button> <button onClick={() => setOpen(false)} className="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors">Fertig</button>
</div> </div>
</div> </div>
@@ -7,6 +7,7 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { ArrowUpDown, Plus, X, ChevronUp, ChevronDown } from 'lucide-react'; import { ArrowUpDown, Plus, X, ChevronUp, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type FieldType = 'text' | 'number' | 'date'; type FieldType = 'text' | 'number' | 'date';
@@ -86,6 +87,7 @@ interface MailSortPanelProps {
} }
export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) { export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLDivElement>(null); const btnRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
@@ -144,8 +146,8 @@ export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
<div ref={btnRef} className="relative flex-shrink-0"> <div ref={btnRef} className="relative flex-shrink-0">
<button <button
onClick={handleToggle} onClick={handleToggle}
title="Sortieren" title={t('mailSortPanel.sortieren')}
aria-label="Sortieren" aria-label={t('mailSortPanel.sortieren')}
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors duration-100 cursor-pointer relative ${open || activeCount > 0 ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}`} className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors duration-100 cursor-pointer relative ${open || activeCount > 0 ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}`}
> >
<ArrowUpDown className="w-3.5 h-3.5" strokeWidth={2} /> <ArrowUpDown className="w-3.5 h-3.5" strokeWidth={2} />
@@ -160,12 +162,12 @@ export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-100"> <div className="flex items-center justify-between px-4 py-3 border-b border-secondary-100">
<span className="text-sm font-semibold text-secondary-800">Sortieren</span> <span className="text-sm font-semibold text-secondary-800">Sortieren</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{activeCount > 0 && <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">Alle löschen</button>} {activeCount > 0 && <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">{t('mailSortPanel.allelöschen')}</button>}
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600"><X className="w-3.5 h-3.5" /></button> <button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600"><X className="w-3.5 h-3.5" /></button>
</div> </div>
</div> </div>
<div className="px-4 py-3 space-y-2"> <div className="px-4 py-3 space-y-2">
{sortState.conditions.length === 0 && <div className="text-center py-6 text-xs text-secondary-400">Keine Sortierung aktiv. Klicke unten um ein Feld hinzuzufügen.</div>} {sortState.conditions.length === 0 && <div className="text-center py-6 text-xs text-secondary-400">{t('mailSortPanel.keinesortierungaktivklickeuntenum')}</div>}
{sortState.conditions.map((cond, idx) => ( {sortState.conditions.map((cond, idx) => (
<div key={cond.id} className="flex items-center gap-1.5"> <div key={cond.id} className="flex items-center gap-1.5">
<span className="text-[10px] font-bold text-secondary-400 w-6 text-center">{idx + 1}.</span> <span className="text-[10px] font-bold text-secondary-400 w-6 text-center">{idx + 1}.</span>
@@ -182,7 +184,7 @@ export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
))} ))}
</div> </div>
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-100"> <div className="flex items-center justify-between px-4 py-3 border-t border-secondary-100">
<button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"><Plus className="w-3.5 h-3.5" />Feld hinzufügen</button> <button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"><Plus className="w-3.5 h-3.5" />{t('mailSortPanel.feldhinzufügen')}</button>
<button onClick={() => setOpen(false)} className="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors">Fertig</button> <button onClick={() => setOpen(false)} className="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors">Fertig</button>
</div> </div>
</div> </div>
+5 -5
View File
@@ -108,7 +108,7 @@ export function PgpSettings() {
value={privateKey} value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)} onChange={(e) => setPrivateKey(e.target.value)}
className="w-full min-h-32 border border-secondary-300 rounded-md p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500" className="w-full min-h-32 border border-secondary-300 rounded-md p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK-----" placeholder={t('pgpSettings.beginpgpprivatekeyblock')}
data-testid="pgp-private-key" data-testid="pgp-private-key"
/> />
</div> </div>
@@ -135,7 +135,7 @@ export function PgpSettings() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-secondary-900">{key.user_id}</p> <p className="text-sm font-medium text-secondary-900">{key.user_id}</p>
<p className="text-xs text-secondary-500">Key ID: {key.key_id}</p> <p className="text-xs text-secondary-500">{t('pgpSettings.keyid')} {key.key_id}</p>
<p className="text-xs text-secondary-400">Fingerprint: {key.fingerprint}</p> <p className="text-xs text-secondary-400">Fingerprint: {key.fingerprint}</p>
</div> </div>
<span className="text-xs text-secondary-400">{key.is_private ? t('mail.privateKeyLabel') : t('mail.publicKey')}</span> <span className="text-xs text-secondary-400">{key.is_private ? t('mail.privateKeyLabel') : t('mail.publicKey')}</span>
@@ -152,7 +152,7 @@ export function PgpSettings() {
label={t('mail.contactId')} label={t('mail.contactId')}
value={contactId} value={contactId}
onChange={(e) => setContactId(e.target.value)} onChange={(e) => setContactId(e.target.value)}
placeholder="contact-uuid" placeholder={t('pgpSettings.contactuuid')}
/> />
<div> <div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.publicKey')}</label> <label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.publicKey')}</label>
@@ -160,7 +160,7 @@ export function PgpSettings() {
value={contactPublicKey} value={contactPublicKey}
onChange={(e) => setContactPublicKey(e.target.value)} onChange={(e) => setContactPublicKey(e.target.value)}
className="w-full min-h-24 border border-secondary-300 rounded-md p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500" className="w-full min-h-24 border border-secondary-300 rounded-md p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----" placeholder={t('pgpSettings.beginpgppublickeyblock')}
data-testid="contact-public-key" data-testid="contact-public-key"
/> />
</div> </div>
@@ -175,7 +175,7 @@ export function PgpSettings() {
{contactKeys.map((ck) => ( {contactKeys.map((ck) => (
<li key={ck.contact_id} className="p-3 rounded-md border border-secondary-200"> <li key={ck.contact_id} className="p-3 rounded-md border border-secondary-200">
<p className="text-sm font-medium text-secondary-900">{ck.contact_name}</p> <p className="text-sm font-medium text-secondary-900">{ck.contact_name}</p>
<p className="text-xs text-secondary-500">Key ID: {ck.key_id}</p> <p className="text-xs text-secondary-500">{t('pgpSettings.keyid')} {ck.key_id}</p>
<p className="text-xs text-secondary-400">Fingerprint: {ck.fingerprint}</p> <p className="text-xs text-secondary-400">Fingerprint: {ck.fingerprint}</p>
</li> </li>
))} ))}
@@ -195,13 +195,13 @@ export function RichTextEditor({ content, onChange, placeholder, editable = true
<div className="w-px h-6 bg-secondary-200 mx-1" /> <div className="w-px h-6 bg-secondary-200 mx-1" />
{/* Color */} {/* Color */}
<label className="p-1.5 rounded hover:bg-secondary-100 cursor-pointer" title="Text color"> <label className="p-1.5 rounded hover:bg-secondary-100 cursor-pointer" title={t('richTextEditor.textcolor')}>
<Heading className="w-4 h-4" strokeWidth={2} /> <Heading className="w-4 h-4" strokeWidth={2} />
<input <input
type="color" type="color"
className="sr-only" className="sr-only"
onChange={(e) => editor.chain().focus().setColor(e.target.value).run()} onChange={(e) => editor.chain().focus().setColor(e.target.value).run()}
title="Text color" title={t('richTextEditor.textcolor')}
/> />
</label> </label>
@@ -175,7 +175,7 @@ export function SignatureManager() {
<RichTextEditor <RichTextEditor
content={bodyHtmlValue} content={bodyHtmlValue}
onChange={(html: string) => setSigValue('body_html', html)} onChange={(html: string) => setSigValue('body_html', html)}
placeholder="<p>Mit freundlichen Grüßen,<br>{{user.name}}</p>" placeholder={t('signatureManager.pmitfreundlichengrüßenbruser')}
/> />
</div> </div>
<label className="flex items-center gap-2 text-sm text-secondary-700"> <label className="flex items-center gap-2 text-sm text-secondary-700">
@@ -20,6 +20,7 @@ import {
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import type { NotificationItem as NotificationItemType } from '@/api/notifications'; import type { NotificationItem as NotificationItemType } from '@/api/notifications';
import { useTranslation } from 'react-i18next';
// ── helpers ── // ── helpers ──
@@ -78,6 +79,7 @@ export interface NotificationItemProps {
} }
export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) { export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) {
const { t } = useTranslation();
const isUnread = notification.read_at == null; const isUnread = notification.read_at == null;
const Icon = getIconForType(notification.type); const Icon = getIconForType(notification.type);
@@ -123,8 +125,8 @@ export function NotificationItem({ notification, onMarkRead }: NotificationItemP
{isUnread && ( {isUnread && (
<span <span
className="flex-shrink-0 w-2 h-2 rounded-full bg-primary-500" className="flex-shrink-0 w-2 h-2 rounded-full bg-primary-500"
aria-label="ungelesen" aria-label={t('notificationItem.ungelesen')}
title="ungelesen" title={t('notificationItem.ungelesen')}
/> />
)} )}
</div> </div>
@@ -4,6 +4,7 @@ import { Loader2 } from 'lucide-react';
import { usePluginStore } from '@/store/pluginStore'; import { usePluginStore } from '@/store/pluginStore';
import { ProtectedRoute } from '@/components/common/ProtectedRoute'; import { ProtectedRoute } from '@/components/common/ProtectedRoute';
import { PluginPage } from './PluginLoader'; import { PluginPage } from './PluginLoader';
import { useTranslation } from 'react-i18next';
/** /**
* PluginRouteRenderer — catch-all route handler that checks the current URL * PluginRouteRenderer — catch-all route handler that checks the current URL
@@ -17,6 +18,7 @@ import { PluginPage } from './PluginLoader';
* { path: '*', element: <PluginRouteRenderer /> } * { path: '*', element: <PluginRouteRenderer /> }
*/ */
export function PluginRouteRenderer() { export function PluginRouteRenderer() {
const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const manifests = usePluginStore((s) => s.manifests); const manifests = usePluginStore((s) => s.manifests);
const loaded = usePluginStore((s) => s.loaded); const loaded = usePluginStore((s) => s.loaded);
@@ -63,7 +65,7 @@ export function PluginRouteRenderer() {
// If manifests haven't loaded yet, show a spinner (not null/blank) // If manifests haven't loaded yet, show a spinner (not null/blank)
if (!loaded) { if (!loaded) {
return ( return (
<div className="flex items-center justify-center min-h-[50vh]" role="status" aria-label="Loading"> <div className="flex items-center justify-center min-h-[50vh]" role="status" aria-label={t('pluginRouteRenderer.loading')}>
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" /> <Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
</div> </div>
); );
@@ -72,9 +74,9 @@ export function PluginRouteRenderer() {
// No plugin route matched — show a simple not-found // No plugin route matched — show a simple not-found
return ( return (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-secondary-500"> <div className="flex flex-col items-center justify-center min-h-[50vh] text-secondary-500">
<h2 className="text-2xl font-semibold mb-2">Page Not Found</h2> <h2 className="text-2xl font-semibold mb-2">{t('pluginRouteRenderer.pagenotfound')}</h2>
<p className="text-sm"> <p className="text-sm">
The page <code className="bg-secondary-100 px-1 rounded">{location.pathname}</code> was not found. {t('pluginRouteRenderer.thepage')} <code className="bg-secondary-100 px-1 rounded">{location.pathname}</code> {t('pluginRouteRenderer.wasnotfound')}
</p> </p>
</div> </div>
); );
@@ -111,21 +111,21 @@ export function WorkspaceManager() {
<h3 className="font-medium">{t('workspaces.createNew', 'Neuen Workspace erstellen')}</h3> <h3 className="font-medium">{t('workspaces.createNew', 'Neuen Workspace erstellen')}</h3>
<input <input
type="text" type="text"
placeholder="Name" placeholder={t('workspaceManager.name')}
value={editName} value={editName}
onChange={e => setEditName(e.target.value)} onChange={e => setEditName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-sm" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-sm"
/> />
<input <input
type="text" type="text"
placeholder="Beschreibung" placeholder={t('workspaceManager.beschreibung')}
value={editDesc} value={editDesc}
onChange={e => setEditDesc(e.target.value)} onChange={e => setEditDesc(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-sm" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-sm"
/> />
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={editDefault} onChange={e => setEditDefault(e.target.checked)} /> <input type="checkbox" checked={editDefault} onChange={e => setEditDefault(e.target.checked)} />
Als Standard-Workspace {t('workspaceManager.alsstandardworkspace')}
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={handleCreate} disabled={!editName} className="px-3 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium disabled:opacity-50"> <button onClick={handleCreate} disabled={!editName} className="px-3 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium disabled:opacity-50">
@@ -169,7 +169,7 @@ export function WorkspaceManager() {
<button <button
onClick={(e) => { e.preventDefault(); setModuleConfig(prev => prev.map(x => x.module_key === m.module_key ? { ...x, config: x.config } : x)); setConfigEditingKey(configEditingKey === m.module_key ? null : m.module_key); }} onClick={(e) => { e.preventDefault(); setModuleConfig(prev => prev.map(x => x.module_key === m.module_key ? { ...x, config: x.config } : x)); setConfigEditingKey(configEditingKey === m.module_key ? null : m.module_key); }}
className="text-xs px-1.5 py-0.5 border rounded hover:bg-gray-100 dark:hover:bg-gray-700" className="text-xs px-1.5 py-0.5 border rounded hover:bg-gray-100 dark:hover:bg-gray-700"
title="Konfiguration bearbeiten" title={t('workspaceManager.konfigurationbearbeiten')}
> >
</button> </button>
@@ -179,7 +179,7 @@ export function WorkspaceManager() {
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
<textarea <textarea
className="w-full text-xs font-mono p-1.5 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-900 h-20" className="w-full text-xs font-mono p-1.5 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-900 h-20"
placeholder='{"visible_folder_ids": []}' placeholder={t('workspaceManager.visiblefolderids')}
value={JSON.stringify(m.config || {}, null, 2)} value={JSON.stringify(m.config || {}, null, 2)}
onChange={(e) => { onChange={(e) => {
try { try {
@@ -190,7 +190,7 @@ export function WorkspaceManager() {
} }
}} }}
/> />
<p className="text-xs text-gray-400">JSON-Konfiguration für dieses Modul (z.B. sichtbare Ordner-IDs)</p> <p className="text-xs text-gray-400">{t('workspaceManager.jsonkonfigurationfürdiesesmodulz')}</p>
</div> </div>
)} )}
</div> </div>
@@ -211,7 +211,7 @@ export function WorkspaceManager() {
{editingId === ws.id ? ( {editingId === ws.id ? (
<div className="space-y-2"> <div className="space-y-2">
<input type="text" value={editName} onChange={e => setEditName(e.target.value)} className="w-full px-3 py-2 border rounded-md text-sm" /> <input type="text" value={editName} onChange={e => setEditName(e.target.value)} className="w-full px-3 py-2 border rounded-md text-sm" />
<input type="text" value={editDesc} onChange={e => setEditDesc(e.target.value)} className="w-full px-3 py-2 border rounded-md text-sm" placeholder="Beschreibung" /> <input type="text" value={editDesc} onChange={e => setEditDesc(e.target.value)} className="w-full px-3 py-2 border rounded-md text-sm" placeholder={t('workspaceManager.beschreibung')} />
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={editDefault} onChange={e => setEditDefault(e.target.checked)} /> <input type="checkbox" checked={editDefault} onChange={e => setEditDefault(e.target.checked)} />
Standard Standard
@@ -246,21 +246,21 @@ export function WorkspaceManager() {
<button <button
onClick={() => openModuleEditor(ws)} onClick={() => openModuleEditor(ws)}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md text-sm" className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md text-sm"
title="Module" title={t('workspaceManager.module')}
> >
<LayoutGrid className="w-4 h-4" /> <LayoutGrid className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => { setEditingId(ws.id); setEditName(ws.name); setEditDesc(ws.description || ''); setEditDefault(ws.is_default); }} onClick={() => { setEditingId(ws.id); setEditName(ws.name); setEditDesc(ws.description || ''); setEditDefault(ws.is_default); }}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md text-sm" className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md text-sm"
title="Bearbeiten" title={t('workspaceManager.bearbeiten')}
> >
<Edit className="w-4 h-4" /> <Edit className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => handleDelete(ws.id)} onClick={() => handleDelete(ws.id)}
className="p-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md text-sm text-red-600" className="p-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md text-sm text-red-600"
title="Löschen" title={t('workspaceManager.löschen')}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
import { useTranslation } from 'react-i18next';
export interface ActivityItem { export interface ActivityItem {
id: string; id: string;
@@ -17,11 +18,12 @@ export interface ActivityFeedProps {
} }
export function ActivityFeed({ activities, title = 'Letzte Aktivitäten', maxItems = 10 }: ActivityFeedProps) { export function ActivityFeed({ activities, title = 'Letzte Aktivitäten', maxItems = 10 }: ActivityFeedProps) {
const { t } = useTranslation();
const visible = activities.slice(0, maxItems); const visible = activities.slice(0, maxItems);
return ( return (
<Card title={title} data-testid="activity-feed"> <Card title={title} data-testid="activity-feed">
{visible.length === 0 ? ( {visible.length === 0 ? (
<p className="text-sm text-secondary-500 py-4 text-center">Keine Aktivitäten vorhanden.</p> <p className="text-sm text-secondary-500 py-4 text-center">{t('activityFeed.keineaktivitätenvorhanden')}</p>
) : ( ) : (
<ul className="space-y-3" role="list"> <ul className="space-y-3" role="list">
{visible.map((activity) => ( {visible.map((activity) => (
+1 -1
View File
@@ -112,7 +112,7 @@ export function DataGrid<T extends Record<string, any>>({
ref={scrollRef} ref={scrollRef}
className="overflow-x-auto" className="overflow-x-auto"
role="region" role="region"
aria-label="Data grid" aria-label={t('dataGrid.datagrid')}
style={shouldVirtualize ? { maxHeight: '70vh', overflowY: 'auto' } : undefined} style={shouldVirtualize ? { maxHeight: '70vh', overflowY: 'auto' } : undefined}
> >
<table className="min-w-full divide-y divide-secondary-200"> <table className="min-w-full divide-y divide-secondary-200">
@@ -143,7 +143,7 @@ export function SearchDropdown({ placeholder }: SearchDropdownProps) {
onClick={handleSeeAll} onClick={handleSeeAll}
className="text-sm text-primary-600 hover:text-primary-700 font-medium min-h-touch" className="text-sm text-primary-600 hover:text-primary-700 font-medium min-h-touch"
> >
Alle Ergebnisse anzeigen {t('searchDropdown.alleergebnisseanzeigen')}
</button> </button>
</div> </div>
</> </>
+6 -4
View File
@@ -10,12 +10,14 @@ import React from 'react';
import { Users } from 'lucide-react'; import { Users } from 'lucide-react';
import { useUsers, useGroups } from '@/api/hooks'; import { useUsers, useGroups } from '@/api/hooks';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
import { useTranslation } from 'react-i18next';
interface SharedTeamPanelProps { interface SharedTeamPanelProps {
onStartDirectChat?: (userId: string, userName: string) => void; onStartDirectChat?: (userId: string, userName: string) => void;
} }
export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) { export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
const { t } = useTranslation();
const { data: usersData, isLoading: usersLoading } = useUsers(); const { data: usersData, isLoading: usersLoading } = useUsers();
const { data: groupsData, isLoading: groupsLoading } = useGroups(); const { data: groupsData, isLoading: groupsLoading } = useGroups();
const users: any[] = usersData?.items || []; const users: any[] = usersData?.items || [];
@@ -28,7 +30,7 @@ export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
{usersLoading ? ( {usersLoading ? (
<p className="text-sm text-secondary-400">Laden...</p> <p className="text-sm text-secondary-400">Laden...</p>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<p className="text-sm text-secondary-400">Keine Mitarbeiter</p> <p className="text-sm text-secondary-400">{t('teamPanel.keinemitarbeiter')}</p>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{users.map((u) => {users.map((u) =>
@@ -40,7 +42,7 @@ export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
> >
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
<Avatar name={u.name} size="sm" /> <Avatar name={u.name} size="sm" />
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-white bg-secondary-300" aria-label="offline" /> <span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-white bg-secondary-300" aria-label={t('teamPanel.offline')} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-secondary-700 truncate">{u.name}</p> <p className="text-sm font-medium text-secondary-700 truncate">{u.name}</p>
@@ -52,7 +54,7 @@ export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
<div key={u.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary-50 transition-colors"> <div key={u.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary-50 transition-colors">
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
<Avatar name={u.name} size="sm" /> <Avatar name={u.name} size="sm" />
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-white bg-secondary-300" aria-label="offline" /> <span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-white bg-secondary-300" aria-label={t('teamPanel.offline')} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-secondary-700 truncate">{u.name}</p> <p className="text-sm font-medium text-secondary-700 truncate">{u.name}</p>
@@ -70,7 +72,7 @@ export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
{groupsLoading ? ( {groupsLoading ? (
<p className="text-sm text-secondary-400">Laden...</p> <p className="text-sm text-secondary-400">Laden...</p>
) : groups.length === 0 ? ( ) : groups.length === 0 ? (
<p className="text-sm text-secondary-400">Keine Gruppen</p> <p className="text-sm text-secondary-400">{t('teamPanel.keinegruppen')}</p>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{groups.map((g) => ( {groups.map((g) => (
+1 -1
View File
@@ -195,7 +195,7 @@ export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
id="assignee-id" id="assignee-id"
value={assigneeId} value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)} onChange={(e) => setAssigneeId(e.target.value)}
placeholder="UUID" placeholder={t('taskDetail.uuid')}
className="w-64" className="w-64"
/> />
</div> </div>
+3 -1
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export interface ModalProps { export interface ModalProps {
open: boolean; open: boolean;
@@ -32,6 +33,7 @@ export function Modal({
showCloseButton = true, showCloseButton = true,
fullScreenMobile = false, fullScreenMobile = false,
}: ModalProps) { }: ModalProps) {
const { t } = useTranslation();
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null); const previouslyFocused = useRef<HTMLElement | null>(null);
@@ -98,7 +100,7 @@ export function Modal({
<button <button
onClick={onClose} onClick={onClose}
className="absolute top-3 right-3 md:top-4 md:right-4 text-secondary-400 hover:text-secondary-600 min-h-touch min-w-touch flex items-center justify-center rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500" className="absolute top-3 right-3 md:top-4 md:right-4 text-secondary-400 hover:text-secondary-600 min-h-touch min-w-touch flex items-center justify-center rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label="Close dialog" aria-label={t('modal.closedialog')}
> >
<X className="h-5 w-5" aria-hidden="true" /> <X className="h-5 w-5" aria-hidden="true" />
</button> </button>
+1 -1
View File
@@ -31,7 +31,7 @@ export function Pagination({ currentPage, totalPages, total, pageSize, onPageCha
if (totalPages <= 1) return null; if (totalPages <= 1) return null;
return ( return (
<nav className="flex items-center justify-between px-3 py-1.5 border-t border-secondary-200 bg-white" aria-label="Pagination"> <nav className="flex items-center justify-between px-3 py-1.5 border-t border-secondary-200 bg-white" aria-label={t('pagination.pagination')}>
<div className="text-xs text-secondary-500"> <div className="text-xs text-secondary-500">
<span>{start}{end}</span> <span>{t('table.of')}</span> <span>{total}</span> <span>{start}{end}</span> <span>{t('table.of')}</span> <span>{total}</span>
</div> </div>
+5 -2
View File
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
export interface SkeletonProps { export interface SkeletonProps {
className?: string; className?: string;
@@ -9,6 +10,7 @@ export interface SkeletonProps {
} }
export function Skeleton({ className, variant = 'rect', width, height }: SkeletonProps) { export function Skeleton({ className, variant = 'rect', width, height }: SkeletonProps) {
const { t } = useTranslation();
const variantClass = { const variantClass = {
text: 'rounded', text: 'rounded',
rect: 'rounded-md', rect: 'rounded-md',
@@ -24,14 +26,15 @@ export function Skeleton({ className, variant = 'rect', width, height }: Skeleto
)} )}
style={{ width, height }} style={{ width, height }}
role="status" role="status"
aria-label="Wird geladen" aria-label={t('skeleton.wirdgeladen')}
/> />
); );
} }
export function SkeletonText({ lines = 3, className }: { lines?: number; className?: string }) { export function SkeletonText({ lines = 3, className }: { lines?: number; className?: string }) {
const { t } = useTranslation();
return ( return (
<div className={clsx('space-y-2', className)} role="status" aria-label="Wird geladen"> <div className={clsx('space-y-2', className)} role="status" aria-label={t('skeleton.wirdgeladen')}>
{Array.from({ length: lines }).map((_, i) => ( {Array.from({ length: lines }).map((_, i) => (
<Skeleton <Skeleton
key={i} key={i}
+4 -2
View File
@@ -1,6 +1,7 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export interface TableColumn<T> { export interface TableColumn<T> {
key: string; key: string;
@@ -30,6 +31,7 @@ export function Table<T extends Record<string, any>>({
emptyMessage = 'Keine Daten vorhanden', emptyMessage = 'Keine Daten vorhanden',
loading = false, loading = false,
}: TableProps<T>) { }: TableProps<T>) {
const { t } = useTranslation();
const [sortColumn, setSortColumn] = useState<string | null>(null); const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>('asc'); const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
@@ -59,7 +61,7 @@ export function Table<T extends Record<string, any>>({
}; };
return ( return (
<div className="overflow-x-auto" role="region" aria-label="Data table"> <div className="overflow-x-auto" role="region" aria-label={t('table.datatable')}>
<table className="min-w-full divide-y divide-secondary-200"> <table className="min-w-full divide-y divide-secondary-200">
<thead> <thead>
<tr> <tr>
@@ -102,7 +104,7 @@ export function Table<T extends Record<string, any>>({
<td colSpan={columns.length} className="px-6 py-8 text-center text-secondary-500"> <td colSpan={columns.length} className="px-6 py-8 text-center text-secondary-500">
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<Loader2 className="animate-spin motion-reduce:animate-none h-5 w-5" aria-hidden="true" /> <Loader2 className="animate-spin motion-reduce:animate-none h-5 w-5" aria-hidden="true" />
Wird geladen... {t('table.wirdgeladen')}
</span> </span>
</td> </td>
</tr> </tr>
+3 -1
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useCallback } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { Check, X, AlertTriangle, Info } from 'lucide-react'; import { Check, X, AlertTriangle, Info } from 'lucide-react';
import { useUIStore, Toast as ToastType } from '@/store/uiStore'; import { useUIStore, Toast as ToastType } from '@/store/uiStore';
import { useTranslation } from 'react-i18next';
const toastStyles: Record<ToastType['type'], string> = { const toastStyles: Record<ToastType['type'], string> = {
success: 'bg-success-50 border-success-500 text-success-800', success: 'bg-success-50 border-success-500 text-success-800',
@@ -18,6 +19,7 @@ const toastIcons: Record<ToastType['type'], React.ComponentType<{ className?: st
}; };
function ToastItem({ toast, onRemove }: { toast: ToastType; onRemove: (id: string) => void }) { function ToastItem({ toast, onRemove }: { toast: ToastType; onRemove: (id: string) => void }) {
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const duration = toast.duration ?? 5000; const duration = toast.duration ?? 5000;
const timer = setTimeout(() => onRemove(toast.id), duration); const timer = setTimeout(() => onRemove(toast.id), duration);
@@ -42,7 +44,7 @@ function ToastItem({ toast, onRemove }: { toast: ToastType; onRemove: (id: strin
<button <button
onClick={() => onRemove(toast.id)} onClick={() => onRemove(toast.id)}
className="flex-shrink-0 text-current opacity-60 hover:opacity-100 min-h-touch min-w-touch flex items-center justify-center rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-current" className="flex-shrink-0 text-current opacity-60 hover:opacity-100 min-h-touch min-w-touch flex items-center justify-center rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-current"
aria-label="Close notification" aria-label={t('toast.closenotification')}
> >
<X className="h-4 w-4" aria-hidden="true" /> <X className="h-4 w-4" aria-hidden="true" />
</button> </button>
@@ -3,6 +3,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Sparkles, Send } from 'lucide-react'; import { Sparkles, Send } from 'lucide-react';
import { streamChat, fetchMessages } from '@/api/ai'; import { streamChat, fetchMessages } from '@/api/ai';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
import { useTranslation } from 'react-i18next';
interface AiChatPanelProps { interface AiChatPanelProps {
windowTitle: string; windowTitle: string;
@@ -16,6 +17,7 @@ interface SimpleMessage {
} }
export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) { export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
const { t } = useTranslation();
const [messages, setMessages] = useState<SimpleMessage[]>([]); const [messages, setMessages] = useState<SimpleMessage[]>([]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
@@ -118,10 +120,10 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
<div className="flex flex-col h-full"> <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"> <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" /> <Sparkles className="w-4 h-4 text-primary-500" />
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span> <span className="text-sm font-semibold text-secondary-700">{t('aiChatPanel.kiassistent')}</span>
</div> </div>
<div className="flex-1 flex items-center justify-center text-sm text-secondary-400"> <div className="flex-1 flex items-center justify-center text-sm text-secondary-400">
Verbinde mit KI... {t('aiChatPanel.verbindemitki')}
</div> </div>
</div> </div>
); );
@@ -132,7 +134,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
<div className="flex flex-col h-full"> <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"> <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" /> <Sparkles className="w-4 h-4 text-primary-500" />
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span> <span className="text-sm font-semibold text-secondary-700">{t('aiChatPanel.kiassistent')}</span>
</div> </div>
<div className="flex-1 flex items-center justify-center p-4 text-center"> <div className="flex-1 flex items-center justify-center p-4 text-center">
<div> <div>
@@ -149,7 +151,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
{/* Header */} {/* Header */}
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2"> <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" /> <Sparkles className="w-4 h-4 text-primary-500" />
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span> <span className="text-sm font-semibold text-secondary-700">{t('aiChatPanel.kiassistent')}</span>
</div> </div>
{/* Context info */} {/* Context info */}
@@ -161,7 +163,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-2"> <div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-2">
{messages.length === 0 && !streamingContent && ( {messages.length === 0 && !streamingContent && (
<p className="text-sm text-secondary-400 text-center mt-4"> <p className="text-sm text-secondary-400 text-center mt-4">
Stelle eine Frage zum aktuellen Fenster... {t('aiChatPanel.stelleeinefragezumaktuellenfenster')}
</p> </p>
)} )}
{messages.map((msg) => ( {messages.map((msg) => (
@@ -193,7 +195,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Nachricht eingeben..." placeholder={t('aiChatPanel.nachrichteingeben')}
rows={1} 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" 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} disabled={isStreaming}
@@ -202,7 +204,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
onClick={handleSend} onClick={handleSend}
disabled={!input.trim() || isStreaming} 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" className="p-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Senden" aria-label={t('aiChatPanel.senden')}
> >
<Send className="w-4 h-4" /> <Send className="w-4 h-4" />
</button> </button>
+8 -6
View File
@@ -3,12 +3,14 @@ import clsx from 'clsx';
import { Sparkles, Maximize2, Minimize2, Minus, X } from 'lucide-react'; import { Sparkles, Maximize2, Minimize2, Minus, X } from 'lucide-react';
import { useWindowStore, type WindowState } from '@/store/windowStore'; import { useWindowStore, type WindowState } from '@/store/windowStore';
import { AiChatPanel } from './AiChatPanel'; import { AiChatPanel } from './AiChatPanel';
import { useTranslation } from 'react-i18next';
interface WindowProps { interface WindowProps {
window: WindowState; window: WindowState;
} }
export function Window({ window: win }: WindowProps) { export function Window({ window: win }: WindowProps) {
const { t } = useTranslation();
const { const {
closeWindow, closeWindow,
minimizeWindow, minimizeWindow,
@@ -106,8 +108,8 @@ export function Window({ window: win }: WindowProps) {
'p-1.5 rounded hover:bg-secondary-200', 'p-1.5 rounded hover:bg-secondary-200',
win.aiChatVisible && 'bg-primary-100 text-primary-600' win.aiChatVisible && 'bg-primary-100 text-primary-600'
)} )}
aria-label="KI Chat ein/aus" aria-label={t('window.kichateinaus')}
title="KI Chat" title={t('window.kichat')}
> >
<Sparkles className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
</button> </button>
@@ -128,8 +130,8 @@ export function Window({ window: win }: WindowProps) {
<button <button
onClick={() => minimizeWindow(win.id)} onClick={() => minimizeWindow(win.id)}
className="p-1.5 rounded hover:bg-secondary-200" className="p-1.5 rounded hover:bg-secondary-200"
aria-label="Minimieren" aria-label={t('window.minimieren')}
title="Minimieren" title={t('window.minimieren')}
> >
<Minus className="w-4 h-4" /> <Minus className="w-4 h-4" />
</button> </button>
@@ -137,8 +139,8 @@ export function Window({ window: win }: WindowProps) {
<button <button
onClick={() => closeWindow(win.id)} onClick={() => closeWindow(win.id)}
className="p-1.5 rounded hover:bg-danger-100 hover:text-danger-600" className="p-1.5 rounded hover:bg-danger-100 hover:text-danger-600"
aria-label="Schließen" aria-label={t('window.schließen')}
title="Schließen" title={t('window.schließen')}
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@@ -3,6 +3,7 @@ import { Select } from '@/components/ui/Select';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Code2, FormInput } from 'lucide-react'; import { Code2, FormInput } from 'lucide-react';
import type { WorkflowStep, WorkflowStepType } from '@/api/workflows'; import type { WorkflowStep, WorkflowStepType } from '@/api/workflows';
import { useTranslation } from 'react-i18next';
const stepTypeOptions: { value: WorkflowStepType; label: string }[] = [ const stepTypeOptions: { value: WorkflowStepType; label: string }[] = [
{ value: 'action', label: 'Action' }, { value: 'action', label: 'Action' },
@@ -53,6 +54,7 @@ export interface StepConfigPanelProps {
} }
export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) { export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
const { t } = useTranslation();
const [mode, setMode] = useState<ConfigMode>('form'); const [mode, setMode] = useState<ConfigMode>('form');
const [configText, setConfigText] = useState(''); const [configText, setConfigText] = useState('');
const [configError, setConfigError] = useState<string | undefined>(undefined); const [configError, setConfigError] = useState<string | undefined>(undefined);
@@ -111,6 +113,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
}; };
const renderTypeForm = () => { const renderTypeForm = () => {
const { t } = useTranslation();
switch (step.type) { switch (step.type) {
case 'wait': case 'wait':
return ( return (
@@ -126,13 +129,13 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
e.target.value === '' ? undefined : Number(e.target.value) e.target.value === '' ? undefined : Number(e.target.value)
) )
} }
placeholder="z.B. 3600" placeholder={t('stepConfigPanel.zb3600')}
/> />
<Input <Input
label="Resume-Zeitpunkt (ISO)" label="Resume-Zeitpunkt (ISO)"
value={strVal('resume_at')} value={strVal('resume_at')}
onChange={(e) => setConfig('resume_at', e.target.value || undefined)} onChange={(e) => setConfig('resume_at', e.target.value || undefined)}
placeholder="2026-08-18T09:00:00Z" placeholder={t('stepConfigPanel.20260818t090000z')}
/> />
</div> </div>
); );
@@ -151,7 +154,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
required required
value={strVal('url')} value={strVal('url')}
onChange={(e) => setConfig('url', e.target.value)} onChange={(e) => setConfig('url', e.target.value)}
placeholder="https://api.example.com/webhook" placeholder={t('stepConfigPanel.httpsapiexamplecomwebhook')}
/> />
</div> </div>
<JsonField <JsonField
@@ -168,7 +171,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
onChange={(e) => setConfig('body', e.target.value)} onChange={(e) => setConfig('body', e.target.value)}
rows={3} rows={3}
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" 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='{"key": "value"}' placeholder={t('stepConfigPanel.keyvalue')}
/> />
</div> </div>
<Input <Input
@@ -194,14 +197,14 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
required required
value={strVal('to')} value={strVal('to')}
onChange={(e) => setConfig('to', e.target.value)} onChange={(e) => setConfig('to', e.target.value)}
placeholder="empfaenger@example.com" placeholder={t('stepConfigPanel.empfaengerexamplecom')}
/> />
<Input <Input
label="Betreff" label="Betreff"
required required
value={strVal('subject')} value={strVal('subject')}
onChange={(e) => setConfig('subject', e.target.value)} onChange={(e) => setConfig('subject', e.target.value)}
placeholder="Betreff" placeholder={t('stepConfigPanel.betreff')}
/> />
</div> </div>
<div> <div>
@@ -213,14 +216,14 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
onChange={(e) => setConfig('body', e.target.value)} onChange={(e) => setConfig('body', e.target.value)}
rows={4} rows={4}
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" 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="Nachrichtentext" placeholder={t('stepConfigPanel.nachrichtentext')}
/> />
</div> </div>
<Input <Input
label="Account-ID (optional)" label="Account-ID (optional)"
value={strVal('account_id')} value={strVal('account_id')}
onChange={(e) => setConfig('account_id', e.target.value || undefined)} onChange={(e) => setConfig('account_id', e.target.value || undefined)}
placeholder="Standard-Konto wenn leer" placeholder={t('stepConfigPanel.standardkontowennleer')}
/> />
</div> </div>
); );
@@ -240,19 +243,19 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
label="Titel" label="Titel"
value={strVal('title')} value={strVal('title')}
onChange={(e) => setConfig('title', e.target.value)} onChange={(e) => setConfig('title', e.target.value)}
placeholder="Event-Titel" placeholder={t('stepConfigPanel.eventtitel')}
/> />
<Input <Input
label="Start (ISO)" label="Start (ISO)"
value={strVal('start')} value={strVal('start')}
onChange={(e) => setConfig('start', e.target.value)} onChange={(e) => setConfig('start', e.target.value)}
placeholder="2026-08-18T09:00:00Z" placeholder={t('stepConfigPanel.20260818t090000z')}
/> />
<Input <Input
label="Ende (ISO)" label="Ende (ISO)"
value={strVal('end')} value={strVal('end')}
onChange={(e) => setConfig('end', e.target.value)} onChange={(e) => setConfig('end', e.target.value)}
placeholder="2026-08-18T10:00:00Z" placeholder={t('stepConfigPanel.20260818t100000z')}
/> />
</div> </div>
)} )}
@@ -261,7 +264,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
label="Event-ID" label="Event-ID"
value={strVal('event_id')} value={strVal('event_id')}
onChange={(e) => setConfig('event_id', e.target.value)} onChange={(e) => setConfig('event_id', e.target.value)}
placeholder="Event-UUID" placeholder={t('stepConfigPanel.eventuuid')}
/> />
)} )}
</div> </div>
@@ -282,7 +285,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
label="Suchbegriff" label="Suchbegriff"
value={strVal('query')} value={strVal('query')}
onChange={(e) => setConfig('query', e.target.value)} onChange={(e) => setConfig('query', e.target.value)}
placeholder="Suchbegriff" placeholder={t('stepConfigPanel.suchbegriff')}
/> />
)} )}
{action === 'download' && ( {action === 'download' && (
@@ -290,7 +293,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
label="Datei-ID" label="Datei-ID"
value={strVal('file_id')} value={strVal('file_id')}
onChange={(e) => setConfig('file_id', e.target.value)} onChange={(e) => setConfig('file_id', e.target.value)}
placeholder="Datei-UUID" placeholder={t('stepConfigPanel.dateiuuid')}
/> />
)} )}
{action === 'upload' && ( {action === 'upload' && (
@@ -299,13 +302,13 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
label="Dateiname" label="Dateiname"
value={strVal('file_name')} value={strVal('file_name')}
onChange={(e) => setConfig('file_name', e.target.value)} onChange={(e) => setConfig('file_name', e.target.value)}
placeholder="datei.pdf" placeholder={t('stepConfigPanel.dateipdf')}
/> />
<Input <Input
label="Inhalt" label="Inhalt"
value={strVal('content')} value={strVal('content')}
onChange={(e) => setConfig('content', e.target.value)} onChange={(e) => setConfig('content', e.target.value)}
placeholder="Dateiinhalt" placeholder={t('stepConfigPanel.dateiinhalt')}
/> />
</div> </div>
)} )}
@@ -320,13 +323,13 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
required required
value={strVal('query')} value={strVal('query')}
onChange={(e) => setConfig('query', e.target.value)} onChange={(e) => setConfig('query', e.target.value)}
placeholder="Suchbegriff" placeholder={t('stepConfigPanel.suchbegriff')}
/> />
<Input <Input
label="Entity-Typ (optional)" label="Entity-Typ (optional)"
value={strVal('entity_type')} value={strVal('entity_type')}
onChange={(e) => setConfig('entity_type', e.target.value || undefined)} onChange={(e) => setConfig('entity_type', e.target.value || undefined)}
placeholder="contact, company, file, ..." placeholder={t('stepConfigPanel.contactcompanyfile')}
/> />
<Input <Input
label="Limit" label="Limit"
@@ -347,7 +350,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
required required
value={strVal('agent_id')} value={strVal('agent_id')}
onChange={(e) => setConfig('agent_id', e.target.value)} onChange={(e) => setConfig('agent_id', e.target.value)}
placeholder="Agent-UUID" placeholder={t('stepConfigPanel.agentuuid')}
/> />
<JsonField <JsonField
label="Input (JSON)" label="Input (JSON)"
@@ -361,7 +364,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
onChange={(e) => setConfig('wait_for_completion', e.target.checked)} onChange={(e) => setConfig('wait_for_completion', e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500" className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/> />
Auf Abschluss warten {t('stepConfigPanel.aufabschlusswarten')}
</label> </label>
</div> </div>
); );
@@ -380,7 +383,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
label="Entity-ID" label="Entity-ID"
value={strVal('entity_id')} value={strVal('entity_id')}
onChange={(e) => setConfig('entity_id', e.target.value)} onChange={(e) => setConfig('entity_id', e.target.value)}
placeholder="Entity-UUID" placeholder={t('stepConfigPanel.entityuuid')}
/> />
)} )}
{(action.includes('create') || action.includes('update')) && ( {(action.includes('create') || action.includes('update')) && (
@@ -397,14 +400,14 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
return ( return (
<div> <div>
<label className="block text-sm font-medium text-secondary-700 mb-1"> <label className="block text-sm font-medium text-secondary-700 mb-1">
Konfiguration (JSON) {t('stepConfigPanel.konfigurationjson')}
</label> </label>
<textarea <textarea
value={configText} value={configText}
onChange={(e) => handleConfigChange(e.target.value)} onChange={(e) => handleConfigChange(e.target.value)}
rows={5} rows={5}
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" 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='{"key": "value"}' placeholder={t('stepConfigPanel.keyvalue')}
/> />
{configError && ( {configError && (
<p className="mt-1 text-sm text-danger-600" role="alert"> <p className="mt-1 text-sm text-danger-600" role="alert">
@@ -424,7 +427,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
required required
value={step.name} value={step.name}
onChange={(e) => onChange({ ...step, name: e.target.value })} onChange={(e) => onChange({ ...step, name: e.target.value })}
placeholder="z.B. Genehmigung einholen" placeholder={t('stepConfigPanel.zbgenehmigungeinholen')}
/> />
<Select <Select
label="Typ" label="Typ"
@@ -445,7 +448,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
onChange={(e) => onChange({ ...step, description: e.target.value || null })} onChange={(e) => onChange({ ...step, description: e.target.value || null })}
rows={2} 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" 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="Optionale Beschreibung" placeholder={t('stepConfigPanel.optionalebeschreibung')}
/> />
</div> </div>
@@ -484,14 +487,14 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
) : ( ) : (
<div> <div>
<label className="block text-sm font-medium text-secondary-700 mb-1"> <label className="block text-sm font-medium text-secondary-700 mb-1">
Konfiguration (JSON) {t('stepConfigPanel.konfigurationjson')}
</label> </label>
<textarea <textarea
value={configText} value={configText}
onChange={(e) => handleConfigChange(e.target.value)} onChange={(e) => handleConfigChange(e.target.value)}
rows={5} rows={5}
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" 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='{"key": "value"}' placeholder={t('stepConfigPanel.keyvalue')}
/> />
{configError && ( {configError && (
<p className="mt-1 text-sm text-danger-600" role="alert"> <p className="mt-1 text-sm text-danger-600" role="alert">
@@ -511,6 +514,7 @@ interface JsonFieldProps {
} }
function JsonField({ label, value, onChange }: JsonFieldProps) { function JsonField({ label, value, onChange }: JsonFieldProps) {
const { t } = useTranslation();
const [text, setText] = useState(value); const [text, setText] = useState(value);
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
@@ -543,7 +547,7 @@ function JsonField({ label, value, onChange }: JsonFieldProps) {
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
rows={3} rows={3}
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" 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='{"key": "value"}' placeholder={t('stepConfigPanel.keyvalue')}
/> />
{error && ( {error && (
<p className="mt-1 text-sm text-danger-600" role="alert"> <p className="mt-1 text-sm text-danger-600" role="alert">
@@ -391,7 +391,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
}`} }`}
aria-pressed={mode === 'json'} aria-pressed={mode === 'json'}
> >
<Code2 className="h-3.5 w-3.5" /> JSON Expert <Code2 className="h-3.5 w-3.5" /> {t('workflowEditor.jsonexpert')}
</button> </button>
</div> </div>
</div> </div>
@@ -428,7 +428,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
required required
value={form.name} value={form.name}
onChange={(e) => updateField('name', e.target.value)} onChange={(e) => updateField('name', e.target.value)}
placeholder="z.B. Deal-Genehmigungsprozess" placeholder={t('workflowEditor.zbdealgenehmigungsprozess')}
/> />
<div> <div>
<label className="block text-sm font-medium text-secondary-700 mb-1"> <label className="block text-sm font-medium text-secondary-700 mb-1">
@@ -439,7 +439,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
onChange={(e) => updateField('description', e.target.value)} onChange={(e) => updateField('description', e.target.value)}
rows={2} 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" 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="Optionale Beschreibung" placeholder={t('workflowEditor.optionalebeschreibung')}
/> />
</div> </div>
<Select <Select
@@ -472,12 +472,12 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
type="button" type="button"
icon={<Plus className="h-4 w-4" />} icon={<Plus className="h-4 w-4" />}
> >
Schritt hinzufuegen {t('workflowEditor.schritthinzufuegen')}
</Button> </Button>
</div> </div>
{form.steps.length === 0 && ( {form.steps.length === 0 && (
<p className="text-sm text-secondary-400 italic"> <p className="text-sm text-secondary-400 italic">
Keine Schritte definiert. Klicke auf Schritt hinzufuegen. {t('workflowEditor.keineschrittedefiniertklickeaufschritt')}
</p> </p>
)} )}
<div className="space-y-4"> <div className="space-y-4">
@@ -493,7 +493,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
onClick={() => moveStep(i, 'up')} onClick={() => moveStep(i, 'up')}
disabled={i === 0} disabled={i === 0}
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1" className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
aria-label="Nach oben" aria-label={t('workflowEditor.nachoben')}
> >
<ArrowUp className="h-4 w-4" /> <ArrowUp className="h-4 w-4" />
</button> </button>
@@ -502,7 +502,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
onClick={() => moveStep(i, 'down')} onClick={() => moveStep(i, 'down')}
disabled={i === form.steps.length - 1} disabled={i === form.steps.length - 1}
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1" className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
aria-label="Nach unten" aria-label={t('workflowEditor.nachunten')}
> >
<ArrowDown className="h-4 w-4" /> <ArrowDown className="h-4 w-4" />
</button> </button>
@@ -510,7 +510,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
type="button" type="button"
onClick={() => removeStep(i)} onClick={() => removeStep(i)}
className="text-danger-500 hover:text-danger-700 p-1" className="text-danger-500 hover:text-danger-700 p-1"
aria-label="Schritt entfernen" aria-label={t('workflowEditor.schrittentfernen')}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
@@ -528,14 +528,14 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
) : ( ) : (
<div> <div>
<label className="block text-sm font-medium text-secondary-700 mb-1"> <label className="block text-sm font-medium text-secondary-700 mb-1">
Workflow (JSON) {t('workflowEditor.workflowjson')}
</label> </label>
<textarea <textarea
value={jsonText} value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)} onChange={(e) => handleJsonChange(e.target.value)}
rows={18} rows={18}
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" 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='{"name": "...", "steps": [...]}' placeholder={t('workflowEditor.namesteps')}
/> />
{jsonError && ( {jsonError && (
<p className="mt-1 text-sm text-danger-600" role="alert"> <p className="mt-1 text-sm text-danger-600" role="alert">
@@ -7,6 +7,7 @@ import { Skeleton } from '@/components/ui/Skeleton';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { AlertCircle, ChevronRight } from 'lucide-react'; import { AlertCircle, ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const statusFilterOptions = [ const statusFilterOptions = [
{ value: '', label: 'Alle Status' }, { value: '', label: 'Alle Status' },
@@ -54,6 +55,7 @@ export interface WorkflowInstanceListProps {
export function WorkflowInstanceList({ export function WorkflowInstanceList({
onSelectInstance, onSelectInstance,
}: WorkflowInstanceListProps) { }: WorkflowInstanceListProps) {
const { t } = useTranslation();
const [statusFilter, setStatusFilter] = useState<string>(''); const [statusFilter, setStatusFilter] = useState<string>('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 20; const pageSize = 20;
@@ -103,12 +105,12 @@ export function WorkflowInstanceList({
<Card className="p-6"> <Card className="p-6">
<div className="flex items-center gap-3 text-danger-600"> <div className="flex items-center gap-3 text-danger-600">
<AlertCircle className="h-5 w-5" /> <AlertCircle className="h-5 w-5" />
<span>Fehler beim Laden der Instanzen</span> <span>{t('workflowInstanceList.fehlerbeimladenderinstanzen')}</span>
<button <button
onClick={() => refetch()} onClick={() => refetch()}
className="text-sm text-primary-600 hover:underline" className="text-sm text-primary-600 hover:underline"
> >
Erneut versuchen {t('workflowInstanceList.erneutversuchen')}
</button> </button>
</div> </div>
</Card> </Card>
@@ -117,7 +119,7 @@ export function WorkflowInstanceList({
{/* Empty state */} {/* Empty state */}
{!isLoading && !isError && instances.length === 0 && ( {!isLoading && !isError && instances.length === 0 && (
<EmptyState <EmptyState
title="Keine Workflow-Instanzen" title={t('workflowInstanceList.keineworkflowinstanzen')}
description="Es wurden keine Instanzen gefunden, die dem Filter entsprechen." description="Es wurden keine Instanzen gefunden, die dem Filter entsprechen."
/> />
)} )}
+621 -10
View File
@@ -1,7 +1,9 @@
{ {
"app": { "app": {
"name": "leocrm", "name": "leocrm",
"tagline": "Mini-CRM für kleine Unternehmen" "tagline": "Mini-CRM für kleine Unternehmen",
"siesindofflineänderungenwerdengespeicher": "Sie sind offline. Änderungen werden gespeichert wenn die Verbindung wiederhergestellt ist.",
"zumhauptinhaltspringen": "Zum Hauptinhalt springen"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -138,7 +140,10 @@
"down": "Nicht verfügbar", "down": "Nicht verfügbar",
"unknown": "Unbekannt" "unknown": "Unbekannt"
} }
} },
"llmcost24h": "LLM Cost (24h)",
"llmtokens24h": "LLM Tokens (24h)",
"activeplugins": "Active Plugins"
}, },
"companies": { "companies": {
"title": "Firmen", "title": "Firmen",
@@ -331,7 +336,8 @@
"livePreviewDescription": "So sieht die Anwendung mit dem aktuellen Theme aus", "livePreviewDescription": "So sieht die Anwendung mit dem aktuellen Theme aus",
"resetTheme": "Zurücksetzen", "resetTheme": "Zurücksetzen",
"saveTheme": "Theme speichern", "saveTheme": "Theme speichern",
"mcp": "MCP" "mcp": "MCP",
"zurückzurstartseite": "Zurück zur Startseite"
}, },
"auditLog": { "auditLog": {
"title": "Audit-Log", "title": "Audit-Log",
@@ -344,7 +350,10 @@
"timestamp": "Zeitpunkt", "timestamp": "Zeitpunkt",
"empty": "Keine Audit-Log-Einträge vorhanden.", "empty": "Keine Audit-Log-Einträge vorhanden.",
"notAvailable": "Audit-Log ist aktuell nicht verfügbar.", "notAvailable": "Audit-Log ist aktuell nicht verfügbar.",
"entityId": "Entität-ID" "entityId": "Entität-ID",
"annaschmidt": "anna.schmidt",
"createupdatedelete": "create, update, delete",
"companycontact": "company, contact"
}, },
"search": { "search": {
"title": "Suchergebnisse", "title": "Suchergebnisse",
@@ -398,7 +407,8 @@
"success": "Erfolg", "success": "Erfolg",
"error": "Fehler", "error": "Fehler",
"warning": "Warnung", "warning": "Warnung",
"info": "Information" "info": "Information",
"closenotification": "Close notification"
}, },
"table": { "table": {
"page": "Seite", "page": "Seite",
@@ -410,7 +420,9 @@
"sortAscending": "Aufsteigend sortieren", "sortAscending": "Aufsteigend sortieren",
"sortDescending": "Absteigend sortieren", "sortDescending": "Absteigend sortieren",
"sortedBy": "Sortiert nach", "sortedBy": "Sortiert nach",
"empty": "Keine Daten vorhanden" "empty": "Keine Daten vorhanden",
"datatable": "Data table",
"wirdgeladen": "Wird geladen..."
}, },
"confirmDialog": { "confirmDialog": {
"title": "Bestätigung erforderlich", "title": "Bestätigung erforderlich",
@@ -422,7 +434,8 @@
"next": "Weiter", "next": "Weiter",
"page": "Seite {{page}}", "page": "Seite {{page}}",
"first": "Erste Seite", "first": "Erste Seite",
"last": "Letzte Seite" "last": "Letzte Seite",
"pagination": "Pagination"
}, },
"emptyState": { "emptyState": {
"title": "Nichts gefunden", "title": "Nichts gefunden",
@@ -707,7 +720,9 @@
"sortDesc": "Absteigend", "sortDesc": "Absteigend",
"syncFailed": "Synchronisierung fehlgeschlagen", "syncFailed": "Synchronisierung fehlgeschlagen",
"syncing": "Synchronisiere...", "syncing": "Synchronisiere...",
"autoSyncEnabled": "Auto-Sync aktiv" "autoSyncEnabled": "Auto-Sync aktiv",
"zurückzuordnern": "Zurück zu Ordnern",
"zurückzurliste": "Zurück zur Liste"
}, },
"notifications": { "notifications": {
"title": "Benachrichtigungseinstellungen", "title": "Benachrichtigungseinstellungen",
@@ -1093,7 +1108,8 @@
"invalidJson": "Ungültige JSON-Daten", "invalidJson": "Ungültige JSON-Daten",
"selectTemplateHint": "Wählen Sie eine Vorlage aus der Liste", "selectTemplateHint": "Wählen Sie eine Vorlage aus der Liste",
"downloadHistory": "Download-Verlauf", "downloadHistory": "Download-Verlauf",
"noDownloads": "Noch keine Downloads" "noDownloads": "Noch keine Downloads",
"keyvalue": "{\"key\": \"value\"}"
}, },
"tasks": { "tasks": {
"title": "Aufgaben", "title": "Aufgaben",
@@ -1146,7 +1162,12 @@
"removeDependency": "Abhängigkeit entfernen", "removeDependency": "Abhängigkeit entfernen",
"targetDate": "Zieldatum", "targetDate": "Zieldatum",
"progress": "Fortschritt", "progress": "Fortschritt",
"milestones": "Meilensteine" "milestones": "Meilensteine",
"nachstatus": "Nach Status",
"nachpriorität": "Nach Priorität",
"alletasks": "Alle Tasks (",
"inbearbeitung": "In Bearbeitung",
"keinetasks": "Keine Tasks"
}, },
"savedFilters": { "savedFilters": {
"save": "Filter speichern", "save": "Filter speichern",
@@ -1440,5 +1461,595 @@
"targetRoom": "Ziel-Raum", "targetRoom": "Ziel-Raum",
"targetRoomDescription": "Name des Raums in der Kommunikation, an den Status-Meldungen gesendet werden.", "targetRoomDescription": "Name des Raums in der Kommunikation, an den Status-Meldungen gesendet werden.",
"defaultRoomName": "Live KI" "defaultRoomName": "Live KI"
},
"activityFilter": {
"benutzername": "Benutzername"
},
"improvementPanel": {
"signalesammeln": "Signale sammeln",
"keinesignaleklickensieaufsammeln": "Keine Signale. Klicken Sie auf „Sammeln\" um zu starten.",
"mustererkennen": "Muster erkennen",
"keinemustersammelnsiezuerstsignale": "Keine Muster. Sammeln Sie zuerst Signale und klicken Sie dann auf „Erkennen\".",
"keinevorschläge": "Keine Vorschläge.",
"evaluieren": "Evaluieren",
"aktivieren": "Aktivieren",
"rollback": "Rollback",
"impactmessen": "Impact messen"
},
"suggestionBadge": {
"kivorschläge": "KI Vorschläge"
},
"suggestionCard": {
"ignorieren": "Ignorieren",
"ausgeführt": "✓ Ausgeführt"
},
"suggestionSidebar": {
"keinevorschlägevorhanden": "Keine Vorschläge vorhanden",
"diekianalysiertdeinenkontext": "Die KI analysiert deinen Kontext...",
"kivorschläge": "KI Vorschläge"
},
"blockRenderer": {
"unbekannterblocktyp": "Unbekannter Block-Typ:"
},
"contactCardBlock": {
"kontaktanzeigen": "Kontakt anzeigen"
},
"miniAppBlock": {
"keinekonfiguration": "Keine Konfiguration"
},
"entityHistoryPanel": {
"loadinghistory": "Loading history"
},
"printButton": {
"druckenoderalspdfexportieren": "Drucken oder als PDF exportieren",
"druckenpdf": "Drucken / PDF",
"alspdf": "Als PDF"
},
"saveFilterDialog": {
"keineaktivenfilterkriterien": "Keine aktiven Filterkriterien"
},
"shareDialog": {
"schließen": "Schließen",
"elementteilen": "Element teilen",
"gewährebenutzernodergruppenzugriffauf": "Gewähre Benutzern oder Gruppen Zugriff auf dieses Element. Die Berechtigungsstufe bestimmt,\n welche Aktionen durchgeführt werden können.",
"nochkeineberechtigungenvergebendiesesele": "Noch keine Berechtigungen vergeben. Dieses Element ist nur für den Besitzer sichtbar.",
"ablaufdatumsetzen": "Ablaufdatum setzen",
"entfernen": "Entfernen",
"berechtigungentfernen": "Berechtigung entfernen",
"neueberechtigung": "Neue Berechtigung",
"ablaufdatumoptional": "Ablaufdatum (optional)",
"berechtigunghinzufügen": "Berechtigung hinzufügen"
},
"contactEditForm": {
"techcorpgmbh": "TechCorp GmbH",
"k00123": "K-00123",
"tag1tag2": "tag1, tag2"
},
"contactFolderTree": {
"optionen": "Optionen",
"mehrereordnerauswählen": "Mehrere Ordner auswählen",
"neuerordner": "Neuer Ordner",
"keineordnervorhanden": "Keine Ordner vorhanden",
"farbewählen": "Farbe wählen"
},
"contactList": {
"ordnerzuweisen": "Ordner zuweisen ▾",
"keineordner": "Keine Ordner",
"tagshinzufügen": "Tags hinzufügen ▾",
"tag1tag2": "tag1, tag2, ...",
"auswahlaufheben": "Auswahl aufheben",
"löschenbestätigen": "Löschen bestätigen",
"kontaktewirklichlöschen": "Kontakt(e) wirklich löschen?",
"customsortierungaktivdraganddrop": "Custom Sortierung aktiv — Drag-and-Drop zum Umsortieren",
"spaltenverwalten": "Spalten verwalten"
},
"filterPanel": {
"filter": "Filter",
"allelöschen": "Alle löschen",
"bedingungenverknüpfen": "Bedingungen verknüpfen:",
"gespeichertefilter": "Gespeicherte Filter",
"keinefilteraktivklickeuntenum": "Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen.",
"wählen": "— wählen —",
"wert": "Wert…",
"bedingunghinzufügen": "Bedingung hinzufügen",
"filterspeichern": "Filter speichern"
},
"folderPermissionDialog": {
"schließen": "Schließen",
"ordnerteilen": "Ordner teilen",
"gewährebenutzernodergruppenzugriffauf": "Gewähre Benutzern oder Gruppen Zugriff auf diesen Ordner. Mit „Vererben\" gelten die Rechte auch für alle Unterordner.",
"nochkeineberechtigungenvergebendieserord": "Noch keine Berechtigungen vergeben. Dieser Ordner ist nur für den Besitzer sichtbar.",
"entfernen": "Entfernen",
"berechtigungentfernen": "Berechtigung entfernen",
"neueberechtigung": "Neue Berechtigung",
"aufunterordnervererben": "Auf Unterordner vererben",
"berechtigunghinzufügen": "Berechtigung hinzufügen"
},
"groupPanel": {
"gruppierung": "Gruppierung",
"allelöschen": "Alle löschen",
"keinegruppierungaktivalledatensätzewerde": "Keine Gruppierung aktiv. Alle Datensätze werden in einer flachen Liste angezeigt.",
"gruppierunghinzufügen": "Gruppierung hinzufügen"
},
"saveViewDialog": {
"ansichtspeichern": "Ansicht speichern",
"namederansicht": "Name der Ansicht",
"zbmeinefirmenkontakte": "z.B. Meine Firmen-Kontakte",
"wassollgespeichertwerden": "Was soll gespeichert werden?",
"nichtaktiv": "nicht aktiv"
},
"sortPanel": {
"sortieren": "Sortieren",
"allelöschen": "Alle löschen",
"keinesortierungaktivdatensätzekönnenper": "Keine Sortierung aktiv. Datensätze können per Drag-and-Drop umsortiert werden.",
"sortierunghinzufügen": "Sortierung hinzufügen"
},
"customFieldRenderer": {
"required": "required",
"keineoptionenverfügbar": "Keine Optionen verfügbar",
"alleoptionenausgewählt": "Alle Optionen ausgewählt",
"bittewählen": "— Bitte wählen —"
},
"aISidebar": {
"chatsidebarcomingsoon": "Chat-Sidebar - Coming Soon",
"keinebenachrichtigungen": "Keine Benachrichtigungen",
"benachrichtigunglöschen": "Benachrichtigung löschen",
"dervollekichatistauf": "Der volle KI-Chat ist auf der AI-Assistant-Seite verfügbar.",
"aiassistantöffnen": "AI Assistant öffnen",
"einklappen": "Einklappen",
"kiassistenteinklappen": "KI Assistent einklappen",
"zurück": "Zurück"
},
"messageSidebar": {
"keinenachrichten": "Keine Nachrichten",
"senden": "Senden",
"einklappen": "Einklappen",
"messagingeinklappen": "Messaging einklappen",
"keinekonversationen": "Keine Konversationen",
"systemkonversationschreibgeschützt": "System-Konversation (schreibgeschützt)",
"zurück": "Zurück"
},
"pluginToolbar": {
"sucheschließen": "Suche schließen"
},
"sidebar": {
"seitenleistenavigation": "Seitenleiste Navigation",
"zurückzurstartseite": "Zurück zur Startseite",
"hauptnavigation": "Hauptnavigation"
},
"sortableMenuItem": {
"ziehenzumsortieren": "Ziehen zum Sortieren"
},
"topBar": {
"seitenleisteeinausklappen": "Seitenleiste ein-/ausklappen"
},
"composeModal": {
"recipientexamplecom": "recipient@example.com",
"ccexamplecom": "cc@example.com",
"bccexamplecom": "bcc@example.com",
"max25mbperfile": "Max 25 MB per file"
},
"mailComposeForm": {
"recipientexamplecom": "recipient@example.com",
"ccexamplecom": "cc@example.com",
"bccexamplecom": "bcc@example.com",
"max25mbperfile": "Max 25 MB per file"
},
"mailFilterPanel": {
"filter": "Filter",
"allelöschen": "Alle löschen",
"bedingungenverknüpfen": "Bedingungen verknüpfen:",
"gespeichertefilter": "Gespeicherte Filter",
"keinefilteraktivklickeuntenum": "Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen.",
"wählen": "— wählen —",
"wert": "Wert…",
"bedingunghinzufügen": "Bedingung hinzufügen",
"filterspeichern": "Filter speichern"
},
"mailFolderTree": {
"ordnerleeren": "Ordner leeren"
},
"mailGroupPanel": {
"gruppierung": "Gruppierung",
"allelöschen": "Alle löschen",
"keinegruppierungaktivklickeuntenum": "Keine Gruppierung aktiv. Klicke unten um ein Feld hinzuzufügen.",
"feldhinzufügen": "Feld hinzufügen"
},
"mailSortPanel": {
"sortieren": "Sortieren",
"allelöschen": "Alle löschen",
"keinesortierungaktivklickeuntenum": "Keine Sortierung aktiv. Klicke unten um ein Feld hinzuzufügen.",
"feldhinzufügen": "Feld hinzufügen"
},
"pgpSettings": {
"beginpgpprivatekeyblock": "-----BEGIN PGP PRIVATE KEY BLOCK-----",
"keyid": "Key ID:",
"contactuuid": "contact-uuid",
"beginpgppublickeyblock": "-----BEGIN PGP PUBLIC KEY BLOCK-----"
},
"richTextEditor": {
"textcolor": "Text color"
},
"signatureManager": {
"pmitfreundlichengrüßenbruser": "<p>Mit freundlichen Grüßen,<br>{{user.name}}</p>"
},
"notificationItem": {
"ungelesen": "ungelesen"
},
"pluginRouteRenderer": {
"loading": "Loading",
"pagenotfound": "Page Not Found",
"thepage": "The page",
"wasnotfound": "was not found."
},
"workspaceManager": {
"name": "Name",
"beschreibung": "Beschreibung",
"alsstandardworkspace": "Als Standard-Workspace",
"konfigurationbearbeiten": "Konfiguration bearbeiten",
"visiblefolderids": "{\"visible_folder_ids\": []}",
"jsonkonfigurationfürdiesesmodulz": "JSON-Konfiguration für dieses Modul (z.B. sichtbare Ordner-IDs)",
"module": "Module",
"bearbeiten": "Bearbeiten",
"löschen": "Löschen"
},
"activityFeed": {
"keineaktivitätenvorhanden": "Keine Aktivitäten vorhanden."
},
"dataGrid": {
"datagrid": "Data grid"
},
"searchDropdown": {
"alleergebnisseanzeigen": "Alle Ergebnisse anzeigen →"
},
"teamPanel": {
"keinemitarbeiter": "Keine Mitarbeiter",
"offline": "offline",
"keinegruppen": "Keine Gruppen"
},
"taskDetail": {
"uuid": "UUID"
},
"modal": {
"closedialog": "Close dialog"
},
"skeleton": {
"wirdgeladen": "Wird geladen"
},
"aiChatPanel": {
"kiassistent": "KI Assistent",
"verbindemitki": "Verbinde mit KI...",
"stelleeinefragezumaktuellenfenster": "Stelle eine Frage zum aktuellen Fenster...",
"nachrichteingeben": "Nachricht eingeben...",
"senden": "Senden"
},
"window": {
"kichateinaus": "KI Chat ein/aus",
"kichat": "KI Chat",
"minimieren": "Minimieren",
"schließen": "Schließen"
},
"stepConfigPanel": {
"zb3600": "z.B. 3600",
"20260818t090000z": "2026-08-18T09:00:00Z",
"httpsapiexamplecomwebhook": "https://api.example.com/webhook",
"keyvalue": "{\"key\": \"value\"}",
"empfaengerexamplecom": "empfaenger@example.com",
"betreff": "Betreff",
"nachrichtentext": "Nachrichtentext",
"standardkontowennleer": "Standard-Konto wenn leer",
"eventtitel": "Event-Titel",
"20260818t100000z": "2026-08-18T10:00:00Z",
"eventuuid": "Event-UUID",
"suchbegriff": "Suchbegriff",
"dateiuuid": "Datei-UUID",
"dateipdf": "datei.pdf",
"dateiinhalt": "Dateiinhalt",
"contactcompanyfile": "contact, company, file, ...",
"agentuuid": "Agent-UUID",
"aufabschlusswarten": "Auf Abschluss warten",
"entityuuid": "Entity-UUID",
"konfigurationjson": "Konfiguration (JSON)",
"zbgenehmigungeinholen": "z.B. Genehmigung einholen",
"optionalebeschreibung": "Optionale Beschreibung"
},
"workflowEditor": {
"jsonexpert": "JSON Expert",
"zbdealgenehmigungsprozess": "z.B. Deal-Genehmigungsprozess",
"optionalebeschreibung": "Optionale Beschreibung",
"schritthinzufuegen": "Schritt hinzufuegen",
"keineschrittedefiniertklickeaufschritt": "Keine Schritte definiert. Klicke auf Schritt hinzufuegen.",
"nachoben": "Nach oben",
"nachunten": "Nach unten",
"schrittentfernen": "Schritt entfernen",
"workflowjson": "Workflow (JSON)",
"namesteps": "{\"name\": \"...\", \"steps\": [...]}"
},
"workflowInstanceList": {
"fehlerbeimladenderinstanzen": "Fehler beim Laden der Instanzen",
"erneutversuchen": "Erneut versuchen",
"keineworkflowinstanzen": "Keine Workflow-Instanzen"
},
"aISettings": {
"azureopenai": "Azure OpenAI",
"modellepresets": "Modelle & Presets",
"presetname": "Preset Name",
"modellidzbgpt4o": "Modell ID (z.B. gpt-4o-mini)",
"anbieterwählen": "Anbieter wählen...",
"temperature": "Temperature",
"maxtokens": "Max Tokens",
"topp": "Top P",
"systempromptoptional": "System Prompt (optional)",
"temp": "· temp=",
"maxtokens2": "· max_tokens=",
"beschreibung": "Beschreibung",
"presetwählen": "Preset wählen...",
"systemprompt": "System Prompt",
"verfügbaretools": "Verfügbare Tools",
"diesetoolswerdenvonpluginsbereitgestellt": "Diese Tools werden von Plugins bereitgestellt und können Agenten zugewiesen werden.",
"keinetoolsverfügbarpluginskönnentools": "Keine Tools verfügbar. Plugins können Tools registrieren.",
"kiassistenteinstellungen": "KI Assistent Einstellungen"
},
"agentDashboard": {
"myagent": "My Agent",
"optionaldescription": "Optional description",
"gpt4": "gpt-4",
"youareahelpfulassistant": "You are a helpful assistant..."
},
"agents": {
"zurückzurstartseite": "Zurück zur Startseite",
"agentennavigation": "Agenten Navigation"
},
"automation": {
"zurückzurstartseite": "Zurück zur Startseite",
"automationnavigation": "Automation Navigation"
},
"automationDashboard": {
"myautomation": "My Automation",
"optionaldescription": "Optional description",
"contactcreated": "contact.created",
"cronexpressioneg09": "Cron expression (e.g. 0 9 * * * for daily at 9am)",
"field": "Field",
"value": "Value",
"removecondition": "Remove condition",
"url": "{\"url\": \"...\"}",
"removeaction": "Remove action"
},
"automationSettings": {
"gpt4": "gpt-4",
"mycustomapp": "my_custom_app",
"mycustomapp2": "My Custom App",
"appwindow": "AppWindow",
"optionaldescription": "Optional description",
"typeformfields": "{\"type\": \"form\", \"fields\": []}"
},
"communication": {
"keinekonversationen": "Keine Konversationen",
"miniapps": "Mini-Apps",
"inneuemfenster": "In neuem Fenster",
"keinenachrichtenschreibedieerste": "Keine Nachrichten. Schreibe die erste!",
"kiantwortet": "KI antwortet...",
"dateianhängen": "Datei anhängen",
"emoji": "Emoji",
"teilnehmerauswählen": "Teilnehmer auswählen",
"wähleeinekonversationaus": "Wähle eine Konversation aus"
},
"contactsList": {
"keinebenutzerdefiniertenansichten": "Keine benutzerdefinierten Ansichten",
"aktuelleansichtspeichern": "Aktuelle Ansicht speichern",
"gespeichertefilter": "Gespeicherte Filter"
},
"customFields": {
"zbbrancheabteilunggeburtsdatum": "z.B. Branche, Abteilung, Geburtsdatum",
"zbbrancheabteilunggeburtsdatum2": "z.B. branche, abteilung, geburtsdatum",
"option1option2option3": "Option 1, Option 2, Option 3"
},
"guestContacts": {
"weiterleitungzukontakten": "Weiterleitung zu Kontakten..."
},
"guestLogin": {
"weiterleitungzumlogin": "Weiterleitung zum Login..."
},
"help": {
"zurückzurstartseite": "Zurück zur Startseite",
"hilfenavigation": "Hilfe Navigation"
},
"importExport": {
"tabs": "Tabs"
},
"logs": {
"zurückzurstartseite": "Zurück zur Startseite",
"logsnavigation": "Logs Navigation"
},
"mailSettings": {
"userexamplecom": "user@example.com",
"johndoe": "John Doe",
"leerlassenfüremailadresse": "Leer lassen für E-Mail-Adresse",
"imapexamplecom": "imap.example.com",
"smtpexamplecom": "smtp.example.com",
"geteiltespostfachfüralletenantbenutzer": "Geteiltes Postfach (für alle Tenant-Benutzer sichtbar)",
"ordnerzuordnungbearbeiten": "Ordner-Zuordnung bearbeiten"
},
"noAccessPage": {
"keinzugriff": "Kein Zugriff",
"siehabenkeineberechtigungaufdiese": "Sie haben keine Berechtigung, auf diese Seite zuzugreifen.\n Bitte wenden Sie sich an einen Administrator, falls Sie Zugriff benötigen.",
"zumdashboard": "Zum Dashboard"
},
"settingsBackup": {
"restore": "RESTORE"
},
"settingsMcp": {
"select": "-- Select --"
},
"settingsPlugins": {
"httpsexamplecompluginzip": "https://example.com/plugin.zip"
},
"settingsRechte": {
"löschen": "Löschen",
"freigabenübersicht": "Freigaben Übersicht",
"nameoderid": "Name oder ID...",
"berechtigunglöschen": "Berechtigung löschen",
"auditlogfürberechtigungen": "Audit-Log für Berechtigungen",
"rechteverwaltungtabs": "Rechteverwaltung Tabs"
},
"settingsSequences": {
"re": "RE-"
},
"settingsStammdaten": {
"bearbeiten": "Bearbeiten",
"löschen": "Löschen",
"adressverwaltung": "Adressverwaltung",
"ladeadressen": "Lade Adressen...",
"bankkontenverwaltung": "Bankkontenverwaltung",
"ladekonten": "Lade Konten...",
"stammdatentabs": "Stammdaten Tabs"
},
"settingsSystem": {
"systemtabs": "System Tabs"
},
"settingsTaxes": {
"de": "DE"
},
"settingsTheme": {
"2563eb": "#2563eb",
"d946ef": "#d946ef",
"primarybutton": "Primary Button",
"secondarybutton": "Secondary Button",
"dangerbutton": "Danger Button",
"ghostbutton": "Ghost Button",
"texteingeben": "Text eingeben...",
"diesisteinebeispielkartemit": "Dies ist eine Beispiel-Karte mit dem aktuellen Theme."
},
"settingsUserManagement": {
"nutzerverwaltungtabs": "Nutzerverwaltung Tabs"
},
"settingsUsers": {
"neumitarbeiterfirmade": "neu.mitarbeiter@firma.de"
},
"startPage": {
"zurückzurstartseite": "Zurück zur Startseite",
"wähleeinenworkspaceaus": "Wähle einen Workspace aus",
"workspacehinzufügen": "Workspace hinzufügen"
},
"workflows": {
"definierenundverwaltensieautomatisiertew": "Definieren und verwalten Sie automatisierte Workflows",
"fehlerbeimladenderworkflows": "Fehler beim Laden der Workflows",
"erneutversuchen": "Erneut versuchen",
"keineworkflows": "Keine Workflows",
"workflowerstellen": "Workflow erstellen",
"bearbeiten": "Bearbeiten",
"loeschen": "Loeschen",
"workflowloeschen": "Workflow loeschen"
},
"agentsOverview": {
"agentenübersicht": "Agenten Übersicht",
"verwaltensiekiagentenführensie": "Verwalten Sie KI-Agenten, führen Sie diese aus und überwachen Sie deren Ausführungen."
},
"agentsPlaceholder": {
"dieseseitewirdgeradeerstelltwählen": "Diese Seite wird gerade erstellt. Wählen Sie ein Thema aus dem Menü links."
},
"automationOverview": {
"automationübersicht": "Automation Übersicht",
"erstellenundverwaltensieautomatisiertewo": "Erstellen und verwalten Sie automatisierte Workflows. Definieren Sie Trigger, Bedingungen und Aktionen.",
"workflowserstellenundverwalten": "Workflows erstellen und verwalten",
"triggeraktionenundbedingungenkonfigurier": "Trigger, Aktionen und Bedingungen konfigurieren"
},
"automationPlaceholder": {
"dieseseitewirdgeradeerstelltwählen": "Diese Seite wird gerade erstellt. Wählen Sie ein Thema aus dem Menü links."
},
"helpApiDocs": {
"apidokumentation": "API Dokumentation",
"dievollständigeapidokumentationfindensie": "Die vollständige API-Dokumentation finden Sie unter",
"swaggerui": "(Swagger UI).",
"leocrmbieteteinerestapimit": "LeoCRM bietet eine REST-API mit über 224 Endpoints. Die API verwendet JSON für Request- und Response-Bodies.",
"dieapiverwendetsessionbasierteauthentifi": "Die API verwendet Session-basierte Authentifizierung mit HttpOnly-Cookies. Nach dem Login über",
"postapiv1authlogin": "POST /api/v1/auth/login",
"wirdeinsessioncookiegesetzt": "wird ein Session-Cookie gesetzt.",
"wichtigeendpoints": "Wichtige Endpoints",
"getapiv1contacts": "GET /api/v1/contacts",
"kontakteabrufen": "— Kontakte abrufen",
"postapiv1contacts": "POST /api/v1/contacts",
"kontakterstellen": "— Kontakt erstellen",
"getapiv1calendarentries": "GET /api/v1/calendar/entries",
"kalendereinträge": "— Kalendereinträge",
"getapiv1mailaccounts": "GET /api/v1/mail/accounts",
"mailkonten": "— Mail-Konten",
"getapiv1pluginsactivemanifests": "GET /api/v1/plugins/active-manifests",
"pluginmanifeste": "— Plugin-Manifeste",
"vollständigedoku": "Vollständige Doku:",
"swaggeruiöffnen": "Swagger UI öffnen"
},
"helpContacts": {
"kontakteverwalten": "Kontakte verwalten",
"kontakteerstellen": "Kontakte erstellen",
"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.",
"jederfirmakönnenmehrerekontaktpersonenzu": "Jeder Firma können mehrere Kontaktpersonen zugeordnet werden. Öffnen Sie eine Firma und fügen Sie Personen hinzu.",
"verwendensietagsumkontaktezu": "Verwenden Sie Tags um Kontakte zu kategorisieren. Tags können frei vergeben werden und helfen bei der Filterung.",
"organisierensiekontakteinordnernordner": "Organisieren Sie Kontakte in Ordnern. Ordner können verschachtelt werden und eigene Berechtigungen haben."
},
"helpLogin": {
"loginanmeldung": "Login & Anmeldung",
"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.",
"passwortvergessen": "Passwort vergessen?",
"klickensieaufderloginseite": "Klicken Sie auf der Login-Seite auf \"Passwort vergessen\". Sie erhalten eine E-Mail mit einem Link zum Zurücksetzen Ihres Passworts.",
"ihresitzungwirdübereinsicheres": "Ihre Sitzung wird über ein sicheres HttpOnly-Cookie verwaltet",
"nachinaktivitätwirddiesitzungautomatisch": "Nach Inaktivität wird die Sitzung automatisch beendet",
"passwörterwerdenmitbcryptcost12": "Passwörter werden mit bcrypt (cost=12) verschlüsselt gespeichert"
},
"helpMailSetup": {
"postfacheinrichten": "Postfach einrichten",
"imapkontohinzufügen": "IMAP-Konto hinzufügen",
"gehensiezueinstellungenemailund": "Gehen Sie zu Einstellungen → Email und klicken Sie auf \"Konto hinzufügen\". Geben Sie Ihre IMAP- und SMTP-Serverdaten ein.",
"benötigtedaten": "Benötigte Daten",
"imapserverzbimapexample": "IMAP-Server (z.B. imap.example.com)",
"imapportmeist993fürssl": "IMAP-Port (meist 993 für SSL)",
"smtpserverzbsmtpexample": "SMTP-Server (z.B. smtp.example.com)",
"smtpportmeist587fürtls": "SMTP-Port (meist 587 für TLS)",
"emailadresseundpasswort": "E-Mail-Adresse und Passwort",
"nachdemeinrichtenwirdihrpostfach": "Nach dem Einrichten wird Ihr Postfach automatisch synchronisiert. Neue E-Mails werden im Hintergrund abgerufen."
},
"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.",
"einworkspaceistihrarbeitsbereichmit": "Ein Workspace ist Ihr Arbeitsbereich mit Sidebar-Navigation. Hier finden Sie Kontakte, Kalender, E-Mail und alle anderen Module.",
"dashamburgermenüobenlinksblendet": "Das Hamburger-Menü oben links blendet die Seitenleiste ein und aus. Die Sidebar zeigt alle verfügbaren Module.",
"rechtsnebendemhamburgermenüfinden": "Rechts neben dem Hamburger-Menü finden Sie einen Zurück-Pfeil, der Sie zurück zur Startseite bringt.",
"globalesuche": "Globale Suche",
"verwendensiedielupeobenoder": "Verwenden Sie die Lupe oben oder Strg+K um das Kommando-Palette zu öffnen und schnell nach Kontakten, Mails oder Dateien zu suchen."
},
"helpPlaceholder": {
"diesehilfeseite": "Diese Hilfeseite (",
"wirdgeradeerstelltschauensiespäter": ") wird gerade erstellt. Schauen Sie später wieder vorbei.",
"wählensieeinthemaausdem": "Wählen Sie ein Thema aus dem Menü links um weitere Hilfe-Artikel zu lesen."
},
"helpWelcome": {
"willkommenbeileocrm": "Willkommen bei LeoCRM",
"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.",
"kontakteverwalten": "Kontakte verwalten",
"firmenpersonenkontaktdatenzentralspeiche": "— Firmen, Personen, Kontaktdaten zentral speichern",
"termineaufgabenunderinnerungen": "— Termine, Aufgaben und Erinnerungen",
"imappostfächersynchronisierendirektantwo": "— IMAP-Postfächer synchronisieren, direkt antworten",
"dateiendms": "Dateien (DMS)",
"dokumentehochladenteilenundverwalten": "— Dokumente hochladen, teilen und verwalten",
"intelligentehilfebeiderarbeit": "— Intelligente Hilfe bei der Arbeit",
"automatisierteprozesse": "— Automatisierte Prozesse",
"ersteschritte": "Erste Schritte",
"meldensiesichmitihrenzugangsdaten": "Melden Sie sich mit Ihren Zugangsdaten an",
"wählensieeinenworkspaceaufder": "Wählen Sie einen Workspace auf der Startseite",
"beginnensiemitdemanlegenvon": "Beginnen Sie mit dem Anlegen von Kontakten",
"richtensieihremailpostfach": "Richten Sie Ihr E-Mail-Postfach unter Einstellungen → Email ein",
"nutzensiedieglobalesuchelupe": "Nutzen Sie die globale Suche (Lupe oben) oder das Kommando-Palette (Strg+K) um schnell zu finden was Sie brauchen."
},
"logsOverview": {
"logsübersicht": "Logs Übersicht",
"systemundauditlogseinsehenfiltern": "System- und Audit-Logs einsehen, filtern und exportieren.",
"alleänderungennachverfolgen": "Alle Änderungen nachverfolgen",
"containerworkerdatenbank": "Container, Worker, Datenbank",
"apiundpluginfehler": "API- und Plugin-Fehler"
},
"logsPlaceholder": {
"dieseseitewirdgeradeerstelltwählen": "Diese Seite wird gerade erstellt. Wählen Sie ein Thema aus dem Menü links."
},
"index": {
"seitewirdgeladen": "Seite wird geladen"
} }
} }
+20 -17
View File
@@ -39,6 +39,7 @@ function ProviderTab() {
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
const { t } = useTranslation();
if (!confirm(t('aiSettings.confirmDeleteProvider'))) return; if (!confirm(t('aiSettings.confirmDeleteProvider'))) return;
try { await deleteProvider(id); await load(); } catch (e: unknown) { const errObj = asError(e); setError(errObj?.message || null); } try { await deleteProvider(id); await load(); } catch (e: unknown) { const errObj = asError(e); setError(errObj?.message || null); }
}; };
@@ -60,7 +61,7 @@ function ProviderTab() {
<option value="openai">OpenAI</option> <option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option> <option value="anthropic">Anthropic</option>
<option value="ollama">Ollama</option> <option value="ollama">Ollama</option>
<option value="azure">Azure OpenAI</option> <option value="azure">{t('aISettings.azureopenai')}</option>
<option value="huggingface">HuggingFace</option> <option value="huggingface">HuggingFace</option>
<option value="custom">Custom</option> <option value="custom">Custom</option>
</select> </select>
@@ -134,24 +135,24 @@ function PresetTab() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h2 className="text-lg font-semibold">Modelle & Presets</h2> <h2 className="text-lg font-semibold">{t('aISettings.modellepresets')}</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> <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> </div>
{error && <div className="text-sm text-red-600">{typeof error === "string" ? error : (error as any)?.message || "t('common.errorOccurred')"}</div>} {error && <div className="text-sm text-red-600">{typeof error === "string" ? error : (error as any)?.message || "t('common.errorOccurred')"}</div>}
{showForm && ( {showForm && (
<div className="border border-secondary-200 rounded-lg p-4 space-y-3 bg-secondary-50"> <div className="border border-secondary-200 rounded-lg p-4 space-y-3 bg-secondary-50">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<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={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="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" /> <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" />
<select value={form.provider_id} onChange={(e) => setForm({ ...form, provider_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="">Anbieter wählen...</option> <option value="">{t('aISettings.anbieterwählen')}</option>
{providers.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)} {providers.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select> </select>
<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" 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="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" 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="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" /> <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" />
</div> </div>
<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" /> <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" />
<div className="flex gap-2"> <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={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> <button onClick={() => setShowForm(false)} className="px-3 py-1.5 text-sm border rounded-lg">{t('aiSettings.cancel')} </button>
@@ -163,7 +164,7 @@ function PresetTab() {
<div key={p.id} className="border border-secondary-200 rounded-lg p-3 flex items-center justify-between"> <div key={p.id} className="border border-secondary-200 rounded-lg p-3 flex items-center justify-between">
<div> <div>
<div className="font-medium text-sm">{p.name}</div> <div className="font-medium text-sm">{p.name}</div>
<div className="text-xs text-secondary-500">{p.model_id} · temp={p.temperature} · max_tokens={p.max_tokens}</div> <div className="text-xs text-secondary-500">{p.model_id} {t('aISettings.temp')}{p.temperature} {t('aISettings.maxtokens2')}{p.max_tokens}</div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={() => handleEdit(p)} className="text-sm text-primary-600 hover:underline">{t('aiSettings.edit')} </button> <button onClick={() => handleEdit(p)} className="text-sm text-primary-600 hover:underline">{t('aiSettings.edit')} </button>
@@ -213,6 +214,7 @@ function AgentTab() {
}; };
const toggleTool = (toolName: string) => { const toggleTool = (toolName: string) => {
const { t } = useTranslation();
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
tool_ids: prev.tool_ids.includes(toolName) tool_ids: prev.tool_ids.includes(toolName)
@@ -234,13 +236,13 @@ function AgentTab() {
<div className="border border-secondary-200 rounded-lg p-4 space-y-3 bg-secondary-50"> <div className="border border-secondary-200 rounded-lg p-4 space-y-3 bg-secondary-50">
<div className="grid grid-cols-2 gap-3"> <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.name')} value={form.name} onChange={(e) => setForm({ ...form, name: 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" /> <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" />
</div> </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"> <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="">Preset wählen...</option> <option value="">{t('aISettings.presetwählen')}</option>
{presets.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.model_id})</option>)} {presets.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.model_id})</option>)}
</select> </select>
<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" /> <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" />
{tools.length > 0 && ( {tools.length > 0 && (
<div> <div>
<div className="text-sm font-medium mb-2">Tools:</div> <div className="text-sm font-medium mb-2">Tools:</div>
@@ -294,11 +296,11 @@ function ToolsTab() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-lg font-semibold">Verfügbare Tools</h2> <h2 className="text-lg font-semibold">{t('aISettings.verfügbaretools')}</h2>
<p className="text-sm text-secondary-500">Diese Tools werden von Plugins bereitgestellt und können Agenten zugewiesen werden.</p> <p className="text-sm text-secondary-500">{t('aISettings.diesetoolswerdenvonpluginsbereitgestellt')}</p>
{error && <div className="text-sm text-red-600">{typeof error === "string" ? error : (error as any)?.message || "t('common.errorOccurred')"}</div>} {error && <div className="text-sm text-red-600">{typeof error === "string" ? error : (error as any)?.message || "t('common.errorOccurred')"}</div>}
{tools.length === 0 ? ( {tools.length === 0 ? (
<div className="text-sm text-secondary-400 py-8 text-center">Keine Tools verfügbar. Plugins können Tools registrieren.</div> <div className="text-sm text-secondary-400 py-8 text-center">{t('aISettings.keinetoolsverfügbarpluginskönnentools')}</div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{tools.map((tool) => ( {tools.map((tool) => (
@@ -322,6 +324,7 @@ function ToolsTab() {
// ─── Main Settings Page ─── // ─── Main Settings Page ───
export function AISettingsPage() { export function AISettingsPage() {
const { t } = useTranslation();
const tabs = [ const tabs = [
{ key: 'providers', label: 'Anbieter', content: <ProviderTab /> }, { key: 'providers', label: 'Anbieter', content: <ProviderTab /> },
{ key: 'presets', label: 'Modelle & Presets', content: <PresetTab /> }, { key: 'presets', label: 'Modelle & Presets', content: <PresetTab /> },
@@ -331,7 +334,7 @@ export function AISettingsPage() {
return ( return (
<div className="max-w-4xl"> <div className="max-w-4xl">
<h1 className="text-2xl font-bold text-secondary-900 mb-6">KI Assistent Einstellungen</h1> <h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('aISettings.kiassistenteinstellungen')}</h1>
<Tabs tabs={tabs} /> <Tabs tabs={tabs} />
</div> </div>
); );
+4 -4
View File
@@ -184,7 +184,7 @@ function AgentForm({
onChange={(e) => updateField('name', e.target.value)} onChange={(e) => updateField('name', e.target.value)}
required 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" 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="My Agent" placeholder={t('agentDashboard.myagent')}
/> />
</div> </div>
<div> <div>
@@ -196,7 +196,7 @@ function AgentForm({
onChange={(e) => updateField('description', e.target.value)} onChange={(e) => updateField('description', e.target.value)}
rows={2} 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" 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="Optional description" placeholder={t('agentDashboard.optionaldescription')}
/> />
</div> </div>
</div> </div>
@@ -213,7 +213,7 @@ function AgentForm({
onChange={(e) => updateField('model', e.target.value)} onChange={(e) => updateField('model', e.target.value)}
list="model-suggestions" 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" 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="gpt-4" placeholder={t('agentDashboard.gpt4')}
/> />
<datalist id="model-suggestions"> <datalist id="model-suggestions">
{commonModels.map((m) => ( {commonModels.map((m) => (
@@ -233,7 +233,7 @@ function AgentForm({
onChange={(e) => updateField('system_prompt', e.target.value)} onChange={(e) => updateField('system_prompt', e.target.value)}
rows={4} 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" 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="You are a helpful assistant..." placeholder={t('agentDashboard.youareahelpfulassistant')}
/> />
</div> </div>
+6 -3
View File
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet } from 'react-router-dom';
import { useUIStore } from '@/store/uiStore'; import { useUIStore } from '@/store/uiStore';
import { ChevronRight, ChevronDown, Bot, Zap, Brain, Cpu, Settings2, Activity, MessageSquare, ArrowLeft } from 'lucide-react'; import { ChevronRight, ChevronDown, Bot, Zap, Brain, Cpu, Settings2, Activity, MessageSquare, ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface AgentNode { interface AgentNode {
title: string; title: string;
@@ -23,6 +24,7 @@ const AGENT_TREE: AgentNode[] = [
]; ];
function TreeItem({ node, depth }: { node: AgentNode; depth: number }) { function TreeItem({ node, depth }: { node: AgentNode; depth: number }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(depth < 1); const [expanded, setExpanded] = useState(depth < 1);
const hasChildren = node.children && node.children.length > 0; const hasChildren = node.children && node.children.length > 0;
const paddingLeft = depth * 16 + 12; const paddingLeft = depth * 16 + 12;
@@ -70,6 +72,7 @@ function TreeItem({ node, depth }: { node: AgentNode; depth: number }) {
} }
export function AgentsPage() { export function AgentsPage() {
const { t } = useTranslation();
const sidebarOpen = useUIStore((state) => state.sidebarOpen); const sidebarOpen = useUIStore((state) => state.sidebarOpen);
return ( return (
@@ -80,14 +83,14 @@ export function AgentsPage() {
<button <button
onClick={() => window.location.href = '/start'} 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" 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="Zurück zur Startseite" aria-label={t('agents.zurückzurstartseite')}
title="Zurück zur Startseite" title={t('agents.zurückzurstartseite')}
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</button> </button>
<h1 className="text-xl font-bold text-secondary-900">Agenten</h1> <h1 className="text-xl font-bold text-secondary-900">Agenten</h1>
</div> </div>
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label="Agenten Navigation"> <nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label={t('agents.agentennavigation')}>
{AGENT_TREE.map((node) => ( {AGENT_TREE.map((node) => (
<TreeItem key={node.title} node={node} depth={0} /> <TreeItem key={node.title} node={node} depth={0} />
))} ))}
+3 -3
View File
@@ -115,19 +115,19 @@ export function AuditLogPage() {
label={t('auditLog.user')} label={t('auditLog.user')}
value={filterUser} value={filterUser}
onChange={(e) => setFilterUser(e.target.value)} onChange={(e) => setFilterUser(e.target.value)}
placeholder="anna.schmidt" placeholder={t('auditLog.annaschmidt')}
/> />
<Input <Input
label={t('auditLog.action')} label={t('auditLog.action')}
value={filterAction} value={filterAction}
onChange={(e) => setFilterAction(e.target.value)} onChange={(e) => setFilterAction(e.target.value)}
placeholder="create, update, delete" placeholder={t('auditLog.createupdatedelete')}
/> />
<Input <Input
label={t('auditLog.entity')} label={t('auditLog.entity')}
value={filterEntity} value={filterEntity}
onChange={(e) => setFilterEntity(e.target.value)} onChange={(e) => setFilterEntity(e.target.value)}
placeholder="company, contact" placeholder={t('auditLog.companycontact')}
/> />
<Input <Input
label={t('auditLog.dateFrom')} label={t('auditLog.dateFrom')}
+6 -3
View File
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet } from 'react-router-dom';
import { useUIStore } from '@/store/uiStore'; import { useUIStore } from '@/store/uiStore';
import { ChevronRight, ChevronDown, Zap, Play, History, GitBranch, Settings2, Activity, Clock, ArrowLeft } from 'lucide-react'; import { ChevronRight, ChevronDown, Zap, Play, History, GitBranch, Settings2, Activity, Clock, ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface AutomationNode { interface AutomationNode {
title: string; title: string;
@@ -32,6 +33,7 @@ const AUTOMATION_TREE: AutomationNode[] = [
]; ];
function TreeItem({ node, depth }: { node: AutomationNode; depth: number }) { function TreeItem({ node, depth }: { node: AutomationNode; depth: number }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(depth < 1); const [expanded, setExpanded] = useState(depth < 1);
const hasChildren = node.children && node.children.length > 0; const hasChildren = node.children && node.children.length > 0;
const paddingLeft = depth * 16 + 12; const paddingLeft = depth * 16 + 12;
@@ -79,6 +81,7 @@ function TreeItem({ node, depth }: { node: AutomationNode; depth: number }) {
} }
export function AutomationPage() { export function AutomationPage() {
const { t } = useTranslation();
const sidebarOpen = useUIStore((state) => state.sidebarOpen); const sidebarOpen = useUIStore((state) => state.sidebarOpen);
return ( return (
@@ -89,14 +92,14 @@ export function AutomationPage() {
<button <button
onClick={() => window.location.href = '/start'} 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" 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="Zurück zur Startseite" aria-label={t('automation.zurückzurstartseite')}
title="Zurück zur Startseite" title={t('automation.zurückzurstartseite')}
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</button> </button>
<h1 className="text-xl font-bold text-secondary-900">Automation</h1> <h1 className="text-xl font-bold text-secondary-900">Automation</h1>
</div> </div>
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label="Automation Navigation"> <nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label={t('automation.automationnavigation')}>
{AUTOMATION_TREE.map((node) => ( {AUTOMATION_TREE.map((node) => (
<TreeItem key={node.title} node={node} depth={0} /> <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)} onChange={(e) => updateField('name', e.target.value)}
required 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" 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="My Automation" placeholder={t('automationDashboard.myautomation')}
/> />
</div> </div>
<div> <div>
@@ -227,7 +227,7 @@ function AutomationForm({
onChange={(e) => updateField('description', e.target.value)} onChange={(e) => updateField('description', e.target.value)}
rows={2} 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" 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="Optional description" placeholder={t('automationDashboard.optionaldescription')}
/> />
</div> </div>
</div> </div>
@@ -255,7 +255,7 @@ function AutomationForm({
value={form.trigger_config.event_name || ''} value={form.trigger_config.event_name || ''}
onChange={(e) => updateField('trigger_config', { ...form.trigger_config, event_name: e.target.value })} 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" 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="contact.created" placeholder={t('automationDashboard.contactcreated')}
/> />
</div> </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" 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 * * *" placeholder="0 9 * * *"
/> />
<p className="text-xs text-secondary-400 mt-1">Cron expression (e.g. 0 9 * * * for daily at 9am)</p> <p className="text-xs text-secondary-400 mt-1">{t('automationDashboard.cronexpressioneg09')}</p>
</div> </div>
)} )}
@@ -294,7 +294,7 @@ function AutomationForm({
type="text" type="text"
value={cond.field} value={cond.field}
onChange={(e) => updateCondition(i, 'field', e.target.value)} onChange={(e) => updateCondition(i, 'field', e.target.value)}
placeholder="Field" placeholder={t('automationDashboard.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" 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 <Select
@@ -307,14 +307,14 @@ function AutomationForm({
type="text" type="text"
value={cond.value} value={cond.value}
onChange={(e) => updateCondition(i, 'value', e.target.value)} onChange={(e) => updateCondition(i, 'value', e.target.value)}
placeholder="Value" placeholder={t('automationDashboard.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" 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 <button
type="button" type="button"
onClick={() => removeCondition(i)} onClick={() => removeCondition(i)}
className="text-danger-500 hover:text-danger-700 p-1" className="text-danger-500 hover:text-danger-700 p-1"
aria-label="Remove condition" aria-label={t('automationDashboard.removecondition')}
> >
<XCircle className="h-4 w-4" /> <XCircle className="h-4 w-4" />
</button> </button>
@@ -353,14 +353,14 @@ function AutomationForm({
// ignore invalid JSON while typing // ignore invalid JSON while typing
} }
}} }}
placeholder='{"url": "..."}' placeholder={t('automationDashboard.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" 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 <button
type="button" type="button"
onClick={() => removeAction(i)} onClick={() => removeAction(i)}
className="text-danger-500 hover:text-danger-700 p-1" className="text-danger-500 hover:text-danger-700 p-1"
aria-label="Remove action" aria-label={t('automationDashboard.removeaction')}
> >
<XCircle className="h-4 w-4" /> <XCircle className="h-4 w-4" />
</button> </button>
+6 -6
View File
@@ -148,7 +148,7 @@ export function AutomationSettingsPage() {
value={form.default_llm_model} value={form.default_llm_model}
onChange={(e) => updateField('default_llm_model', e.target.value)} 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" 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="gpt-4" placeholder={t('automationSettings.gpt4')}
/> />
<p className="text-xs text-secondary-400 mt-1">{t('automation.defaultLlmModelHint')}</p> <p className="text-xs text-secondary-400 mt-1">{t('automation.defaultLlmModelHint')}</p>
</div> </div>
@@ -272,7 +272,7 @@ export function AutomationSettingsPage() {
value={miniAppForm.app_id} value={miniAppForm.app_id}
onChange={(e) => setMiniAppForm({ ...miniAppForm, app_id: e.target.value })} onChange={(e) => setMiniAppForm({ ...miniAppForm, app_id: e.target.value })}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm" className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
placeholder="my_custom_app" placeholder={t('automationSettings.mycustomapp')}
/> />
</div> </div>
<div> <div>
@@ -282,7 +282,7 @@ export function AutomationSettingsPage() {
value={miniAppForm.name} value={miniAppForm.name}
onChange={(e) => setMiniAppForm({ ...miniAppForm, name: e.target.value })} onChange={(e) => setMiniAppForm({ ...miniAppForm, name: e.target.value })}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm" className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
placeholder="My Custom App" placeholder={t('automationSettings.mycustomapp2')}
/> />
</div> </div>
<div> <div>
@@ -292,7 +292,7 @@ export function AutomationSettingsPage() {
value={miniAppForm.icon} value={miniAppForm.icon}
onChange={(e) => setMiniAppForm({ ...miniAppForm, icon: e.target.value })} onChange={(e) => setMiniAppForm({ ...miniAppForm, icon: e.target.value })}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm" className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
placeholder="AppWindow" placeholder={t('automationSettings.appwindow')}
/> />
</div> </div>
<div> <div>
@@ -302,7 +302,7 @@ export function AutomationSettingsPage() {
onChange={(e) => setMiniAppForm({ ...miniAppForm, description: e.target.value })} onChange={(e) => setMiniAppForm({ ...miniAppForm, description: e.target.value })}
rows={2} rows={2}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm" className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
placeholder="Optional description" placeholder={t('automationSettings.optionaldescription')}
/> />
</div> </div>
<div> <div>
@@ -312,7 +312,7 @@ export function AutomationSettingsPage() {
onChange={(e) => setMiniAppForm({ ...miniAppForm, render_schema: e.target.value })} onChange={(e) => setMiniAppForm({ ...miniAppForm, render_schema: e.target.value })}
rows={4} rows={4}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono" className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono"
placeholder='{"type": "form", "fields": []}' placeholder={t('automationSettings.typeformfields')}
/> />
</div> </div>
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200"> <div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
+14 -9
View File
@@ -12,6 +12,7 @@ import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { usePluginToolbarStore } from '@/store/pluginToolbarStore'; import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
import { streamChat, fetchAgents, fetchMessages as fetchAiMessages, type AIAgent } from '@/api/ai'; import { streamChat, fetchAgents, fetchMessages as fetchAiMessages, type AIAgent } from '@/api/ai';
import { useTranslation } from 'react-i18next';
// ─── Types ─── // ─── Types ───
@@ -216,6 +217,7 @@ interface ConversationTreeProps {
} }
function ConversationTree({ conversations, activeConvId, onSelect, onNewChat, loading }: ConversationTreeProps) { function ConversationTree({ conversations, activeConvId, onSelect, onNewChat, loading }: ConversationTreeProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<Record<ConversationCategory, boolean>>({ const [expanded, setExpanded] = useState<Record<ConversationCategory, boolean>>({
system: true, system: true,
ai: true, ai: true,
@@ -293,7 +295,7 @@ function ConversationTree({ conversations, activeConvId, onSelect, onNewChat, lo
</button> </button>
))} ))}
{expanded[section.category] && section.conversations.length === 0 && ( {expanded[section.category] && section.conversations.length === 0 && (
<div className="px-6 py-2 text-xs text-secondary-400">Keine Konversationen</div> <div className="px-6 py-2 text-xs text-secondary-400">{t('communication.keinekonversationen')}</div>
)} )}
</div> </div>
))} ))}
@@ -311,6 +313,7 @@ interface ChatWindowProps {
} }
function ChatWindow({ convId, category, onBack }: ChatWindowProps) { function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
const { t } = useTranslation();
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -499,11 +502,11 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
<button <button
onClick={() => setShowMiniApps(!showMiniApps)} onClick={() => setShowMiniApps(!showMiniApps)}
className="p-1 hover:bg-secondary-100 rounded" className="p-1 hover:bg-secondary-100 rounded"
title="Mini-Apps" title={t('communication.miniapps')}
> >
<Sparkles className="w-4 h-4 text-secondary-400" /> <Sparkles className="w-4 h-4 text-secondary-400" />
</button> </button>
<button className="p-1 hover:bg-secondary-100 rounded" title="In neuem Fenster"> <button className="p-1 hover:bg-secondary-100 rounded" title={t('communication.inneuemfenster')}>
<ExternalLink className="w-4 h-4 text-secondary-400" /> <ExternalLink className="w-4 h-4 text-secondary-400" />
</button> </button>
</div> </div>
@@ -537,7 +540,7 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
{!loading && messages.length === 0 && !aiStreaming && ( {!loading && messages.length === 0 && !aiStreaming && (
<div className="text-center text-secondary-400 py-8"> <div className="text-center text-secondary-400 py-8">
<MessageSquare className="w-10 h-10 mx-auto mb-2 opacity-50" /> <MessageSquare className="w-10 h-10 mx-auto mb-2 opacity-50" />
<p className="text-sm">Keine Nachrichten. Schreibe die erste!</p> <p className="text-sm">{t('communication.keinenachrichtenschreibedieerste')}</p>
</div> </div>
)} )}
{messages.map(msg => ( {messages.map(msg => (
@@ -549,7 +552,7 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
<Bot className="w-4 h-4 text-primary-600" /> <Bot className="w-4 h-4 text-primary-600" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="text-xs text-secondary-500 mb-1">KI antwortet...</div> <div className="text-xs text-secondary-500 mb-1">{t('communication.kiantwortet')}</div>
<div className="ai-markdown max-w-none break-words"> <div className="ai-markdown max-w-none break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{streamingContent || '...'}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]}>{streamingContent || '...'}</ReactMarkdown>
</div> </div>
@@ -561,10 +564,10 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
{/* Input */} {/* Input */}
<div className="border-t border-secondary-200 p-3"> <div className="border-t border-secondary-200 p-3">
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<button className="p-2 hover:bg-secondary-100 rounded-lg" title="Datei anhängen"> <button className="p-2 hover:bg-secondary-100 rounded-lg" title={t('communication.dateianhängen')}>
<Paperclip className="w-4 h-4 text-secondary-400" /> <Paperclip className="w-4 h-4 text-secondary-400" />
</button> </button>
<button className="p-2 hover:bg-secondary-100 rounded-lg" title="Emoji"> <button className="p-2 hover:bg-secondary-100 rounded-lg" title={t('communication.emoji')}>
<Smile className="w-4 h-4 text-secondary-400" /> <Smile className="w-4 h-4 text-secondary-400" />
</button> </button>
<textarea <textarea
@@ -667,6 +670,7 @@ interface NewChatDialogProps {
} }
function NewChatDialog({ category, onClose, onCreate }: NewChatDialogProps) { function NewChatDialog({ category, onClose, onCreate }: NewChatDialogProps) {
const { t } = useTranslation();
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [users, setUsers] = useState<{id: string; name: string; email: string}[]>([]); const [users, setUsers] = useState<{id: string; name: string; email: string}[]>([]);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
@@ -707,7 +711,7 @@ function NewChatDialog({ category, onClose, onCreate }: NewChatDialogProps) {
/> />
{category === 'colleague' && ( {category === 'colleague' && (
<div> <div>
<label className="text-sm font-medium text-secondary-700 mb-1 block">Teilnehmer auswählen</label> <label className="text-sm font-medium text-secondary-700 mb-1 block">{t('communication.teilnehmerauswählen')}</label>
{loading ? ( {loading ? (
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
) : ( ) : (
@@ -745,6 +749,7 @@ function NewChatDialog({ category, onClose, onCreate }: NewChatDialogProps) {
// ─── Main Page ─── // ─── Main Page ───
export function CommunicationPage() { export function CommunicationPage() {
const { t } = useTranslation();
const [conversations, setConversations] = useState<Conversation[]>([]); const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConvId, setActiveConvId] = useState<string | null>(null); const [activeConvId, setActiveConvId] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -851,7 +856,7 @@ export function CommunicationPage() {
<div className="flex items-center justify-center h-full text-secondary-400"> <div className="flex items-center justify-center h-full text-secondary-400">
<div className="text-center"> <div className="text-center">
<MessageSquare className="w-12 h-12 mx-auto mb-3 opacity-50" /> <MessageSquare className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>Wähle eine Konversation aus</p> <p>{t('communication.wähleeinekonversationaus')}</p>
</div> </div>
</div> </div>
)} )}
+4 -4
View File
@@ -537,13 +537,13 @@ export function ContactsListPage() {
{/* Placeholder for saved custom views */} {/* Placeholder for saved custom views */}
{savedViews.length === 0 ? ( {savedViews.length === 0 ? (
<div className="px-2 py-3 text-center"> <div className="px-2 py-3 text-center">
<p className="text-[11px] text-secondary-400 mb-2">Keine benutzerdefinierten Ansichten</p> <p className="text-[11px] text-secondary-400 mb-2">{t('contactsList.keinebenutzerdefiniertenansichten')}</p>
<button <button
onClick={handleSaveView} 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" 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" /> <Plus className="w-3 h-3" />
Aktuelle Ansicht speichern {t('contactsList.aktuelleansichtspeichern')}
</button> </button>
</div> </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" 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" /> <Plus className="w-3 h-3" />
Aktuelle Ansicht speichern {t('contactsList.aktuelleansichtspeichern')}
</button> </button>
</> </>
)} )}
@@ -756,7 +756,7 @@ export function ContactsListPage() {
{/* Saved Filters Modal */} {/* Saved Filters Modal */}
{savedFiltersOpen && ( {savedFiltersOpen && (
<Modal open={savedFiltersOpen} onClose={() => setSavedFiltersOpen(false)} title="Gespeicherte Filter"> <Modal open={savedFiltersOpen} onClose={() => setSavedFiltersOpen(false)} title={t('contactsList.gespeichertefilter')}>
<SavedFilters <SavedFilters
entityType="contacts" entityType="contacts"
currentCriteria={{ search: debouncedSearch, type: contactType, sortBy, sortOrder, folderId }} currentCriteria={{ search: debouncedSearch, type: contactType, sortBy, sortOrder, folderId }}
+3 -3
View File
@@ -394,7 +394,7 @@ export function CustomFieldsPage() {
required required
value={form.label} value={form.label}
onChange={(e) => handleLabelChange(e.target.value)} onChange={(e) => handleLabelChange(e.target.value)}
placeholder="z.B. Branche, Abteilung, Geburtsdatum" placeholder={t('customFields.zbbrancheabteilunggeburtsdatum')}
/> />
{/* Name (auto-generated) */} {/* Name (auto-generated) */}
@@ -404,7 +404,7 @@ export function CustomFieldsPage() {
value={form.name} value={form.name}
onChange={(e) => handleNameChange(e.target.value)} onChange={(e) => handleNameChange(e.target.value)}
helperText="Wird automatisch aus der Bezeichnung generiert" helperText="Wird automatisch aus der Bezeichnung generiert"
placeholder="z.B. branche, abteilung, geburtsdatum" placeholder={t('customFields.zbbrancheabteilunggeburtsdatum2')}
/> />
{/* Field type */} {/* Field type */}
@@ -423,7 +423,7 @@ export function CustomFieldsPage() {
required required
value={form.optionsStr} value={form.optionsStr}
onChange={(e) => setForm((prev) => ({ ...prev, optionsStr: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, optionsStr: e.target.value }))}
placeholder="Option 1, Option 2, Option 3" placeholder={t('customFields.option1option2option3')}
helperText="Trennen Sie die Optionen mit Kommas" 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="border border-secondary-200 rounded-lg p-4 bg-white">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<DollarSign className="w-4 h-4 text-warning-600" /> <DollarSign className="w-4 h-4 text-warning-600" />
<span className="text-sm font-medium text-secondary-900">LLM Cost (24h)</span> <span className="text-sm font-medium text-secondary-900">{t('dashboard.llmcost24h')}</span>
</div> </div>
<p className="text-2xl font-bold text-secondary-900">${systemData.llm.last_24h_cost?.toFixed(2) ?? '0.00'}</p> <p className="text-2xl font-bold text-secondary-900">${systemData.llm.last_24h_cost?.toFixed(2) ?? '0.00'}</p>
</div> </div>
<div className="border border-secondary-200 rounded-lg p-4 bg-white"> <div className="border border-secondary-200 rounded-lg p-4 bg-white">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-4 h-4 text-primary-600" /> <TrendingUp className="w-4 h-4 text-primary-600" />
<span className="text-sm font-medium text-secondary-900">LLM Tokens (24h)</span> <span className="text-sm font-medium text-secondary-900">{t('dashboard.llmtokens24h')}</span>
</div> </div>
<p className="text-2xl font-bold text-secondary-900">{systemData.llm.last_24h_tokens?.toLocaleString() ?? '0'}</p> <p className="text-2xl font-bold text-secondary-900">{systemData.llm.last_24h_tokens?.toLocaleString() ?? '0'}</p>
</div> </div>
<div className="border border-secondary-200 rounded-lg p-4 bg-white"> <div className="border border-secondary-200 rounded-lg p-4 bg-white">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-primary-600" /> <Activity className="w-4 h-4 text-primary-600" />
<span className="text-sm font-medium text-secondary-900">Active Plugins</span> <span className="text-sm font-medium text-secondary-900">{t('dashboard.activeplugins')}</span>
</div> </div>
<p className="text-2xl font-bold text-secondary-900">{systemData.plugins?.active_plugins?.length ?? '—'}</p> <p className="text-2xl font-bold text-secondary-900">{systemData.plugins?.active_plugins?.length ?? '—'}</p>
</div> </div>
+3 -1
View File
@@ -2,8 +2,10 @@
// Guest contacts page now redirects to normal contacts page // Guest contacts page now redirects to normal contacts page
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export function GuestContactsPage() { export function GuestContactsPage() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
@@ -13,7 +15,7 @@ export function GuestContactsPage() {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<p className="text-gray-500">Weiterleitung zu Kontakten...</p> <p className="text-gray-500">{t('guestContacts.weiterleitungzukontakten')}</p>
</div> </div>
); );
} }
+3 -1
View File
@@ -2,8 +2,10 @@
// Guest login page now redirects to normal login // Guest login page now redirects to normal login
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export function GuestLoginPage() { export function GuestLoginPage() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
@@ -13,7 +15,7 @@ export function GuestLoginPage() {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<p className="text-gray-500">Weiterleitung zum Login...</p> <p className="text-gray-500">{t('guestLogin.weiterleitungzumlogin')}</p>
</div> </div>
); );
} }
+3 -3
View File
@@ -142,14 +142,14 @@ export function HelpPage() {
<button <button
onClick={() => window.location.href = '/start'} 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" 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="Zurück zur Startseite" aria-label={t('help.zurückzurstartseite')}
title="Zurück zur Startseite" title={t('help.zurückzurstartseite')}
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</button> </button>
<h1 className="text-xl font-bold text-secondary-900">Hilfe</h1> <h1 className="text-xl font-bold text-secondary-900">Hilfe</h1>
</div> </div>
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label="Hilfe Navigation"> <nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label={t('help.hilfenavigation')}>
{HELP_TREE.map((node) => ( {HELP_TREE.map((node) => (
<TreeItem key={node.title} node={node} depth={0} /> <TreeItem key={node.title} node={node} depth={0} />
))} ))}
+1 -1
View File
@@ -38,7 +38,7 @@ export function ImportExportPage() {
{/* Tab navigation */} {/* Tab navigation */}
<div className="border-b border-secondary-200"> <div className="border-b border-secondary-200">
<nav className="flex gap-1" aria-label="Tabs"> <nav className="flex gap-1" aria-label={t('importExport.tabs')}>
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.key} key={tab.key}
+6 -3
View File
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet } from 'react-router-dom';
import { useUIStore } from '@/store/uiStore'; import { useUIStore } from '@/store/uiStore';
import { ChevronRight, ChevronDown, ScrollText, FileText, AlertTriangle, ArrowLeft } from 'lucide-react'; import { ChevronRight, ChevronDown, ScrollText, FileText, AlertTriangle, ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface LogNode { interface LogNode {
title: string; title: string;
@@ -40,6 +41,7 @@ const LOG_TREE: LogNode[] = [
]; ];
function TreeItem({ node, depth }: { node: LogNode; depth: number }) { function TreeItem({ node, depth }: { node: LogNode; depth: number }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(depth < 1); const [expanded, setExpanded] = useState(depth < 1);
const hasChildren = node.children && node.children.length > 0; const hasChildren = node.children && node.children.length > 0;
const paddingLeft = depth * 16 + 12; const paddingLeft = depth * 16 + 12;
@@ -87,6 +89,7 @@ function TreeItem({ node, depth }: { node: LogNode; depth: number }) {
} }
export function LogsPage() { export function LogsPage() {
const { t } = useTranslation();
const sidebarOpen = useUIStore((state) => state.sidebarOpen); const sidebarOpen = useUIStore((state) => state.sidebarOpen);
return ( return (
@@ -97,14 +100,14 @@ export function LogsPage() {
<button <button
onClick={() => window.location.href = '/start'} 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" 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="Zurück zur Startseite" aria-label={t('logs.zurückzurstartseite')}
title="Zurück zur Startseite" title={t('logs.zurückzurstartseite')}
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</button> </button>
<h1 className="text-xl font-bold text-secondary-900">Logs</h1> <h1 className="text-xl font-bold text-secondary-900">Logs</h1>
</div> </div>
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label="Logs Navigation"> <nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label={t('logs.logsnavigation')}>
{LOG_TREE.map((node) => ( {LOG_TREE.map((node) => (
<TreeItem key={node.title} node={node} depth={0} /> <TreeItem key={node.title} node={node} depth={0} />
))} ))}
+2 -2
View File
@@ -970,7 +970,7 @@ export function MailPage() {
<button <button
onClick={() => setActiveView('folders')} 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" 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="Zurück zu Ordnern" aria-label={t('mail.zurückzuordnern')}
data-testid="mobile-back-to-folders" data-testid="mobile-back-to-folders"
> >
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} /> <ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
@@ -1003,7 +1003,7 @@ export function MailPage() {
<button <button
onClick={() => setActiveView('list')} 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" 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="Zurück zur Liste" aria-label={t('mail.zurückzurliste')}
data-testid="mobile-back-to-list" data-testid="mobile-back-to-list"
> >
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} /> <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')} label={t('mail.email')}
{...registerAccount('email')} {...registerAccount('email')}
error={accountErrors.email?.message === 'required' ? t('validation.required') : accountErrors.email?.message === 'invalidEmail' ? t('validation.email') : undefined} error={accountErrors.email?.message === 'required' ? t('validation.required') : accountErrors.email?.message === 'invalidEmail' ? t('validation.email') : undefined}
placeholder="user@example.com" placeholder={t('mailSettings.userexamplecom')}
required required
/> />
<Input <Input
label={t('mail.displayName')} label={t('mail.displayName')}
{...registerAccount('display_name')} {...registerAccount('display_name')}
placeholder="John Doe" placeholder={t('mailSettings.johndoe')}
/> />
<Input <Input
label="Benutzername (IMAP/SMTP)" label="Benutzername (IMAP/SMTP)"
{...registerAccount('username')} {...registerAccount('username')}
placeholder="Leer lassen für E-Mail-Adresse" placeholder={t('mailSettings.leerlassenfüremailadresse')}
/> />
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Input <Input
label={t('mail.imapHost')} label={t('mail.imapHost')}
{...registerAccount('imap_host')} {...registerAccount('imap_host')}
error={accountErrors.imap_host?.message === 'required' ? t('validation.required') : undefined} error={accountErrors.imap_host?.message === 'required' ? t('validation.required') : undefined}
placeholder="imap.example.com" placeholder={t('mailSettings.imapexamplecom')}
/> />
<Input <Input
label={t('mail.imapPort')} label={t('mail.imapPort')}
@@ -253,7 +253,7 @@ export function MailSettingsPage() {
label={t('mail.smtpHost')} label={t('mail.smtpHost')}
{...registerAccount('smtp_host')} {...registerAccount('smtp_host')}
error={accountErrors.smtp_host?.message === 'required' ? t('validation.required') : undefined} error={accountErrors.smtp_host?.message === 'required' ? t('validation.required') : undefined}
placeholder="smtp.example.com" placeholder={t('mailSettings.smtpexamplecom')}
/> />
<Input <Input
label={t('mail.smtpPort')} label={t('mail.smtpPort')}
@@ -275,7 +275,7 @@ export function MailSettingsPage() {
{...registerAccount('is_shared')} {...registerAccount('is_shared')}
className="rounded" className="rounded"
/> />
Geteiltes Postfach (für alle Tenant-Benutzer sichtbar) {t('mailSettings.geteiltespostfachfüralletenantbenutzer')}
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="submit" isLoading={accountSubmitting} size="sm">{t('common.save')}</Button> <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" className="text-xs text-secondary-400 hover:text-secondary-600"
data-testid={`folder-mapping-btn-${acc.id}`} data-testid={`folder-mapping-btn-${acc.id}`}
> >
Ordner-Zuordnung bearbeiten {t('mailSettings.ordnerzuordnungbearbeiten')}
</button> </button>
</div> </div>
)} )}
+5 -4
View File
@@ -1,21 +1,22 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ShieldX } from 'lucide-react'; import { ShieldX } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export function NoAccessPage() { export function NoAccessPage() {
const { t } = useTranslation();
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-secondary-50 p-4"> <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} /> <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">Kein Zugriff</h1> <h1 className="text-2xl font-bold text-secondary-700 mb-2">{t('noAccessPage.keinzugriff')}</h1>
<p className="text-secondary-500 mb-6 text-center max-w-md"> <p className="text-secondary-500 mb-6 text-center max-w-md">
Sie haben keine Berechtigung, auf diese Seite zuzugreifen. {t('noAccessPage.siehabenkeineberechtigungaufdiese')}
Bitte wenden Sie sich an einen Administrator, falls Sie Zugriff benötigen.
</p> </p>
<Link <Link
to="/dashboard" to="/dashboard"
className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors" className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
> >
Zum Dashboard {t('noAccessPage.zumdashboard')}
</Link> </Link>
</div> </div>
); );
+1 -1
View File
@@ -397,7 +397,7 @@ export function ReportsPage() {
<textarea <textarea
value={jsonData} value={jsonData}
onChange={(e) => setJsonData(e.target.value)} onChange={(e) => setJsonData(e.target.value)}
placeholder='{"key": "value"}' placeholder={t('reports.keyvalue')}
className="w-full text-xs font-mono border border-secondary-300 rounded px-2 py-1.5 h-40 resize-none" className="w-full text-xs font-mono border border-secondary-300 rounded px-2 py-1.5 h-40 resize-none"
spellCheck={false} spellCheck={false}
data-testid="textarea-json-data" data-testid="textarea-json-data"
+2 -2
View File
@@ -74,8 +74,8 @@ export function SettingsPage() {
<button <button
onClick={() => window.location.href = '/start'} 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" 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="Zurück zur Startseite" aria-label={t('settings.zurückzurstartseite')}
title="Zurück zur Startseite" title={t('settings.zurückzurstartseite')}
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</button> </button>
+1 -1
View File
@@ -167,7 +167,7 @@ function RestoreModal({ open, backup, onConfirm, onCancel, isRestoring }: Restor
type="text" type="text"
value={confirmText} value={confirmText}
onChange={(e) => setConfirmText(e.target.value)} onChange={(e) => setConfirmText(e.target.value)}
placeholder="RESTORE" placeholder={t('settingsBackup.restore')}
className={clsx( className={clsx(
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch', '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', '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)} onChange={(e) => setExecuteToolName(e.target.value)}
className="border border-secondary-300 rounded px-3 py-1.5 text-sm" className="border border-secondary-300 rounded px-3 py-1.5 text-sm"
> >
<option value="">-- Select --</option> <option value="">{t('settingsMcp.select')}</option>
{toolsData?.tools.map((tool) => ( {toolsData?.tools.map((tool) => (
<option key={tool.name} value={tool.name}>{tool.name}</option> <option key={tool.name} value={tool.name}>{tool.name}</option>
))} ))}
+1 -1
View File
@@ -149,7 +149,7 @@ function InstallPluginSection() {
type="text" type="text"
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/plugin.zip" placeholder={t('settingsPlugins.httpsexamplecompluginzip')}
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" 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" data-testid="plugin-url-input"
/> />
+6 -6
View File
@@ -198,7 +198,7 @@ function FreigabenTab() {
<button <button
onClick={() => setConfirmDelete(row)} onClick={() => setConfirmDelete(row)}
className="p-1 rounded hover:bg-danger-50 text-danger-600" className="p-1 rounded hover:bg-danger-50 text-danger-600"
aria-label="Löschen" aria-label={t('settingsRechte.löschen')}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
@@ -217,7 +217,7 @@ function FreigabenTab() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card title="Freigaben Übersicht"> <Card title={t('settingsRechte.freigabenübersicht')}>
<div className="flex gap-4 mb-4"> <div className="flex gap-4 mb-4">
<div className="w-64"> <div className="w-64">
<Select <Select
@@ -233,7 +233,7 @@ function FreigabenTab() {
<div className="w-64"> <div className="w-64">
<Input <Input
label="Nach Principal suchen" label="Nach Principal suchen"
placeholder="Name oder ID..." placeholder={t('settingsRechte.nameoderid')}
value={filterPrincipal} value={filterPrincipal}
onChange={(e) => setFilterPrincipal(e.target.value)} onChange={(e) => setFilterPrincipal(e.target.value)}
/> />
@@ -253,7 +253,7 @@ function FreigabenTab() {
open={!!confirmDelete} open={!!confirmDelete}
onCancel={() => setConfirmDelete(null)} onCancel={() => setConfirmDelete(null)}
onConfirm={handleDelete} onConfirm={handleDelete}
title="Berechtigung löschen" title={t('settingsRechte.berechtigunglöschen')}
message={`Soll die Berechtigung für ${entityTypeMap.get(confirmDelete.entity_type) || confirmDelete.entity_type} wirklich gelöscht werden?`} 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'} confirmLabel={deleting ? 'Wird gelöscht...' : 'Löschen'}
variant="danger" variant="danger"
@@ -325,7 +325,7 @@ function AuditTab() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card title="Audit-Log für Berechtigungen"> <Card title={t('settingsRechte.auditlogfürberechtigungen')}>
<Table <Table
columns={columns} columns={columns}
data={data?.items || []} data={data?.items || []}
@@ -384,7 +384,7 @@ export function SettingsRechtePage() {
<h1 className="text-2xl font-bold text-secondary-900">Rechteverwaltung</h1> <h1 className="text-2xl font-bold text-secondary-900">Rechteverwaltung</h1>
<div className="border-b border-secondary-200"> <div className="border-b border-secondary-200">
<nav className="flex gap-4" role="tablist" aria-label="Rechteverwaltung Tabs"> <nav className="flex gap-4" role="tablist" aria-label={t('settingsRechte.rechteverwaltungtabs')}>
{tabs.map((tab) => { {tabs.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
return ( return (
+1 -1
View File
@@ -127,7 +127,7 @@ export function SettingsSequencesPage() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm font-medium text-secondary-700">{t('sequences.prefix')}</label> <label className="block text-sm font-medium text-secondary-700">{t('sequences.prefix')}</label>
<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" /> <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" />
{errors.prefix && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.prefix.message)}</p>} {errors.prefix && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.prefix.message)}</p>}
</div> </div>
<div> <div>
+9 -9
View File
@@ -167,10 +167,10 @@ function AdressenTab() {
{ {
key: 'actions', header: '', render: (row) => ( key: 'actions', header: '', render: (row) => (
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label="Bearbeiten"> <button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label={t('settingsStammdaten.bearbeiten')}>
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</button> </button>
<button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label="Löschen"> <button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label={t('settingsStammdaten.löschen')}>
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
@@ -180,14 +180,14 @@ function AdressenTab() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card title="Adressverwaltung" actions={ <Card title={t('settingsStammdaten.adressverwaltung')} actions={
<Button size="sm" onClick={handleAdd}> <Button size="sm" onClick={handleAdd}>
<Plus className="w-4 h-4 mr-1" /> <Plus className="w-4 h-4 mr-1" />
{t('common.add', 'Hinzufügen')} {t('common.add', 'Hinzufügen')}
</Button> </Button>
}> }>
{loading ? ( {loading ? (
<div className="text-center py-4 text-secondary-500">Lade Adressen...</div> <div className="text-center py-4 text-secondary-500">{t('settingsStammdaten.ladeadressen')}</div>
) : ( ) : (
<Table columns={columns} data={addresses} rowKey={(row) => row.id} emptyMessage="Noch keine Adressen angelegt" /> <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) => ( key: 'actions', header: '', render: (row) => (
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label="Bearbeiten"> <button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label={t('settingsStammdaten.bearbeiten')}>
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</button> </button>
<button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label="Löschen"> <button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label={t('settingsStammdaten.löschen')}>
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
@@ -365,14 +365,14 @@ function KontenTab() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card title="Bankkontenverwaltung" actions={ <Card title={t('settingsStammdaten.bankkontenverwaltung')} actions={
<Button size="sm" onClick={handleAdd}> <Button size="sm" onClick={handleAdd}>
<Plus className="w-4 h-4 mr-1" /> <Plus className="w-4 h-4 mr-1" />
{t('common.add', 'Hinzufügen')} {t('common.add', 'Hinzufügen')}
</Button> </Button>
}> }>
{loading ? ( {loading ? (
<div className="text-center py-4 text-secondary-500">Lade Konten...</div> <div className="text-center py-4 text-secondary-500">{t('settingsStammdaten.ladekonten')}</div>
) : ( ) : (
<Table columns={columns} data={accounts} rowKey={(row) => row.id} emptyMessage="Noch keine Konten angelegt" /> <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> <h1 className="text-2xl font-bold text-secondary-900">Stammdaten</h1>
<div className="border-b border-secondary-200"> <div className="border-b border-secondary-200">
<nav className="flex gap-4" role="tablist" aria-label="Stammdaten Tabs"> <nav className="flex gap-4" role="tablist" aria-label={t('settingsStammdaten.stammdatentabs')}>
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.key} 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> <h1 className="text-2xl font-bold text-secondary-900">System</h1>
<div className="border-b border-secondary-200"> <div className="border-b border-secondary-200">
<nav className="flex gap-4" role="tablist" aria-label="System Tabs"> <nav className="flex gap-4" role="tablist" aria-label={t('settingsSystem.systemtabs')}>
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.key} key={tab.key}
+1 -1
View File
@@ -135,7 +135,7 @@ export function SettingsTaxesPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-secondary-700">{t('taxes.country')}</label> <label className="block text-sm font-medium text-secondary-700">{t('taxes.country')}</label>
<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" /> <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" />
{errors.country && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.country.message)}</p>} {errors.country && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.country.message)}</p>}
</div> </div>
</div> </div>
+8 -8
View File
@@ -217,7 +217,7 @@ export function SettingsThemePage() {
value={primaryColor} value={primaryColor}
onChange={(e) => handlePrimaryChange(e.target.value)} onChange={(e) => handlePrimaryChange(e.target.value)}
className="flex-1" className="flex-1"
placeholder="#2563eb" placeholder={t('settingsTheme.2563eb')}
/> />
</div> </div>
</div> </div>
@@ -239,7 +239,7 @@ export function SettingsThemePage() {
value={accentColor} value={accentColor}
onChange={(e) => handleAccentChange(e.target.value)} onChange={(e) => handleAccentChange(e.target.value)}
className="flex-1" className="flex-1"
placeholder="#d946ef" placeholder={t('settingsTheme.d946ef')}
/> />
</div> </div>
</div> </div>
@@ -302,10 +302,10 @@ export function SettingsThemePage() {
<div className="space-y-4"> <div className="space-y-4">
{/* Buttons */} {/* Buttons */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="primary" size="sm">Primary Button</Button> <Button variant="primary" size="sm">{t('settingsTheme.primarybutton')}</Button>
<Button variant="secondary" size="sm">Secondary Button</Button> <Button variant="secondary" size="sm">{t('settingsTheme.secondarybutton')}</Button>
<Button variant="danger" size="sm">Danger Button</Button> <Button variant="danger" size="sm">{t('settingsTheme.dangerbutton')}</Button>
<Button variant="ghost" size="sm">Ghost Button</Button> <Button variant="ghost" size="sm">{t('settingsTheme.ghostbutton')}</Button>
</div> </div>
{/* Badges */} {/* Badges */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -317,11 +317,11 @@ export function SettingsThemePage() {
</div> </div>
{/* Input preview */} {/* Input preview */}
<div className="max-w-xs"> <div className="max-w-xs">
<Input label="Beispiel-Input" placeholder="Text eingeben..." /> <Input label="Beispiel-Input" placeholder={t('settingsTheme.texteingeben')} />
</div> </div>
{/* Card preview */} {/* Card preview */}
<div className="bg-white rounded-lg border border-secondary-200 p-4 shadow-sm"> <div className="bg-white rounded-lg border border-secondary-200 p-4 shadow-sm">
<p className="text-sm text-secondary-700">Dies ist eine Beispiel-Karte mit dem aktuellen Theme.</p> <p className="text-sm text-secondary-700">{t('settingsTheme.diesisteinebeispielkartemit')}</p>
</div> </div>
</div> </div>
</Card> </Card>
@@ -19,7 +19,7 @@ export function SettingsUserManagementPage() {
<h1 className="text-2xl font-bold text-secondary-900">Nutzerverwaltung</h1> <h1 className="text-2xl font-bold text-secondary-900">Nutzerverwaltung</h1>
<div className="border-b border-secondary-200"> <div className="border-b border-secondary-200">
<nav className="flex gap-4" role="tablist" aria-label="Nutzerverwaltung Tabs"> <nav className="flex gap-4" role="tablist" aria-label={t('settingsUserManagement.nutzerverwaltungtabs')}>
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.key} key={tab.key}
+1 -1
View File
@@ -260,7 +260,7 @@ export function SettingsUsersPage() {
type="email" type="email"
{...register('email')} {...register('email')}
error={errorMsg(errors.email?.message)} error={errorMsg(errors.email?.message)}
placeholder="neu.mitarbeiter@firma.de" placeholder={t('settingsUsers.neumitarbeiterfirmade')}
data-testid="invite-email" data-testid="invite-email"
/> />
<Input <Input
+4 -4
View File
@@ -64,8 +64,8 @@ export function StartPage() {
<button <button
onClick={() => navigate('/start')} 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" 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="Zurück zur Startseite" aria-label={t('startPage.zurückzurstartseite')}
title="Zurück zur Startseite" title={t('startPage.zurückzurstartseite')}
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</button> </button>
@@ -101,7 +101,7 @@ export function StartPage() {
<div className="max-w-5xl mx-auto p-8"> <div className="max-w-5xl mx-auto p-8">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-2xl font-bold text-secondary-900">Willkommen{user?.first_name ? `, ${user.first_name}` : ''}!</h1> <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">Wähle einen Workspace aus</p> <p className="text-sm text-secondary-500 mt-1">{t('startPage.wähleeinenworkspaceaus')}</p>
</div> </div>
{/* Dashboard stats */} {/* 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" /> <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"> <span className="text-sm text-secondary-400 group-hover:text-primary-600 mt-2 transition-colors">
Workspace hinzufügen {t('startPage.workspacehinzufügen')}
</span> </span>
</button> </button>
</div> </div>
+5 -5
View File
@@ -116,18 +116,18 @@ function TaskTree({ tasks, selectedTaskId, onSelect, filter, setFilter }: TaskTr
<button <button
onClick={() => setTreeMode('status')} 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'}`} className={`px-2 py-1 text-xs rounded ${treeMode === 'status' ? 'bg-primary-100 text-primary-700' : 'text-secondary-500 hover:bg-secondary-100'}`}
>Nach Status</button> >{t('tasks.nachstatus')}</button>
<button <button
onClick={() => setTreeMode('priority')} 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'}`} className={`px-2 py-1 text-xs rounded ${treeMode === 'priority' ? 'bg-primary-100 text-primary-700' : 'text-secondary-500 hover:bg-secondary-100'}`}
>Nach Priorität</button> >{t('tasks.nachpriorität')}</button>
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto p-2">
<button <button
onClick={() => setFilter({})} 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'}`} 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'}`}
>Alle Tasks ({tasks.length})</button> >{t('tasks.alletasks')}{tasks.length})</button>
{grouped.map(group => ( {grouped.map(group => (
<div key={group.key} className="mt-1"> <div key={group.key} className="mt-1">
<div <div
@@ -201,7 +201,7 @@ function TaskDetail({ task, onEdit, onDelete, onStatusChange }: TaskDetailProps)
<div><span className="font-medium text-secondary-500">Status:</span> <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"> <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="open">Offen</option>
<option value="in_progress">In Bearbeitung</option> <option value="in_progress">{t('tasks.inbearbeitung')}</option>
<option value="review">Review</option> <option value="review">Review</option>
<option value="done">Erledigt</option> <option value="done">Erledigt</option>
<option value="cancelled">Abgebrochen</option> <option value="cancelled">Abgebrochen</option>
@@ -370,7 +370,7 @@ export function TasksPage() {
</div> </div>
</div> </div>
))} ))}
{col.items.length === 0 && <p className="text-xs text-secondary-400 text-center py-4">Keine Tasks</p>} {col.items.length === 0 && <p className="text-xs text-secondary-400 text-center py-4">{t('tasks.keinetasks')}</p>}
</div> </div>
</div> </div>
))} ))}
+8 -8
View File
@@ -92,7 +92,7 @@ export function WorkflowsPage() {
{t('workflows.title', 'Workflows')} {t('workflows.title', 'Workflows')}
</h1> </h1>
<p className="text-sm text-secondary-500 mt-1"> <p className="text-sm text-secondary-500 mt-1">
Definieren und verwalten Sie automatisierte Workflows {t('workflows.definierenundverwaltensieautomatisiertew')}
</p> </p>
</div> </div>
{activeTab === 'definitions' && ( {activeTab === 'definitions' && (
@@ -147,9 +147,9 @@ export function WorkflowsPage() {
<Card className="p-6"> <Card className="p-6">
<div className="flex items-center gap-3 text-danger-600"> <div className="flex items-center gap-3 text-danger-600">
<AlertCircle className="h-5 w-5" /> <AlertCircle className="h-5 w-5" />
<span>Fehler beim Laden der Workflows</span> <span>{t('workflows.fehlerbeimladenderworkflows')}</span>
<Button size="sm" variant="secondary" onClick={() => refetch()}> <Button size="sm" variant="secondary" onClick={() => refetch()}>
Erneut versuchen {t('workflows.erneutversuchen')}
</Button> </Button>
</div> </div>
</Card> </Card>
@@ -157,12 +157,12 @@ export function WorkflowsPage() {
{!isLoading && !isError && workflows.length === 0 && ( {!isLoading && !isError && workflows.length === 0 && (
<EmptyState <EmptyState
title="Keine Workflows" title={t('workflows.keineworkflows')}
description="Erstellen Sie Ihren ersten Workflow, um automatisierte Prozesse zu definieren." description="Erstellen Sie Ihren ersten Workflow, um automatisierte Prozesse zu definieren."
icon={<WorkflowIcon className="h-8 w-8" />} icon={<WorkflowIcon className="h-8 w-8" />}
action={ action={
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}> <Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
Workflow erstellen {t('workflows.workflowerstellen')}
</Button> </Button>
} }
/> />
@@ -216,7 +216,7 @@ export function WorkflowsPage() {
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => openEdit(wf)} onClick={() => openEdit(wf)}
title="Bearbeiten" title={t('workflows.bearbeiten')}
> >
<Settings2 className="h-4 w-4" /> <Settings2 className="h-4 w-4" />
</Button> </Button>
@@ -224,7 +224,7 @@ export function WorkflowsPage() {
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => setConfirmDelete(wf)} onClick={() => setConfirmDelete(wf)}
title="Loeschen" title={t('workflows.loeschen')}
> >
<Trash2 className="h-4 w-4 text-danger-500" /> <Trash2 className="h-4 w-4 text-danger-500" />
</Button> </Button>
@@ -269,7 +269,7 @@ export function WorkflowsPage() {
open={!!confirmDelete} open={!!confirmDelete}
onCancel={() => setConfirmDelete(null)} onCancel={() => setConfirmDelete(null)}
onConfirm={handleDelete} onConfirm={handleDelete}
title="Workflow loeschen" title={t('workflows.workflowloeschen')}
message={`Moechten Sie den Workflow "${confirmDelete?.name}" wirklich loeschen?`} message={`Moechten Sie den Workflow "${confirmDelete?.name}" wirklich loeschen?`}
confirmLabel="Loeschen" confirmLabel="Loeschen"
variant="danger" variant="danger"
+2 -2
View File
@@ -20,9 +20,9 @@ export function AgentsOverviewPage() {
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Agenten Übersicht</h1> <h1 className="text-2xl font-bold text-secondary-900 mb-4">{t('agentsOverview.agentenübersicht')}</h1>
<p className="text-secondary-600 mb-6"> <p className="text-secondary-600 mb-6">
Verwalten Sie KI-Agenten, führen Sie diese aus und überwachen Sie deren Ausführungen. {t('agentsOverview.verwaltensiekiagentenführensie')}
</p> </p>
{/* Loading */} {/* Loading */}
@@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
export function AgentsPlaceholderPage() { export function AgentsPlaceholderPage() {
const { t } = useTranslation();
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Agenten</h1> <h1 className="text-2xl font-bold text-secondary-900 mb-4">Agenten</h1>
<p className="text-secondary-600"> <p className="text-secondary-600">
Diese Seite wird gerade erstellt. Wählen Sie ein Thema aus dem Menü links. {t('agentsPlaceholder.dieseseitewirdgeradeerstelltwählen')}
</p> </p>
</div> </div>
); );
@@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
export function AutomationOverviewPage() { export function AutomationOverviewPage() {
const { t } = useTranslation();
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Automation Übersicht</h1> <h1 className="text-2xl font-bold text-secondary-900 mb-4">{t('automationOverview.automationübersicht')}</h1>
<p className="text-secondary-600 mb-6"> <p className="text-secondary-600 mb-6">
Erstellen und verwalten Sie automatisierte Workflows. Definieren Sie Trigger, Bedingungen und Aktionen. {t('automationOverview.erstellenundverwaltensieautomatisiertewo')}
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-6 bg-white rounded-xl border border-secondary-200"> <div className="p-6 bg-white rounded-xl border border-secondary-200">
@@ -13,14 +15,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> <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> </div>
<h3 className="font-semibold text-secondary-900">Automations</h3> <h3 className="font-semibold text-secondary-900">Automations</h3>
<p className="text-sm text-secondary-500 mt-1">Workflows erstellen und verwalten</p> <p className="text-sm text-secondary-500 mt-1">{t('automationOverview.workflowserstellenundverwalten')}</p>
</div> </div>
<div className="p-6 bg-white rounded-xl border border-secondary-200"> <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"> <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> <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> </div>
<h3 className="font-semibold text-secondary-900">Einstellungen</h3> <h3 className="font-semibold text-secondary-900">Einstellungen</h3>
<p className="text-sm text-secondary-500 mt-1">Trigger, Aktionen und Bedingungen konfigurieren</p> <p className="text-sm text-secondary-500 mt-1">{t('automationOverview.triggeraktionenundbedingungenkonfigurier')}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
export function AutomationPlaceholderPage() { export function AutomationPlaceholderPage() {
const { t } = useTranslation();
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Automation</h1> <h1 className="text-2xl font-bold text-secondary-900 mb-4">Automation</h1>
<p className="text-secondary-600"> <p className="text-secondary-600">
Diese Seite wird gerade erstellt. Wählen Sie ein Thema aus dem Menü links. {t('automationPlaceholder.dieseseitewirdgeradeerstelltwählen')}
</p> </p>
</div> </div>
); );
+13 -11
View File
@@ -1,31 +1,33 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
export function HelpApiDocsPage() { export function HelpApiDocsPage() {
const { t } = useTranslation();
return ( return (
<div className="max-w-4xl mx-auto prose prose-secondary"> <div className="max-w-4xl mx-auto prose prose-secondary">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">API Dokumentation</h1> <h1 className="text-2xl font-bold text-secondary-900 mb-4">{t('helpApiDocs.apidokumentation')}</h1>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
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). {t('helpApiDocs.dievollständigeapidokumentationfindensie')} <a href="/docs" target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">/docs</a> {t('helpApiDocs.swaggerui')}
</p> </p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Übersicht</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Übersicht</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
LeoCRM bietet eine REST-API mit über 224 Endpoints. Die API verwendet JSON für Request- und Response-Bodies. {t('helpApiDocs.leocrmbieteteinerestapimit')}
</p> </p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Authentifizierung</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Authentifizierung</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
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. {t('helpApiDocs.dieapiverwendetsessionbasierteauthentifi')} <code className="bg-secondary-100 px-1.5 py-0.5 rounded text-sm">{t('helpApiDocs.postapiv1authlogin')}</code> {t('helpApiDocs.wirdeinsessioncookiegesetzt')}
</p> </p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Wichtige Endpoints</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">{t('helpApiDocs.wichtigeendpoints')}</h2>
<ul className="list-disc list-inside text-secondary-600 space-y-1"> <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">GET /api/v1/contacts</code> Kontakte abrufen</li> <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">POST /api/v1/contacts</code> Kontakt erstellen</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">GET /api/v1/calendar/entries</code> Kalendereinträge</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">GET /api/v1/mail/accounts</code> Mail-Konten</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">GET /api/v1/plugins/active-manifests</code> Plugin-Manifeste</li> <li><code className="bg-secondary-100 px-1.5 py-0.5 rounded text-sm">{t('helpApiDocs.getapiv1pluginsactivemanifests')}</code> {t('helpApiDocs.pluginmanifeste')}</li>
</ul> </ul>
<div className="mt-6 p-4 bg-primary-50 rounded-lg border border-primary-200"> <div className="mt-6 p-4 bg-primary-50 rounded-lg border border-primary-200">
<p className="text-sm text-primary-700"> <p className="text-sm text-primary-700">
📖 <strong>Vollständige Doku:</strong> <a href="/docs" target="_blank" rel="noopener noreferrer" className="underline">Swagger UI öffnen</a> 📖 <strong>{t('helpApiDocs.vollständigedoku')}</strong> <a href="/docs" target="_blank" rel="noopener noreferrer" className="underline">{t('helpApiDocs.swaggeruiöffnen')}</a>
</p> </p>
</div> </div>
</div> </div>
+8 -6
View File
@@ -1,24 +1,26 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
export function HelpContactsPage() { export function HelpContactsPage() {
const { t } = useTranslation();
return ( return (
<div className="max-w-3xl mx-auto prose prose-secondary"> <div className="max-w-3xl mx-auto prose prose-secondary">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Kontakte verwalten</h1> <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">Kontakte erstellen</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">{t('helpContacts.kontakteerstellen')}</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
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. {t('helpContacts.gehensiezukontakteundklicken')}
</p> </p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Kontaktpersonen</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Kontaktpersonen</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
Jeder Firma können mehrere Kontaktpersonen zugeordnet werden. Öffnen Sie eine Firma und fügen Sie Personen hinzu. {t('helpContacts.jederfirmakönnenmehrerekontaktpersonenzu')}
</p> </p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Tags</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Tags</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
Verwenden Sie Tags um Kontakte zu kategorisieren. Tags können frei vergeben werden und helfen bei der Filterung. {t('helpContacts.verwendensietagsumkontaktezu')}
</p> </p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Ordner</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Ordner</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
Organisieren Sie Kontakte in Ordnern. Ordner können verschachtelt werden und eigene Berechtigungen haben. {t('helpContacts.organisierensiekontakteinordnernordner')}
</p> </p>
</div> </div>
); );
+9 -7
View File
@@ -1,22 +1,24 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
export function HelpLoginPage() { export function HelpLoginPage() {
const { t } = useTranslation();
return ( return (
<div className="max-w-3xl mx-auto prose prose-secondary"> <div className="max-w-3xl mx-auto prose prose-secondary">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Login & Anmeldung</h1> <h1 className="text-2xl font-bold text-secondary-900 mb-4">{t('helpLogin.loginanmeldung')}</h1>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Anmeldung</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Anmeldung</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
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. {t('helpLogin.rufensiedieleocrmurlauf')}
</p> </p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Passwort vergessen?</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">{t('helpLogin.passwortvergessen')}</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
Klicken Sie auf der Login-Seite auf "Passwort vergessen". Sie erhalten eine E-Mail mit einem Link zum Zurücksetzen Ihres Passworts. {t('helpLogin.klickensieaufderloginseite')}
</p> </p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Sicherheit</h2> <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"> <ul className="list-disc list-inside text-secondary-600 space-y-1">
<li>Ihre Sitzung wird über ein sicheres HttpOnly-Cookie verwaltet</li> <li>{t('helpLogin.ihresitzungwirdübereinsicheres')}</li>
<li>Nach Inaktivität wird die Sitzung automatisch beendet</li> <li>{t('helpLogin.nachinaktivitätwirddiesitzungautomatisch')}</li>
<li>Passwörter werden mit bcrypt (cost=12) verschlüsselt gespeichert</li> <li>{t('helpLogin.passwörterwerdenmitbcryptcost12')}</li>
</ul> </ul>
</div> </div>
); );
+12 -10
View File
@@ -1,24 +1,26 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
export function HelpMailSetupPage() { export function HelpMailSetupPage() {
const { t } = useTranslation();
return ( return (
<div className="max-w-3xl mx-auto prose prose-secondary"> <div className="max-w-3xl mx-auto prose prose-secondary">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Postfach einrichten</h1> <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">IMAP-Konto hinzufügen</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">{t('helpMailSetup.imapkontohinzufügen')}</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
Gehen Sie zu Einstellungen Email und klicken Sie auf "Konto hinzufügen". Geben Sie Ihre IMAP- und SMTP-Serverdaten ein. {t('helpMailSetup.gehensiezueinstellungenemailund')}
</p> </p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Benötigte Daten</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">{t('helpMailSetup.benötigtedaten')}</h2>
<ul className="list-disc list-inside text-secondary-600 space-y-1"> <ul className="list-disc list-inside text-secondary-600 space-y-1">
<li>IMAP-Server (z.B. imap.example.com)</li> <li>{t('helpMailSetup.imapserverzbimapexample')}</li>
<li>IMAP-Port (meist 993 für SSL)</li> <li>{t('helpMailSetup.imapportmeist993fürssl')}</li>
<li>SMTP-Server (z.B. smtp.example.com)</li> <li>{t('helpMailSetup.smtpserverzbsmtpexample')}</li>
<li>SMTP-Port (meist 587 für TLS)</li> <li>{t('helpMailSetup.smtpportmeist587fürtls')}</li>
<li>E-Mail-Adresse und Passwort</li> <li>{t('helpMailSetup.emailadresseundpasswort')}</li>
</ul> </ul>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Synchronisation</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Synchronisation</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
Nach dem Einrichten wird Ihr Postfach automatisch synchronisiert. Neue E-Mails werden im Hintergrund abgerufen. {t('helpMailSetup.nachdemeinrichtenwirdihrpostfach')}
</p> </p>
</div> </div>
); );
+8 -6
View File
@@ -1,28 +1,30 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
export function HelpNavigationPage() { export function HelpNavigationPage() {
const { t } = useTranslation();
return ( return (
<div className="max-w-3xl mx-auto prose prose-secondary"> <div className="max-w-3xl mx-auto prose prose-secondary">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">Navigation</h1> <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> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Startseite</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
Nach dem Login gelangen Sie zur Startseite. Hier können Sie einen Workspace auswählen oder zu den Einstellungen und der Hilfe navigieren. {t('helpNavigation.nachdemlogingelangensiezur')}
</p> </p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Workspace</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Workspace</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
Ein Workspace ist Ihr Arbeitsbereich mit Sidebar-Navigation. Hier finden Sie Kontakte, Kalender, E-Mail und alle anderen Module. {t('helpNavigation.einworkspaceistihrarbeitsbereichmit')}
</p> </p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Sidebar</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Sidebar</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
Das Hamburger-Menü oben links blendet die Seitenleiste ein und aus. Die Sidebar zeigt alle verfügbaren Module. {t('helpNavigation.dashamburgermenüobenlinksblendet')}
</p> </p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Zurück-Pfeil</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Zurück-Pfeil</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
Rechts neben dem Hamburger-Menü finden Sie einen Zurück-Pfeil, der Sie zurück zur Startseite bringt. {t('helpNavigation.rechtsnebendemhamburgermenüfinden')}
</p> </p>
<h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">Globale Suche</h2> <h2 className="text-lg font-semibold text-secondary-800 mt-6 mb-2">{t('helpNavigation.globalesuche')}</h2>
<p className="text-secondary-600 mb-4"> <p className="text-secondary-600 mb-4">
Verwenden Sie die Lupe oben oder Strg+K um das Kommando-Palette zu öffnen und schnell nach Kontakten, Mails oder Dateien zu suchen. {t('helpNavigation.verwendensiedielupeobenoder')}
</p> </p>
</div> </div>
); );

Some files were not shown because too many files have changed in this diff Show More