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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
|
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 {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
status: 'open' | 'in_progress' | 'done';
|
status: TaskStatus;
|
||||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||||
due_date: string | null;
|
due_date: string | null;
|
||||||
assigned_to: string | null;
|
assigned_to: string | null;
|
||||||
contact_id: 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_by: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -29,28 +46,58 @@ export interface TaskListResponse {
|
|||||||
export interface TaskCreateInput {
|
export interface TaskCreateInput {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
status?: string;
|
status?: TaskStatus;
|
||||||
priority?: string;
|
priority?: 'low' | 'medium' | 'high' | 'urgent';
|
||||||
due_date?: string | null;
|
due_date?: string | null;
|
||||||
assigned_to?: string | null;
|
assigned_to?: string | null;
|
||||||
contact_id?: 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 {
|
export interface TaskUpdateInput {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
status?: string;
|
status?: TaskStatus;
|
||||||
priority?: string;
|
priority?: 'low' | 'medium' | 'high' | 'urgent';
|
||||||
due_date?: string | null;
|
due_date?: string | null;
|
||||||
assigned_to?: string | null;
|
assigned_to?: string | null;
|
||||||
contact_id?: 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 {
|
export interface TaskFilter {
|
||||||
status?: string;
|
status?: TaskStatus;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
assigned_to?: string;
|
assigned_to?: string;
|
||||||
contact_id?: 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;
|
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?.priority) params.set('priority', filter.priority);
|
||||||
if (filter?.assigned_to) params.set('assigned_to', filter.assigned_to);
|
if (filter?.assigned_to) params.set('assigned_to', filter.assigned_to);
|
||||||
if (filter?.contact_id) params.set('contact_id', filter.contact_id);
|
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);
|
if (filter?.search) params.set('search', filter.search);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['tasks', page, pageSize, filter],
|
queryKey: ['tasks', page, pageSize, filter],
|
||||||
@@ -110,8 +163,8 @@ export function useDeleteTask() {
|
|||||||
export function useAssignTask() {
|
export function useAssignTask() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, assignedTo }: { id: string; assignedTo: string }) =>
|
mutationFn: ({ id, assigneeType, assigneeId }: { id: string; assigneeType: AssigneeType; assigneeId: string }) =>
|
||||||
apiPost<Task>(`/tasks/${id}/assign`, { assigned_to: assignedTo }),
|
apiPost<Task>(`/tasks/${id}/assign`, { assignee_type: assigneeType, assignee_id: assigneeId }),
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['tasks', variables.id] });
|
queryClient.invalidateQueries({ queryKey: ['tasks', variables.id] });
|
||||||
@@ -122,7 +175,7 @@ export function useAssignTask() {
|
|||||||
export function useUpdateTaskStatus() {
|
export function useUpdateTaskStatus() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
mutationFn: ({ id, status }: { id: string; status: TaskStatus }) =>
|
||||||
apiPost<Task>(`/tasks/${id}/status`, { status }),
|
apiPost<Task>(`/tasks/${id}/status`, { status }),
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
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",
|
"priority_field": "Priorität",
|
||||||
"statusOpen": "Offen",
|
"statusOpen": "Offen",
|
||||||
"statusInProgress": "In Bearbeitung",
|
"statusInProgress": "In Bearbeitung",
|
||||||
|
"statusReview": "Review",
|
||||||
|
"statusBlocked": "Blockiert",
|
||||||
"statusDone": "Erledigt",
|
"statusDone": "Erledigt",
|
||||||
|
"statusCancelled": "Abgebrochen",
|
||||||
"priorityLow": "Niedrig",
|
"priorityLow": "Niedrig",
|
||||||
"priorityMedium": "Mittel",
|
"priorityMedium": "Mittel",
|
||||||
"priorityHigh": "Hoch",
|
"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": {
|
"savedFilters": {
|
||||||
"save": "Filter speichern",
|
"save": "Filter speichern",
|
||||||
|
|||||||
@@ -1083,11 +1083,39 @@
|
|||||||
"priority_field": "Priority",
|
"priority_field": "Priority",
|
||||||
"statusOpen": "Open",
|
"statusOpen": "Open",
|
||||||
"statusInProgress": "In Progress",
|
"statusInProgress": "In Progress",
|
||||||
|
"statusReview": "Review",
|
||||||
|
"statusBlocked": "Blocked",
|
||||||
"statusDone": "Done",
|
"statusDone": "Done",
|
||||||
|
"statusCancelled": "Cancelled",
|
||||||
"priorityLow": "Low",
|
"priorityLow": "Low",
|
||||||
"priorityMedium": "Medium",
|
"priorityMedium": "Medium",
|
||||||
"priorityHigh": "High",
|
"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": {
|
"savedFilters": {
|
||||||
"save": "Save Filter",
|
"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