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,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;
|
||||
Reference in New Issue
Block a user