diff --git a/alembic/versions/0041_custom_field_definitions.py b/alembic/versions/0041_custom_field_definitions.py new file mode 100644 index 0000000..b19dff0 --- /dev/null +++ b/alembic/versions/0041_custom_field_definitions.py @@ -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")) diff --git a/app/main.py b/app/main.py index ba4baa4..f45c8f5 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py index a97af4f..6db7d43 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", diff --git a/app/models/custom_field_definition.py b/app/models/custom_field_definition.py new file mode 100644 index 0000000..328728a --- /dev/null +++ b/app/models/custom_field_definition.py @@ -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 + ) diff --git a/app/plugins/builtins/automation/plugin.py b/app/plugins/builtins/automation/plugin.py index a46b870..7684986 100644 --- a/app/plugins/builtins/automation/plugin.py +++ b/app/plugins/builtins/automation/plugin.py @@ -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( diff --git a/app/routes/custom_field_definitions.py b/app/routes/custom_field_definitions.py new file mode 100644 index 0000000..44e345a --- /dev/null +++ b/app/routes/custom_field_definitions.py @@ -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 diff --git a/app/routes/custom_fields.py b/app/routes/custom_fields.py index 92a3374..5936796 100644 --- a/app/routes/custom_fields.py +++ b/app/routes/custom_fields.py @@ -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} diff --git a/app/schemas/custom_field_definition.py b/app/schemas/custom_field_definition.py new file mode 100644 index 0000000..71739f3 --- /dev/null +++ b/app/schemas/custom_field_definition.py @@ -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 diff --git a/app/services/custom_field_service.py b/app/services/custom_field_service.py new file mode 100644 index 0000000..3f3e0b0 --- /dev/null +++ b/app/services/custom_field_service.py @@ -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 diff --git a/frontend/src/api/customFieldDefinitions.ts b/frontend/src/api/customFieldDefinitions.ts new file mode 100644 index 0000000..0615414 --- /dev/null +++ b/frontend/src/api/customFieldDefinitions.ts @@ -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(`/custom-fields/definitions${params}`); + }, + }); +} + +/** + * Create a new custom field definition. + */ +export function useCreateCustomFieldDefinition() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CustomFieldDefinitionCreate) => + apiPost('/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(`/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(`/custom-fields/definitions/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['custom-field-definitions'] }); + }, + }); +} diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index e94a395..3ec70f1 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -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('/notifications/unread-count'); return data.count; }, + refetchInterval: options?.refetchInterval, }); } diff --git a/frontend/src/components/custom-fields/CustomFieldRenderer.tsx b/frontend/src/components/custom-fields/CustomFieldRenderer.tsx new file mode 100644 index 0000000..d05c5b5 --- /dev/null +++ b/frontend/src/components/custom-fields/CustomFieldRenderer.tsx @@ -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 ( +
+ +
+ ); + } + + // --- 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 ( +
+ + {selectedValues.length > 0 && ( +
+ {selectedValues.map((opt) => ( + + {opt} + + + ))} +
+ )} + {unselected.length > 0 ? ( +
+ {unselected.map((opt) => ( + + ))} +
+ ) : availableOptions.length === 0 ? ( +

Keine Optionen verfügbar

+ ) : ( +

Alle Optionen ausgewählt

+ )} +
+ ); + } + + // --- Select: dropdown --- + if (field_type === 'select') { + const selectOptions = (options || []).map((opt) => ({ value: opt, label: opt })); + return ( + { + const raw = e.target.value; + onChange(raw === '' ? null : Number(raw)); + }} + /> + ); + } + + // --- Date: date input --- + if (field_type === 'date') { + return ( + onChange(e.target.value)} + /> + ); + } + + // --- Text (default) --- + return ( + onChange(e.target.value)} + /> + ); +} diff --git a/frontend/src/components/layout/NotificationBell.tsx b/frontend/src/components/layout/NotificationBell.tsx new file mode 100644 index 0000000..5079f43 --- /dev/null +++ b/frontend/src/components/layout/NotificationBell.tsx @@ -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(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 ( +
+ + + {open && } +
+ ); +} diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index d872b49..2d757a3 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -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() {
+ {/* Minimized windows */} {minimizedWindows.length > 0 && (
diff --git a/frontend/src/components/notifications/NotificationDropdown.tsx b/frontend/src/components/notifications/NotificationDropdown.tsx new file mode 100644 index 0000000..a2a549a --- /dev/null +++ b/frontend/src/components/notifications/NotificationDropdown.tsx @@ -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 ( +
+ {/* Header */} +
+

+ {t('notifications.title', 'Benachrichtigungen')} +

+ {hasUnread && ( + + )} +
+ + {/* Body */} +
+ {isLoading && } + + {isError && ( +
+ +

+ {t('notifications.errorLoading', 'Fehler beim Laden der Benachrichtigungen')} +

+
+ )} + + {!isLoading && !isError && notifications.length === 0 && ( +
+ +

+ {t('notifications.empty', 'Keine Benachrichtigungen')} +

+
+ )} + + {!isLoading && !isError && notifications.length > 0 && ( +
+ {notifications.map((n) => ( + markReadMutation.mutate(id)} + /> + ))} +
+ )} +
+ + {/* Footer */} +
+ +
+
+ ); +} + +// ── internal: loading skeleton ── + +function NotificationSkeleton() { + return ( +