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,
system_settings,
attachments,
custom_field_definitions,
custom_fields,
saved_filters,
)
@@ -330,6 +331,7 @@ def create_app() -> FastAPI:
app.include_router(addresses.router)
app.include_router(bank_accounts.router)
app.include_router(audit.router)
app.include_router(custom_field_definitions.router)
app.include_router(custom_fields.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.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.custom_field_definition import CustomFieldDefinition
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
__all__ = [
@@ -54,6 +55,7 @@ __all__ = [
"PluginMigration",
"AIConversation",
"AIMessage",
"CustomFieldDefinition",
"Workflow",
"WorkflowInstance",
"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",
order=54,
),
FrontendMenuItem(
label_key="nav.tags",
label="Tags",
path="/tags",
icon="Tag",
order=55,
),
],
page_routes=[
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.deps import get_current_user, require_permission
from app.models.contact import Contact
from app.models.custom_field_definition import CustomFieldDefinition
from app.plugins.registry import get_registry
router = APIRouter(prefix="/api/v1/contacts", tags=["custom-fields"])
@@ -24,10 +25,19 @@ class CustomFieldUpdateRequest(BaseModel):
values: dict[str, Any] = {}
def _collect_custom_field_definitions(entity: str = "contact") -> list[dict[str, Any]]:
"""Collect all custom field definitions from active plugin manifests."""
async def _collect_custom_field_definitions(
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]] = []
seen_names: set[str] = set()
# 1. Collect from active plugin manifests
registry = get_registry()
for plugin in registry._plugins.values():
manifest = plugin.manifest
@@ -50,10 +60,40 @@ def _collect_custom_field_definitions(entity: str = "contact") -> list[dict[str,
"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
def _merge_definitions_with_values(
async def _merge_definitions_with_values(
definitions: list[dict[str, Any]], stored: dict[str, Any] | None
) -> list[dict[str, Any]]:
"""Merge field definitions with stored values, applying defaults."""
@@ -87,8 +127,8 @@ async def get_custom_fields(
if contact is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
definitions = _collect_custom_field_definitions("contact")
merged = _merge_definitions_with_values(definitions, contact.custom)
definitions = await _collect_custom_field_definitions(db, tenant_id, "contact")
merged = await _merge_definitions_with_values(definitions, contact.custom)
return {"fields": merged}
@@ -114,7 +154,7 @@ async def update_custom_fields(
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
# 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}
current_custom = dict(contact.custom or {})
@@ -154,5 +194,5 @@ async def update_custom_fields(
contact.custom = current_custom
await db.flush()
merged = _merge_definitions_with_values(definitions, contact.custom)
merged = await _merge_definitions_with_values(definitions, contact.custom)
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({
queryKey: ['notifications', 'unread-count'],
queryFn: async () => {
const data = await apiGet<UnreadCountResponse>('/notifications/unread-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 { SuggestionBadge } from '@/components/ai/SuggestionBadge';
import { Building, ChevronDown, Menu, Zap, Bot, Layers } from 'lucide-react';
import { NotificationBell } from '@/components/layout/NotificationBell';
import { useWindowStore } from '@/store/windowStore';
export function TopBar() {
@@ -80,6 +81,7 @@ export function TopBar() {
</div>
<div className="flex items-center gap-2">
<NotificationBell />
{/* Minimized windows */}
{minimizedWindows.length > 0 && (
<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/ai-settings', label: 'KI Einstellungen', icon: '\ud83e\udde0' },
{ 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));
+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 DedupMergePage = React.lazy(() => import('@/pages/DedupMerge').then(m => ({ default: m.DedupMergePage })));
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 */
function PageLoader() {
@@ -131,6 +133,7 @@ const router = createBrowserRouter([
{ path: '/workflows', element: withSuspense(<WorkflowsPage />) },
{ path: '/contacts/dedup', element: withSuspense(<DedupMergePage />) },
{ path: '/import-export', element: withSuspense(<ImportExportPage />) },
{ path: '/tags', element: withSuspense(<TagsPage />) },
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
{
path: '/settings',
@@ -155,6 +158,7 @@ const router = createBrowserRouter([
{ path: 'mcp', element: withSuspense(<SettingsMcpPage />) },
{ path: 'ai-settings', element: withSuspense(<SettingsAIPage />) },
{ path: 'menu', element: withSuspense(<SettingsMenuOrderPage />) },
{ path: 'custom-fields', element: withSuspense(<CustomFieldsPage />) },
{ path: '*', element: <PluginRouteRenderer /> },
],
},