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

This commit is contained in:
Agent Zero
2026-08-22 07:37:11 +02:00
parent 40cc99af5c
commit db4701bae7
30 changed files with 5 additions and 5076 deletions
-57
View File
@@ -1,57 +0,0 @@
"""Outbox delivery model for per-consumer delivery tracking."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
class OutboxDelivery(Base):
"""Tracks per-consumer delivery status for outbox events.
Each row represents one consumer (event handler) processing one outbox
event. An event is only fully 'published' when all mandatory deliveries
succeed.
"""
__tablename__ = "outbox_deliveries"
__table_args__ = (
UniqueConstraint("event_id", "consumer_name", name="uq_outbox_deliveries_event_consumer"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
primary_key=True,
server_default=func.gen_random_uuid(),
)
event_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("event_outbox.id", ondelete="CASCADE"),
nullable=False,
)
consumer_name: Mapped[str] = mapped_column(String(150), nullable=False)
status: Mapped[str] = mapped_column(
String(30), nullable=False, server_default="pending",
)
attempt_count: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0",
)
next_attempt_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
processed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
@@ -1,94 +0,0 @@
"""Integrates external MCP server tools into the AI Assistant tool_registry.
When the MCP Client plugin activates, it loads all enabled MCP server configs,
fetches their tool lists, and registers each tool in the global ToolRegistry.
Agents can then call external MCP tools like native tools.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
from app.plugins.builtins.mcp_client.client import McpClient
from app.plugins.builtins.mcp_client.models import McpServerConfig as McpServerConfigModel
logger = logging.getLogger(__name__)
PLUGIN_NAME = "mcp_client"
def _make_tool_name(server_name: str, tool_name: str) -> str:
"""Generate a unique tool name: mcp__{server}__{tool}."""
safe_server = server_name.replace(" ", "_").replace("-", "_").lower()
return f"mcp__{safe_server}__{tool_name}"
def _make_handler(server_cfg: McpServerConfigModel, tool_name: str):
"""Create an async handler that calls the external MCP server."""
async def _handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
client = McpClient(base_url=server_cfg.url, api_token=server_cfg.api_token)
try:
resp = await client.execute_tool(tool_name, arguments)
if resp.success:
import json
result = resp.result if resp.result is not None else {}
return json.dumps(result) if isinstance(result, dict) else str(result)
return f"Error: {resp.error or 'Unknown error'}"
except Exception as exc:
logger.exception("MCP tool execution failed: %s/%s", server_cfg.name, tool_name)
return f"Error: {exc}"
return _handler
async def sync_external_tools(db: AsyncSession, tenant_id: uuid.UUID) -> int:
"""Fetch tools from all enabled MCP servers and register them in the tool registry.
Returns the number of tools registered.
"""
registry = get_tool_registry()
# Unregister previous tools from this plugin
registry.unregister_plugin(PLUGIN_NAME)
stmt = select(McpServerConfigModel).where(
McpServerConfigModel.tenant_id == tenant_id,
McpServerConfigModel.enabled.is_(True),
)
result = await db.execute(stmt)
configs = result.scalars().all()
count = 0
for cfg in configs:
try:
client = McpClient(base_url=cfg.url, api_token=cfg.api_token, timeout=10.0)
tools_resp = await client.list_tools()
for tool in tools_resp.tools:
tool_name = _make_tool_name(cfg.name, tool.name)
registry.register(
name=tool_name,
description=f"[MCP:{cfg.name}] {tool.description}",
parameters=tool.parameters if isinstance(tool.parameters, dict) else {},
handler=_make_handler(cfg, tool.name),
plugin_name=PLUGIN_NAME,
required_permission="mcp-client:read",
category="mcp-external",
)
count += 1
logger.info("Registered %d tools from MCP server %s", len(tools_resp.tools), cfg.name)
except Exception as exc:
logger.warning("Failed to sync tools from MCP server %s: %s", cfg.name, exc)
return count
def unregister_all_external_tools() -> None:
"""Remove all MCP client tools from the registry."""
registry = get_tool_registry()
registry.unregister_plugin(PLUGIN_NAME)
-215
View File
@@ -1,215 +0,0 @@
"""Plugin quarantine — extract, validate, and install external plugins safely.
Workflow:
1. Extract ZIP to a temporary directory
2. Validate manifest exists and is valid
3. Check for dangerous imports
4. Validate migration SQL
5. Verify signature (if provided)
6. If all checks pass: move to plugins/ directory
7. If any check fails: delete temp directory and raise error
Usage::
from app.plugins.quarantine import quarantine_plugin
plugin_dir = await quarantine_plugin(
zip_path=Path("plugin.zip"),
signature=b"...",
public_key=b"...",
)
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from app.plugins.signature import PluginSignature
logger = logging.getLogger(__name__)
# Maximum plugin ZIP size (50 MB)
MAX_PLUGIN_SIZE = 50 * 1024 * 1024
# Dangerous patterns in plugin source code
DANGEROUS_PATTERNS = [
(r"\bos\.system\b", "os.system call"),
(r"\bsubprocess\.", "subprocess module"),
(r"\beval\s*\(", "eval() call"),
(r"\bexec\s*\(", "exec() call"),
(r"\b__import__\s*\(", "__import__() call"),
(r"\bcompile\s*\(", "compile() call"),
(r"\bopen\s*\([^)]*['\"]w['\"]", "file write outside DMS"),
]
class QuarantineError(Exception):
"""Raised when a plugin fails quarantine validation."""
def _validate_manifest(plugin_dir: Path) -> dict:
"""Validate that the plugin has a valid manifest.
Returns the parsed manifest data.
"""
plugin_py = plugin_dir / "plugin.py"
init_py = plugin_dir / "__init__.py"
if not plugin_py.exists() and not init_py.exists():
raise QuarantineError("Plugin must have plugin.py or __init__.py")
# Read source and look for manifest
source_file = plugin_py if plugin_py.exists() else init_py
source = source_file.read_text(encoding="utf-8")
if "PluginManifest" not in source:
raise QuarantineError("Plugin source must define a PluginManifest")
if "BasePlugin" not in source:
raise QuarantineError("Plugin source must inherit from BasePlugin")
return {"source_file": str(source_file), "has_manifest": True}
def _check_dangerous_imports(plugin_dir: Path) -> list[str]:
"""Check plugin source for dangerous imports/patterns.
Returns a list of dangerous patterns found (empty if safe).
"""
found: list[str] = []
for py_file in plugin_dir.rglob("*.py"):
source = py_file.read_text(encoding="utf-8")
for pattern, description in DANGEROUS_PATTERNS:
if re.search(pattern, source):
found.append(f"{py_file.name}: {description}")
return found
def _check_migration_sql(plugin_dir: Path) -> list[str]:
"""Validate migration SQL files in the plugin.
Returns a list of issues found (empty if OK).
"""
issues: list[str] = []
migrations_dir = plugin_dir / "migrations"
if not migrations_dir.exists():
return issues # No migrations is OK
for sql_file in migrations_dir.glob("*.sql"):
content = sql_file.read_text(encoding="utf-8")
# Check for tenant_id in CREATE TABLE
if "CREATE TABLE" in content.upper() and "tenant_id" not in content.lower():
issues.append(
f"{sql_file.name}: CREATE TABLE without tenant_id column"
)
# Check for DROP DATABASE / DROP SCHEMA
if "DROP DATABASE" in content.upper() or "DROP SCHEMA" in content.upper():
issues.append(f"{sql_file.name}: Contains DROP DATABASE/SCHEMA")
return issues
async def quarantine_plugin(
zip_path: Path,
signature: bytes | None = None,
public_key: bytes | None = None,
plugins_dir: Path | None = None,
) -> Path:
"""Extract, validate, and install a plugin from a ZIP file.
Args:
zip_path: Path to the plugin ZIP file.
signature: Optional Ed25519 signature bytes.
public_key: Optional Ed25519 public key bytes.
plugins_dir: Target directory for external plugins (default: plugins/).
Returns:
Path to the installed plugin directory.
Raises:
QuarantineError: If any validation check fails.
"""
# Check file size
file_size = zip_path.stat().st_size # noqa: ASYNC240
if file_size > MAX_PLUGIN_SIZE:
raise QuarantineError(
f"Plugin ZIP too large: {file_size} bytes (max {MAX_PLUGIN_SIZE})"
)
# Verify signature if provided
if signature and public_key:
if not PluginSignature.verify_signature(zip_path, signature, public_key):
raise QuarantineError("Signature verification failed")
# Create temp directory for extraction
temp_dir = Path(tempfile.mkdtemp(prefix="plugin_quarantine_"))
try:
# Extract ZIP
with zipfile.ZipFile(zip_path, "r") as zf:
# Check for path traversal in ZIP entries
for entry in zf.namelist():
if entry.startswith("/") or ".." in entry:
raise QuarantineError(f"Unsafe ZIP entry: {entry}")
zf.extractall(temp_dir)
# Find the plugin directory (might be nested)
plugin_dir = temp_dir
if not (plugin_dir / "plugin.py").exists() and not (plugin_dir / "__init__.py").exists():
# Look for a single subdirectory
subdirs = [d for d in plugin_dir.iterdir() if d.is_dir() and not d.name.startswith("_")]
if len(subdirs) == 1:
plugin_dir = subdirs[0]
else:
raise QuarantineError("Could not find plugin root directory in ZIP")
# 1. Validate manifest
_validate_manifest(plugin_dir)
logger.info("Manifest validated for plugin in %s", plugin_dir.name)
# 2. Check dangerous imports
dangerous = _check_dangerous_imports(plugin_dir)
if dangerous:
raise QuarantineError(
f"Dangerous patterns found in plugin: {', '.join(dangerous)}"
)
# 3. Check migration SQL
sql_issues = _check_migration_sql(plugin_dir)
if sql_issues:
raise QuarantineError(
f"Migration SQL issues: {', '.join(sql_issues)}"
)
# 4. All checks passed — move to plugins directory
target_dir = plugins_dir or Path(os.environ.get("EXTERNAL_PLUGINS_PATH", "plugins"))
target_dir.mkdir(parents=True, exist_ok=True)
plugin_name = plugin_dir.name
final_dir = target_dir / plugin_name
if final_dir.exists():
raise QuarantineError(f"Plugin directory already exists: {final_dir}")
shutil.copytree(plugin_dir, final_dir)
logger.info("Plugin installed to %s", final_dir)
return final_dir
except Exception:
# Clean up temp directory on any error
shutil.rmtree(temp_dir, ignore_errors=True)
raise
finally:
# Always clean up temp directory
if temp_dir.exists(): # noqa: ASYNC240
shutil.rmtree(temp_dir, ignore_errors=True)
@@ -1,172 +0,0 @@
"""CustomFieldDefinition service — CRUD with tenant isolation and visibility filter."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.visibility import apply_visibility_filter, check_single_entity_access
from app.models.custom_field_definition import CustomFieldDefinition
def _definition_to_dict(d: CustomFieldDefinition) -> dict[str, Any]:
"""Serialize a CustomFieldDefinition ORM object to dict."""
return {
"id": str(d.id),
"entity": d.entity,
"name": d.name,
"label": d.label,
"field_type": d.field_type,
"options": d.options,
"default_value": d.default_value,
"required": d.required,
"is_active": d.is_active,
"sort_order": d.sort_order,
"owner_id": str(d.owner_id) if d.owner_id else None,
"created_by": str(d.created_by) if d.created_by else None,
"updated_by": str(d.updated_by) if d.updated_by else None,
}
async def list_custom_field_definitions(
db: AsyncSession,
tenant_id: uuid.UUID,
entity: str | None = None,
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> list[dict[str, Any]]:
"""List custom field definitions for a tenant, optionally filtered by entity."""
q = select(CustomFieldDefinition).where(
CustomFieldDefinition.tenant_id == tenant_id,
CustomFieldDefinition.is_active == True, # noqa: E712
)
if entity:
q = q.where(CustomFieldDefinition.entity == entity)
if user_id and not is_system_admin:
q = await apply_visibility_filter(
db, q, "custom_field_definition", CustomFieldDefinition, user_id, tenant_id, is_system_admin
)
q = q.order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.name)
result = await db.execute(q)
return [_definition_to_dict(d) for d in result.scalars().all()]
async def get_custom_field_definition(
db: AsyncSession,
tenant_id: uuid.UUID,
definition_id: uuid.UUID,
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> dict[str, Any] | None:
"""Get a single custom field definition by ID."""
q = select(CustomFieldDefinition).where(
CustomFieldDefinition.id == definition_id,
CustomFieldDefinition.tenant_id == tenant_id,
)
result = await db.execute(q)
definition = result.scalar_one_or_none()
if definition is None:
return None
if user_id and not is_system_admin:
has_access = await check_single_entity_access(
db, "custom_field_definition", definition.id, user_id, tenant_id, "read", is_system_admin
)
if not has_access:
raise PermissionError("No access")
return _definition_to_dict(definition)
async def create_custom_field_definition(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new custom field definition."""
definition = CustomFieldDefinition(
tenant_id=tenant_id,
entity=data["entity"],
name=data["name"],
label=data["label"],
field_type=data["field_type"],
options=data.get("options"),
default_value=data.get("default_value"),
required=data.get("required", False),
is_active=data.get("is_active", True),
sort_order=data.get("sort_order", 0),
created_by=user_id,
updated_by=user_id,
owner_id=user_id,
)
db.add(definition)
await db.flush()
await db.refresh(definition)
return _definition_to_dict(definition)
async def update_custom_field_definition(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
definition_id: uuid.UUID,
data: dict[str, Any],
is_system_admin: bool = False,
) -> dict[str, Any] | None:
"""Update an existing custom field definition."""
q = select(CustomFieldDefinition).where(
CustomFieldDefinition.id == definition_id,
CustomFieldDefinition.tenant_id == tenant_id,
)
result = await db.execute(q)
definition = result.scalar_one_or_none()
if definition is None:
return None
if not is_system_admin:
has_access = await check_single_entity_access(
db, "custom_field_definition", definition.id, user_id, tenant_id, "write", is_system_admin
)
if not has_access:
raise PermissionError("No access")
update_fields = ["label", "field_type", "options", "default_value", "required", "is_active", "sort_order"]
for field in update_fields:
if field in data:
setattr(definition, field, data[field])
definition.updated_by = user_id
await db.flush()
await db.refresh(definition)
return _definition_to_dict(definition)
async def delete_custom_field_definition(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
definition_id: uuid.UUID,
is_system_admin: bool = False,
) -> bool:
"""Delete a custom field definition."""
q = select(CustomFieldDefinition).where(
CustomFieldDefinition.id == definition_id,
CustomFieldDefinition.tenant_id == tenant_id,
)
result = await db.execute(q)
definition = result.scalar_one_or_none()
if definition is None:
return False
if not is_system_admin:
has_access = await check_single_entity_access(
db, "custom_field_definition", definition.id, user_id, tenant_id, "admin", is_system_admin
)
if not has_access:
raise PermissionError("No access")
await db.delete(definition)
await db.flush()
return True
-187
View File
@@ -1,187 +0,0 @@
"""Plugin install service — business logic for plugin installation from ZIP/URL."""
from __future__ import annotations
import importlib
import logging
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Any
from app.plugins.base import BasePlugin
from app.services.plugin_service import get_plugin_service
logger = logging.getLogger(__name__)
class PluginInstallService:
"""Service layer for plugin installation operations.
Handles ZIP extraction, security validation, and plugin registration.
"""
@staticmethod
def validate_manifest_name(name: str) -> str:
"""Validate plugin name is alphanumeric with underscores only."""
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", name):
raise ValueError(
f"Invalid plugin name '{name}': must start with a letter and contain only "
f"alphanumeric characters and underscores"
)
return name
@staticmethod
def check_dangerous_imports(source_code: str) -> list[str]:
"""Check plugin source for dangerous imports/patterns.
Returns a list of dangerous patterns found (empty if safe).
"""
dangerous_patterns = [
(r"\bos\.system\b", "os.system call"),
(r"\bsubprocess\.", "subprocess module"),
(r"\beval\s*\(", "eval() call"),
(r"\bexec\s*\(", "exec() call"),
(r"\b__import__\s*\(", "__import__() call"),
(r"\bcompile\s*\(", "compile() call"),
]
found: list[str] = []
for pattern, description in dangerous_patterns:
if re.search(pattern, source_code):
found.append(description)
return found
@staticmethod
def check_migration_sql(sql_content: str) -> list[str]:
"""Basic SQL validation for migration files.
Returns a list of issues found (empty if OK).
"""
issues: list[str] = []
lines = sql_content.strip().split("\n")
for i, line in enumerate(lines, 1):
stripped = line.strip()
if not stripped or stripped.startswith("--"):
continue
if stripped.count("(") != stripped.count(")"):
issues.append(f"Line {i}: unbalanced parentheses")
if re.search(r"\bDROP\s+TABLE\b", stripped, re.IGNORECASE):
issues.append(f"Line {i}: DROP TABLE is not allowed in plugin migrations")
return issues
@staticmethod
def find_plugin_class_in_module(module: Any) -> type[BasePlugin] | None:
"""Find a BasePlugin subclass in a module."""
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, BasePlugin)
and attr is not BasePlugin
):
return attr
return None
@staticmethod
def extract_plugin_from_zip(zip_path: str) -> tuple[Path, str, type[BasePlugin]]:
"""Extract a ZIP file and find the plugin class.
Returns (extract_dir, plugin_name, plugin_class).
"""
extract_dir = Path(tempfile.mkdtemp(prefix="plugin_upload_"))
try:
with zipfile.ZipFile(zip_path, "r") as zf:
bad_files = [f for f in zf.namelist() if f.startswith("..") or f.startswith("/")]
if bad_files:
raise ValueError(f"ZIP contains files with unsafe paths: {bad_files}")
zf.extractall(extract_dir)
plugin_py_path: Path | None = None
for fpath in extract_dir.rglob("plugin.py"):
plugin_py_path = fpath
break
if plugin_py_path is None:
raise ValueError("ZIP does not contain a plugin.py file")
source_code = plugin_py_path.read_text(encoding="utf-8")
dangerous = PluginInstallService.check_dangerous_imports(source_code)
if dangerous:
raise ValueError(
f"Plugin contains dangerous patterns: {', '.join(dangerous)}"
)
spec = importlib.util.spec_from_file_location(
"uploaded_plugin", plugin_py_path
)
if spec is None or spec.loader is None:
raise ValueError("Could not load plugin.py module")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
plugin_class = PluginInstallService.find_plugin_class_in_module(module)
if plugin_class is None:
raise ValueError(
"plugin.py does not contain a BasePlugin subclass"
)
plugin_instance = plugin_class()
plugin_name = plugin_instance.name
manifest = plugin_instance.manifest
if not manifest.name or not manifest.version or not manifest.display_name:
raise ValueError(
"Plugin manifest must include name, version, and display_name"
)
PluginInstallService.validate_manifest_name(manifest.name)
migrations_dir = plugin_py_path.parent / "migrations"
if migrations_dir.exists():
for sql_file in sorted(migrations_dir.glob("*.sql")):
sql_content = sql_file.read_text(encoding="utf-8")
issues = PluginInstallService.check_migration_sql(sql_content)
if issues:
raise ValueError(
f"Migration file {sql_file.name} has issues: {'; '.join(issues)}"
)
return extract_dir, plugin_name, plugin_class
except Exception:
shutil.rmtree(extract_dir, ignore_errors=True)
raise
@staticmethod
def install_plugin_from_dir(
extract_dir: Path,
plugin_name: str,
plugin_class: type[BasePlugin],
) -> None:
"""Copy plugin directory to builtins and register it.
Copies the extracted plugin directory to app/plugins/builtins/{plugin_name}/.
"""
builtins_dir = Path(__file__).parent.parent / "plugins" / "builtins" / plugin_name
builtins_dir.mkdir(parents=True, exist_ok=True)
for item in extract_dir.iterdir():
dest = builtins_dir / item.name
if item.is_dir():
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)
registry = get_plugin_service().registry
instance = plugin_class()
registry.register_plugin(instance)
logger.info(
"Installed plugin '%s' from uploaded ZIP to %s",
plugin_name,
builtins_dir,
)
-162
View File
@@ -1,162 +0,0 @@
"""SavedView service — CRUD with tenant isolation and visibility filter."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.visibility import apply_visibility_filter, check_single_entity_access
from app.models.saved_view import SavedView
def _view_to_dict(v: SavedView) -> dict[str, Any]:
"""Serialize a SavedView ORM object to dict."""
return {
"id": str(v.id),
"name": v.name,
"entity_type": v.entity_type,
"view_config": v.view_config,
"user_id": str(v.user_id),
"created_at": v.created_at.isoformat() if v.created_at else None,
"updated_at": v.updated_at.isoformat() if v.updated_at else None,
}
async def list_saved_views(
db: AsyncSession,
tenant_id: uuid.UUID,
entity_type: str | None = None,
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> list[dict[str, Any]]:
"""List saved views for a tenant, optionally filtered by entity_type."""
q = select(SavedView).where(
SavedView.tenant_id == tenant_id,
SavedView.deleted_at.is_(None),
)
if entity_type:
q = q.where(SavedView.entity_type == entity_type)
if user_id and not is_system_admin:
q = await apply_visibility_filter(
db, q, "saved_view", SavedView, user_id, tenant_id, is_system_admin
)
q = q.order_by(SavedView.name)
result = await db.execute(q)
return [_view_to_dict(v) for v in result.scalars().all()]
async def get_saved_view(
db: AsyncSession,
tenant_id: uuid.UUID,
view_id: uuid.UUID,
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> dict[str, Any] | None:
"""Get a single saved view by ID."""
q = select(SavedView).where(
SavedView.id == view_id,
SavedView.tenant_id == tenant_id,
SavedView.deleted_at.is_(None),
)
result = await db.execute(q)
saved = result.scalar_one_or_none()
if saved is None:
return None
if user_id and not is_system_admin:
has_access = await check_single_entity_access(
db, "saved_view", saved.id, user_id, tenant_id, "read", is_system_admin
)
if not has_access:
raise PermissionError("No access")
return _view_to_dict(saved)
async def create_saved_view(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new saved view."""
saved = SavedView(
tenant_id=tenant_id,
user_id=user_id,
name=data["name"],
entity_type=data["entity_type"],
view_config=data.get("view_config", {}),
owner_id=user_id,
)
db.add(saved)
await db.flush()
await db.refresh(saved)
return _view_to_dict(saved)
async def update_saved_view(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
view_id: uuid.UUID,
data: dict[str, Any],
is_system_admin: bool = False,
) -> dict[str, Any] | None:
"""Update a saved view."""
q = select(SavedView).where(
SavedView.id == view_id,
SavedView.tenant_id == tenant_id,
SavedView.deleted_at.is_(None),
)
result = await db.execute(q)
saved = result.scalar_one_or_none()
if saved is None:
return None
if not is_system_admin:
has_access = await check_single_entity_access(
db, "saved_view", saved.id, user_id, tenant_id, "write", is_system_admin
)
if not has_access:
raise PermissionError("No access")
if "name" in data and data["name"] is not None:
saved.name = data["name"]
if "view_config" in data and data["view_config"] is not None:
saved.view_config = data["view_config"]
await db.flush()
await db.refresh(saved)
return _view_to_dict(saved)
async def delete_saved_view(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
view_id: uuid.UUID,
is_system_admin: bool = False,
) -> bool:
"""Soft-delete a saved view."""
q = select(SavedView).where(
SavedView.id == view_id,
SavedView.tenant_id == tenant_id,
SavedView.deleted_at.is_(None),
)
result = await db.execute(q)
saved = result.scalar_one_or_none()
if saved is None:
return False
if not is_system_admin:
has_access = await check_single_entity_access(
db, "saved_view", saved.id, user_id, tenant_id, "admin", is_system_admin
)
if not has_access:
raise PermissionError("No access")
saved.deleted_at = datetime.now(UTC)
await db.flush()
return True
-118
View File
@@ -1,118 +0,0 @@
"""Onboarding workflow — runs on user creation.
A code-engine workflow that creates a welcome notification and an
approval step for admin confirmation of new users.
"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.workflow_service import create_instance
# Onboarding workflow definition (code-engine — hardcoded steps)
ONBOARDING_WORKFLOW_STEPS = [
{
"name": "Send welcome notification",
"type": "notification",
"config": {
"notification_type": "welcome",
"title": "Welcome to LeoCRM!",
"body": "Your account has been created. An admin will approve your access shortly.",
},
"description": "Send a welcome notification to the new user",
},
{
"name": "Admin approval",
"type": "approval",
"config": {
"required_role": "admin",
"description": "Admin must approve the new user account",
},
"description": "Admin reviews and approves the new user",
},
{
"name": "Send confirmation",
"type": "notification",
"config": {
"notification_type": "onboarding_complete",
"title": "Account approved",
"body": "Your account has been approved. You can now use LeoCRM.",
},
"description": "Notify user that their account is approved",
},
]
def get_onboarding_workflow_definition() -> dict[str, Any]:
"""Return the onboarding workflow definition for DB seeding."""
return {
"name": "User Onboarding",
"description": "Automated onboarding workflow triggered on user creation",
"trigger_event": "user.created",
"steps": ONBOARDING_WORKFLOW_STEPS,
"is_active": True,
}
async def ensure_onboarding_workflow_exists(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> uuid.UUID | None:
"""Ensure the onboarding workflow exists in the DB for this tenant.
If it doesn't exist yet, create it. Returns the workflow ID.
"""
from sqlalchemy import select
from app.models.workflow import Workflow
result = await db.execute(
select(Workflow).where(
Workflow.tenant_id == tenant_id,
Workflow.trigger_event == "user.created",
Workflow.name == "User Onboarding",
)
)
existing = result.scalar_one_or_none()
if existing:
return existing.id
from app.services.workflow_service import create_workflow
wf_dict = await create_workflow(
db,
tenant_id,
user_id,
get_onboarding_workflow_definition(),
)
return uuid.UUID(wf_dict["id"]) if wf_dict else None
async def trigger_onboarding(
db: AsyncSession,
tenant_id: uuid.UUID,
admin_user_id: uuid.UUID,
new_user_id: uuid.UUID,
) -> dict[str, Any] | None:
"""Trigger the onboarding workflow for a newly created user.
1. Ensure the onboarding workflow exists in DB
2. Create a workflow instance with new_user_id in context
"""
wf_id = await ensure_onboarding_workflow_exists(db, tenant_id, admin_user_id)
if wf_id is None:
return None
instance = await create_instance(
db,
tenant_id,
admin_user_id,
str(wf_id),
context={"new_user_id": str(new_user_id), "user_id": str(new_user_id)},
)
return instance
+4 -2
View File
@@ -19,7 +19,7 @@ export default defineConfig({
},
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:5173',
baseURL: process.env.BASE_URL ?? 'https://crm.media-on.de',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
@@ -35,11 +35,13 @@ export default defineConfig({
],
webServer: process.env.CI
? undefined
: process.env.BASE_URL
? undefined
: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
reuseExistingServer: true,
timeout: 60_000,
},
});
-289
View File
@@ -1,289 +0,0 @@
import { asError } from '@/utils/errorTypes';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client';
interface Address {
id: string;
entity_type: string;
entity_id: string;
label: string;
address_type: string;
street: string | null;
street_number: string | null;
city: string | null;
zip: string | null;
state: string | null;
country: string | null;
is_default: boolean;
created_at: string | null;
updated_at: string | null;
}
interface AddressListProps {
entityType: 'company' | 'contact';
entityId: string;
}
const ADDRESS_TYPE_COLORS: Record<string, string> = {
billing: 'bg-blue-100 text-blue-800',
shipping: 'bg-purple-100 text-purple-800',
headquarters: 'bg-green-100 text-green-800',
branch: 'bg-yellow-100 text-yellow-800',
private: 'bg-pink-100 text-pink-800',
other: 'bg-gray-100 text-gray-800',
};
export function AddressList({ entityType, entityId }: AddressListProps) {
const { t } = useTranslation();
const [addresses, setAddresses] = useState<Address[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [editingAddress, setEditingAddress] = useState<Address | null>(null);
const fetchAddresses = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await apiGet<{ items: Address[]; total: number }>(
`/addresses?entity_type=${entityType}&entity_id=${entityId}`
);
setAddresses(data.items);
} catch (err: unknown) { const errObj = asError(err);
setError(errObj.message || t('common.error'));
} finally {
setLoading(false);
}
}, [entityType, entityId, t]);
React.useEffect(() => {
fetchAddresses();
}, [fetchAddresses]);
const handleDelete = async (id: string) => {
if (!window.confirm(t('address.confirmDelete'))) return;
try {
await apiDelete(`/addresses/${id}`);
await fetchAddresses();
} catch (err: unknown) { const errObj = asError(err);
setError(errObj.message || t('common.error'));
}
};
const handleSetDefault = async (id: string) => {
try {
await apiPatch(`/addresses/${id}`, { is_default: true });
await fetchAddresses();
} catch (err: unknown) { const errObj = asError(err);
setError(errObj.message || t('common.error'));
}
};
const handleSave = async (data: Partial<Address>) => {
try {
if (editingAddress) {
await apiPatch(`/addresses/${editingAddress.id}`, data);
} else {
await apiPost('/addresses', { ...data, entity_type: entityType, entity_id: entityId });
}
setShowForm(false);
setEditingAddress(null);
await fetchAddresses();
} catch (err: unknown) { const errObj = asError(err);
setError(errObj.message || t('common.error'));
}
};
if (loading) {
return <div className="text-secondary-500">{t('common.loading')}</div>;
}
return (
<div className="space-y-4" data-testid="address-list">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-secondary-900">{t('address.title')}</h3>
<button
onClick={() => { setEditingAddress(null); setShowForm(true); }}
className="px-3 py-1.5 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700"
data-testid="address-add-btn"
>
{t('address.addAddress')}
</button>
</div>
{error && (
<div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</div>
)}
{showForm && (
<AddressForm
address={editingAddress}
onSave={handleSave}
onCancel={() => { setShowForm(false); setEditingAddress(null); }}
/>
)}
{addresses.length === 0 && !showForm ? (
<p className="text-sm text-secondary-500">{t('address.noAddresses')}</p>
) : (
<div className="space-y-3">
{addresses.map((addr) => (
<div
key={addr.id}
className="p-4 border border-secondary-200 rounded-lg flex items-start justify-between"
data-testid={`address-item-${addr.id}`}
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium text-secondary-900">{addr.label}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${ADDRESS_TYPE_COLORS[addr.address_type] || ADDRESS_TYPE_COLORS.other}`}>
{t(`addressType.${addr.address_type}`)}
</span>
{addr.is_default && (
<span className="px-2 py-0.5 text-xs rounded-full bg-primary-100 text-primary-700">
{t('address.defaultAddress')}
</span>
)}
</div>
<div className="text-sm text-secondary-600">
{addr.street && <span>{addr.street}</span>}
{addr.street_number && <span> {addr.street_number}</span>}
{(addr.street || addr.street_number) && <br />}
{addr.zip && <span>{addr.zip} </span>}
{addr.city && <span>{addr.city}</span>}
{(addr.zip || addr.city) && <br />}
{addr.state && <span>{addr.state}, </span>}
{addr.country && <span>{addr.country}</span>}
</div>
</div>
<div className="flex items-center gap-2">
{!addr.is_default && (
<button
onClick={() => handleSetDefault(addr.id)}
className="text-xs text-primary-600 hover:underline"
>
{t('address.setDefault')}
</button>
)}
<button
onClick={() => { setEditingAddress(addr); setShowForm(true); }}
className="text-xs text-secondary-600 hover:underline"
>
{t('common.edit')}
</button>
<button
onClick={() => handleDelete(addr.id)}
className="text-xs text-red-600 hover:underline"
>
{t('common.delete')}
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
function AddressForm({
address,
onSave,
onCancel,
}: {
address: Address | null;
onSave: (data: Partial<Address>) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [label, setLabel] = useState(address?.label || '');
const [addressType, setAddressType] = useState(address?.address_type || 'headquarters');
const [street, setStreet] = useState(address?.street || '');
const [streetNumber, setStreetNumber] = useState(address?.street_number || '');
const [city, setCity] = useState(address?.city || '');
const [zip, setZip] = useState(address?.zip || '');
const [state, setState] = useState(address?.state || '');
const [country, setCountry] = useState(address?.country || '');
const [isDefault, setIsDefault] = useState(address?.is_default || false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({ label, address_type: addressType, street, street_number: streetNumber, city, zip, state, country, is_default: isDefault });
};
const addressTypes = ['billing', 'shipping', 'headquarters', 'branch', 'private', 'other'];
return (
<form onSubmit={handleSubmit} className="p-4 border border-secondary-200 rounded-lg space-y-3" data-testid="address-form">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.label')}</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.type')}</label>
<select
value={addressType}
onChange={(e) => setAddressType(e.target.value)}
className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm"
>
{addressTypes.map((type) => (
<option key={type} value={type}>{t(`addressType.${type}`)}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="block text-sm font-medium text-secondary-700">{t('address.street')}</label>
<input type="text" value={street} onChange={(e) => setStreet(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.streetNumber')}</label>
<input type="text" value={streetNumber} onChange={(e) => setStreetNumber(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.zip')}</label>
<input type="text" value={zip} onChange={(e) => setZip(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.city')}</label>
<input type="text" value={city} onChange={(e) => setCity(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.state')}</label>
<input type="text" value={state} onChange={(e) => setState(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('address.country')}</label>
<input type="text" value={country} onChange={(e) => setCountry(e.target.value)} maxLength={2} placeholder="DE" className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm font-medium text-secondary-700">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} className="rounded" />
{t('address.defaultAddress')}
</label>
</div>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={onCancel} className="px-3 py-1.5 text-sm font-medium text-secondary-700 bg-secondary-100 rounded-lg hover:bg-secondary-200">
{t('common.cancel')}
</button>
<button type="submit" className="px-3 py-1.5 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700">
{t('common.save')}
</button>
</div>
</form>
);
}
@@ -1,89 +0,0 @@
/**
* PWA Install Prompt — shows install button when PWA is installable (Task 5.24).
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Download, X } from 'lucide-react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
const DISMISS_KEY = 'leocrm_pwa_install_dismissed';
export function PWAInstallPrompt() {
const { t } = useTranslation();
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const dismissed = localStorage.getItem(DISMISS_KEY);
if (dismissed) return;
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setVisible(true);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const handleInstall = useCallback(async () => {
if (!deferredPrompt) return;
await deferredPrompt.prompt();
const choice = await deferredPrompt.userChoice;
if (choice.outcome === 'accepted') {
setVisible(false);
}
setDeferredPrompt(null);
}, [deferredPrompt]);
const handleDismiss = useCallback(() => {
localStorage.setItem(DISMISS_KEY, '1');
setVisible(false);
}, []);
if (!visible || !deferredPrompt) return null;
return (
<div
className="fixed bottom-4 right-4 z-50 bg-white rounded-lg shadow-lg border border-secondary-200 p-4 max-w-sm"
data-testid="pwa-install-prompt"
>
<div className="flex items-start gap-3">
<Download className="w-5 h-5 text-primary-600 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-secondary-900">{t('pwa.installTitle')}</p>
<p className="text-sm text-secondary-600 mt-1">{t('pwa.installDescription')}</p>
<div className="flex gap-2 mt-3">
<button
className="px-3 py-1.5 bg-primary-600 text-white rounded-md text-sm font-medium hover:bg-primary-700"
onClick={handleInstall}
data-testid="pwa-install-btn"
>
{t('pwa.install')}
</button>
<button
className="px-3 py-1.5 text-secondary-600 text-sm hover:bg-secondary-100 rounded-md"
onClick={handleDismiss}
data-testid="pwa-dismiss-btn"
>
{t('pwa.dismiss')}
</button>
</div>
</div>
<button
className="text-secondary-400 hover:text-secondary-600"
onClick={handleDismiss}
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}
-118
View File
@@ -1,118 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Undo2, X } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { useUndoLastAction } from '@/api/entityHistory';
interface UndoToastProps {
message: string;
onUndo: () => void;
onDismiss: () => void;
isUndoing: boolean;
}
/**
* Undo toast shown after delete actions.
* Auto-dismisses after 5 seconds and is manually dismissable.
*/
export function UndoToast({ message, onUndo, onDismiss, isUndoing }: UndoToastProps) {
const { t } = useTranslation();
return (
<div
className="fixed bottom-4 right-4 z-[100] flex items-center gap-3 p-4 rounded-lg shadow-lg border border-secondary-200 bg-white max-w-sm"
role="alert"
aria-live="polite"
data-testid="undo-toast"
>
<p className="flex-1 text-sm font-medium text-secondary-900">{message}</p>
<Button
variant="primary"
size="sm"
onClick={onUndo}
isLoading={isUndoing}
icon={<Undo2 className="w-4 h-4" />}
>
{t('undoToast.undo', 'Undo')}
</Button>
<button
onClick={onDismiss}
className="flex-shrink-0 text-secondary-400 hover:text-secondary-700 min-h-touch min-w-touch flex items-center justify-center rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('common.dismiss', 'Schließen')}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
);
}
interface UndoToastState {
message: string;
entityType: string;
entityId: string;
}
/**
* Hook that shows an undo toast after a delete action and provides the undo function.
*
* Returns:
* - `showUndoToast(message, entityType, entityId)`: trigger the toast
* - `undoToast`: the JSX element to render (render it once in the page)
*/
export function useUndoToast() {
const { t } = useTranslation();
const [state, setState] = useState<UndoToastState | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const undoMutation = useUndoLastAction();
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const dismiss = useCallback(() => {
clearTimer();
setState(null);
}, [clearTimer]);
const showUndoToast = useCallback(
(message: string, entityType: string, entityId: string) => {
clearTimer();
setState({ message, entityType, entityId });
timerRef.current = setTimeout(() => {
setState(null);
timerRef.current = null;
}, 5000);
},
[clearTimer]
);
const handleUndo = useCallback(async () => {
if (!state) return;
try {
await undoMutation.mutateAsync({
entityType: state.entityType,
entityId: state.entityId,
});
dismiss();
} catch {
// Keep the toast visible so the user can retry; the mutation error is surfaced elsewhere.
}
}, [state, undoMutation, dismiss]);
// Cleanup timer on unmount
useEffect(() => clearTimer, [clearTimer]);
const undoToast = state ? (
<UndoToast
message={state.message}
onUndo={handleUndo}
onDismiss={dismiss}
isUndoing={undoMutation.isPending}
/>
) : null;
return { showUndoToast, undoToast };
}
@@ -1,396 +0,0 @@
import React, { useEffect, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { useToast } from '@/components/ui/Toast';
import {
useCreateAgent,
useUpdateAgent,
useTestRunAgent,
useAgentToolsFull,
useAgentSkills,
} from '@/api/automation';
import type { AgentDefinitionFull } from '@/types/automation';
import { Play, Save, X, Loader2 } from 'lucide-react';
const agentEditorSchema = z.object({
name: z.string().min(1, 'required').max(120),
description: z.string().max(500).default(''),
system_prompt: z.string().default(''),
llm_model: z.string().min(1, 'required').max(100),
tool_ids: z.array(z.string()).default([]),
skill_ids: z.array(z.string()).default([]),
max_steps: z.coerce.number().int().min(1).max(100).default(20),
max_duration_seconds: z.coerce.number().int().min(1).max(86400).default(300),
budget_limit_usd: z.coerce.number().min(0).max(10000).default(1.0),
temperature: z.coerce.number().min(0).max(2).default(0.3),
max_tokens: z.coerce.number().int().min(1).max(100000).default(1000),
trace_mode: z.enum(['standard', 'extended']).default('standard'),
mode: z.enum(['proactive', 'reactive']).default('reactive'),
is_active: z.boolean().default(true),
trigger_config: z.record(z.string(), z.unknown()).default({}),
ai_use_case_metadata: z.record(z.string(), z.unknown()).default({}),
});
type AgentEditorFormData = z.infer<typeof agentEditorSchema>;
export interface AgentEditorProps {
agent?: AgentDefinitionFull | null;
onSaved?: (agent: AgentDefinitionFull) => void;
onCancel?: () => void;
}
const modeOptions = [
{ value: 'reactive', label: 'Reactive' },
{ value: 'proactive', label: 'Proactive' },
];
const traceModeOptions = [
{ value: 'standard', label: 'Standard' },
{ value: 'extended', label: 'Extended' },
];
const commonModels = [
'ollama/deepseek-v4-flash',
'gpt-4',
'gpt-4-turbo',
'gpt-3.5-turbo',
'claude-3-opus',
'claude-3-sonnet',
'claude-3-haiku',
'llama-3-70b',
'llama-3-8b',
'mistral-large',
'mixtral-8x7b',
];
export function AgentEditor({ agent, onSaved, onCancel }: AgentEditorProps) {
const { t } = useTranslation();
const toast = useToast();
const { data: tools = [] } = useAgentToolsFull();
const { data: skills = [] } = useAgentSkills();
const createAgent = useCreateAgent();
const updateAgent = useUpdateAgent();
const testRunAgent = useTestRunAgent();
const defaultValues = useMemo<AgentEditorFormData>(() => {
if (agent) {
return {
name: agent.name,
description: agent.description || '',
system_prompt: agent.system_prompt || '',
llm_model: agent.llm_model || agent.model || '',
tool_ids: agent.tool_ids || [],
skill_ids: agent.skill_ids || [],
max_steps: agent.max_steps ?? 20,
max_duration_seconds: agent.max_duration_seconds ?? 300,
budget_limit_usd: agent.budget_limit_usd ?? agent.budget_limit ?? 1.0,
temperature: agent.temperature ?? 0.3,
max_tokens: agent.max_tokens ?? 1000,
trace_mode: (agent.trace_mode === 'extended' ? 'extended' : 'standard') as 'standard' | 'extended',
mode: agent.mode,
is_active: agent.is_active ?? agent.active ?? true,
trigger_config: agent.trigger_config || {},
ai_use_case_metadata: agent.ai_use_case_metadata || {},
};
}
return {
name: '',
description: '',
system_prompt: '',
llm_model: 'ollama/deepseek-v4-flash',
tool_ids: [],
skill_ids: [],
max_steps: 20,
max_duration_seconds: 300,
budget_limit_usd: 1.0,
temperature: 0.3,
max_tokens: 1000,
trace_mode: 'standard',
mode: 'reactive',
is_active: true,
trigger_config: {},
ai_use_case_metadata: {},
};
}, [agent]);
const {
register,
handleSubmit,
control,
reset,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<AgentEditorFormData>({
resolver: zodResolver(agentEditorSchema),
defaultValues,
});
const selectedToolIds = watch('tool_ids');
const selectedSkillIds = watch('skill_ids');
useEffect(() => {
reset(defaultValues);
}, [reset, defaultValues]);
const onSubmit = async (data: AgentEditorFormData) => {
try {
if (agent) {
const updated = await updateAgent.mutateAsync({ id: agent.id, data });
toast.success(t('agent.saved'));
onSaved?.(updated);
} else {
const created = await createAgent.mutateAsync(data);
toast.success(t('agent.created'));
onSaved?.(created);
}
} catch {
toast.error(t('agent.saveFailed'));
}
};
const handleTestRun = async () => {
if (!agent) {
toast.warning(t('agent.saveBeforeTest'));
return;
}
try {
await testRunAgent.mutateAsync(agent.id);
toast.success(t('agent.testRunOk'));
} catch {
toast.error(t('agent.testRunFailed'));
}
};
const toggleArrayValue = (field: 'tool_ids' | 'skill_ids', value: string) => {
const current = field === 'tool_ids' ? selectedToolIds : selectedSkillIds;
const next = (current || []).includes(value)
? (current || []).filter((v) => v !== value)
: [...(current || []), value];
setValue(field, next, { shouldDirty: true, shouldValidate: true });
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6" data-testid="agent-editor">
{/* Basic info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t('agent.name')}
required
error={errors.name?.message}
placeholder="My Agent"
{...register('name')}
/>
<Input
label={t('agent.description')}
error={errors.description?.message}
placeholder={t('agent.descriptionPlaceholder')}
{...register('description')}
/>
</div>
{/* System prompt */}
<div>
<label htmlFor="agent-system-prompt" className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.systemPrompt')}
</label>
<textarea
id="agent-system-prompt"
rows={5}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-touch"
placeholder="You are a helpful assistant..."
aria-invalid={!!errors.system_prompt}
{...register('system_prompt')}
/>
{errors.system_prompt && (
<p className="mt-1 text-sm text-danger-600" role="alert">{errors.system_prompt.message}</p>
)}
</div>
{/* Model + mode + trace */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label htmlFor="agent-llm-model" className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.model')}
</label>
<input
id="agent-llm-model"
list="agent-model-suggestions"
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-touch"
aria-invalid={!!errors.llm_model}
{...register('llm_model')}
/>
<datalist id="agent-model-suggestions">
{commonModels.map((m) => (
<option key={m} value={m} />
))}
</datalist>
{errors.llm_model && (
<p className="mt-1 text-sm text-danger-600" role="alert">{errors.llm_model.message}</p>
)}
</div>
<Controller
control={control}
name="mode"
render={({ field }) => (
<Select
label={t('agent.mode')}
options={modeOptions}
value={field.value}
onChange={field.onChange}
/>
)}
/>
<Controller
control={control}
name="trace_mode"
render={({ field }) => (
<Select
label={t('agent.traceMode')}
options={traceModeOptions}
value={field.value}
onChange={field.onChange}
/>
)}
/>
</div>
{/* Tools multi-select */}
<div>
<span className="block text-sm font-medium text-secondary-700 mb-2">{t('agent.tools')}</span>
{tools.length === 0 ? (
<p className="text-sm text-secondary-400 italic">{t('agent.noToolsAvailable')}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-40 overflow-y-auto" role="group" aria-label={t('agent.tools')}>
{tools.map((tool) => (
<label key={tool.id || tool.name} className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer text-sm min-h-touch">
<input
type="checkbox"
checked={selectedToolIds.includes(tool.id || tool.name)}
onChange={() => toggleArrayValue('tool_ids', tool.id || tool.name)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="font-medium text-secondary-700">{tool.name}</span>
{tool.description && (
<p className="text-xs text-secondary-400">{tool.description}</p>
)}
</div>
</label>
))}
</div>
)}
</div>
{/* Skills multi-select */}
<div>
<span className="block text-sm font-medium text-secondary-700 mb-2">{t('agent.skills')}</span>
{skills.length === 0 ? (
<p className="text-sm text-secondary-400 italic">{t('agent.noSkillsAvailable')}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-40 overflow-y-auto" role="group" aria-label={t('agent.skills')}>
{skills.map((skill) => (
<label key={skill.name} className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer text-sm min-h-touch">
<input
type="checkbox"
checked={selectedSkillIds.includes(skill.name)}
onChange={() => toggleArrayValue('skill_ids', skill.name)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="font-medium text-secondary-700">{skill.name}</span>
{skill.description && (
<p className="text-xs text-secondary-400">{skill.description}</p>
)}
</div>
</label>
))}
</div>
)}
</div>
{/* Limits */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Input
type="number"
label={t('agent.maxSteps')}
error={errors.max_steps?.message}
{...register('max_steps')}
/>
<Input
type="number"
label={`${t('agent.maxDuration')} (s)`}
error={errors.max_duration_seconds?.message}
{...register('max_duration_seconds')}
/>
<Input
type="number"
step="0.01"
label={`${t('agent.budgetLimit')} ($)`}
error={errors.budget_limit_usd?.message}
{...register('budget_limit_usd')}
/>
<Input
type="number"
step="0.1"
label={t('agent.temperature')}
error={errors.temperature?.message}
{...register('temperature')}
/>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Input
type="number"
label={t('agent.maxTokens')}
error={errors.max_tokens?.message}
{...register('max_tokens')}
/>
<Input
type="number"
label={t('agent.maxExecutions')}
defaultValue={10}
disabled
/>
</div>
{/* Active toggle */}
<label className="flex items-center gap-2 text-sm min-h-touch">
<input
type="checkbox"
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
{...register('is_active')}
/>
{t('agent.active')}
</label>
{/* Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
{agent && (
<Button
type="button"
variant="secondary"
onClick={handleTestRun}
isLoading={testRunAgent.isPending}
icon={<Play className="w-4 h-4" />}
>
{t('agent.testRun')}
</Button>
)}
{onCancel && (
<Button type="button" variant="ghost" onClick={onCancel} icon={<X className="w-4 h-4" />}>
{t('common.cancel')}
</Button>
)}
<Button type="submit" isLoading={isSubmitting} icon={<Save className="w-4 h-4" />}>
{agent ? t('common.save') : t('common.create')}
</Button>
</div>
</form>
);
}
@@ -1,86 +0,0 @@
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { BarChart3, AlertTriangle, DollarSign, Clock } from 'lucide-react';
export function AgentMonitor() {
const { t } = useTranslation();
const { data: stats, isLoading } = useQuery({
queryKey: ['agent-monitor-stats'],
queryFn: async () => {
const res = await fetch('/api/v1/agents/monitor/stats');
if (!res.ok) throw new Error('Failed to fetch stats');
return res.json() as Promise<{ active_runs: number; total_budget_usd: number; runs_per_hour: number; error_rate: number }>;
},
refetchInterval: 5000,
});
const { data: activeRuns } = useQuery({
queryKey: ['agent-active-runs'],
queryFn: async () => {
const res = await fetch('/api/v1/agents/runs/recent?status=running&limit=20');
if (!res.ok) throw new Error('Failed to fetch runs');
return res.json() as Promise<Array<{ id: string; agent_name: string; status: string; started_at: string; cost_usd: number }>>;
},
refetchInterval: 5000,
});
return (
<div className="flex flex-col h-full bg-white dark:bg-gray-900 p-6 space-y-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('agents.monitoring')}</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-5 h-5 text-primary-600" />
<span className="text-sm text-gray-600 dark:text-gray-400">{t('agents.activeRuns')}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats?.active_runs ?? 0}</p>
</div>
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20">
<div className="flex items-center gap-2 mb-2">
<DollarSign className="w-5 h-5 text-green-600" />
<span className="text-sm text-gray-600 dark:text-gray-400">{t('agents.totalBudget')}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">${(stats?.total_budget_usd ?? 0).toFixed(4)}</p>
</div>
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20">
<div className="flex items-center gap-2 mb-2">
<BarChart3 className="w-5 h-5 text-blue-600" />
<span className="text-sm text-gray-600 dark:text-gray-400">{t('agents.runsPerHour')}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats?.runs_per_hour ?? 0}</p>
</div>
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
<span className="text-sm text-gray-600 dark:text-gray-400">{t('agents.errorRate')}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{(stats?.error_rate ?? 0).toFixed(1)}%</p>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">{t('agents.activeRunsList')}</h3>
{isLoading && <p className="text-gray-500">{t('common.loading')}</p>}
<div className="space-y-2">
{activeRuns?.map((run) => (
<div key={run.id} className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{run.agent_name}</p>
<p className="text-xs text-gray-400">{new Date(run.started_at).toLocaleString()}</p>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-400">${run.cost_usd.toFixed(6)}</span>
<span className="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800">{run.status}</span>
</div>
</div>
))}
{(!activeRuns || activeRuns.length === 0) && !isLoading && (
<p className="text-sm text-gray-400">{t('agents.noActiveRuns')}</p>
)}
</div>
</div>
</div>
);
}
@@ -1,82 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
interface RunStep {
id: string;
step_number: number;
thought: string | null;
action: string | null;
action_input: Record<string, unknown> | null;
observation: string | null;
cost_usd: number;
created_at: string;
}
interface AgentRunLogProps {
agentId: string;
runId: string;
}
export function AgentRunLog({ agentId, runId }: AgentRunLogProps) {
const { t } = useTranslation();
const [filterStatus, setFilterStatus] = useState<string>('all');
const { data: steps, isLoading } = useQuery({
queryKey: ['agent-run-steps', agentId, runId],
queryFn: async () => {
const res = await fetch(`/api/v1/agents/${agentId}/runs/${runId}/steps`);
if (!res.ok) throw new Error('Failed to fetch steps');
return res.json() as Promise<RunStep[]>;
},
});
const handleExport = (format: 'json' | 'csv') => {
if (!steps) return;
const data = format === 'json' ? JSON.stringify(steps, null, 2) :
'step,thought,action,observation,cost,timestamp\n' +
steps.map((s) => `${s.step_number},${s.thought || ''},${s.action || ''},${s.observation || ''},${s.cost_usd},${s.created_at}`).join('\n');
const blob = new Blob([data], { type: format === 'json' ? 'application/json' : 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `agent-run-${runId}.${format}`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="flex flex-col h-full bg-white dark:bg-gray-900">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('agents.runLog')}</h2>
<div className="flex items-center gap-2">
<button onClick={() => handleExport('json')} className="p-2 rounded-lg text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 min-h-[44px] min-w-[44px]" aria-label={t('agents.exportJson')}>
<Download className="w-5 h-5" />
</button>
<button onClick={() => handleExport('csv')} className="p-2 rounded-lg text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 min-h-[44px] min-w-[44px]" aria-label={t('agents.exportCsv')}>
<Download className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{isLoading && <p className="text-gray-500">{t('common.loading')}</p>}
{steps?.map((step) => (
<div key={step.id} className="p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">Step {step.step_number}</span>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-400">${step.cost_usd.toFixed(6)}</span>
<span className="text-xs text-gray-400">{new Date(step.created_at).toLocaleString()}</span>
</div>
</div>
{step.thought && <p className="text-sm text-gray-700 dark:text-gray-300 mb-2"><strong>Thought:</strong> {step.thought}</p>}
{step.action && <p className="text-sm text-primary-600 dark:text-primary-400 mb-2"><strong>Action:</strong> {step.action}</p>}
{step.action_input && <pre className="text-xs text-gray-500 bg-gray-50 dark:bg-gray-800 p-2 rounded mb-2 overflow-x-auto">{JSON.stringify(step.action_input, null, 2)}</pre>}
{step.observation && <p className="text-sm text-gray-600 dark:text-gray-400"><strong>Observation:</strong> {step.observation}</p>}
</div>
))}
</div>
</div>
);
}
@@ -1,883 +0,0 @@
/**
* ABACRuleEditor — UI zum Erstellen und Bearbeiten von ABAC Policies.
*
* Features:
* - Liste aller Policies für einen Entity-Type
* - Neue Policy erstellen / bestehende bearbeiten
* - Conditions Builder mit AND/OR Gruppen
* - Policy löschen mit ConfirmDialog
* - Text-basierte Vorschau der Policy
*/
import { asError } from '@/utils/errorTypes';
import React, { useState, useCallback, useMemo } from 'react';
import clsx from 'clsx';
import {
Plus,
Pencil,
Trash2,
X,
Shield,
ShieldCheck,
ShieldX,
GripVertical,
ChevronDown,
ChevronRight,
Eye,
EyeOff,
ArrowUpDown,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
usePolicies,
useCreatePolicy,
useUpdatePolicy,
useDeletePolicy,
} from '../../api/policyHooks';
import { useUsers } from '../../api/users';
import { useGroups } from '../../api/groups';
import { useRoles } from '../../api/roles';
import {
type ABACPolicy,
type PrincipalType,
type ConditionOperator,
type ConditionGroupLogic,
type Condition,
type ConditionGroup,
type CreatePolicyPayload,
type UpdatePolicyPayload,
} from '../../api/policies';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { Badge } from '../ui/Badge';
import { Select, type SelectOption } from '../ui/Select';
import { Input } from '../ui/Input';
import { Modal } from '../ui/Modal';
import { ConfirmDialog } from '../ui/ConfirmDialog';
// ─── Constants ─────────────────────────────────────────────────────────────
const OPERATOR_OPTIONS: SelectOption[] = [
{ value: 'eq', label: '=' },
{ value: 'neq', label: '≠' },
{ value: 'in', label: 'in' },
{ value: 'gt', label: '>' },
{ value: 'gte', label: '≥' },
{ value: 'lt', label: '<' },
{ value: 'lte', label: '≤' },
{ value: 'contains', label: 'contains' },
{ value: 'starts_with', label: 'starts with' },
{ value: 'is_null', label: 'is null' },
];
const PRINCIPAL_TYPE_OPTIONS: SelectOption[] = [
{ value: 'user', label: 'User' },
{ value: 'group', label: 'Group' },
{ value: 'role', label: 'Role' },
];
const EFFECT_OPTIONS: SelectOption[] = [
{ value: 'allow', label: 'Allow' },
{ value: 'deny', label: 'Deny' },
];
const LOGIC_OPTIONS: SelectOption[] = [
{ value: 'AND', label: 'AND' },
{ value: 'OR', label: 'OR' },
];
// ─── Helpers ───────────────────────────────────────────────────────────────
function generateConditionId(): string {
return `cond_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function generateGroupId(): string {
return `grp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function createEmptyCondition(): Condition {
return { id: generateConditionId(), field: '', operator: 'eq', value: '' };
}
function createEmptyGroup(logic: ConditionGroupLogic = 'AND'): ConditionGroup {
return {
id: generateGroupId(),
logic,
conditions: [createEmptyCondition()],
groups: [],
};
}
/**
* Generate a human-readable description of a condition group.
*/
function describeConditionGroup(group: ConditionGroup | null): string {
if (!group) return '—';
const parts: string[] = [];
for (const cond of group.conditions) {
if (!cond.field) continue;
const opLabel = OPERATOR_OPTIONS.find((o) => o.value === cond.operator)?.label || cond.operator;
if (cond.operator === 'is_null') {
parts.push(`${cond.field} is null`);
} else if (cond.operator === 'in') {
parts.push(`${cond.field} ${opLabel} (${cond.value})`);
} else {
parts.push(`${cond.field} ${opLabel} ${cond.value}`);
}
}
for (const sub of group.groups || []) {
const subDesc = describeConditionGroup(sub);
if (subDesc !== '—') {
parts.push(`(${subDesc})`);
}
}
if (parts.length === 0) return '—';
return parts.join(` ${group.logic} `);
}
/**
* Generate a full human-readable policy description.
*/
function describePolicy(policy: ABACPolicy): string {
const principalLabel = policy.principal_name || policy.principal_id;
const effectLabel = policy.effect === 'allow' ? 'darf' : 'darf nicht';
const condDesc = describeConditionGroup(policy.conditions);
if (condDesc === '—') {
return `${policy.principal_type}${principalLabel}${effectLabel} auf ${policy.entity_type} zugreifen`;
}
return `${policy.principal_type}${principalLabel}${effectLabel} auf ${policy.entity_type} zugreifen, wenn ${condDesc}`;
}
// ─── Sub-Components ────────────────────────────────────────────────────────
interface ConditionRowProps {
condition: Condition;
onChange: (condition: Condition) => void;
onRemove: () => void;
canRemove: boolean;
}
function ConditionRow({ condition, onChange, onRemove, canRemove }: ConditionRowProps) {
const { t } = useTranslation();
return (
<div className="flex items-start gap-2 py-1">
<div className="flex-1">
<Input
placeholder={t('abac.fieldPlaceholder', 'Field')}
value={condition.field}
onChange={(e) => onChange({ ...condition, field: e.target.value })}
/>
</div>
<div className="w-32">
<Select
options={OPERATOR_OPTIONS}
value={condition.operator}
onChange={(e) => onChange({ ...condition, operator: e.target.value as ConditionOperator })}
className="text-sm"
/>
</div>
<div className="flex-1">
<Input
placeholder={t('abac.valuePlaceholder', 'Value')}
value={condition.value}
onChange={(e) => onChange({ ...condition, value: e.target.value })}
/>
</div>
{canRemove && (
<button
type="button"
onClick={onRemove}
className="mt-1 text-secondary-400 hover:text-danger-500 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.removeCondition', 'Remove condition')}
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
}
interface ConditionGroupEditorProps {
group: ConditionGroup;
onChange: (group: ConditionGroup) => void;
onRemove?: () => void;
depth: number;
canRemove: boolean;
}
function ConditionGroupEditor({
group,
onChange,
onRemove,
depth,
canRemove,
}: ConditionGroupEditorProps) {
const { t } = useTranslation();
const addCondition = useCallback(() => {
onChange({
...group,
conditions: [...group.conditions, createEmptyCondition()],
});
}, [group, onChange]);
const updateCondition = useCallback(
(index: number, condition: Condition) => {
const updated = [...group.conditions];
updated[index] = condition;
onChange({ ...group, conditions: updated });
},
[group, onChange]
);
const removeCondition = useCallback(
(index: number) => {
if (group.conditions.length <= 1) return;
const updated = group.conditions.filter((_, i) => i !== index);
onChange({ ...group, conditions: updated });
},
[group, onChange]
);
const toggleLogic = useCallback(() => {
onChange({
...group,
logic: group.logic === 'AND' ? 'OR' : 'AND',
});
}, [group, onChange]);
return (
<div className={clsx(
'border rounded-md p-3',
depth > 0 && 'ml-4 bg-secondary-50/50'
)}>
{/* Header: Logic toggle + actions */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleLogic}
className={clsx(
'px-2 py-0.5 text-xs font-medium rounded border transition-colors',
group.logic === 'AND'
? 'bg-primary-100 text-primary-700 border-primary-300'
: 'bg-accent-100 text-accent-700 border-accent-300'
)}
>
{group.logic}
</button>
<span className="text-xs text-secondary-500">
{t('abac.groupConditions', 'Conditions')}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
icon={<Plus className="h-3.5 w-3.5" />}
onClick={addCondition}
>
{t('abac.addCondition', 'Add')}
</Button>
{canRemove && onRemove && (
<button
type="button"
onClick={onRemove}
className="text-secondary-400 hover:text-danger-500 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.removeGroup', 'Remove group')}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Conditions */}
{group.conditions.map((cond, idx) => (
<ConditionRow
key={cond.id}
condition={cond}
onChange={(c) => updateCondition(idx, c)}
onRemove={() => removeCondition(idx)}
canRemove={group.conditions.length > 1}
/>
))}
{/* Nested groups */}
{group.groups?.map((sub, idx) => (
<ConditionGroupEditor
key={sub.id}
group={sub}
onChange={(g) => {
const updated = [...(group.groups || [])];
updated[idx] = g;
onChange({ ...group, groups: updated });
}}
onRemove={() => {
const updated = (group.groups || []).filter((_, i) => i !== idx);
onChange({ ...group, groups: updated });
}}
depth={depth + 1}
canRemove={true}
/>
))}
{/* Add nested group */}
<button
type="button"
onClick={() => {
onChange({
...group,
groups: [...(group.groups || []), createEmptyGroup('AND')],
});
}}
className="mt-2 text-xs text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<Plus className="h-3 w-3" />
{t('abac.addNestedGroup', 'Add nested group')}
</button>
</div>
);
}
// ─── Policy Form ───────────────────────────────────────────────────────────
interface PolicyFormProps {
initial?: ABACPolicy | null;
entityType: string;
onSave: () => void;
onCancel: () => void;
}
function PolicyForm({ initial, entityType, onSave, onCancel }: PolicyFormProps) {
const { t } = useTranslation();
const createPolicy = useCreatePolicy(entityType);
const updatePolicy = useUpdatePolicy(entityType);
// Fetch principals for selectors
const { data: usersData } = useUsers();
const { data: groupsData } = useGroups();
const { data: rolesData } = useRoles();
const [name, setName] = useState(initial?.name || '');
const [principalType, setPrincipalType] = useState<PrincipalType>(
initial?.principal_type || 'user'
);
const [principalId, setPrincipalId] = useState(initial?.principal_id || '');
const [effect, setEffect] = useState<'allow' | 'deny'>(initial?.effect || 'allow');
const [conditions, setConditions] = useState<ConditionGroup | null>(
initial?.conditions || null
);
const [priority, setPriority] = useState(initial?.priority ?? 0);
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
const [error, setError] = useState<string | null>(null);
// Build principal options based on selected type
const principalOptions: SelectOption[] = useMemo(() => {
if (principalType === 'user') {
return (usersData?.items || []).map((u) => ({
value: u.id,
label: u.name || u.email,
}));
}
if (principalType === 'group') {
return (groupsData?.items || []).map((g) => ({
value: g.id,
label: g.name,
}));
}
if (principalType === 'role') {
return (rolesData?.items || []).map((r) => ({
value: r.id,
label: r.name,
}));
}
return [];
}, [principalType, usersData, groupsData, rolesData]);
const handleSave = useCallback(async () => {
setError(null);
if (!name.trim()) {
setError(t('abac.nameRequired', 'Name is required'));
return;
}
if (!principalId) {
setError(t('abac.principalRequired', 'Principal is required'));
return;
}
try {
if (initial) {
const payload: UpdatePolicyPayload = {
name: name.trim(),
principal_type: principalType,
principal_id: principalId,
effect,
conditions,
priority,
enabled,
};
await updatePolicy.mutateAsync({ policyId: initial.id, data: payload });
} else {
const payload: CreatePolicyPayload = {
name: name.trim(),
principal_type: principalType,
principal_id: principalId,
effect,
conditions,
priority,
enabled,
};
await createPolicy.mutateAsync(payload);
}
onSave();
} catch (err: unknown) { const errObj = asError(err);
setError(errObj?.message || t('abac.saveError', 'Failed to save policy'));
}
}, [
initial,
name,
principalType,
principalId,
effect,
conditions,
priority,
enabled,
createPolicy,
updatePolicy,
onSave,
t,
]);
const isSaving = createPolicy.isPending || updatePolicy.isPending;
// Generate preview text
const previewText = useMemo(() => {
if (!name.trim() && !principalId) return '';
const mockPolicy: ABACPolicy = {
id: initial?.id || 'new',
name: name.trim() || '(unnamed)',
entity_type: entityType,
principal_type: principalType,
principal_id: principalId,
principal_name:
principalOptions.find((o) => o.value === principalId)?.label || null,
effect,
conditions,
priority,
enabled,
};
return describePolicy(mockPolicy);
}, [name, principalType, principalId, effect, conditions, priority, enabled, entityType, initial, principalOptions]);
return (
<div className="space-y-4">
{/* Name */}
<Input
label={t('abac.policyName', 'Policy Name')}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('abac.policyNamePlaceholder', 'e.g. Vertrieb kann Kontakte sehen')}
required
/>
{/* Principal Type + ID */}
<div className="grid grid-cols-2 gap-4">
<Select
label={t('abac.principalType', 'Principal Type')}
options={PRINCIPAL_TYPE_OPTIONS}
value={principalType}
onChange={(e) => {
setPrincipalType(e.target.value as PrincipalType);
setPrincipalId('');
}}
/>
<Select
label={t('abac.principal', 'Principal')}
options={principalOptions}
value={principalId}
onChange={(e) => setPrincipalId(e.target.value)}
placeholder={t('abac.selectPrincipal', 'Select...')}
/>
</div>
{/* Effect + Priority */}
<div className="grid grid-cols-2 gap-4">
<Select
label={t('abac.effect', 'Effect')}
options={EFFECT_OPTIONS}
value={effect}
onChange={(e) => setEffect(e.target.value as 'allow' | 'deny')}
/>
<Input
label={t('abac.priority', 'Priority')}
type="number"
value={String(priority)}
onChange={(e) => setPriority(parseInt(e.target.value) || 0)}
/>
</div>
{/* Enabled */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-secondary-700">
{t('abac.enabled', 'Enabled')}
</span>
</label>
{/* Conditions Builder */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-secondary-700">
{t('abac.conditions', 'Conditions')}
</label>
{!conditions && (
<Button
variant="ghost"
size="sm"
icon={<Plus className="h-3.5 w-3.5" />}
onClick={() => setConditions(createEmptyGroup('AND'))}
>
{t('abac.addConditions', 'Add conditions')}
</Button>
)}
</div>
{conditions && (
<div className="space-y-2">
<ConditionGroupEditor
group={conditions}
onChange={setConditions}
onRemove={() => setConditions(null)}
depth={0}
canRemove={true}
/>
</div>
)}
</div>
{/* Preview */}
{previewText && (
<div className="bg-secondary-50 border border-secondary-200 rounded-md p-3">
<p className="text-xs font-medium text-secondary-500 mb-1">
{t('abac.preview', 'Preview')}
</p>
<p className="text-sm text-secondary-800">{previewText}</p>
</div>
)}
{/* Error */}
{error && (
<p className="text-sm text-danger-600" role="alert">
{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}
</p>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={onCancel}>
{t('abac.cancel', 'Cancel')}
</Button>
<Button variant="primary" onClick={handleSave} isLoading={isSaving}>
{initial ? t('abac.update', 'Update') : t('abac.create', 'Create')}
</Button>
</div>
</div>
);
}
// ─── Main Component ────────────────────────────────────────────────────────
export interface ABACRuleEditorProps {
entityType: string;
onClose: () => void;
}
export function ABACRuleEditor({ entityType, onClose }: ABACRuleEditorProps) {
const { t } = useTranslation();
const { data: policiesData, isLoading, error: fetchError } = usePolicies(entityType);
const deletePolicy = useDeletePolicy(entityType);
const [showForm, setShowForm] = useState(false);
const [editingPolicy, setEditingPolicy] = useState<ABACPolicy | null>(null);
const [deletingPolicy, setDeletingPolicy] = useState<ABACPolicy | null>(null);
const [expandedPolicies, setExpandedPolicies] = useState<Set<string>>(new Set());
const policies = policiesData?.items || [];
const handleCreate = useCallback(() => {
setEditingPolicy(null);
setShowForm(true);
}, []);
const handleEdit = useCallback((policy: ABACPolicy) => {
setEditingPolicy(policy);
setShowForm(true);
}, []);
const handleFormSave = useCallback(() => {
setShowForm(false);
setEditingPolicy(null);
}, []);
const handleFormCancel = useCallback(() => {
setShowForm(false);
setEditingPolicy(null);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deletingPolicy) return;
try {
await deletePolicy.mutateAsync(deletingPolicy.id);
} catch {
// Error is handled by the mutation
}
setDeletingPolicy(null);
}, [deletingPolicy, deletePolicy]);
const toggleExpand = useCallback((policyId: string) => {
setExpandedPolicies((prev) => {
const next = new Set(prev);
if (next.has(policyId)) {
next.delete(policyId);
} else {
next.add(policyId);
}
return next;
});
}, []);
return (
<Card
title={t('abac.ruleEditor', 'ABAC Rule Editor')}
description={`${entityType}`}
actions={
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleCreate}
>
{t('abac.newPolicy', 'New Policy')}
</Button>
<Button
variant="ghost"
size="sm"
icon={<X className="h-4 w-4" />}
onClick={onClose}
>
{t('abac.close', 'Close')}
</Button>
</div>
}
>
{/* Loading state */}
{isLoading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin h-6 w-6 border-2 border-primary-600 border-t-transparent rounded-full" />
<span className="ml-3 text-sm text-secondary-500">
{t('abac.loading', 'Loading policies...')}
</span>
</div>
)}
{/* Error state */}
{fetchError && !isLoading && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<p className="text-sm text-danger-700">
{t('abac.fetchError', 'Failed to load policies')}: {String(fetchError)}
</p>
</div>
)}
{/* Empty state */}
{!isLoading && !fetchError && policies.length === 0 && !showForm && (
<div className="text-center py-8">
<Shield className="h-12 w-12 text-secondary-300 mx-auto mb-3" />
<p className="text-sm text-secondary-500 mb-4">
{t('abac.noPolicies', 'No policies defined for this entity type.')}
</p>
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleCreate}
>
{t('abac.createFirst', 'Create first policy')}
</Button>
</div>
)}
{/* Policy list */}
{!isLoading && !fetchError && policies.length > 0 && !showForm && (
<div className="space-y-2">
{policies.map((policy) => {
const isExpanded = expandedPolicies.has(policy.id);
return (
<div
key={policy.id}
className="border border-secondary-200 rounded-md hover:border-secondary-300 transition-colors"
>
{/* Policy header */}
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer"
onClick={() => toggleExpand(policy.id)}
>
<div className="flex items-center gap-3 min-w-0">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-secondary-400 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-secondary-400 shrink-0" />
)}
<div className="min-w-0">
<p className="text-sm font-medium text-secondary-900 truncate">
{policy.name}
</p>
<p className="text-xs text-secondary-500 truncate">
{describePolicy(policy)}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge
variant={policy.effect === 'allow' ? 'success' : 'danger'}
>
{policy.effect === 'allow' ? (
<ShieldCheck className="h-3 w-3 mr-1" />
) : (
<ShieldX className="h-3 w-3 mr-1" />
)}
{policy.effect}
</Badge>
{!policy.enabled && (
<Badge variant="warning">{t('abac.disabled', 'Disabled')}</Badge>
)}
<span className="text-xs text-secondary-400">P{policy.priority}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleEdit(policy);
}}
className="text-secondary-400 hover:text-primary-600 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.editPolicy', 'Edit policy')}
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setDeletingPolicy(policy);
}}
className="text-secondary-400 hover:text-danger-500 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.deletePolicy', 'Delete policy')}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{/* Expanded details */}
{isExpanded && (
<div className="px-4 pb-3 pt-0 border-t border-secondary-100">
<div className="grid grid-cols-2 gap-2 mt-2 text-xs">
<div>
<span className="text-secondary-500">
{t('abac.principal', 'Principal')}:
</span>{' '}
<span className="text-secondary-800">
{policy.principal_name || policy.principal_id}
</span>
</div>
<div>
<span className="text-secondary-500">
{t('abac.principalType', 'Type')}:
</span>{' '}
<span className="text-secondary-800">{policy.principal_type}</span>
</div>
<div>
<span className="text-secondary-500">
{t('abac.priority', 'Priority')}:
</span>{' '}
<span className="text-secondary-800">{policy.priority}</span>
</div>
<div>
<span className="text-secondary-500">
{t('abac.enabled', 'Enabled')}:
</span>{' '}
<span className="text-secondary-800">
{policy.enabled ? '✓' : '✗'}
</span>
</div>
</div>
{policy.conditions && (
<div className="mt-2">
<span className="text-xs text-secondary-500">
{t('abac.conditions', 'Conditions')}:
</span>
<p className="text-xs text-secondary-800 mt-1">
{describeConditionGroup(policy.conditions)}
</p>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{/* Create/Edit Form Modal */}
<Modal
open={showForm}
onClose={handleFormCancel}
title={
editingPolicy
? t('abac.editPolicyTitle', 'Edit Policy')
: t('abac.createPolicyTitle', 'Create Policy')
}
size="lg"
>
<PolicyForm
initial={editingPolicy}
entityType={entityType}
onSave={handleFormSave}
onCancel={handleFormCancel}
/>
</Modal>
{/* Delete Confirmation */}
<ConfirmDialog
open={!!deletingPolicy}
title={t('abac.deletePolicyTitle', 'Delete Policy')}
message={
deletingPolicy
? t('abac.deleteConfirm', 'Are you sure you want to delete policy "{{name}}"?', {
name: deletingPolicy.name,
})
: ''
}
variant="danger"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeletingPolicy(null)}
/>
</Card>
);
}
@@ -1,349 +0,0 @@
import { asError } from '@/utils/errorTypes';
import React, { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Button } from '@/components/ui/Button';
import { useToast } from '@/components/ui/Toast';
import {
type UnifiedContact,
useCreateUnifiedContact,
useUpdateUnifiedContact,
} from '@/api/hooks';
import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer';
import { useCustomFields, useUpdateCustomFields } from '@/api/customFields';
import { usePluginStore } from '@/store/pluginStore';
export interface ContactEditModalProps {
open: boolean;
onClose: () => void;
contact?: UnifiedContact | null;
onSaved?: (id: string) => void;
}
// ── Zod Schema ──
const phoneRegex = /^[+]?[\d\s\-().]{6,20}$/;
const contactSchema = z.object({
type: z.enum(['company', 'person']),
name: z.string().optional().default(''),
firstname: z.string().optional().default(''),
surname: z.string().optional().default(''),
code: z.string().optional().default(''),
email_1: z.string().email('Invalid email').or(z.literal('')).optional().default(''),
email_2: z.string().email('Invalid email').or(z.literal('')).optional().default(''),
phone_1: z.string().regex(phoneRegex, 'Invalid phone').or(z.literal('')).optional().default(''),
phone_2: z.string().regex(phoneRegex, 'Invalid phone').or(z.literal('')).optional().default(''),
website: z.string().optional().default(''),
mailing_street: z.string().optional().default(''),
mailing_number: z.string().optional().default(''),
mailing_postalcode: z.string().optional().default(''),
mailing_city: z.string().optional().default(''),
mailing_country: z.string().optional().default(''),
vat_code: z.string().optional().default(''),
fiscal_code: z.string().optional().default(''),
commerce_code: z.string().optional().default(''),
bic: z.string().optional().default(''),
bank_account: z.string().optional().default(''),
tags: z.string().optional().default(''),
projectnote: z.string().optional().default(''),
contact_warning: z.string().optional().default(''),
}).superRefine((data, ctx) => {
if (data.type === 'company' && !data.name?.trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['name'] });
}
if (data.type === 'person' && !data.firstname?.trim() && !data.surname?.trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['firstname'] });
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Required', path: ['surname'] });
}
});
type ContactFormData = z.infer<typeof contactSchema>;
export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEditModalProps) {
const { t } = useTranslation();
const toast = useToast();
const createMutation = useCreateUnifiedContact();
const updateMutation = useUpdateUnifiedContact();
const isEdit = !!contact;
const {
register,
handleSubmit,
reset,
watch,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: {
type: 'company',
name: '',
firstname: '',
surname: '',
code: '',
email_1: '',
email_2: '',
phone_1: '',
phone_2: '',
website: '',
mailing_street: '',
mailing_number: '',
mailing_postalcode: '',
mailing_city: '',
mailing_country: '',
vat_code: '',
fiscal_code: '',
commerce_code: '',
bic: '',
bank_account: '',
tags: '',
projectnote: '',
contact_warning: '',
},
});
const currentType = watch('type');
// Custom fields
const manifests = usePluginStore(s => s.manifests);
const customFieldDefs = useMemo(
() => manifests
.flatMap((m) => m.custom_fields || [])
.filter((cf) => cf.entity === 'contact'),
[manifests]
);
const { data: customFieldsData } = useCustomFields(contact?.id);
const updateCustomFields = useUpdateCustomFields();
const [customValues, setCustomValues] = React.useState<Record<string, any>>({});
React.useEffect(() => {
if (open && customFieldsData?.fields) {
const vals: Record<string, any> = {};
for (const f of customFieldsData.fields) {
vals[f.name] = f.value ?? f.default_value ?? null;
}
setCustomValues(vals);
}
}, [open, customFieldsData]);
const handleCustomFieldChange = (name: string, value: any) => {
setCustomValues(prev => ({ ...prev, [name]: value }));
};
// Reset form when modal opens
useEffect(() => {
if (open) {
reset({
type: (contact?.type as 'company' | 'person') || 'company',
name: contact?.name || '',
firstname: contact?.firstname || '',
surname: contact?.surname || '',
code: contact?.code || '',
email_1: contact?.email_1 || '',
email_2: contact?.email_2 || '',
phone_1: contact?.phone_1 || '',
phone_2: contact?.phone_2 || '',
website: contact?.website || '',
mailing_street: contact?.mailing_street || '',
mailing_number: contact?.mailing_number || '',
mailing_postalcode: contact?.mailing_postalcode || '',
mailing_city: contact?.mailing_city || '',
mailing_country: contact?.mailing_country || '',
vat_code: contact?.vat_code || '',
fiscal_code: contact?.fiscal_code || '',
commerce_code: contact?.commerce_code || '',
bic: contact?.bic || '',
bank_account: contact?.bank_account || '',
tags: contact?.tags || '',
projectnote: contact?.projectnote || '',
contact_warning: contact?.contact_warning || '',
});
}
}, [open, contact, reset]);
const onSubmit = async (formData: ContactFormData) => {
const data: Partial<UnifiedContact> = {
type: formData.type,
name: formData.type === 'company' ? formData.name || null : null,
firstname: formData.type === 'person' ? formData.firstname || null : null,
surname: formData.type === 'person' ? formData.surname || null : null,
code: formData.code || null,
email_1: formData.email_1 || null,
email_2: formData.email_2 || null,
phone_1: formData.phone_1 || null,
phone_2: formData.phone_2 || null,
website: formData.website || null,
mailing_street: formData.mailing_street || null,
mailing_number: formData.mailing_number || null,
mailing_postalcode: formData.mailing_postalcode || null,
mailing_city: formData.mailing_city || null,
mailing_country: formData.mailing_country || null,
vat_code: formData.vat_code || null,
fiscal_code: formData.fiscal_code || null,
commerce_code: formData.commerce_code || null,
bic: formData.bic || null,
bank_account: formData.bank_account || null,
tags: formData.tags || null,
projectnote: formData.projectnote || null,
contact_warning: formData.contact_warning || null,
};
try {
let savedId: string | undefined;
if (isEdit && contact) {
await updateMutation.mutateAsync({ id: contact.id, data });
savedId = contact.id;
toast.success(t('contacts.updated'));
onSaved?.(contact.id);
} else {
const result = await createMutation.mutateAsync(data) as { id: string };
savedId = result.id;
toast.success(t('contacts.created'));
onSaved?.(result.id);
}
// Save custom fields if any definitions exist and we have a contact ID
if (savedId && customFieldDefs.length > 0 && Object.keys(customValues).length > 0) {
try {
await updateCustomFields.mutateAsync({ contactId: savedId, values: customValues });
} catch (cfErr: unknown) { const errObj = asError(cfErr);
// Don't fail the whole save if custom fields fail
console.error('Custom fields save failed:', errObj);
}
}
onClose();
} catch (err: unknown) { const errObj = asError(err);
toast.error(errObj.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed')));
}
};
return (
<Modal open={open} onClose={onClose} title={isEdit ? t('contacts.edit') : t('contacts.create')} size="xl" fullScreenMobile>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Type */}
<Select
label={t('contacts.type')}
{...register('type')}
options={[
{ value: 'company', label: t('contacts.companies') },
{ value: 'person', label: t('contacts.persons') },
]}
data-testid="contact-type-select"
/>
{/* Name fields */}
{currentType === 'company' ? (
<Input
label={t('contacts.name')}
{...register('name')}
error={errors.name?.message}
required
data-testid="contact-name-input"
placeholder="TechCorp GmbH"
/>
) : (
<div className="grid grid-cols-2 gap-3">
<Input
label={t('contacts.firstName')}
{...register('firstname')}
error={errors.firstname?.message}
data-testid="contact-first-name-input"
/>
<Input
label={t('contacts.lastName')}
{...register('surname')}
error={errors.surname?.message}
data-testid="contact-last-name-input"
/>
</div>
)}
{/* Code */}
<Input label={t('contacts.code')} {...register('code')} placeholder="K-00123" />
{/* Communication */}
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.communication')}</h3>
<div className="grid grid-cols-2 gap-3">
<Input label={t('contacts.email') + ' 1'} type="email" {...register('email_1')} error={errors.email_1?.message} />
<Input label={t('contacts.email') + ' 2'} type="email" {...register('email_2')} error={errors.email_2?.message} />
<Input label={t('contacts.phone') + ' 1'} {...register('phone_1')} error={errors.phone_1?.message} />
<Input label={t('contacts.phone') + ' 2'} {...register('phone_2')} error={errors.phone_2?.message} />
<Input label={t('contacts.website')} {...register('website')} />
</div>
</div>
{/* Mailing Address */}
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.mailingAddress')}</h3>
<div className="grid grid-cols-2 gap-3">
<Input label={t('address.street')} {...register('mailing_street')} />
<Input label={t('address.streetNumber')} {...register('mailing_number')} />
<Input label={t('address.zip')} {...register('mailing_postalcode')} />
<Input label={t('address.city')} {...register('mailing_city')} />
<Input label={t('address.country')} {...register('mailing_country')} />
</div>
</div>
{/* Financial */}
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.financial')}</h3>
<div className="grid grid-cols-2 gap-3">
<Input label={t('contacts.vatCode')} {...register('vat_code')} />
<Input label={t('contacts.fiscalCode')} {...register('fiscal_code')} />
<Input label={t('contacts.commerceCode')} {...register('commerce_code')} />
<Input label={t('contacts.bic')} {...register('bic')} />
<Input label={t('contacts.bankAccount')} {...register('bank_account')} />
</div>
</div>
{/* Notes */}
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.notes')}</h3>
<div className="space-y-3">
<Input label={t('contacts.tags')} {...register('tags')} placeholder="tag1, tag2" />
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.projectnote')}</label>
<textarea
{...register('projectnote')}
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.contactWarning')}</label>
<textarea
{...register('contact_warning')}
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
/>
</div>
</div>
</div>
{/* Custom Fields */}
{customFieldDefs.length > 0 && (
<div className="border border-secondary-200 rounded-lg p-3">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.customFields')}</h3>
<CustomFieldRenderer
fields={customFieldsData?.fields || customFieldDefs.map(d => ({ ...d, value: d.default_value, plugin: '' }))}
mode="edit"
values={customValues}
onChange={handleCustomFieldChange}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button type="submit" isLoading={isSubmitting || createMutation.isPending || updateMutation.isPending} data-testid="contact-submit-btn">
{isEdit ? t('common.save') : t('common.create')}
</Button>
</div>
</form>
</Modal>
);
}
@@ -1256,7 +1256,7 @@ export function ContactList({
};
return (
<div data-testid="contact-list" className="flex flex-col h-full" data-testid="contact-cards-view">
<div data-testid="contact-cards-view" className="flex flex-col h-full">
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3">
{isGrouped && groupedContacts ? (
<div className="space-y-4">
@@ -1,195 +0,0 @@
/**
* DedupDialog — UI for finding and merging duplicate contacts (Task 5.23).
*/
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useFindDuplicates, useMergeContacts, type DuplicatePair } from '@/api/dedup';
import { useToast } from '@/components/ui/Toast';
import { GitMerge, Search, AlertTriangle, Check } from 'lucide-react';
export function DedupDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const { success, error: showError } = useToast();
const findDuplicates = useFindDuplicates();
const mergeContacts = useMergeContacts();
const [duplicates, setDuplicates] = useState<DuplicatePair[]>([]);
const [selectedPair, setSelectedPair] = useState<number | null>(null);
const [fieldOverrides, setFieldOverrides] = useState<Record<string, string>>({});
const handleSearch = useCallback(async () => {
try {
const result = await findDuplicates.mutateAsync({ threshold: 0.7, limit: 50 });
setDuplicates(result || []);
setSelectedPair(null);
} catch {
showError(t('dedup.searchFailed'));
}
}, [findDuplicates, showError, t]);
const handleMerge = useCallback(async () => {
if (selectedPair === null) return;
const pair = duplicates[selectedPair];
if (!pair) return;
try {
const overrides: Record<string, unknown> = {};
for (const [field, value] of Object.entries(fieldOverrides)) {
if (value === 'source') {
overrides[field] = (pair.source_contact as unknown as Record<string, unknown>)[field];
} else if (value === 'target') {
overrides[field] = (pair.target_contact as unknown as Record<string, unknown>)[field];
}
}
await mergeContacts.mutateAsync({
source_contact_id: pair.source_contact.id,
target_contact_id: pair.target_contact.id,
field_overrides: Object.keys(overrides).length > 0 ? overrides : undefined,
});
success(t('dedup.mergeSuccess'));
setDuplicates((prev) => prev.filter((_, i) => i !== selectedPair));
setSelectedPair(null);
setFieldOverrides({});
} catch {
showError(t('dedup.mergeFailed'));
}
}, [duplicates, selectedPair, fieldOverrides, mergeContacts, success, showError, t]);
const compareFields = [
{ key: 'displayname', label: t('dedup.fields.displayName') },
{ key: 'email_1', label: t('dedup.fields.email') },
{ key: 'phone_1', label: t('dedup.fields.phone') },
{ key: 'mailing_city', label: t('dedup.fields.city') },
{ key: 'mailing_postalcode', label: t('dedup.fields.postalCode') },
];
return (
<Modal open={open} onClose={onClose} title={t('dedup.title')} size="xl">
<div className="space-y-4" data-testid="dedup-dialog">
<div className="flex items-center gap-3">
<Button
variant="primary"
icon={<Search className="w-4 h-4" />}
onClick={handleSearch}
isLoading={findDuplicates.isPending}
data-testid="dedup-search-btn"
>
{t('dedup.findDuplicates')}
</Button>
{duplicates.length > 0 && (
<Badge variant="info">{duplicates.length} {t('dedup.pairsFound')}</Badge>
)}
</div>
{duplicates.length === 0 && !findDuplicates.isPending && (
<p className="text-sm text-secondary-500" data-testid="dedup-empty">
{t('dedup.noDuplicates')}
</p>
)}
{duplicates.map((pair, idx) => (
<div
key={`${pair.source_contact.id}-${pair.target_contact.id}`}
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
selectedPair === idx ? 'border-primary-500 bg-primary-50' : 'border-secondary-200'
}`}
onClick={() => setSelectedPair(idx)}
data-testid={`dedup-pair-${idx}`}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-warning-500" />
<span className="font-medium">
{t('dedup.similarity')}: {Math.round(pair.similarity_score * 100)}%
</span>
</div>
<div className="flex gap-1">
{pair.match_reasons.map((reason) => (
<Badge key={reason} variant="warning">{reason}</Badge>
))}
</div>
</div>
{selectedPair === idx && (
<div className="mt-4 space-y-3">
<div className="grid grid-cols-3 gap-2 text-sm font-medium text-secondary-600">
<div>{t('dedup.field')}</div>
<div className="text-center">{t('dedup.source')}</div>
<div className="text-center">{t('dedup.target')}</div>
</div>
{compareFields.map((field) => {
const sourceVal = (pair.source_contact as unknown as Record<string, unknown>)[field.key] as string | null;
const targetVal = (pair.target_contact as unknown as Record<string, unknown>)[field.key] as string | null;
return (
<div key={field.key} className="grid grid-cols-3 gap-2 text-sm">
<div className="text-secondary-700">{field.label}</div>
<div className="text-center">
<button
className={`px-2 py-1 rounded ${
fieldOverrides[field.key] === 'source' ? 'bg-primary-100 text-primary-700' : 'hover:bg-secondary-100'
}`}
onClick={(e) => {
e.stopPropagation();
setFieldOverrides((prev) => ({ ...prev, [field.key]: 'source' }));
}}
data-testid={`dedup-field-${field.key}-source`}
>
{sourceVal || '—'}
</button>
</div>
<div className="text-center">
<button
className={`px-2 py-1 rounded ${
fieldOverrides[field.key] === 'target' ? 'bg-primary-100 text-primary-700' : 'hover:bg-secondary-100'
}`}
onClick={(e) => {
e.stopPropagation();
setFieldOverrides((prev) => ({ ...prev, [field.key]: 'target' }));
}}
data-testid={`dedup-field-${field.key}-target`}
>
{targetVal || '—'}
</button>
</div>
</div>
);
})}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="primary"
icon={<GitMerge className="w-4 h-4" />}
onClick={handleMerge}
isLoading={mergeContacts.isPending}
data-testid="dedup-merge-btn"
>
{t('dedup.merge')}
</Button>
</div>
</div>
)}
{selectedPair !== idx && (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="font-medium text-secondary-800">{pair.source_contact.displayname}</div>
<div className="text-secondary-500">{pair.source_contact.email_1 || '—'}</div>
</div>
<div>
<div className="font-medium text-secondary-800">{pair.target_contact.displayname}</div>
<div className="text-secondary-500">{pair.target_contact.email_1 || '—'}</div>
</div>
</div>
)}
</div>
))}
</div>
</Modal>
);
}
@@ -1,139 +0,0 @@
/**
* AskKnowledge — query input + answer + evidence cards.
*
* Calls POST /api/v1/knowledge/ask with `{ query, source_types }` and renders
* the returned answer plus evidence cards.
*/
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Search, Sparkles } from 'lucide-react';
import { askKnowledge, type KnowledgeAskResponse } from '@/api/knowledge';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState';
const SOURCE_TYPES = ['contact', 'company', 'wiki', 'dms_file', 'email', 'task'];
export function AskKnowledge() {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [sourceTypes, setSourceTypes] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<KnowledgeAskResponse | null>(null);
const toggleSource = useCallback((type: string) => {
setSourceTypes((prev) =>
prev.includes(type) ? prev.filter((s) => s !== type) : [...prev, type]
);
}, []);
const handleAsk = useCallback(async () => {
if (!query.trim()) return;
setLoading(true);
setError(null);
try {
const response = await askKnowledge({
query: query.trim(),
source_types: sourceTypes.length > 0 ? sourceTypes : undefined,
});
setResult(response);
} catch (err) {
setError(err instanceof Error ? err.message : t('knowledge.ask.error'));
} finally {
setLoading(false);
}
}, [query, sourceTypes, t]);
return (
<Card title={t('knowledge.ask.title')} description={t('knowledge.ask.description')}>
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-secondary-400" aria-hidden="true" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleAsk();
}}
placeholder={t('knowledge.ask.placeholder')}
className="pl-9"
aria-label={t('knowledge.ask.placeholder')}
/>
</div>
<Button onClick={() => void handleAsk()} isLoading={loading} icon={<Sparkles className="h-4 w-4" aria-hidden="true" />}>
{t('knowledge.ask.submit')}
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-secondary-500">{t('knowledge.ask.sourceTypes')}</span>
{SOURCE_TYPES.map((type) => {
const active = sourceTypes.includes(type);
return (
<button
key={type}
type="button"
onClick={() => toggleSource(type)}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium min-h-touch transition-colors ${
active ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200'
}`}
aria-pressed={active}
>
{type}
</button>
);
})}
</div>
{error && <p className="text-sm text-danger-600" role="alert">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</p>}
{loading && (
<div className="flex items-center justify-center py-10" role="status" aria-label={t('common.loading')}>
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
</div>
)}
{!loading && result && (
<div className="space-y-4">
<div className="border border-secondary-200 rounded-lg p-4 bg-secondary-50">
<h4 className="text-sm font-semibold text-secondary-900 mb-2">{t('knowledge.ask.answer')}</h4>
<p className="text-sm text-secondary-800 whitespace-pre-wrap">{result.answer}</p>
</div>
<div>
<h4 className="text-sm font-semibold text-secondary-900 mb-2">{t('knowledge.ask.evidence')}</h4>
{result.evidence.length === 0 ? (
<EmptyState
title={t('knowledge.ask.noEvidence')}
description={t('knowledge.ask.noEvidenceDescription')}
/>
) : (
<ul className="space-y-2">
{result.evidence.map((evidence) => (
<li key={evidence.id} className="border border-secondary-200 rounded-lg p-3 bg-white">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-sm font-medium text-secondary-900">{evidence.title}</span>
<Badge variant="info">{evidence.source_type}</Badge>
</div>
<p className="text-sm text-secondary-600 line-clamp-3">{evidence.snippet}</p>
{typeof evidence.score === 'number' && (
<p className="text-xs text-secondary-400 mt-1">
{t('knowledge.ask.score')}: {Math.round(evidence.score * 100)}%
</p>
)}
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
</Card>
);
}
@@ -1,369 +0,0 @@
/**
* KnowledgeGraph — SVG-based visualization of entity relationships.
*
* Fetches relationships from GET /api/v1/graph/relationships and renders
* Contacts/Companies as nodes (circles) connected by labeled edges.
* Supports pan/zoom and click-to-inspect details.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
import { fetchGraphRelationships, type GraphRelationship } from '@/api/knowledge';
import { Card } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { Button } from '@/components/ui/Button';
interface GraphNode {
id: string;
entity_type: string;
label: string;
x: number;
y: number;
}
interface GraphEdge {
id: string;
source: string;
target: string;
label: string;
}
interface SelectedNode {
id: string;
entity_type: string;
label: string;
relationships: GraphRelationship[];
}
const NODE_RADIUS = 24;
const WIDTH = 900;
const HEIGHT = 560;
const TYPE_COLORS: Record<string, string> = {
contact: '#3b82f6',
company: '#10b981',
email: '#8b5cf6',
task: '#f59e0b',
dms_file: '#ef4444',
default: '#64748b',
};
function typeColor(type: string): string {
return TYPE_COLORS[type] ?? TYPE_COLORS.default;
}
function typeLabel(type: string): string {
return type.replace(/_/g, ' ');
}
/**
* Simple force-directed layout: nodes repel, edges attract.
* Deterministic initial placement avoids layout jumps.
*/
function layoutNodes(relationships: GraphRelationship[]): { nodes: GraphNode[]; edges: GraphEdge[] } {
const nodeMap = new Map<string, { entity_type: string; label: string }>();
const edges: GraphEdge[] = [];
for (const rel of relationships) {
const sourceKey = `${rel.source_type}:${rel.source_id}`;
const targetKey = `${rel.target_type}:${rel.target_id}`;
if (!nodeMap.has(sourceKey)) {
nodeMap.set(sourceKey, { entity_type: rel.source_type, label: rel.source_type });
}
if (!nodeMap.has(targetKey)) {
nodeMap.set(targetKey, { entity_type: rel.target_type, label: rel.target_type });
}
edges.push({
id: rel.id,
source: sourceKey,
target: targetKey,
label: rel.relationship_type,
});
}
const keys = Array.from(nodeMap.keys());
const nodes: GraphNode[] = keys.map((key, i) => {
const angle = (i / Math.max(keys.length, 1)) * Math.PI * 2;
const radius = Math.min(220, 80 + (i % 5) * 40);
return {
id: key,
entity_type: nodeMap.get(key)?.entity_type ?? 'unknown',
label: nodeMap.get(key)?.label ?? key,
x: WIDTH / 2 + Math.cos(angle) * radius,
y: HEIGHT / 2 + Math.sin(angle) * radius,
};
});
// Simple repulsion/attraction relaxation
for (let iter = 0; iter < 80; iter++) {
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const force = 40 / (dist * dist);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
nodes[i].x -= fx;
nodes[i].y -= fy;
nodes[j].x += fx;
nodes[j].y += fy;
}
}
for (const edge of edges) {
const s = nodes.find((n) => n.id === edge.source);
const t = nodes.find((n) => n.id === edge.target);
if (!s || !t) continue;
const dx = t.x - s.x;
const dy = t.y - s.y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const force = (dist - 160) * 0.02;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
s.x += fx;
s.y += fy;
t.x -= fx;
t.y -= fy;
}
}
// Clamp to viewport with padding
for (const n of nodes) {
n.x = Math.min(Math.max(n.x, 60), WIDTH - 60);
n.y = Math.min(Math.max(n.y, 60), HEIGHT - 60);
}
return { nodes, edges };
}
export function KnowledgeGraph() {
const { t } = useTranslation();
const [relationships, setRelationships] = useState<GraphRelationship[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<GraphNode | null>(null);
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const dragStart = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await fetchGraphRelationships({ page_size: 200 });
setRelationships(result.items);
} catch (err) {
setError(err instanceof Error ? err.message : t('knowledge.graph.loadError'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void load();
}, [load]);
const { nodes, edges } = useMemo(() => layoutNodes(relationships), [relationships]);
const selectedRelationships = useMemo(() => {
if (!selected) return [];
return relationships.filter(
(r) =>
`${r.source_type}:${r.source_id}` === selected.id ||
`${r.target_type}:${r.target_id}` === selected.id
);
}, [selected, relationships]);
const handleNodeClick = useCallback((node: GraphNode) => {
setSelected(node);
}, []);
const handleWheel = useCallback((e: React.WheelEvent<SVGSVGElement>) => {
const factor = e.deltaY > 0 ? 0.9 : 1.1;
setZoom((z) => Math.min(Math.max(z * factor, 0.4), 3));
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
if (e.button !== 0) return;
setDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
}, [pan]);
const handleMouseMove = useCallback(
(e: React.MouseEvent<SVGSVGElement>) => {
if (!dragging || !dragStart.current) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
setPan({ x: dragStart.current.panX + dx, y: dragStart.current.panY + dy });
},
[dragging]
);
const handleMouseUp = useCallback(() => {
setDragging(false);
dragStart.current = null;
}, []);
const resetView = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
return (
<Card
title={t('knowledge.graph.title')}
description={t('knowledge.graph.description')}
actions={
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => setZoom((z) => Math.min(z * 1.2, 3))} aria-label={t('knowledge.graph.zoomIn')}>
<ZoomIn className="h-4 w-4" aria-hidden="true" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setZoom((z) => Math.max(z * 0.8, 0.4))} aria-label={t('knowledge.graph.zoomOut')}>
<ZoomOut className="h-4 w-4" aria-hidden="true" />
</Button>
<Button variant="ghost" size="sm" onClick={resetView} aria-label={t('knowledge.graph.reset')}>
<Maximize2 className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
}
>
{loading && (
<div className="flex items-center justify-center py-16" role="status" aria-label={t('common.loading')}>
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
</div>
)}
{!loading && error && (
<div className="py-8 text-center">
<p className="text-danger-600 text-sm mb-4">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</p>
<Button variant="secondary" onClick={() => void load()}>
{t('common.retry')}
</Button>
</div>
)}
{!loading && !error && relationships.length === 0 && (
<EmptyState
title={t('knowledge.graph.emptyTitle')}
description={t('knowledge.graph.emptyDescription')}
/>
)}
{!loading && !error && relationships.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2 border border-secondary-200 rounded-lg overflow-hidden bg-secondary-50">
<svg
width="100%"
height={HEIGHT}
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
className="cursor-grab active:cursor-grabbing touch-none select-none"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
role="img"
aria-label={t('knowledge.graph.ariaLabel')}
>
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
{/* Edges */}
{edges.map((edge) => {
const source = nodes.find((n) => n.id === edge.source);
const target = nodes.find((n) => n.id === edge.target);
if (!source || !target) return null;
const mx = (source.x + target.x) / 2;
const my = (source.y + target.y) / 2;
return (
<g key={edge.id}>
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke="#94a3b8"
strokeWidth={1.5}
/>
<text
x={mx}
y={my - 6}
textAnchor="middle"
fontSize={11}
fill="#64748b"
className="pointer-events-none"
>
{edge.label}
</text>
</g>
);
})}
{/* Nodes */}
{nodes.map((node) => {
const isSelected = selected?.id === node.id;
return (
<g
key={node.id}
transform={`translate(${node.x}, ${node.y})`}
onClick={(e) => {
e.stopPropagation();
handleNodeClick(node);
}}
className="cursor-pointer"
role="button"
aria-label={`${node.label} ${node.entity_type}`}
>
<circle
r={NODE_RADIUS}
fill={typeColor(node.entity_type)}
fillOpacity={isSelected ? 1 : 0.85}
stroke={isSelected ? '#0f172a' : '#ffffff'}
strokeWidth={isSelected ? 3 : 2}
/>
<text
textAnchor="middle"
dy="0.35em"
fontSize={11}
fill="#ffffff"
fontWeight={600}
className="pointer-events-none"
>
{node.label.length > 12 ? `${node.label.slice(0, 11)}` : node.label}
</text>
</g>
);
})}
</g>
</svg>
</div>
<div>
{selected ? (
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
<h4 className="font-semibold text-secondary-900 mb-1">{selected.label}</h4>
<p className="text-sm text-secondary-500 mb-3">{typeLabel(selected.entity_type)}</p>
<p className="text-sm font-medium text-secondary-700 mb-2">{t('knowledge.graph.relationships')}</p>
{selectedRelationships.length === 0 && (
<p className="text-sm text-secondary-400">{t('knowledge.graph.noRelationships')}</p>
)}
<ul className="space-y-2">
{selectedRelationships.map((rel) => {
const isSource = `${rel.source_type}:${rel.source_id}` === selected.id;
const otherType = isSource ? rel.target_type : rel.source_type;
return (
<li key={rel.id} className="text-sm text-secondary-700">
<span className="font-medium">{rel.relationship_type}</span>
<span className="text-secondary-400"> </span>
<span>{typeLabel(otherType)}</span>
</li>
);
})}
</ul>
</div>
) : (
<div className="border border-secondary-200 rounded-lg p-4 bg-white text-sm text-secondary-500">
{t('knowledge.graph.selectHint')}
</div>
)}
</div>
</div>
)}
</Card>
);
}
@@ -1,46 +0,0 @@
/**
* Mail search bar — input for full-text mail search.
*/
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/Input';
import { Search } from 'lucide-react';
export interface MailSearchBarProps {
onSearch: (query: string) => void;
value?: string;
}
export function MailSearchBar({ onSearch }: MailSearchBarProps) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
}, []);
const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
onSearch(query.trim());
}, [query, onSearch]);
return (
<form onSubmit={handleSubmit} className="relative" data-testid="mail-search-bar">
<Input
type="search"
value={query}
onChange={handleChange}
placeholder={t('mail.searchPlaceholder')}
aria-label={t('common.search')}
/>
<button
type="submit"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch"
aria-label={t('common.search')}
>
<Search className="w-4 h-4 text-secondary-400" aria-hidden="true" strokeWidth={2} />
</button>
</form>
);
}
@@ -1,34 +0,0 @@
/**
* Shared mailbox selector — switch between personal and shared accounts.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Select } from '@/components/ui/Select';
import type { MailAccount } from '@/api/mail';
export interface SharedMailboxSelectorProps {
accounts: MailAccount[];
selectedAccountId: string;
onSelect: (accountId: string) => void;
}
export function SharedMailboxSelector({ accounts, selectedAccountId, onSelect }: SharedMailboxSelectorProps) {
const { t } = useTranslation();
const options = accounts.map((acc) => ({
value: acc.id,
label: `${acc.display_name} (${acc.email})${acc.is_shared ? ' — ' + t('mail.shared') : ''}`,
}));
return (
<div data-testid="shared-mailbox-selector">
<Select
label={t('mail.selectAccount')}
value={selectedAccountId}
onChange={(e) => onSelect(e.target.value)}
options={options}
/>
</div>
);
}
@@ -1,155 +0,0 @@
import { asError } from '@/utils/errorTypes';
import React, { useState, useRef, useCallback } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { useToast } from '@/components/ui/Toast';
import { apiClient } from '@/api/client';
import { useTranslation } from 'react-i18next';
export interface CsvImportDialogProps {
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
interface ParsedRow {
[key: string]: string;
}
function parseCSV(text: string): { headers: string[]; rows: ParsedRow[] } {
const lines = text.trim().split(/\n/);
if (lines.length === 0) return { headers: [], rows: [] };
const headers = lines[0].split(',').map((h) => h.trim());
const rows: ParsedRow[] = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const values = lines[i].split(',').map((v) => v.trim());
const row: ParsedRow = {};
headers.forEach((header, idx) => {
row[header] = values[idx] || '';
});
rows.push(row);
}
return { headers, rows };
}
export function CsvImportDialog({ open, onClose, onSuccess }: CsvImportDialogProps) {
const { t } = useTranslation();
const toast = useToast();
const [importing, setImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewData, setPreviewData] = useState<{ headers: string[]; rows: ParsedRow[] } | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.csv')) {
setError('Bitte wählen Sie eine CSV-Datei aus.');
return;
}
setError(null);
setSelectedFile(file);
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result as string;
const parsed = parseCSV(text);
setPreviewData(parsed);
};
reader.readAsText(file);
}, []);
const handleImport = async () => {
if (!selectedFile) return;
setImporting(true);
try {
const formData = new FormData();
formData.append('file', selectedFile);
await apiClient.post('/contacts/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
toast.success('Import erfolgreich abgeschlossen.');
setSelectedFile(null);
setPreviewData(null);
setError(null);
onSuccess?.();
onClose();
} catch (err: unknown) { const errObj = asError(err);
toast.error(errObj.message || 'Import fehlgeschlagen.');
} finally {
setImporting(false);
}
};
const handleClose = () => {
setSelectedFile(null);
setPreviewData(null);
setError(null);
onClose();
};
return (
<Modal open={open} onClose={handleClose} title="CSV Import" size="lg" >
<div className="space-y-4" data-testid="csv-import-dialog">
<div>
<p className="text-sm text-secondary-600 mb-3">
Wählen Sie eine CSV-Datei mit Firmendaten. Erforderliche Spalte: name.
Optionale Spalten: account_number, industry, phone, email, website, description.
</p>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileSelect}
className="block w-full text-sm text-secondary-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 min-h-touch"
aria-label="CSV-Datei auswählen"
data-testid="csv-file-input"
/>
{error && <p className="mt-2 text-sm text-danger-600" role="alert">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</p>}
</div>
{previewData && previewData.rows.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-secondary-900 mb-2">Vorschau ({previewData.rows.length} Datensätze)</h4>
<div className="overflow-x-auto border border-secondary-200 rounded-md max-h-60">
<table className="min-w-full text-sm">
<thead className="bg-secondary-50 sticky top-0">
<tr>
{previewData.headers.map((header) => (
<th key={header} className="px-3 py-2 text-left font-semibold text-secondary-600">{header}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{previewData.rows.slice(0, 10).map((row, idx) => (
<tr key={idx}>
{previewData.headers.map((header) => (
<td key={header} className="px-3 py-2 text-secondary-900">{row[header]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{previewData.rows.length > 10 && (
<p className="text-xs text-secondary-500 mt-1">Zeige 10 von {previewData.rows.length} Datensätzen.</p>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={handleClose}>{t('common.cancel')}</Button>
<Button
onClick={handleImport}
disabled={!selectedFile || importing}
isLoading={importing}
data-testid="csv-import-button"
>
{t('common.save')}
</Button>
</div>
</div>
</Modal>
);
}
@@ -1,28 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { useLocation, useNavigate, useBlocker } from 'react-router-dom';
export interface UnsavedChangesGuardProps {
isDirty: boolean;
message?: string;
onConfirm?: () => void;
}
export function UnsavedChangesGuard({ isDirty, message = 'Sie haben ungespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?', onConfirm }: UnsavedChangesGuardProps) {
const blocker = useBlocker(isDirty);
const messageRef = useRef(message);
messageRef.current = message;
useEffect(() => {
if (blocker.state === 'blocked') {
const confirmed = window.confirm(messageRef.current);
if (confirmed) {
onConfirm?.();
blocker.proceed();
} else {
blocker.reset();
}
}
}, [blocker, onConfirm]);
return null;
}
@@ -1,171 +0,0 @@
import clsx from 'clsx';
/**
* Bulk tag assignment dialog.
* Assigns selected tags to multiple entities at once.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { EmptyState } from '@/components/ui/EmptyState';
import { useToast } from '@/components/ui/Toast';
import {
fetchTags,
bulkAssignTags,
type Tag,
type EntityType,
} from '@/api/tags';
export interface BulkTagDialogProps {
open: boolean;
entityType: EntityType;
entityIds: string[];
onClose: () => void;
onAssigned: () => void;
}
export function BulkTagDialog({
open,
entityType,
entityIds,
onClose,
onAssigned,
}: BulkTagDialogProps) {
const { t } = useTranslation();
const toast = useToast();
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) {
setLoading(true);
fetchTags()
.then((allTags) => {
setTags(allTags);
})
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
})
.finally(() => setLoading(false));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const filteredTags = tags.filter((tag) =>
tag.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleToggleTag = useCallback((tagId: string) => {
setSelectedTagIds((prev) => {
const next = new Set(prev);
if (next.has(tagId)) {
next.delete(tagId);
} else {
next.add(tagId);
}
return next;
});
}, []);
const handleAssign = useCallback(async () => {
if (selectedTagIds.size === 0 || entityIds.length === 0) return;
setSubmitting(true);
try {
await bulkAssignTags({
tag_ids: Array.from(selectedTagIds),
entity_type: entityType,
entity_ids: entityIds,
});
toast.success(t('tags.assignSuccess'));
setSelectedTagIds(new Set());
setSearchQuery('');
onAssigned();
onClose();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
}
setSubmitting(false);
}, [selectedTagIds, entityIds, entityType, toast, t, onAssigned, onClose]);
return (
<Modal
open={open}
onClose={onClose}
title={t('tags.bulkAssignTitle')}
size="md"
data-testid="bulk-tag-dialog"
>
<div className="space-y-4">
<p className="text-sm text-secondary-500">
{t('tags.selectedEntities', { count: entityIds.length })}
</p>
<Input
label={t('tags.search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('tags.search')}
/>
{loading ? (
<p className="text-sm text-secondary-500">{t('tags.loading')}...</p>
) : filteredTags.length === 0 ? (
<EmptyState title={t('tags.noTags')} />
) : (
<div className="flex flex-wrap gap-2 max-h-60 overflow-y-auto" role="list" data-testid="bulk-tag-list">
{filteredTags.map((tag) => {
const isSelected = selectedTagIds.has(tag.id);
return (
<button
key={tag.id}
onClick={() => handleToggleTag(tag.id)}
className={clsx(
'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium motion-safe:transition-colors min-h-touch',
isSelected
? 'bg-primary-600 text-white'
: 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200'
)}
aria-pressed={isSelected}
aria-label={`${tag.name} ${isSelected ? '(selected)' : ''}`}
>
<span
className="w-2 h-2 rounded-full inline-block"
style={{ backgroundColor: tag.color }}
aria-hidden="true"
/>
{tag.name}
</button>
);
})}
</div>
)}
{selectedTagIds.size > 0 && (
<p className="text-sm text-primary-600">
{t('tags.selectTags', { count: selectedTagIds.size })}
</p>
)}
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose}>
{t('tags.cancel')}
</Button>
<Button
onClick={handleAssign}
isLoading={submitting}
disabled={selectedTagIds.size === 0 || entityIds.length === 0}
>
{t('tags.bulkAssign')}
</Button>
</div>
</div>
</Modal>
);
}
-75
View File
@@ -1,75 +0,0 @@
/**
* Tag cloud display component.
* Renders tags with font sizes proportional to usage count.
*/
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { EmptyState } from '@/components/ui/EmptyState';
import type { Tag } from '@/api/tags';
export interface TagCloudProps {
tags: Tag[];
onTagClick?: (tag: Tag) => void;
loading?: boolean;
}
export function TagCloud({ tags, onTagClick, loading = false }: TagCloudProps) {
const { t } = useTranslation();
const tagsWithSize = useMemo(() => {
if (tags.length === 0) return [];
const maxCount = Math.max(...tags.map((tag) => tag.usage_count || 0), 1);
const minCount = Math.min(...tags.map((tag) => tag.usage_count || 0), 0);
const range = maxCount - minCount || 1;
return tags.map((tag) => {
const count = tag.usage_count || 0;
const ratio = (count - minCount) / range;
const sizeClass =
ratio > 0.75 ? 'text-2xl' :
ratio > 0.5 ? 'text-xl' :
ratio > 0.25 ? 'text-lg' :
'text-base';
return { tag, sizeClass };
});
}, [tags]);
if (loading) {
return (
<div className="flex flex-wrap gap-3 items-center justify-center py-8" data-testid="tag-cloud-loading">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-6 bg-secondary-100 rounded animate-pulse" style={{ width: `${60 + i * 20}px` }} />
))}
</div>
);
}
if (tags.length === 0) {
return (
<div data-testid="tag-cloud-empty">
<EmptyState title={t('tags.noTags')} />
</div>
);
}
return (
<div className="flex flex-wrap gap-3 items-center justify-center py-8" data-testid="tag-cloud" role="list">
{tagsWithSize.map(({ tag, sizeClass }) => (
<button
key={tag.id}
onClick={() => onTagClick?.(tag)}
className={`${sizeClass} font-medium text-secondary-700 hover:text-primary-600 motion-safe:transition-colors min-h-touch px-2 py-1 rounded`}
aria-label={`${tag.name} (${tag.usage_count || 0} ${t('tags.usageCount')})`}
>
<span
className="inline-block w-3 h-3 rounded-full mr-1 align-middle"
style={{ backgroundColor: tag.color }}
aria-hidden="true"
/>
{tag.name}
</button>
))}
</div>
);
}
-248
View File
@@ -1,248 +0,0 @@
import clsx from 'clsx';
/**
* Tag picker for entity detail pages.
* Shows assigned tags and allows assigning/unassigning tags.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { EmptyState } from '@/components/ui/EmptyState';
import { useToast } from '@/components/ui/Toast';
import { X } from 'lucide-react';
import {
fetchTags,
assignTag,
unassignTag,
createTag,
type Tag,
type EntityType,
} from '@/api/tags';
export interface TagPickerProps {
entityType: EntityType;
entityId: string;
assignedTags?: Tag[];
}
export function TagPicker({ entityType, entityId, assignedTags: initialAssigned = [] }: TagPickerProps) {
const { t } = useTranslation();
const toast = useToast();
const [allTags, setAllTags] = useState<Tag[]>([]);
const [assignedTags, setAssignedTags] = useState<Tag[]>(initialAssigned);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [submitting, setSubmitting] = useState(false);
// ── Tag create form (RHF + Zod) ──
const tagSchema = z.object({
name: z.string().min(1, 'required'),
color: z.string().optional().default('#3B82F6'),
});
type TagFormData = z.infer<typeof tagSchema>;
const { register: registerTag, handleSubmit: handleSubmitTag, reset: resetTag, watch: watchTag, setValue: setTagValue, formState: { errors: tagErrors } } = useForm<TagFormData>({
resolver: zodResolver(tagSchema),
defaultValues: { name: '', color: '#3B82F6' },
});
const newTagColor = watchTag('color');
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchTags()
.then((tags) => {
if (cancelled) return;
setAllTags(tags);
})
.catch((err) => {
if (cancelled) return;
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [toast]);
const assignedTagIds = new Set(assignedTags.map((tag) => tag.id));
const filteredTags = allTags.filter((tag) => {
const matchesSearch = tag.name.toLowerCase().includes(searchQuery.toLowerCase());
const isAssigned = assignedTagIds.has(tag.id);
return matchesSearch && !isAssigned;
});
const handleAssign = useCallback(async (tagId: string) => {
setSubmitting(true);
try {
await assignTag({ tag_id: tagId, entity_type: entityType, entity_id: entityId });
const tag = allTags.find((t) => t.id === tagId) || assignedTags.find((t) => t.id === tagId);
if (tag) {
setAssignedTags((prev) => [...prev, tag]);
}
toast.success(t('tags.assignSuccess'));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
}
setSubmitting(false);
}, [entityType, entityId, allTags, assignedTags, toast, t]);
const handleUnassign = useCallback(async (tagId: string) => {
setSubmitting(true);
try {
await unassignTag({ tag_id: tagId, entity_type: entityType, entity_id: entityId });
setAssignedTags((prev) => prev.filter((tag) => tag.id !== tagId));
toast.success(t('tags.unassignSuccess'));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
}
setSubmitting(false);
}, [entityType, entityId, toast, t]);
const handleCreateTag = useCallback(async (data: TagFormData) => {
setSubmitting(true);
try {
const tag = await createTag({ name: data.name.trim(), color: data.color });
setAllTags((prev) => [...prev, tag]);
await handleAssign(tag.id);
resetTag({ name: '', color: '#3B82F6' });
setShowCreateForm(false);
toast.success(t('tags.createSuccess'));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
}
setSubmitting(false);
}, [handleAssign, toast, t, resetTag]);
const colorOptions = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#F97316', '#EC4899', '#6B7280'];
return (
<div className="space-y-4" data-testid="tag-picker">
{/* Assigned tags */}
<div className="space-y-2">
<h3 className="text-sm font-semibold text-secondary-900">{t('tags.assignedTags')}</h3>
{loading ? (
<p className="text-sm text-secondary-500">{t('tags.loading')}...</p>
) : assignedTags.length === 0 ? (
<EmptyState title={t('tags.noTagsAssigned')} />
) : (
<div className="flex flex-wrap gap-2" role="list" aria-label={t('tags.assignedTags')}>
{assignedTags.map((tag) => (
<div key={tag.id} className="flex items-center">
<Badge
variant="primary"
className="cursor-default"
>
<span
className="w-2 h-2 rounded-full inline-block mr-1"
style={{ backgroundColor: tag.color }}
aria-hidden="true"
/>
{tag.name}
</Badge>
<button
onClick={() => handleUnassign(tag.id)}
className="ml-1 text-secondary-400 hover:text-danger-600 min-h-touch min-w-touch"
aria-label={t('tags.removeTag')}
disabled={submitting}
>
<X className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
</button>
</div>
))}
</div>
)}
</div>
{/* Search and assign */}
<div className="space-y-3">
<Input
label={t('tags.search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('tags.search')}
/>
{filteredTags.length > 0 && (
<div className="flex flex-wrap gap-2" role="list" aria-label={t('tags.availableTags')} data-testid="available-tags-list">
{filteredTags.map((tag) => (
<button
key={tag.id}
onClick={() => handleAssign(tag.id)}
disabled={submitting}
className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-700 hover:bg-primary-100 hover:text-primary-700 motion-safe:transition-colors min-h-touch disabled:opacity-50"
aria-label={t('tags.addTag')}
>
<span
className="w-2 h-2 rounded-full inline-block"
style={{ backgroundColor: tag.color }}
aria-hidden="true"
/>
{tag.name}
</button>
))}
</div>
)}
{searchQuery && filteredTags.length === 0 && !loading && (
<p className="text-sm text-secondary-500">{t('tags.noTags')}</p>
)}
{/* Create new tag */}
{!showCreateForm ? (
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateForm(true)}
>
{t('tags.create')}
</Button>
) : (
<div className="space-y-3 p-4 border border-secondary-200 rounded-lg" data-testid="create-tag-form">
<form onSubmit={handleSubmitTag(handleCreateTag)}>
<Input
label={t('tags.tagName')}
{...registerTag('name')}
error={tagErrors.name?.message === 'required' ? t('validation.required') : undefined}
placeholder={t('tags.tagName')}
/>
<div className="space-y-1">
<label className="block text-sm font-medium text-secondary-700">{t('tags.tagColor')}</label>
<div className="flex items-center gap-2">
{colorOptions.map((color) => (
<button
key={color}
type="button"
onClick={() => setTagValue('color', color)}
className={clsx(
'w-6 h-6 rounded-full transition-transform',
newTagColor === color ? 'ring-2 ring-offset-2 ring-secondary-400 scale-110' : ''
)}
style={{ backgroundColor: color }}
aria-label={`${t('tags.tagColor')}: ${color}`}
aria-pressed={newTagColor === color}
/>
))}
</div>
</div>
<div className="flex gap-2">
<Button size="sm" type="submit" isLoading={submitting}>{t('tags.save')}</Button>
<Button variant="secondary" size="sm" type="button" onClick={() => setShowCreateForm(false)}>{t('tags.cancel')}</Button>
</div>
</form>
</div>
)}
</div>
</div>
);
}
-152
View File
@@ -1,152 +0,0 @@
/**
* GoalView — goal overview with progress bar and milestone hierarchy.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
import { useTask, useListSubtasks, type Task, type TaskStatus } from '@/api/tasks';
import { Loader2, Target, Flag } from 'lucide-react';
const STATUS_VARIANTS: Record<TaskStatus, 'secondary' | 'info' | 'warning' | 'success' | 'danger'> = {
open: 'secondary',
in_progress: 'info',
review: 'warning',
blocked: 'danger',
done: 'success',
cancelled: 'secondary',
};
function statusLabel(t: (k: string) => string, status: TaskStatus): string {
const map: Record<TaskStatus, string> = {
open: t('tasks.statusOpen'),
in_progress: t('tasks.statusInProgress'),
review: t('tasks.statusReview'),
blocked: t('tasks.statusBlocked'),
done: t('tasks.statusDone'),
cancelled: t('tasks.statusCancelled'),
};
return map[status];
}
interface GoalViewProps {
goalId: string;
onSelectTask?: (task: Task) => void;
}
export function GoalView({ goalId, onSelectTask }: GoalViewProps) {
const { t } = useTranslation();
const { data: goal, isLoading } = useTask(goalId);
const { data: children } = useListSubtasks(goalId);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12" role="status">
<Loader2 className="h-6 w-6 animate-spin text-primary-500" aria-hidden="true" />
<span className="sr-only">{t('common.loading')}</span>
</div>
);
}
if (!goal) {
return (
<Card title={t('tasks.title')}>
<p className="text-sm text-gray-500">{t('tasks.noTasks')}</p>
</Card>
);
}
const progress = goal.progress ?? 0;
const milestones = (children ?? []).filter((c) => c.task_type === 'milestone');
const todos = (children ?? []).filter((c) => c.task_type !== 'milestone');
return (
<Card title={goal.title}>
<div className="space-y-4">
{goal.description ? <p className="text-sm text-gray-700">{goal.description}</p> : null}
<div className="flex items-center gap-2">
<Badge variant={STATUS_VARIANTS[goal.status]}>{statusLabel(t, goal.status)}</Badge>
<Badge variant="primary">{progress}%</Badge>
{goal.target_date ? (
<Badge variant="info">
{t('tasks.targetDate')}: {new Date(goal.target_date).toLocaleDateString()}
</Badge>
) : null}
</div>
{/* Progress bar */}
<div>
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span className="inline-flex items-center gap-1">
<Target className="h-3 w-3" aria-hidden="true" />
{t('tasks.progress')}
</span>
<span>{progress}%</span>
</div>
<div
className="h-2 w-full overflow-hidden rounded-full bg-gray-200"
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="h-full rounded-full bg-primary-500 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Milestones */}
{milestones.length > 0 ? (
<div>
<h4 className="mb-2 flex items-center gap-1 text-sm font-semibold text-gray-700">
<Flag className="h-4 w-4" aria-hidden="true" />
{t('tasks.milestones')}
</h4>
<div className="space-y-1">
{milestones.map((m) => (
<button
key={m.id}
type="button"
onClick={() => onSelectTask?.(m)}
className="flex w-full items-center justify-between rounded border border-gray-200 px-3 py-2 text-left hover:bg-gray-50"
>
<span className="text-sm text-gray-800">{m.title}</span>
<span className="flex items-center gap-2">
<span className="text-xs text-gray-500">{m.progress ?? 0}%</span>
<Badge variant={STATUS_VARIANTS[m.status]}>{statusLabel(t, m.status)}</Badge>
</span>
</button>
))}
</div>
</div>
) : null}
{/* Todos */}
{todos.length > 0 ? (
<div>
<h4 className="mb-2 text-sm font-semibold text-gray-700">{t('tasks.subtasks')}</h4>
<div className="space-y-1">
{todos.map((todo) => (
<button
key={todo.id}
type="button"
onClick={() => onSelectTask?.(todo)}
className="flex w-full items-center justify-between rounded border border-gray-200 px-3 py-2 text-left hover:bg-gray-50"
>
<span className="text-sm text-gray-800">{todo.title}</span>
<Badge variant={STATUS_VARIANTS[todo.status]}>{statusLabel(t, todo.status)}</Badge>
</button>
))}
</div>
</div>
) : null}
</div>
</Card>
);
}
export default GoalView;
-140
View File
@@ -1,140 +0,0 @@
/**
* TaskBoard — Kanban view with columns by lifecycle status.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
import { useTasks, type Task, type TaskStatus, type TaskFilter } from '@/api/tasks';
import { Clock, AlertCircle, CheckCircle2, Loader2 } from 'lucide-react';
const STATUS_COLUMNS: TaskStatus[] = ['open', 'in_progress', 'review', 'blocked', 'done', 'cancelled'];
const STATUS_VARIANTS: Record<TaskStatus, 'secondary' | 'info' | 'warning' | 'success' | 'danger'> = {
open: 'secondary',
in_progress: 'info',
review: 'warning',
blocked: 'danger',
done: 'success',
cancelled: 'secondary',
};
const PRIORITY_VARIANTS: Record<string, 'secondary' | 'info' | 'warning' | 'danger'> = {
low: 'secondary',
medium: 'info',
high: 'warning',
urgent: 'danger',
};
function statusLabel(t: (k: string) => string, status: TaskStatus): string {
const map: Record<TaskStatus, string> = {
open: t('tasks.statusOpen'),
in_progress: t('tasks.statusInProgress'),
review: t('tasks.statusReview'),
blocked: t('tasks.statusBlocked'),
done: t('tasks.statusDone'),
cancelled: t('tasks.statusCancelled'),
};
return map[status];
}
function isOverdue(dateStr: string | null, status: string): boolean {
if (!dateStr || status === 'done' || status === 'cancelled') return false;
try {
return new Date(dateStr) < new Date();
} catch {
return false;
}
}
interface TaskCardProps {
task: Task;
onSelect: (task: Task) => void;
}
function TaskCard({ task, onSelect }: TaskCardProps) {
const { t } = useTranslation();
const overdue = isOverdue(task.due_date, task.status);
return (
<button
type="button"
onClick={() => onSelect(task)}
className="w-full text-left rounded-lg border border-gray-200 bg-white p-3 shadow-sm hover:shadow-md transition-shadow focus:outline-none focus:ring-2 focus:ring-primary-500"
aria-label={task.title}
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium text-gray-900 line-clamp-2">{task.title}</span>
<Badge variant={PRIORITY_VARIANTS[task.priority] ?? 'secondary'}>{t(`tasks.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`)}</Badge>
</div>
{task.description ? (
<p className="mt-1 text-xs text-gray-500 line-clamp-2">{task.description}</p>
) : null}
<div className="mt-2 flex items-center gap-3 text-xs text-gray-500">
{task.due_date ? (
<span className={`inline-flex items-center gap-1 ${overdue ? 'text-danger-600' : ''}`}>
<Clock className="h-3 w-3" aria-hidden="true" />
{new Date(task.due_date).toLocaleDateString()}
</span>
) : null}
{task.task_type !== 'todo' ? (
<Badge variant="info">{t(`tasks.type${task.task_type.charAt(0).toUpperCase() + task.task_type.slice(1)}`)}</Badge>
) : null}
{task.progress > 0 ? (
<span className="inline-flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
{task.progress}%
</span>
) : null}
</div>
</button>
);
}
interface TaskBoardProps {
filter?: TaskFilter;
onSelectTask?: (task: Task) => void;
}
export function TaskBoard({ filter, onSelectTask }: TaskBoardProps) {
const { t } = useTranslation();
const { data, isLoading } = useTasks(1, 200, filter);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12" role="status">
<Loader2 className="h-6 w-6 animate-spin text-primary-500" aria-hidden="true" />
<span className="sr-only">{t('common.loading')}</span>
</div>
);
}
const tasks = data?.items ?? [];
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{STATUS_COLUMNS.map((status) => {
const columnTasks = tasks.filter((task) => task.status === status);
return (
<div key={status} className="flex flex-col rounded-lg bg-gray-50 p-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">{statusLabel(t, status)}</h3>
<Badge variant={STATUS_VARIANTS[status]}>{columnTasks.length}</Badge>
</div>
<div className="flex flex-col gap-2">
{columnTasks.length === 0 ? (
<p className="text-xs text-gray-400">{t('tasks.noTasks')}</p>
) : (
columnTasks.map((task) => (
<TaskCard key={task.id} task={task} onSelect={onSelectTask ?? (() => {})} />
))
)}
</div>
</div>
);
})}
</div>
);
}
export default TaskBoard;
-24
View File
@@ -1,24 +0,0 @@
import { useAuthStore } from '@/store/authStore';
import { useSwitchTenant } from '@/api/hooks';
export function useTenant() {
const { user, currentTenant, setTenant } = useAuthStore();
const switchTenantMutation = useSwitchTenant();
const availableTenants = user?.tenants ?? [];
const switchTenant = async (tenantId: string) => {
try {
await switchTenantMutation.mutateAsync(tenantId);
} catch (error) {
console.error('Failed to switch tenant:', error);
}
};
return {
currentTenant,
availableTenants,
switchTenant,
isSwitching: switchTenantMutation.isPending,
};
}