revert(frontend): Frontend auf letzten funktionierenden Stand 5680179 zurückgesetzt — i18n-Massen-Batch brach Dashboard-Shell in Produktion; Render-Loop-Fix und DSGVO-Antrags-UI liegen sicher in Historie für kontrollierten Wiedereinspiel
This commit is contained in:
@@ -80,7 +80,7 @@ export function ActivityFilter({ onFilter, initialValues }: ActivityFilterProps)
|
||||
type="text"
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
placeholder={t('activityFilter.benutzername')}
|
||||
placeholder="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
import { useState } from 'react';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
|
||||
type SubView = 'signals' | 'patterns' | 'proposals';
|
||||
|
||||
export function ImprovementPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [subView, setSubView] = useState<SubView>('signals');
|
||||
|
||||
return (
|
||||
@@ -49,7 +47,6 @@ export function ImprovementPanel() {
|
||||
}
|
||||
|
||||
function SignalsView() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useSignals(1, 20);
|
||||
const collectMut = useCollectSignals();
|
||||
const signals = data?.items ?? [];
|
||||
@@ -62,7 +59,7 @@ function SignalsView() {
|
||||
onClick={() => collectMut.mutate({ limit: 100 })}
|
||||
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"
|
||||
aria-label={t('improvementPanel.signalesammeln')}
|
||||
aria-label="Signale sammeln"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${collectMut.isPending ? 'animate-spin' : ''}`} aria-hidden="true" strokeWidth={2} />
|
||||
Sammeln
|
||||
@@ -71,7 +68,7 @@ function SignalsView() {
|
||||
|
||||
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
||||
{!isLoading && signals.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 text-center py-4">{t('improvementPanel.keinesignaleklickensieaufsammeln')}</p>
|
||||
<p className="text-sm text-secondary-400 text-center py-4">Keine Signale. Klicken Sie auf „Sammeln" um zu starten.</p>
|
||||
)}
|
||||
|
||||
{signals.map(s => (
|
||||
@@ -95,7 +92,6 @@ function SignalsView() {
|
||||
}
|
||||
|
||||
function PatternsView() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = usePatterns(1, 20);
|
||||
const detectMut = useDetectPatterns();
|
||||
const patterns = data?.items ?? [];
|
||||
@@ -108,7 +104,7 @@ function PatternsView() {
|
||||
onClick={() => detectMut.mutate({ min_occurrences: 2 })}
|
||||
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"
|
||||
aria-label={t('improvementPanel.mustererkennen')}
|
||||
aria-label="Muster erkennen"
|
||||
>
|
||||
<TrendingUp className={`w-3 h-3 ${detectMut.isPending ? 'animate-pulse' : ''}`} aria-hidden="true" strokeWidth={2} />
|
||||
Erkennen
|
||||
@@ -117,7 +113,7 @@ function PatternsView() {
|
||||
|
||||
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
||||
{!isLoading && patterns.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 text-center py-4">{t('improvementPanel.keinemustersammelnsiezuerstsignale')}</p>
|
||||
<p className="text-sm text-secondary-400 text-center py-4">Keine Muster. Sammeln Sie zuerst Signale und klicken Sie dann auf „Erkennen".</p>
|
||||
)}
|
||||
|
||||
{patterns.map(p => (
|
||||
@@ -147,7 +143,6 @@ function PatternsView() {
|
||||
}
|
||||
|
||||
function ProposalsView() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useProposals(1, 20);
|
||||
const evalMut = useEvaluateProposal();
|
||||
const activateMut = useActivateProposal();
|
||||
@@ -173,7 +168,7 @@ function ProposalsView() {
|
||||
|
||||
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
||||
{!isLoading && proposals.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 text-center py-4">{t('improvementPanel.keinevorschläge')}</p>
|
||||
<p className="text-sm text-secondary-400 text-center py-4">Keine Vorschläge.</p>
|
||||
)}
|
||||
|
||||
{proposals.map(p => (
|
||||
@@ -196,7 +191,7 @@ function ProposalsView() {
|
||||
onClick={() => evalMut.mutate(p.id)}
|
||||
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"
|
||||
aria-label={t('improvementPanel.evaluieren')}
|
||||
aria-label="Evaluieren"
|
||||
>
|
||||
<Play className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
Evaluieren
|
||||
@@ -207,7 +202,7 @@ function ProposalsView() {
|
||||
onClick={() => activateMut.mutate(p.id)}
|
||||
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"
|
||||
aria-label={t('improvementPanel.aktivieren')}
|
||||
aria-label="Aktivieren"
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
Aktivieren
|
||||
@@ -219,7 +214,7 @@ function ProposalsView() {
|
||||
onClick={() => rollbackMut.mutate({ proposalId: p.id, reason: 'Manual rollback' })}
|
||||
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"
|
||||
aria-label={t('improvementPanel.rollback')}
|
||||
aria-label="Rollback"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
Rollback
|
||||
@@ -228,7 +223,7 @@ function ProposalsView() {
|
||||
onClick={() => measureMut.mutate(p.id)}
|
||||
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"
|
||||
aria-label={t('improvementPanel.impactmessen')}
|
||||
aria-label="Impact messen"
|
||||
>
|
||||
<BarChart3 className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
Messen
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SuggestionBadgeProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function SuggestionBadge({ onClick }: SuggestionBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const [count, setCount] = useState(0);
|
||||
const [pulsing, setPulsing] = useState(false);
|
||||
|
||||
@@ -33,7 +31,7 @@ export function SuggestionBadge({ onClick }: SuggestionBadgeProps) {
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="relative p-2 text-gray-500 hover:text-gray-700 transition-colors min-h-touch min-w-touch"
|
||||
title={t('suggestionBadge.kivorschläge')}
|
||||
title="KI Vorschläge"
|
||||
>
|
||||
🤖
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import type { Suggestion } from '@/api/aiProactive';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const typeConfig = {
|
||||
info: { icon: '💡', color: 'blue', bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', bar: 'bg-blue-500' },
|
||||
@@ -16,7 +15,6 @@ interface SuggestionCardProps {
|
||||
}
|
||||
|
||||
export function SuggestionCard({ suggestion, onDismiss, onAct }: SuggestionCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const config = typeConfig[suggestion.suggestion_type] || typeConfig.info;
|
||||
const confidencePercent = Math.round(suggestion.confidence * 100);
|
||||
@@ -34,7 +32,7 @@ export function SuggestionCard({ suggestion, onDismiss, onAct }: SuggestionCardP
|
||||
<button
|
||||
onClick={() => onDismiss(suggestion.id)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title={t('suggestionCard.ignorieren')}
|
||||
title="Ignorieren"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -88,7 +86,7 @@ export function SuggestionCard({ suggestion, onDismiss, onAct }: SuggestionCardP
|
||||
{/* Acted upon badge */}
|
||||
{suggestion.is_acted_upon && (
|
||||
<div className="mt-2 text-xs text-green-600 font-medium flex items-center gap-1">
|
||||
{t('suggestionCard.ausgeführt')}
|
||||
✓ Ausgeführt
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useSuggestions } from '@/api/aiProactive';
|
||||
import { SuggestionCard } from '@/components/ai/SuggestionCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SuggestionSidebarProps {
|
||||
isOpen: boolean;
|
||||
@@ -21,7 +20,6 @@ const filterOptions = [
|
||||
* Used inside the AISidebar proactive tab.
|
||||
*/
|
||||
export function SuggestionList() {
|
||||
const { t } = useTranslation();
|
||||
const { suggestions, connected, dismiss, act } = useSuggestions();
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
|
||||
@@ -66,8 +64,8 @@ export function SuggestionList() {
|
||||
{filteredSuggestions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-secondary-400">
|
||||
<div className="text-4xl mb-3">🤖</div>
|
||||
<p className="text-sm">{t('suggestionSidebar.keinevorschlägevorhanden')}</p>
|
||||
<p className="text-xs mt-1">{t('suggestionSidebar.diekianalysiertdeinenkontext')}</p>
|
||||
<p className="text-sm">Keine Vorschläge vorhanden</p>
|
||||
<p className="text-xs mt-1">Die KI analysiert deinen Kontext...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -91,7 +89,6 @@ export function SuggestionList() {
|
||||
* Uses SuggestionList internally.
|
||||
*/
|
||||
export function SuggestionSidebar({ isOpen, onClose }: SuggestionSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
@@ -111,7 +108,7 @@ export function SuggestionSidebar({ isOpen, onClose }: SuggestionSidebarProps) {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-semibold text-gray-800">{t('suggestionSidebar.kivorschläge')}</h2>
|
||||
<h2 className="font-semibold text-gray-800">KI Vorschläge</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
||||
@@ -8,14 +8,12 @@ import VideoBlock from './VideoBlock';
|
||||
import FileBlock from './FileBlock';
|
||||
import { getBlockComponent } from './registry';
|
||||
import './registrations'; // plugin-contributed block registrations
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface BlockRendererProps {
|
||||
blocks: MessageBlock[];
|
||||
}
|
||||
|
||||
const BlockRenderer: React.FC<BlockRendererProps> = ({ blocks }) => {
|
||||
const { t } = useTranslation();
|
||||
if (!blocks || blocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -57,7 +55,7 @@ const BlockRenderer: React.FC<BlockRendererProps> = ({ blocks }) => {
|
||||
// Fallback for unknown block types
|
||||
return (
|
||||
<div className="text-xs text-secondary-400 italic p-2 rounded bg-secondary-50">
|
||||
{t('blockRenderer.unbekannterblocktyp')} {block.block_type}
|
||||
Unbekannter Block-Typ: {block.block_type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import React from 'react';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ContactCardBlockProps {
|
||||
block: MessageBlock;
|
||||
}
|
||||
|
||||
const ContactCardBlock: React.FC<ContactCardBlockProps> = ({ block }) => {
|
||||
const { t } = useTranslation();
|
||||
const { contact_id, name } = block.block_data;
|
||||
|
||||
const contactName: string = name || 'Unbekannter Kontakt';
|
||||
@@ -32,7 +30,7 @@ const ContactCardBlock: React.FC<ContactCardBlockProps> = ({ block }) => {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-secondary-700 truncate">{contactName}</p>
|
||||
<p className="text-xs text-secondary-400">{t('contactCardBlock.kontaktanzeigen')}</p>
|
||||
<p className="text-xs text-secondary-400">Kontakt anzeigen</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
||||
</a>
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
|
||||
import type { MessageBlock } from '@/store/commStore';
|
||||
import { AppWindow, Loader2 } from 'lucide-react';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface MiniAppBlockProps {
|
||||
block: MessageBlock;
|
||||
@@ -18,7 +17,6 @@ interface MiniAppDef {
|
||||
}
|
||||
|
||||
const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
|
||||
const { t } = useTranslation();
|
||||
const { app_id, config } = block.block_data;
|
||||
const [appDef, setAppDef] = useState<MiniAppDef | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -90,7 +88,7 @@ const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
|
||||
)}
|
||||
|
||||
{!hasConfig && !hasSchema && (
|
||||
<p className="text-xs text-secondary-400 text-center py-2">{t('miniAppBlock.keinekonfiguration')}</p>
|
||||
<p className="text-xs text-secondary-400 text-center py-2">Keine Konfiguration</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -217,9 +217,8 @@ function TimelineEntry({
|
||||
* Loading skeleton — 3 placeholder entries.
|
||||
*/
|
||||
function LoadingSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse" aria-label={t('entityHistoryPanel.loadinghistory')}>
|
||||
<div className="space-y-4 animate-pulse" aria-label="Loading history">
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-secondary-200 flex-shrink-0" />
|
||||
|
||||
@@ -7,7 +7,6 @@ import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { Printer, FileDown, ChevronDown } from 'lucide-react';
|
||||
import { printElement, printCurrentPage, exportToPDF } from '@/utils/print';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface PrintButtonProps {
|
||||
/** Element id to print. If omitted, prints the whole page. */
|
||||
@@ -23,7 +22,6 @@ export function PrintButton({
|
||||
filename = 'export',
|
||||
className,
|
||||
}: PrintButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -81,8 +79,8 @@ export function PrintButton({
|
||||
)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={t('printButton.druckenoderalspdfexportieren')}
|
||||
title={t('printButton.druckenpdf')}
|
||||
aria-label="Drucken oder als PDF exportieren"
|
||||
title="Drucken / PDF"
|
||||
data-testid="print-button-trigger"
|
||||
>
|
||||
<Printer className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
@@ -130,7 +128,7 @@ export function PrintButton({
|
||||
data-testid="print-button-pdf"
|
||||
>
|
||||
<FileDown className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} />
|
||||
<span>{t('printButton.alspdf')}</span>
|
||||
<span>Als PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -28,7 +28,6 @@ export interface SaveFilterDialogProps {
|
||||
* Empty / null / undefined values are omitted.
|
||||
*/
|
||||
function CriteriaSummary({ criteria }: { criteria: Record<string, any> }) {
|
||||
const { t } = useTranslation();
|
||||
const entries = Object.entries(criteria).filter(
|
||||
([, v]) => v !== null && v !== undefined && v !== ''
|
||||
);
|
||||
@@ -36,7 +35,7 @@ function CriteriaSummary({ criteria }: { criteria: Record<string, any> }) {
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-secondary-400 italic">
|
||||
{t('saveFilterDialog.keineaktivenfilterkriterien')}
|
||||
Keine aktiven Filterkriterien
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -86,7 +85,6 @@ export function SaveFilterDialog({
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
const { t } = useTranslation();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSubmitting(true);
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import { useUsers } from '@/api/users';
|
||||
import { useGroups } from '@/api/groups';
|
||||
import type { EntityPermission, PermissionLevel } from '@/api/entityPermissions';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ShareDialogProps {
|
||||
entityType: string;
|
||||
@@ -52,7 +51,6 @@ function permLabel(level: string) {
|
||||
}
|
||||
|
||||
export function ShareDialog({ entityType, entityId, entityName, onClose }: ShareDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: permData, isLoading } = useEntityPermissions(entityType, entityId);
|
||||
const { data: accessData } = useEntityAccess(entityType, entityId);
|
||||
const { data: usersData } = useUsers(1, 100);
|
||||
@@ -135,7 +133,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-secondary-400 hover:text-secondary-600 p-1 rounded-md hover:bg-secondary-100"
|
||||
aria-label={t('shareDialog.schließen')}
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<X className="w-5 h-5" strokeWidth={2} />
|
||||
</button>
|
||||
@@ -145,9 +143,10 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{/* Info banner */}
|
||||
<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">{t('shareDialog.elementteilen')}</p>
|
||||
<p className="font-medium mb-1">Element teilen</p>
|
||||
<p className="text-primary-600">
|
||||
{t('shareDialog.gewährebenutzernodergruppenzugriffauf')}
|
||||
Gewähre Benutzern oder Gruppen Zugriff auf dieses Element. Die Berechtigungsstufe bestimmt,
|
||||
welche Aktionen durchgeführt werden können.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +168,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
|
||||
<div className="text-sm text-secondary-400 py-4 text-center">Laden…</div>
|
||||
) : permissions.length === 0 ? (
|
||||
<div className="text-sm text-secondary-400 py-4 text-center">
|
||||
{t('shareDialog.nochkeineberechtigungenvergebendiesesele')}
|
||||
Noch keine Berechtigungen vergeben. Dieses Element ist nur für den Besitzer sichtbar.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -208,7 +207,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
|
||||
value={perm.expires_at ? perm.expires_at.split('T')[0] : ''}
|
||||
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"
|
||||
title={t('shareDialog.ablaufdatumsetzen')}
|
||||
title="Ablaufdatum setzen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,8 +229,8 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
|
||||
<button
|
||||
onClick={() => handleDelete(perm)}
|
||||
className="text-secondary-400 hover:text-red-600 p-1.5 rounded-md hover:bg-red-50 flex-shrink-0"
|
||||
title={t('shareDialog.entfernen')}
|
||||
aria-label={t('shareDialog.berechtigungentfernen')}
|
||||
title="Entfernen"
|
||||
aria-label="Berechtigung entfernen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" strokeWidth={2} />
|
||||
</button>
|
||||
@@ -246,7 +245,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="flex items-center gap-2 mb-3">
|
||||
<Plus className="w-4 h-4 text-primary-600" strokeWidth={2} />
|
||||
<span className="text-sm font-medium text-secondary-700">{t('shareDialog.neueberechtigung')}</span>
|
||||
<span className="text-sm font-medium text-secondary-700">Neue Berechtigung</span>
|
||||
</div>
|
||||
|
||||
{/* Type toggle */}
|
||||
@@ -331,7 +330,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
|
||||
<div className="mb-3">
|
||||
<label className="flex items-center gap-2 text-sm text-secondary-600 mb-1">
|
||||
<Calendar className="w-4 h-4" strokeWidth={2} />
|
||||
{t('shareDialog.ablaufdatumoptional')}
|
||||
Ablaufdatum (optional)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
@@ -365,7 +364,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"
|
||||
>
|
||||
<Plus className="w-4 h-4" strokeWidth={2} />
|
||||
{t('shareDialog.berechtigunghinzufügen')}
|
||||
Berechtigung hinzufügen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -237,7 +237,7 @@ export function ContactEditForm({ onClose, contact, onSaved }: ContactEditFormPr
|
||||
error={errors.name?.message}
|
||||
required
|
||||
data-testid="contact-name-input"
|
||||
placeholder={t('contactEditForm.techcorpgmbh')}
|
||||
placeholder="TechCorp GmbH"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -257,7 +257,7 @@ export function ContactEditForm({ onClose, contact, onSaved }: ContactEditFormPr
|
||||
)}
|
||||
|
||||
{/* Code */}
|
||||
<Input label={t('contacts.code')} {...register('code')} placeholder={t('contactEditForm.k00123')} />
|
||||
<Input label={t('contacts.code')} {...register('code')} placeholder="K-00123" />
|
||||
|
||||
{/* Communication */}
|
||||
<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">
|
||||
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.notes')}</h3>
|
||||
<div className="space-y-3">
|
||||
<Input label={t('contacts.tags')} {...register('tags')} placeholder={t('contactEditForm.tag1tag2')} />
|
||||
<Input label={t('contacts.tags')} {...register('tags')} placeholder="tag1, tag2" />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.projectnote')}</label>
|
||||
<textarea
|
||||
|
||||
@@ -162,7 +162,6 @@ function FolderTreeItem({
|
||||
multiSelectedFolders?: string[];
|
||||
onToggleMultiSelect?: (folderId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const folderKey = `folder:${node.id}` as ContactFilter;
|
||||
const isActive = selectedFilter === folderKey;
|
||||
@@ -229,8 +228,8 @@ function FolderTreeItem({
|
||||
type="button"
|
||||
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"
|
||||
title={t('contactFolderTree.optionen')}
|
||||
aria-label={t('contactFolderTree.optionen')}
|
||||
title="Optionen"
|
||||
aria-label="Optionen"
|
||||
>
|
||||
{icon(ICONS.more, 'w-4 h-4')}
|
||||
</button>
|
||||
@@ -447,8 +446,8 @@ export function ContactFolderTree({
|
||||
<button
|
||||
onClick={handleToggleMultiSelectMode}
|
||||
className={`text-secondary-400 hover:text-primary-600 p-0.5 ${multiSelectMode ? 'text-primary-600 bg-primary-50 rounded' : ''}`}
|
||||
title={t('contactFolderTree.mehrereordnerauswählen')}
|
||||
aria-label={t('contactFolderTree.mehrereordnerauswählen')}
|
||||
title="Mehrere Ordner auswählen"
|
||||
aria-label="Mehrere Ordner auswählen"
|
||||
>
|
||||
<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" />
|
||||
@@ -462,8 +461,8 @@ export function ContactFolderTree({
|
||||
<button
|
||||
onClick={handleNewFolder}
|
||||
className="text-secondary-400 hover:text-primary-600 p-0.5"
|
||||
title={t('contactFolderTree.neuerordner')}
|
||||
aria-label={t('contactFolderTree.neuerordner')}
|
||||
title="Neuer Ordner"
|
||||
aria-label="Neuer Ordner"
|
||||
>
|
||||
{icon(ICONS.plus, 'w-3.5 h-3.5')}
|
||||
</button>
|
||||
@@ -513,7 +512,7 @@ export function ContactFolderTree({
|
||||
)}
|
||||
|
||||
{tree.length === 0 && !foldersLoading && !loading && (
|
||||
<div className="px-2 py-1 text-xs text-secondary-400">{t('contactFolderTree.keineordnervorhanden')}</div>
|
||||
<div className="px-2 py-1 text-xs text-secondary-400">Keine Ordner vorhanden</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -575,7 +574,7 @@ export function ContactFolderTree({
|
||||
<>
|
||||
<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="text-sm font-semibold text-secondary-700 mb-2">{t('contactFolderTree.farbewählen')}</div>
|
||||
<div className="text-sm font-semibold text-secondary-700 mb-2">Farbe wählen</div>
|
||||
<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) => (
|
||||
<button
|
||||
|
||||
@@ -969,7 +969,7 @@ export function ContactList({
|
||||
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"
|
||||
>
|
||||
{t('contactList.ordnerzuweisen')}
|
||||
Ordner zuweisen ▾
|
||||
</button>
|
||||
{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">
|
||||
@@ -987,7 +987,7 @@ export function ContactList({
|
||||
</button>
|
||||
))}
|
||||
{folderList.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-secondary-400">{t('contactList.keineordner')}</div>
|
||||
<div className="px-3 py-2 text-xs text-secondary-400">Keine Ordner</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -999,7 +999,7 @@ export function ContactList({
|
||||
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"
|
||||
>
|
||||
{t('contactList.tagshinzufügen')}
|
||||
Tags hinzufügen ▾
|
||||
</button>
|
||||
{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">
|
||||
@@ -1007,7 +1007,7 @@ export function ContactList({
|
||||
type="text"
|
||||
value={bulkTagsInput}
|
||||
onChange={(e) => setBulkTagsInput(e.target.value)}
|
||||
placeholder={t('contactList.tag1tag2')}
|
||||
placeholder="tag1, tag2, ..."
|
||||
className="w-48 px-2 py-1 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400"
|
||||
/>
|
||||
<button
|
||||
@@ -1032,7 +1032,7 @@ export function ContactList({
|
||||
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"
|
||||
>
|
||||
{t('contactList.auswahlaufheben')}
|
||||
Auswahl aufheben
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1041,9 +1041,9 @@ export function ContactList({
|
||||
{bulkDeleteConfirm && (
|
||||
<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">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 mb-2">{t('contactList.löschenbestätigen')}</h3>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 mb-2">Löschen bestätigen</h3>
|
||||
<p className="text-sm text-secondary-600 mb-4">
|
||||
{selectedContactIds?.size || 0} {t('contactList.kontaktewirklichlöschen')}
|
||||
{selectedContactIds?.size || 0} Kontakt(e) wirklich löschen?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
@@ -1073,7 +1073,7 @@ export function ContactList({
|
||||
{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">
|
||||
<Info className="w-3 h-3" />
|
||||
<span>{t('contactList.customsortierungaktivdraganddrop')}</span>
|
||||
<span>Custom Sortierung aktiv — Drag-and-Drop zum Umsortieren</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1138,15 +1138,15 @@ export function ContactList({
|
||||
<div ref={colMenuRef} className="relative">
|
||||
<button
|
||||
onClick={() => setColMenuOpen(!colMenuOpen)}
|
||||
title={t('contactList.spaltenverwalten')}
|
||||
aria-label={t('contactList.spaltenverwalten')}
|
||||
title="Spalten verwalten"
|
||||
aria-label="Spalten verwalten"
|
||||
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" />
|
||||
</button>
|
||||
{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="px-3 py-2 text-xs font-semibold text-secondary-700 border-b border-secondary-100">{t('contactList.spaltenverwalten')}</div>
|
||||
<div className="px-3 py-2 text-xs font-semibold text-secondary-700 border-b border-secondary-100">Spalten verwalten</div>
|
||||
{ALL_COLUMNS.map((col) => (
|
||||
<label
|
||||
key={col.key}
|
||||
|
||||
@@ -8,7 +8,6 @@ import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Filter, Plus, X, ChevronDown, Bookmark } from 'lucide-react';
|
||||
import type { UnifiedContact } from '@/api/unifiedContacts';
|
||||
import { useCustomFieldDefinitions, type CustomFieldDefinition } from '@/api/customFieldDefinitions';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ─── Field definitions ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -246,7 +245,6 @@ function newConditionId() {
|
||||
}
|
||||
|
||||
export function FilterPanel({ filters, onFiltersChange, contactType, savedFilters = [], onSaveFilter, onLoadFilter, onDeleteFilter }: FilterPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const btnRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -375,8 +373,8 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
||||
<div ref={btnRef} className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
title={t('filterPanel.filter')}
|
||||
aria-label={t('filterPanel.filter')}
|
||||
title="Filter"
|
||||
aria-label="Filter"
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
||||
transition-colors duration-100 cursor-pointer relative
|
||||
@@ -417,7 +415,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
||||
onClick={clearAll}
|
||||
className="text-xs text-secondary-500 hover:text-red-600 transition-colors"
|
||||
>
|
||||
{t('filterPanel.allelöschen')}
|
||||
Alle löschen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -432,7 +430,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
||||
{/* Logic toggle */}
|
||||
{activeCount > 0 && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-100">
|
||||
<span className="text-xs text-secondary-500">{t('filterPanel.bedingungenverknüpfen')}</span>
|
||||
<span className="text-xs text-secondary-500">Bedingungen verknüpfen:</span>
|
||||
<button
|
||||
onClick={toggleLogic}
|
||||
className={`
|
||||
@@ -485,7 +483,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
||||
{/* Saved filters */}
|
||||
{savedFilters.length > 0 && (
|
||||
<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">{t('filterPanel.gespeichertefilter')}</div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-secondary-400 mb-1.5">Gespeicherte Filter</div>
|
||||
<div className="space-y-0.5">
|
||||
{savedFilters.map((sf) => (
|
||||
<div key={sf.id} className="flex items-center gap-1 group">
|
||||
@@ -512,7 +510,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
{filters.conditions.length === 0 && (
|
||||
<div className="text-center py-6 text-xs text-secondary-400">
|
||||
{t('filterPanel.keinefilteraktivklickeuntenum')}
|
||||
Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen.
|
||||
</div>
|
||||
)}
|
||||
{filters.conditions.map((cond, idx) => {
|
||||
@@ -572,7 +570,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
||||
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="">{t('filterPanel.wählen')}</option>
|
||||
<option value="">— wählen —</option>
|
||||
{def.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
@@ -589,7 +587,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
||||
type="text"
|
||||
value={cond.value}
|
||||
onChange={(e) => updateCondition(cond.id, { value: e.target.value })}
|
||||
placeholder={t('filterPanel.wert')}
|
||||
placeholder="Wert…"
|
||||
className="w-24 px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400"
|
||||
/>
|
||||
)
|
||||
@@ -617,7 +615,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"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('filterPanel.bedingunghinzufügen')}
|
||||
Bedingung hinzufügen
|
||||
</button>
|
||||
{activeCount > 0 && onSaveFilter && (
|
||||
<button
|
||||
@@ -625,7 +623,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"
|
||||
>
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
{t('filterPanel.filterspeichern')}
|
||||
Filter speichern
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { useUsers } from '@/api/users';
|
||||
import { useGroups } from '@/api/groups';
|
||||
import type { FolderPermission } from '@/api/contactFolders';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface FolderPermissionDialogProps {
|
||||
folderId: string;
|
||||
@@ -37,7 +36,6 @@ function permLabel(level: string) {
|
||||
}
|
||||
|
||||
export function FolderPermissionDialog({ folderId, folderName, onClose }: FolderPermissionDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: permData, isLoading } = useFolderPermissions(folderId);
|
||||
const { data: usersData } = useUsers(1, 100);
|
||||
const { data: groupsData } = useGroups();
|
||||
@@ -111,7 +109,7 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-secondary-400 hover:text-secondary-600 p-1 rounded-md hover:bg-secondary-100"
|
||||
aria-label={t('folderPermissionDialog.schließen')}
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<X className="w-5 h-5" strokeWidth={2} />
|
||||
</button>
|
||||
@@ -121,9 +119,9 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{/* Info banner */}
|
||||
<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">{t('folderPermissionDialog.ordnerteilen')}</p>
|
||||
<p className="font-medium mb-1">Ordner teilen</p>
|
||||
<p className="text-primary-600">
|
||||
{t('folderPermissionDialog.gewährebenutzernodergruppenzugriffauf')}
|
||||
Gewähre Benutzern oder Gruppen Zugriff auf diesen Ordner. Mit „Vererben" gelten die Rechte auch für alle Unterordner.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -132,7 +130,7 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
|
||||
<div className="text-sm text-secondary-400 py-4 text-center">Laden…</div>
|
||||
) : permissions.length === 0 ? (
|
||||
<div className="text-sm text-secondary-400 py-4 text-center">
|
||||
{t('folderPermissionDialog.nochkeineberechtigungenvergebendieserord')}
|
||||
Noch keine Berechtigungen vergeben. Dieser Ordner ist nur für den Besitzer sichtbar.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -178,8 +176,8 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
|
||||
<button
|
||||
onClick={() => handleDelete(perm)}
|
||||
className="text-secondary-400 hover:text-red-600 p-1.5 rounded-md hover:bg-red-50 flex-shrink-0"
|
||||
title={t('folderPermissionDialog.entfernen')}
|
||||
aria-label={t('folderPermissionDialog.berechtigungentfernen')}
|
||||
title="Entfernen"
|
||||
aria-label="Berechtigung entfernen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" strokeWidth={2} />
|
||||
</button>
|
||||
@@ -194,7 +192,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="flex items-center gap-2 mb-3">
|
||||
<Plus className="w-4 h-4 text-primary-600" strokeWidth={2} />
|
||||
<span className="text-sm font-medium text-secondary-700">{t('folderPermissionDialog.neueberechtigung')}</span>
|
||||
<span className="text-sm font-medium text-secondary-700">Neue Berechtigung</span>
|
||||
</div>
|
||||
|
||||
{/* Type toggle */}
|
||||
@@ -266,7 +264,7 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
|
||||
onChange={(e) => setAddInherit(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
{t('folderPermissionDialog.aufunterordnervererben')}
|
||||
Auf Unterordner vererben
|
||||
</label>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -292,7 +290,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"
|
||||
>
|
||||
<Plus className="w-4 h-4" strokeWidth={2} />
|
||||
{t('folderPermissionDialog.berechtigunghinzufügen')}
|
||||
Berechtigung hinzufügen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,6 @@ import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Group as GroupIcon, Plus, X } from 'lucide-react';
|
||||
import type { UnifiedContact } from '@/api/unifiedContacts';
|
||||
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ─── Field definitions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -173,7 +172,6 @@ function newGroupId() {
|
||||
}
|
||||
|
||||
export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const btnRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -291,8 +289,8 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
|
||||
<div ref={btnRef} className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
title={t('groupPanel.gruppierung')}
|
||||
aria-label={t('groupPanel.gruppierung')}
|
||||
title="Gruppierung"
|
||||
aria-label="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
|
||||
@@ -333,7 +331,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
|
||||
onClick={clearAll}
|
||||
className="text-xs text-secondary-500 hover:text-red-600 transition-colors"
|
||||
>
|
||||
{t('groupPanel.allelöschen')}
|
||||
Alle löschen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -349,7 +347,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
|
||||
{activeCount === 0 && (
|
||||
<div className="px-4 py-3 border-b border-secondary-100">
|
||||
<div className="text-center py-4 text-xs text-secondary-400">
|
||||
{t('groupPanel.keinegruppierungaktivalledatensätzewerde')}
|
||||
Keine Gruppierung aktiv. Alle Datensätze werden in einer flachen Liste angezeigt.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -437,7 +435,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"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('groupPanel.gruppierunghinzufügen')}
|
||||
Gruppierung hinzufügen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Bookmark, Check, Folder, Filter, Group as GroupIcon, ArrowDownAZ, LayoutGrid } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface SaveViewSelection {
|
||||
folder: boolean;
|
||||
@@ -33,7 +32,6 @@ const defaultSelection: SaveViewSelection = {
|
||||
};
|
||||
|
||||
export function SaveViewDialog({ open, onClose, onSave, hasFilter, hasGroup, hasSort, hasFolder }: SaveViewDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
const [selection, setSelection] = useState<SaveViewSelection>(defaultSelection);
|
||||
|
||||
@@ -68,19 +66,19 @@ export function SaveViewDialog({ open, onClose, onSave, hasFilter, hasGroup, has
|
||||
{/* Header */}
|
||||
<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} />
|
||||
<h2 className="text-base font-semibold text-secondary-800">{t('saveViewDialog.ansichtspeichern')}</h2>
|
||||
<h2 className="text-base font-semibold text-secondary-800">Ansicht speichern</h2>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
{/* Name input */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-secondary-600 mb-1">{t('saveViewDialog.namederansicht')}</label>
|
||||
<label className="block text-xs font-medium text-secondary-600 mb-1">Name der Ansicht</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('saveViewDialog.zbmeinefirmenkontakte')}
|
||||
placeholder="z.B. Meine Firmen-Kontakte"
|
||||
autoFocus
|
||||
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"
|
||||
@@ -89,7 +87,7 @@ export function SaveViewDialog({ open, onClose, onSave, hasFilter, hasGroup, has
|
||||
|
||||
{/* Component selection */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-secondary-600 mb-2">{t('saveViewDialog.wassollgespeichertwerden')}</label>
|
||||
<label className="block text-xs font-medium text-secondary-600 mb-2">Was soll gespeichert werden?</label>
|
||||
<div className="space-y-1.5">
|
||||
{options.map((opt) => (
|
||||
<label
|
||||
@@ -114,7 +112,7 @@ export function SaveViewDialog({ open, onClose, onSave, hasFilter, hasGroup, has
|
||||
</div>
|
||||
</span>
|
||||
{!opt.available && (
|
||||
<span className="text-[10px] text-secondary-400 italic">{t('saveViewDialog.nichtaktiv')}</span>
|
||||
<span className="text-[10px] text-secondary-400 italic">nicht aktiv</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -8,7 +8,6 @@ import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { ArrowDownAZ, ArrowUpZA, Plus, X } from 'lucide-react';
|
||||
import type { UnifiedContact } from '@/api/unifiedContacts';
|
||||
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ─── Field definitions (reuse from FilterPanel) ────────────────────────────────
|
||||
|
||||
@@ -165,7 +164,6 @@ function newSortId() {
|
||||
}
|
||||
|
||||
export function SortPanel({ sortState, onSortChange, contactType }: SortPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const btnRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -284,8 +282,8 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
|
||||
<div ref={btnRef} className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
title={t('sortPanel.sortieren')}
|
||||
aria-label={t('sortPanel.sortieren')}
|
||||
title="Sortieren"
|
||||
aria-label="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
|
||||
@@ -326,7 +324,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
|
||||
onClick={clearAll}
|
||||
className="text-xs text-secondary-500 hover:text-red-600 transition-colors"
|
||||
>
|
||||
{t('sortPanel.allelöschen')}
|
||||
Alle löschen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -342,7 +340,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
|
||||
{activeCount === 0 && (
|
||||
<div className="px-4 py-3 border-b border-secondary-100">
|
||||
<div className="text-center py-4 text-xs text-secondary-400">
|
||||
{t('sortPanel.keinesortierungaktivdatensätzekönnenper')}
|
||||
Keine Sortierung aktiv. Datensätze können per Drag-and-Drop umsortiert werden.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -450,7 +448,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"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('sortPanel.sortierunghinzufügen')}
|
||||
Sortierung hinzufügen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Select } from '@/components/ui/Select';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { X } from 'lucide-react';
|
||||
import type { CustomFieldDefinition } from '@/api/customFieldDefinitions';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface CustomFieldRendererProps {
|
||||
definition: CustomFieldDefinition;
|
||||
@@ -18,7 +17,6 @@ export interface CustomFieldRendererProps {
|
||||
}
|
||||
|
||||
export function CustomFieldRenderer({ definition, value, onChange }: CustomFieldRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
const generatedId = useId();
|
||||
const fieldId = `cf-${definition.id || generatedId}`;
|
||||
const { field_type, options, required } = definition;
|
||||
@@ -41,7 +39,7 @@ export function CustomFieldRenderer({ definition, value, onChange }: CustomField
|
||||
/>
|
||||
<span>
|
||||
{definition.label}
|
||||
{required && <span className="text-danger-500 ml-1" aria-label={t('customFieldRenderer.required')}>*</span>}
|
||||
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -75,7 +73,7 @@ export function CustomFieldRenderer({ definition, value, onChange }: CustomField
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{definition.label}
|
||||
{required && <span className="text-danger-500 ml-1" aria-label={t('customFieldRenderer.required')}>*</span>}
|
||||
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>}
|
||||
</label>
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
@@ -108,9 +106,9 @@ export function CustomFieldRenderer({ definition, value, onChange }: CustomField
|
||||
))}
|
||||
</div>
|
||||
) : availableOptions.length === 0 ? (
|
||||
<p className="text-sm text-secondary-400">{t('customFieldRenderer.keineoptionenverfügbar')}</p>
|
||||
<p className="text-sm text-secondary-400">Keine Optionen verfügbar</p>
|
||||
) : (
|
||||
<p className="text-sm text-secondary-400">{t('customFieldRenderer.alleoptionenausgewählt')}</p>
|
||||
<p className="text-sm text-secondary-400">Alle Optionen ausgewählt</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -125,7 +123,7 @@ export function CustomFieldRenderer({ definition, value, onChange }: CustomField
|
||||
label={definition.label}
|
||||
required={required}
|
||||
options={selectOptions}
|
||||
placeholder={t('customFieldRenderer.bittewählen')}
|
||||
placeholder="— Bitte wählen —"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -44,11 +44,10 @@ interface TabDef {
|
||||
}
|
||||
|
||||
function ChatPanel() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="chat-panel">
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<p className="text-sm text-secondary-400 text-center py-8">{t('aISidebar.chatsidebarcomingsoon')}</p>
|
||||
<p className="text-sm text-secondary-400 text-center py-8">Chat-Sidebar - Coming Soon</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -104,7 +103,6 @@ export function AISidebar() {
|
||||
}
|
||||
|
||||
const renderTabContent = () => {
|
||||
const { t } = useTranslation();
|
||||
if (aiSidebarTab === 'proactive') {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -119,14 +117,14 @@ export function AISidebar() {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto p-3 gap-2" data-testid="notification-list">
|
||||
{notifications.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 text-center py-4">{t('aISidebar.keinebenachrichtigungen')}</p>
|
||||
<p className="text-sm text-secondary-400 text-center py-4">Keine Benachrichtigungen</p>
|
||||
)}
|
||||
{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">
|
||||
<span className="flex-1 leading-snug">{msg}</span>
|
||||
<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"
|
||||
aria-label={t('aISidebar.benachrichtigunglöschen')}
|
||||
aria-label="Benachrichtigung löschen"
|
||||
onClick={() => removeNotification(i)}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" aria-hidden="true" strokeWidth={2} />
|
||||
@@ -152,13 +150,13 @@ export function AISidebar() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-4 gap-3">
|
||||
<p className="text-sm text-secondary-500">
|
||||
{t('aISidebar.dervollekichatistauf')}
|
||||
Der volle KI-Chat ist auf der AI-Assistant-Seite verfügbar.
|
||||
</p>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
{t('aISidebar.aiassistantöffnen')}
|
||||
AI Assistant öffnen
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@@ -192,8 +190,8 @@ export function AISidebar() {
|
||||
<button
|
||||
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"
|
||||
title={t('aISidebar.einklappen')}
|
||||
aria-label={t('aISidebar.kiassistenteinklappen')}
|
||||
title="Einklappen"
|
||||
aria-label="KI Assistent einklappen"
|
||||
>
|
||||
{chevronRightIcon}
|
||||
</button>
|
||||
@@ -212,7 +210,7 @@ export function AISidebar() {
|
||||
<button
|
||||
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"
|
||||
aria-label={t('aISidebar.zurück')}
|
||||
aria-label="Zurück"
|
||||
data-testid="ai-sidebar-back"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
|
||||
|
||||
@@ -128,7 +128,6 @@ function MessageFeed({
|
||||
messages: Message[];
|
||||
loading: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -148,7 +147,7 @@ function MessageFeed({
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-sm text-secondary-400">
|
||||
{t('messageSidebar.keinenachrichten')}
|
||||
Keine Nachrichten
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -206,7 +205,6 @@ function MessageInput({
|
||||
onSend: (text: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const handleSend = () => {
|
||||
@@ -238,7 +236,7 @@ function MessageInput({
|
||||
onClick={handleSend}
|
||||
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"
|
||||
aria-label={t('messageSidebar.senden')}
|
||||
aria-label="Senden"
|
||||
data-testid="message-send-btn"
|
||||
>
|
||||
{sendIcon}
|
||||
@@ -388,7 +386,6 @@ export function MessageSidebar() {
|
||||
|
||||
// Determine which pinned conv to select when a quick-access button is clicked
|
||||
const handleQuickAccess = (qa: QuickAccessDef) => {
|
||||
const { t } = useTranslation();
|
||||
setCurrentView(qa.view);
|
||||
if (qa.view === 'conversations' && qa.filterPinned) {
|
||||
// Find pinned conversation matching the filter by title
|
||||
@@ -483,8 +480,8 @@ export function MessageSidebar() {
|
||||
<button
|
||||
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"
|
||||
title={t('messageSidebar.einklappen')}
|
||||
aria-label={t('messageSidebar.messagingeinklappen')}
|
||||
title="Einklappen"
|
||||
aria-label="Messaging einklappen"
|
||||
>
|
||||
{chevronRightIcon}
|
||||
</button>
|
||||
@@ -501,7 +498,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>
|
||||
)}
|
||||
{!loading && conversations.length === 0 && !error && (
|
||||
<p className="text-sm text-secondary-400 text-center py-4">{t('messageSidebar.keinekonversationen')}</p>
|
||||
<p className="text-sm text-secondary-400 text-center py-4">Keine Konversationen</p>
|
||||
)}
|
||||
{/* Pinned conversations */}
|
||||
{pinnedConversations.length > 0 && (
|
||||
@@ -540,7 +537,6 @@ export function MessageSidebar() {
|
||||
|
||||
// ─── Main Content Area ───
|
||||
const renderContent = () => {
|
||||
const { t } = useTranslation();
|
||||
if (currentView === 'team') {
|
||||
return <TeamPanel onStartDirectChat={handleStartDirectChat} />;
|
||||
}
|
||||
@@ -568,7 +564,7 @@ export function MessageSidebar() {
|
||||
)}
|
||||
{isSystemLocked && (
|
||||
<div className="p-3 border-t border-secondary-200 text-center">
|
||||
<span className="text-xs text-secondary-400">{t('messageSidebar.systemkonversationschreibgeschützt')}</span>
|
||||
<span className="text-xs text-secondary-400">System-Konversation (schreibgeschützt)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -588,7 +584,7 @@ export function MessageSidebar() {
|
||||
<button
|
||||
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"
|
||||
aria-label={t('messageSidebar.zurück')}
|
||||
aria-label="Zurück"
|
||||
data-testid="message-sidebar-back"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React from 'react';
|
||||
import { usePluginToolbarStore, type ToolbarItem } from '@/store/pluginToolbarStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function ToolbarButton({ item }: { item: ToolbarItem }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
onClick={item.onClick}
|
||||
@@ -23,7 +21,6 @@ function ToolbarButton({ item }: { item: ToolbarItem }) {
|
||||
}
|
||||
|
||||
function ToolbarSearch({ item }: { item: ToolbarItem }) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [value, setValue] = React.useState('');
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
@@ -77,7 +74,7 @@ function ToolbarSearch({ item }: { item: ToolbarItem }) {
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="ml-1 p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600 transition-colors"
|
||||
aria-label={t('pluginToolbar.sucheschließen')}
|
||||
aria-label="Suche schließen"
|
||||
>
|
||||
<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" />
|
||||
|
||||
@@ -182,21 +182,21 @@ export function Sidebar() {
|
||||
'flex flex-col transition-transform motion-safe:duration-300',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full md:hidden'
|
||||
)}
|
||||
aria-label={t('sidebar.seitenleistenavigation')}
|
||||
aria-label="Seitenleiste Navigation"
|
||||
data-testid="sidebar"
|
||||
>
|
||||
<div className="h-[58px] flex items-center gap-2 px-4 border-b border-secondary-700">
|
||||
<button
|
||||
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"
|
||||
aria-label={t('sidebar.zurückzurstartseite')}
|
||||
title={t('sidebar.zurückzurstartseite')}
|
||||
aria-label="Zurück zur Startseite"
|
||||
title="Zurück zur Startseite"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xl font-bold text-white">leocrm</span>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-4" aria-label={t('sidebar.hauptnavigation')}>
|
||||
<nav className="flex-1 overflow-y-auto py-4" aria-label="Hauptnavigation">
|
||||
<ul className="space-y-1 px-2">
|
||||
{(() => {
|
||||
const groups = new Map<string, typeof allMenuItems>();
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Curated icon map — avoids `import * as LucideIcons` which loads ALL icons
|
||||
// and causes OOM in tests (ARCH-063).
|
||||
@@ -70,7 +69,6 @@ function getIcon(name: string): React.ReactNode {
|
||||
}
|
||||
|
||||
export function SortableMenuItem({ id, label, icon, isGroup }: SortableMenuItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -101,7 +99,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"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={t('sortableMenuItem.ziehenzumsortieren')}
|
||||
aria-label="Ziehen zum Sortieren"
|
||||
type="button"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
|
||||
@@ -63,7 +63,7 @@ export function TopBar() {
|
||||
<button
|
||||
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"
|
||||
aria-label={t('topBar.seitenleisteeinausklappen')}
|
||||
aria-label="Seitenleiste ein-/ausklappen"
|
||||
aria-expanded={true}
|
||||
>
|
||||
<Menu className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
|
||||
@@ -303,7 +303,7 @@ export function ComposeModal({
|
||||
label={t('mail.to')}
|
||||
{...register('to')}
|
||||
error={errorMsg(errors.to?.message)}
|
||||
placeholder={t('composeModal.recipientexamplecom')}
|
||||
placeholder="recipient@example.com"
|
||||
required
|
||||
data-testid="compose-to"
|
||||
/>
|
||||
@@ -322,13 +322,13 @@ export function ComposeModal({
|
||||
label={t('mail.cc')}
|
||||
{...register('cc')}
|
||||
error={errorMsg(errors.cc?.message)}
|
||||
placeholder={t('composeModal.ccexamplecom')}
|
||||
placeholder="cc@example.com"
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.bcc')}
|
||||
{...register('bcc')}
|
||||
error={errorMsg(errors.bcc?.message)}
|
||||
placeholder={t('composeModal.bccexamplecom')}
|
||||
placeholder="bcc@example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -376,7 +376,7 @@ export function ComposeModal({
|
||||
>
|
||||
{t('mail.addAttachment')}
|
||||
</Button>
|
||||
<span className="text-xs text-secondary-400">{t('composeModal.max25mbperfile')}</span>
|
||||
<span className="text-xs text-secondary-400">Max 25 MB per file</span>
|
||||
</div>
|
||||
{attachments.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
|
||||
@@ -304,7 +304,7 @@ export function MailComposeForm({
|
||||
label={t('mail.to')}
|
||||
{...register('to')}
|
||||
error={errorMsg(errors.to?.message)}
|
||||
placeholder={t('mailComposeForm.recipientexamplecom')}
|
||||
placeholder="recipient@example.com"
|
||||
required
|
||||
data-testid="compose-to"
|
||||
/>
|
||||
@@ -323,13 +323,13 @@ export function MailComposeForm({
|
||||
label={t('mail.cc')}
|
||||
{...register('cc')}
|
||||
error={errorMsg(errors.cc?.message)}
|
||||
placeholder={t('mailComposeForm.ccexamplecom')}
|
||||
placeholder="cc@example.com"
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.bcc')}
|
||||
{...register('bcc')}
|
||||
error={errorMsg(errors.bcc?.message)}
|
||||
placeholder={t('mailComposeForm.bccexamplecom')}
|
||||
placeholder="bcc@example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -377,7 +377,7 @@ export function MailComposeForm({
|
||||
>
|
||||
{t('mail.addAttachment')}
|
||||
</Button>
|
||||
<span className="text-xs text-secondary-400">{t('mailComposeForm.max25mbperfile')}</span>
|
||||
<span className="text-xs text-secondary-400">Max 25 MB per file</span>
|
||||
</div>
|
||||
{attachments.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Filter, Plus, X, Bookmark } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ─── Field definitions ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -184,7 +183,6 @@ function newConditionId() {
|
||||
}
|
||||
|
||||
export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], onSaveFilter, onLoadFilter, onDeleteFilter }: MailFilterPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const btnRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -274,8 +272,8 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
||||
<div ref={btnRef} className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
title={t('mailFilterPanel.filter')}
|
||||
aria-label={t('mailFilterPanel.filter')}
|
||||
title="Filter"
|
||||
aria-label="Filter"
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
||||
transition-colors duration-100 cursor-pointer relative
|
||||
@@ -313,7 +311,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
||||
<div className="flex items-center gap-2">
|
||||
{activeCount > 0 && (
|
||||
<button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">
|
||||
{t('mailFilterPanel.allelöschen')}
|
||||
Alle löschen
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600">
|
||||
@@ -325,7 +323,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
||||
{/* Logic toggle */}
|
||||
{activeCount > 0 && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-100">
|
||||
<span className="text-xs text-secondary-500">{t('mailFilterPanel.bedingungenverknüpfen')}</span>
|
||||
<span className="text-xs text-secondary-500">Bedingungen verknü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 === 'OR' ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'}`}>ODER</button>
|
||||
</div>
|
||||
@@ -350,7 +348,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
||||
{/* Saved filters */}
|
||||
{(savedFilters?.length ?? 0) > 0 && (
|
||||
<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">{t('mailFilterPanel.gespeichertefilter')}</div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-secondary-400 mb-1.5">Gespeicherte Filter</div>
|
||||
<div className="space-y-0.5">
|
||||
{savedFilters.map((sf) => (
|
||||
<div key={sf.id} className="flex items-center gap-1 group">
|
||||
@@ -371,7 +369,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
{(filters.conditions?.length ?? 0) === 0 && (
|
||||
<div className="text-center py-6 text-xs text-secondary-400">
|
||||
{t('mailFilterPanel.keinefilteraktivklickeuntenum')}
|
||||
Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen.
|
||||
</div>
|
||||
)}
|
||||
{filters.conditions.map((cond, idx) => {
|
||||
@@ -411,13 +409,13 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
||||
{currentOp?.needsValue ? (
|
||||
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">
|
||||
<option value="">{t('mailFilterPanel.wählen')}</option>
|
||||
<option value="">— wählen —</option>
|
||||
{def.options?.map((opt) => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
|
||||
</select>
|
||||
) : 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="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" />
|
||||
<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" />
|
||||
)
|
||||
) : (
|
||||
<div className="w-24" />
|
||||
@@ -436,12 +434,12 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
||||
<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">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('mailFilterPanel.bedingunghinzufügen')}
|
||||
Bedingung hinzufügen
|
||||
</button>
|
||||
{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">
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
{t('mailFilterPanel.filterspeichern')}
|
||||
Filter speichern
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -116,7 +116,6 @@ function ContextMenu({
|
||||
onClose: () => void;
|
||||
onEmptyFolder: (folderId: string, folderName: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -137,7 +136,7 @@ function ContextMenu({
|
||||
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"
|
||||
>
|
||||
{t('mailFolderTree.ordnerleeren')}
|
||||
Ordner leeren
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Group as GroupIcon, Plus, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type FieldType = 'text' | 'number' | 'date';
|
||||
|
||||
@@ -115,7 +114,6 @@ interface MailGroupPanelProps {
|
||||
}
|
||||
|
||||
export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const btnRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -170,8 +168,8 @@ export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProp
|
||||
<div ref={btnRef} className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
title={t('mailGroupPanel.gruppierung')}
|
||||
aria-label={t('mailGroupPanel.gruppierung')}
|
||||
title="Gruppierung"
|
||||
aria-label="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'}`}
|
||||
>
|
||||
<GroupIcon className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
@@ -186,12 +184,12 @@ export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProp
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeCount > 0 && <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">{t('mailGroupPanel.allelöschen')}</button>}
|
||||
{activeCount > 0 && <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">Alle lö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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
{groupState.conditions.length === 0 && <div className="text-center py-6 text-xs text-secondary-400">{t('mailGroupPanel.keinegruppierungaktivklickeuntenum')}</div>}
|
||||
{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.map((cond, idx) => (
|
||||
<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>
|
||||
@@ -205,7 +203,7 @@ export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProp
|
||||
))}
|
||||
</div>
|
||||
<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" />{t('mailGroupPanel.feldhinzufü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" />Feld hinzufü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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { ArrowUpDown, Plus, X, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type FieldType = 'text' | 'number' | 'date';
|
||||
|
||||
@@ -87,7 +86,6 @@ interface MailSortPanelProps {
|
||||
}
|
||||
|
||||
export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const btnRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -146,8 +144,8 @@ export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
|
||||
<div ref={btnRef} className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
title={t('mailSortPanel.sortieren')}
|
||||
aria-label={t('mailSortPanel.sortieren')}
|
||||
title="Sortieren"
|
||||
aria-label="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'}`}
|
||||
>
|
||||
<ArrowUpDown className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
@@ -162,12 +160,12 @@ export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeCount > 0 && <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">{t('mailSortPanel.allelöschen')}</button>}
|
||||
{activeCount > 0 && <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">Alle lö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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
{sortState.conditions.length === 0 && <div className="text-center py-6 text-xs text-secondary-400">{t('mailSortPanel.keinesortierungaktivklickeuntenum')}</div>}
|
||||
{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.map((cond, idx) => (
|
||||
<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>
|
||||
@@ -184,7 +182,7 @@ export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
|
||||
))}
|
||||
</div>
|
||||
<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" />{t('mailSortPanel.feldhinzufü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" />Feld hinzufü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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function PgpSettings() {
|
||||
value={privateKey}
|
||||
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"
|
||||
placeholder={t('pgpSettings.beginpgpprivatekeyblock')}
|
||||
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK-----"
|
||||
data-testid="pgp-private-key"
|
||||
/>
|
||||
</div>
|
||||
@@ -135,7 +135,7 @@ export function PgpSettings() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-900">{key.user_id}</p>
|
||||
<p className="text-xs text-secondary-500">{t('pgpSettings.keyid')} {key.key_id}</p>
|
||||
<p className="text-xs text-secondary-500">Key ID: {key.key_id}</p>
|
||||
<p className="text-xs text-secondary-400">Fingerprint: {key.fingerprint}</p>
|
||||
</div>
|
||||
<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')}
|
||||
value={contactId}
|
||||
onChange={(e) => setContactId(e.target.value)}
|
||||
placeholder={t('pgpSettings.contactuuid')}
|
||||
placeholder="contact-uuid"
|
||||
/>
|
||||
<div>
|
||||
<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}
|
||||
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"
|
||||
placeholder={t('pgpSettings.beginpgppublickeyblock')}
|
||||
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"
|
||||
data-testid="contact-public-key"
|
||||
/>
|
||||
</div>
|
||||
@@ -175,7 +175,7 @@ export function PgpSettings() {
|
||||
{contactKeys.map((ck) => (
|
||||
<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-xs text-secondary-500">{t('pgpSettings.keyid')} {ck.key_id}</p>
|
||||
<p className="text-xs text-secondary-500">Key ID: {ck.key_id}</p>
|
||||
<p className="text-xs text-secondary-400">Fingerprint: {ck.fingerprint}</p>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -195,13 +195,13 @@ export function RichTextEditor({ content, onChange, placeholder, editable = true
|
||||
<div className="w-px h-6 bg-secondary-200 mx-1" />
|
||||
|
||||
{/* Color */}
|
||||
<label className="p-1.5 rounded hover:bg-secondary-100 cursor-pointer" title={t('richTextEditor.textcolor')}>
|
||||
<label className="p-1.5 rounded hover:bg-secondary-100 cursor-pointer" title="Text color">
|
||||
<Heading className="w-4 h-4" strokeWidth={2} />
|
||||
<input
|
||||
type="color"
|
||||
className="sr-only"
|
||||
onChange={(e) => editor.chain().focus().setColor(e.target.value).run()}
|
||||
title={t('richTextEditor.textcolor')}
|
||||
title="Text color"
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ export function SignatureManager() {
|
||||
<RichTextEditor
|
||||
content={bodyHtmlValue}
|
||||
onChange={(html: string) => setSigValue('body_html', html)}
|
||||
placeholder={t('signatureManager.pmitfreundlichengrüßenbruser')}
|
||||
placeholder="<p>Mit freundlichen Grüßen,<br>{{user.name}}</p>"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-secondary-700">
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import type { NotificationItem as NotificationItemType } from '@/api/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
@@ -79,7 +78,6 @@ export interface NotificationItemProps {
|
||||
}
|
||||
|
||||
export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const isUnread = notification.read_at == null;
|
||||
const Icon = getIconForType(notification.type);
|
||||
|
||||
@@ -125,8 +123,8 @@ export function NotificationItem({ notification, onMarkRead }: NotificationItemP
|
||||
{isUnread && (
|
||||
<span
|
||||
className="flex-shrink-0 w-2 h-2 rounded-full bg-primary-500"
|
||||
aria-label={t('notificationItem.ungelesen')}
|
||||
title={t('notificationItem.ungelesen')}
|
||||
aria-label="ungelesen"
|
||||
title="ungelesen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Loader2 } from 'lucide-react';
|
||||
import { usePluginStore } from '@/store/pluginStore';
|
||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||
import { PluginPage } from './PluginLoader';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* PluginRouteRenderer — catch-all route handler that checks the current URL
|
||||
@@ -18,7 +17,6 @@ import { useTranslation } from 'react-i18next';
|
||||
* { path: '*', element: <PluginRouteRenderer /> }
|
||||
*/
|
||||
export function PluginRouteRenderer() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const manifests = usePluginStore((s) => s.manifests);
|
||||
const loaded = usePluginStore((s) => s.loaded);
|
||||
@@ -65,7 +63,7 @@ export function PluginRouteRenderer() {
|
||||
// If manifests haven't loaded yet, show a spinner (not null/blank)
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]" role="status" aria-label={t('pluginRouteRenderer.loading')}>
|
||||
<div className="flex items-center justify-center min-h-[50vh]" role="status" aria-label="Loading">
|
||||
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
@@ -74,9 +72,9 @@ export function PluginRouteRenderer() {
|
||||
// No plugin route matched — show a simple not-found
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] text-secondary-500">
|
||||
<h2 className="text-2xl font-semibold mb-2">{t('pluginRouteRenderer.pagenotfound')}</h2>
|
||||
<h2 className="text-2xl font-semibold mb-2">Page Not Found</h2>
|
||||
<p className="text-sm">
|
||||
{t('pluginRouteRenderer.thepage')} <code className="bg-secondary-100 px-1 rounded">{location.pathname}</code> {t('pluginRouteRenderer.wasnotfound')}
|
||||
The page <code className="bg-secondary-100 px-1 rounded">{location.pathname}</code> was not found.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -111,21 +111,21 @@ export function WorkspaceManager() {
|
||||
<h3 className="font-medium">{t('workspaces.createNew', 'Neuen Workspace erstellen')}</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('workspaceManager.name')}
|
||||
placeholder="Name"
|
||||
value={editName}
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('workspaceManager.beschreibung')}
|
||||
placeholder="Beschreibung"
|
||||
value={editDesc}
|
||||
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"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={editDefault} onChange={e => setEditDefault(e.target.checked)} />
|
||||
{t('workspaceManager.alsstandardworkspace')}
|
||||
Als Standard-Workspace
|
||||
</label>
|
||||
<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">
|
||||
@@ -169,7 +169,7 @@ export function WorkspaceManager() {
|
||||
<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); }}
|
||||
className="text-xs px-1.5 py-0.5 border rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title={t('workspaceManager.konfigurationbearbeiten')}
|
||||
title="Konfiguration bearbeiten"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
@@ -179,7 +179,7 @@ export function WorkspaceManager() {
|
||||
<div className="mt-2 space-y-1">
|
||||
<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"
|
||||
placeholder={t('workspaceManager.visiblefolderids')}
|
||||
placeholder='{"visible_folder_ids": []}'
|
||||
value={JSON.stringify(m.config || {}, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
@@ -190,7 +190,7 @@ export function WorkspaceManager() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-400">{t('workspaceManager.jsonkonfigurationfürdiesesmodulz')}</p>
|
||||
<p className="text-xs text-gray-400">JSON-Konfiguration für dieses Modul (z.B. sichtbare Ordner-IDs)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -211,7 +211,7 @@ export function WorkspaceManager() {
|
||||
{editingId === ws.id ? (
|
||||
<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={editDesc} onChange={e => setEditDesc(e.target.value)} className="w-full px-3 py-2 border rounded-md text-sm" placeholder={t('workspaceManager.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="Beschreibung" />
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={editDefault} onChange={e => setEditDefault(e.target.checked)} />
|
||||
Standard
|
||||
@@ -246,21 +246,21 @@ export function WorkspaceManager() {
|
||||
<button
|
||||
onClick={() => openModuleEditor(ws)}
|
||||
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md text-sm"
|
||||
title={t('workspaceManager.module')}
|
||||
title="Module"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
title={t('workspaceManager.bearbeiten')}
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
title={t('workspaceManager.löschen')}
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface ActivityItem {
|
||||
id: string;
|
||||
@@ -18,12 +17,11 @@ export interface ActivityFeedProps {
|
||||
}
|
||||
|
||||
export function ActivityFeed({ activities, title = 'Letzte Aktivitäten', maxItems = 10 }: ActivityFeedProps) {
|
||||
const { t } = useTranslation();
|
||||
const visible = activities.slice(0, maxItems);
|
||||
return (
|
||||
<Card title={title} data-testid="activity-feed">
|
||||
{visible.length === 0 ? (
|
||||
<p className="text-sm text-secondary-500 py-4 text-center">{t('activityFeed.keineaktivitätenvorhanden')}</p>
|
||||
<p className="text-sm text-secondary-500 py-4 text-center">Keine Aktivitäten vorhanden.</p>
|
||||
) : (
|
||||
<ul className="space-y-3" role="list">
|
||||
{visible.map((activity) => (
|
||||
|
||||
@@ -112,7 +112,7 @@ export function DataGrid<T extends Record<string, any>>({
|
||||
ref={scrollRef}
|
||||
className="overflow-x-auto"
|
||||
role="region"
|
||||
aria-label={t('dataGrid.datagrid')}
|
||||
aria-label="Data grid"
|
||||
style={shouldVirtualize ? { maxHeight: '70vh', overflowY: 'auto' } : undefined}
|
||||
>
|
||||
<table className="min-w-full divide-y divide-secondary-200">
|
||||
|
||||
@@ -143,7 +143,7 @@ export function SearchDropdown({ placeholder }: SearchDropdownProps) {
|
||||
onClick={handleSeeAll}
|
||||
className="text-sm text-primary-600 hover:text-primary-700 font-medium min-h-touch"
|
||||
>
|
||||
{t('searchDropdown.alleergebnisseanzeigen')}
|
||||
Alle Ergebnisse anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -10,14 +10,12 @@ import React from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useUsers, useGroups } from '@/api/hooks';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SharedTeamPanelProps {
|
||||
onStartDirectChat?: (userId: string, userName: string) => void;
|
||||
}
|
||||
|
||||
export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: usersData, isLoading: usersLoading } = useUsers();
|
||||
const { data: groupsData, isLoading: groupsLoading } = useGroups();
|
||||
const users: any[] = usersData?.items || [];
|
||||
@@ -30,7 +28,7 @@ export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
|
||||
{usersLoading ? (
|
||||
<p className="text-sm text-secondary-400">Laden...</p>
|
||||
) : users.length === 0 ? (
|
||||
<p className="text-sm text-secondary-400">{t('teamPanel.keinemitarbeiter')}</p>
|
||||
<p className="text-sm text-secondary-400">Keine Mitarbeiter</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{users.map((u) =>
|
||||
@@ -42,7 +40,7 @@ export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<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={t('teamPanel.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="offline" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-secondary-700 truncate">{u.name}</p>
|
||||
@@ -54,7 +52,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 className="relative flex-shrink-0">
|
||||
<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={t('teamPanel.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="offline" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-secondary-700 truncate">{u.name}</p>
|
||||
@@ -72,7 +70,7 @@ export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
|
||||
{groupsLoading ? (
|
||||
<p className="text-sm text-secondary-400">Laden...</p>
|
||||
) : groups.length === 0 ? (
|
||||
<p className="text-sm text-secondary-400">{t('teamPanel.keinegruppen')}</p>
|
||||
<p className="text-sm text-secondary-400">Keine Gruppen</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{groups.map((g) => (
|
||||
|
||||
@@ -195,7 +195,7 @@ export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
|
||||
id="assignee-id"
|
||||
value={assigneeId}
|
||||
onChange={(e) => setAssigneeId(e.target.value)}
|
||||
placeholder={t('taskDetail.uuid')}
|
||||
placeholder="UUID"
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface ModalProps {
|
||||
open: boolean;
|
||||
@@ -33,7 +32,6 @@ export function Modal({
|
||||
showCloseButton = true,
|
||||
fullScreenMobile = false,
|
||||
}: ModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||
|
||||
@@ -100,7 +98,7 @@ export function Modal({
|
||||
<button
|
||||
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"
|
||||
aria-label={t('modal.closedialog')}
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<X className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Pagination({ currentPage, totalPages, total, pageSize, onPageCha
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<nav className="flex items-center justify-between px-3 py-1.5 border-t border-secondary-200 bg-white" aria-label={t('pagination.pagination')}>
|
||||
<nav className="flex items-center justify-between px-3 py-1.5 border-t border-secondary-200 bg-white" aria-label="Pagination">
|
||||
<div className="text-xs text-secondary-500">
|
||||
<span>{start}–{end}</span> <span>{t('table.of')}</span> <span>{total}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface SkeletonProps {
|
||||
className?: string;
|
||||
@@ -10,7 +9,6 @@ export interface SkeletonProps {
|
||||
}
|
||||
|
||||
export function Skeleton({ className, variant = 'rect', width, height }: SkeletonProps) {
|
||||
const { t } = useTranslation();
|
||||
const variantClass = {
|
||||
text: 'rounded',
|
||||
rect: 'rounded-md',
|
||||
@@ -26,15 +24,14 @@ export function Skeleton({ className, variant = 'rect', width, height }: Skeleto
|
||||
)}
|
||||
style={{ width, height }}
|
||||
role="status"
|
||||
aria-label={t('skeleton.wirdgeladen')}
|
||||
aria-label="Wird geladen"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonText({ lines = 3, className }: { lines?: number; className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={clsx('space-y-2', className)} role="status" aria-label={t('skeleton.wirdgeladen')}>
|
||||
<div className={clsx('space-y-2', className)} role="status" aria-label="Wird geladen">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface TableColumn<T> {
|
||||
key: string;
|
||||
@@ -31,7 +30,6 @@ export function Table<T extends Record<string, any>>({
|
||||
emptyMessage = 'Keine Daten vorhanden',
|
||||
loading = false,
|
||||
}: TableProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
|
||||
@@ -61,7 +59,7 @@ export function Table<T extends Record<string, any>>({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto" role="region" aria-label={t('table.datatable')}>
|
||||
<div className="overflow-x-auto" role="region" aria-label="Data table">
|
||||
<table className="min-w-full divide-y divide-secondary-200">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -104,7 +102,7 @@ export function Table<T extends Record<string, any>>({
|
||||
<td colSpan={columns.length} className="px-6 py-8 text-center text-secondary-500">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="animate-spin motion-reduce:animate-none h-5 w-5" aria-hidden="true" />
|
||||
{t('table.wirdgeladen')}
|
||||
Wird geladen...
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useEffect, useCallback } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { Check, X, AlertTriangle, Info } from 'lucide-react';
|
||||
import { useUIStore, Toast as ToastType } from '@/store/uiStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const toastStyles: Record<ToastType['type'], string> = {
|
||||
success: 'bg-success-50 border-success-500 text-success-800',
|
||||
@@ -19,7 +18,6 @@ const toastIcons: Record<ToastType['type'], React.ComponentType<{ className?: st
|
||||
};
|
||||
|
||||
function ToastItem({ toast, onRemove }: { toast: ToastType; onRemove: (id: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
const duration = toast.duration ?? 5000;
|
||||
const timer = setTimeout(() => onRemove(toast.id), duration);
|
||||
@@ -44,7 +42,7 @@ function ToastItem({ toast, onRemove }: { toast: ToastType; onRemove: (id: strin
|
||||
<button
|
||||
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"
|
||||
aria-label={t('toast.closenotification')}
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,6 @@ import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Sparkles, Send } from 'lucide-react';
|
||||
import { streamChat, fetchMessages } from '@/api/ai';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface AiChatPanelProps {
|
||||
windowTitle: string;
|
||||
@@ -17,7 +16,6 @@ interface SimpleMessage {
|
||||
}
|
||||
|
||||
export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messages, setMessages] = useState<SimpleMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
@@ -120,10 +118,10 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary-500" />
|
||||
<span className="text-sm font-semibold text-secondary-700">{t('aiChatPanel.kiassistent')}</span>
|
||||
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-secondary-400">
|
||||
{t('aiChatPanel.verbindemitki')}
|
||||
Verbinde mit KI...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -134,7 +132,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary-500" />
|
||||
<span className="text-sm font-semibold text-secondary-700">{t('aiChatPanel.kiassistent')}</span>
|
||||
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center p-4 text-center">
|
||||
<div>
|
||||
@@ -151,7 +149,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
||||
{/* Header */}
|
||||
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary-500" />
|
||||
<span className="text-sm font-semibold text-secondary-700">{t('aiChatPanel.kiassistent')}</span>
|
||||
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span>
|
||||
</div>
|
||||
|
||||
{/* Context info */}
|
||||
@@ -163,7 +161,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{messages.length === 0 && !streamingContent && (
|
||||
<p className="text-sm text-secondary-400 text-center mt-4">
|
||||
{t('aiChatPanel.stelleeinefragezumaktuellenfenster')}
|
||||
Stelle eine Frage zum aktuellen Fenster...
|
||||
</p>
|
||||
)}
|
||||
{messages.map((msg) => (
|
||||
@@ -195,7 +193,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('aiChatPanel.nachrichteingeben')}
|
||||
placeholder="Nachricht eingeben..."
|
||||
rows={1}
|
||||
className="flex-1 px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
|
||||
disabled={isStreaming}
|
||||
@@ -204,7 +202,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isStreaming}
|
||||
className="p-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label={t('aiChatPanel.senden')}
|
||||
aria-label="Senden"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -3,14 +3,12 @@ import clsx from 'clsx';
|
||||
import { Sparkles, Maximize2, Minimize2, Minus, X } from 'lucide-react';
|
||||
import { useWindowStore, type WindowState } from '@/store/windowStore';
|
||||
import { AiChatPanel } from './AiChatPanel';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface WindowProps {
|
||||
window: WindowState;
|
||||
}
|
||||
|
||||
export function Window({ window: win }: WindowProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
closeWindow,
|
||||
minimizeWindow,
|
||||
@@ -108,8 +106,8 @@ export function Window({ window: win }: WindowProps) {
|
||||
'p-1.5 rounded hover:bg-secondary-200',
|
||||
win.aiChatVisible && 'bg-primary-100 text-primary-600'
|
||||
)}
|
||||
aria-label={t('window.kichateinaus')}
|
||||
title={t('window.kichat')}
|
||||
aria-label="KI Chat ein/aus"
|
||||
title="KI Chat"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -130,8 +128,8 @@ export function Window({ window: win }: WindowProps) {
|
||||
<button
|
||||
onClick={() => minimizeWindow(win.id)}
|
||||
className="p-1.5 rounded hover:bg-secondary-200"
|
||||
aria-label={t('window.minimieren')}
|
||||
title={t('window.minimieren')}
|
||||
aria-label="Minimieren"
|
||||
title="Minimieren"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -139,8 +137,8 @@ export function Window({ window: win }: WindowProps) {
|
||||
<button
|
||||
onClick={() => closeWindow(win.id)}
|
||||
className="p-1.5 rounded hover:bg-danger-100 hover:text-danger-600"
|
||||
aria-label={t('window.schließen')}
|
||||
title={t('window.schließen')}
|
||||
aria-label="Schließen"
|
||||
title="Schließen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Select } from '@/components/ui/Select';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Code2, FormInput } from 'lucide-react';
|
||||
import type { WorkflowStep, WorkflowStepType } from '@/api/workflows';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const stepTypeOptions: { value: WorkflowStepType; label: string }[] = [
|
||||
{ value: 'action', label: 'Action' },
|
||||
@@ -54,7 +53,6 @@ export interface StepConfigPanelProps {
|
||||
}
|
||||
|
||||
export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<ConfigMode>('form');
|
||||
const [configText, setConfigText] = useState('');
|
||||
const [configError, setConfigError] = useState<string | undefined>(undefined);
|
||||
@@ -113,7 +111,6 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
};
|
||||
|
||||
const renderTypeForm = () => {
|
||||
const { t } = useTranslation();
|
||||
switch (step.type) {
|
||||
case 'wait':
|
||||
return (
|
||||
@@ -129,13 +126,13 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
e.target.value === '' ? undefined : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
placeholder={t('stepConfigPanel.zb3600')}
|
||||
placeholder="z.B. 3600"
|
||||
/>
|
||||
<Input
|
||||
label="Resume-Zeitpunkt (ISO)"
|
||||
value={strVal('resume_at')}
|
||||
onChange={(e) => setConfig('resume_at', e.target.value || undefined)}
|
||||
placeholder={t('stepConfigPanel.20260818t090000z')}
|
||||
placeholder="2026-08-18T09:00:00Z"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -154,7 +151,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
required
|
||||
value={strVal('url')}
|
||||
onChange={(e) => setConfig('url', e.target.value)}
|
||||
placeholder={t('stepConfigPanel.httpsapiexamplecomwebhook')}
|
||||
placeholder="https://api.example.com/webhook"
|
||||
/>
|
||||
</div>
|
||||
<JsonField
|
||||
@@ -171,7 +168,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
onChange={(e) => setConfig('body', e.target.value)}
|
||||
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"
|
||||
placeholder={t('stepConfigPanel.keyvalue')}
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
@@ -197,14 +194,14 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
required
|
||||
value={strVal('to')}
|
||||
onChange={(e) => setConfig('to', e.target.value)}
|
||||
placeholder={t('stepConfigPanel.empfaengerexamplecom')}
|
||||
placeholder="empfaenger@example.com"
|
||||
/>
|
||||
<Input
|
||||
label="Betreff"
|
||||
required
|
||||
value={strVal('subject')}
|
||||
onChange={(e) => setConfig('subject', e.target.value)}
|
||||
placeholder={t('stepConfigPanel.betreff')}
|
||||
placeholder="Betreff"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -216,14 +213,14 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
onChange={(e) => setConfig('body', e.target.value)}
|
||||
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"
|
||||
placeholder={t('stepConfigPanel.nachrichtentext')}
|
||||
placeholder="Nachrichtentext"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Account-ID (optional)"
|
||||
value={strVal('account_id')}
|
||||
onChange={(e) => setConfig('account_id', e.target.value || undefined)}
|
||||
placeholder={t('stepConfigPanel.standardkontowennleer')}
|
||||
placeholder="Standard-Konto wenn leer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -243,19 +240,19 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
label="Titel"
|
||||
value={strVal('title')}
|
||||
onChange={(e) => setConfig('title', e.target.value)}
|
||||
placeholder={t('stepConfigPanel.eventtitel')}
|
||||
placeholder="Event-Titel"
|
||||
/>
|
||||
<Input
|
||||
label="Start (ISO)"
|
||||
value={strVal('start')}
|
||||
onChange={(e) => setConfig('start', e.target.value)}
|
||||
placeholder={t('stepConfigPanel.20260818t090000z')}
|
||||
placeholder="2026-08-18T09:00:00Z"
|
||||
/>
|
||||
<Input
|
||||
label="Ende (ISO)"
|
||||
value={strVal('end')}
|
||||
onChange={(e) => setConfig('end', e.target.value)}
|
||||
placeholder={t('stepConfigPanel.20260818t100000z')}
|
||||
placeholder="2026-08-18T10:00:00Z"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -264,7 +261,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
label="Event-ID"
|
||||
value={strVal('event_id')}
|
||||
onChange={(e) => setConfig('event_id', e.target.value)}
|
||||
placeholder={t('stepConfigPanel.eventuuid')}
|
||||
placeholder="Event-UUID"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -285,7 +282,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
label="Suchbegriff"
|
||||
value={strVal('query')}
|
||||
onChange={(e) => setConfig('query', e.target.value)}
|
||||
placeholder={t('stepConfigPanel.suchbegriff')}
|
||||
placeholder="Suchbegriff"
|
||||
/>
|
||||
)}
|
||||
{action === 'download' && (
|
||||
@@ -293,7 +290,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
label="Datei-ID"
|
||||
value={strVal('file_id')}
|
||||
onChange={(e) => setConfig('file_id', e.target.value)}
|
||||
placeholder={t('stepConfigPanel.dateiuuid')}
|
||||
placeholder="Datei-UUID"
|
||||
/>
|
||||
)}
|
||||
{action === 'upload' && (
|
||||
@@ -302,13 +299,13 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
label="Dateiname"
|
||||
value={strVal('file_name')}
|
||||
onChange={(e) => setConfig('file_name', e.target.value)}
|
||||
placeholder={t('stepConfigPanel.dateipdf')}
|
||||
placeholder="datei.pdf"
|
||||
/>
|
||||
<Input
|
||||
label="Inhalt"
|
||||
value={strVal('content')}
|
||||
onChange={(e) => setConfig('content', e.target.value)}
|
||||
placeholder={t('stepConfigPanel.dateiinhalt')}
|
||||
placeholder="Dateiinhalt"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -323,13 +320,13 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
required
|
||||
value={strVal('query')}
|
||||
onChange={(e) => setConfig('query', e.target.value)}
|
||||
placeholder={t('stepConfigPanel.suchbegriff')}
|
||||
placeholder="Suchbegriff"
|
||||
/>
|
||||
<Input
|
||||
label="Entity-Typ (optional)"
|
||||
value={strVal('entity_type')}
|
||||
onChange={(e) => setConfig('entity_type', e.target.value || undefined)}
|
||||
placeholder={t('stepConfigPanel.contactcompanyfile')}
|
||||
placeholder="contact, company, file, ..."
|
||||
/>
|
||||
<Input
|
||||
label="Limit"
|
||||
@@ -350,7 +347,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
required
|
||||
value={strVal('agent_id')}
|
||||
onChange={(e) => setConfig('agent_id', e.target.value)}
|
||||
placeholder={t('stepConfigPanel.agentuuid')}
|
||||
placeholder="Agent-UUID"
|
||||
/>
|
||||
<JsonField
|
||||
label="Input (JSON)"
|
||||
@@ -364,7 +361,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
onChange={(e) => setConfig('wait_for_completion', e.target.checked)}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
{t('stepConfigPanel.aufabschlusswarten')}
|
||||
Auf Abschluss warten
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
@@ -383,7 +380,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
label="Entity-ID"
|
||||
value={strVal('entity_id')}
|
||||
onChange={(e) => setConfig('entity_id', e.target.value)}
|
||||
placeholder={t('stepConfigPanel.entityuuid')}
|
||||
placeholder="Entity-UUID"
|
||||
/>
|
||||
)}
|
||||
{(action.includes('create') || action.includes('update')) && (
|
||||
@@ -400,14 +397,14 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('stepConfigPanel.konfigurationjson')}
|
||||
Konfiguration (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={configText}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
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"
|
||||
placeholder={t('stepConfigPanel.keyvalue')}
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
{configError && (
|
||||
<p className="mt-1 text-sm text-danger-600" role="alert">
|
||||
@@ -427,7 +424,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
required
|
||||
value={step.name}
|
||||
onChange={(e) => onChange({ ...step, name: e.target.value })}
|
||||
placeholder={t('stepConfigPanel.zbgenehmigungeinholen')}
|
||||
placeholder="z.B. Genehmigung einholen"
|
||||
/>
|
||||
<Select
|
||||
label="Typ"
|
||||
@@ -448,7 +445,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
onChange={(e) => onChange({ ...step, description: e.target.value || null })}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder={t('stepConfigPanel.optionalebeschreibung')}
|
||||
placeholder="Optionale Beschreibung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -487,14 +484,14 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('stepConfigPanel.konfigurationjson')}
|
||||
Konfiguration (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={configText}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
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"
|
||||
placeholder={t('stepConfigPanel.keyvalue')}
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
{configError && (
|
||||
<p className="mt-1 text-sm text-danger-600" role="alert">
|
||||
@@ -514,7 +511,6 @@ interface JsonFieldProps {
|
||||
}
|
||||
|
||||
function JsonField({ label, value, onChange }: JsonFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const [text, setText] = useState(value);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
@@ -547,7 +543,7 @@ function JsonField({ label, value, onChange }: JsonFieldProps) {
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
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"
|
||||
placeholder={t('stepConfigPanel.keyvalue')}
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
{error && (
|
||||
<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'}
|
||||
>
|
||||
<Code2 className="h-3.5 w-3.5" /> {t('workflowEditor.jsonexpert')}
|
||||
<Code2 className="h-3.5 w-3.5" /> JSON Expert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -428,7 +428,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
placeholder={t('workflowEditor.zbdealgenehmigungsprozess')}
|
||||
placeholder="z.B. Deal-Genehmigungsprozess"
|
||||
/>
|
||||
<div>
|
||||
<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)}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder={t('workflowEditor.optionalebeschreibung')}
|
||||
placeholder="Optionale Beschreibung"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
@@ -472,12 +472,12 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
|
||||
type="button"
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
>
|
||||
{t('workflowEditor.schritthinzufuegen')}
|
||||
Schritt hinzufuegen
|
||||
</Button>
|
||||
</div>
|
||||
{form.steps.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 italic">
|
||||
{t('workflowEditor.keineschrittedefiniertklickeaufschritt')}
|
||||
Keine Schritte definiert. Klicke auf Schritt hinzufuegen.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
@@ -493,7 +493,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
|
||||
onClick={() => moveStep(i, 'up')}
|
||||
disabled={i === 0}
|
||||
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
|
||||
aria-label={t('workflowEditor.nachoben')}
|
||||
aria-label="Nach oben"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -502,7 +502,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
|
||||
onClick={() => moveStep(i, 'down')}
|
||||
disabled={i === form.steps.length - 1}
|
||||
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
|
||||
aria-label={t('workflowEditor.nachunten')}
|
||||
aria-label="Nach unten"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -510,7 +510,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
|
||||
type="button"
|
||||
onClick={() => removeStep(i)}
|
||||
className="text-danger-500 hover:text-danger-700 p-1"
|
||||
aria-label={t('workflowEditor.schrittentfernen')}
|
||||
aria-label="Schritt entfernen"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -528,14 +528,14 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('workflowEditor.workflowjson')}
|
||||
Workflow (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
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"
|
||||
placeholder={t('workflowEditor.namesteps')}
|
||||
placeholder='{"name": "...", "steps": [...]}'
|
||||
/>
|
||||
{jsonError && (
|
||||
<p className="mt-1 text-sm text-danger-600" role="alert">
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { AlertCircle, ChevronRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const statusFilterOptions = [
|
||||
{ value: '', label: 'Alle Status' },
|
||||
@@ -55,7 +54,6 @@ export interface WorkflowInstanceListProps {
|
||||
export function WorkflowInstanceList({
|
||||
onSelectInstance,
|
||||
}: WorkflowInstanceListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
@@ -105,12 +103,12 @@ export function WorkflowInstanceList({
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 text-danger-600">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>{t('workflowInstanceList.fehlerbeimladenderinstanzen')}</span>
|
||||
<span>Fehler beim Laden der Instanzen</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-sm text-primary-600 hover:underline"
|
||||
>
|
||||
{t('workflowInstanceList.erneutversuchen')}
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -119,7 +117,7 @@ export function WorkflowInstanceList({
|
||||
{/* Empty state */}
|
||||
{!isLoading && !isError && instances.length === 0 && (
|
||||
<EmptyState
|
||||
title={t('workflowInstanceList.keineworkflowinstanzen')}
|
||||
title="Keine Workflow-Instanzen"
|
||||
description="Es wurden keine Instanzen gefunden, die dem Filter entsprechen."
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user