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