feat(F.14): Unified Task System — F-TASK-MODEL/API/AGENT/WORK/UI/MIG/GOAL/TEST
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- F-TASK-MODEL: Extended Task model with polymorphic assignee/entity/creator, subtasks, dependencies, task_type, success_criteria, progress - F-TASK-API: Extended task routes with polymorphic filters, subtasks, dependencies, new lifecycle - F-TASK-AGENT: ai_tools.py (191 lines) — create_task, assign_task, update_task_status, decompose_goal tools - F-TASK-WORK: workstream.py — task_card and goal_card blocks in communication system - F-TASK-UI: TaskBoard.tsx, TaskDetail.tsx, GoalView.tsx frontend components - F-TASK-MIG: Migration 0124 — new columns, data migration for contact_id/assigned_to - F-TASK-GOAL: Progress aggregation, success criteria evaluation, parent status propagation - F-TASK-TEST: test_unified_tasks.py (414 lines) - i18n updates for task system
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""Unified Task System (F.14).
|
||||
|
||||
Adds polymorphic assignment/entity/creator fields, subtasks, dependencies,
|
||||
goals/milestones and agent-subtask support to the tasks table. Migrates
|
||||
legacy ``contact_id``/``assigned_to`` values into the polymorphic fields and
|
||||
migrates existing ``agent_subtasks`` rows into tasks with
|
||||
``task_type='agent_subtask'``.
|
||||
|
||||
Revision ID: 0124
|
||||
Revises: 0123
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0124"
|
||||
down_revision = "0123"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Add new columns to tasks ────────────────────────────────────────────
|
||||
op.add_column("tasks", sa.Column("assignee_type", sa.String(20), nullable=False, server_default="user"))
|
||||
op.add_column("tasks", sa.Column("assignee_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("entity_type", sa.String(80), nullable=True))
|
||||
op.add_column("tasks", sa.Column("entity_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("creator_type", sa.String(20), nullable=False, server_default="user"))
|
||||
op.add_column("tasks", sa.Column("creator_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("parent_task_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("depends_on", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")))
|
||||
op.add_column("tasks", sa.Column("task_type", sa.String(30), nullable=False, server_default="todo"))
|
||||
op.add_column("tasks", sa.Column("success_criteria", JSONB, nullable=True))
|
||||
op.add_column("tasks", sa.Column("target_date", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("tasks", sa.Column("progress", sa.Integer(), nullable=False, server_default="0"))
|
||||
|
||||
# ── Migrate legacy data into polymorphic fields ─────────────────────────
|
||||
# contact_id → entity_type='contact' + entity_id
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET entity_type = 'contact', entity_id = contact_id
|
||||
WHERE contact_id IS NOT NULL AND entity_type IS NULL
|
||||
"""
|
||||
)
|
||||
# assigned_to → assignee_type='user' + assignee_id
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET assignee_type = 'user', assignee_id = assigned_to
|
||||
WHERE assigned_to IS NOT NULL AND assignee_id IS NULL
|
||||
"""
|
||||
)
|
||||
# created_by → creator_type='user' + creator_id
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET creator_type = 'user', creator_id = created_by
|
||||
WHERE created_by IS NOT NULL AND creator_id IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
# ── Migrate AgentSubtask rows into tasks ────────────────────────────────
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tasks (
|
||||
id, tenant_id, title, description, status, priority,
|
||||
assignee_type, assignee_id, entity_type, entity_id,
|
||||
creator_type, creator_id, task_type, depends_on, progress,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
asub.id, asub.tenant_id,
|
||||
asub.task_description, asub.task_description, asub.status, 'medium',
|
||||
'agent', asub.child_agent_id, 'agent', asub.parent_agent_id,
|
||||
'agent', asub.parent_agent_id, 'agent_subtask', '[]'::jsonb, 0,
|
||||
asub.created_at, asub.updated_at
|
||||
FROM agent_subtasks asub
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM tasks t WHERE t.id = asub.id
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# ── Indexes ─────────────────────────────────────────────────────────────
|
||||
op.create_index("ix_tasks_tenant_entity", "tasks", ["tenant_id", "entity_type", "entity_id"])
|
||||
op.create_index("ix_tasks_tenant_assignee", "tasks", ["tenant_id", "assignee_type", "assignee_id"])
|
||||
op.create_index("ix_tasks_tenant_parent", "tasks", ["tenant_id", "parent_task_id"])
|
||||
op.create_index("ix_tasks_tenant_type", "tasks", ["tenant_id", "task_type"])
|
||||
op.create_foreign_key(
|
||||
"fk_tasks_parent_task_id", "tasks", "tasks", ["parent_task_id"], ["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_tasks_parent_task_id", "tasks", type_="foreignkey")
|
||||
op.drop_index("ix_tasks_tenant_type", table_name="tasks")
|
||||
op.drop_index("ix_tasks_tenant_parent", table_name="tasks")
|
||||
op.drop_index("ix_tasks_tenant_assignee", table_name="tasks")
|
||||
op.drop_index("ix_tasks_tenant_entity", table_name="tasks")
|
||||
op.drop_column("tasks", "progress")
|
||||
op.drop_column("tasks", "target_date")
|
||||
op.drop_column("tasks", "success_criteria")
|
||||
op.drop_column("tasks", "task_type")
|
||||
op.drop_column("tasks", "depends_on")
|
||||
op.drop_column("tasks", "parent_task_id")
|
||||
op.drop_column("tasks", "creator_id")
|
||||
op.drop_column("tasks", "creator_type")
|
||||
op.drop_column("tasks", "entity_id")
|
||||
op.drop_column("tasks", "entity_type")
|
||||
op.drop_column("tasks", "assignee_id")
|
||||
op.drop_column("tasks", "assignee_type")
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Task → Workstream integration (F-TASK-WORK).
|
||||
|
||||
Posts task_card and goal_card blocks to the communication system so tasks
|
||||
and goals appear in the workstream with live status and progress.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.tasks.models import Task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _task_card_data(task: Task) -> dict[str, Any]:
|
||||
"""Build a task_card block payload for a task."""
|
||||
return {
|
||||
"task_id": str(task.id),
|
||||
"title": task.title,
|
||||
"status": task.status,
|
||||
"priority": task.priority,
|
||||
"task_type": task.task_type,
|
||||
"assignee_type": task.assignee_type,
|
||||
"assignee_id": str(task.assignee_id) if task.assignee_id else None,
|
||||
"entity_type": task.entity_type,
|
||||
"entity_id": str(task.entity_id) if task.entity_id else None,
|
||||
"parent_task_id": str(task.parent_task_id) if task.parent_task_id else None,
|
||||
"due_date": task.due_date.isoformat() if task.due_date else None,
|
||||
"progress": task.progress or 0,
|
||||
}
|
||||
|
||||
|
||||
def _goal_card_data(task: Task) -> dict[str, Any]:
|
||||
"""Build a goal_card block payload for a goal/milestone."""
|
||||
return {
|
||||
"goal_id": str(task.id),
|
||||
"title": task.title,
|
||||
"status": task.status,
|
||||
"task_type": task.task_type,
|
||||
"progress": task.progress or 0,
|
||||
"target_date": task.target_date.isoformat() if task.target_date else None,
|
||||
"success_criteria": task.success_criteria,
|
||||
"parent_task_id": str(task.parent_task_id) if task.parent_task_id else None,
|
||||
}
|
||||
|
||||
|
||||
async def post_task_to_workstream(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
task: Task,
|
||||
*,
|
||||
actor_id: uuid.UUID | None = None,
|
||||
actor_type: str = "user",
|
||||
conversation_id: uuid.UUID | None = None,
|
||||
content: str | None = None,
|
||||
) -> uuid.UUID | None:
|
||||
"""Post a task_card block to the workstream.
|
||||
|
||||
Returns the created message ID, or None if the communication system is
|
||||
unavailable.
|
||||
"""
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.services import send_message
|
||||
|
||||
block_type = "goal_card" if task.task_type in ("goal", "milestone") else "task_card"
|
||||
block_data = _goal_card_data(task) if block_type == "goal_card" else _task_card_data(task)
|
||||
message_id = await send_message(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
sender_id=actor_id or task.created_by or task.id,
|
||||
sender_type=actor_type,
|
||||
conversation_id=conversation_id,
|
||||
content=content or f"{task.title} ({task.status})",
|
||||
blocks=[{"type": block_type, "data": block_data}],
|
||||
metadata={"source": "tasks", "task_id": str(task.id)},
|
||||
)
|
||||
return message_id
|
||||
except Exception:
|
||||
logger.exception("Failed to post task %s to workstream", task.id)
|
||||
return None
|
||||
|
||||
|
||||
async def post_task_status_update(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
task: Task,
|
||||
*,
|
||||
old_status: str | None = None,
|
||||
actor_id: uuid.UUID | None = None,
|
||||
actor_type: str = "user",
|
||||
conversation_id: uuid.UUID | None = None,
|
||||
) -> uuid.UUID | None:
|
||||
"""Post a status-change update to the workstream."""
|
||||
content = f"Status geändert: {task.title} → {task.status}"
|
||||
if old_status and old_status != task.status:
|
||||
content = f"Status geändert: {task.title} ({old_status} → {task.status})"
|
||||
return await post_task_to_workstream(
|
||||
db, tenant_id, task,
|
||||
actor_id=actor_id, actor_type=actor_type,
|
||||
conversation_id=conversation_id, content=content,
|
||||
)
|
||||
+119
-10
@@ -1,19 +1,36 @@
|
||||
/**
|
||||
* Tasks API hooks — CRUD, assign, status update.
|
||||
* Tasks API hooks — CRUD, assign, status update, subtasks, dependencies, goals.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
|
||||
|
||||
export type TaskStatus = 'open' | 'in_progress' | 'review' | 'blocked' | 'done' | 'cancelled';
|
||||
export type TaskType = 'todo' | 'approval' | 'follow_up' | 'review' | 'goal' | 'milestone' | 'agent_subtask';
|
||||
export type AssigneeType = 'user' | 'agent' | 'group';
|
||||
export type CreatorType = 'user' | 'agent' | 'workflow' | 'system';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: 'open' | 'in_progress' | 'done';
|
||||
status: TaskStatus;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
due_date: string | null;
|
||||
assigned_to: string | null;
|
||||
contact_id: string | null;
|
||||
assignee_type: AssigneeType;
|
||||
assignee_id: string | null;
|
||||
entity_type: string | null;
|
||||
entity_id: string | null;
|
||||
creator_type: CreatorType;
|
||||
creator_id: string | null;
|
||||
parent_task_id: string | null;
|
||||
depends_on: string[];
|
||||
task_type: TaskType;
|
||||
success_criteria: Record<string, unknown> | null;
|
||||
target_date: string | null;
|
||||
progress: number;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -29,28 +46,58 @@ export interface TaskListResponse {
|
||||
export interface TaskCreateInput {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
status?: TaskStatus;
|
||||
priority?: 'low' | 'medium' | 'high' | 'urgent';
|
||||
due_date?: string | null;
|
||||
assigned_to?: string | null;
|
||||
contact_id?: string | null;
|
||||
assignee_type?: AssigneeType;
|
||||
assignee_id?: string | null;
|
||||
entity_type?: string | null;
|
||||
entity_id?: string | null;
|
||||
creator_type?: CreatorType;
|
||||
creator_id?: string | null;
|
||||
parent_task_id?: string | null;
|
||||
depends_on?: string[];
|
||||
task_type?: TaskType;
|
||||
success_criteria?: Record<string, unknown> | null;
|
||||
target_date?: string | null;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export interface TaskUpdateInput {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
status?: TaskStatus;
|
||||
priority?: 'low' | 'medium' | 'high' | 'urgent';
|
||||
due_date?: string | null;
|
||||
assigned_to?: string | null;
|
||||
contact_id?: string | null;
|
||||
assignee_type?: AssigneeType;
|
||||
assignee_id?: string | null;
|
||||
entity_type?: string | null;
|
||||
entity_id?: string | null;
|
||||
creator_type?: CreatorType;
|
||||
creator_id?: string | null;
|
||||
parent_task_id?: string | null;
|
||||
depends_on?: string[];
|
||||
task_type?: TaskType;
|
||||
success_criteria?: Record<string, unknown> | null;
|
||||
target_date?: string | null;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export interface TaskFilter {
|
||||
status?: string;
|
||||
status?: TaskStatus;
|
||||
priority?: string;
|
||||
assigned_to?: string;
|
||||
contact_id?: string;
|
||||
entity_type?: string;
|
||||
entity_id?: string;
|
||||
assignee_type?: AssigneeType;
|
||||
assignee_id?: string;
|
||||
parent_task_id?: string;
|
||||
task_type?: TaskType;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
@@ -60,6 +107,12 @@ export function useTasks(page = 1, pageSize = 25, filter?: TaskFilter) {
|
||||
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?.entity_type) params.set('entity_type', filter.entity_type);
|
||||
if (filter?.entity_id) params.set('entity_id', filter.entity_id);
|
||||
if (filter?.assignee_type) params.set('assignee_type', filter.assignee_type);
|
||||
if (filter?.assignee_id) params.set('assignee_id', filter.assignee_id);
|
||||
if (filter?.parent_task_id) params.set('parent_task_id', filter.parent_task_id);
|
||||
if (filter?.task_type) params.set('task_type', filter.task_type);
|
||||
if (filter?.search) params.set('search', filter.search);
|
||||
return useQuery({
|
||||
queryKey: ['tasks', page, pageSize, filter],
|
||||
@@ -110,8 +163,8 @@ export function useDeleteTask() {
|
||||
export function useAssignTask() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, assignedTo }: { id: string; assignedTo: string }) =>
|
||||
apiPost<Task>(`/tasks/${id}/assign`, { assigned_to: assignedTo }),
|
||||
mutationFn: ({ id, assigneeType, assigneeId }: { id: string; assigneeType: AssigneeType; assigneeId: string }) =>
|
||||
apiPost<Task>(`/tasks/${id}/assign`, { assignee_type: assigneeType, assignee_id: assigneeId }),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', variables.id] });
|
||||
@@ -122,7 +175,7 @@ export function useAssignTask() {
|
||||
export function useUpdateTaskStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
mutationFn: ({ id, status }: { id: string; status: TaskStatus }) =>
|
||||
apiPost<Task>(`/tasks/${id}/status`, { status }),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
@@ -130,3 +183,59 @@ export function useUpdateTaskStatus() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSubtask() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ parentId, data }: { parentId: string; data: TaskCreateInput }) =>
|
||||
apiPost<Task>(`/tasks/${parentId}/subtasks`, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', variables.parentId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useListSubtasks(parentId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', parentId, 'subtasks'],
|
||||
queryFn: () => apiGet<Task[]>(`/tasks/${parentId}/subtasks`),
|
||||
enabled: !!parentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddDependency() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dependsOn }: { id: string; dependsOn: string }) =>
|
||||
apiPost<Task>(`/tasks/${id}/dependencies`, { depends_on: dependsOn }),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', variables.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveDependency() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dependsOn }: { id: string; dependsOn: string }) =>
|
||||
apiDelete(`/tasks/${id}/dependencies/${dependsOn}`),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', variables.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDecomposeGoal() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ goalId, subtasks }: { goalId: string; subtasks: TaskCreateInput[] }) =>
|
||||
apiPost<{ goal: Task; subtasks: Task[] }>(`/tasks/${goalId}/decompose`, subtasks),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', variables.goalId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* GoalView — goal overview with progress bar and milestone hierarchy.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useTask, useListSubtasks, type Task, type TaskStatus } from '@/api/tasks';
|
||||
import { Loader2, Target, Flag } from 'lucide-react';
|
||||
|
||||
const STATUS_VARIANTS: Record<TaskStatus, 'secondary' | 'info' | 'warning' | 'success' | 'danger'> = {
|
||||
open: 'secondary',
|
||||
in_progress: 'info',
|
||||
review: 'warning',
|
||||
blocked: 'danger',
|
||||
done: 'success',
|
||||
cancelled: 'secondary',
|
||||
};
|
||||
|
||||
function statusLabel(t: (k: string) => string, status: TaskStatus): string {
|
||||
const map: Record<TaskStatus, string> = {
|
||||
open: t('tasks.statusOpen'),
|
||||
in_progress: t('tasks.statusInProgress'),
|
||||
review: t('tasks.statusReview'),
|
||||
blocked: t('tasks.statusBlocked'),
|
||||
done: t('tasks.statusDone'),
|
||||
cancelled: t('tasks.statusCancelled'),
|
||||
};
|
||||
return map[status];
|
||||
}
|
||||
|
||||
interface GoalViewProps {
|
||||
goalId: string;
|
||||
onSelectTask?: (task: Task) => void;
|
||||
}
|
||||
|
||||
export function GoalView({ goalId, onSelectTask }: GoalViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: goal, isLoading } = useTask(goalId);
|
||||
const { data: children } = useListSubtasks(goalId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12" role="status">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary-500" aria-hidden="true" />
|
||||
<span className="sr-only">{t('common.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!goal) {
|
||||
return (
|
||||
<Card title={t('tasks.title')}>
|
||||
<p className="text-sm text-gray-500">{t('tasks.noTasks')}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const progress = goal.progress ?? 0;
|
||||
const milestones = (children ?? []).filter((c) => c.task_type === 'milestone');
|
||||
const todos = (children ?? []).filter((c) => c.task_type !== 'milestone');
|
||||
|
||||
return (
|
||||
<Card title={goal.title}>
|
||||
<div className="space-y-4">
|
||||
{goal.description ? <p className="text-sm text-gray-700">{goal.description}</p> : null}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={STATUS_VARIANTS[goal.status]}>{statusLabel(t, goal.status)}</Badge>
|
||||
<Badge variant="primary">{progress}%</Badge>
|
||||
{goal.target_date ? (
|
||||
<Badge variant="info">
|
||||
{t('tasks.targetDate')}: {new Date(goal.target_date).toLocaleDateString()}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Target className="h-3 w-3" aria-hidden="true" />
|
||||
{t('tasks.progress')}
|
||||
</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-2 w-full overflow-hidden rounded-full bg-gray-200"
|
||||
role="progressbar"
|
||||
aria-valuenow={progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-primary-500 transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Milestones */}
|
||||
{milestones.length > 0 ? (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1 text-sm font-semibold text-gray-700">
|
||||
<Flag className="h-4 w-4" aria-hidden="true" />
|
||||
{t('tasks.milestones')}
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{milestones.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onSelectTask?.(m)}
|
||||
className="flex w-full items-center justify-between rounded border border-gray-200 px-3 py-2 text-left hover:bg-gray-50"
|
||||
>
|
||||
<span className="text-sm text-gray-800">{m.title}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">{m.progress ?? 0}%</span>
|
||||
<Badge variant={STATUS_VARIANTS[m.status]}>{statusLabel(t, m.status)}</Badge>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Todos */}
|
||||
{todos.length > 0 ? (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">{t('tasks.subtasks')}</h4>
|
||||
<div className="space-y-1">
|
||||
{todos.map((todo) => (
|
||||
<button
|
||||
key={todo.id}
|
||||
type="button"
|
||||
onClick={() => onSelectTask?.(todo)}
|
||||
className="flex w-full items-center justify-between rounded border border-gray-200 px-3 py-2 text-left hover:bg-gray-50"
|
||||
>
|
||||
<span className="text-sm text-gray-800">{todo.title}</span>
|
||||
<Badge variant={STATUS_VARIANTS[todo.status]}>{statusLabel(t, todo.status)}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default GoalView;
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* TaskBoard — Kanban view with columns by lifecycle status.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useTasks, type Task, type TaskStatus } from '@/api/tasks';
|
||||
import { Clock, AlertCircle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
const STATUS_COLUMNS: TaskStatus[] = ['open', 'in_progress', 'review', 'blocked', 'done', 'cancelled'];
|
||||
|
||||
const STATUS_VARIANTS: Record<TaskStatus, 'secondary' | 'info' | 'warning' | 'success' | 'danger'> = {
|
||||
open: 'secondary',
|
||||
in_progress: 'info',
|
||||
review: 'warning',
|
||||
blocked: 'danger',
|
||||
done: 'success',
|
||||
cancelled: 'secondary',
|
||||
};
|
||||
|
||||
const PRIORITY_VARIANTS: Record<string, 'secondary' | 'info' | 'warning' | 'danger'> = {
|
||||
low: 'secondary',
|
||||
medium: 'info',
|
||||
high: 'warning',
|
||||
urgent: 'danger',
|
||||
};
|
||||
|
||||
function statusLabel(t: (k: string) => string, status: TaskStatus): string {
|
||||
const map: Record<TaskStatus, string> = {
|
||||
open: t('tasks.statusOpen'),
|
||||
in_progress: t('tasks.statusInProgress'),
|
||||
review: t('tasks.statusReview'),
|
||||
blocked: t('tasks.statusBlocked'),
|
||||
done: t('tasks.statusDone'),
|
||||
cancelled: t('tasks.statusCancelled'),
|
||||
};
|
||||
return map[status];
|
||||
}
|
||||
|
||||
function isOverdue(dateStr: string | null, status: string): boolean {
|
||||
if (!dateStr || status === 'done' || status === 'cancelled') return false;
|
||||
try {
|
||||
return new Date(dateStr) < new Date();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
onSelect: (task: Task) => void;
|
||||
}
|
||||
|
||||
function TaskCard({ task, onSelect }: TaskCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const overdue = isOverdue(task.due_date, task.status);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(task)}
|
||||
className="w-full text-left rounded-lg border border-gray-200 bg-white p-3 shadow-sm hover:shadow-md transition-shadow focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
aria-label={task.title}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 line-clamp-2">{task.title}</span>
|
||||
<Badge variant={PRIORITY_VARIANTS[task.priority] ?? 'secondary'}>{t(`tasks.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`)}</Badge>
|
||||
</div>
|
||||
{task.description ? (
|
||||
<p className="mt-1 text-xs text-gray-500 line-clamp-2">{task.description}</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-500">
|
||||
{task.due_date ? (
|
||||
<span className={`inline-flex items-center gap-1 ${overdue ? 'text-danger-600' : ''}`}>
|
||||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||||
{new Date(task.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
) : null}
|
||||
{task.task_type !== 'todo' ? (
|
||||
<Badge variant="info">{t(`tasks.type${task.task_type.charAt(0).toUpperCase() + task.task_type.slice(1)}`)}</Badge>
|
||||
) : null}
|
||||
{task.progress > 0 ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
{task.progress}%
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskBoardProps {
|
||||
filter?: {
|
||||
entity_type?: string;
|
||||
entity_id?: string;
|
||||
assignee_type?: string;
|
||||
assignee_id?: string;
|
||||
parent_task_id?: string;
|
||||
task_type?: string;
|
||||
};
|
||||
onSelectTask?: (task: Task) => void;
|
||||
}
|
||||
|
||||
export function TaskBoard({ filter, onSelectTask }: TaskBoardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useTasks(1, 200, filter);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12" role="status">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary-500" aria-hidden="true" />
|
||||
<span className="sr-only">{t('common.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tasks = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{STATUS_COLUMNS.map((status) => {
|
||||
const columnTasks = tasks.filter((task) => task.status === status);
|
||||
return (
|
||||
<div key={status} className="flex flex-col rounded-lg bg-gray-50 p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-700">{statusLabel(t, status)}</h3>
|
||||
<Badge variant={STATUS_VARIANTS[status]}>{columnTasks.length}</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{columnTasks.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">{t('tasks.noTasks')}</p>
|
||||
) : (
|
||||
columnTasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onSelect={onSelectTask ?? (() => {})} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TaskBoard;
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* TaskDetail — detail view with subtasks, dependencies, assignee dropdown.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
useTask,
|
||||
useListSubtasks,
|
||||
useCreateSubtask,
|
||||
useUpdateTaskStatus,
|
||||
useAssignTask,
|
||||
useAddDependency,
|
||||
useRemoveDependency,
|
||||
type Task,
|
||||
type TaskStatus,
|
||||
type AssigneeType,
|
||||
} from '@/api/tasks';
|
||||
import { Loader2, Plus, X, Link2, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
const STATUS_OPTIONS: TaskStatus[] = ['open', 'in_progress', 'review', 'blocked', 'done', 'cancelled'];
|
||||
const ASSIGNEE_TYPES: AssigneeType[] = ['user', 'agent', 'group'];
|
||||
|
||||
const STATUS_VARIANTS: Record<TaskStatus, 'secondary' | 'info' | 'warning' | 'success' | 'danger'> = {
|
||||
open: 'secondary',
|
||||
in_progress: 'info',
|
||||
review: 'warning',
|
||||
blocked: 'danger',
|
||||
done: 'success',
|
||||
cancelled: 'secondary',
|
||||
};
|
||||
|
||||
function statusLabel(t: (k: string) => string, status: TaskStatus): string {
|
||||
const map: Record<TaskStatus, string> = {
|
||||
open: t('tasks.statusOpen'),
|
||||
in_progress: t('tasks.statusInProgress'),
|
||||
review: t('tasks.statusReview'),
|
||||
blocked: t('tasks.statusBlocked'),
|
||||
done: t('tasks.statusDone'),
|
||||
cancelled: t('tasks.statusCancelled'),
|
||||
};
|
||||
return map[status];
|
||||
}
|
||||
|
||||
interface TaskDetailProps {
|
||||
taskId: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { data: task, isLoading } = useTask(taskId);
|
||||
const { data: subtasks } = useListSubtasks(taskId);
|
||||
|
||||
const [subtaskTitle, setSubtaskTitle] = useState('');
|
||||
const [depId, setDepId] = useState('');
|
||||
const [assigneeType, setAssigneeType] = useState<AssigneeType>('user');
|
||||
const [assigneeId, setAssigneeId] = useState('');
|
||||
|
||||
const statusMutation = useUpdateTaskStatus();
|
||||
const assignMutation = useAssignTask();
|
||||
const createSubtaskMutation = useCreateSubtask();
|
||||
const addDepMutation = useAddDependency();
|
||||
const removeDepMutation = useRemoveDependency();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12" role="status">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary-500" aria-hidden="true" />
|
||||
<span className="sr-only">{t('common.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<Card title={t('tasks.title')}>
|
||||
<p className="text-sm text-gray-500">{t('tasks.noTasks')}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const handleStatusChange = (status: TaskStatus) => {
|
||||
statusMutation.mutate(
|
||||
{ id: task.id, status },
|
||||
{
|
||||
onSuccess: () => toast.success(t('tasks.statusUpdated')),
|
||||
onError: () => toast.error(t('common.error')),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (!assigneeId) return;
|
||||
assignMutation.mutate(
|
||||
{ id: task.id, assigneeType, assigneeId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('tasks.updated'));
|
||||
setAssigneeId('');
|
||||
},
|
||||
onError: () => toast.error(t('common.error')),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateSubtask = () => {
|
||||
if (!subtaskTitle.trim()) return;
|
||||
createSubtaskMutation.mutate(
|
||||
{ parentId: task.id, data: { title: subtaskTitle.trim() } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('tasks.created'));
|
||||
setSubtaskTitle('');
|
||||
},
|
||||
onError: () => toast.error(t('common.error')),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddDependency = () => {
|
||||
if (!depId.trim()) return;
|
||||
addDepMutation.mutate(
|
||||
{ id: task.id, dependsOn: depId.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('tasks.updated'));
|
||||
setDepId('');
|
||||
},
|
||||
onError: () => toast.error(t('common.error')),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={task.title}
|
||||
actions={
|
||||
onClose ? (
|
||||
<Button variant="ghost" size="sm" onClick={onClose} aria-label={t('common.close')}>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{task.description ? <p className="text-sm text-gray-700">{task.description}</p> : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={STATUS_VARIANTS[task.status]}>{statusLabel(t, task.status)}</Badge>
|
||||
<Badge variant="info">{t(`tasks.type${task.task_type.charAt(0).toUpperCase() + task.task_type.slice(1)}`)}</Badge>
|
||||
{task.progress > 0 ? <Badge variant="primary">{task.progress}%</Badge> : null}
|
||||
</div>
|
||||
|
||||
{/* Status change */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="task-status" className="text-sm font-medium text-gray-700">
|
||||
{t('tasks.status_field')}
|
||||
</label>
|
||||
<Select
|
||||
id="task-status"
|
||||
value={task.status}
|
||||
onChange={(e) => handleStatusChange(e.target.value as TaskStatus)}
|
||||
className="w-48"
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{statusLabel(t, s)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div>
|
||||
<label htmlFor="assignee-type" className="block text-sm font-medium text-gray-700">
|
||||
{t('tasks.assigneeType')}
|
||||
</label>
|
||||
<Select
|
||||
id="assignee-type"
|
||||
value={assigneeType}
|
||||
onChange={(e) => setAssigneeType(e.target.value as AssigneeType)}
|
||||
className="w-32"
|
||||
>
|
||||
{ASSIGNEE_TYPES.map((at) => (
|
||||
<option key={at} value={at}>
|
||||
{t(`tasks.assigneeType${at.charAt(0).toUpperCase() + at.slice(1)}`)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="assignee-id" className="block text-sm font-medium text-gray-700">
|
||||
{t('tasks.assigneeId')}
|
||||
</label>
|
||||
<Input
|
||||
id="assignee-id"
|
||||
value={assigneeId}
|
||||
onChange={(e) => setAssigneeId(e.target.value)}
|
||||
placeholder="UUID"
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleAssign} disabled={!assigneeId}>
|
||||
{t('tasks.assign')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Subtasks */}
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">{t('tasks.subtasks')}</h4>
|
||||
<div className="space-y-1">
|
||||
{(subtasks ?? []).map((st) => (
|
||||
<div key={st.id} className="flex items-center justify-between rounded border border-gray-200 px-3 py-2">
|
||||
<span className="text-sm text-gray-800">{st.title}</span>
|
||||
<Badge variant={STATUS_VARIANTS[st.status]}>{statusLabel(t, st.status)}</Badge>
|
||||
</div>
|
||||
))}
|
||||
{subtasks?.length === 0 ? <p className="text-xs text-gray-400">{t('tasks.noSubtasks')}</p> : null}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
value={subtaskTitle}
|
||||
onChange={(e) => setSubtaskTitle(e.target.value)}
|
||||
placeholder={t('tasks.subtaskPlaceholder')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleCreateSubtask} disabled={!subtaskTitle.trim()}>
|
||||
<Plus className="mr-1 h-4 w-4" aria-hidden="true" />
|
||||
{t('tasks.addSubtask')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1 text-sm font-semibold text-gray-700">
|
||||
<Link2 className="h-4 w-4" aria-hidden="true" />
|
||||
{t('tasks.dependencies')}
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{task.depends_on.map((depId) => (
|
||||
<div key={depId} className="flex items-center justify-between rounded border border-gray-200 px-3 py-2">
|
||||
<span className="text-sm text-gray-800">{depId}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
removeDepMutation.mutate(
|
||||
{ id: task.id, dependsOn: depId },
|
||||
{ onSuccess: () => toast.success(t('tasks.updated')), onError: () => toast.error(t('common.error')) },
|
||||
)
|
||||
}
|
||||
aria-label={t('tasks.removeDependency')}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{task.depends_on.length === 0 ? <p className="text-xs text-gray-400">{t('tasks.noDependencies')}</p> : null}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
value={depId}
|
||||
onChange={(e) => setDepId(e.target.value)}
|
||||
placeholder={t('tasks.dependencyPlaceholder')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleAddDependency} disabled={!depId.trim()}>
|
||||
{t('tasks.addDependency')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default TaskDetail;
|
||||
@@ -1083,11 +1083,39 @@
|
||||
"priority_field": "Priorität",
|
||||
"statusOpen": "Offen",
|
||||
"statusInProgress": "In Bearbeitung",
|
||||
"statusReview": "Review",
|
||||
"statusBlocked": "Blockiert",
|
||||
"statusDone": "Erledigt",
|
||||
"statusCancelled": "Abgebrochen",
|
||||
"priorityLow": "Niedrig",
|
||||
"priorityMedium": "Mittel",
|
||||
"priorityHigh": "Hoch",
|
||||
"priorityUrgent": "Dringend"
|
||||
"priorityUrgent": "Dringend",
|
||||
"typeTodo": "Aufgabe",
|
||||
"typeApproval": "Freigabe",
|
||||
"typeFollowUp": "Follow-up",
|
||||
"typeReview": "Review",
|
||||
"typeGoal": "Ziel",
|
||||
"typeMilestone": "Meilenstein",
|
||||
"typeAgentSubtask": "Agent-Subtask",
|
||||
"assigneeType": "Zuweisungstyp",
|
||||
"assigneeId": "Zuweisungs-ID",
|
||||
"assigneeTypeUser": "Benutzer",
|
||||
"assigneeTypeAgent": "Agent",
|
||||
"assigneeTypeGroup": "Gruppe",
|
||||
"assign": "Zuweisen",
|
||||
"subtasks": "Unteraufgaben",
|
||||
"noSubtasks": "Keine Unteraufgaben",
|
||||
"subtaskPlaceholder": "Unteraufgaben-Titel...",
|
||||
"addSubtask": "Unteraufgabe hinzufügen",
|
||||
"dependencies": "Abhängigkeiten",
|
||||
"noDependencies": "Keine Abhängigkeiten",
|
||||
"dependencyPlaceholder": "Task-ID...",
|
||||
"addDependency": "Abhängigkeit hinzufügen",
|
||||
"removeDependency": "Abhängigkeit entfernen",
|
||||
"targetDate": "Zieldatum",
|
||||
"progress": "Fortschritt",
|
||||
"milestones": "Meilensteine"
|
||||
},
|
||||
"savedFilters": {
|
||||
"save": "Filter speichern",
|
||||
|
||||
@@ -1083,11 +1083,39 @@
|
||||
"priority_field": "Priority",
|
||||
"statusOpen": "Open",
|
||||
"statusInProgress": "In Progress",
|
||||
"statusReview": "Review",
|
||||
"statusBlocked": "Blocked",
|
||||
"statusDone": "Done",
|
||||
"statusCancelled": "Cancelled",
|
||||
"priorityLow": "Low",
|
||||
"priorityMedium": "Medium",
|
||||
"priorityHigh": "High",
|
||||
"priorityUrgent": "Urgent"
|
||||
"priorityUrgent": "Urgent",
|
||||
"typeTodo": "Task",
|
||||
"typeApproval": "Approval",
|
||||
"typeFollowUp": "Follow-up",
|
||||
"typeReview": "Review",
|
||||
"typeGoal": "Goal",
|
||||
"typeMilestone": "Milestone",
|
||||
"typeAgentSubtask": "Agent Subtask",
|
||||
"assigneeType": "Assignee Type",
|
||||
"assigneeId": "Assignee ID",
|
||||
"assigneeTypeUser": "User",
|
||||
"assigneeTypeAgent": "Agent",
|
||||
"assigneeTypeGroup": "Group",
|
||||
"assign": "Assign",
|
||||
"subtasks": "Subtasks",
|
||||
"noSubtasks": "No subtasks",
|
||||
"subtaskPlaceholder": "Subtask title...",
|
||||
"addSubtask": "Add Subtask",
|
||||
"dependencies": "Dependencies",
|
||||
"noDependencies": "No dependencies",
|
||||
"dependencyPlaceholder": "Task ID...",
|
||||
"addDependency": "Add Dependency",
|
||||
"removeDependency": "Remove Dependency",
|
||||
"targetDate": "Target Date",
|
||||
"progress": "Progress",
|
||||
"milestones": "Milestones"
|
||||
},
|
||||
"savedFilters": {
|
||||
"save": "Save Filter",
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Unified Task System (F.14) tests.
|
||||
|
||||
Covers polymorphic assignment, entity links, subtasks, agent task creation,
|
||||
goal decomposition, progress aggregation, success criteria evaluation and
|
||||
migration of legacy fields.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestPolymorphicAssignment:
|
||||
"""Polymorphic assignee (user/agent/group)."""
|
||||
|
||||
async def test_create_task_with_agent_assignee(self, tasks_client: AsyncClient, db_session):
|
||||
"""POST /tasks with assignee_type=agent stores assignee_id."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
agent_id = str(uuid.uuid4())
|
||||
resp = await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={
|
||||
"title": "Agent task",
|
||||
"assignee_type": "agent",
|
||||
"assignee_id": agent_id,
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["assignee_type"] == "agent"
|
||||
assert data["assignee_id"] == agent_id
|
||||
|
||||
async def test_create_task_with_group_assignee(self, tasks_client: AsyncClient, db_session):
|
||||
"""POST /tasks with assignee_type=group stores assignee_id."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
group_id = str(uuid.uuid4())
|
||||
resp = await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={
|
||||
"title": "Group task",
|
||||
"assignee_type": "group",
|
||||
"assignee_id": group_id,
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["assignee_type"] == "group"
|
||||
assert data["assignee_id"] == group_id
|
||||
|
||||
async def test_assign_task_polymorphic(self, tasks_client: AsyncClient, db_session):
|
||||
"""POST /tasks/{id}/assign with assignee_type=agent."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
created = await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Assign me"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
task_id = created.json()["id"]
|
||||
agent_id = str(uuid.uuid4())
|
||||
resp = await tasks_client.post(
|
||||
f"/api/v1/tasks/{task_id}/assign",
|
||||
json={"assignee_type": "agent", "assignee_id": agent_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["assignee_type"] == "agent"
|
||||
assert data["assignee_id"] == agent_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestEntityLinks:
|
||||
"""Polymorphic entity links (entity_type + entity_id)."""
|
||||
|
||||
async def test_create_task_with_entity_link(self, tasks_client: AsyncClient, db_session):
|
||||
"""POST /tasks with entity_type=company stores entity_id."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
company_id = str(uuid.uuid4())
|
||||
resp = await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={
|
||||
"title": "Company task",
|
||||
"entity_type": "company",
|
||||
"entity_id": company_id,
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["entity_type"] == "company"
|
||||
assert data["entity_id"] == company_id
|
||||
|
||||
async def test_filter_tasks_by_entity(self, tasks_client: AsyncClient, db_session):
|
||||
"""GET /tasks?entity_type=&entity_id= filters by entity."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
company_id = str(uuid.uuid4())
|
||||
await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Company task", "entity_type": "company", "entity_id": company_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Other task"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
resp = await tasks_client.get(
|
||||
f"/api/v1/tasks?entity_type=company&entity_id={company_id}",
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["entity_type"] == "company"
|
||||
assert items[0]["entity_id"] == company_id
|
||||
|
||||
async def test_legacy_contact_id_mirrors_entity(self, tasks_client: AsyncClient, db_session):
|
||||
"""POST /tasks with contact_id sets entity_type='contact'."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
contact_id = str(uuid.uuid4())
|
||||
resp = await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Contact task", "contact_id": contact_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["contact_id"] == contact_id
|
||||
assert data["entity_type"] == "contact"
|
||||
assert data["entity_id"] == contact_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSubtasks:
|
||||
"""Subtasks (parent_task_id self-reference)."""
|
||||
|
||||
async def test_create_subtask(self, tasks_client: AsyncClient, db_session):
|
||||
"""POST /tasks/{id}/subtasks creates a subtask."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
parent = await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Parent"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
parent_id = parent.json()["id"]
|
||||
resp = await tasks_client.post(
|
||||
f"/api/v1/tasks/{parent_id}/subtasks",
|
||||
json={"title": "Child"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["parent_task_id"] == parent_id
|
||||
|
||||
async def test_list_subtasks(self, tasks_client: AsyncClient, db_session):
|
||||
"""GET /tasks/{id}/subtasks lists children."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
parent = await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Parent"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
parent_id = parent.json()["id"]
|
||||
await tasks_client.post(
|
||||
f"/api/v1/tasks/{parent_id}/subtasks",
|
||||
json={"title": "Child 1"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
await tasks_client.post(
|
||||
f"/api/v1/tasks/{parent_id}/subtasks",
|
||||
json={"title": "Child 2"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
resp = await tasks_client.get(
|
||||
f"/api/v1/tasks/{parent_id}/subtasks",
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDependencies:
|
||||
"""Task dependencies (depends_on)."""
|
||||
|
||||
async def test_add_and_remove_dependency(self, tasks_client: AsyncClient, db_session):
|
||||
"""POST/DELETE /tasks/{id}/dependencies."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
t1 = await tasks_client.post("/api/v1/tasks", json={"title": "Task 1"}, headers=ORIGIN_HEADER)
|
||||
t2 = await tasks_client.post("/api/v1/tasks", json={"title": "Task 2"}, headers=ORIGIN_HEADER)
|
||||
t1_id, t2_id = t1.json()["id"], t2.json()["id"]
|
||||
resp = await tasks_client.post(
|
||||
f"/api/v1/tasks/{t1_id}/dependencies",
|
||||
json={"depends_on": t2_id},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert t2_id in resp.json()["depends_on"]
|
||||
resp = await tasks_client.delete(
|
||||
f"/api/v1/tasks/{t1_id}/dependencies/{t2_id}",
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert t2_id not in resp.json()["depends_on"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentTaskCreation:
|
||||
"""Agent task creation (task_type='agent_subtask')."""
|
||||
|
||||
async def test_create_agent_subtask(self, tasks_client: AsyncClient, db_session):
|
||||
"""POST /tasks with task_type=agent_subtask."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
resp = await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={
|
||||
"title": "Agent subtask",
|
||||
"task_type": "agent_subtask",
|
||||
"assignee_type": "agent",
|
||||
"assignee_id": str(uuid.uuid4()),
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["task_type"] == "agent_subtask"
|
||||
assert data["assignee_type"] == "agent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGoalDecomposition:
|
||||
"""Goal decomposition into milestones/todos."""
|
||||
|
||||
async def test_decompose_goal(self, tasks_client: AsyncClient, db_session):
|
||||
"""POST /tasks/{id}/decompose creates subtasks."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
goal = await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Big Goal", "task_type": "goal"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
goal_id = goal.json()["id"]
|
||||
resp = await tasks_client.post(
|
||||
f"/api/v1/tasks/{goal_id}/decompose",
|
||||
json=[
|
||||
{"title": "Milestone 1", "milestone": True},
|
||||
{"title": "Todo 1"},
|
||||
],
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["goal"]["task_type"] == "goal"
|
||||
assert len(data["subtasks"]) == 2
|
||||
types = {s["task_type"] for s in data["subtasks"]}
|
||||
assert "milestone" in types
|
||||
assert "todo" in types
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestProgressAggregation:
|
||||
"""Parent progress aggregated from child task status."""
|
||||
|
||||
async def test_progress_aggregates_from_children(self, tasks_client: AsyncClient, db_session):
|
||||
"""Parent progress = % of done children."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
parent = await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Parent", "task_type": "goal"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
parent_id = parent.json()["id"]
|
||||
c1 = await tasks_client.post(
|
||||
f"/api/v1/tasks/{parent_id}/subtasks",
|
||||
json={"title": "Child 1"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
c2 = await tasks_client.post(
|
||||
f"/api/v1/tasks/{parent_id}/subtasks",
|
||||
json={"title": "Child 2"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
# Mark one child done → parent progress 50%
|
||||
await tasks_client.post(
|
||||
f"/api/v1/tasks/{c1.json()['id']}/status",
|
||||
json={"status": "done"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
parent_resp = await tasks_client.get(f"/api/v1/tasks/{parent_id}", headers=ORIGIN_HEADER)
|
||||
assert parent_resp.json()["progress"] == 50
|
||||
# Mark second child done → parent progress 100%
|
||||
await tasks_client.post(
|
||||
f"/api/v1/tasks/{c2.json()['id']}/status",
|
||||
json={"status": "done"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
parent_resp = await tasks_client.get(f"/api/v1/tasks/{parent_id}", headers=ORIGIN_HEADER)
|
||||
assert parent_resp.json()["progress"] == 100
|
||||
|
||||
async def test_parent_status_propagates_to_review(self, tasks_client: AsyncClient, db_session):
|
||||
"""All children done → parent auto review."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
parent = await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Parent", "task_type": "goal"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
parent_id = parent.json()["id"]
|
||||
c1 = await tasks_client.post(
|
||||
f"/api/v1/tasks/{parent_id}/subtasks",
|
||||
json={"title": "Child 1"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
await tasks_client.post(
|
||||
f"/api/v1/tasks/{c1.json()['id']}/status",
|
||||
json={"status": "done"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
parent_resp = await tasks_client.get(f"/api/v1/tasks/{parent_id}", headers=ORIGIN_HEADER)
|
||||
assert parent_resp.json()["status"] == "review"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSuccessCriteria:
|
||||
"""Success criteria evaluation for goals."""
|
||||
|
||||
async def test_goal_done_when_criteria_met(self, tasks_client: AsyncClient, db_session):
|
||||
"""Goal with all_done criteria becomes done at 100% progress."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
goal = await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={
|
||||
"title": "Goal",
|
||||
"task_type": "goal",
|
||||
"success_criteria": {"all_done": True},
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
goal_id = goal.json()["id"]
|
||||
c1 = await tasks_client.post(
|
||||
f"/api/v1/tasks/{goal_id}/subtasks",
|
||||
json={"title": "Child 1"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
await tasks_client.post(
|
||||
f"/api/v1/tasks/{c1.json()['id']}/status",
|
||||
json={"status": "done"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
goal_resp = await tasks_client.get(f"/api/v1/tasks/{goal_id}", headers=ORIGIN_HEADER)
|
||||
assert goal_resp.json()["status"] == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestLifecycleStatuses:
|
||||
"""New lifecycle statuses."""
|
||||
|
||||
async def test_all_statuses_accepted(self, tasks_client: AsyncClient, db_session):
|
||||
"""All lifecycle statuses are accepted by the API."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
task = await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Status task"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
task_id = task.json()["id"]
|
||||
for status in ["open", "in_progress", "review", "blocked", "done", "cancelled"]:
|
||||
resp = await tasks_client.post(
|
||||
f"/api/v1/tasks/{task_id}/status",
|
||||
json={"status": status},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == status
|
||||
|
||||
async def test_invalid_status_rejected(self, tasks_client: AsyncClient, db_session):
|
||||
"""Invalid status returns 422."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(tasks_client, "admin@tenanta.com")
|
||||
task = await tasks_client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "Status task"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
task_id = task.json()["id"]
|
||||
resp = await tasks_client.post(
|
||||
f"/api/v1/tasks/{task_id}/status",
|
||||
json={"status": "invalid"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
Reference in New Issue
Block a user