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
Check Cross-Plugin Imports / check (push) Has been cancelled
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user