Phase 2: Tags UI, Custom Fields UI, Notifications Bell

- Tags UI: TagsPage (CRUD, color picker), TagBadge, TagSelector (multi-select, inline creation)
- Custom Fields Backend: model, schema, service, routes, migration 0041
- Custom Fields Frontend: CustomFieldsPage (definitions CRUD), CustomFieldRenderer (dynamic field rendering)
- Custom Fields: _collect_custom_field_definitions() extended to merge DB definitions with plugin definitions
- Notifications Bell: NotificationBell (30s polling, unread badge), NotificationDropdown, NotificationItem
- NotificationBell integrated into TopBar
- Routes: /tags, /settings/custom-fields registered
- Settings nav: Custom Fields entry added
- Menu items: Tags added to automation plugin manifest
This commit is contained in:
Agent Zero
2026-07-26 03:02:25 +02:00
parent 444c7fdb88
commit a7e3890634
22 changed files with 2684 additions and 8 deletions
@@ -0,0 +1,86 @@
"""Create custom_field_definitions table for user-defined custom fields.
Revision ID: 0041_custom_field_definitions
Revises: 0040_outbox
Create Date: 2026-07-26
Stores user-defined custom field definitions that are merged with
plugin-provided custom fields at query time.
"""
from __future__ import annotations
from typing import Union
import sqlalchemy as sa
from alembic import op
# revision identifiers
revision: str = "0041_custom_field_definitions"
down_revision: Union[str, None] = "0040_outbox"
branch_labels: Union[str, None] = None
depends_on: Union[str, None] = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"""
CREATE TABLE IF NOT EXISTS custom_field_definitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
entity VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
label VARCHAR(200) NOT NULL,
field_type VARCHAR(20) NOT NULL DEFAULT 'text',
options JSONB,
default_value JSONB,
required BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_by UUID,
updated_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
)
"""
)
)
# Indexes
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_custom_field_def_tenant "
"ON custom_field_definitions (tenant_id)"
)
)
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_custom_field_def_entity "
"ON custom_field_definitions (entity)"
)
)
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_custom_field_def_tenant_active "
"ON custom_field_definitions (tenant_id, is_active)"
)
)
conn.execute(
sa.text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_custom_field_def_tenant_entity_name "
"ON custom_field_definitions (tenant_id, entity, name)"
)
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP INDEX IF EXISTS uq_custom_field_def_tenant_entity_name"))
conn.execute(sa.text("DROP INDEX IF EXISTS ix_custom_field_def_tenant_active"))
conn.execute(sa.text("DROP INDEX IF EXISTS ix_custom_field_def_entity"))
conn.execute(sa.text("DROP INDEX IF EXISTS ix_custom_field_def_tenant"))
conn.execute(sa.text("DROP TABLE IF EXISTS custom_field_definitions"))
+2
View File
@@ -49,6 +49,7 @@ from app.routes import (
sequences, sequences,
system_settings, system_settings,
attachments, attachments,
custom_field_definitions,
custom_fields, custom_fields,
saved_filters, saved_filters,
) )
@@ -330,6 +331,7 @@ def create_app() -> FastAPI:
app.include_router(addresses.router) app.include_router(addresses.router)
app.include_router(bank_accounts.router) app.include_router(bank_accounts.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(custom_field_definitions.router)
app.include_router(custom_fields.router) app.include_router(custom_fields.router)
app.include_router(saved_filters.router) app.include_router(saved_filters.router)
+2
View File
@@ -21,6 +21,7 @@ from app.models.system_settings import SystemSettings
from app.models.tax import TaxRate from app.models.tax import TaxRate
from app.models.tenant import Tenant from app.models.tenant import Tenant
from app.models.user import User, UserTenant from app.models.user import User, UserTenant
from app.models.custom_field_definition import CustomFieldDefinition
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
__all__ = [ __all__ = [
@@ -54,6 +55,7 @@ __all__ = [
"PluginMigration", "PluginMigration",
"AIConversation", "AIConversation",
"AIMessage", "AIMessage",
"CustomFieldDefinition",
"Workflow", "Workflow",
"WorkflowInstance", "WorkflowInstance",
"WorkflowStepHistory", "WorkflowStepHistory",
+57
View File
@@ -0,0 +1,57 @@
"""CustomFieldDefinition model — user-defined custom fields stored in DB."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import Boolean, Integer, JSON, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class CustomFieldDefinition(Base, TenantMixin):
"""User-defined custom field definition stored in the database.
These definitions are merged with plugin-provided custom fields
at query time. Each definition is scoped to a tenant and entity type.
"""
__tablename__ = "custom_field_definitions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "entity", "name",
name="uq_custom_field_def_tenant_entity_name",
),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
# ── Identity ──
entity: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
label: Mapped[str] = mapped_column(String(200), nullable=False)
# ── Type & Options ──
field_type: Mapped[str] = mapped_column(
String(20), nullable=False, default="text"
) # text, number, date, select, multiselect, boolean
options: Mapped[list[str] | None] = mapped_column(JSON, nullable=True, default=list)
default_value: Mapped[Any | None] = mapped_column(JSON, nullable=True, default=None)
# ── Behaviour ──
required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# ── Audit ──
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
@@ -92,6 +92,13 @@ class AutomationPlugin(BasePlugin):
icon="Copy", icon="Copy",
order=54, order=54,
), ),
FrontendMenuItem(
label_key="nav.tags",
label="Tags",
path="/tags",
icon="Tag",
order=55,
),
], ],
page_routes=[ page_routes=[
FrontendPageRoute( FrontendPageRoute(
+110
View File
@@ -0,0 +1,110 @@
"""API routes for CustomFieldDefinition CRUD."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.schemas.custom_field_definition import (
CustomFieldDefinitionCreate,
CustomFieldDefinitionResponse,
CustomFieldDefinitionUpdate,
)
from app.services import custom_field_service
router = APIRouter(prefix="/api/v1/custom-fields", tags=["custom-fields-definitions"])
@router.get(
"/definitions",
response_model=list[CustomFieldDefinitionResponse],
dependencies=[Depends(require_permission("contacts:read"))],
)
async def list_definitions(
entity: str | None = Query(None, description="Filter by entity type (e.g. 'contact', 'company')"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all active custom field definitions for the current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
definitions = await custom_field_service.list_definitions(db, tenant_id, entity=entity)
return definitions
@router.post(
"/definitions",
response_model=CustomFieldDefinitionResponse,
status_code=201,
dependencies=[Depends(require_permission("contacts:write"))],
)
async def create_definition(
body: CustomFieldDefinitionCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new custom field definition."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
definition = await custom_field_service.create_definition(
db, tenant_id, user_id, body.model_dump()
)
return definition
@router.patch(
"/definitions/{definition_id}",
response_model=CustomFieldDefinitionResponse,
dependencies=[Depends(require_permission("contacts:write"))],
)
async def update_definition(
definition_id: str,
body: CustomFieldDefinitionUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update an existing custom field definition."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
def_id = uuid.UUID(definition_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid definition_id", "code": "invalid_id"}) from None
# Filter out None values from the update body
update_data = {k: v for k, v in body.model_dump().items() if v is not None}
if not update_data:
raise HTTPException(400, detail={"detail": "No fields to update", "code": "no_updates"})
definition = await custom_field_service.update_definition(
db, tenant_id, def_id, update_data, user_id=user_id
)
if definition is None:
raise HTTPException(404, detail={"detail": "Definition not found", "code": "not_found"})
return definition
@router.delete(
"/definitions/{definition_id}",
status_code=204,
dependencies=[Depends(require_permission("contacts:write"))],
)
async def delete_definition(
definition_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a custom field definition."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
def_id = uuid.UUID(definition_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid definition_id", "code": "invalid_id"}) from None
deleted = await custom_field_service.delete_definition(db, tenant_id, def_id)
if not deleted:
raise HTTPException(404, detail={"detail": "Definition not found", "code": "not_found"})
return None
+47 -7
View File
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.deps import get_current_user, require_permission from app.deps import get_current_user, require_permission
from app.models.contact import Contact from app.models.contact import Contact
from app.models.custom_field_definition import CustomFieldDefinition
from app.plugins.registry import get_registry from app.plugins.registry import get_registry
router = APIRouter(prefix="/api/v1/contacts", tags=["custom-fields"]) router = APIRouter(prefix="/api/v1/contacts", tags=["custom-fields"])
@@ -24,10 +25,19 @@ class CustomFieldUpdateRequest(BaseModel):
values: dict[str, Any] = {} values: dict[str, Any] = {}
def _collect_custom_field_definitions(entity: str = "contact") -> list[dict[str, Any]]: async def _collect_custom_field_definitions(
"""Collect all custom field definitions from active plugin manifests.""" db: AsyncSession,
tenant_id: uuid.UUID,
entity: str = "contact",
) -> list[dict[str, Any]]:
"""Collect all custom field definitions from plugin manifests and DB.
DB-stored definitions override plugin definitions with the same name.
"""
definitions: list[dict[str, Any]] = [] definitions: list[dict[str, Any]] = []
seen_names: set[str] = set() seen_names: set[str] = set()
# 1. Collect from active plugin manifests
registry = get_registry() registry = get_registry()
for plugin in registry._plugins.values(): for plugin in registry._plugins.values():
manifest = plugin.manifest manifest = plugin.manifest
@@ -50,10 +60,40 @@ def _collect_custom_field_definitions(entity: str = "contact") -> list[dict[str,
"plugin": manifest.name, "plugin": manifest.name,
} }
) )
# 2. Collect from DB (user-defined custom field definitions)
stmt = select(CustomFieldDefinition).where(
CustomFieldDefinition.tenant_id == tenant_id,
CustomFieldDefinition.entity == entity,
CustomFieldDefinition.is_active == True, # noqa: E712
).order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.name)
result = await db.execute(stmt)
db_definitions = result.scalars().all()
for d in db_definitions:
if d.name in seen_names:
# DB definition overrides plugin definition — replace it
definitions = [x for x in definitions if x["name"] != d.name]
else:
seen_names.add(d.name)
definitions.append(
{
"name": d.name,
"label": d.label,
"label_key": "",
"field_type": d.field_type,
"options": d.options or [],
"default_value": d.default_value,
"required": d.required,
"entity": d.entity,
"plugin": "user_defined",
}
)
return definitions return definitions
def _merge_definitions_with_values( async def _merge_definitions_with_values(
definitions: list[dict[str, Any]], stored: dict[str, Any] | None definitions: list[dict[str, Any]], stored: dict[str, Any] | None
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Merge field definitions with stored values, applying defaults.""" """Merge field definitions with stored values, applying defaults."""
@@ -87,8 +127,8 @@ async def get_custom_fields(
if contact is None: if contact is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"}) raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
definitions = _collect_custom_field_definitions("contact") definitions = await _collect_custom_field_definitions(db, tenant_id, "contact")
merged = _merge_definitions_with_values(definitions, contact.custom) merged = await _merge_definitions_with_values(definitions, contact.custom)
return {"fields": merged} return {"fields": merged}
@@ -114,7 +154,7 @@ async def update_custom_fields(
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"}) raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
# Validate against definitions # Validate against definitions
definitions = _collect_custom_field_definitions("contact") definitions = await _collect_custom_field_definitions(db, tenant_id, "contact")
def_map = {d["name"]: d for d in definitions} def_map = {d["name"]: d for d in definitions}
current_custom = dict(contact.custom or {}) current_custom = dict(contact.custom or {})
@@ -154,5 +194,5 @@ async def update_custom_fields(
contact.custom = current_custom contact.custom = current_custom
await db.flush() await db.flush()
merged = _merge_definitions_with_values(definitions, contact.custom) merged = await _merge_definitions_with_values(definitions, contact.custom)
return {"fields": merged} return {"fields": merged}
+64
View File
@@ -0,0 +1,64 @@
"""Pydantic schemas for CustomFieldDefinition CRUD."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class CustomFieldDefinitionCreate(BaseModel):
"""Schema for creating a new custom field definition."""
entity: str = Field(..., description="Entity type (e.g. 'contact', 'company')")
name: str = Field(..., min_length=1, max_length=100, description="Unique field name (snake_case)")
label: str = Field(..., min_length=1, max_length=200, description="Human-readable label")
field_type: str = Field(
...,
pattern=r"^(text|number|date|select|multiselect|boolean)$",
description="Field type",
)
options: list[str] | None = Field(default=None, description="Options for select/multiselect types")
default_value: Any = Field(default=None, description="Default value")
required: bool = Field(default=False, description="Whether the field is required")
is_active: bool = Field(default=True, description="Whether the field is active")
sort_order: int = Field(default=0, description="Sort order")
class CustomFieldDefinitionUpdate(BaseModel):
"""Schema for updating an existing custom field definition."""
label: str | None = Field(default=None, max_length=200)
field_type: str | None = Field(
default=None,
pattern=r"^(text|number|date|select|multiselect|boolean)$",
)
options: list[str] | None = Field(default=None)
default_value: Any = Field(default=None)
required: bool | None = Field(default=None)
is_active: bool | None = Field(default=None)
sort_order: int | None = Field(default=None)
class CustomFieldDefinitionResponse(BaseModel):
"""Schema for returning a custom field definition."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tenant_id: uuid.UUID
entity: str
name: str
label: str
field_type: str
options: list[str] | None = None
default_value: Any = None
required: bool = False
is_active: bool = True
sort_order: int = 0
created_by: uuid.UUID | None = None
updated_by: uuid.UUID | None = None
created_at: datetime
updated_at: datetime
+113
View File
@@ -0,0 +1,113 @@
"""CRUD service for CustomFieldDefinition."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.custom_field_definition import CustomFieldDefinition
async def list_definitions(
db: AsyncSession,
tenant_id: uuid.UUID,
entity: str | None = None,
) -> list[CustomFieldDefinition]:
"""List custom field definitions for a tenant, optionally filtered by entity."""
stmt = select(CustomFieldDefinition).where(
CustomFieldDefinition.tenant_id == tenant_id,
CustomFieldDefinition.is_active == True, # noqa: E712
)
if entity:
stmt = stmt.where(CustomFieldDefinition.entity == entity)
stmt = stmt.order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.name)
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_definition(
db: AsyncSession,
tenant_id: uuid.UUID,
definition_id: uuid.UUID,
) -> CustomFieldDefinition | None:
"""Get a single custom field definition by ID."""
stmt = select(CustomFieldDefinition).where(
CustomFieldDefinition.id == definition_id,
CustomFieldDefinition.tenant_id == tenant_id,
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def create_definition(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> CustomFieldDefinition:
"""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,
)
db.add(definition)
await db.flush()
await db.refresh(definition)
return definition
async def update_definition(
db: AsyncSession,
tenant_id: uuid.UUID,
definition_id: uuid.UUID,
data: dict[str, Any],
user_id: uuid.UUID | None = None,
) -> CustomFieldDefinition | None:
"""Update an existing custom field definition."""
definition = await get_definition(db, tenant_id, definition_id)
if definition is None:
return None
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])
if user_id is not None:
definition.updated_by = user_id
await db.flush()
await db.refresh(definition)
return definition
async def delete_definition(
db: AsyncSession,
tenant_id: uuid.UUID,
definition_id: uuid.UUID,
) -> bool:
"""Delete a custom field definition."""
stmt = select(CustomFieldDefinition).where(
CustomFieldDefinition.id == definition_id,
CustomFieldDefinition.tenant_id == tenant_id,
)
result = await db.execute(stmt)
definition = result.scalar_one_or_none()
if definition is None:
return False
await db.delete(definition)
await db.flush()
return True
+109
View File
@@ -0,0 +1,109 @@
/**
* Custom field definitions API hooks — CRUD for entity-level field definitions.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
export type CustomFieldType = 'text' | 'number' | 'date' | 'select' | 'multiselect' | 'boolean';
export type CustomFieldEntity = 'contact' | 'company';
export interface CustomFieldDefinition {
id: string;
tenant_id: string;
entity: string;
name: string;
label: string;
field_type: CustomFieldType;
options: string[];
default_value: any;
required: boolean;
is_active: boolean;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface CustomFieldDefinitionCreate {
entity: string;
name: string;
label: string;
field_type: CustomFieldType;
options?: string[];
default_value?: any;
required?: boolean;
is_active?: boolean;
sort_order?: number;
}
export interface CustomFieldDefinitionUpdate {
name?: string;
label?: string;
field_type?: CustomFieldType;
options?: string[];
default_value?: any;
required?: boolean;
is_active?: boolean;
sort_order?: number;
}
export interface CustomFieldDefinitionsResponse {
items: CustomFieldDefinition[];
total: number;
}
/**
* Fetch custom field definitions, optionally filtered by entity.
*/
export function useCustomFieldDefinitions(entity?: string) {
return useQuery({
queryKey: ['custom-field-definitions', entity ?? 'all'],
queryFn: () => {
const params = entity ? `?entity=${encodeURIComponent(entity)}` : '';
return apiGet<CustomFieldDefinitionsResponse>(`/custom-fields/definitions${params}`);
},
});
}
/**
* Create a new custom field definition.
*/
export function useCreateCustomFieldDefinition() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CustomFieldDefinitionCreate) =>
apiPost<CustomFieldDefinition>('/custom-fields/definitions', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['custom-field-definitions'] });
},
});
}
/**
* Update an existing custom field definition.
*/
export function useUpdateCustomFieldDefinition() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: CustomFieldDefinitionUpdate }) =>
apiPatch<CustomFieldDefinition>(`/custom-fields/definitions/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['custom-field-definitions'] });
},
});
}
/**
* Delete a custom field definition.
*/
export function useDeleteCustomFieldDefinition() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiDelete<void>(`/custom-fields/definitions/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['custom-field-definitions'] });
},
});
}
+2 -1
View File
@@ -43,13 +43,14 @@ export function useNotifications() {
}); });
} }
export function useUnreadNotificationCount() { export function useUnreadNotificationCount(options?: { refetchInterval?: number }) {
return useQuery({ return useQuery({
queryKey: ['notifications', 'unread-count'], queryKey: ['notifications', 'unread-count'],
queryFn: async () => { queryFn: async () => {
const data = await apiGet<UnreadCountResponse>('/notifications/unread-count'); const data = await apiGet<UnreadCountResponse>('/notifications/unread-count');
return data.count; return data.count;
}, },
refetchInterval: options?.refetchInterval,
}); });
} }
@@ -0,0 +1,175 @@
/**
* Dynamic custom field renderer.
* Renders the appropriate input element based on field_type.
*/
import React, { useId } from 'react';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Badge } from '@/components/ui/Badge';
import { X } from 'lucide-react';
import type { CustomFieldDefinition } from '@/api/customFieldDefinitions';
export interface CustomFieldRendererProps {
definition: CustomFieldDefinition;
value: any;
onChange: (value: any) => void;
}
export function CustomFieldRenderer({ definition, value, onChange }: CustomFieldRendererProps) {
const generatedId = useId();
const fieldId = `cf-${definition.id || generatedId}`;
const { field_type, options, required } = definition;
// --- Boolean: checkbox ---
if (field_type === 'boolean') {
return (
<div className="w-full">
<label
htmlFor={fieldId}
className="flex items-center gap-2 text-sm font-medium text-secondary-700 cursor-pointer"
>
<input
id={fieldId}
type="checkbox"
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
required={required}
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<span>
{definition.label}
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>}
</span>
</label>
</div>
);
}
// --- Multiselect: chips with toggle ---
if (field_type === 'multiselect') {
const selectedValues: string[] = Array.isArray(value)
? value
: value != null && value !== ''
? [String(value)]
: [];
const availableOptions = options || [];
const toggleOption = (opt: string) => {
if (selectedValues.includes(opt)) {
onChange(selectedValues.filter((v) => v !== opt));
} else {
onChange([...selectedValues, opt]);
}
};
const removeChip = (opt: string) => {
onChange(selectedValues.filter((v) => v !== opt));
};
const unselected = availableOptions.filter((o) => !selectedValues.includes(o));
return (
<div className="w-full">
<label className="block text-sm font-medium text-secondary-700 mb-1">
{definition.label}
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>}
</label>
{selectedValues.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{selectedValues.map((opt) => (
<Badge key={opt} variant="primary" className="gap-1">
{opt}
<button
type="button"
onClick={() => removeChip(opt)}
className="inline-flex items-center justify-center rounded-full hover:bg-primary-200"
aria-label={`Remove ${opt}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
{unselected.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{unselected.map((opt) => (
<button
key={opt}
type="button"
onClick={() => toggleOption(opt)}
className="px-2.5 py-0.5 rounded-full text-xs font-medium border border-secondary-300 text-secondary-700 hover:bg-secondary-100"
>
+ {opt}
</button>
))}
</div>
) : availableOptions.length === 0 ? (
<p className="text-sm text-secondary-400">Keine Optionen verfügbar</p>
) : (
<p className="text-sm text-secondary-400">Alle Optionen ausgewählt</p>
)}
</div>
);
}
// --- Select: dropdown ---
if (field_type === 'select') {
const selectOptions = (options || []).map((opt) => ({ value: opt, label: opt }));
return (
<Select
id={fieldId}
label={definition.label}
required={required}
options={selectOptions}
placeholder="— Bitte wählen —"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
/>
);
}
// --- Number: numeric input ---
if (field_type === 'number') {
return (
<Input
id={fieldId}
label={definition.label}
required={required}
type="number"
value={value ?? ''}
onChange={(e) => {
const raw = e.target.value;
onChange(raw === '' ? null : Number(raw));
}}
/>
);
}
// --- Date: date input ---
if (field_type === 'date') {
return (
<Input
id={fieldId}
label={definition.label}
required={required}
type="date"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
/>
);
}
// --- Text (default) ---
return (
<Input
id={fieldId}
label={definition.label}
required={required}
type="text"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
/>
);
}
@@ -0,0 +1,74 @@
/**
* NotificationBell — bell icon with unread badge + dropdown.
*
* - Uses useUnreadNotificationCount() with 30 s polling
* - Bell icon (lucide-react Bell) with red badge count if > 0
* - Click toggles dropdown
* - Click outside closes dropdown
*/
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Bell } from 'lucide-react';
import { useUnreadNotificationCount } from '@/api/notifications';
import { NotificationDropdown } from '@/components/notifications/NotificationDropdown';
export function NotificationBell() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { data: unreadCount } = useUnreadNotificationCount({
refetchInterval: 30_000,
});
// Close on click outside
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [open]);
const count = unreadCount ?? 0;
const displayCount = count > 99 ? '99+' : String(count);
return (
<div ref={containerRef} className="relative">
<button
onClick={() => setOpen(!open)}
className="relative p-2 rounded-md hover:bg-secondary-100 text-secondary-600 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('notifications.title', 'Benachrichtigungen')}
aria-expanded={open}
aria-haspopup="menu"
>
<Bell className="w-5 h-5" strokeWidth={2} aria-hidden="true" />
{count > 0 && (
<span
className="absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-danger-500 text-white text-[10px] font-bold leading-none"
aria-label={`${count} ungelesene Benachrichtigungen`}
>
{displayCount}
</span>
)}
</button>
{open && <NotificationDropdown />}
</div>
);
}
@@ -9,6 +9,7 @@ import { Avatar } from '@/components/ui/Avatar';
import { SearchDropdown } from '@/components/shared/SearchDropdown'; import { SearchDropdown } from '@/components/shared/SearchDropdown';
import { SuggestionBadge } from '@/components/ai/SuggestionBadge'; import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
import { Building, ChevronDown, Menu, Zap, Bot, Layers } from 'lucide-react'; import { Building, ChevronDown, Menu, Zap, Bot, Layers } from 'lucide-react';
import { NotificationBell } from '@/components/layout/NotificationBell';
import { useWindowStore } from '@/store/windowStore'; import { useWindowStore } from '@/store/windowStore';
export function TopBar() { export function TopBar() {
@@ -80,6 +81,7 @@ export function TopBar() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<NotificationBell />
{/* Minimized windows */} {/* Minimized windows */}
{minimizedWindows.length > 0 && ( {minimizedWindows.length > 0 && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -0,0 +1,131 @@
/**
* NotificationDropdown — panel that lists notifications inside the bell dropdown.
*
* Features:
* - Uses useNotifications() to list notification items
* - "Alle als gelesen" button marks all unread notifications as read
* - "Alle anzeigen" link navigates to /settings/notifications
* - Loading skeleton, empty state
* - Max height with scroll
*/
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Bell, CheckCheck, Settings, AlertCircle } from 'lucide-react';
import { useNotifications, useMarkNotificationRead } from '@/api/notifications';
import { NotificationItem } from './NotificationItem';
export function NotificationDropdown() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data, isLoading, isError } = useNotifications();
const markReadMutation = useMarkNotificationRead();
const notifications = data?.items ?? [];
const hasUnread = notifications.some((n) => n.read_at == null);
const handleMarkAllRead = () => {
notifications.forEach((n) => {
if (n.read_at == null) {
markReadMutation.mutate(n.id);
}
});
};
const handleNavigateAll = () => {
navigate('/settings/notifications');
};
return (
<div
className="absolute top-full right-0 mt-1 w-80 bg-white rounded-md shadow-lg border border-secondary-200 z-50"
role="menu"
aria-label={t('notifications.title', 'Benachrichtigungen')}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-secondary-200">
<h3 className="text-sm font-semibold text-secondary-900">
{t('notifications.title', 'Benachrichtigungen')}
</h3>
{hasUnread && (
<button
onClick={handleMarkAllRead}
disabled={markReadMutation.isPending}
className="flex items-center gap-1 text-xs text-primary-600 hover:text-primary-700 font-medium disabled:opacity-50 disabled:cursor-not-allowed min-h-touch px-1 py-0.5 rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('notifications.markAllRead', 'Alle als gelesen')}
>
<CheckCheck className="w-3.5 h-3.5" strokeWidth={2} />
{t('notifications.markAllRead', 'Alle als gelesen')}
</button>
)}
</div>
{/* Body */}
<div className="max-h-80 overflow-y-auto">
{isLoading && <NotificationSkeleton />}
{isError && (
<div className="px-3 py-6 text-center">
<AlertCircle className="w-6 h-6 text-danger-400 mx-auto mb-2" strokeWidth={2} />
<p className="text-sm text-secondary-500">
{t('notifications.errorLoading', 'Fehler beim Laden der Benachrichtigungen')}
</p>
</div>
)}
{!isLoading && !isError && notifications.length === 0 && (
<div className="px-3 py-8 text-center">
<Bell className="w-8 h-8 text-secondary-300 mx-auto mb-2" strokeWidth={2} />
<p className="text-sm text-secondary-500">
{t('notifications.empty', 'Keine Benachrichtigungen')}
</p>
</div>
)}
{!isLoading && !isError && notifications.length > 0 && (
<div role="menu">
{notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onMarkRead={(id) => markReadMutation.mutate(id)}
/>
))}
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-secondary-200 px-3 py-2">
<button
onClick={handleNavigateAll}
className="w-full flex items-center justify-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 font-medium py-1.5 rounded-md hover:bg-primary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('notifications.viewAll', 'Alle anzeigen')}
>
<Settings className="w-3.5 h-3.5" strokeWidth={2} />
{t('notifications.viewAll', 'Alle anzeigen')}
</button>
</div>
</div>
);
}
// ── internal: loading skeleton ──
function NotificationSkeleton() {
return (
<div className="px-3 py-2" aria-hidden="true">
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-start gap-3 py-2.5 border-b border-secondary-100 last:border-b-0">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-secondary-100 animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-3.5 bg-secondary-100 rounded animate-pulse w-3/4" />
<div className="h-2.5 bg-secondary-100 rounded animate-pulse w-1/2" />
<div className="h-2 bg-secondary-100 rounded animate-pulse w-1/4" />
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,144 @@
/**
* NotificationItem — single notification row inside the dropdown.
*
* Props:
* notification: NotificationItem
* onMarkRead: (id: string) => void
*/
import React from 'react';
import clsx from 'clsx';
import {
Info,
Mail,
CheckSquare,
Calendar,
AlertCircle,
User,
FileText,
Bell,
type LucideIcon,
} from 'lucide-react';
import type { NotificationItem as NotificationItemType } from '@/api/notifications';
// ── helpers ──
/**
* Returns a German relative-time string like "vor 5 Min" or "vor 2 Stunden".
* Falls back to "gerade eben" for < 1 min and an absolute date for > 7 days.
*/
function relativeTime(isoDate: string | null | undefined): string {
if (!isoDate) return '';
const now = Date.now();
const then = new Date(isoDate).getTime();
if (Number.isNaN(then)) return '';
const diffMs = now - then;
if (diffMs < 0) return 'gerade eben';
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'gerade eben';
if (diffMin < 60) return `vor ${diffMin} Min`;
const diffHrs = Math.floor(diffMin / 60);
if (diffHrs < 24) return `vor ${diffHrs} Std`;
const diffDays = Math.floor(diffHrs / 24);
if (diffDays < 7) return `vor ${diffDays} ${diffDays === 1 ? 'Tag' : 'Tagen'}`;
// > 7 days: show absolute date
return new Date(isoDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
/** Map notification type → lucide icon. */
const typeIconMap: Record<string, LucideIcon> = {
info: Info,
email: Mail,
mail: Mail,
task: CheckSquare,
calendar: Calendar,
event: Calendar,
alert: AlertCircle,
warning: AlertCircle,
error: AlertCircle,
contact: User,
user: User,
document: FileText,
file: FileText,
};
function getIconForType(type: string): LucideIcon {
return typeIconMap[type] ?? Bell;
}
// ── component ──
export interface NotificationItemProps {
notification: NotificationItemType;
onMarkRead: (id: string) => void;
}
export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) {
const isUnread = notification.read_at == null;
const Icon = getIconForType(notification.type);
const handleClick = () => {
if (isUnread) {
onMarkRead(notification.id);
}
};
return (
<button
onClick={handleClick}
className={clsx(
'w-full text-left flex items-start gap-3 px-3 py-2.5 hover:bg-secondary-50 transition-colors cursor-pointer',
'border-b border-secondary-100 last:border-b-0 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isUnread && 'bg-primary-50/40',
)}
role="menuitem"
aria-label={notification.title}
>
{/* Icon */}
<span
className={clsx(
'flex-shrink-0 mt-0.5 w-8 h-8 rounded-full flex items-center justify-center',
isUnread ? 'bg-primary-100 text-primary-600' : 'bg-secondary-100 text-secondary-500',
)}
aria-hidden="true"
>
<Icon className="w-4 h-4" strokeWidth={2} />
</span>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p
className={clsx(
'text-sm truncate',
isUnread ? 'font-semibold text-secondary-900' : 'font-medium text-secondary-700',
)}
>
{notification.title}
</p>
{isUnread && (
<span
className="flex-shrink-0 w-2 h-2 rounded-full bg-primary-500"
aria-label="ungelesen"
title="ungelesen"
/>
)}
</div>
{notification.body && (
<p className="text-xs text-secondary-500 truncate mt-0.5">
{notification.body}
</p>
)}
{notification.created_at && (
<p className="text-xs text-secondary-400 mt-1">
{relativeTime(notification.created_at)}
</p>
)}
</div>
</button>
);
}
+152
View File
@@ -0,0 +1,152 @@
/**
* TagBadge — Reusable colored badge for displaying a tag.
*
* Renders a colored pill with the tag name and an optional remove (X) button.
* Colors are mapped from the tag's `color` string (e.g. "red", "blue") to
* Tailwind classes. Unknown colors fall back to gray.
*/
import React from 'react';
import clsx from 'clsx';
import { X } from 'lucide-react';
// ─── Color Mapping ──────────────────────────────────────────────────────────
export interface TagColorClasses {
bg: string;
text: string;
dot: string;
border: string;
}
/** Static map of predefined tag color names → Tailwind classes. */
const TAG_COLOR_MAP: Record<string, TagColorClasses> = {
red: {
bg: 'bg-red-100',
text: 'text-red-800',
dot: 'bg-red-500',
border: 'border-red-300',
},
blue: {
bg: 'bg-blue-100',
text: 'text-blue-800',
dot: 'bg-blue-500',
border: 'border-blue-300',
},
green: {
bg: 'bg-green-100',
text: 'text-green-800',
dot: 'bg-green-500',
border: 'border-green-300',
},
yellow: {
bg: 'bg-yellow-100',
text: 'text-yellow-800',
dot: 'bg-yellow-500',
border: 'border-yellow-300',
},
purple: {
bg: 'bg-purple-100',
text: 'text-purple-800',
dot: 'bg-purple-500',
border: 'border-purple-300',
},
pink: {
bg: 'bg-pink-100',
text: 'text-pink-800',
dot: 'bg-pink-500',
border: 'border-pink-300',
},
orange: {
bg: 'bg-orange-100',
text: 'text-orange-800',
dot: 'bg-orange-500',
border: 'border-orange-300',
},
gray: {
bg: 'bg-gray-100',
text: 'text-gray-800',
dot: 'bg-gray-500',
border: 'border-gray-300',
},
};
/** All predefined color names (used by Tags page and TagSelector). */
export const TAG_COLOR_NAMES = Object.keys(TAG_COLOR_MAP);
/**
* Resolve Tailwind classes for a tag color name.
* Falls back to gray for unknown colors.
*/
export function getTagColorClasses(color: string): TagColorClasses {
return TAG_COLOR_MAP[color] ?? TAG_COLOR_MAP.gray;
}
// ─── Component ──────────────────────────────────────────────────────────────
export interface TagBadgeProps {
/** Tag data — only name and color are required. */
tag: { name: string; color: string };
/** Optional remove handler. When provided, an X button is shown. */
onRemove?: () => void;
/** Badge size. */
size?: 'sm' | 'md';
/** Extra classes. */
className?: string;
}
const sizeClasses = {
sm: 'text-xs px-2 py-0.5 gap-1',
md: 'text-sm px-2.5 py-1 gap-1.5',
};
const dotSizeClasses = {
sm: 'w-1.5 h-1.5',
md: 'w-2 h-2',
};
const removeIconSize = {
sm: 'h-3 w-3',
md: 'h-3.5 w-3.5',
};
export function TagBadge({ tag, onRemove, size = 'md', className }: TagBadgeProps) {
const colors = getTagColorClasses(tag.color);
return (
<span
className={clsx(
'inline-flex items-center rounded-full border font-medium',
colors.bg,
colors.text,
colors.border,
sizeClasses[size],
className
)}
data-testid="tag-badge"
>
<span
className={clsx('rounded-full flex-shrink-0', colors.dot, dotSizeClasses[size])}
aria-hidden="true"
/>
<span className="truncate max-w-[200px]">{tag.name}</span>
{onRemove && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className={clsx(
'inline-flex items-center justify-center rounded-full hover:bg-black/10 flex-shrink-0',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-current min-w-touch min-h-touch',
removeIconSize[size]
)}
aria-label={`Remove tag ${tag.name}`}
>
<X className={removeIconSize[size]} aria-hidden="true" />
</button>
)}
</span>
);
}
@@ -0,0 +1,383 @@
/**
* TagSelector — Multi-select tag picker with dropdown.
*
* Features:
* - Dropdown showing all available tags with checkboxes
* - Search/filter within the dropdown
* - Selected tags shown as TagBadge chips
* - Inline "Neues Tag" creation (name + color → createTag)
* - Click outside to close
*/
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import clsx from 'clsx';
import { ChevronDown, Check, Plus, Search, Tag as TagIcon } from 'lucide-react';
import { fetchTags, createTag, type Tag, type CreateTagPayload } from '@/api/tags';
import { TagBadge, getTagColorClasses, TAG_COLOR_NAMES } from './TagBadge';
// ─── Component ──────────────────────────────────────────────────────────────
export interface TagSelectorProps {
/** Entity type for assignment (e.g. "contact", "file"). */
entityType: string;
/** Entity ID for assignment. */
entityId: string;
/** Currently selected tags. */
selectedTags: Tag[];
/** Callback when selection changes. */
onChange: (tags: Tag[]) => void;
/** Optional placeholder text. */
placeholder?: string;
/** Optional className for the wrapper. */
className?: string;
}
export function TagSelector({
entityType,
entityId,
selectedTags,
onChange,
placeholder,
className,
}: TagSelectorProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
// ─── State ────────────────────────────────────────────────────────────────
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [newTagName, setNewTagName] = useState('');
const [newTagColor, setNewTagColor] = useState('blue');
// ─── Refs ─────────────────────────────────────────────────────────────────
const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// ─── Queries ──────────────────────────────────────────────────────────────
const { data: allTags = [], isLoading: loadingTags } = useQuery<Tag[]>({
queryKey: ['tags'],
queryFn: fetchTags,
});
// ─── Mutations ────────────────────────────────────────────────────────────
const createTagMutation = useMutation<Tag, Error, CreateTagPayload>({
mutationFn: createTag,
onSuccess: (newTag) => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
// Auto-select the newly created tag
onChange([...selectedTags, newTag]);
setNewTagName('');
setNewTagColor('blue');
setShowCreateForm(false);
},
});
// ─── Click outside handler ────────────────────────────────────────────────
useEffect(() => {
if (!isOpen) return;
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setShowCreateForm(false);
setNewTagName('');
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
// ─── Focus search input on open ───────────────────────────────────────────
useEffect(() => {
if (isOpen && !showCreateForm) {
const timer = setTimeout(() => searchInputRef.current?.focus(), 50);
return () => clearTimeout(timer);
}
}, [isOpen, showCreateForm]);
// ─── Derived data ─────────────────────────────────────────────────────────
const selectedTagIds = useMemo(() => new Set(selectedTags.map((tag) => tag.id)), [selectedTags]);
const filteredTags = useMemo(() => {
if (!searchQuery.trim()) return allTags;
const q = searchQuery.toLowerCase();
return allTags.filter(
(tag) =>
tag.name.toLowerCase().includes(q) ||
(tag.description?.toLowerCase().includes(q) ?? false)
);
}, [allTags, searchQuery]);
// ─── Handlers ─────────────────────────────────────────────────────────────
const toggleTag = useCallback(
(tag: Tag) => {
if (selectedTagIds.has(tag.id)) {
onChange(selectedTags.filter((t) => t.id !== tag.id));
} else {
onChange([...selectedTags, tag]);
}
},
[selectedTagIds, selectedTags, onChange]
);
const handleRemoveTag = useCallback(
(tagId: string) => {
onChange(selectedTags.filter((t) => t.id !== tagId));
},
[selectedTags, onChange]
);
const handleCreateTag = useCallback(() => {
const name = newTagName.trim();
if (!name) return;
createTagMutation.mutate({ name, color: newTagColor });
}, [newTagName, newTagColor, createTagMutation]);
const handleOpenChange = useCallback(() => {
setIsOpen((prev) => !prev);
if (isOpen) {
setShowCreateForm(false);
setNewTagName('');
setSearchQuery('');
}
}, [isOpen]);
// ─── Render ───────────────────────────────────────────────────────────────
// Suppress unused variable warnings for props that are used for context
void entityType;
void entityId;
return (
<div ref={containerRef} className={clsx('relative', className)} data-testid="tag-selector">
{/* Selected tags chips + dropdown toggle */}
<div
className={clsx(
'min-h-[2.5rem] w-full rounded-md border border-secondary-300 bg-white px-2 py-1.5',
'flex flex-wrap items-center gap-1 cursor-text',
'focus-within:ring-2 focus-within:ring-primary-500 focus-within:border-primary-500',
'transition-colors'
)}
onClick={handleOpenChange}
>
{selectedTags.length === 0 && (
<span className="text-sm text-secondary-400 px-1">
{placeholder || t('tags.selectPlaceholder', 'Tags auswählen...')}
</span>
)}
{selectedTags.map((tag) => (
<TagBadge
key={tag.id}
tag={tag}
size="sm"
onRemove={() => handleRemoveTag(tag.id)}
/>
))}
<button
type="button"
className={clsx(
'ml-auto inline-flex items-center justify-center rounded p-1',
'text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500'
)}
aria-label={t('tags.toggleDropdown', 'Tags auswählen')}
aria-expanded={isOpen}
>
<ChevronDown className={clsx('h-4 w-4 transition-transform', isOpen && 'rotate-180')} />
</button>
</div>
{/* Dropdown panel */}
{isOpen && (
<div
className={clsx(
'absolute z-50 mt-1 w-full rounded-md border border-secondary-200 bg-white shadow-lg',
'max-h-80 overflow-hidden flex flex-col'
)}
data-testid="tag-selector-dropdown"
>
{/* Search bar */}
<div className="p-2 border-b border-secondary-100">
<div className="relative">
<Search
className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-secondary-400"
aria-hidden="true"
/>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('tags.search', 'Tag suchen...')}
className={clsx(
'w-full rounded-md border border-secondary-300 pl-8 pr-3 py-1.5 text-sm',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
)}
data-testid="tag-selector-search"
/>
</div>
</div>
{/* Tag list or create form */}
{!showCreateForm ? (
<>
<div className="overflow-y-auto flex-1 max-h-56">
{loadingTags && (
<div className="px-3 py-4 text-sm text-secondary-500 text-center">
{t('common.loading', 'Laden...')}
</div>
)}
{!loadingTags && filteredTags.length === 0 && (
<div className="px-3 py-4 text-sm text-secondary-500 text-center">
{t('tags.noTagsFound', 'Keine Tags gefunden.')}
</div>
)}
{!loadingTags &&
filteredTags.map((tag) => {
const isSelected = selectedTagIds.has(tag.id);
const colors = getTagColorClasses(tag.color);
return (
<label
key={tag.id}
className={clsx(
'flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-secondary-50',
'transition-colors'
)}
data-testid={`tag-option-${tag.id}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleTag(tag)}
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<span
className={clsx('rounded-full flex-shrink-0', colors.dot, 'w-2.5 h-2.5')}
aria-hidden="true"
/>
<span className="text-sm text-secondary-800 truncate flex-1">{tag.name}</span>
{tag.usage_count !== undefined && tag.usage_count > 0 && (
<span className="text-xs text-secondary-400">{tag.usage_count}×</span>
)}
{isSelected && (
<Check className="h-4 w-4 text-primary-600" aria-hidden="true" />
)}
</label>
);
})}
</div>
{/* Create new tag button */}
<div className="border-t border-secondary-100 p-2">
<button
type="button"
onClick={() => setShowCreateForm(true)}
className={clsx(
'flex items-center gap-2 w-full rounded-md px-3 py-2 text-sm font-medium',
'text-primary-600 hover:bg-primary-50 transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500'
)}
data-testid="tag-selector-create-btn"
>
<Plus className="h-4 w-4" aria-hidden="true" />
{t('tags.createInline', 'Neues Tag')}
</button>
</div>
</>
) : (
/* Inline create form */
<div className="p-3 space-y-3" data-testid="tag-create-inline">
<div>
<input
type="text"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateTag();
}
}}
placeholder={t('tags.namePlaceholder', 'Tag-Name')}
className={clsx(
'w-full rounded-md border border-secondary-300 px-3 py-2 text-sm',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
)}
autoFocus
data-testid="tag-create-name"
/>
</div>
<div>
<div className="flex items-center gap-1.5 flex-wrap">
{TAG_COLOR_NAMES.map((colorName) => {
const colors = getTagColorClasses(colorName);
return (
<button
key={colorName}
type="button"
onClick={() => setNewTagColor(colorName)}
className={clsx(
'rounded-full transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary-500',
colors.dot,
'w-6 h-6',
newTagColor === colorName
? 'ring-2 ring-offset-1 ring-secondary-400 scale-110'
: 'hover:scale-110'
)}
aria-label={colorName}
aria-pressed={newTagColor === colorName}
/>
);
})}
</div>
</div>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => {
setShowCreateForm(false);
setNewTagName('');
}}
className={clsx(
'rounded-md px-3 py-1.5 text-sm font-medium',
'text-secondary-600 hover:bg-secondary-100 transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-500'
)}
>
{t('common.cancel', 'Abbrechen')}
</button>
<button
type="button"
onClick={handleCreateTag}
disabled={!newTagName.trim() || createTagMutation.isPending}
className={clsx(
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium',
'bg-primary-600 text-white hover:bg-primary-700 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500'
)}
data-testid="tag-create-submit"
>
{createTagMutation.isPending ? (
t('common.creating', 'Erstelle...')
) : (
<>
<TagIcon className="h-3.5 w-3.5" aria-hidden="true" />
{t('tags.create', 'Erstellen')}
</>
)}
</button>
</div>
{createTagMutation.isError && (
<p className="text-sm text-danger-600">
{createTagMutation.error?.message || t('tags.createError', 'Fehler beim Erstellen.')}
</p>
)}
</div>
)}
</div>
)}
</div>
);
}
+519
View File
@@ -0,0 +1,519 @@
/**
* Custom Fields Settings page.
* Manage custom field definitions per entity (contact/company).
*/
import React, { useState, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import { Badge } from '@/components/ui/Badge';
import { Modal } from '@/components/ui/Modal';
import { Table, TableColumn } from '@/components/ui/Table';
import { useToast } from '@/components/ui/Toast';
import { Plus, Pencil, Trash2 } from 'lucide-react';
import {
CustomFieldDefinition,
CustomFieldDefinitionCreate,
CustomFieldDefinitionUpdate,
CustomFieldType,
CustomFieldEntity,
useCustomFieldDefinitions,
useCreateCustomFieldDefinition,
useUpdateCustomFieldDefinition,
useDeleteCustomFieldDefinition,
} from '@/api/customFieldDefinitions';
// --- Helpers ---
const FIELD_TYPE_OPTIONS = [
{ value: 'text', label: 'Text' },
{ value: 'number', label: 'Zahl' },
{ value: 'date', label: 'Datum' },
{ value: 'select', label: 'Auswahl (einfach)' },
{ value: 'multiselect', label: 'Auswahl (mehrfach)' },
{ value: 'boolean', label: 'Checkbox (Ja/Nein)' },
];
const ENTITY_OPTIONS = [
{ value: 'contact', label: 'Kontakt' },
{ value: 'company', label: 'Firma' },
];
const FIELD_TYPE_LABELS: Record<string, string> = {
text: 'Text',
number: 'Zahl',
date: 'Datum',
select: 'Auswahl',
multiselect: 'Mehrfachauswahl',
boolean: 'Checkbox',
};
function slugifyLabel(label: string): string {
return label
.toLowerCase()
.trim()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
function parseOptionsString(optionsStr: string): string[] {
return optionsStr
.split(',')
.map((o) => o.trim())
.filter((o) => o.length > 0);
}
function optionsToString(options: string[] | undefined): string {
return (options || []).join(', ');
}
// --- Form state interface ---
interface FormState {
label: string;
name: string;
nameTouched: boolean;
field_type: CustomFieldType;
optionsStr: string;
required: boolean;
default_value: string;
is_active: boolean;
}
function emptyForm(): FormState {
return {
label: '',
name: '',
nameTouched: false,
field_type: 'text',
optionsStr: '',
required: false,
default_value: '',
is_active: true,
};
}
function formFromDefinition(def: CustomFieldDefinition): FormState {
return {
label: def.label,
name: def.name,
nameTouched: true,
field_type: def.field_type,
optionsStr: optionsToString(def.options),
required: def.required,
default_value: def.default_value != null ? String(def.default_value) : '',
is_active: def.is_active,
};
}
// --- Page component ---
export function CustomFieldsPage() {
const { t } = useTranslation();
const toast = useToast();
const [entity, setEntity] = useState<CustomFieldEntity>('contact');
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(emptyForm());
const [deleteTarget, setDeleteTarget] = useState<CustomFieldDefinition | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const { data, isLoading } = useCustomFieldDefinitions(entity);
const createMutation = useCreateCustomFieldDefinition();
const updateMutation = useUpdateCustomFieldDefinition();
const deleteMutation = useDeleteCustomFieldDefinition();
const definitions = useMemo(() => data?.items ?? [], [data]);
// Auto-generate name from label when name hasn't been manually edited
useEffect(() => {
if (!form.nameTouched && form.label) {
setForm((prev) => ({ ...prev, name: slugifyLabel(prev.label) }));
}
}, [form.label, form.nameTouched]);
const openCreate = () => {
setForm(emptyForm());
setEditingId(null);
setFormError(null);
setShowModal(true);
};
const openEdit = (def: CustomFieldDefinition) => {
setForm(formFromDefinition(def));
setEditingId(def.id);
setFormError(null);
setShowModal(true);
};
const closeFormModal = () => {
setShowModal(false);
setEditingId(null);
setFormError(null);
};
const handleNameChange = (val: string) => {
setForm((prev) => ({ ...prev, name: val, nameTouched: true }));
};
const handleLabelChange = (val: string) => {
setForm((prev) => ({ ...prev, label: val }));
};
const handleFieldTypeChange = (val: string) => {
setForm((prev) => ({
...prev,
field_type: val as CustomFieldType,
// Reset default_value and options when switching away from select/multiselect
default_value: val === 'boolean' ? '' : prev.default_value,
}));
};
const handleSave = async () => {
setFormError(null);
if (!form.label.trim()) {
setFormError('Bitte ein Label eingeben');
return;
}
if (!form.name.trim()) {
setFormError('Bitte einen Feldnamen eingeben');
return;
}
const needsOptions = form.field_type === 'select' || form.field_type === 'multiselect';
const parsedOptions = needsOptions ? parseOptionsString(form.optionsStr) : [];
if (needsOptions && parsedOptions.length === 0) {
setFormError('Bitte mindestens eine Option eingeben (kommagetrennt)');
return;
}
// Convert default_value based on field_type
let defaultValue: any = form.default_value;
if (form.field_type === 'number') {
defaultValue = form.default_value === '' ? null : Number(form.default_value);
} else if (form.field_type === 'boolean') {
defaultValue = form.default_value === 'true' || form.default_value === '1';
} else if (form.default_value === '') {
defaultValue = null;
}
const isEditing = !!editingId;
if (isEditing) {
const updateData: CustomFieldDefinitionUpdate = {
name: form.name.trim(),
label: form.label.trim(),
field_type: form.field_type,
options: needsOptions ? parsedOptions : [],
default_value: defaultValue,
required: form.required,
is_active: form.is_active,
};
try {
await updateMutation.mutateAsync({ id: editingId!, data: updateData });
toast.success(t('customFields.updated', 'Feld aktualisiert'));
closeFormModal();
} catch (err: any) {
setFormError(err.message || 'Fehler beim Aktualisieren');
}
} else {
const createData: CustomFieldDefinitionCreate = {
entity,
name: form.name.trim(),
label: form.label.trim(),
field_type: form.field_type,
options: needsOptions ? parsedOptions : [],
default_value: defaultValue,
required: form.required,
is_active: form.is_active,
};
try {
await createMutation.mutateAsync(createData);
toast.success(t('customFields.created', 'Feld erstellt'));
closeFormModal();
} catch (err: any) {
setFormError(err.message || 'Fehler beim Erstellen');
}
}
};
const confirmDelete = async () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.id);
toast.success(t('customFields.deleted', 'Feld gelöscht'));
setDeleteTarget(null);
} catch (err: any) {
toast.error(err.message || 'Fehler beim Löschen');
setDeleteTarget(null);
}
};
const needsOptions = form.field_type === 'select' || form.field_type === 'multiselect';
const saving = createMutation.isPending || updateMutation.isPending;
// --- Table columns ---
const columns: TableColumn<CustomFieldDefinition>[] = [
{
key: 'label',
header: t('customFields.columnLabel', 'Bezeichnung'),
sortable: true,
render: (row) => (
<span className="font-medium text-secondary-900">{row.label}</span>
),
},
{
key: 'name',
header: t('customFields.columnName', 'Feldname'),
render: (row) => <code className="text-xs text-secondary-600">{row.name}</code>,
},
{
key: 'field_type',
header: t('customFields.columnType', 'Typ'),
render: (row) => {
const variantMap: Record<string, 'primary' | 'info' | 'success' | 'warning' | 'secondary'> = {
text: 'secondary',
number: 'info',
date: 'warning',
select: 'primary',
multiselect: 'primary',
boolean: 'success',
};
return (
<Badge variant={variantMap[row.field_type] || 'secondary'}>
{FIELD_TYPE_LABELS[row.field_type] || row.field_type}
</Badge>
);
},
},
{
key: 'required',
header: t('customFields.columnRequired', 'Pflicht'),
render: (row) =>
row.required ? (
<Badge variant="danger">{t('common.required', 'Pflichtfeld')}</Badge>
) : (
<span className="text-secondary-400"></span>
),
},
{
key: 'is_active',
header: t('customFields.columnStatus', 'Status'),
render: (row) =>
row.is_active ? (
<Badge variant="success" dot>{t('common.active', 'Aktiv')}</Badge>
) : (
<Badge variant="default">{t('common.inactive', 'Inaktiv')}</Badge>
),
},
{
key: 'actions',
header: '',
render: (row) => (
<div className="flex gap-2">
<button
onClick={() => openEdit(row)}
className="p-1 rounded hover:bg-secondary-100"
aria-label={t('common.edit', 'Bearbeiten')}
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteTarget(row)}
className="p-1 rounded hover:bg-danger-50 text-danger-600"
aria-label={t('common.delete', 'Löschen')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
),
},
];
// --- Render ---
return (
<div className="space-y-4">
{/* Header with entity selector */}
<Card
title={t('customFields.title', 'Custom Fields')}
description={t('customFields.description', 'Verwalten Sie benutzerdefinierte Felder für Kontakte und Firmen')}
actions={
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1" />
{t('customFields.addNew', 'Neues Feld')}
</Button>
}
>
<div className="mb-4">
<Select
label={t('customFields.entity', 'Entität')}
options={ENTITY_OPTIONS}
value={entity}
onChange={(e) => setEntity(e.target.value as CustomFieldEntity)}
className="max-w-xs"
/>
</div>
<Table
columns={columns}
data={definitions}
rowKey={(row) => row.id}
loading={isLoading}
emptyMessage={t('customFields.empty', 'Noch keine Felder definiert')}
/>
</Card>
{/* Create / Edit Modal */}
<Modal
open={showModal}
onClose={closeFormModal}
title={editingId ? t('customFields.editTitle', 'Feld bearbeiten') : t('customFields.createTitle', 'Neues Feld')}
size="lg"
>
<div className="space-y-4">
{formError && (
<div className="p-3 rounded-md bg-danger-50 border border-danger-200 text-danger-700 text-sm">
{formError}
</div>
)}
{/* Label */}
<Input
label={t('customFields.fieldLabel', 'Bezeichnung')}
required
value={form.label}
onChange={(e) => handleLabelChange(e.target.value)}
placeholder="z.B. Branche, Abteilung, Geburtsdatum"
/>
{/* Name (auto-generated) */}
<Input
label={t('customFields.fieldName', 'Feldname (intern)')}
required
value={form.name}
onChange={(e) => handleNameChange(e.target.value)}
helperText="Wird automatisch aus der Bezeichnung generiert"
placeholder="z.B. branche, abteilung, geburtsdatum"
/>
{/* Field type */}
<Select
label={t('customFields.fieldType', 'Feldtyp')}
required
options={FIELD_TYPE_OPTIONS}
value={form.field_type}
onChange={(e) => handleFieldTypeChange(e.target.value)}
/>
{/* Options (only for select / multiselect) */}
{needsOptions && (
<Input
label={t('customFields.options', 'Optionen (kommagetrennt)')}
required
value={form.optionsStr}
onChange={(e) => setForm((prev) => ({ ...prev, optionsStr: e.target.value }))}
placeholder="Option 1, Option 2, Option 3"
helperText="Trennen Sie die Optionen mit Kommas"
/>
)}
{/* Default value — varies by field type */}
{form.field_type !== 'multiselect' && form.field_type !== 'boolean' && (
<Input
label={t('customFields.defaultValue', 'Standardwert')}
type={form.field_type === 'number' ? 'number' : form.field_type === 'date' ? 'date' : 'text'}
value={form.default_value}
onChange={(e) => setForm((prev) => ({ ...prev, default_value: e.target.value }))}
helperText="Optional — wird für neue Einträge vorbelegt"
/>
)}
{form.field_type === 'boolean' && (
<Select
label={t('customFields.defaultValue', 'Standardwert')}
options={[
{ value: '', label: '— Keiner —' },
{ value: 'true', label: 'Ja / Aktiv' },
{ value: 'false', label: 'Nein / Inaktiv' },
]}
value={form.default_value}
onChange={(e) => setForm((prev) => ({ ...prev, default_value: e.target.value }))}
/>
)}
{/* Required checkbox */}
<div className="flex items-center gap-2">
<input
id="cf-required"
type="checkbox"
checked={form.required}
onChange={(e) => setForm((prev) => ({ ...prev, required: e.target.checked }))}
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="cf-required" className="text-sm font-medium text-secondary-700 cursor-pointer">
{t('customFields.required', 'Pflichtfeld')}
</label>
</div>
{/* Active checkbox */}
<div className="flex items-center gap-2">
<input
id="cf-active"
type="checkbox"
checked={form.is_active}
onChange={(e) => setForm((prev) => ({ ...prev, is_active: e.target.checked }))}
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="cf-active" className="text-sm font-medium text-secondary-700 cursor-pointer">
{t('customFields.active', 'Aktiv')}
</label>
</div>
{/* Form actions */}
<div className="flex justify-end gap-2 pt-4 border-t border-secondary-200">
<Button variant="secondary" onClick={closeFormModal}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button onClick={handleSave} isLoading={saving}>
{editingId ? t('common.save', 'Speichern') : t('common.create', 'Erstellen')}
</Button>
</div>
</div>
</Modal>
{/* Delete confirmation modal */}
<Modal
open={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title={t('customFields.deleteTitle', 'Feld löschen')}
size="sm"
>
<div className="space-y-4">
<p className="text-sm text-secondary-700">
{t('customFields.deleteConfirm', 'Möchten Sie das Feld')}{' '}
<strong>{deleteTarget?.label}</strong>{' '}
{t('customFields.deleteConfirmEnd', 'wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')}
</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button variant="danger" onClick={confirmDelete} isLoading={deleteMutation.isPending}>
<Trash2 className="w-4 h-4 mr-1" />
{t('common.delete', 'Löschen')}
</Button>
</div>
</div>
</Modal>
</div>
);
}
+1
View File
@@ -21,6 +21,7 @@ export function SettingsPage() {
{ to: '/settings/mail', label: t('mail.settings'), icon: '\ud83d\udce7' }, { to: '/settings/mail', label: t('mail.settings'), icon: '\ud83d\udce7' },
{ to: '/settings/ai-settings', label: 'KI Einstellungen', icon: '\ud83e\udde0' }, { to: '/settings/ai-settings', label: 'KI Einstellungen', icon: '\ud83e\udde0' },
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' }, { to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
{ to: '/settings/custom-fields', label: 'Custom Fields', icon: '\ud83d\udccb' },
]; ];
const existingPaths = new Set(hardcodedNavItems.map(item => item.to)); const existingPaths = new Set(hardcodedNavItems.map(item => item.to));
+500
View File
@@ -0,0 +1,500 @@
/**
* Tags page — Tag management.
*
* Features:
* - Table listing all tags with color dot, name, description, usage_count
* - "+ Neuer Tag" button opens a create modal
* - Edit modal for existing tags (name, color, description)
* - Delete with confirmation dialog
* - Color picker: predefined colors as clickable circles
*/
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import clsx from 'clsx';
import { Plus, Pencil, Trash2, AlertTriangle, Tag as TagIcon } from 'lucide-react';
import { fetchTags, createTag, updateTag, deleteTag, type Tag, type CreateTagPayload, type UpdateTagPayload } from '@/api/tags';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { getTagColorClasses, TAG_COLOR_NAMES } from '@/components/tags/TagBadge';
// ─── Tag Form Modal (shared for create & edit) ──────────────────────────────
interface TagFormModalProps {
open: boolean;
onClose: () => void;
tag?: Tag | null;
onSubmit: (data: CreateTagPayload | UpdateTagPayload) => void;
isSubmitting: boolean;
error?: string | null;
}
function TagFormModal({ open, onClose, tag, onSubmit, isSubmitting, error }: TagFormModalProps) {
const { t } = useTranslation();
const isEdit = !!tag;
const [name, setName] = useState(tag?.name ?? '');
const [color, setColor] = useState(tag?.color ?? 'blue');
const [description, setDescription] = useState(tag?.description ?? '');
// Reset form when modal opens or tag changes
React.useEffect(() => {
if (open) {
setName(tag?.name ?? '');
setColor(tag?.color ?? 'blue');
setDescription(tag?.description ?? '');
}
}, [open, tag]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
const trimmedName = name.trim();
if (!trimmedName) return;
const data: CreateTagPayload | UpdateTagPayload = {
name: trimmedName,
color,
description: description.trim() || null,
};
onSubmit(data);
},
[name, color, description, onSubmit]
);
return (
<Modal
open={open}
onClose={onClose}
title={isEdit ? t('tags.editTitle', 'Tag bearbeiten') : t('tags.createTitle', 'Neuer Tag')}
size="md"
>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<Input
label={t('tags.name', 'Name')}
value={name}
onChange={(e) => setName(e.target.value)}
required
placeholder={t('tags.namePlaceholder', 'z.B. Wichtig')}
autoFocus
data-testid="tag-form-name"
/>
{/* Color picker */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1.5">
{t('tags.color', 'Farbe')}
</label>
<div className="flex items-center gap-2 flex-wrap">
{TAG_COLOR_NAMES.map((colorName) => {
const colors = getTagColorClasses(colorName);
return (
<button
key={colorName}
type="button"
onClick={() => setColor(colorName)}
className={clsx(
'rounded-full transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-500',
colors.dot,
'w-8 h-8',
color === colorName
? 'ring-2 ring-offset-2 ring-secondary-400 scale-110'
: 'hover:scale-110'
)}
aria-label={colorName}
aria-pressed={color === colorName}
data-testid={`color-option-${colorName}`}
/>
);
})}
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('tags.description', 'Beschreibung')}
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('tags.descriptionPlaceholder', 'Optionale Beschreibung')}
rows={3}
className={clsx(
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'motion-safe:transition-colors text-secondary-900 placeholder-secondary-400'
)}
data-testid="tag-form-description"
/>
</div>
{/* Error */}
{error && (
<div className="rounded-md bg-danger-50 border border-danger-200 px-3 py-2 text-sm text-danger-700">
{error}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={isSubmitting}
disabled={!name.trim()}
icon={<Plus className="h-4 w-4" />}
data-testid="tag-form-submit"
>
{isEdit ? t('common.save', 'Speichern') : t('tags.create', 'Erstellen')}
</Button>
</div>
</form>
</Modal>
);
}
// ─── Delete Confirmation Modal ──────────────────────────────────────────────
interface DeleteModalProps {
open: boolean;
tag: Tag | null;
onConfirm: () => void;
onCancel: () => void;
isDeleting: boolean;
}
function DeleteModal({ open, tag, onConfirm, onCancel, isDeleting }: DeleteModalProps) {
const { t } = useTranslation();
return (
<Modal open={open} onClose={onCancel} title={t('tags.deleteTitle', 'Tag löschen')} size="sm">
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 rounded-full bg-danger-100 p-2">
<AlertTriangle className="h-5 w-5 text-danger-600" aria-hidden="true" />
</div>
<div>
<p className="text-sm text-secondary-700">
{t('tags.deleteConfirm', 'Möchten Sie den Tag')}{' '}
<span className="font-semibold text-secondary-900">{tag?.name}</span>{' '}
{t('tags.deleteConfirmEnd', 'wirklich löschen?')}
</p>
{tag && tag.usage_count !== undefined && tag.usage_count > 0 && (
<p className="text-sm text-secondary-500 mt-1">
{t('tags.deleteUsageWarning', 'Dieser Tag wird von')}{' '}
<span className="font-semibold">{tag.usage_count}</span>{' '}
{t('tags.deleteUsageEntities', 'Element(en) verwendet.')}
</p>
)}
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onCancel} disabled={isDeleting}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button
type="button"
variant="danger"
onClick={onConfirm}
isLoading={isDeleting}
icon={<Trash2 className="h-4 w-4" />}
data-testid="tag-delete-confirm"
>
{t('common.delete', 'Löschen')}
</Button>
</div>
</div>
</Modal>
);
}
// ─── Tags Page ──────────────────────────────────────────────────────────────
export function TagsPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
// ─── State ────────────────────────────────────────────────────────────────
const [showFormModal, setShowFormModal] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [deletingTag, setDeletingTag] = useState<Tag | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
// ─── Queries ──────────────────────────────────────────────────────────────
const { data: tags = [], isLoading } = useQuery<Tag[]>({
queryKey: ['tags'],
queryFn: fetchTags,
});
// ─── Mutations ────────────────────────────────────────────────────────────
const createMutation = useMutation<Tag, Error, CreateTagPayload>({
mutationFn: createTag,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
setShowFormModal(false);
setFormError(null);
},
onError: (err: Error) => {
setFormError(err.message || t('tags.createError', 'Fehler beim Erstellen.'));
},
});
const updateMutation = useMutation<Tag, Error, { id: string; payload: UpdateTagPayload }>({
mutationFn: ({ id, payload }) => updateTag(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
setShowFormModal(false);
setEditingTag(null);
setFormError(null);
},
onError: (err: Error) => {
setFormError(err.message || t('tags.updateError', 'Fehler beim Speichern.'));
},
});
const deleteMutation = useMutation<void, Error, string>({
mutationFn: deleteTag,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
setShowDeleteModal(false);
setDeletingTag(null);
},
});
// ─── Handlers ─────────────────────────────────────────────────────────────
const handleCreateClick = useCallback(() => {
setEditingTag(null);
setFormError(null);
setShowFormModal(true);
}, []);
const handleEditClick = useCallback((tag: Tag) => {
setEditingTag(tag);
setFormError(null);
setShowFormModal(true);
}, []);
const handleDeleteClick = useCallback((tag: Tag) => {
setDeletingTag(tag);
setShowDeleteModal(true);
}, []);
const handleFormSubmit = useCallback(
(data: CreateTagPayload | UpdateTagPayload) => {
setFormError(null);
if (editingTag) {
updateMutation.mutate({ id: editingTag.id, payload: data as UpdateTagPayload });
} else {
createMutation.mutate(data as CreateTagPayload);
}
},
[editingTag, createMutation, updateMutation]
);
const handleDeleteConfirm = useCallback(() => {
if (deletingTag) {
deleteMutation.mutate(deletingTag.id);
}
}, [deletingTag, deleteMutation]);
const handleFormClose = useCallback(() => {
setShowFormModal(false);
setEditingTag(null);
setFormError(null);
}, []);
const handleDeleteCancel = useCallback(() => {
setShowDeleteModal(false);
setDeletingTag(null);
}, []);
const isSubmitting = createMutation.isPending || updateMutation.isPending;
// ─── Render ───────────────────────────────────────────────────────────────
return (
<div className="space-y-4" data-testid="tags-page">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-secondary-900">
{t('tags.title', 'Tags')}
</h1>
<p className="text-sm text-secondary-500 mt-0.5">
{t('tags.subtitle', 'Verwalten Sie Tags für Kontakte, Dateien und Termine')}
</p>
</div>
<Button
variant="primary"
icon={<Plus className="h-4 w-4" />}
onClick={handleCreateClick}
data-testid="tag-create-btn"
>
{t('tags.newTag', 'Neuer Tag')}
</Button>
</div>
{/* Tags table */}
<Card>
{isLoading ? (
<div className="py-12 text-center">
<div className="inline-flex items-center gap-2 text-secondary-500">
<TagIcon className="h-5 w-5 animate-pulse" aria-hidden="true" />
{t('common.loading', 'Laden...')}
</div>
</div>
) : tags.length === 0 ? (
<div className="py-12 text-center">
<div className="mx-auto mb-3 w-12 h-12 rounded-full bg-secondary-100 flex items-center justify-center">
<TagIcon className="h-6 w-6 text-secondary-400" aria-hidden="true" />
</div>
<p className="text-sm text-secondary-500 mb-3">
{t('tags.empty', 'Noch keine Tags vorhanden.')}
</p>
<Button
variant="secondary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleCreateClick}
>
{t('tags.createFirst', 'Ersten Tag erstellen')}
</Button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full" data-testid="tags-table">
<thead>
<tr className="border-b border-secondary-200">
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
{t('tags.color', 'Farbe')}
</th>
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
{t('tags.name', 'Name')}
</th>
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
{t('tags.description', 'Beschreibung')}
</th>
<th className="text-right text-sm font-medium text-secondary-500 px-3 py-2">
{t('tags.usage', 'Verwendung')}
</th>
<th className="text-right text-sm font-medium text-secondary-500 px-3 py-2">
{t('common.actions', 'Aktionen')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{tags.map((tag) => {
const colors = getTagColorClasses(tag.color);
return (
<tr
key={tag.id}
className="hover:bg-secondary-50 transition-colors"
data-testid={`tag-row-${tag.id}`}
>
{/* Color dot */}
<td className="px-3 py-3">
<span
className={clsx('inline-block rounded-full', colors.dot, 'w-3.5 h-3.5')}
aria-hidden="true"
/>
</td>
{/* Name */}
<td className="px-3 py-3">
<span className="text-sm font-medium text-secondary-900">{tag.name}</span>
</td>
{/* Description */}
<td className="px-3 py-3 max-w-xs">
<span className="text-sm text-secondary-600 truncate block">
{tag.description || '—'}
</span>
</td>
{/* Usage count */}
<td className="px-3 py-3 text-right">
<span
className={clsx(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
tag.usage_count && tag.usage_count > 0
? 'bg-primary-50 text-primary-700'
: 'bg-secondary-100 text-secondary-500'
)}
>
{tag.usage_count ?? 0}
</span>
</td>
{/* Actions */}
<td className="px-3 py-3 text-right">
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={() => handleEditClick(tag)}
className={clsx(
'inline-flex items-center justify-center rounded-md p-1.5',
'text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100',
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
'min-h-touch min-w-touch'
)}
aria-label={t('tags.editLabel', 'Tag bearbeiten')}
data-testid={`tag-edit-btn-${tag.id}`}
>
<Pencil className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => handleDeleteClick(tag)}
className={clsx(
'inline-flex items-center justify-center rounded-md p-1.5',
'text-secondary-400 hover:text-danger-600 hover:bg-danger-50',
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-danger-500',
'min-h-touch min-w-touch'
)}
aria-label={t('tags.deleteLabel', 'Tag löschen')}
data-testid={`tag-delete-btn-${tag.id}`}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
{/* Create / Edit Modal */}
<TagFormModal
open={showFormModal}
onClose={handleFormClose}
tag={editingTag}
onSubmit={handleFormSubmit}
isSubmitting={isSubmitting}
error={formError}
/>
{/* Delete Confirmation Modal */}
<DeleteModal
open={showDeleteModal}
tag={deletingTag}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
isDeleting={deleteMutation.isPending}
/>
{/* Delete error */}
{deleteMutation.isError && (
<div className="fixed bottom-4 right-4 rounded-md bg-danger-600 text-white px-4 py-3 shadow-lg text-sm z-50">
{deleteMutation.error?.message || t('tags.deleteError', 'Fehler beim Löschen.')}
</div>
)}
</div>
);
}
+4
View File
@@ -55,6 +55,8 @@ const CommunicationPage = React.lazy(() => import('@/pages/Communication').then(
const WorkflowsPage = React.lazy(() => import('@/pages/Workflows').then(m => ({ default: m.WorkflowsPage }))); const WorkflowsPage = React.lazy(() => import('@/pages/Workflows').then(m => ({ default: m.WorkflowsPage })));
const DedupMergePage = React.lazy(() => import('@/pages/DedupMerge').then(m => ({ default: m.DedupMergePage }))); const DedupMergePage = React.lazy(() => import('@/pages/DedupMerge').then(m => ({ default: m.DedupMergePage })));
const ImportExportPage = React.lazy(() => import('@/pages/ImportExport').then(m => ({ default: m.ImportExportPage }))); const ImportExportPage = React.lazy(() => import('@/pages/ImportExport').then(m => ({ default: m.ImportExportPage })));
const TagsPage = React.lazy(() => import('@/pages/Tags').then(m => ({ default: m.TagsPage })));
const CustomFieldsPage = React.lazy(() => import('@/pages/CustomFields').then(m => ({ default: m.CustomFieldsPage })));
/** Centered spinner fallback for lazy-loaded routes */ /** Centered spinner fallback for lazy-loaded routes */
function PageLoader() { function PageLoader() {
@@ -131,6 +133,7 @@ const router = createBrowserRouter([
{ path: '/workflows', element: withSuspense(<WorkflowsPage />) }, { path: '/workflows', element: withSuspense(<WorkflowsPage />) },
{ path: '/contacts/dedup', element: withSuspense(<DedupMergePage />) }, { path: '/contacts/dedup', element: withSuspense(<DedupMergePage />) },
{ path: '/import-export', element: withSuspense(<ImportExportPage />) }, { path: '/import-export', element: withSuspense(<ImportExportPage />) },
{ path: '/tags', element: withSuspense(<TagsPage />) },
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) }, { path: '/profile', element: withSuspense(<SettingsProfilePage />) },
{ {
path: '/settings', path: '/settings',
@@ -155,6 +158,7 @@ const router = createBrowserRouter([
{ path: 'mcp', element: withSuspense(<SettingsMcpPage />) }, { path: 'mcp', element: withSuspense(<SettingsMcpPage />) },
{ path: 'ai-settings', element: withSuspense(<SettingsAIPage />) }, { path: 'ai-settings', element: withSuspense(<SettingsAIPage />) },
{ path: 'menu', element: withSuspense(<SettingsMenuOrderPage />) }, { path: 'menu', element: withSuspense(<SettingsMenuOrderPage />) },
{ path: 'custom-fields', element: withSuspense(<CustomFieldsPage />) },
{ path: '*', element: <PluginRouteRenderer /> }, { path: '*', element: <PluginRouteRenderer /> },
], ],
}, },