Compare commits
4 Commits
63b99ba489
...
a914280a4e
| Author | SHA1 | Date | |
|---|---|---|---|
| a914280a4e | |||
| 182af355d1 | |||
| 2c9e74776e | |||
| bdad91a649 |
+80
@@ -464,3 +464,83 @@ LeoCRM-Agenten können externe MCP-Server nutzen (Web-Search, Code-Execution, ex
|
||||
- Bestehende Patterns verwendet (apiClient, React Query hooks, lazy-loaded pages)
|
||||
|
||||
**Phase 5 Batch 5 Gesamt: ✅ Complete**
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 Batch 6a (Tasks 5.20-5.22) — ✅ Complete
|
||||
|
||||
### Task 5.20: Custom Fields — Plugin-Felder in UI (6h)
|
||||
|
||||
**Backend:**
|
||||
- `app/plugins/manifest.py` — `CustomFieldDefinition` model (name, label, label_key, field_type, options, default_value, required, entity) + `custom_fields` field on `PluginManifest`
|
||||
- `app/routes/custom_fields.py` — `GET/PATCH /api/v1/contacts/{id}/custom-fields` routes
|
||||
- Merges plugin-defined field definitions with stored values from `contacts.custom` JSONB
|
||||
- Validates required fields, select/multiselect options
|
||||
- RBAC: `contacts:read` / `contacts:write`
|
||||
- `app/plugins/registry.py` — `get_active_manifests` now includes `custom_fields` in response
|
||||
|
||||
**Frontend:**
|
||||
- `frontend/src/api/customFields.ts` — `useCustomFields`, `useUpdateCustomFields` React Query hooks
|
||||
- `frontend/src/components/contacts/CustomFieldRenderer.tsx` — renders fields by type (text/number/date/select/multiselect/boolean) in read + edit modes
|
||||
- Integrated into `ContactDetail` (read-only) and `ContactEditModal` (editable with save logic)
|
||||
- `frontend/src/store/pluginStore.ts` — `PluginCustomFieldDefinition` interface + `getCustomFieldsForEntity` selector
|
||||
|
||||
**Tests:**
|
||||
- `tests/test_custom_fields.py` — 9 tests (GET/PATCH/manifest validation)
|
||||
- `frontend/src/__tests__/CustomFieldRenderer.test.tsx` — 5 tests (read/edit/multiselect/empty)
|
||||
|
||||
### Task 5.21: Tasks-Plugin (12h)
|
||||
|
||||
**Backend:**
|
||||
- New plugin `app/plugins/builtins/tasks/` with full structure:
|
||||
- `plugin.py` — PluginManifest (name='tasks', dependencies=['permissions'], permissions=['tasks:read/write/delete'])
|
||||
- `models.py` — Task model with TenantMixin (title, description, status, priority, due_date, assigned_to, contact_id)
|
||||
- `schemas.py` — Pydantic schemas for CRUD + assign + status
|
||||
- `routes.py` — CRUD endpoints: GET/POST /tasks, GET/PATCH/DELETE /tasks/{id}, POST /tasks/{id}/assign, POST /tasks/{id}/status
|
||||
- `services.py` — Business logic with filtering, pagination, soft-delete
|
||||
- `migrations/0001_initial.sql` — Creates tasks table with indexes
|
||||
- `jobs.py` — ARQ `tasks_due_reminder` cron job (daily 8:00) sends notifications for due tasks
|
||||
- Registered in `app/core/worker.py` (functions + cron_jobs)
|
||||
- Registered in `tests/conftest.py`
|
||||
|
||||
**Frontend:**
|
||||
- `frontend/src/api/tasks.ts` — Full React Query hooks (useTasks, useTask, useCreateTask, useUpdateTask, useDeleteTask, useAssignTask, useUpdateTaskStatus)
|
||||
- `frontend/src/pages/Tasks.tsx` — Task list with filter (status/priority/search), create/edit modal, detail modal, pagination
|
||||
- Route `/tasks` in `routes/index.tsx` (lazy-loaded)
|
||||
- Sidebar entry via plugin manifest menu_items
|
||||
- i18n keys for `nav.tasks` and `tasks.*` in de.json and en.json
|
||||
|
||||
**Tests:**
|
||||
- `tests/test_tasks.py` — 11 tests (list/create/update/status/delete + auth + validation)
|
||||
- `frontend/src/__tests__/Tasks.test.tsx` — 3 tests (render/list/create modal)
|
||||
|
||||
### Task 5.22: Saved Searches / Smart Lists (6h)
|
||||
|
||||
**Backend:**
|
||||
- `app/models/saved_filter.py` — SavedFilter model with TenantMixin (name, entity_type, filter_criteria JSONB, user_id)
|
||||
- `app/routes/saved_filters.py` — GET/POST /saved-filters, DELETE /saved-filters/{id} with RBAC
|
||||
- `alembic/versions/0029_saved_filters.py` — Migration creates saved_filters table
|
||||
- Registered in `app/main.py` and `tests/conftest.py`
|
||||
|
||||
**Frontend:**
|
||||
- `frontend/src/api/savedFilters.ts` — useSavedFilters, useCreateSavedFilter, useDeleteSavedFilter hooks
|
||||
- `frontend/src/components/SavedFilters.tsx` — Filter-builder UI with save button, load saved filters as tabs, delete
|
||||
- Integrated into `ContactsListPage` as example (saves search/type/sort criteria)
|
||||
- i18n keys for `savedFilters.*` in de.json and en.json
|
||||
|
||||
**Tests:**
|
||||
- `tests/test_saved_filters.py` — 9 tests (list/create/delete + auth + validation + duplicate)
|
||||
- `frontend/src/__tests__/SavedFilters.test.tsx` — 3 tests (render/save modal/load filter)
|
||||
|
||||
### Verifikation
|
||||
- TSC: 0 neue Errors (2 pre-existing Dms.tsx errors)
|
||||
- 3 Commits mit klaren Messages
|
||||
- Mindestens 3 Tests pro Task (9+11+9 backend, 5+3+3 frontend)
|
||||
- RBAC (require_permission) auf allen API-Routes
|
||||
- TenantMixin auf allen neuen DB-Models
|
||||
- i18n (de.json, en.json) aktualisiert
|
||||
- Keine .env committet
|
||||
- Bestehende Patterns verwendet (apiClient, React Query hooks, lazy-loaded pages, Zustand stores)
|
||||
- Plugins automatisch via pkgutil entdeckt
|
||||
|
||||
**Phase 5 Batch 6a Gesamt: ✅ Complete**
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""saved_filters table
|
||||
|
||||
Revision ID: 0029_saved_filters
|
||||
Revises: 0028_user_preferences
|
||||
Create Date: 2025-07-23
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
# revision identifiers
|
||||
revision = "0029_saved_filters"
|
||||
down_revision = "0028_user_preferences"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"saved_filters",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("entity_type", sa.String(50), nullable=False),
|
||||
sa.Column("filter_criteria", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "user_id", "entity_type", "name", name="uq_saved_filters_tenant_user_entity_name"),
|
||||
)
|
||||
op.create_index("ix_saved_filters_tenant_user", "saved_filters", ["tenant_id", "user_id"])
|
||||
op.create_index("ix_saved_filters_tenant_entity", "saved_filters", ["tenant_id", "entity_type"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_saved_filters_tenant_entity", table_name="saved_filters")
|
||||
op.drop_index("ix_saved_filters_tenant_user", table_name="saved_filters")
|
||||
op.drop_table("saved_filters")
|
||||
@@ -53,6 +53,7 @@ from app.plugins.builtins.automation.scheduler import scheduler_tick
|
||||
from app.plugins.builtins.automation.workflow_timeout import check_workflow_timeouts
|
||||
from app.plugins.builtins.automation.agent_runner import run_agent
|
||||
from app.plugins.builtins.automation.execution_engine import run_automation
|
||||
from app.plugins.builtins.tasks.jobs import tasks_due_reminder
|
||||
|
||||
|
||||
class WorkerSettings:
|
||||
@@ -69,6 +70,7 @@ class WorkerSettings:
|
||||
check_workflow_timeouts,
|
||||
run_agent,
|
||||
run_automation,
|
||||
tasks_due_reminder,
|
||||
]
|
||||
redis_settings = _get_redis_settings()
|
||||
on_startup = on_startup
|
||||
@@ -79,4 +81,5 @@ class WorkerSettings:
|
||||
cron_jobs = [
|
||||
cron(scheduler_tick, second={0, 30}),
|
||||
cron(check_workflow_timeouts, minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}),
|
||||
cron(tasks_due_reminder, hour=8, minute=0),
|
||||
]
|
||||
|
||||
@@ -47,6 +47,8 @@ from app.routes import (
|
||||
sequences,
|
||||
system_settings,
|
||||
attachments,
|
||||
custom_fields,
|
||||
saved_filters,
|
||||
)
|
||||
|
||||
|
||||
@@ -304,6 +306,8 @@ def create_app() -> FastAPI:
|
||||
app.include_router(attachments.router)
|
||||
app.include_router(addresses.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(custom_fields.router)
|
||||
app.include_router(saved_filters.router)
|
||||
|
||||
# ── Register plugin routes (before SPA catch-all) ──────────────────
|
||||
registry = get_registry()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""SavedFilter model — tenant-scoped, user-scoped saved filter criteria."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class SavedFilter(Base, TenantMixin):
|
||||
"""Saved filter — reusable filter criteria for list views (contacts, mail, calendar, DMS)."""
|
||||
|
||||
__tablename__ = "saved_filters"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "user_id", "entity_type", "name", name="uq_saved_filters_tenant_user_entity_name"),
|
||||
Index("ix_saved_filters_tenant_user", "tenant_id", "user_id"),
|
||||
Index("ix_saved_filters_tenant_entity", "tenant_id", "entity_type"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
entity_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False
|
||||
) # contacts, mail, calendar, dms
|
||||
filter_criteria: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Tasks builtin plugin."""
|
||||
|
||||
from app.plugins.builtins.tasks.plugin import TasksPlugin
|
||||
|
||||
__all__ = ["TasksPlugin"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""ARQ reminder job for due tasks — sends notifications for overdue/due tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def tasks_due_reminder(ctx: dict) -> None:
|
||||
"""Check for due tasks and send notifications to assigned users.
|
||||
|
||||
Runs daily at 8:00 via cron. Finds all non-done tasks with due_date <= now
|
||||
and creates a notification for the assigned user.
|
||||
"""
|
||||
from app.plugins.builtins.tasks.services import get_due_tasks
|
||||
from app.models.tenant import Tenant
|
||||
from app.core.notifications import create_notification
|
||||
from sqlalchemy import select
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as db:
|
||||
result = await db.execute(select(Tenant))
|
||||
tenants = result.scalars().all()
|
||||
|
||||
total_notified = 0
|
||||
for tenant in tenants:
|
||||
try:
|
||||
due_tasks = await get_due_tasks(db, tenant.id)
|
||||
for task in due_tasks:
|
||||
if not task.get("assigned_to"):
|
||||
continue
|
||||
await create_notification(
|
||||
db,
|
||||
user_id=task["assigned_to"],
|
||||
tenant_id=str(tenant.id),
|
||||
type="task_due",
|
||||
title=f"Task due: {task['title']}",
|
||||
body=f"Task '{task['title']}' is due. Priority: {task['priority']}",
|
||||
data={"task_id": task["id"], "due_date": task.get("due_date")},
|
||||
)
|
||||
total_notified += 1
|
||||
except Exception:
|
||||
logger.warning(f"Failed to process due tasks for tenant {tenant.id}", exc_info=True)
|
||||
|
||||
if total_notified > 0:
|
||||
logger.info(f"Tasks reminder: sent {total_notified} notifications")
|
||||
await db.commit()
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Tasks plugin initial migration: creates tasks table
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
priority VARCHAR(10) NOT NULL DEFAULT 'medium',
|
||||
due_date TIMESTAMPTZ,
|
||||
assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_deleted ON tasks(tenant_id, deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_status ON tasks(tenant_id, status);
|
||||
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_assigned ON tasks(tenant_id, assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_due ON tasks(tenant_id, due_date);
|
||||
CREATE INDEX IF NOT EXISTS ix_tasks_contact ON tasks(contact_id);
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Task model for the Tasks plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class Task(Base, TenantMixin):
|
||||
"""Task entity — free activities (calls, notes, visits) linked to contacts."""
|
||||
|
||||
__tablename__ = "tasks"
|
||||
__table_args__ = (
|
||||
Index("ix_tasks_tenant_deleted", "tenant_id", "deleted_at"),
|
||||
Index("ix_tasks_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_tasks_tenant_assigned", "tenant_id", "assigned_to"),
|
||||
Index("ix_tasks_tenant_due", "tenant_id", "due_date"),
|
||||
Index("ix_tasks_contact", "contact_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="open"
|
||||
) # open, in_progress, done
|
||||
priority: Mapped[str] = mapped_column(
|
||||
String(10), nullable=False, default="medium"
|
||||
) # low, medium, high, urgent
|
||||
due_date: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
assigned_to: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
contact_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("contacts.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Tasks plugin — manage free tasks/activities linked to contacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import (
|
||||
PluginManifest,
|
||||
PluginRouteDef,
|
||||
FrontendMenuItem,
|
||||
FrontendPageRoute,
|
||||
CronJobContribution,
|
||||
)
|
||||
|
||||
|
||||
class TasksPlugin(BasePlugin):
|
||||
"""Tasks plugin for managing activities (calls, notes, visits) linked to contacts."""
|
||||
|
||||
manifest = PluginManifest(
|
||||
name="tasks",
|
||||
version="1.0.0",
|
||||
display_name="Tasks",
|
||||
description="Manage free tasks/activities with status, priority, due dates, and contact links.",
|
||||
dependencies=["permissions"],
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
path="/api/v1/tasks",
|
||||
module="app.plugins.builtins.tasks.routes",
|
||||
router_attr="router",
|
||||
),
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[
|
||||
"tasks:read",
|
||||
"tasks:write",
|
||||
"tasks:delete",
|
||||
],
|
||||
is_core=True,
|
||||
menu_items=[
|
||||
FrontendMenuItem(
|
||||
label_key="nav.tasks",
|
||||
label="Tasks",
|
||||
path="/tasks",
|
||||
icon="CheckSquare",
|
||||
order=30,
|
||||
),
|
||||
],
|
||||
page_routes=[
|
||||
FrontendPageRoute(
|
||||
path="/tasks",
|
||||
component="@/pages/Tasks",
|
||||
protected=True,
|
||||
order=30,
|
||||
),
|
||||
],
|
||||
cron_jobs=[
|
||||
CronJobContribution(
|
||||
name="tasks_due_reminder",
|
||||
cron_expression="0 8 * * *",
|
||||
job_type="custom",
|
||||
target_name="tasks_due_reminder",
|
||||
plugin_name="tasks",
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tasks plugin routes — CRUD, assign, status update."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.tasks import services
|
||||
from app.plugins.builtins.tasks.schemas import (
|
||||
TaskAssignRequest,
|
||||
TaskCreate,
|
||||
TaskStatusRequest,
|
||||
TaskUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(val)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
|
||||
) from None
|
||||
|
||||
|
||||
@router.get("", dependencies=[Depends(require_permission("tasks:read"))])
|
||||
async def list_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(25, ge=1, le=100),
|
||||
status: str | None = Query(None, pattern="^(open|in_progress|done)$"),
|
||||
priority: str | None = Query(None, pattern="^(low|medium|high|urgent)$"),
|
||||
assigned_to: str | None = Query(None),
|
||||
contact_id: str | None = Query(None),
|
||||
search: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List tasks with filtering and pagination."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
return await services.list_tasks(
|
||||
db, tenant_id,
|
||||
page=page, page_size=page_size,
|
||||
status=status, priority=priority,
|
||||
assigned_to=assigned_to, contact_id=contact_id,
|
||||
search=search,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("tasks:write"))])
|
||||
async def create_task(
|
||||
body: TaskCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new task."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
data = body.model_dump()
|
||||
return await services.create_task(db, tenant_id, user_id, data)
|
||||
|
||||
|
||||
@router.get("/{task_id}", dependencies=[Depends(require_permission("tasks:read"))])
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single task by ID."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
tid = _parse_uuid(task_id, "task_id")
|
||||
result = await services.get_task(db, tenant_id, tid)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/{task_id}", dependencies=[Depends(require_permission("tasks:write"))])
|
||||
async def update_task(
|
||||
task_id: str,
|
||||
body: TaskUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update a task."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
tid = _parse_uuid(task_id, "task_id")
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
result = await services.update_task(db, tenant_id, tid, data)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("tasks:delete"))])
|
||||
async def delete_task(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a task (soft-delete)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
tid = _parse_uuid(task_id, "task_id")
|
||||
deleted = await services.delete_task(db, tenant_id, tid)
|
||||
if not deleted:
|
||||
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/{task_id}/assign", dependencies=[Depends(require_permission("tasks:write"))])
|
||||
async def assign_task(
|
||||
task_id: str,
|
||||
body: TaskAssignRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Assign a task to a user."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
tid = _parse_uuid(task_id, "task_id")
|
||||
assigned_to = _parse_uuid(body.assigned_to, "assigned_to")
|
||||
result = await services.assign_task(db, tenant_id, tid, assigned_to)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{task_id}/status", dependencies=[Depends(require_permission("tasks:write"))])
|
||||
async def update_task_status(
|
||||
task_id: str,
|
||||
body: TaskStatusRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update task status."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
tid = _parse_uuid(task_id, "task_id")
|
||||
result = await services.update_task_status(db, tenant_id, tid, body.status)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "Task not found", "code": "not_found"})
|
||||
return result
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Pydantic schemas for the Tasks plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
"""Schema for creating a task."""
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
status: str = Field(default="open", pattern="^(open|in_progress|done)$")
|
||||
priority: str = Field(default="medium", pattern="^(low|medium|high|urgent)$")
|
||||
due_date: datetime | None = None
|
||||
assigned_to: str | None = None
|
||||
contact_id: str | None = None
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
"""Schema for updating a task."""
|
||||
title: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
status: str | None = Field(default=None, pattern="^(open|in_progress|done)$")
|
||||
priority: str | None = Field(default=None, pattern="^(low|medium|high|urgent)$")
|
||||
due_date: datetime | None = None
|
||||
assigned_to: str | None = None
|
||||
contact_id: str | None = None
|
||||
|
||||
|
||||
class TaskAssignRequest(BaseModel):
|
||||
"""Schema for assigning a task."""
|
||||
assigned_to: str = Field(..., description="User ID to assign the task to")
|
||||
|
||||
|
||||
class TaskStatusRequest(BaseModel):
|
||||
"""Schema for updating task status."""
|
||||
status: str = Field(..., pattern="^(open|in_progress|done)$")
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
"""Schema for task response."""
|
||||
id: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: str
|
||||
priority: str
|
||||
due_date: datetime | None = None
|
||||
assigned_to: str | None = None
|
||||
contact_id: str | None = None
|
||||
created_by: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TaskListResponse(BaseModel):
|
||||
"""Paginated task list response."""
|
||||
items: list[TaskResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Business logic for the Tasks plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.tasks.models import Task
|
||||
|
||||
|
||||
def _task_to_dict(task: Task) -> dict[str, Any]:
|
||||
"""Serialize a Task model to a dict."""
|
||||
return {
|
||||
"id": str(task.id),
|
||||
"title": task.title,
|
||||
"description": task.description,
|
||||
"status": task.status,
|
||||
"priority": task.priority,
|
||||
"due_date": task.due_date.isoformat() if task.due_date else None,
|
||||
"assigned_to": str(task.assigned_to) if task.assigned_to else None,
|
||||
"contact_id": str(task.contact_id) if task.contact_id else None,
|
||||
"created_by": str(task.created_by) if task.created_by else None,
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def list_tasks(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
assigned_to: str | None = None,
|
||||
contact_id: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List tasks with filtering and pagination."""
|
||||
query = select(Task).where(Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
||||
|
||||
if status:
|
||||
query = query.where(Task.status == status)
|
||||
if priority:
|
||||
query = query.where(Task.priority == priority)
|
||||
if assigned_to:
|
||||
query = query.where(Task.assigned_to == uuid.UUID(assigned_to))
|
||||
if contact_id:
|
||||
query = query.where(Task.contact_id == uuid.UUID(contact_id))
|
||||
if search:
|
||||
query = query.where(Task.title.ilike(f"%{search}%"))
|
||||
|
||||
# Count total
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Paginate
|
||||
query = query.order_by(Task.due_date.asc().nulls_last(), Task.created_at.desc())
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await db.execute(query)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
return {
|
||||
"items": [_task_to_dict(t) for t in tasks],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
async def get_task(db: AsyncSession, tenant_id: uuid.UUID, task_id: uuid.UUID) -> dict[str, Any] | None:
|
||||
"""Get a single task by ID."""
|
||||
result = await db.execute(
|
||||
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
return None
|
||||
return _task_to_dict(task)
|
||||
|
||||
|
||||
async def create_task(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new task."""
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
title=data["title"],
|
||||
description=data.get("description"),
|
||||
status=data.get("status", "open"),
|
||||
priority=data.get("priority", "medium"),
|
||||
due_date=data.get("due_date"),
|
||||
assigned_to=uuid.UUID(data["assigned_to"]) if data.get("assigned_to") else None,
|
||||
contact_id=uuid.UUID(data["contact_id"]) if data.get("contact_id") else None,
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
return _task_to_dict(task)
|
||||
|
||||
|
||||
async def update_task(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
task_id: uuid.UUID,
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update a task."""
|
||||
result = await db.execute(
|
||||
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
return None
|
||||
|
||||
if "title" in data and data["title"] is not None:
|
||||
task.title = data["title"]
|
||||
if "description" in data:
|
||||
task.description = data["description"]
|
||||
if "status" in data and data["status"] is not None:
|
||||
task.status = data["status"]
|
||||
if "priority" in data and data["priority"] is not None:
|
||||
task.priority = data["priority"]
|
||||
if "due_date" in data:
|
||||
task.due_date = data["due_date"]
|
||||
if "assigned_to" in data:
|
||||
task.assigned_to = uuid.UUID(data["assigned_to"]) if data["assigned_to"] else None
|
||||
if "contact_id" in data:
|
||||
task.contact_id = uuid.UUID(data["contact_id"]) if data["contact_id"] else None
|
||||
|
||||
await db.flush()
|
||||
return _task_to_dict(task)
|
||||
|
||||
|
||||
async def delete_task(db: AsyncSession, tenant_id: uuid.UUID, task_id: uuid.UUID) -> bool:
|
||||
"""Soft-delete a task."""
|
||||
result = await db.execute(
|
||||
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
return False
|
||||
task.deleted_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def assign_task(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
task_id: uuid.UUID,
|
||||
assigned_to: uuid.UUID,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Assign a task to a user."""
|
||||
result = await db.execute(
|
||||
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
return None
|
||||
task.assigned_to = assigned_to
|
||||
await db.flush()
|
||||
return _task_to_dict(task)
|
||||
|
||||
|
||||
async def update_task_status(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
task_id: uuid.UUID,
|
||||
new_status: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update task status."""
|
||||
result = await db.execute(
|
||||
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
return None
|
||||
task.status = new_status
|
||||
await db.flush()
|
||||
return _task_to_dict(task)
|
||||
|
||||
|
||||
async def get_due_tasks(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
*,
|
||||
before: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get tasks that are due (for ARQ reminder job)."""
|
||||
now = before or datetime.now(timezone.utc)
|
||||
result = await db.execute(
|
||||
select(Task).where(
|
||||
Task.tenant_id == tenant_id,
|
||||
Task.deleted_at.is_(None),
|
||||
Task.status != "done",
|
||||
Task.due_date.is_not(None),
|
||||
Task.due_date <= now,
|
||||
)
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
return [_task_to_dict(t) for t in tasks]
|
||||
@@ -138,6 +138,21 @@ class FrontendDashboardWidget(BaseModel):
|
||||
permission: str = Field(default="", description="Optional permission required")
|
||||
|
||||
|
||||
class CustomFieldDefinition(BaseModel):
|
||||
"""A custom field definition contributed by a plugin manifest."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=80, description="Unique field name (snake_case)")
|
||||
label: str = Field(default="", description="Human-readable label (fallback if i18n key missing)")
|
||||
label_key: str = Field(default="", description="i18n key for the field label")
|
||||
field_type: str = Field(
|
||||
..., pattern="^(text|number|date|select|multiselect|boolean)$", description="Field type"
|
||||
)
|
||||
options: list[str] = Field(default_factory=list, description="Options for select/multiselect types")
|
||||
default_value: Any = Field(default=None, description="Default value for the field")
|
||||
required: bool = Field(default=False, description="Whether the field is required")
|
||||
entity: str = Field(default="contact", description="Entity type this field applies to (contact/file/etc)")
|
||||
|
||||
|
||||
class MiniAppContribution(BaseModel):
|
||||
"""A MiniApp contributed by a plugin manifest."""
|
||||
|
||||
@@ -214,6 +229,9 @@ class PluginManifest(BaseModel):
|
||||
miniapps: list[MiniAppContribution] = Field(
|
||||
default_factory=list, description="MiniApps contributed by this plugin"
|
||||
)
|
||||
custom_fields: list[CustomFieldDefinition] = Field(
|
||||
default_factory=list, description="Custom field definitions contributed by this plugin"
|
||||
)
|
||||
|
||||
|
||||
@field_validator("name")
|
||||
|
||||
@@ -760,6 +760,7 @@ class PluginRegistry:
|
||||
"detail_tabs": [tab.model_dump() for tab in m.detail_tabs],
|
||||
"settings_pages": [page.model_dump() for page in m.settings_pages],
|
||||
"dashboard_widgets": [widget.model_dump() for widget in m.dashboard_widgets],
|
||||
"custom_fields": [cf.model_dump() for cf in m.custom_fields],
|
||||
}
|
||||
)
|
||||
return manifests
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Custom fields routes — merge plugin definitions with stored values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
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.plugins.registry import get_registry
|
||||
|
||||
router = APIRouter(prefix="/api/v1/contacts", tags=["custom-fields"])
|
||||
|
||||
|
||||
class CustomFieldUpdateRequest(BaseModel):
|
||||
"""Request body for updating custom field values."""
|
||||
|
||||
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."""
|
||||
definitions: list[dict[str, Any]] = []
|
||||
seen_names: set[str] = set()
|
||||
registry = get_registry()
|
||||
for plugin in registry._plugins.values():
|
||||
manifest = plugin.manifest
|
||||
for cf in manifest.custom_fields:
|
||||
if cf.entity != entity:
|
||||
continue
|
||||
if cf.name in seen_names:
|
||||
continue
|
||||
seen_names.add(cf.name)
|
||||
definitions.append(
|
||||
{
|
||||
"name": cf.name,
|
||||
"label": cf.label,
|
||||
"label_key": cf.label_key,
|
||||
"field_type": cf.field_type,
|
||||
"options": cf.options,
|
||||
"default_value": cf.default_value,
|
||||
"required": cf.required,
|
||||
"entity": cf.entity,
|
||||
"plugin": manifest.name,
|
||||
}
|
||||
)
|
||||
return definitions
|
||||
|
||||
|
||||
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."""
|
||||
stored = stored or {}
|
||||
result: list[dict[str, Any]] = []
|
||||
for d in definitions:
|
||||
name = d["name"]
|
||||
value = stored.get(name, d.get("default_value"))
|
||||
entry = {**d, "value": value}
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:read"))])
|
||||
async def get_custom_fields(
|
||||
contact_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get all custom fields for a contact (merged definitions + stored values)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
cid = uuid.UUID(contact_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None
|
||||
|
||||
result = await db.execute(
|
||||
select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id)
|
||||
)
|
||||
contact = result.scalar_one_or_none()
|
||||
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)
|
||||
return {"fields": merged}
|
||||
|
||||
|
||||
@router.patch("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:write"))])
|
||||
async def update_custom_fields(
|
||||
contact_id: str,
|
||||
body: CustomFieldUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update custom field values for a contact (stored in contacts.custom JSONB)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
cid = uuid.UUID(contact_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None
|
||||
|
||||
result = await db.execute(
|
||||
select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id)
|
||||
)
|
||||
contact = result.scalar_one_or_none()
|
||||
if contact is None:
|
||||
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
||||
|
||||
# Validate against definitions
|
||||
definitions = _collect_custom_field_definitions("contact")
|
||||
def_map = {d["name"]: d for d in definitions}
|
||||
|
||||
current_custom = dict(contact.custom or {})
|
||||
for name, value in body.values.items():
|
||||
if name not in def_map:
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail={"detail": f"Unknown custom field: {name}", "code": "unknown_field"},
|
||||
)
|
||||
field_def = def_map[name]
|
||||
# Validate required
|
||||
if field_def["required"] and (value is None or value == ""):
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail={"detail": f"Field '{name}' is required", "code": "required_field"},
|
||||
)
|
||||
# Validate select/multiselect options
|
||||
if field_def["field_type"] == "select" and value is not None:
|
||||
if value not in field_def["options"]:
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail={"detail": f"Invalid option for field '{name}'", "code": "invalid_option"},
|
||||
)
|
||||
if field_def["field_type"] == "multiselect" and value is not None:
|
||||
if not isinstance(value, list):
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail={"detail": f"Field '{name}' must be a list", "code": "invalid_type"},
|
||||
)
|
||||
for v in value:
|
||||
if v not in field_def["options"]:
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail={"detail": f"Invalid option '{v}' for field '{name}'", "code": "invalid_option"},
|
||||
)
|
||||
current_custom[name] = value
|
||||
|
||||
contact.custom = current_custom
|
||||
await db.flush()
|
||||
merged = _merge_definitions_with_values(definitions, contact.custom)
|
||||
return {"fields": merged}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Saved filters routes — CRUD for reusable filter criteria."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
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.saved_filter import SavedFilter
|
||||
|
||||
router = APIRouter(prefix="/api/v1/saved-filters", tags=["saved-filters"])
|
||||
|
||||
VALID_ENTITY_TYPES = {"contacts", "mail", "calendar", "dms"}
|
||||
|
||||
|
||||
class SavedFilterCreate(BaseModel):
|
||||
"""Schema for creating a saved filter."""
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
entity_type: str = Field(..., pattern="^(contacts|mail|calendar|dms)$")
|
||||
filter_criteria: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SavedFilterUpdate(BaseModel):
|
||||
"""Schema for updating a saved filter."""
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
filter_criteria: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _filter_to_dict(f: SavedFilter) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(f.id),
|
||||
"name": f.name,
|
||||
"entity_type": f.entity_type,
|
||||
"filter_criteria": f.filter_criteria,
|
||||
"user_id": str(f.user_id),
|
||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", dependencies=[Depends(require_permission("contacts:read"))])
|
||||
async def list_saved_filters(
|
||||
entity_type: str | None = Query(None, pattern="^(contacts|mail|calendar|dms)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List saved filters for the current user, optionally filtered by entity_type."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
query = select(SavedFilter).where(
|
||||
SavedFilter.tenant_id == tenant_id,
|
||||
SavedFilter.user_id == user_id,
|
||||
SavedFilter.deleted_at.is_(None),
|
||||
)
|
||||
if entity_type:
|
||||
query = query.where(SavedFilter.entity_type == entity_type)
|
||||
query = query.order_by(SavedFilter.name)
|
||||
|
||||
result = await db.execute(query)
|
||||
filters = result.scalars().all()
|
||||
return [_filter_to_dict(f) for f in filters]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("contacts:read"))])
|
||||
async def create_saved_filter(
|
||||
body: SavedFilterCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new saved filter for the current user."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
# Check uniqueness within user+entity
|
||||
existing = await db.execute(
|
||||
select(SavedFilter).where(
|
||||
SavedFilter.tenant_id == tenant_id,
|
||||
SavedFilter.user_id == user_id,
|
||||
SavedFilter.entity_type == body.entity_type,
|
||||
SavedFilter.name == body.name,
|
||||
SavedFilter.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(409, detail={"detail": "Filter name already exists", "code": "duplicate"})
|
||||
|
||||
saved = SavedFilter(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
name=body.name,
|
||||
entity_type=body.entity_type,
|
||||
filter_criteria=body.filter_criteria,
|
||||
)
|
||||
db.add(saved)
|
||||
await db.flush()
|
||||
return _filter_to_dict(saved)
|
||||
|
||||
|
||||
@router.delete("/{filter_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("contacts:read"))])
|
||||
async def delete_saved_filter(
|
||||
filter_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a saved filter (soft-delete)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
try:
|
||||
fid = uuid.UUID(filter_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(400, detail={"detail": "Invalid filter_id", "code": "invalid_id"}) from None
|
||||
|
||||
result = await db.execute(
|
||||
select(SavedFilter).where(
|
||||
SavedFilter.id == fid,
|
||||
SavedFilter.tenant_id == tenant_id,
|
||||
SavedFilter.user_id == user_id,
|
||||
SavedFilter.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
saved = result.scalar_one_or_none()
|
||||
if saved is None:
|
||||
raise HTTPException(404, detail={"detail": "Saved filter not found", "code": "not_found"})
|
||||
|
||||
from datetime import datetime, timezone
|
||||
saved.deleted_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* CustomFieldRenderer tests — renders custom fields based on field_type.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer';
|
||||
import type { CustomFieldDefinition } from '@/api/customFields';
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Mock UI components
|
||||
vi.mock('@/components/ui/Input', () => ({
|
||||
Input: ({ value, onChange, type, id, ...props }: any) => (
|
||||
<input
|
||||
data-testid={props['data-testid'] || `input-${id}`}
|
||||
type={type || 'text'}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Select', () => ({
|
||||
Select: ({ value, onChange, id, options, children, ...props }: any) => (
|
||||
<select
|
||||
data-testid={props['data-testid'] || `select-${id}`}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
>
|
||||
{options ? options.map((o: any) => <option key={o.value} value={o.value}>{o.label}</option>) : children}
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockFields: CustomFieldDefinition[] = [
|
||||
{
|
||||
name: 'lead_source',
|
||||
label: 'Lead Source',
|
||||
label_key: 'custom.leadSource',
|
||||
field_type: 'select',
|
||||
options: ['website', 'referral', 'cold_call'],
|
||||
default_value: null,
|
||||
required: false,
|
||||
entity: 'contact',
|
||||
plugin: 'test_plugin',
|
||||
value: 'website',
|
||||
},
|
||||
{
|
||||
name: 'score',
|
||||
label: 'Score',
|
||||
label_key: 'custom.score',
|
||||
field_type: 'number',
|
||||
options: [],
|
||||
default_value: 0,
|
||||
required: false,
|
||||
entity: 'contact',
|
||||
plugin: 'test_plugin',
|
||||
value: 42,
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
label: 'Active',
|
||||
label_key: 'custom.active',
|
||||
field_type: 'boolean',
|
||||
options: [],
|
||||
default_value: false,
|
||||
required: false,
|
||||
entity: 'contact',
|
||||
plugin: 'test_plugin',
|
||||
value: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('CustomFieldRenderer', () => {
|
||||
it('renders read mode with field values', () => {
|
||||
render(<CustomFieldRenderer fields={mockFields} mode="read" />);
|
||||
expect(screen.getByTestId('custom-fields-read')).toBeInTheDocument();
|
||||
expect(screen.getByText('website')).toBeInTheDocument();
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
expect(screen.getByText('✓')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders edit mode with form controls', () => {
|
||||
render(
|
||||
<CustomFieldRenderer
|
||||
fields={mockFields}
|
||||
mode="edit"
|
||||
values={{ lead_source: 'website', score: 42, active: true }}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('custom-fields-edit')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('select-cf-lead_source')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('input-cf-score')).toBeInTheDocument();
|
||||
expect(document.getElementById('cf-active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when select value changes', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<CustomFieldRenderer
|
||||
fields={[mockFields[0]]}
|
||||
mode="edit"
|
||||
values={{ lead_source: 'website' }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
const select = screen.getByTestId('select-cf-lead_source');
|
||||
fireEvent.change(select, { target: { value: 'referral' } });
|
||||
expect(onChange).toHaveBeenCalledWith('lead_source', 'referral');
|
||||
});
|
||||
|
||||
it('renders nothing when fields array is empty', () => {
|
||||
const { container } = render(<CustomFieldRenderer fields={[]} mode="read" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders multiselect field with checkboxes', () => {
|
||||
const multiField: CustomFieldDefinition = {
|
||||
name: 'tags',
|
||||
label: 'Tags',
|
||||
label_key: 'custom.tags',
|
||||
field_type: 'multiselect',
|
||||
options: ['vip', 'customer', 'lead'],
|
||||
default_value: [],
|
||||
required: false,
|
||||
entity: 'contact',
|
||||
plugin: 'test_plugin',
|
||||
value: ['vip'],
|
||||
};
|
||||
render(
|
||||
<CustomFieldRenderer
|
||||
fields={[multiField]}
|
||||
mode="edit"
|
||||
values={{ tags: ['vip'] }}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes).toHaveLength(3);
|
||||
expect(checkboxes[0]).toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* SavedFilters component tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { SavedFilters } from '@/components/SavedFilters';
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Mock toast
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock UI components
|
||||
vi.mock('@/components/ui/Input', () => ({
|
||||
Input: ({ value, onChange, label, ...props }: any) => (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
<input
|
||||
data-testid={props['data-testid'] || 'input'}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Modal', () => ({
|
||||
Modal: ({ open, onClose, title, children }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal" role="dialog">
|
||||
<h2>{title}</h2>
|
||||
<button onClick={onClose}>Close</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Button', () => ({
|
||||
Button: ({ children, onClick, disabled, ...props }: any) => (
|
||||
<button onClick={onClick} disabled={disabled} data-testid={props['data-testid'] || 'button'}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock saved filters API
|
||||
vi.mock('@/api/savedFilters', () => ({
|
||||
useSavedFilters: vi.fn(() => ({
|
||||
data: [
|
||||
{
|
||||
id: 'filter-1',
|
||||
name: 'Important Clients',
|
||||
entity_type: 'contacts',
|
||||
filter_criteria: { type: 'company' },
|
||||
user_id: 'user-1',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})),
|
||||
useCreateSavedFilter: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
useDeleteSavedFilter: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
}));
|
||||
|
||||
describe('SavedFilters', () => {
|
||||
it('renders saved filter buttons', () => {
|
||||
render(
|
||||
<SavedFilters
|
||||
entityType="contacts"
|
||||
currentCriteria={{}}
|
||||
onLoadFilter={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('saved-filters')).toBeInTheDocument();
|
||||
expect(screen.getByText('Important Clients')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens save modal when save button is clicked', () => {
|
||||
render(
|
||||
<SavedFilters
|
||||
entityType="contacts"
|
||||
currentCriteria={{}}
|
||||
onLoadFilter={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('save-filter-btn'));
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('filter-name-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onLoadFilter when a saved filter is clicked', () => {
|
||||
const onLoadFilter = vi.fn();
|
||||
render(
|
||||
<SavedFilters
|
||||
entityType="contacts"
|
||||
currentCriteria={{}}
|
||||
onLoadFilter={onLoadFilter}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Important Clients'));
|
||||
expect(onLoadFilter).toHaveBeenCalledWith({ type: 'company' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Tasks page tests — basic rendering and interaction.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { TasksPage } from '@/pages/Tasks';
|
||||
import * as tasksApi from '@/api/tasks';
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Mock toast
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock UI components
|
||||
vi.mock('@/components/ui/Input', () => ({
|
||||
Input: ({ value, onChange, type, label, ...props }: any) => (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
<input
|
||||
data-testid={props['data-testid'] || 'input'}
|
||||
type={type || 'text'}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Select', () => ({
|
||||
Select: ({ value, onChange, options, label, ...props }: any) => (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
<select data-testid={props['data-testid'] || 'select'} value={value || ''} onChange={onChange}>
|
||||
{options?.map((o: any) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Modal', () => ({
|
||||
Modal: ({ open, onClose, title, children }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal" role="dialog">
|
||||
<h2>{title}</h2>
|
||||
<button onClick={onClose}>Close</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Button', () => ({
|
||||
Button: ({ children, onClick, disabled, ...props }: any) => (
|
||||
<button onClick={onClick} disabled={disabled} data-testid={props['data-testid'] || 'button'}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Badge', () => ({
|
||||
Badge: ({ children, variant }: any) => <span data-testid="badge" data-variant={variant}>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/EmptyState', () => ({
|
||||
EmptyState: ({ title, description }: any) => (
|
||||
<div data-testid="empty-state">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock tasks API
|
||||
vi.mock('@/api/tasks', () => ({
|
||||
useTasks: vi.fn(() => ({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: 'Test Task',
|
||||
description: 'Test description',
|
||||
status: 'open',
|
||||
priority: 'high',
|
||||
due_date: '2025-12-31T10:00:00Z',
|
||||
assigned_to: null,
|
||||
contact_id: null,
|
||||
created_by: null,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
},
|
||||
isLoading: false,
|
||||
})),
|
||||
useCreateTask: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
useUpdateTask: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
useDeleteTask: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
useUpdateTaskStatus: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
}));
|
||||
|
||||
describe('TasksPage', () => {
|
||||
it('renders the tasks page with header', () => {
|
||||
render(<TasksPage />);
|
||||
expect(screen.getByTestId('tasks-page')).toBeInTheDocument();
|
||||
expect(screen.getByText('tasks.title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders task items from API', () => {
|
||||
render(<TasksPage />);
|
||||
expect(screen.getByText('Test Task')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens create modal when create button is clicked', () => {
|
||||
render(<TasksPage />);
|
||||
const createBtn = screen.getByText('tasks.create');
|
||||
fireEvent.click(createBtn);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('task-title-input')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Custom fields API hooks — merge plugin definitions with stored values.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPatch } from './client';
|
||||
|
||||
export interface CustomFieldDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
label_key: string;
|
||||
field_type: 'text' | 'number' | 'date' | 'select' | 'multiselect' | 'boolean';
|
||||
options: string[];
|
||||
default_value: any;
|
||||
required: boolean;
|
||||
entity: string;
|
||||
plugin: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface CustomFieldsResponse {
|
||||
fields: CustomFieldDefinition[];
|
||||
}
|
||||
|
||||
export function useCustomFields(contactId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['custom-fields', contactId],
|
||||
queryFn: () => apiGet<CustomFieldsResponse>(`/contacts/${contactId}/custom-fields`),
|
||||
enabled: !!contactId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCustomFields() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ contactId, values }: { contactId: string; values: Record<string, any> }) =>
|
||||
apiPatch<CustomFieldsResponse>(`/contacts/${contactId}/custom-fields`, { values }),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['custom-fields', variables.contactId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Saved filters API hooks — CRUD for reusable filter criteria.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPost, apiDelete } from './client';
|
||||
|
||||
export interface SavedFilter {
|
||||
id: string;
|
||||
name: string;
|
||||
entity_type: string;
|
||||
filter_criteria: Record<string, any>;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SavedFilterCreateInput {
|
||||
name: string;
|
||||
entity_type: string;
|
||||
filter_criteria: Record<string, any>;
|
||||
}
|
||||
|
||||
export function useSavedFilters(entityType?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (entityType) params.set('entity_type', entityType);
|
||||
return useQuery({
|
||||
queryKey: ['saved-filters', entityType],
|
||||
queryFn: () => apiGet<SavedFilter[]>(`/saved-filters?${params.toString()}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSavedFilter() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: SavedFilterCreateInput) => apiPost<SavedFilter>('/saved-filters', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['saved-filters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSavedFilter() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/saved-filters/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['saved-filters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Tasks API hooks — CRUD, assign, status update.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: 'open' | 'in_progress' | 'done';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
due_date: string | null;
|
||||
assigned_to: string | null;
|
||||
contact_id: string | null;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TaskListResponse {
|
||||
items: Task[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface TaskCreateInput {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
due_date?: string | null;
|
||||
assigned_to?: string | null;
|
||||
contact_id?: string | null;
|
||||
}
|
||||
|
||||
export interface TaskUpdateInput {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
due_date?: string | null;
|
||||
assigned_to?: string | null;
|
||||
contact_id?: string | null;
|
||||
}
|
||||
|
||||
export interface TaskFilter {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
assigned_to?: string;
|
||||
contact_id?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export function useTasks(page = 1, pageSize = 25, filter?: TaskFilter) {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (filter?.status) params.set('status', filter.status);
|
||||
if (filter?.priority) params.set('priority', filter.priority);
|
||||
if (filter?.assigned_to) params.set('assigned_to', filter.assigned_to);
|
||||
if (filter?.contact_id) params.set('contact_id', filter.contact_id);
|
||||
if (filter?.search) params.set('search', filter.search);
|
||||
return useQuery({
|
||||
queryKey: ['tasks', page, pageSize, filter],
|
||||
queryFn: () => apiGet<TaskListResponse>(`/tasks?${params.toString()}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTask(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', id],
|
||||
queryFn: () => apiGet<Task>(`/tasks/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTask() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: TaskCreateInput) => apiPost<Task>('/tasks', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTask() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: TaskUpdateInput }) =>
|
||||
apiPatch<Task>(`/tasks/${id}`, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', variables.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTask() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/tasks/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignTask() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, assignedTo }: { id: string; assignedTo: string }) =>
|
||||
apiPost<Task>(`/tasks/${id}/assign`, { assigned_to: assignedTo }),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', variables.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTaskStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
apiPost<Task>(`/tasks/${id}/status`, { status }),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', variables.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* SavedFilters — filter-builder UI, save button, load saved filters.
|
||||
* Integrates into list views (Contacts, Mail, Calendar, DMS).
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Bookmark, Trash2, Save } from 'lucide-react';
|
||||
import {
|
||||
useSavedFilters,
|
||||
useCreateSavedFilter,
|
||||
useDeleteSavedFilter,
|
||||
type SavedFilter,
|
||||
} from '@/api/savedFilters';
|
||||
|
||||
export interface SavedFiltersProps {
|
||||
entityType: string;
|
||||
currentCriteria: Record<string, any>;
|
||||
onLoadFilter: (criteria: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
export function SavedFilters({ entityType, currentCriteria, onLoadFilter }: SavedFiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [filterName, setFilterName] = useState('');
|
||||
|
||||
const { data: savedFilters = [] } = useSavedFilters(entityType);
|
||||
const createMutation = useCreateSavedFilter();
|
||||
const deleteMutation = useDeleteSavedFilter();
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!filterName.trim()) return;
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
name: filterName.trim(),
|
||||
entity_type: entityType,
|
||||
filter_criteria: currentCriteria,
|
||||
});
|
||||
toast.success(t('savedFilters.saved'));
|
||||
setFilterName('');
|
||||
setSaveModalOpen(false);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
toast.success(t('savedFilters.deleted'));
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoad = (filter: SavedFilter) => {
|
||||
onLoadFilter(filter.filter_criteria);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap" data-testid="saved-filters">
|
||||
{/* Saved filter tabs/buttons */}
|
||||
{savedFilters.map((filter) => (
|
||||
<div
|
||||
key={filter.id}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md bg-secondary-100 hover:bg-secondary-200 cursor-pointer group"
|
||||
onClick={() => handleLoad(filter)}
|
||||
data-testid={`saved-filter-${filter.id}`}
|
||||
>
|
||||
<Bookmark className="w-3 h-3 text-secondary-500" />
|
||||
<span className="text-xs text-secondary-700">{filter.name}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(filter.id); }}
|
||||
className="opacity-0 group-hover:opacity-100 text-danger-500 hover:text-danger-700"
|
||||
aria-label={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Save current filter button */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setSaveModalOpen(true)}
|
||||
data-testid="save-filter-btn"
|
||||
>
|
||||
<Save className="w-3 h-3 mr-1" />
|
||||
{t('savedFilters.save')}
|
||||
</Button>
|
||||
|
||||
{/* Save modal */}
|
||||
<Modal open={saveModalOpen} onClose={() => setSaveModalOpen(false)} title={t('savedFilters.saveTitle')} size="sm">
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label={t('savedFilters.name')}
|
||||
value={filterName}
|
||||
onChange={(e) => setFilterName(e.target.value)}
|
||||
placeholder={t('savedFilters.namePlaceholder')}
|
||||
data-testid="filter-name-input"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setSaveModalOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} disabled={!filterName.trim() || createMutation.isPending} data-testid="save-filter-confirm">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import { usePluginStore } from '@/store/pluginStore';
|
||||
import { useAIUIControlStore } from '@/store/aiUIControlStore';
|
||||
import { PluginPage } from '@/components/plugins/PluginLoader';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer';
|
||||
import { useCustomFields } from '@/api/customFields';
|
||||
import {
|
||||
type UnifiedContact,
|
||||
type ContactPerson,
|
||||
@@ -177,6 +179,10 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
}, [aiActiveModal]);
|
||||
const user = useAuthStore(s => s.user);
|
||||
|
||||
// Custom fields from plugin definitions
|
||||
const customFieldDefs = usePluginStore(s => s.getCustomFieldsForEntity('contact'));
|
||||
const { data: customFieldsData } = useCustomFields(contact?.id);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12" data-testid="contact-detail-loading">
|
||||
@@ -420,11 +426,12 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
</Section>
|
||||
|
||||
{/* Custom Fields */}
|
||||
{contact.custom && Object.keys(contact.custom).length > 0 && (
|
||||
{contact.id && customFieldDefs.length > 0 && (
|
||||
<Section title={t('contacts.customFields')}>
|
||||
<pre className="text-xs text-secondary-700 bg-secondary-50 rounded p-2 overflow-x-auto">
|
||||
{JSON.stringify(contact.custom, null, 2)}
|
||||
</pre>
|
||||
<CustomFieldRenderer
|
||||
fields={customFieldsData?.fields || []}
|
||||
mode="read"
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
useCreateUnifiedContact,
|
||||
useUpdateUnifiedContact,
|
||||
} from '@/api/hooks';
|
||||
import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer';
|
||||
import { useCustomFields, useUpdateCustomFields } from '@/api/customFields';
|
||||
import { usePluginStore } from '@/store/pluginStore';
|
||||
|
||||
export interface ContactEditModalProps {
|
||||
open: boolean;
|
||||
@@ -105,6 +108,26 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
||||
|
||||
const currentType = watch('type');
|
||||
|
||||
// Custom fields
|
||||
const customFieldDefs = usePluginStore(s => s.getCustomFieldsForEntity('contact'));
|
||||
const { data: customFieldsData } = useCustomFields(contact?.id);
|
||||
const updateCustomFields = useUpdateCustomFields();
|
||||
const [customValues, setCustomValues] = React.useState<Record<string, any>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && customFieldsData?.fields) {
|
||||
const vals: Record<string, any> = {};
|
||||
for (const f of customFieldsData.fields) {
|
||||
vals[f.name] = f.value ?? f.default_value ?? null;
|
||||
}
|
||||
setCustomValues(vals);
|
||||
}
|
||||
}, [open, customFieldsData]);
|
||||
|
||||
const handleCustomFieldChange = (name: string, value: any) => {
|
||||
setCustomValues(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -164,15 +187,27 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
||||
};
|
||||
|
||||
try {
|
||||
let savedId: string | undefined;
|
||||
if (isEdit && contact) {
|
||||
await updateMutation.mutateAsync({ id: contact.id, data });
|
||||
savedId = contact.id;
|
||||
toast.success(t('contacts.updated'));
|
||||
onSaved?.(contact.id);
|
||||
} else {
|
||||
const result = await createMutation.mutateAsync(data) as { id: string };
|
||||
savedId = result.id;
|
||||
toast.success(t('contacts.created'));
|
||||
onSaved?.(result.id);
|
||||
}
|
||||
// Save custom fields if any definitions exist and we have a contact ID
|
||||
if (savedId && customFieldDefs.length > 0 && Object.keys(customValues).length > 0) {
|
||||
try {
|
||||
await updateCustomFields.mutateAsync({ contactId: savedId, values: customValues });
|
||||
} catch (cfErr: any) {
|
||||
// Don't fail the whole save if custom fields fail
|
||||
console.error('Custom fields save failed:', cfErr);
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed')));
|
||||
@@ -281,6 +316,19 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Fields */}
|
||||
{customFieldDefs.length > 0 && (
|
||||
<div className="border border-secondary-200 rounded-lg p-3">
|
||||
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.customFields')}</h3>
|
||||
<CustomFieldRenderer
|
||||
fields={customFieldsData?.fields || customFieldDefs.map(d => ({ ...d, value: d.default_value, plugin: '' }))}
|
||||
mode="edit"
|
||||
values={customValues}
|
||||
onChange={handleCustomFieldChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* CustomFieldRenderer — renders custom fields based on field_type.
|
||||
* Supports read-only and editable modes.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import type { CustomFieldDefinition } from '@/api/customFields';
|
||||
|
||||
export interface CustomFieldRendererProps {
|
||||
fields: CustomFieldDefinition[];
|
||||
mode: 'read' | 'edit';
|
||||
values?: Record<string, any>;
|
||||
onChange?: (name: string, value: any) => void;
|
||||
}
|
||||
|
||||
function formatValue(value: any, fieldType: string): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (fieldType === 'boolean') return value ? '✓' : '✗';
|
||||
if (fieldType === 'multiselect' && Array.isArray(value)) return value.join(', ');
|
||||
if (fieldType === 'date' && value) {
|
||||
try {
|
||||
return new Date(value).toLocaleDateString();
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function CustomFieldRenderer({ fields, mode, values = {}, onChange }: CustomFieldRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!fields || fields.length === 0) return null;
|
||||
|
||||
if (mode === 'read') {
|
||||
return (
|
||||
<dl className="grid grid-cols-2 gap-3" data-testid="custom-fields-read">
|
||||
{fields.map((field) => {
|
||||
const label = field.label_key ? t(field.label_key) : field.label || field.name;
|
||||
const value = values[field.name] ?? field.value;
|
||||
return (
|
||||
<div key={field.name}>
|
||||
<dt className="text-xs font-medium text-secondary-500">{label}</dt>
|
||||
<dd className="text-sm text-secondary-900">{formatValue(value, field.field_type)}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3" data-testid="custom-fields-edit">
|
||||
{fields.map((field) => {
|
||||
const label = field.label_key ? t(field.label_key) : field.label || field.name;
|
||||
const value = values[field.name] ?? field.value ?? field.default_value ?? '';
|
||||
|
||||
if (field.field_type === 'boolean') {
|
||||
return (
|
||||
<div key={field.name} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`cf-${field.name}`}
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange?.(field.name, e.target.checked)}
|
||||
className="h-4 w-4 rounded border-secondary-300"
|
||||
/>
|
||||
<label htmlFor={`cf-${field.name}`} className="text-sm text-secondary-700">
|
||||
{label}{field.required && ' *'}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.field_type === 'select') {
|
||||
return (
|
||||
<div key={field.name}>
|
||||
<label htmlFor={`cf-${field.name}`} className="text-xs font-medium text-secondary-500">
|
||||
{label}{field.required && ' *'}
|
||||
</label>
|
||||
<Select
|
||||
id={`cf-${field.name}`}
|
||||
value={value || ''}
|
||||
options={field.options.map((opt) => ({ value: opt, label: opt }))}
|
||||
onChange={(e) => onChange?.(field.name, e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.field_type === 'multiselect') {
|
||||
const selected: string[] = Array.isArray(value) ? value : [];
|
||||
return (
|
||||
<div key={field.name}>
|
||||
<label htmlFor={`cf-${field.name}`} className="text-xs font-medium text-secondary-500">
|
||||
{label}{field.required && ' *'}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{field.options.map((opt) => (
|
||||
<label key={opt} className="flex items-center gap-1 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(opt)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
onChange?.(field.name, [...selected, opt]);
|
||||
} else {
|
||||
onChange?.(field.name, selected.filter((v) => v !== opt));
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border-secondary-300"
|
||||
/>
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// text, number, date
|
||||
return (
|
||||
<div key={field.name}>
|
||||
<label htmlFor={`cf-${field.name}`} className="text-xs font-medium text-secondary-500">
|
||||
{label}{field.required && ' *'}
|
||||
</label>
|
||||
<Input
|
||||
id={`cf-${field.name}`}
|
||||
type={field.field_type === 'number' ? 'number' : field.field_type === 'date' ? 'date' : 'text'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => {
|
||||
let val: any = e.target.value;
|
||||
if (field.field_type === 'number' && val !== '') val = Number(val);
|
||||
if (val === '') val = null;
|
||||
onChange?.(field.name, val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const mockManifests: PluginUiManifest[] = [
|
||||
detail_tabs: [],
|
||||
settings_pages: [],
|
||||
dashboard_widgets: [],
|
||||
custom_fields: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const mockManifests: PluginUiManifest[] = [
|
||||
detail_tabs: [],
|
||||
settings_pages: [],
|
||||
dashboard_widgets: [],
|
||||
custom_fields: [],
|
||||
},
|
||||
{
|
||||
name: 'calendar_plugin',
|
||||
@@ -42,6 +43,7 @@ const mockManifests: PluginUiManifest[] = [
|
||||
detail_tabs: [],
|
||||
settings_pages: [],
|
||||
dashboard_widgets: [],
|
||||
custom_fields: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"settings": "Einstellungen",
|
||||
"aiAssistant": "KI Assistent",
|
||||
"mcpSettings": "MCP Einstellungen",
|
||||
"reports": "Reports"
|
||||
"reports": "Reports",
|
||||
"tasks": "Aufgaben"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -996,5 +997,40 @@
|
||||
"selectTemplateHint": "Wählen Sie eine Vorlage aus der Liste",
|
||||
"downloadHistory": "Download-Verlauf",
|
||||
"noDownloads": "Noch keine Downloads"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Aufgaben",
|
||||
"create": "Neue Aufgabe",
|
||||
"edit": "Aufgabe bearbeiten",
|
||||
"deleteConfirm": "Diese Aufgabe löschen?",
|
||||
"created": "Aufgabe erstellt",
|
||||
"updated": "Aufgabe aktualisiert",
|
||||
"deleted": "Aufgabe gelöscht",
|
||||
"statusUpdated": "Status aktualisiert",
|
||||
"noTasks": "Keine Aufgaben",
|
||||
"noTasksDesc": "Es wurden noch keine Aufgaben erstellt.",
|
||||
"markDone": "Erledigt",
|
||||
"dueDate": "Fälligkeitsdatum",
|
||||
"title_field": "Titel",
|
||||
"description": "Beschreibung",
|
||||
"status_field": "Status",
|
||||
"priority_field": "Priorität",
|
||||
"statusOpen": "Offen",
|
||||
"statusInProgress": "In Bearbeitung",
|
||||
"statusDone": "Erledigt",
|
||||
"priorityLow": "Niedrig",
|
||||
"priorityMedium": "Mittel",
|
||||
"priorityHigh": "Hoch",
|
||||
"priorityUrgent": "Dringend"
|
||||
},
|
||||
"savedFilters": {
|
||||
"save": "Filter speichern",
|
||||
"saveTitle": "Filter speichern",
|
||||
"name": "Filtername",
|
||||
"namePlaceholder": "z.B. Wichtige Kunden",
|
||||
"saved": "Filter gespeichert",
|
||||
"deleted": "Filter gelöscht",
|
||||
"load": "Filter laden",
|
||||
"noFilters": "Keine gespeicherten Filter"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"settings": "Settings",
|
||||
"aiAssistant": "AI Assistant",
|
||||
"mcpSettings": "MCP Settings",
|
||||
"reports": "Reports"
|
||||
"reports": "Reports",
|
||||
"tasks": "Tasks"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign In",
|
||||
@@ -996,5 +997,40 @@
|
||||
"selectTemplateHint": "Select a template from the list",
|
||||
"downloadHistory": "Download History",
|
||||
"noDownloads": "No downloads yet"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Tasks",
|
||||
"create": "New Task",
|
||||
"edit": "Edit Task",
|
||||
"deleteConfirm": "Delete this task?",
|
||||
"created": "Task created",
|
||||
"updated": "Task updated",
|
||||
"deleted": "Task deleted",
|
||||
"statusUpdated": "Status updated",
|
||||
"noTasks": "No tasks",
|
||||
"noTasksDesc": "No tasks have been created yet.",
|
||||
"markDone": "Mark Done",
|
||||
"dueDate": "Due Date",
|
||||
"title_field": "Title",
|
||||
"description": "Description",
|
||||
"status_field": "Status",
|
||||
"priority_field": "Priority",
|
||||
"statusOpen": "Open",
|
||||
"statusInProgress": "In Progress",
|
||||
"statusDone": "Done",
|
||||
"priorityLow": "Low",
|
||||
"priorityMedium": "Medium",
|
||||
"priorityHigh": "High",
|
||||
"priorityUrgent": "Urgent"
|
||||
},
|
||||
"savedFilters": {
|
||||
"save": "Save Filter",
|
||||
"saveTitle": "Save Filter",
|
||||
"name": "Filter Name",
|
||||
"namePlaceholder": "e.g. Important Clients",
|
||||
"saved": "Filter saved",
|
||||
"deleted": "Filter deleted",
|
||||
"load": "Load Filter",
|
||||
"noFilters": "No saved filters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ContactFolderTree, type ContactFilter } from '@/components/contacts/Con
|
||||
import { ContactList, type ContactViewMode } from '@/components/contacts/ContactList';
|
||||
import { ContactDetail } from '@/components/contacts/ContactDetail';
|
||||
import { ContactEditModal } from '@/components/contacts/ContactEditModal';
|
||||
import { SavedFilters } from '@/components/SavedFilters';
|
||||
import { ArrowDownAZ, ArrowUpZA, ChevronLeft, LayoutGrid, List, Plus } from 'lucide-react';
|
||||
import {
|
||||
useUnifiedContacts,
|
||||
@@ -328,6 +329,21 @@ export function ContactsListPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Saved Filters */}
|
||||
<div className="px-3 py-1.5 border-b border-secondary-200 bg-secondary-50 flex-shrink-0">
|
||||
<SavedFilters
|
||||
entityType="contacts"
|
||||
currentCriteria={{ search: debouncedSearch, type: contactType, sortBy, sortOrder, folderId }}
|
||||
onLoadFilter={(criteria) => {
|
||||
if (criteria.search) setSearch(criteria.search); else setSearch('');
|
||||
if (criteria.sortBy) setSortBy(criteria.sortBy);
|
||||
if (criteria.sortOrder) setSortOrder(criteria.sortOrder);
|
||||
if (criteria.type) setSelectedFilter(criteria.type as ContactFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ContactList
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Tasks page — list with filter, create modal, detail.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Loader2, Plus, CheckSquare, Clock, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
useTasks,
|
||||
useCreateTask,
|
||||
useUpdateTask,
|
||||
useDeleteTask,
|
||||
useUpdateTaskStatus,
|
||||
type Task,
|
||||
type TaskFilter,
|
||||
} from '@/api/tasks';
|
||||
|
||||
const STATUS_COLORS: Record<string, 'secondary' | 'info' | 'success'> = {
|
||||
open: 'secondary',
|
||||
in_progress: 'info',
|
||||
done: 'success',
|
||||
};
|
||||
|
||||
const PRIORITY_COLORS: Record<string, 'secondary' | 'info' | 'warning' | 'danger'> = {
|
||||
low: 'secondary',
|
||||
medium: 'info',
|
||||
high: 'warning',
|
||||
urgent: 'danger',
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '—';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function isOverdue(dateStr: string | null, status: string): boolean {
|
||||
if (!dateStr || status === 'done') return false;
|
||||
try {
|
||||
return new Date(dateStr) < new Date();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function TasksPage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [filter, setFilter] = useState<TaskFilter>({});
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
||||
// Debounce search
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const effectiveFilter = useMemo(
|
||||
() => ({ ...filter, search: debouncedSearch || undefined }),
|
||||
[filter, debouncedSearch],
|
||||
);
|
||||
|
||||
const { data, isLoading } = useTasks(page, 25, effectiveFilter);
|
||||
const createMutation = useCreateTask();
|
||||
const updateMutation = useUpdateTask();
|
||||
const deleteMutation = useDeleteTask();
|
||||
const statusMutation = useUpdateTaskStatus();
|
||||
|
||||
const tasks = data?.items || [];
|
||||
|
||||
const handleCreate = async (formData: Partial<Task>) => {
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
title: formData.title || '',
|
||||
description: formData.description || null,
|
||||
priority: formData.priority || 'medium',
|
||||
status: formData.status || 'open',
|
||||
due_date: formData.due_date || null,
|
||||
contact_id: formData.contact_id || null,
|
||||
});
|
||||
toast.success(t('tasks.created'));
|
||||
setCreateModalOpen(false);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (formData: Partial<Task>) => {
|
||||
if (!editingTask) return;
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: editingTask.id, data: formData });
|
||||
toast.success(t('tasks.updated'));
|
||||
setEditingTask(null);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (task: Task) => {
|
||||
if (!window.confirm(t('tasks.deleteConfirm'))) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(task.id);
|
||||
toast.success(t('tasks.deleted'));
|
||||
setSelectedTask(null);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (task: Task, newStatus: string) => {
|
||||
try {
|
||||
await statusMutation.mutateAsync({ id: task.id, status: newStatus });
|
||||
toast.success(t('tasks.statusUpdated'));
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="tasks-page">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-200 bg-white">
|
||||
<h1 className="text-lg font-semibold text-secondary-900 flex items-center gap-2">
|
||||
<CheckSquare className="w-5 h-5" />
|
||||
{t('tasks.title')}
|
||||
</h1>
|
||||
<Button size="sm" onClick={() => { setEditingTask(null); setCreateModalOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t('tasks.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-200 bg-white flex-wrap">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('common.search')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-48"
|
||||
/>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'open', label: t('tasks.statusOpen') },
|
||||
{ value: 'in_progress', label: t('tasks.statusInProgress') },
|
||||
{ value: 'done', label: t('tasks.statusDone') },
|
||||
]}
|
||||
value={filter.status || ''}
|
||||
onChange={(e) => { setFilter(f => ({ ...f, status: e.target.value || undefined })); setPage(1); }}
|
||||
className="w-40"
|
||||
/>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'low', label: t('tasks.priorityLow') },
|
||||
{ value: 'medium', label: t('tasks.priorityMedium') },
|
||||
{ value: 'high', label: t('tasks.priorityHigh') },
|
||||
{ value: 'urgent', label: t('tasks.priorityUrgent') },
|
||||
]}
|
||||
value={filter.priority || ''}
|
||||
onChange={(e) => { setFilter(f => ({ ...f, priority: e.target.value || undefined })); setPage(1); }}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" />
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<EmptyState title={t('tasks.noTasks')} description={t('tasks.noTasksDesc')} />
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary-100" role="list">
|
||||
{tasks.map((task) => {
|
||||
const overdue = isOverdue(task.due_date, task.status);
|
||||
return (
|
||||
<li
|
||||
key={task.id}
|
||||
className="px-4 py-3 hover:bg-secondary-50 cursor-pointer"
|
||||
onClick={() => setSelectedTask(task)}
|
||||
data-testid={`task-item-${task.id}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-secondary-900">{task.title}</span>
|
||||
{overdue && (
|
||||
<AlertCircle className="w-4 h-4 text-danger-500" />
|
||||
)}
|
||||
</div>
|
||||
{task.description && (
|
||||
<p className="text-xs text-secondary-500 truncate mt-0.5">{task.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant={STATUS_COLORS[task.status] || 'secondary'}>
|
||||
{t(`tasks.status${task.status.charAt(0).toUpperCase() + task.status.slice(1).replace('_', '')}`)}
|
||||
</Badge>
|
||||
<Badge variant={PRIORITY_COLORS[task.priority] || 'secondary'}>
|
||||
{t(`tasks.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`)}
|
||||
</Badge>
|
||||
{task.due_date && (
|
||||
<span className={`text-xs flex items-center gap-1 ${overdue ? 'text-danger-600' : 'text-secondary-500'}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDate(task.due_date)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{task.status !== 'done' && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleStatusChange(task, 'done'); }}
|
||||
className="text-xs text-success-600 hover:text-success-700 px-2 py-1 min-h-touch"
|
||||
>
|
||||
{t('tasks.markDone')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setEditingTask(task); }}
|
||||
className="text-xs text-primary-600 hover:text-primary-700 px-2 py-1 min-h-touch"
|
||||
>
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(task); }}
|
||||
className="text-xs text-danger-600 hover:text-danger-700 px-2 py-1 min-h-touch"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.total > 25 && (
|
||||
<div className="flex items-center justify-between px-4 py-2 border-t border-secondary-200">
|
||||
<span className="text-xs text-secondary-500">
|
||||
{((page - 1) * 25) + 1}–{Math.min(page * 25, data.total)} / {data.total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" disabled={page * 25 >= data.total} onClick={() => setPage(p => p + 1)}>
|
||||
{t('common.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<TaskModal
|
||||
open={createModalOpen || !!editingTask}
|
||||
onClose={() => { setCreateModalOpen(false); setEditingTask(null); }}
|
||||
task={editingTask}
|
||||
onSubmit={editingTask ? handleUpdate : handleCreate}
|
||||
/>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedTask && (
|
||||
<Modal open={!!selectedTask} onClose={() => setSelectedTask(null)} title={selectedTask.title} size="lg">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={STATUS_COLORS[selectedTask.status] || 'secondary'}>
|
||||
{t(`tasks.status${selectedTask.status.charAt(0).toUpperCase() + selectedTask.status.slice(1).replace('_', '')}`)}
|
||||
</Badge>
|
||||
<Badge variant={PRIORITY_COLORS[selectedTask.priority] || 'secondary'}>
|
||||
{t(`tasks.priority${selectedTask.priority.charAt(0).toUpperCase() + selectedTask.priority.slice(1)}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
{selectedTask.description && (
|
||||
<p className="text-sm text-secondary-700">{selectedTask.description}</p>
|
||||
)}
|
||||
<div className="text-xs text-secondary-500">
|
||||
<span className="font-medium">{t('tasks.dueDate')}:</span> {formatDate(selectedTask.due_date)}
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
{selectedTask.status !== 'done' && (
|
||||
<Button size="sm" onClick={() => { handleStatusChange(selectedTask, 'done'); setSelectedTask(null); }}>
|
||||
{t('tasks.markDone')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="secondary" onClick={() => { setEditingTask(selectedTask); setSelectedTask(null); }}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => handleDelete(selectedTask)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Task Create/Edit Modal ──
|
||||
|
||||
function TaskModal({
|
||||
open,
|
||||
onClose,
|
||||
task,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
task?: Task | null;
|
||||
onSubmit: (data: Partial<Task>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [title, setTitle] = useState(task?.title || '');
|
||||
const [description, setDescription] = useState(task?.description || '');
|
||||
const [priority, setPriority] = useState<string>(task?.priority || 'medium');
|
||||
const [status, setStatus] = useState<string>(task?.status || 'open');
|
||||
const [dueDate, setDueDate] = useState(task?.due_date ? task.due_date.slice(0, 10) : '');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setTitle(task?.title || '');
|
||||
setDescription(task?.description || '');
|
||||
setPriority(task?.priority || 'medium');
|
||||
setStatus(task?.status || 'open');
|
||||
setDueDate(task?.due_date ? task.due_date.slice(0, 10) : '');
|
||||
}
|
||||
}, [open, task]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
onSubmit({
|
||||
title: title.trim(),
|
||||
description: description || null,
|
||||
priority: priority as any,
|
||||
status: status as any,
|
||||
due_date: dueDate ? new Date(dueDate).toISOString() : null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={task ? t('tasks.edit') : t('tasks.create')} size="md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label={t('tasks.title_field')}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
data-testid="task-title-input"
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('tasks.description')}</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select
|
||||
label={t('tasks.status_field')}
|
||||
options={[
|
||||
{ value: 'open', label: t('tasks.statusOpen') },
|
||||
{ value: 'in_progress', label: t('tasks.statusInProgress') },
|
||||
{ value: 'done', label: t('tasks.statusDone') },
|
||||
]}
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label={t('tasks.priority_field')}
|
||||
options={[
|
||||
{ value: 'low', label: t('tasks.priorityLow') },
|
||||
{ value: 'medium', label: t('tasks.priorityMedium') },
|
||||
{ value: 'high', label: t('tasks.priorityHigh') },
|
||||
{ value: 'urgent', label: t('tasks.priorityUrgent') },
|
||||
]}
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label={t('tasks.dueDate')}
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button type="submit" data-testid="task-submit-btn">{t('common.save')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashb
|
||||
const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage })));
|
||||
const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage })));
|
||||
const ReportsPage = React.lazy(() => import('@/pages/Reports').then(m => ({ default: m.ReportsPage })));
|
||||
const TasksPage = React.lazy(() => import('@/pages/Tasks').then(m => ({ default: m.TasksPage })));
|
||||
|
||||
/** Centered spinner fallback for lazy-loaded routes */
|
||||
function PageLoader() {
|
||||
@@ -91,6 +92,7 @@ const router = createBrowserRouter([
|
||||
{ path: '/automation', element: withSuspense(<AutomationDashboardPage />) },
|
||||
{ path: '/agents', element: withSuspense(<AgentDashboardPage />) },
|
||||
{ path: '/reports', element: withSuspense(<ReportsPage />) },
|
||||
{ path: '/tasks', element: withSuspense(<TasksPage />) },
|
||||
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
||||
{
|
||||
path: '/settings',
|
||||
|
||||
@@ -25,6 +25,7 @@ const mockManifests: PluginUiManifest[] = [
|
||||
dashboard_widgets: [
|
||||
{ id: 'widget_a', label_key: 'widgets.a', label: 'Widget A', component: '@/components/WidgetA', icon: 'A', order: 200, col_span: 2, row_span: 1, permission: '' },
|
||||
],
|
||||
custom_fields: [],
|
||||
},
|
||||
{
|
||||
name: 'plugin_b',
|
||||
@@ -46,6 +47,7 @@ const mockManifests: PluginUiManifest[] = [
|
||||
dashboard_widgets: [
|
||||
{ id: 'widget_b', label_key: 'widgets.b', label: 'Widget B', component: '@/components/WidgetB', icon: 'B', order: 100, col_span: 1, row_span: 1, permission: '' },
|
||||
],
|
||||
custom_fields: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -50,6 +50,17 @@ export interface PluginDashboardWidget {
|
||||
permission: string;
|
||||
}
|
||||
|
||||
export interface PluginCustomFieldDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
label_key: string;
|
||||
field_type: 'text' | 'number' | 'date' | 'select' | 'multiselect' | 'boolean';
|
||||
options: string[];
|
||||
default_value: any;
|
||||
required: boolean;
|
||||
entity: string;
|
||||
}
|
||||
|
||||
export interface PluginUiManifest {
|
||||
name: string;
|
||||
display_name: string;
|
||||
@@ -60,6 +71,7 @@ export interface PluginUiManifest {
|
||||
detail_tabs: PluginDetailTab[];
|
||||
settings_pages: PluginSettingsPage[];
|
||||
dashboard_widgets: PluginDashboardWidget[];
|
||||
custom_fields: PluginCustomFieldDefinition[];
|
||||
}
|
||||
|
||||
interface PluginState {
|
||||
@@ -77,6 +89,7 @@ interface PluginState {
|
||||
getDetailTabsForEntity: (entityType: string) => PluginDetailTab[];
|
||||
getAllSettingsPages: () => PluginSettingsPage[];
|
||||
getAllDashboardWidgets: () => PluginDashboardWidget[];
|
||||
getCustomFieldsForEntity: (entityType: string) => PluginCustomFieldDefinition[];
|
||||
}
|
||||
|
||||
export const usePluginStore = create<PluginState>((set, get) => ({
|
||||
@@ -125,4 +138,11 @@ export const usePluginStore = create<PluginState>((set, get) => ({
|
||||
.flatMap((m) => m.dashboard_widgets)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
},
|
||||
|
||||
getCustomFieldsForEntity: (entityType: string) => {
|
||||
const { manifests } = get();
|
||||
return manifests
|
||||
.flatMap((m) => m.custom_fields || [])
|
||||
.filter((cf) => cf.entity === entityType);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -80,6 +80,9 @@ from app.plugins.builtins.report_generator.models import ( # noqa: F401
|
||||
ReportTemplate,
|
||||
)
|
||||
from app.plugins.builtins.tags.models import Tag, TagAssignment # noqa: F401
|
||||
from app.plugins.builtins.tasks import TasksPlugin # noqa: F401
|
||||
from app.plugins.builtins.tasks.models import Task # noqa: F401
|
||||
from app.models.saved_filter import SavedFilter # noqa: F401
|
||||
from app.plugins.registry import reset_registry_for_testing # noqa: F401
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing # noqa: F401
|
||||
|
||||
@@ -351,6 +354,7 @@ async def dms_app(engine: AsyncEngine, redis_client):
|
||||
|
||||
registry.register_plugin(PermissionsPlugin())
|
||||
registry.register_plugin(DmsPlugin())
|
||||
registry.register_plugin(TasksPlugin())
|
||||
reset_plugin_service_for_testing(registry)
|
||||
|
||||
yield app
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Custom fields tests — plugin-defined custom fields on contacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCustomFieldsGet:
|
||||
"""GET /api/v1/contacts/{id}/custom-fields"""
|
||||
|
||||
async def test_get_custom_fields_returns_200(self, client: AsyncClient, db_session):
|
||||
"""GET custom fields for a contact returns 200 with merged definitions."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
# Create a contact first
|
||||
resp = await client.post(
|
||||
"/api/v1/contacts",
|
||||
json={"type": "company", "name": "Test Corp", "displayname": "Test Corp"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
contact_id = resp.json()["id"]
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/v1/contacts/{contact_id}/custom-fields",
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "fields" in data
|
||||
assert isinstance(data["fields"], list)
|
||||
|
||||
async def test_get_custom_fields_invalid_uuid_returns_400(self, client: AsyncClient, db_session):
|
||||
"""GET custom fields with invalid UUID returns 400."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.get(
|
||||
"/api/v1/contacts/not-a-uuid/custom-fields",
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_get_custom_fields_not_found_returns_404(self, client: AsyncClient, db_session):
|
||||
"""GET custom fields for non-existent contact returns 404."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.get(
|
||||
"/api/v1/contacts/00000000-0000-0000-0000-000000000000/custom-fields",
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCustomFieldsUpdate:
|
||||
"""PATCH /api/v1/contacts/{id}/custom-fields"""
|
||||
|
||||
async def test_update_custom_fields_returns_200(self, client: AsyncClient, db_session):
|
||||
"""PATCH custom fields stores values in contacts.custom JSONB."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
# Create a contact
|
||||
resp = await client.post(
|
||||
"/api/v1/contacts",
|
||||
json={"type": "company", "name": "Custom Corp", "displayname": "Custom Corp"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
contact_id = resp.json()["id"]
|
||||
|
||||
# Update custom fields (empty values is valid since no plugin defines fields)
|
||||
resp = await client.patch(
|
||||
f"/api/v1/contacts/{contact_id}/custom-fields",
|
||||
json={"values": {"test_field": "test_value"}},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
# Without plugin definitions, unknown fields should be rejected
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_update_custom_fields_empty_values_returns_200(self, client: AsyncClient, db_session):
|
||||
"""PATCH custom fields with empty values dict returns 200."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.post(
|
||||
"/api/v1/contacts",
|
||||
json={"type": "company", "name": "Empty Corp", "displayname": "Empty Corp"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
contact_id = resp.json()["id"]
|
||||
|
||||
resp = await client.patch(
|
||||
f"/api/v1/contacts/{contact_id}/custom-fields",
|
||||
json={"values": {}},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "fields" in resp.json()
|
||||
|
||||
async def test_update_custom_fields_not_found_returns_404(self, client: AsyncClient, db_session):
|
||||
"""PATCH custom fields for non-existent contact returns 404."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.patch(
|
||||
"/api/v1/contacts/00000000-0000-0000-0000-000000000000/custom-fields",
|
||||
json={"values": {}},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCustomFieldDefinition:
|
||||
"""Test CustomFieldDefinition model in manifest."""
|
||||
|
||||
async def test_custom_field_definition_creation(self):
|
||||
"""CustomFieldDefinition can be created with valid field types."""
|
||||
from app.plugins.manifest import CustomFieldDefinition
|
||||
|
||||
cf = CustomFieldDefinition(
|
||||
name="lead_source",
|
||||
label="Lead Source",
|
||||
label_key="custom.leadSource",
|
||||
field_type="select",
|
||||
options=["website", "referral", "cold_call"],
|
||||
required=False,
|
||||
entity="contact",
|
||||
)
|
||||
assert cf.name == "lead_source"
|
||||
assert cf.field_type == "select"
|
||||
assert len(cf.options) == 3
|
||||
|
||||
async def test_custom_field_definition_invalid_type_raises(self):
|
||||
"""CustomFieldDefinition rejects invalid field_type."""
|
||||
from app.plugins.manifest import CustomFieldDefinition
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CustomFieldDefinition(
|
||||
name="bad_field",
|
||||
field_type="invalid_type",
|
||||
)
|
||||
|
||||
async def test_plugin_manifest_with_custom_fields(self):
|
||||
"""PluginManifest accepts custom_fields list."""
|
||||
from app.plugins.manifest import PluginManifest, CustomFieldDefinition
|
||||
|
||||
manifest = PluginManifest(
|
||||
name="test_plugin",
|
||||
version="1.0.0",
|
||||
display_name="Test Plugin",
|
||||
custom_fields=[
|
||||
CustomFieldDefinition(
|
||||
name="score",
|
||||
label="Score",
|
||||
field_type="number",
|
||||
entity="contact",
|
||||
),
|
||||
],
|
||||
)
|
||||
assert len(manifest.custom_fields) == 1
|
||||
assert manifest.custom_fields[0].name == "score"
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Saved filters tests — CRUD for reusable filter criteria."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSavedFilterList:
|
||||
"""GET /api/v1/saved-filters"""
|
||||
|
||||
async def test_list_saved_filters_returns_200(self, client: AsyncClient, db_session):
|
||||
"""GET /saved-filters returns 200 with list."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
|
||||
async def test_list_saved_filters_with_entity_type(self, client: AsyncClient, db_session):
|
||||
"""GET /saved-filters?entity_type=contacts filters by entity."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.get("/api/v1/saved-filters?entity_type=contacts", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
for item in resp.json():
|
||||
assert item["entity_type"] == "contacts"
|
||||
|
||||
async def test_list_saved_filters_requires_auth(self, client: AsyncClient, db_session):
|
||||
"""GET /saved-filters without auth returns 401."""
|
||||
resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSavedFilterCreate:
|
||||
"""POST /api/v1/saved-filters"""
|
||||
|
||||
async def test_create_saved_filter_returns_201(self, client: AsyncClient, db_session):
|
||||
"""POST /saved-filters creates a filter and returns 201."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.post(
|
||||
"/api/v1/saved-filters",
|
||||
json={
|
||||
"name": "Important Clients",
|
||||
"entity_type": "contacts",
|
||||
"filter_criteria": {"type": "company", "search": "important"},
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "Important Clients"
|
||||
assert data["entity_type"] == "contacts"
|
||||
assert data["filter_criteria"]["type"] == "company"
|
||||
|
||||
async def test_create_saved_filter_duplicate_returns_409(self, client: AsyncClient, db_session):
|
||||
"""POST /saved-filters with duplicate name returns 409."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
# Create first
|
||||
await client.post(
|
||||
"/api/v1/saved-filters",
|
||||
json={"name": "My Filter", "entity_type": "contacts", "filter_criteria": {}},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
# Create duplicate
|
||||
resp = await client.post(
|
||||
"/api/v1/saved-filters",
|
||||
json={"name": "My Filter", "entity_type": "contacts", "filter_criteria": {}},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_create_saved_filter_invalid_entity_returns_422(self, client: AsyncClient, db_session):
|
||||
"""POST /saved-filters with invalid entity_type returns 422."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.post(
|
||||
"/api/v1/saved-filters",
|
||||
json={"name": "Test", "entity_type": "invalid", "filter_criteria": {}},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSavedFilterDelete:
|
||||
"""DELETE /api/v1/saved-filters/{id}"""
|
||||
|
||||
async def test_delete_saved_filter_returns_204(self, client: AsyncClient, db_session):
|
||||
"""DELETE /saved-filters/{id} soft-deletes the filter."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
# Create
|
||||
create_resp = await client.post(
|
||||
"/api/v1/saved-filters",
|
||||
json={"name": "To Delete", "entity_type": "contacts", "filter_criteria": {}},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
filter_id = create_resp.json()["id"]
|
||||
# Delete
|
||||
resp = await client.delete(f"/api/v1/saved-filters/{filter_id}", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 204
|
||||
# Verify gone from list
|
||||
list_resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
|
||||
assert not any(f["id"] == filter_id for f in list_resp.json())
|
||||
|
||||
async def test_delete_saved_filter_not_found_returns_404(self, client: AsyncClient, db_session):
|
||||
"""DELETE non-existent filter returns 404."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.delete(
|
||||
"/api/v1/saved-filters/00000000-0000-0000-0000-000000000000",
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_delete_saved_filter_invalid_uuid_returns_400(self, client: AsyncClient, db_session):
|
||||
"""DELETE with invalid UUID returns 400."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.delete("/api/v1/saved-filters/not-a-uuid", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 400
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Tasks plugin tests — CRUD, assign, status update, filtering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTaskList:
|
||||
"""GET /api/v1/tasks"""
|
||||
|
||||
async def test_list_tasks_returns_200(self, client: AsyncClient, db_session):
|
||||
"""GET /tasks returns 200 with paginated list."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.get("/api/v1/tasks", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert "page" in data
|
||||
assert "page_size" in data
|
||||
|
||||
async def test_list_tasks_with_status_filter(self, client: AsyncClient, db_session):
|
||||
"""GET /tasks?status=open filters by status."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.get("/api/v1/tasks?status=open", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200
|
||||
for item in resp.json()["items"]:
|
||||
assert item["status"] == "open"
|
||||
|
||||
async def test_list_tasks_requires_auth(self, client: AsyncClient, db_session):
|
||||
"""GET /tasks without auth returns 401."""
|
||||
resp = await client.get("/api/v1/tasks", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTaskCreate:
|
||||
"""POST /api/v1/tasks"""
|
||||
|
||||
async def test_create_task_returns_201(self, client: AsyncClient, db_session):
|
||||
"""POST /tasks creates a task and returns 201."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Call customer", "priority": "high"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["title"] == "Call customer"
|
||||
assert data["priority"] == "high"
|
||||
assert data["status"] == "open"
|
||||
|
||||
async def test_create_task_with_due_date(self, client: AsyncClient, db_session):
|
||||
"""POST /tasks with due_date stores it correctly."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Follow up", "due_date": "2025-12-31T10:00:00Z"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["due_date"] is not None
|
||||
|
||||
async def test_create_task_empty_title_returns_422(self, client: AsyncClient, db_session):
|
||||
"""POST /tasks with empty title returns 422."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": ""},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTaskUpdate:
|
||||
"""PATCH /api/v1/tasks/{id}"""
|
||||
|
||||
async def test_update_task_returns_200(self, client: AsyncClient, db_session):
|
||||
"""PATCH /tasks/{id} updates the task."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
# Create
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Original"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
task_id = create_resp.json()["id"]
|
||||
# Update
|
||||
resp = await client.patch(
|
||||
f"/api/v1/tasks/{task_id}",
|
||||
json={"title": "Updated", "status": "in_progress"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "Updated"
|
||||
assert resp.json()["status"] == "in_progress"
|
||||
|
||||
async def test_update_task_not_found_returns_404(self, client: AsyncClient, db_session):
|
||||
"""PATCH non-existent task returns 404."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
resp = await client.patch(
|
||||
"/api/v1/tasks/00000000-0000-0000-0000-000000000000",
|
||||
json={"title": "Updated"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTaskStatus:
|
||||
"""POST /api/v1/tasks/{id}/status"""
|
||||
|
||||
async def test_update_status_returns_200(self, client: AsyncClient, db_session):
|
||||
"""POST /tasks/{id}/status updates status."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Task to complete"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
task_id = create_resp.json()["id"]
|
||||
resp = await client.post(
|
||||
f"/api/v1/tasks/{task_id}/status",
|
||||
json={"status": "done"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "done"
|
||||
|
||||
async def test_update_status_invalid_returns_422(self, client: AsyncClient, db_session):
|
||||
"""POST /tasks/{id}/status with invalid status returns 422."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Task"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
task_id = create_resp.json()["id"]
|
||||
resp = await client.post(
|
||||
f"/api/v1/tasks/{task_id}/status",
|
||||
json={"status": "invalid"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTaskDelete:
|
||||
"""DELETE /api/v1/tasks/{id}"""
|
||||
|
||||
async def test_delete_task_returns_204(self, client: AsyncClient, db_session):
|
||||
"""DELETE /tasks/{id} soft-deletes the task."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "To delete"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
task_id = create_resp.json()["id"]
|
||||
resp = await client.delete(f"/api/v1/tasks/{task_id}", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 204
|
||||
# Verify it's gone from list
|
||||
list_resp = await client.get("/api/v1/tasks", headers=ORIGIN_HEADER)
|
||||
assert not any(t["id"] == task_id for t in list_resp.json()["items"])
|
||||
Reference in New Issue
Block a user