fix: BUG-080/082 (20 unused frontend components deleted), BUG-083 (useTenant.ts deleted), BUG-011 (playwright baseURL), BUG-069 (unused python modules deleted), BUG-065 (already has eager loading), BUG-026 (already fixed 422), BUG-023 (no sync I/O found)
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-08-22 07:37:11 +02:00
parent 40cc99af5c
commit db4701bae7
30 changed files with 5 additions and 5076 deletions
-289
View File
@@ -1,289 +0,0 @@
import { asError } from '@/utils/errorTypes';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client';
interface Address {
id: string;
entity_type: string;
entity_id: string;
label: string;
address_type: string;
street: string | null;
street_number: string | null;
city: string | null;
zip: string | null;
state: string | null;
country: string | null;
is_default: boolean;
created_at: string | null;
updated_at: string | null;
}
interface AddressListProps {
entityType: 'company' | 'contact';
entityId: string;
}
const ADDRESS_TYPE_COLORS: Record<string, string> = {
billing: 'bg-blue-100 text-blue-800',
shipping: 'bg-purple-100 text-purple-800',
headquarters: 'bg-green-100 text-green-800',
branch: 'bg-yellow-100 text-yellow-800',
private: 'bg-pink-100 text-pink-800',
other: 'bg-gray-100 text-gray-800',
};
export function AddressList({ entityType, entityId }: AddressListProps) {
const { t } = useTranslation();
const [addresses, setAddresses] = useState<Address[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [editingAddress, setEditingAddress] = useState<Address | null>(null);
const fetchAddresses = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await apiGet<{ items: Address[]; total: number }>(
`/addresses?entity_type=${entityType}&entity_id=${entityId}`
);
setAddresses(data.items);
} catch (err: unknown) { const errObj = asError(err);
setError(errObj.message || t('common.error'));
} finally {
setLoading(false);
}
}, [entityType, entityId, t]);
React.useEffect(() => {
fetchAddresses();
}, [fetchAddresses]);
const handleDelete = async (id: string) => {
if (!window.confirm(t('address.confirmDelete'))) return;
try {
await apiDelete(`/addresses/${id}`);
await fetchAddresses();
} catch (err: unknown) { const errObj = asError(err);
setError(errObj.message || t('common.error'));
}
};
const handleSetDefault = async (id: string) => {
try {
await apiPatch(`/addresses/${id}`, { is_default: true });
await fetchAddresses();
} catch (err: unknown) { const errObj = asError(err);
setError(errObj.message || t('common.error'));
}
};
const handleSave = async (data: Partial<Address>) => {
try {
if (editingAddress) {
await apiPatch(`/addresses/${editingAddress.id}`, data);
} else {
await apiPost('/addresses', { ...data, entity_type: entityType, entity_id: entityId });
}
setShowForm(false);
setEditingAddress(null);
await fetchAddresses();
} catch (err: unknown) { const errObj = asError(err);
setError(errObj.message || t('common.error'));
}
};
if (loading) {
return <div className="text-secondary-500">{t('common.loading')}</div>;
}
return (
<div className="space-y-4" data-testid="address-list">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-secondary-900">{t('address.title')}</h3>
<button
onClick={() => { setEditingAddress(null); setShowForm(true); }}
className="px-3 py-1.5 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700"
data-testid="address-add-btn"
>
{t('address.addAddress')}
</button>
</div>
{error && (
<div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</div>
)}
{showForm && (
<AddressForm
address={editingAddress}
onSave={handleSave}
onCancel={() => { setShowForm(false); setEditingAddress(null); }}
/>
)}
{addresses.length === 0 && !showForm ? (
<p className="text-sm text-secondary-500">{t('address.noAddresses')}</p>
) : (
<div className="space-y-3">
{addresses.map((addr) => (
<div
key={addr.id}
className="p-4 border border-secondary-200 rounded-lg flex items-start justify-between"
data-testid={`address-item-${addr.id}`}
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium text-secondary-900">{addr.label}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${ADDRESS_TYPE_COLORS[addr.address_type] || ADDRESS_TYPE_COLORS.other}`}>
{t(`addressType.${addr.address_type}`)}
</span>
{addr.is_default && (
<span className="px-2 py-0.5 text-xs rounded-full bg-primary-100 text-primary-700">
{t('address.defaultAddress')}
</span>
)}
</div>
<div className="text-sm text-secondary-600">
{addr.street && <span>{addr.street}</span>}
{addr.street_number && <span> {addr.street_number}</span>}
{(addr.street || addr.street_number) && <br />}
{addr.zip && <span>{addr.zip} </span>}
{addr.city && <span>{addr.city}</span>}
{(addr.zip || addr.city) && <br />}
{addr.state && <span>{addr.state}, </span>}
{addr.country && <span>{addr.country}</span>}
</div>
</div>
<div className="flex items-center gap-2">
{!addr.is_default && (
<button
onClick={() => handleSetDefault(addr.id)}
className="text-xs text-primary-600 hover:underline"
>
{t('address.setDefault')}
</button>
)}
<button
onClick={() => { setEditingAddress(addr); setShowForm(true); }}
className="text-xs text-secondary-600 hover:underline"
>
{t('common.edit')}
</button>
<button
onClick={() => handleDelete(addr.id)}
className="text-xs text-red-600 hover:underline"
>
{t('common.delete')}
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
function AddressForm({
address,
onSave,
onCancel,
}: {
address: Address | null;
onSave: (data: Partial<Address>) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [label, setLabel] = useState(address?.label || '');
const [addressType, setAddressType] = useState(address?.address_type || 'headquarters');
const [street, setStreet] = useState(address?.street || '');
const [streetNumber, setStreetNumber] = useState(address?.street_number || '');
const [city, setCity] = useState(address?.city || '');
const [zip, setZip] = useState(address?.zip || '');
const [state, setState] = useState(address?.state || '');
const [country, setCountry] = useState(address?.country || '');
const [isDefault, setIsDefault] = useState(address?.is_default || false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({ label, address_type: addressType, street, street_number: streetNumber, city, zip, state, country, is_default: isDefault });
};
const addressTypes = ['billing', 'shipping', 'headquarters', 'branch', 'private', 'other'];
return (
<form onSubmit={handleSubmit} className="p-4 border border-secondary-200 rounded-lg space-y-3" data-testid="address-form">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.label')}</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.type')}</label>
<select
value={addressType}
onChange={(e) => setAddressType(e.target.value)}
className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm"
>
{addressTypes.map((type) => (
<option key={type} value={type}>{t(`addressType.${type}`)}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="block text-sm font-medium text-secondary-700">{t('address.street')}</label>
<input type="text" value={street} onChange={(e) => setStreet(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.streetNumber')}</label>
<input type="text" value={streetNumber} onChange={(e) => setStreetNumber(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.zip')}</label>
<input type="text" value={zip} onChange={(e) => setZip(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.city')}</label>
<input type="text" value={city} onChange={(e) => setCity(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.state')}</label>
<input type="text" value={state} onChange={(e) => setState(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.country')}</label>
<input type="text" value={country} onChange={(e) => setCountry(e.target.value)} maxLength={2} placeholder="DE" className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm font-medium text-secondary-700">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} className="rounded" />
{t('address.defaultAddress')}
</label>
</div>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={onCancel} className="px-3 py-1.5 text-sm font-medium text-secondary-700 bg-secondary-100 rounded-lg hover:bg-secondary-200">
{t('common.cancel')}
</button>
<button type="submit" className="px-3 py-1.5 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700">
{t('common.save')}
</button>
</div>
</form>
);
}
@@ -1,89 +0,0 @@
/**
* PWA Install Prompt — shows install button when PWA is installable (Task 5.24).
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Download, X } from 'lucide-react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
const DISMISS_KEY = 'leocrm_pwa_install_dismissed';
export function PWAInstallPrompt() {
const { t } = useTranslation();
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const dismissed = localStorage.getItem(DISMISS_KEY);
if (dismissed) return;
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setVisible(true);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const handleInstall = useCallback(async () => {
if (!deferredPrompt) return;
await deferredPrompt.prompt();
const choice = await deferredPrompt.userChoice;
if (choice.outcome === 'accepted') {
setVisible(false);
}
setDeferredPrompt(null);
}, [deferredPrompt]);
const handleDismiss = useCallback(() => {
localStorage.setItem(DISMISS_KEY, '1');
setVisible(false);
}, []);
if (!visible || !deferredPrompt) return null;
return (
<div
className="fixed bottom-4 right-4 z-50 bg-white rounded-lg shadow-lg border border-secondary-200 p-4 max-w-sm"
data-testid="pwa-install-prompt"
>
<div className="flex items-start gap-3">
<Download className="w-5 h-5 text-primary-600 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-secondary-900">{t('pwa.installTitle')}</p>
<p className="text-sm text-secondary-600 mt-1">{t('pwa.installDescription')}</p>
<div className="flex gap-2 mt-3">
<button
className="px-3 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700"
onClick={handleInstall}
data-testid="pwa-install-btn"
>
{t('pwa.install')}
</button>
<button
className="px-3 py-1.5 text-secondary-600 text-sm hover:bg-secondary-100 rounded-md"
onClick={handleDismiss}
data-testid="pwa-dismiss-btn"
>
{t('pwa.dismiss')}
</button>
</div>
</div>
<button
className="text-secondary-400 hover:text-secondary-600"
onClick={handleDismiss}
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}
-118
View File
@@ -1,118 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Undo2, X } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { useUndoLastAction } from '@/api/entityHistory';
interface UndoToastProps {
message: string;
onUndo: () => void;
onDismiss: () => void;
isUndoing: boolean;
}
/**
* Undo toast shown after delete actions.
* Auto-dismisses after 5 seconds and is manually dismissable.
*/
export function UndoToast({ message, onUndo, onDismiss, isUndoing }: UndoToastProps) {
const { t } = useTranslation();
return (
<div
className="fixed bottom-4 right-4 z-[100] flex items-center gap-3 p-4 rounded-lg shadow-lg border border-secondary-200 bg-white max-w-sm"
role="alert"
aria-live="polite"
data-testid="undo-toast"
>
<p className="flex-1 text-sm font-medium text-secondary-900">{message}</p>
<Button
variant="primary"
size="sm"
onClick={onUndo}
isLoading={isUndoing}
icon={<Undo2 className="w-4 h-4" />}
>
{t('undoToast.undo', 'Undo')}
</Button>
<button
onClick={onDismiss}
className="flex-shrink-0 text-secondary-400 hover:text-secondary-700 min-h-touch min-w-touch flex items-center justify-center rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('common.dismiss', 'Schließen')}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
);
}
interface UndoToastState {
message: string;
entityType: string;
entityId: string;
}
/**
* Hook that shows an undo toast after a delete action and provides the undo function.
*
* Returns:
* - `showUndoToast(message, entityType, entityId)`: trigger the toast
* - `undoToast`: the JSX element to render (render it once in the page)
*/
export function useUndoToast() {
const { t } = useTranslation();
const [state, setState] = useState<UndoToastState | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const undoMutation = useUndoLastAction();
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const dismiss = useCallback(() => {
clearTimer();
setState(null);
}, [clearTimer]);
const showUndoToast = useCallback(
(message: string, entityType: string, entityId: string) => {
clearTimer();
setState({ message, entityType, entityId });
timerRef.current = setTimeout(() => {
setState(null);
timerRef.current = null;
}, 5000);
},
[clearTimer]
);
const handleUndo = useCallback(async () => {
if (!state) return;
try {
await undoMutation.mutateAsync({
entityType: state.entityType,
entityId: state.entityId,
});
dismiss();
} catch {
// Keep the toast visible so the user can retry; the mutation error is surfaced elsewhere.
}
}, [state, undoMutation, dismiss]);
// Cleanup timer on unmount
useEffect(() => clearTimer, [clearTimer]);
const undoToast = state ? (
<UndoToast
message={state.message}
onUndo={handleUndo}
onDismiss={dismiss}
isUndoing={undoMutation.isPending}
/>
) : null;
return { showUndoToast, undoToast };
}
@@ -1,396 +0,0 @@
import React, { useEffect, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { useToast } from '@/components/ui/Toast';
import {
useCreateAgent,
useUpdateAgent,
useTestRunAgent,
useAgentToolsFull,
useAgentSkills,
} from '@/api/automation';
import type { AgentDefinitionFull } from '@/types/automation';
import { Play, Save, X, Loader2 } from 'lucide-react';
const agentEditorSchema = z.object({
name: z.string().min(1, 'required').max(120),
description: z.string().max(500).default(''),
system_prompt: z.string().default(''),
llm_model: z.string().min(1, 'required').max(100),
tool_ids: z.array(z.string()).default([]),
skill_ids: z.array(z.string()).default([]),
max_steps: z.coerce.number().int().min(1).max(100).default(20),
max_duration_seconds: z.coerce.number().int().min(1).max(86400).default(300),
budget_limit_usd: z.coerce.number().min(0).max(10000).default(1.0),
temperature: z.coerce.number().min(0).max(2).default(0.3),
max_tokens: z.coerce.number().int().min(1).max(100000).default(1000),
trace_mode: z.enum(['standard', 'extended']).default('standard'),
mode: z.enum(['proactive', 'reactive']).default('reactive'),
is_active: z.boolean().default(true),
trigger_config: z.record(z.string(), z.unknown()).default({}),
ai_use_case_metadata: z.record(z.string(), z.unknown()).default({}),
});
type AgentEditorFormData = z.infer<typeof agentEditorSchema>;
export interface AgentEditorProps {
agent?: AgentDefinitionFull | null;
onSaved?: (agent: AgentDefinitionFull) => void;
onCancel?: () => void;
}
const modeOptions = [
{ value: 'reactive', label: 'Reactive' },
{ value: 'proactive', label: 'Proactive' },
];
const traceModeOptions = [
{ value: 'standard', label: 'Standard' },
{ value: 'extended', label: 'Extended' },
];
const commonModels = [
'ollama/deepseek-v4-flash',
'gpt-4',
'gpt-4-turbo',
'gpt-3.5-turbo',
'claude-3-opus',
'claude-3-sonnet',
'claude-3-haiku',
'llama-3-70b',
'llama-3-8b',
'mistral-large',
'mixtral-8x7b',
];
export function AgentEditor({ agent, onSaved, onCancel }: AgentEditorProps) {
const { t } = useTranslation();
const toast = useToast();
const { data: tools = [] } = useAgentToolsFull();
const { data: skills = [] } = useAgentSkills();
const createAgent = useCreateAgent();
const updateAgent = useUpdateAgent();
const testRunAgent = useTestRunAgent();
const defaultValues = useMemo<AgentEditorFormData>(() => {
if (agent) {
return {
name: agent.name,
description: agent.description || '',
system_prompt: agent.system_prompt || '',
llm_model: agent.llm_model || agent.model || '',
tool_ids: agent.tool_ids || [],
skill_ids: agent.skill_ids || [],
max_steps: agent.max_steps ?? 20,
max_duration_seconds: agent.max_duration_seconds ?? 300,
budget_limit_usd: agent.budget_limit_usd ?? agent.budget_limit ?? 1.0,
temperature: agent.temperature ?? 0.3,
max_tokens: agent.max_tokens ?? 1000,
trace_mode: (agent.trace_mode === 'extended' ? 'extended' : 'standard') as 'standard' | 'extended',
mode: agent.mode,
is_active: agent.is_active ?? agent.active ?? true,
trigger_config: agent.trigger_config || {},
ai_use_case_metadata: agent.ai_use_case_metadata || {},
};
}
return {
name: '',
description: '',
system_prompt: '',
llm_model: 'ollama/deepseek-v4-flash',
tool_ids: [],
skill_ids: [],
max_steps: 20,
max_duration_seconds: 300,
budget_limit_usd: 1.0,
temperature: 0.3,
max_tokens: 1000,
trace_mode: 'standard',
mode: 'reactive',
is_active: true,
trigger_config: {},
ai_use_case_metadata: {},
};
}, [agent]);
const {
register,
handleSubmit,
control,
reset,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<AgentEditorFormData>({
resolver: zodResolver(agentEditorSchema),
defaultValues,
});
const selectedToolIds = watch('tool_ids');
const selectedSkillIds = watch('skill_ids');
useEffect(() => {
reset(defaultValues);
}, [reset, defaultValues]);
const onSubmit = async (data: AgentEditorFormData) => {
try {
if (agent) {
const updated = await updateAgent.mutateAsync({ id: agent.id, data });
toast.success(t('agent.saved'));
onSaved?.(updated);
} else {
const created = await createAgent.mutateAsync(data);
toast.success(t('agent.created'));
onSaved?.(created);
}
} catch {
toast.error(t('agent.saveFailed'));
}
};
const handleTestRun = async () => {
if (!agent) {
toast.warning(t('agent.saveBeforeTest'));
return;
}
try {
await testRunAgent.mutateAsync(agent.id);
toast.success(t('agent.testRunOk'));
} catch {
toast.error(t('agent.testRunFailed'));
}
};
const toggleArrayValue = (field: 'tool_ids' | 'skill_ids', value: string) => {
const current = field === 'tool_ids' ? selectedToolIds : selectedSkillIds;
const next = (current || []).includes(value)
? (current || []).filter((v) => v !== value)
: [...(current || []), value];
setValue(field, next, { shouldDirty: true, shouldValidate: true });
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6" data-testid="agent-editor">
{/* Basic info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t('agent.name')}
required
error={errors.name?.message}
placeholder="My Agent"
{...register('name')}
/>
<Input
label={t('agent.description')}
error={errors.description?.message}
placeholder={t('agent.descriptionPlaceholder')}
{...register('description')}
/>
</div>
{/* System prompt */}
<div>
<label htmlFor="agent-system-prompt" className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.systemPrompt')}
</label>
<textarea
id="agent-system-prompt"
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 min-h-touch"
placeholder="You are a helpful assistant..."
aria-invalid={!!errors.system_prompt}
{...register('system_prompt')}
/>
{errors.system_prompt && (
<p className="mt-1 text-sm text-danger-600" role="alert">{errors.system_prompt.message}</p>
)}
</div>
{/* Model + mode + trace */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label htmlFor="agent-llm-model" className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.model')}
</label>
<input
id="agent-llm-model"
list="agent-model-suggestions"
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 min-h-touch"
aria-invalid={!!errors.llm_model}
{...register('llm_model')}
/>
<datalist id="agent-model-suggestions">
{commonModels.map((m) => (
<option key={m} value={m} />
))}
</datalist>
{errors.llm_model && (
<p className="mt-1 text-sm text-danger-600" role="alert">{errors.llm_model.message}</p>
)}
</div>
<Controller
control={control}
name="mode"
render={({ field }) => (
<Select
label={t('agent.mode')}
options={modeOptions}
value={field.value}
onChange={field.onChange}
/>
)}
/>
<Controller
control={control}
name="trace_mode"
render={({ field }) => (
<Select
label={t('agent.traceMode')}
options={traceModeOptions}
value={field.value}
onChange={field.onChange}
/>
)}
/>
</div>
{/* Tools multi-select */}
<div>
<span className="block text-sm font-medium text-secondary-700 mb-2">{t('agent.tools')}</span>
{tools.length === 0 ? (
<p className="text-sm text-secondary-400 italic">{t('agent.noToolsAvailable')}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-40 overflow-y-auto" role="group" aria-label={t('agent.tools')}>
{tools.map((tool) => (
<label key={tool.id || tool.name} className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer text-sm min-h-touch">
<input
type="checkbox"
checked={selectedToolIds.includes(tool.id || tool.name)}
onChange={() => toggleArrayValue('tool_ids', tool.id || tool.name)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="font-medium text-secondary-700">{tool.name}</span>
{tool.description && (
<p className="text-xs text-secondary-400">{tool.description}</p>
)}
</div>
</label>
))}
</div>
)}
</div>
{/* Skills multi-select */}
<div>
<span className="block text-sm font-medium text-secondary-700 mb-2">{t('agent.skills')}</span>
{skills.length === 0 ? (
<p className="text-sm text-secondary-400 italic">{t('agent.noSkillsAvailable')}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-40 overflow-y-auto" role="group" aria-label={t('agent.skills')}>
{skills.map((skill) => (
<label key={skill.name} className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer text-sm min-h-touch">
<input
type="checkbox"
checked={selectedSkillIds.includes(skill.name)}
onChange={() => toggleArrayValue('skill_ids', skill.name)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="font-medium text-secondary-700">{skill.name}</span>
{skill.description && (
<p className="text-xs text-secondary-400">{skill.description}</p>
)}
</div>
</label>
))}
</div>
)}
</div>
{/* Limits */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Input
type="number"
label={t('agent.maxSteps')}
error={errors.max_steps?.message}
{...register('max_steps')}
/>
<Input
type="number"
label={`${t('agent.maxDuration')} (s)`}
error={errors.max_duration_seconds?.message}
{...register('max_duration_seconds')}
/>
<Input
type="number"
step="0.01"
label={`${t('agent.budgetLimit')} ($)`}
error={errors.budget_limit_usd?.message}
{...register('budget_limit_usd')}
/>
<Input
type="number"
step="0.1"
label={t('agent.temperature')}
error={errors.temperature?.message}
{...register('temperature')}
/>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Input
type="number"
label={t('agent.maxTokens')}
error={errors.max_tokens?.message}
{...register('max_tokens')}
/>
<Input
type="number"
label={t('agent.maxExecutions')}
defaultValue={10}
disabled
/>
</div>
{/* Active toggle */}
<label className="flex items-center gap-2 text-sm min-h-touch">
<input
type="checkbox"
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
{...register('is_active')}
/>
{t('agent.active')}
</label>
{/* Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
{agent && (
<Button
type="button"
variant="secondary"
onClick={handleTestRun}
isLoading={testRunAgent.isPending}
icon={<Play className="w-4 h-4" />}
>
{t('agent.testRun')}
</Button>
)}
{onCancel && (
<Button type="button" variant="ghost" onClick={onCancel} icon={<X className="w-4 h-4" />}>
{t('common.cancel')}
</Button>
)}
<Button type="submit" isLoading={isSubmitting} icon={<Save className="w-4 h-4" />}>
{agent ? t('common.save') : t('common.create')}
</Button>
</div>
</form>
);
}
@@ -1,86 +0,0 @@
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { BarChart3, AlertTriangle, DollarSign, Clock } from 'lucide-react';
export function AgentMonitor() {
const { t } = useTranslation();
const { data: stats, isLoading } = useQuery({
queryKey: ['agent-monitor-stats'],
queryFn: async () => {
const res = await fetch('/api/v1/agents/monitor/stats');
if (!res.ok) throw new Error('Failed to fetch stats');
return res.json() as Promise<{ active_runs: number; total_budget_usd: number; runs_per_hour: number; error_rate: number }>;
},
refetchInterval: 5000,
});
const { data: activeRuns } = useQuery({
queryKey: ['agent-active-runs'],
queryFn: async () => {
const res = await fetch('/api/v1/agents/runs/recent?status=running&limit=20');
if (!res.ok) throw new Error('Failed to fetch runs');
return res.json() as Promise<Array<{ id: string; agent_name: string; status: string; started_at: string; cost_usd: number }>>;
},
refetchInterval: 5000,
});
return (
<div className="flex flex-col h-full bg-white dark:bg-gray-900 p-6 space-y-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('agents.monitoring')}</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-5 h-5 text-primary-600" />
<span className="text-sm text-gray-600 dark:text-gray-400">{t('agents.activeRuns')}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats?.active_runs ?? 0}</p>
</div>
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20">
<div className="flex items-center gap-2 mb-2">
<DollarSign className="w-5 h-5 text-green-600" />
<span className="text-sm text-gray-600 dark:text-gray-400">{t('agents.totalBudget')}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">${(stats?.total_budget_usd ?? 0).toFixed(4)}</p>
</div>
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20">
<div className="flex items-center gap-2 mb-2">
<BarChart3 className="w-5 h-5 text-blue-600" />
<span className="text-sm text-gray-600 dark:text-gray-400">{t('agents.runsPerHour')}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats?.runs_per_hour ?? 0}</p>
</div>
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
<span className="text-sm text-gray-600 dark:text-gray-400">{t('agents.errorRate')}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{(stats?.error_rate ?? 0).toFixed(1)}%</p>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">{t('agents.activeRunsList')}</h3>
{isLoading && <p className="text-gray-500">{t('common.loading')}</p>}
<div className="space-y-2">
{activeRuns?.map((run) => (
<div key={run.id} className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{run.agent_name}</p>
<p className="text-xs text-gray-400">{new Date(run.started_at).toLocaleString()}</p>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-400">${run.cost_usd.toFixed(6)}</span>
<span className="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800">{run.status}</span>
</div>
</div>
))}
{(!activeRuns || activeRuns.length === 0) && !isLoading && (
<p className="text-sm text-gray-400">{t('agents.noActiveRuns')}</p>
)}
</div>
</div>
</div>
);
}
@@ -1,82 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
interface RunStep {
id: string;
step_number: number;
thought: string | null;
action: string | null;
action_input: Record<string, unknown> | null;
observation: string | null;
cost_usd: number;
created_at: string;
}
interface AgentRunLogProps {
agentId: string;
runId: string;
}
export function AgentRunLog({ agentId, runId }: AgentRunLogProps) {
const { t } = useTranslation();
const [filterStatus, setFilterStatus] = useState<string>('all');
const { data: steps, isLoading } = useQuery({
queryKey: ['agent-run-steps', agentId, runId],
queryFn: async () => {
const res = await fetch(`/api/v1/agents/${agentId}/runs/${runId}/steps`);
if (!res.ok) throw new Error('Failed to fetch steps');
return res.json() as Promise<RunStep[]>;
},
});
const handleExport = (format: 'json' | 'csv') => {
if (!steps) return;
const data = format === 'json' ? JSON.stringify(steps, null, 2) :
'step,thought,action,observation,cost,timestamp\n' +
steps.map((s) => `${s.step_number},${s.thought || ''},${s.action || ''},${s.observation || ''},${s.cost_usd},${s.created_at}`).join('\n');
const blob = new Blob([data], { type: format === 'json' ? 'application/json' : 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `agent-run-${runId}.${format}`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="flex flex-col h-full bg-white dark:bg-gray-900">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('agents.runLog')}</h2>
<div className="flex items-center gap-2">
<button onClick={() => handleExport('json')} className="p-2 rounded-lg text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 min-h-[44px] min-w-[44px]" aria-label={t('agents.exportJson')}>
<Download className="w-5 h-5" />
</button>
<button onClick={() => handleExport('csv')} className="p-2 rounded-lg text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 min-h-[44px] min-w-[44px]" aria-label={t('agents.exportCsv')}>
<Download className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{isLoading && <p className="text-gray-500">{t('common.loading')}</p>}
{steps?.map((step) => (
<div key={step.id} className="p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">Step {step.step_number}</span>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-400">${step.cost_usd.toFixed(6)}</span>
<span className="text-xs text-gray-400">{new Date(step.created_at).toLocaleString()}</span>
</div>
</div>
{step.thought && <p className="text-sm text-gray-700 dark:text-gray-300 mb-2"><strong>Thought:</strong> {step.thought}</p>}
{step.action && <p className="text-sm text-primary-600 dark:text-primary-400 mb-2"><strong>Action:</strong> {step.action}</p>}
{step.action_input && <pre className="text-xs text-gray-500 bg-gray-50 dark:bg-gray-800 p-2 rounded mb-2 overflow-x-auto">{JSON.stringify(step.action_input, null, 2)}</pre>}
{step.observation && <p className="text-sm text-gray-600 dark:text-gray-400"><strong>Observation:</strong> {step.observation}</p>}
</div>
))}
</div>
</div>
);
}
@@ -1,883 +0,0 @@
/**
* ABACRuleEditor — UI zum Erstellen und Bearbeiten von ABAC Policies.
*
* Features:
* - Liste aller Policies für einen Entity-Type
* - Neue Policy erstellen / bestehende bearbeiten
* - Conditions Builder mit AND/OR Gruppen
* - Policy löschen mit ConfirmDialog
* - Text-basierte Vorschau der Policy
*/
import { asError } from '@/utils/errorTypes';
import React, { useState, useCallback, useMemo } from 'react';
import clsx from 'clsx';
import {
Plus,
Pencil,
Trash2,
X,
Shield,
ShieldCheck,
ShieldX,
GripVertical,
ChevronDown,
ChevronRight,
Eye,
EyeOff,
ArrowUpDown,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
usePolicies,
useCreatePolicy,
useUpdatePolicy,
useDeletePolicy,
} from '../../api/policyHooks';
import { useUsers } from '../../api/users';
import { useGroups } from '../../api/groups';
import { useRoles } from '../../api/roles';
import {
type ABACPolicy,
type PrincipalType,
type ConditionOperator,
type ConditionGroupLogic,
type Condition,
type ConditionGroup,
type CreatePolicyPayload,
type UpdatePolicyPayload,
} from '../../api/policies';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { Badge } from '../ui/Badge';
import { Select, type SelectOption } from '../ui/Select';
import { Input } from '../ui/Input';
import { Modal } from '../ui/Modal';
import { ConfirmDialog } from '../ui/ConfirmDialog';
// ─── Constants ─────────────────────────────────────────────────────────────
const OPERATOR_OPTIONS: SelectOption[] = [
{ value: 'eq', label: '=' },
{ value: 'neq', label: '≠' },
{ value: 'in', label: 'in' },
{ value: 'gt', label: '>' },
{ value: 'gte', label: '≥' },
{ value: 'lt', label: '<' },
{ value: 'lte', label: '≤' },
{ value: 'contains', label: 'contains' },
{ value: 'starts_with', label: 'starts with' },
{ value: 'is_null', label: 'is null' },
];
const PRINCIPAL_TYPE_OPTIONS: SelectOption[] = [
{ value: 'user', label: 'User' },
{ value: 'group', label: 'Group' },
{ value: 'role', label: 'Role' },
];
const EFFECT_OPTIONS: SelectOption[] = [
{ value: 'allow', label: 'Allow' },
{ value: 'deny', label: 'Deny' },
];
const LOGIC_OPTIONS: SelectOption[] = [
{ value: 'AND', label: 'AND' },
{ value: 'OR', label: 'OR' },
];
// ─── Helpers ───────────────────────────────────────────────────────────────
function generateConditionId(): string {
return `cond_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function generateGroupId(): string {
return `grp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function createEmptyCondition(): Condition {
return { id: generateConditionId(), field: '', operator: 'eq', value: '' };
}
function createEmptyGroup(logic: ConditionGroupLogic = 'AND'): ConditionGroup {
return {
id: generateGroupId(),
logic,
conditions: [createEmptyCondition()],
groups: [],
};
}
/**
* Generate a human-readable description of a condition group.
*/
function describeConditionGroup(group: ConditionGroup | null): string {
if (!group) return '—';
const parts: string[] = [];
for (const cond of group.conditions) {
if (!cond.field) continue;
const opLabel = OPERATOR_OPTIONS.find((o) => o.value === cond.operator)?.label || cond.operator;
if (cond.operator === 'is_null') {
parts.push(`${cond.field} is null`);
} else if (cond.operator === 'in') {
parts.push(`${cond.field} ${opLabel} (${cond.value})`);
} else {
parts.push(`${cond.field} ${opLabel} ${cond.value}`);
}
}
for (const sub of group.groups || []) {
const subDesc = describeConditionGroup(sub);
if (subDesc !== '—') {
parts.push(`(${subDesc})`);
}
}
if (parts.length === 0) return '—';
return parts.join(` ${group.logic} `);
}
/**
* Generate a full human-readable policy description.
*/
function describePolicy(policy: ABACPolicy): string {
const principalLabel = policy.principal_name || policy.principal_id;
const effectLabel = policy.effect === 'allow' ? 'darf' : 'darf nicht';
const condDesc = describeConditionGroup(policy.conditions);
if (condDesc === '—') {
return `${policy.principal_type}${principalLabel}${effectLabel} auf ${policy.entity_type} zugreifen`;
}
return `${policy.principal_type}${principalLabel}${effectLabel} auf ${policy.entity_type} zugreifen, wenn ${condDesc}`;
}
// ─── Sub-Components ────────────────────────────────────────────────────────
interface ConditionRowProps {
condition: Condition;
onChange: (condition: Condition) => void;
onRemove: () => void;
canRemove: boolean;
}
function ConditionRow({ condition, onChange, onRemove, canRemove }: ConditionRowProps) {
const { t } = useTranslation();
return (
<div className="flex items-start gap-2 py-1">
<div className="flex-1">
<Input
placeholder={t('abac.fieldPlaceholder', 'Field')}
value={condition.field}
onChange={(e) => onChange({ ...condition, field: e.target.value })}
/>
</div>
<div className="w-32">
<Select
options={OPERATOR_OPTIONS}
value={condition.operator}
onChange={(e) => onChange({ ...condition, operator: e.target.value as ConditionOperator })}
className="text-sm"
/>
</div>
<div className="flex-1">
<Input
placeholder={t('abac.valuePlaceholder', 'Value')}
value={condition.value}
onChange={(e) => onChange({ ...condition, value: e.target.value })}
/>
</div>
{canRemove && (
<button
type="button"
onClick={onRemove}
className="mt-1 text-secondary-400 hover:text-danger-500 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.removeCondition', 'Remove condition')}
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
}
interface ConditionGroupEditorProps {
group: ConditionGroup;
onChange: (group: ConditionGroup) => void;
onRemove?: () => void;
depth: number;
canRemove: boolean;
}
function ConditionGroupEditor({
group,
onChange,
onRemove,
depth,
canRemove,
}: ConditionGroupEditorProps) {
const { t } = useTranslation();
const addCondition = useCallback(() => {
onChange({
...group,
conditions: [...group.conditions, createEmptyCondition()],
});
}, [group, onChange]);
const updateCondition = useCallback(
(index: number, condition: Condition) => {
const updated = [...group.conditions];
updated[index] = condition;
onChange({ ...group, conditions: updated });
},
[group, onChange]
);
const removeCondition = useCallback(
(index: number) => {
if (group.conditions.length <= 1) return;
const updated = group.conditions.filter((_, i) => i !== index);
onChange({ ...group, conditions: updated });
},
[group, onChange]
);
const toggleLogic = useCallback(() => {
onChange({
...group,
logic: group.logic === 'AND' ? 'OR' : 'AND',
});
}, [group, onChange]);
return (
<div className={clsx(
'border rounded-md p-3',
depth > 0 && 'ml-4 bg-secondary-50/50'
)}>
{/* Header: Logic toggle + actions */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleLogic}
className={clsx(
'px-2 py-0.5 text-xs font-medium rounded border transition-colors',
group.logic === 'AND'
? 'bg-primary-100 text-primary-700 border-primary-300'
: 'bg-accent-100 text-accent-700 border-accent-300'
)}
>
{group.logic}
</button>
<span className="text-xs text-secondary-500">
{t('abac.groupConditions', 'Conditions')}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
icon={<Plus className="h-3.5 w-3.5" />}
onClick={addCondition}
>
{t('abac.addCondition', 'Add')}
</Button>
{canRemove && onRemove && (
<button
type="button"
onClick={onRemove}
className="text-secondary-400 hover:text-danger-500 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.removeGroup', 'Remove group')}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Conditions */}
{group.conditions.map((cond, idx) => (
<ConditionRow
key={cond.id}
condition={cond}
onChange={(c) => updateCondition(idx, c)}
onRemove={() => removeCondition(idx)}
canRemove={group.conditions.length > 1}
/>
))}
{/* Nested groups */}
{group.groups?.map((sub, idx) => (
<ConditionGroupEditor
key={sub.id}
group={sub}
onChange={(g) => {
const updated = [...(group.groups || [])];
updated[idx] = g;
onChange({ ...group, groups: updated });
}}
onRemove={() => {
const updated = (group.groups || []).filter((_, i) => i !== idx);
onChange({ ...group, groups: updated });
}}
depth={depth + 1}
canRemove={true}
/>
))}
{/* Add nested group */}
<button
type="button"
onClick={() => {
onChange({
...group,
groups: [...(group.groups || []), createEmptyGroup('AND')],
});
}}
className="mt-2 text-xs text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<Plus className="h-3 w-3" />
{t('abac.addNestedGroup', 'Add nested group')}
</button>
</div>
);
}
// ─── Policy Form ───────────────────────────────────────────────────────────
interface PolicyFormProps {
initial?: ABACPolicy | null;
entityType: string;
onSave: () => void;
onCancel: () => void;
}
function PolicyForm({ initial, entityType, onSave, onCancel }: PolicyFormProps) {
const { t } = useTranslation();
const createPolicy = useCreatePolicy(entityType);
const updatePolicy = useUpdatePolicy(entityType);
// Fetch principals for selectors
const { data: usersData } = useUsers();
const { data: groupsData } = useGroups();
const { data: rolesData } = useRoles();
const [name, setName] = useState(initial?.name || '');
const [principalType, setPrincipalType] = useState<PrincipalType>(
initial?.principal_type || 'user'
);
const [principalId, setPrincipalId] = useState(initial?.principal_id || '');
const [effect, setEffect] = useState<'allow' | 'deny'>(initial?.effect || 'allow');
const [conditions, setConditions] = useState<ConditionGroup | null>(
initial?.conditions || null
);
const [priority, setPriority] = useState(initial?.priority ?? 0);
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
const [error, setError] = useState<string | null>(null);
// Build principal options based on selected type
const principalOptions: SelectOption[] = useMemo(() => {
if (principalType === 'user') {
return (usersData?.items || []).map((u) => ({
value: u.id,
label: u.name || u.email,
}));
}
if (principalType === 'group') {
return (groupsData?.items || []).map((g) => ({
value: g.id,
label: g.name,
}));
}
if (principalType === 'role') {
return (rolesData?.items || []).map((r) => ({
value: r.id,
label: r.name,
}));
}
return [];
}, [principalType, usersData, groupsData, rolesData]);
const handleSave = useCallback(async () => {
setError(null);
if (!name.trim()) {
setError(t('abac.nameRequired', 'Name is required'));
return;
}
if (!principalId) {
setError(t('abac.principalRequired', 'Principal is required'));
return;
}
try {
if (initial) {
const payload: UpdatePolicyPayload = {
name: name.trim(),
principal_type: principalType,
principal_id: principalId,
effect,
conditions,
priority,
enabled,
};
await updatePolicy.mutateAsync({ policyId: initial.id, data: payload });
} else {
const payload: CreatePolicyPayload = {
name: name.trim(),
principal_type: principalType,
principal_id: principalId,
effect,
conditions,
priority,
enabled,
};
await createPolicy.mutateAsync(payload);
}
onSave();
} catch (err: unknown) { const errObj = asError(err);
setError(errObj?.message || t('abac.saveError', 'Failed to save policy'));
}
}, [
initial,
name,
principalType,
principalId,
effect,
conditions,
priority,
enabled,
createPolicy,
updatePolicy,
onSave,
t,
]);
const isSaving = createPolicy.isPending || updatePolicy.isPending;
// Generate preview text
const previewText = useMemo(() => {
if (!name.trim() && !principalId) return '';
const mockPolicy: ABACPolicy = {
id: initial?.id || 'new',
name: name.trim() || '(unnamed)',
entity_type: entityType,
principal_type: principalType,
principal_id: principalId,
principal_name:
principalOptions.find((o) => o.value === principalId)?.label || null,
effect,
conditions,
priority,
enabled,
};
return describePolicy(mockPolicy);
}, [name, principalType, principalId, effect, conditions, priority, enabled, entityType, initial, principalOptions]);
return (
<div className="space-y-4">
{/* Name */}
<Input
label={t('abac.policyName', 'Policy Name')}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('abac.policyNamePlaceholder', 'e.g. Vertrieb kann Kontakte sehen')}
required
/>
{/* Principal Type + ID */}
<div className="grid grid-cols-2 gap-4">
<Select
label={t('abac.principalType', 'Principal Type')}
options={PRINCIPAL_TYPE_OPTIONS}
value={principalType}
onChange={(e) => {
setPrincipalType(e.target.value as PrincipalType);
setPrincipalId('');
}}
/>
<Select
label={t('abac.principal', 'Principal')}
options={principalOptions}
value={principalId}
onChange={(e) => setPrincipalId(e.target.value)}
placeholder={t('abac.selectPrincipal', 'Select...')}
/>
</div>
{/* Effect + Priority */}
<div className="grid grid-cols-2 gap-4">
<Select
label={t('abac.effect', 'Effect')}
options={EFFECT_OPTIONS}
value={effect}
onChange={(e) => setEffect(e.target.value as 'allow' | 'deny')}
/>
<Input
label={t('abac.priority', 'Priority')}
type="number"
value={String(priority)}
onChange={(e) => setPriority(parseInt(e.target.value) || 0)}
/>
</div>
{/* Enabled */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-secondary-700">
{t('abac.enabled', 'Enabled')}
</span>
</label>
{/* Conditions Builder */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-secondary-700">
{t('abac.conditions', 'Conditions')}
</label>
{!conditions && (
<Button
variant="ghost"
size="sm"
icon={<Plus className="h-3.5 w-3.5" />}
onClick={() => setConditions(createEmptyGroup('AND'))}
>
{t('abac.addConditions', 'Add conditions')}
</Button>
)}
</div>
{conditions && (
<div className="space-y-2">
<ConditionGroupEditor
group={conditions}
onChange={setConditions}
onRemove={() => setConditions(null)}
depth={0}
canRemove={true}
/>
</div>
)}
</div>
{/* Preview */}
{previewText && (
<div className="bg-secondary-50 border border-secondary-200 rounded-md p-3">
<p className="text-xs font-medium text-secondary-500 mb-1">
{t('abac.preview', 'Preview')}
</p>
<p className="text-sm text-secondary-800">{previewText}</p>
</div>
)}
{/* Error */}
{error && (
<p className="text-sm text-danger-600" role="alert">
{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}
</p>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={onCancel}>
{t('abac.cancel', 'Cancel')}
</Button>
<Button variant="primary" onClick={handleSave} isLoading={isSaving}>
{initial ? t('abac.update', 'Update') : t('abac.create', 'Create')}
</Button>
</div>
</div>
);
}
// ─── Main Component ────────────────────────────────────────────────────────
export interface ABACRuleEditorProps {
entityType: string;
onClose: () => void;
}
export function ABACRuleEditor({ entityType, onClose }: ABACRuleEditorProps) {
const { t } = useTranslation();
const { data: policiesData, isLoading, error: fetchError } = usePolicies(entityType);
const deletePolicy = useDeletePolicy(entityType);
const [showForm, setShowForm] = useState(false);
const [editingPolicy, setEditingPolicy] = useState<ABACPolicy | null>(null);
const [deletingPolicy, setDeletingPolicy] = useState<ABACPolicy | null>(null);
const [expandedPolicies, setExpandedPolicies] = useState<Set<string>>(new Set());
const policies = policiesData?.items || [];
const handleCreate = useCallback(() => {
setEditingPolicy(null);
setShowForm(true);
}, []);
const handleEdit = useCallback((policy: ABACPolicy) => {
setEditingPolicy(policy);
setShowForm(true);
}, []);
const handleFormSave = useCallback(() => {
setShowForm(false);
setEditingPolicy(null);
}, []);
const handleFormCancel = useCallback(() => {
setShowForm(false);
setEditingPolicy(null);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deletingPolicy) return;
try {
await deletePolicy.mutateAsync(deletingPolicy.id);
} catch {
// Error is handled by the mutation
}
setDeletingPolicy(null);
}, [deletingPolicy, deletePolicy]);
const toggleExpand = useCallback((policyId: string) => {
setExpandedPolicies((prev) => {
const next = new Set(prev);
if (next.has(policyId)) {
next.delete(policyId);
} else {
next.add(policyId);
}
return next;
});
}, []);
return (
<Card
title={t('abac.ruleEditor', 'ABAC Rule Editor')}
description={`${entityType}`}
actions={
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleCreate}
>
{t('abac.newPolicy', 'New Policy')}
</Button>
<Button
variant="ghost"
size="sm"
icon={<X className="h-4 w-4" />}
onClick={onClose}
>
{t('abac.close', 'Close')}
</Button>
</div>
}
>
{/* Loading state */}
{isLoading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin h-6 w-6 border-2 border-primary-600 border-t-transparent rounded-full" />
<span className="ml-3 text-sm text-secondary-500">
{t('abac.loading', 'Loading policies...')}
</span>
</div>
)}
{/* Error state */}
{fetchError && !isLoading && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<p className="text-sm text-danger-700">
{t('abac.fetchError', 'Failed to load policies')}: {String(fetchError)}
</p>
</div>
)}
{/* Empty state */}
{!isLoading && !fetchError && policies.length === 0 && !showForm && (
<div className="text-center py-8">
<Shield className="h-12 w-12 text-secondary-300 mx-auto mb-3" />
<p className="text-sm text-secondary-500 mb-4">
{t('abac.noPolicies', 'No policies defined for this entity type.')}
</p>
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleCreate}
>
{t('abac.createFirst', 'Create first policy')}
</Button>
</div>
)}
{/* Policy list */}
{!isLoading && !fetchError && policies.length > 0 && !showForm && (
<div className="space-y-2">
{policies.map((policy) => {
const isExpanded = expandedPolicies.has(policy.id);
return (
<div
key={policy.id}
className="border border-secondary-200 rounded-md hover:border-secondary-300 transition-colors"
>
{/* Policy header */}
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer"
onClick={() => toggleExpand(policy.id)}
>
<div className="flex items-center gap-3 min-w-0">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-secondary-400 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-secondary-400 shrink-0" />
)}
<div className="min-w-0">
<p className="text-sm font-medium text-secondary-900 truncate">
{policy.name}
</p>
<p className="text-xs text-secondary-500 truncate">
{describePolicy(policy)}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge
variant={policy.effect === 'allow' ? 'success' : 'danger'}
>
{policy.effect === 'allow' ? (
<ShieldCheck className="h-3 w-3 mr-1" />
) : (
<ShieldX className="h-3 w-3 mr-1" />
)}
{policy.effect}
</Badge>
{!policy.enabled && (
<Badge variant="warning">{t('abac.disabled', 'Disabled')}</Badge>
)}
<span className="text-xs text-secondary-400">P{policy.priority}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleEdit(policy);
}}
className="text-secondary-400 hover:text-primary-600 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.editPolicy', 'Edit policy')}
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setDeletingPolicy(policy);
}}
className="text-secondary-400 hover:text-danger-500 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.deletePolicy', 'Delete policy')}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{/* Expanded details */}
{isExpanded && (
<div className="px-4 pb-3 pt-0 border-t border-secondary-100">
<div className="grid grid-cols-2 gap-2 mt-2 text-xs">
<div>
<span className="text-secondary-500">
{t('abac.principal', 'Principal')}:
</span>{' '}
<span className="text-secondary-800">
{policy.principal_name || policy.principal_id}
</span>
</div>
<div>
<span className="text-secondary-500">
{t('abac.principalType', 'Type')}:
</span>{' '}
<span className="text-secondary-800">{policy.principal_type}</span>
</div>
<div>
<span className="text-secondary-500">
{t('abac.priority', 'Priority')}:
</span>{' '}
<span className="text-secondary-800">{policy.priority}</span>
</div>
<div>
<span className="text-secondary-500">
{t('abac.enabled', 'Enabled')}:
</span>{' '}
<span className="text-secondary-800">
{policy.enabled ? '✓' : '✗'}
</span>
</div>
</div>
{policy.conditions && (
<div className="mt-2">
<span className="text-xs text-secondary-500">
{t('abac.conditions', 'Conditions')}:
</span>
<p className="text-xs text-secondary-800 mt-1">
{describeConditionGroup(policy.conditions)}
</p>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{/* Create/Edit Form Modal */}
<Modal
open={showForm}
onClose={handleFormCancel}
title={
editingPolicy
? t('abac.editPolicyTitle', 'Edit Policy')
: t('abac.createPolicyTitle', 'Create Policy')
}
size="lg"
>
<PolicyForm
initial={editingPolicy}
entityType={entityType}
onSave={handleFormSave}
onCancel={handleFormCancel}
/>
</Modal>
{/* Delete Confirmation */}
<ConfirmDialog
open={!!deletingPolicy}
title={t('abac.deletePolicyTitle', 'Delete Policy')}
message={
deletingPolicy
? t('abac.deleteConfirm', 'Are you sure you want to delete policy "{{name}}"?', {
name: deletingPolicy.name,
})
: ''
}
variant="danger"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeletingPolicy(null)}
/>
</Card>
);
}
@@ -1,349 +0,0 @@
import { asError } from '@/utils/errorTypes';
import React, { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Button } from '@/components/ui/Button';
import { useToast } from '@/components/ui/Toast';
import {
type UnifiedContact,
useCreateUnifiedContact,
useUpdateUnifiedContact,
} from '@/api/hooks';
import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer';
import { useCustomFields, useUpdateCustomFields } from '@/api/customFields';
import { usePluginStore } from '@/store/pluginStore';
export interface ContactEditModalProps {
open: boolean;
onClose: () => void;
contact?: UnifiedContact | null;
onSaved?: (id: string) => void;
}
// ── Zod Schema ──
const phoneRegex = /^[+]?[\d\s\-().]{6,20}$/;
const contactSchema = z.object({
type: z.enum(['company', 'person']),
name: z.string().optional().default(''),
firstname: z.string().optional().default(''),
surname: z.string().optional().default(''),
code: z.string().optional().default(''),
email_1: z.string().email('Invalid email').or(z.literal('')).optional().default(''),
email_2: z.string().email('Invalid email').or(z.literal('')).optional().default(''),
phone_1: z.string().regex(phoneRegex, 'Invalid phone').or(z.literal('')).optional().default(''),
phone_2: z.string().regex(phoneRegex, 'Invalid phone').or(z.literal('')).optional().default(''),
website: z.string().optional().default(''),
mailing_street: z.string().optional().default(''),
mailing_number: z.string().optional().default(''),
mailing_postalcode: z.string().optional().default(''),
mailing_city: z.string().optional().default(''),
mailing_country: z.string().optional().default(''),
vat_code: z.string().optional().default(''),
fiscal_code: z.string().optional().default(''),
commerce_code: z.string().optional().default(''),
bic: z.string().optional().default(''),
bank_account: z.string().optional().default(''),
tags: z.string().optional().default(''),
projectnote: z.string().optional().default(''),
contact_warning: z.string().optional().default(''),
}).superRefine((data, ctx) => {
if (data.type === 'company' && !data.name?.trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['name'] });
}
if (data.type === 'person' && !data.firstname?.trim() && !data.surname?.trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['firstname'] });
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['surname'] });
}
});
type ContactFormData = z.infer<typeof contactSchema>;
export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEditModalProps) {
const { t } = useTranslation();
const toast = useToast();
const createMutation = useCreateUnifiedContact();
const updateMutation = useUpdateUnifiedContact();
const isEdit = !!contact;
const {
register,
handleSubmit,
reset,
watch,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: {
type: 'company',
name: '',
firstname: '',
surname: '',
code: '',
email_1: '',
email_2: '',
phone_1: '',
phone_2: '',
website: '',
mailing_street: '',
mailing_number: '',
mailing_postalcode: '',
mailing_city: '',
mailing_country: '',
vat_code: '',
fiscal_code: '',
commerce_code: '',
bic: '',
bank_account: '',
tags: '',
projectnote: '',
contact_warning: '',
},
});
const currentType = watch('type');
// Custom fields
const manifests = usePluginStore(s => s.manifests);
const customFieldDefs = useMemo(
() => manifests
.flatMap((m) => m.custom_fields || [])
.filter((cf) => cf.entity === 'contact'),
[manifests]
);
const { data: customFieldsData } = useCustomFields(contact?.id);
const updateCustomFields = useUpdateCustomFields();
const [customValues, setCustomValues] = React.useState<Record<string, any>>({});
React.useEffect(() => {
if (open && customFieldsData?.fields) {
const vals: Record<string, any> = {};
for (const f of customFieldsData.fields) {
vals[f.name] = f.value ?? f.default_value ?? null;
}
setCustomValues(vals);
}
}, [open, customFieldsData]);
const handleCustomFieldChange = (name: string, value: any) => {
setCustomValues(prev => ({ ...prev, [name]: value }));
};
// Reset form when modal opens
useEffect(() => {
if (open) {
reset({
type: (contact?.type as 'company' | 'person') || 'company',
name: contact?.name || '',
firstname: contact?.firstname || '',
surname: contact?.surname || '',
code: contact?.code || '',
email_1: contact?.email_1 || '',
email_2: contact?.email_2 || '',
phone_1: contact?.phone_1 || '',
phone_2: contact?.phone_2 || '',
website: contact?.website || '',
mailing_street: contact?.mailing_street || '',
mailing_number: contact?.mailing_number || '',
mailing_postalcode: contact?.mailing_postalcode || '',
mailing_city: contact?.mailing_city || '',
mailing_country: contact?.mailing_country || '',
vat_code: contact?.vat_code || '',
fiscal_code: contact?.fiscal_code || '',
commerce_code: contact?.commerce_code || '',
bic: contact?.bic || '',
bank_account: contact?.bank_account || '',
tags: contact?.tags || '',
projectnote: contact?.projectnote || '',
contact_warning: contact?.contact_warning || '',
});
}
}, [open, contact, reset]);
const onSubmit = async (formData: ContactFormData) => {
const data: Partial<UnifiedContact> = {
type: formData.type,
name: formData.type === 'company' ? formData.name || null : null,
firstname: formData.type === 'person' ? formData.firstname || null : null,
surname: formData.type === 'person' ? formData.surname || null : null,
code: formData.code || null,
email_1: formData.email_1 || null,
email_2: formData.email_2 || null,
phone_1: formData.phone_1 || null,
phone_2: formData.phone_2 || null,
website: formData.website || null,
mailing_street: formData.mailing_street || null,
mailing_number: formData.mailing_number || null,
mailing_postalcode: formData.mailing_postalcode || null,
mailing_city: formData.mailing_city || null,
mailing_country: formData.mailing_country || null,
vat_code: formData.vat_code || null,
fiscal_code: formData.fiscal_code || null,
commerce_code: formData.commerce_code || null,
bic: formData.bic || null,
bank_account: formData.bank_account || null,
tags: formData.tags || null,
projectnote: formData.projectnote || null,
contact_warning: formData.contact_warning || null,
};
try {
let savedId: string | undefined;
if (isEdit && contact) {
await updateMutation.mutateAsync({ id: contact.id, data });
savedId = contact.id;
toast.success(t('contacts.updated'));
onSaved?.(contact.id);
} else {
const result = await createMutation.mutateAsync(data) as { id: string };
savedId = result.id;
toast.success(t('contacts.created'));
onSaved?.(result.id);
}
// Save custom fields if any definitions exist and we have a contact ID
if (savedId && customFieldDefs.length > 0 && Object.keys(customValues).length > 0) {
try {
await updateCustomFields.mutateAsync({ contactId: savedId, values: customValues });
} catch (cfErr: unknown) { const errObj = asError(cfErr);
// Don't fail the whole save if custom fields fail
console.error('Custom fields save failed:', errObj);
}
}
onClose();
} catch (err: unknown) { const errObj = asError(err);
toast.error(errObj.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed')));
}
};
return (
<Modal open={open} onClose={onClose} title={isEdit ? t('contacts.edit') : t('contacts.create')} size="xl" fullScreenMobile>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Type */}
<Select
label={t('contacts.type')}
{...register('type')}
options={[
{ value: 'company', label: t('contacts.companies') },
{ value: 'person', label: t('contacts.persons') },
]}
data-testid="contact-type-select"
/>
{/* Name fields */}
{currentType === 'company' ? (
<Input
label={t('contacts.name')}
{...register('name')}
error={errors.name?.message}
required
data-testid="contact-name-input"
placeholder="TechCorp GmbH"
/>
) : (
<div className="grid grid-cols-2 gap-3">
<Input
label={t('contacts.firstName')}
{...register('firstname')}
error={errors.firstname?.message}
data-testid="contact-first-name-input"
/>
<Input
label={t('contacts.lastName')}
{...register('surname')}
error={errors.surname?.message}
data-testid="contact-last-name-input"
/>
</div>
)}
{/* Code */}
<Input label={t('contacts.code')} {...register('code')} placeholder="K-00123" />
{/* Communication */}
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.communication')}</h3>
<div className="grid grid-cols-2 gap-3">
<Input label={t('contacts.email') + ' 1'} type="email" {...register('email_1')} error={errors.email_1?.message} />
<Input label={t('contacts.email') + ' 2'} type="email" {...register('email_2')} error={errors.email_2?.message} />
<Input label={t('contacts.phone') + ' 1'} {...register('phone_1')} error={errors.phone_1?.message} />
<Input label={t('contacts.phone') + ' 2'} {...register('phone_2')} error={errors.phone_2?.message} />
<Input label={t('contacts.website')} {...register('website')} />
</div>
</div>
{/* Mailing Address */}
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.mailingAddress')}</h3>
<div className="grid grid-cols-2 gap-3">
<Input label={t('address.street')} {...register('mailing_street')} />
<Input label={t('address.streetNumber')} {...register('mailing_number')} />
<Input label={t('address.zip')} {...register('mailing_postalcode')} />
<Input label={t('address.city')} {...register('mailing_city')} />
<Input label={t('address.country')} {...register('mailing_country')} />
</div>
</div>
{/* Financial */}
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.financial')}</h3>
<div className="grid grid-cols-2 gap-3">
<Input label={t('contacts.vatCode')} {...register('vat_code')} />
<Input label={t('contacts.fiscalCode')} {...register('fiscal_code')} />
<Input label={t('contacts.commerceCode')} {...register('commerce_code')} />
<Input label={t('contacts.bic')} {...register('bic')} />
<Input label={t('contacts.bankAccount')} {...register('bank_account')} />
</div>
</div>
{/* Notes */}
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.notes')}</h3>
<div className="space-y-3">
<Input label={t('contacts.tags')} {...register('tags')} placeholder="tag1, tag2" />
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.projectnote')}</label>
<textarea
{...register('projectnote')}
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.contactWarning')}</label>
<textarea
{...register('contact_warning')}
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
/>
</div>
</div>
</div>
{/* Custom Fields */}
{customFieldDefs.length > 0 && (
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.customFields')}</h3>
<CustomFieldRenderer
fields={customFieldsData?.fields || customFieldDefs.map(d => ({ ...d, value: d.default_value, plugin: '' }))}
mode="edit"
values={customValues}
onChange={handleCustomFieldChange}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button type="submit" isLoading={isSubmitting || createMutation.isPending || updateMutation.isPending} data-testid="contact-submit-btn">
{isEdit ? t('common.save') : t('common.create')}
</Button>
</div>
</form>
</Modal>
);
}
@@ -1256,7 +1256,7 @@ export function ContactList({
};
return (
<div data-testid="contact-list" className="flex flex-col h-full" data-testid="contact-cards-view">
<div data-testid="contact-cards-view" className="flex flex-col h-full">
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3">
{isGrouped && groupedContacts ? (
<div className="space-y-4">
@@ -1,195 +0,0 @@
/**
* DedupDialog — UI for finding and merging duplicate contacts (Task 5.23).
*/
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useFindDuplicates, useMergeContacts, type DuplicatePair } from '@/api/dedup';
import { useToast } from '@/components/ui/Toast';
import { GitMerge, Search, AlertTriangle, Check } from 'lucide-react';
export function DedupDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const { success, error: showError } = useToast();
const findDuplicates = useFindDuplicates();
const mergeContacts = useMergeContacts();
const [duplicates, setDuplicates] = useState<DuplicatePair[]>([]);
const [selectedPair, setSelectedPair] = useState<number | null>(null);
const [fieldOverrides, setFieldOverrides] = useState<Record<string, string>>({});
const handleSearch = useCallback(async () => {
try {
const result = await findDuplicates.mutateAsync({ threshold: 0.7, limit: 50 });
setDuplicates(result || []);
setSelectedPair(null);
} catch {
showError(t('dedup.searchFailed'));
}
}, [findDuplicates, showError, t]);
const handleMerge = useCallback(async () => {
if (selectedPair === null) return;
const pair = duplicates[selectedPair];
if (!pair) return;
try {
const overrides: Record<string, unknown> = {};
for (const [field, value] of Object.entries(fieldOverrides)) {
if (value === 'source') {
overrides[field] = (pair.source_contact as unknown as Record<string, unknown>)[field];
} else if (value === 'target') {
overrides[field] = (pair.target_contact as unknown as Record<string, unknown>)[field];
}
}
await mergeContacts.mutateAsync({
source_contact_id: pair.source_contact.id,
target_contact_id: pair.target_contact.id,
field_overrides: Object.keys(overrides).length > 0 ? overrides : undefined,
});
success(t('dedup.mergeSuccess'));
setDuplicates((prev) => prev.filter((_, i) => i !== selectedPair));
setSelectedPair(null);
setFieldOverrides({});
} catch {
showError(t('dedup.mergeFailed'));
}
}, [duplicates, selectedPair, fieldOverrides, mergeContacts, success, showError, t]);
const compareFields = [
{ key: 'displayname', label: t('dedup.fields.displayName') },
{ key: 'email_1', label: t('dedup.fields.email') },
{ key: 'phone_1', label: t('dedup.fields.phone') },
{ key: 'mailing_city', label: t('dedup.fields.city') },
{ key: 'mailing_postalcode', label: t('dedup.fields.postalCode') },
];
return (
<Modal open={open} onClose={onClose} title={t('dedup.title')} size="xl">
<div className="space-y-4" data-testid="dedup-dialog">
<div className="flex items-center gap-3">
<Button
variant="primary"
icon={<Search className="w-4 h-4" />}
onClick={handleSearch}
isLoading={findDuplicates.isPending}
data-testid="dedup-search-btn"
>
{t('dedup.findDuplicates')}
</Button>
{duplicates.length > 0 && (
<Badge variant="info">{duplicates.length} {t('dedup.pairsFound')}</Badge>
)}
</div>
{duplicates.length === 0 && !findDuplicates.isPending && (
<p className="text-sm text-secondary-500" data-testid="dedup-empty">
{t('dedup.noDuplicates')}
</p>
)}
{duplicates.map((pair, idx) => (
<div
key={`${pair.source_contact.id}-${pair.target_contact.id}`}
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
selectedPair === idx ? 'border-primary-500 bg-primary-50' : 'border-secondary-200'
}`}
onClick={() => setSelectedPair(idx)}
data-testid={`dedup-pair-${idx}`}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-warning-500" />
<span className="font-medium">
{t('dedup.similarity')}: {Math.round(pair.similarity_score * 100)}%
</span>
</div>
<div className="flex gap-1">
{pair.match_reasons.map((reason) => (
<Badge key={reason} variant="warning">{reason}</Badge>
))}
</div>
</div>
{selectedPair === idx && (
<div className="mt-4 space-y-3">
<div className="grid grid-cols-3 gap-2 text-sm font-medium text-secondary-600">
<div>{t('dedup.field')}</div>
<div className="text-center">{t('dedup.source')}</div>
<div className="text-center">{t('dedup.target')}</div>
</div>
{compareFields.map((field) => {
const sourceVal = (pair.source_contact as unknown as Record<string, unknown>)[field.key] as string | null;
const targetVal = (pair.target_contact as unknown as Record<string, unknown>)[field.key] as string | null;
return (
<div key={field.key} className="grid grid-cols-3 gap-2 text-sm">
<div className="text-secondary-700">{field.label}</div>
<div className="text-center">
<button
className={`px-2 py-1 rounded ${
fieldOverrides[field.key] === 'source' ? 'bg-primary-100 text-primary-700' : 'hover:bg-secondary-100'
}`}
onClick={(e) => {
e.stopPropagation();
setFieldOverrides((prev) => ({ ...prev, [field.key]: 'source' }));
}}
data-testid={`dedup-field-${field.key}-source`}
>
{sourceVal || '—'}
</button>
</div>
<div className="text-center">
<button
className={`px-2 py-1 rounded ${
fieldOverrides[field.key] === 'target' ? 'bg-primary-100 text-primary-700' : 'hover:bg-secondary-100'
}`}
onClick={(e) => {
e.stopPropagation();
setFieldOverrides((prev) => ({ ...prev, [field.key]: 'target' }));
}}
data-testid={`dedup-field-${field.key}-target`}
>
{targetVal || '—'}
</button>
</div>
</div>
);
})}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="primary"
icon={<GitMerge className="w-4 h-4" />}
onClick={handleMerge}
isLoading={mergeContacts.isPending}
data-testid="dedup-merge-btn"
>
{t('dedup.merge')}
</Button>
</div>
</div>
)}
{selectedPair !== idx && (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="font-medium text-secondary-800">{pair.source_contact.displayname}</div>
<div className="text-secondary-500">{pair.source_contact.email_1 || '—'}</div>
</div>
<div>
<div className="font-medium text-secondary-800">{pair.target_contact.displayname}</div>
<div className="text-secondary-500">{pair.target_contact.email_1 || '—'}</div>
</div>
</div>
)}
</div>
))}
</div>
</Modal>
);
}
@@ -1,139 +0,0 @@
/**
* AskKnowledge — query input + answer + evidence cards.
*
* Calls POST /api/v1/knowledge/ask with `{ query, source_types }` and renders
* the returned answer plus evidence cards.
*/
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Search, Sparkles } from 'lucide-react';
import { askKnowledge, type KnowledgeAskResponse } from '@/api/knowledge';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState';
const SOURCE_TYPES = ['contact', 'company', 'wiki', 'dms_file', 'email', 'task'];
export function AskKnowledge() {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [sourceTypes, setSourceTypes] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<KnowledgeAskResponse | null>(null);
const toggleSource = useCallback((type: string) => {
setSourceTypes((prev) =>
prev.includes(type) ? prev.filter((s) => s !== type) : [...prev, type]
);
}, []);
const handleAsk = useCallback(async () => {
if (!query.trim()) return;
setLoading(true);
setError(null);
try {
const response = await askKnowledge({
query: query.trim(),
source_types: sourceTypes.length > 0 ? sourceTypes : undefined,
});
setResult(response);
} catch (err) {
setError(err instanceof Error ? err.message : t('knowledge.ask.error'));
} finally {
setLoading(false);
}
}, [query, sourceTypes, t]);
return (
<Card title={t('knowledge.ask.title')} description={t('knowledge.ask.description')}>
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-secondary-400" aria-hidden="true" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleAsk();
}}
placeholder={t('knowledge.ask.placeholder')}
className="pl-9"
aria-label={t('knowledge.ask.placeholder')}
/>
</div>
<Button onClick={() => void handleAsk()} isLoading={loading} icon={<Sparkles className="h-4 w-4" aria-hidden="true" />}>
{t('knowledge.ask.submit')}
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-secondary-500">{t('knowledge.ask.sourceTypes')}</span>
{SOURCE_TYPES.map((type) => {
const active = sourceTypes.includes(type);
return (
<button
key={type}
type="button"
onClick={() => toggleSource(type)}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium min-h-touch transition-colors ${
active ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200'
}`}
aria-pressed={active}
>
{type}
</button>
);
})}
</div>
{error && <p className="text-sm text-danger-600" role="alert">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</p>}
{loading && (
<div className="flex items-center justify-center py-10" role="status" aria-label={t('common.loading')}>
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
</div>
)}
{!loading && result && (
<div className="space-y-4">
<div className="border border-secondary-200 rounded-lg p-4 bg-secondary-50">
<h4 className="text-sm font-semibold text-secondary-900 mb-2">{t('knowledge.ask.answer')}</h4>
<p className="text-sm text-secondary-800 whitespace-pre-wrap">{result.answer}</p>
</div>
<div>
<h4 className="text-sm font-semibold text-secondary-900 mb-2">{t('knowledge.ask.evidence')}</h4>
{result.evidence.length === 0 ? (
<EmptyState
title={t('knowledge.ask.noEvidence')}
description={t('knowledge.ask.noEvidenceDescription')}
/>
) : (
<ul className="space-y-2">
{result.evidence.map((evidence) => (
<li key={evidence.id} className="border border-secondary-200 rounded-lg p-3 bg-white">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-sm font-medium text-secondary-900">{evidence.title}</span>
<Badge variant="info">{evidence.source_type}</Badge>
</div>
<p className="text-sm text-secondary-600 line-clamp-3">{evidence.snippet}</p>
{typeof evidence.score === 'number' && (
<p className="text-xs text-secondary-400 mt-1">
{t('knowledge.ask.score')}: {Math.round(evidence.score * 100)}%
</p>
)}
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
</Card>
);
}
@@ -1,369 +0,0 @@
/**
* KnowledgeGraph — SVG-based visualization of entity relationships.
*
* Fetches relationships from GET /api/v1/graph/relationships and renders
* Contacts/Companies as nodes (circles) connected by labeled edges.
* Supports pan/zoom and click-to-inspect details.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
import { fetchGraphRelationships, type GraphRelationship } from '@/api/knowledge';
import { Card } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { Button } from '@/components/ui/Button';
interface GraphNode {
id: string;
entity_type: string;
label: string;
x: number;
y: number;
}
interface GraphEdge {
id: string;
source: string;
target: string;
label: string;
}
interface SelectedNode {
id: string;
entity_type: string;
label: string;
relationships: GraphRelationship[];
}
const NODE_RADIUS = 24;
const WIDTH = 900;
const HEIGHT = 560;
const TYPE_COLORS: Record<string, string> = {
contact: '#3b82f6',
company: '#10b981',
email: '#8b5cf6',
task: '#f59e0b',
dms_file: '#ef4444',
default: '#64748b',
};
function typeColor(type: string): string {
return TYPE_COLORS[type] ?? TYPE_COLORS.default;
}
function typeLabel(type: string): string {
return type.replace(/_/g, ' ');
}
/**
* Simple force-directed layout: nodes repel, edges attract.
* Deterministic initial placement avoids layout jumps.
*/
function layoutNodes(relationships: GraphRelationship[]): { nodes: GraphNode[]; edges: GraphEdge[] } {
const nodeMap = new Map<string, { entity_type: string; label: string }>();
const edges: GraphEdge[] = [];
for (const rel of relationships) {
const sourceKey = `${rel.source_type}:${rel.source_id}`;
const targetKey = `${rel.target_type}:${rel.target_id}`;
if (!nodeMap.has(sourceKey)) {
nodeMap.set(sourceKey, { entity_type: rel.source_type, label: rel.source_type });
}
if (!nodeMap.has(targetKey)) {
nodeMap.set(targetKey, { entity_type: rel.target_type, label: rel.target_type });
}
edges.push({
id: rel.id,
source: sourceKey,
target: targetKey,
label: rel.relationship_type,
});
}
const keys = Array.from(nodeMap.keys());
const nodes: GraphNode[] = keys.map((key, i) => {
const angle = (i / Math.max(keys.length, 1)) * Math.PI * 2;
const radius = Math.min(220, 80 + (i % 5) * 40);
return {
id: key,
entity_type: nodeMap.get(key)?.entity_type ?? 'unknown',
label: nodeMap.get(key)?.label ?? key,
x: WIDTH / 2 + Math.cos(angle) * radius,
y: HEIGHT / 2 + Math.sin(angle) * radius,
};
});
// Simple repulsion/attraction relaxation
for (let iter = 0; iter < 80; iter++) {
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const force = 40 / (dist * dist);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
nodes[i].x -= fx;
nodes[i].y -= fy;
nodes[j].x += fx;
nodes[j].y += fy;
}
}
for (const edge of edges) {
const s = nodes.find((n) => n.id === edge.source);
const t = nodes.find((n) => n.id === edge.target);
if (!s || !t) continue;
const dx = t.x - s.x;
const dy = t.y - s.y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const force = (dist - 160) * 0.02;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
s.x += fx;
s.y += fy;
t.x -= fx;
t.y -= fy;
}
}
// Clamp to viewport with padding
for (const n of nodes) {
n.x = Math.min(Math.max(n.x, 60), WIDTH - 60);
n.y = Math.min(Math.max(n.y, 60), HEIGHT - 60);
}
return { nodes, edges };
}
export function KnowledgeGraph() {
const { t } = useTranslation();
const [relationships, setRelationships] = useState<GraphRelationship[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<GraphNode | null>(null);
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const dragStart = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await fetchGraphRelationships({ page_size: 200 });
setRelationships(result.items);
} catch (err) {
setError(err instanceof Error ? err.message : t('knowledge.graph.loadError'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void load();
}, [load]);
const { nodes, edges } = useMemo(() => layoutNodes(relationships), [relationships]);
const selectedRelationships = useMemo(() => {
if (!selected) return [];
return relationships.filter(
(r) =>
`${r.source_type}:${r.source_id}` === selected.id ||
`${r.target_type}:${r.target_id}` === selected.id
);
}, [selected, relationships]);
const handleNodeClick = useCallback((node: GraphNode) => {
setSelected(node);
}, []);
const handleWheel = useCallback((e: React.WheelEvent<SVGSVGElement>) => {
const factor = e.deltaY > 0 ? 0.9 : 1.1;
setZoom((z) => Math.min(Math.max(z * factor, 0.4), 3));
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
if (e.button !== 0) return;
setDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
}, [pan]);
const handleMouseMove = useCallback(
(e: React.MouseEvent<SVGSVGElement>) => {
if (!dragging || !dragStart.current) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
setPan({ x: dragStart.current.panX + dx, y: dragStart.current.panY + dy });
},
[dragging]
);
const handleMouseUp = useCallback(() => {
setDragging(false);
dragStart.current = null;
}, []);
const resetView = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
return (
<Card
title={t('knowledge.graph.title')}
description={t('knowledge.graph.description')}
actions={
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => setZoom((z) => Math.min(z * 1.2, 3))} aria-label={t('knowledge.graph.zoomIn')}>
<ZoomIn className="h-4 w-4" aria-hidden="true" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setZoom((z) => Math.max(z * 0.8, 0.4))} aria-label={t('knowledge.graph.zoomOut')}>
<ZoomOut className="h-4 w-4" aria-hidden="true" />
</Button>
<Button variant="ghost" size="sm" onClick={resetView} aria-label={t('knowledge.graph.reset')}>
<Maximize2 className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
}
>
{loading && (
<div className="flex items-center justify-center py-16" role="status" aria-label={t('common.loading')}>
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
</div>
)}
{!loading && error && (
<div className="py-8 text-center">
<p className="text-danger-600 text-sm mb-4">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</p>
<Button variant="secondary" onClick={() => void load()}>
{t('common.retry')}
</Button>
</div>
)}
{!loading && !error && relationships.length === 0 && (
<EmptyState
title={t('knowledge.graph.emptyTitle')}
description={t('knowledge.graph.emptyDescription')}
/>
)}
{!loading && !error && relationships.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2 border border-secondary-200 rounded-lg overflow-hidden bg-secondary-50">
<svg
width="100%"
height={HEIGHT}
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
className="cursor-grab active:cursor-grabbing touch-none select-none"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
role="img"
aria-label={t('knowledge.graph.ariaLabel')}
>
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
{/* Edges */}
{edges.map((edge) => {
const source = nodes.find((n) => n.id === edge.source);
const target = nodes.find((n) => n.id === edge.target);
if (!source || !target) return null;
const mx = (source.x + target.x) / 2;
const my = (source.y + target.y) / 2;
return (
<g key={edge.id}>
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke="#94a3b8"
strokeWidth={1.5}
/>
<text
x={mx}
y={my - 6}
textAnchor="middle"
fontSize={11}
fill="#64748b"
className="pointer-events-none"
>
{edge.label}
</text>
</g>
);
})}
{/* Nodes */}
{nodes.map((node) => {
const isSelected = selected?.id === node.id;
return (
<g
key={node.id}
transform={`translate(${node.x}, ${node.y})`}
onClick={(e) => {
e.stopPropagation();
handleNodeClick(node);
}}
className="cursor-pointer"
role="button"
aria-label={`${node.label} ${node.entity_type}`}
>
<circle
r={NODE_RADIUS}
fill={typeColor(node.entity_type)}
fillOpacity={isSelected ? 1 : 0.85}
stroke={isSelected ? '#0f172a' : '#ffffff'}
strokeWidth={isSelected ? 3 : 2}
/>
<text
textAnchor="middle"
dy="0.35em"
fontSize={11}
fill="#ffffff"
fontWeight={600}
className="pointer-events-none"
>
{node.label.length > 12 ? `${node.label.slice(0, 11)}` : node.label}
</text>
</g>
);
})}
</g>
</svg>
</div>
<div>
{selected ? (
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
<h4 className="font-semibold text-secondary-900 mb-1">{selected.label}</h4>
<p className="text-sm text-secondary-500 mb-3">{typeLabel(selected.entity_type)}</p>
<p className="text-sm font-medium text-secondary-700 mb-2">{t('knowledge.graph.relationships')}</p>
{selectedRelationships.length === 0 && (
<p className="text-sm text-secondary-400">{t('knowledge.graph.noRelationships')}</p>
)}
<ul className="space-y-2">
{selectedRelationships.map((rel) => {
const isSource = `${rel.source_type}:${rel.source_id}` === selected.id;
const otherType = isSource ? rel.target_type : rel.source_type;
return (
<li key={rel.id} className="text-sm text-secondary-700">
<span className="font-medium">{rel.relationship_type}</span>
<span className="text-secondary-400"> </span>
<span>{typeLabel(otherType)}</span>
</li>
);
})}
</ul>
</div>
) : (
<div className="border border-secondary-200 rounded-lg p-4 bg-white text-sm text-secondary-500">
{t('knowledge.graph.selectHint')}
</div>
)}
</div>
</div>
)}
</Card>
);
}
@@ -1,46 +0,0 @@
/**
* Mail search bar — input for full-text mail search.
*/
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/Input';
import { Search } from 'lucide-react';
export interface MailSearchBarProps {
onSearch: (query: string) => void;
value?: string;
}
export function MailSearchBar({ onSearch }: MailSearchBarProps) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
}, []);
const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
onSearch(query.trim());
}, [query, onSearch]);
return (
<form onSubmit={handleSubmit} className="relative" data-testid="mail-search-bar">
<Input
type="search"
value={query}
onChange={handleChange}
placeholder={t('mail.searchPlaceholder')}
aria-label={t('common.search')}
/>
<button
type="submit"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch"
aria-label={t('common.search')}
>
<Search className="w-4 h-4 text-secondary-400" aria-hidden="true" strokeWidth={2} />
</button>
</form>
);
}
@@ -1,34 +0,0 @@
/**
* Shared mailbox selector — switch between personal and shared accounts.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Select } from '@/components/ui/Select';
import type { MailAccount } from '@/api/mail';
export interface SharedMailboxSelectorProps {
accounts: MailAccount[];
selectedAccountId: string;
onSelect: (accountId: string) => void;
}
export function SharedMailboxSelector({ accounts, selectedAccountId, onSelect }: SharedMailboxSelectorProps) {
const { t } = useTranslation();
const options = accounts.map((acc) => ({
value: acc.id,
label: `${acc.display_name} (${acc.email})${acc.is_shared ? ' — ' + t('mail.shared') : ''}`,
}));
return (
<div data-testid="shared-mailbox-selector">
<Select
label={t('mail.selectAccount')}
value={selectedAccountId}
onChange={(e) => onSelect(e.target.value)}
options={options}
/>
</div>
);
}
@@ -1,155 +0,0 @@
import { asError } from '@/utils/errorTypes';
import React, { useState, useRef, useCallback } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { useToast } from '@/components/ui/Toast';
import { apiClient } from '@/api/client';
import { useTranslation } from 'react-i18next';
export interface CsvImportDialogProps {
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
interface ParsedRow {
[key: string]: string;
}
function parseCSV(text: string): { headers: string[]; rows: ParsedRow[] } {
const lines = text.trim().split(/\n/);
if (lines.length === 0) return { headers: [], rows: [] };
const headers = lines[0].split(',').map((h) => h.trim());
const rows: ParsedRow[] = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const values = lines[i].split(',').map((v) => v.trim());
const row: ParsedRow = {};
headers.forEach((header, idx) => {
row[header] = values[idx] || '';
});
rows.push(row);
}
return { headers, rows };
}
export function CsvImportDialog({ open, onClose, onSuccess }: CsvImportDialogProps) {
const { t } = useTranslation();
const toast = useToast();
const [importing, setImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewData, setPreviewData] = useState<{ headers: string[]; rows: ParsedRow[] } | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.csv')) {
setError('Bitte wählen Sie eine CSV-Datei aus.');
return;
}
setError(null);
setSelectedFile(file);
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result as string;
const parsed = parseCSV(text);
setPreviewData(parsed);
};
reader.readAsText(file);
}, []);
const handleImport = async () => {
if (!selectedFile) return;
setImporting(true);
try {
const formData = new FormData();
formData.append('file', selectedFile);
await apiClient.post('/contacts/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
toast.success('Import erfolgreich abgeschlossen.');
setSelectedFile(null);
setPreviewData(null);
setError(null);
onSuccess?.();
onClose();
} catch (err: unknown) { const errObj = asError(err);
toast.error(errObj.message || 'Import fehlgeschlagen.');
} finally {
setImporting(false);
}
};
const handleClose = () => {
setSelectedFile(null);
setPreviewData(null);
setError(null);
onClose();
};
return (
<Modal open={open} onClose={handleClose} title="CSV Import" size="lg" >
<div className="space-y-4" data-testid="csv-import-dialog">
<div>
<p className="text-sm text-secondary-600 mb-3">
Wählen Sie eine CSV-Datei mit Firmendaten. Erforderliche Spalte: name.
Optionale Spalten: account_number, industry, phone, email, website, description.
</p>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileSelect}
className="block w-full text-sm text-secondary-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 min-h-touch"
aria-label="CSV-Datei auswählen"
data-testid="csv-file-input"
/>
{error && <p className="mt-2 text-sm text-danger-600" role="alert">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</p>}
</div>
{previewData && previewData.rows.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-secondary-900 mb-2">Vorschau ({previewData.rows.length} Datensätze)</h4>
<div className="overflow-x-auto border border-secondary-200 rounded-md max-h-60">
<table className="min-w-full text-sm">
<thead className="bg-secondary-50 sticky top-0">
<tr>
{previewData.headers.map((header) => (
<th key={header} className="px-3 py-2 text-left font-semibold text-secondary-600">{header}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{previewData.rows.slice(0, 10).map((row, idx) => (
<tr key={idx}>
{previewData.headers.map((header) => (
<td key={header} className="px-3 py-2 text-secondary-900">{row[header]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{previewData.rows.length > 10 && (
<p className="text-xs text-secondary-500 mt-1">Zeige 10 von {previewData.rows.length} Datensätzen.</p>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={handleClose}>{t('common.cancel')}</Button>
<Button
onClick={handleImport}
disabled={!selectedFile || importing}
isLoading={importing}
data-testid="csv-import-button"
>
{t('common.save')}
</Button>
</div>
</div>
</Modal>
);
}
@@ -1,28 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { useLocation, useNavigate, useBlocker } from 'react-router-dom';
export interface UnsavedChangesGuardProps {
isDirty: boolean;
message?: string;
onConfirm?: () => void;
}
export function UnsavedChangesGuard({ isDirty, message = 'Sie haben ungespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?', onConfirm }: UnsavedChangesGuardProps) {
const blocker = useBlocker(isDirty);
const messageRef = useRef(message);
messageRef.current = message;
useEffect(() => {
if (blocker.state === 'blocked') {
const confirmed = window.confirm(messageRef.current);
if (confirmed) {
onConfirm?.();
blocker.proceed();
} else {
blocker.reset();
}
}
}, [blocker, onConfirm]);
return null;
}
@@ -1,171 +0,0 @@
import clsx from 'clsx';
/**
* Bulk tag assignment dialog.
* Assigns selected tags to multiple entities at once.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { EmptyState } from '@/components/ui/EmptyState';
import { useToast } from '@/components/ui/Toast';
import {
fetchTags,
bulkAssignTags,
type Tag,
type EntityType,
} from '@/api/tags';
export interface BulkTagDialogProps {
open: boolean;
entityType: EntityType;
entityIds: string[];
onClose: () => void;
onAssigned: () => void;
}
export function BulkTagDialog({
open,
entityType,
entityIds,
onClose,
onAssigned,
}: BulkTagDialogProps) {
const { t } = useTranslation();
const toast = useToast();
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) {
setLoading(true);
fetchTags()
.then((allTags) => {
setTags(allTags);
})
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
})
.finally(() => setLoading(false));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const filteredTags = tags.filter((tag) =>
tag.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleToggleTag = useCallback((tagId: string) => {
setSelectedTagIds((prev) => {
const next = new Set(prev);
if (next.has(tagId)) {
next.delete(tagId);
} else {
next.add(tagId);
}
return next;
});
}, []);
const handleAssign = useCallback(async () => {
if (selectedTagIds.size === 0 || entityIds.length === 0) return;
setSubmitting(true);
try {
await bulkAssignTags({
tag_ids: Array.from(selectedTagIds),
entity_type: entityType,
entity_ids: entityIds,
});
toast.success(t('tags.assignSuccess'));
setSelectedTagIds(new Set());
setSearchQuery('');
onAssigned();
onClose();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
}
setSubmitting(false);
}, [selectedTagIds, entityIds, entityType, toast, t, onAssigned, onClose]);
return (
<Modal
open={open}
onClose={onClose}
title={t('tags.bulkAssignTitle')}
size="md"
data-testid="bulk-tag-dialog"
>
<div className="space-y-4">
<p className="text-sm text-secondary-500">
{t('tags.selectedEntities', { count: entityIds.length })}
</p>
<Input
label={t('tags.search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('tags.search')}
/>
{loading ? (
<p className="text-sm text-secondary-500">{t('tags.loading')}...</p>
) : filteredTags.length === 0 ? (
<EmptyState title={t('tags.noTags')} />
) : (
<div className="flex flex-wrap gap-2 max-h-60 overflow-y-auto" role="list" data-testid="bulk-tag-list">
{filteredTags.map((tag) => {
const isSelected = selectedTagIds.has(tag.id);
return (
<button
key={tag.id}
onClick={() => handleToggleTag(tag.id)}
className={clsx(
'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium motion-safe:transition-colors min-h-touch',
isSelected
? 'bg-primary-600 text-white'
: 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200'
)}
aria-pressed={isSelected}
aria-label={`${tag.name} ${isSelected ? '(selected)' : ''}`}
>
<span
className="w-2 h-2 rounded-full inline-block"
style={{ backgroundColor: tag.color }}
aria-hidden="true"
/>
{tag.name}
</button>
);
})}
</div>
)}
{selectedTagIds.size > 0 && (
<p className="text-sm text-primary-600">
{t('tags.selectTags', { count: selectedTagIds.size })}
</p>
)}
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose}>
{t('tags.cancel')}
</Button>
<Button
onClick={handleAssign}
isLoading={submitting}
disabled={selectedTagIds.size === 0 || entityIds.length === 0}
>
{t('tags.bulkAssign')}
</Button>
</div>
</div>
</Modal>
);
}
-75
View File
@@ -1,75 +0,0 @@
/**
* Tag cloud display component.
* Renders tags with font sizes proportional to usage count.
*/
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { EmptyState } from '@/components/ui/EmptyState';
import type { Tag } from '@/api/tags';
export interface TagCloudProps {
tags: Tag[];
onTagClick?: (tag: Tag) => void;
loading?: boolean;
}
export function TagCloud({ tags, onTagClick, loading = false }: TagCloudProps) {
const { t } = useTranslation();
const tagsWithSize = useMemo(() => {
if (tags.length === 0) return [];
const maxCount = Math.max(...tags.map((tag) => tag.usage_count || 0), 1);
const minCount = Math.min(...tags.map((tag) => tag.usage_count || 0), 0);
const range = maxCount - minCount || 1;
return tags.map((tag) => {
const count = tag.usage_count || 0;
const ratio = (count - minCount) / range;
const sizeClass =
ratio > 0.75 ? 'text-2xl' :
ratio > 0.5 ? 'text-xl' :
ratio > 0.25 ? 'text-lg' :
'text-base';
return { tag, sizeClass };
});
}, [tags]);
if (loading) {
return (
<div className="flex flex-wrap gap-3 items-center justify-center py-8" data-testid="tag-cloud-loading">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-6 bg-secondary-100 rounded animate-pulse" style={{ width: `${60 + i * 20}px` }} />
))}
</div>
);
}
if (tags.length === 0) {
return (
<div data-testid="tag-cloud-empty">
<EmptyState title={t('tags.noTags')} />
</div>
);
}
return (
<div className="flex flex-wrap gap-3 items-center justify-center py-8" data-testid="tag-cloud" role="list">
{tagsWithSize.map(({ tag, sizeClass }) => (
<button
key={tag.id}
onClick={() => onTagClick?.(tag)}
className={`${sizeClass} font-medium text-secondary-700 hover:text-primary-600 motion-safe:transition-colors min-h-touch px-2 py-1 rounded`}
aria-label={`${tag.name} (${tag.usage_count || 0} ${t('tags.usageCount')})`}
>
<span
className="inline-block w-3 h-3 rounded-full mr-1 align-middle"
style={{ backgroundColor: tag.color }}
aria-hidden="true"
/>
{tag.name}
</button>
))}
</div>
);
}
-248
View File
@@ -1,248 +0,0 @@
import clsx from 'clsx';
/**
* Tag picker for entity detail pages.
* Shows assigned tags and allows assigning/unassigning tags.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { EmptyState } from '@/components/ui/EmptyState';
import { useToast } from '@/components/ui/Toast';
import { X } from 'lucide-react';
import {
fetchTags,
assignTag,
unassignTag,
createTag,
type Tag,
type EntityType,
} from '@/api/tags';
export interface TagPickerProps {
entityType: EntityType;
entityId: string;
assignedTags?: Tag[];
}
export function TagPicker({ entityType, entityId, assignedTags: initialAssigned = [] }: TagPickerProps) {
const { t } = useTranslation();
const toast = useToast();
const [allTags, setAllTags] = useState<Tag[]>([]);
const [assignedTags, setAssignedTags] = useState<Tag[]>(initialAssigned);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [submitting, setSubmitting] = useState(false);
// ── Tag create form (RHF + Zod) ──
const tagSchema = z.object({
name: z.string().min(1, 'required'),
color: z.string().optional().default('#3B82F6'),
});
type TagFormData = z.infer<typeof tagSchema>;
const { register: registerTag, handleSubmit: handleSubmitTag, reset: resetTag, watch: watchTag, setValue: setTagValue, formState: { errors: tagErrors } } = useForm<TagFormData>({
resolver: zodResolver(tagSchema),
defaultValues: { name: '', color: '#3B82F6' },
});
const newTagColor = watchTag('color');
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchTags()
.then((tags) => {
if (cancelled) return;
setAllTags(tags);
})
.catch((err) => {
if (cancelled) return;
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [toast]);
const assignedTagIds = new Set(assignedTags.map((tag) => tag.id));
const filteredTags = allTags.filter((tag) => {
const matchesSearch = tag.name.toLowerCase().includes(searchQuery.toLowerCase());
const isAssigned = assignedTagIds.has(tag.id);
return matchesSearch && !isAssigned;
});
const handleAssign = useCallback(async (tagId: string) => {
setSubmitting(true);
try {
await assignTag({ tag_id: tagId, entity_type: entityType, entity_id: entityId });
const tag = allTags.find((t) => t.id === tagId) || assignedTags.find((t) => t.id === tagId);
if (tag) {
setAssignedTags((prev) => [...prev, tag]);
}
toast.success(t('tags.assignSuccess'));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
}
setSubmitting(false);
}, [entityType, entityId, allTags, assignedTags, toast, t]);
const handleUnassign = useCallback(async (tagId: string) => {
setSubmitting(true);
try {
await unassignTag({ tag_id: tagId, entity_type: entityType, entity_id: entityId });
setAssignedTags((prev) => prev.filter((tag) => tag.id !== tagId));
toast.success(t('tags.unassignSuccess'));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
}
setSubmitting(false);
}, [entityType, entityId, toast, t]);
const handleCreateTag = useCallback(async (data: TagFormData) => {
setSubmitting(true);
try {
const tag = await createTag({ name: data.name.trim(), color: data.color });
setAllTags((prev) => [...prev, tag]);
await handleAssign(tag.id);
resetTag({ name: '', color: '#3B82F6' });
setShowCreateForm(false);
toast.success(t('tags.createSuccess'));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
}
setSubmitting(false);
}, [handleAssign, toast, t, resetTag]);
const colorOptions = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#F97316', '#EC4899', '#6B7280'];
return (
<div className="space-y-4" data-testid="tag-picker">
{/* Assigned tags */}
<div className="space-y-2">
<h3 className="text-sm font-semibold text-secondary-900">{t('tags.assignedTags')}</h3>
{loading ? (
<p className="text-sm text-secondary-500">{t('tags.loading')}...</p>
) : assignedTags.length === 0 ? (
<EmptyState title={t('tags.noTagsAssigned')} />
) : (
<div className="flex flex-wrap gap-2" role="list" aria-label={t('tags.assignedTags')}>
{assignedTags.map((tag) => (
<div key={tag.id} className="flex items-center">
<Badge
variant="primary"
className="cursor-default"
>
<span
className="w-2 h-2 rounded-full inline-block mr-1"
style={{ backgroundColor: tag.color }}
aria-hidden="true"
/>
{tag.name}
</Badge>
<button
onClick={() => handleUnassign(tag.id)}
className="ml-1 text-secondary-400 hover:text-danger-600 min-h-touch min-w-touch"
aria-label={t('tags.removeTag')}
disabled={submitting}
>
<X className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
</button>
</div>
))}
</div>
)}
</div>
{/* Search and assign */}
<div className="space-y-3">
<Input
label={t('tags.search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('tags.search')}
/>
{filteredTags.length > 0 && (
<div className="flex flex-wrap gap-2" role="list" aria-label={t('tags.availableTags')} data-testid="available-tags-list">
{filteredTags.map((tag) => (
<button
key={tag.id}
onClick={() => handleAssign(tag.id)}
disabled={submitting}
className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-700 hover:bg-primary-100 hover:text-primary-700 motion-safe:transition-colors min-h-touch disabled:opacity-50"
aria-label={t('tags.addTag')}
>
<span
className="w-2 h-2 rounded-full inline-block"
style={{ backgroundColor: tag.color }}
aria-hidden="true"
/>
{tag.name}
</button>
))}
</div>
)}
{searchQuery && filteredTags.length === 0 && !loading && (
<p className="text-sm text-secondary-500">{t('tags.noTags')}</p>
)}
{/* Create new tag */}
{!showCreateForm ? (
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateForm(true)}
>
{t('tags.create')}
</Button>
) : (
<div className="space-y-3 p-4 border border-secondary-200 rounded-lg" data-testid="create-tag-form">
<form onSubmit={handleSubmitTag(handleCreateTag)}>
<Input
label={t('tags.tagName')}
{...registerTag('name')}
error={tagErrors.name?.message === 'required' ? t('validation.required') : undefined}
placeholder={t('tags.tagName')}
/>
<div className="space-y-1">
<label className="block text-sm font-medium text-secondary-700">{t('tags.tagColor')}</label>
<div className="flex items-center gap-2">
{colorOptions.map((color) => (
<button
key={color}
type="button"
onClick={() => setTagValue('color', color)}
className={clsx(
'w-6 h-6 rounded-full transition-transform',
newTagColor === color ? 'ring-2 ring-offset-2 ring-secondary-400 scale-110' : ''
)}
style={{ backgroundColor: color }}
aria-label={`${t('tags.tagColor')}: ${color}`}
aria-pressed={newTagColor === color}
/>
))}
</div>
</div>
<div className="flex gap-2">
<Button size="sm" type="submit" isLoading={submitting}>{t('tags.save')}</Button>
<Button variant="secondary" size="sm" type="button" onClick={() => setShowCreateForm(false)}>{t('tags.cancel')}</Button>
</div>
</form>
</div>
)}
</div>
</div>
);
}
-152
View File
@@ -1,152 +0,0 @@
/**
* GoalView — goal overview with progress bar and milestone hierarchy.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
import { useTask, useListSubtasks, type Task, type TaskStatus } from '@/api/tasks';
import { Loader2, Target, Flag } from 'lucide-react';
const STATUS_VARIANTS: Record<TaskStatus, 'secondary' | 'info' | 'warning' | 'success' | 'danger'> = {
open: 'secondary',
in_progress: 'info',
review: 'warning',
blocked: 'danger',
done: 'success',
cancelled: 'secondary',
};
function statusLabel(t: (k: string) => string, status: TaskStatus): string {
const map: Record<TaskStatus, string> = {
open: t('tasks.statusOpen'),
in_progress: t('tasks.statusInProgress'),
review: t('tasks.statusReview'),
blocked: t('tasks.statusBlocked'),
done: t('tasks.statusDone'),
cancelled: t('tasks.statusCancelled'),
};
return map[status];
}
interface GoalViewProps {
goalId: string;
onSelectTask?: (task: Task) => void;
}
export function GoalView({ goalId, onSelectTask }: GoalViewProps) {
const { t } = useTranslation();
const { data: goal, isLoading } = useTask(goalId);
const { data: children } = useListSubtasks(goalId);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12" role="status">
<Loader2 className="h-6 w-6 animate-spin text-primary-500" aria-hidden="true" />
<span className="sr-only">{t('common.loading')}</span>
</div>
);
}
if (!goal) {
return (
<Card title={t('tasks.title')}>
<p className="text-sm text-gray-500">{t('tasks.noTasks')}</p>
</Card>
);
}
const progress = goal.progress ?? 0;
const milestones = (children ?? []).filter((c) => c.task_type === 'milestone');
const todos = (children ?? []).filter((c) => c.task_type !== 'milestone');
return (
<Card title={goal.title}>
<div className="space-y-4">
{goal.description ? <p className="text-sm text-gray-700">{goal.description}</p> : null}
<div className="flex items-center gap-2">
<Badge variant={STATUS_VARIANTS[goal.status]}>{statusLabel(t, goal.status)}</Badge>
<Badge variant="primary">{progress}%</Badge>
{goal.target_date ? (
<Badge variant="info">
{t('tasks.targetDate')}: {new Date(goal.target_date).toLocaleDateString()}
</Badge>
) : null}
</div>
{/* Progress bar */}
<div>
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span className="inline-flex items-center gap-1">
<Target className="h-3 w-3" aria-hidden="true" />
{t('tasks.progress')}
</span>
<span>{progress}%</span>
</div>
<div
className="h-2 w-full overflow-hidden rounded-full bg-gray-200"
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="h-full rounded-full bg-primary-500 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Milestones */}
{milestones.length > 0 ? (
<div>
<h4 className="mb-2 flex items-center gap-1 text-sm font-semibold text-gray-700">
<Flag className="h-4 w-4" aria-hidden="true" />
{t('tasks.milestones')}
</h4>
<div className="space-y-1">
{milestones.map((m) => (
<button
key={m.id}
type="button"
onClick={() => onSelectTask?.(m)}
className="flex w-full items-center justify-between rounded border border-gray-200 px-3 py-2 text-left hover:bg-gray-50"
>
<span className="text-sm text-gray-800">{m.title}</span>
<span className="flex items-center gap-2">
<span className="text-xs text-gray-500">{m.progress ?? 0}%</span>
<Badge variant={STATUS_VARIANTS[m.status]}>{statusLabel(t, m.status)}</Badge>
</span>
</button>
))}
</div>
</div>
) : null}
{/* Todos */}
{todos.length > 0 ? (
<div>
<h4 className="mb-2 text-sm font-semibold text-gray-700">{t('tasks.subtasks')}</h4>
<div className="space-y-1">
{todos.map((todo) => (
<button
key={todo.id}
type="button"
onClick={() => onSelectTask?.(todo)}
className="flex w-full items-center justify-between rounded border border-gray-200 px-3 py-2 text-left hover:bg-gray-50"
>
<span className="text-sm text-gray-800">{todo.title}</span>
<Badge variant={STATUS_VARIANTS[todo.status]}>{statusLabel(t, todo.status)}</Badge>
</button>
))}
</div>
</div>
) : null}
</div>
</Card>
);
}
export default GoalView;
-140
View File
@@ -1,140 +0,0 @@
/**
* TaskBoard — Kanban view with columns by lifecycle status.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
import { useTasks, type Task, type TaskStatus, type TaskFilter } from '@/api/tasks';
import { Clock, AlertCircle, CheckCircle2, Loader2 } from 'lucide-react';
const STATUS_COLUMNS: TaskStatus[] = ['open', 'in_progress', 'review', 'blocked', 'done', 'cancelled'];
const STATUS_VARIANTS: Record<TaskStatus, 'secondary' | 'info' | 'warning' | 'success' | 'danger'> = {
open: 'secondary',
in_progress: 'info',
review: 'warning',
blocked: 'danger',
done: 'success',
cancelled: 'secondary',
};
const PRIORITY_VARIANTS: Record<string, 'secondary' | 'info' | 'warning' | 'danger'> = {
low: 'secondary',
medium: 'info',
high: 'warning',
urgent: 'danger',
};
function statusLabel(t: (k: string) => string, status: TaskStatus): string {
const map: Record<TaskStatus, string> = {
open: t('tasks.statusOpen'),
in_progress: t('tasks.statusInProgress'),
review: t('tasks.statusReview'),
blocked: t('tasks.statusBlocked'),
done: t('tasks.statusDone'),
cancelled: t('tasks.statusCancelled'),
};
return map[status];
}
function isOverdue(dateStr: string | null, status: string): boolean {
if (!dateStr || status === 'done' || status === 'cancelled') return false;
try {
return new Date(dateStr) < new Date();
} catch {
return false;
}
}
interface TaskCardProps {
task: Task;
onSelect: (task: Task) => void;
}
function TaskCard({ task, onSelect }: TaskCardProps) {
const { t } = useTranslation();
const overdue = isOverdue(task.due_date, task.status);
return (
<button
type="button"
onClick={() => onSelect(task)}
className="w-full text-left rounded-lg border border-gray-200 bg-white p-3 shadow-sm hover:shadow-md transition-shadow focus:outline-none focus:ring-2 focus:ring-primary-500"
aria-label={task.title}
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium text-gray-900 line-clamp-2">{task.title}</span>
<Badge variant={PRIORITY_VARIANTS[task.priority] ?? 'secondary'}>{t(`tasks.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`)}</Badge>
</div>
{task.description ? (
<p className="mt-1 text-xs text-gray-500 line-clamp-2">{task.description}</p>
) : null}
<div className="mt-2 flex items-center gap-3 text-xs text-gray-500">
{task.due_date ? (
<span className={`inline-flex items-center gap-1 ${overdue ? 'text-danger-600' : ''}`}>
<Clock className="h-3 w-3" aria-hidden="true" />
{new Date(task.due_date).toLocaleDateString()}
</span>
) : null}
{task.task_type !== 'todo' ? (
<Badge variant="info">{t(`tasks.type${task.task_type.charAt(0).toUpperCase() + task.task_type.slice(1)}`)}</Badge>
) : null}
{task.progress > 0 ? (
<span className="inline-flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
{task.progress}%
</span>
) : null}
</div>
</button>
);
}
interface TaskBoardProps {
filter?: TaskFilter;
onSelectTask?: (task: Task) => void;
}
export function TaskBoard({ filter, onSelectTask }: TaskBoardProps) {
const { t } = useTranslation();
const { data, isLoading } = useTasks(1, 200, filter);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12" role="status">
<Loader2 className="h-6 w-6 animate-spin text-primary-500" aria-hidden="true" />
<span className="sr-only">{t('common.loading')}</span>
</div>
);
}
const tasks = data?.items ?? [];
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{STATUS_COLUMNS.map((status) => {
const columnTasks = tasks.filter((task) => task.status === status);
return (
<div key={status} className="flex flex-col rounded-lg bg-gray-50 p-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">{statusLabel(t, status)}</h3>
<Badge variant={STATUS_VARIANTS[status]}>{columnTasks.length}</Badge>
</div>
<div className="flex flex-col gap-2">
{columnTasks.length === 0 ? (
<p className="text-xs text-gray-400">{t('tasks.noTasks')}</p>
) : (
columnTasks.map((task) => (
<TaskCard key={task.id} task={task} onSelect={onSelectTask ?? (() => {})} />
))
)}
</div>
</div>
);
})}
</div>
);
}
export default TaskBoard;
-24
View File
@@ -1,24 +0,0 @@
import { useAuthStore } from '@/store/authStore';
import { useSwitchTenant } from '@/api/hooks';
export function useTenant() {
const { user, currentTenant, setTenant } = useAuthStore();
const switchTenantMutation = useSwitchTenant();
const availableTenants = user?.tenants ?? [];
const switchTenant = async (tenantId: string) => {
try {
await switchTenantMutation.mutateAsync(tenantId);
} catch (error) {
console.error('Failed to switch tenant:', error);
}
};
return {
currentTenant,
availableTenants,
switchTenant,
isSwitching: switchTenantMutation.isPending,
};
}