Task dependencies UI in TaskModal + API

- Dependencies section shows links with colored labels
- Add dependency form (type + task ID)
- Delete link button
- API: getTaskLinks, createTaskLink, deleteTaskLink
This commit is contained in:
Markov 2026-02-27 23:34:07 +01:00
parent a9da3672b9
commit be3007f40a
2 changed files with 91 additions and 2 deletions

View File

@ -1,10 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { Task, Member, Step, Message } from "@/lib/api"; import type { Task, Member, Step, Message, TaskLink } from "@/lib/api";
import { import {
updateTask, deleteTask, getMembers, updateTask, deleteTask, getMembers,
getSteps, createStep, updateStep, deleteStep as _deleteStepApi, getSteps, createStep, updateStep, deleteStep as _deleteStepApi,
getMessages, sendMessage, getMessages, sendMessage,
getTaskLinks, createTaskLink, deleteTaskLink,
} from "@/lib/api"; } from "@/lib/api";
const STATUSES = [ const STATUSES = [
@ -45,6 +46,10 @@ export default function TaskModal({ task, projectId: _projectId, projectSlug: _p
const [assigneeId, setAssigneeId] = useState(task.assignee_id || ""); const [assigneeId, setAssigneeId] = useState(task.assignee_id || "");
const [members, setMembers] = useState<Member[]>([]); const [members, setMembers] = useState<Member[]>([]);
const [steps, setSteps] = useState<Step[]>(task.steps || []); const [steps, setSteps] = useState<Step[]>(task.steps || []);
const [links, setLinks] = useState<TaskLink[]>([]);
const [showAddLink, setShowAddLink] = useState(false);
const [linkTargetId, setLinkTargetId] = useState("");
const [linkType, setLinkType] = useState("depends_on");
const [comments, setComments] = useState<Message[]>([]); const [comments, setComments] = useState<Message[]>([]);
const [_saving, setSaving] = useState(false); const [_saving, setSaving] = useState(false);
const [editingDesc, setEditingDesc] = useState(false); const [editingDesc, setEditingDesc] = useState(false);
@ -56,6 +61,7 @@ export default function TaskModal({ task, projectId: _projectId, projectSlug: _p
useEffect(() => { useEffect(() => {
getMembers().then(setMembers).catch(() => {}); getMembers().then(setMembers).catch(() => {});
getSteps(task.id).then(setSteps).catch(() => {}); getSteps(task.id).then(setSteps).catch(() => {});
getTaskLinks(task.id).then(setLinks).catch(() => {});
getMessages({ task_id: task.id }).then(setComments).catch(() => {}); getMessages({ task_id: task.id }).then(setComments).catch(() => {});
}, [task.id]); }, [task.id]);
@ -233,6 +239,68 @@ export default function TaskModal({ task, projectId: _projectId, projectSlug: _p
</div> </div>
</div> </div>
{/* Dependencies */}
{(links.length > 0 || showAddLink) && (
<div>
<div className="text-sm text-[var(--muted)] mb-2">Зависимости</div>
<div className="space-y-1 mb-2">
{links.map((link) => {
const label = link.link_type === "blocks" ? "🔴 Блокирует" :
link.link_type === "blocked_by" ? "🟡 Заблокировано" :
link.link_type === "depends_on" ? "🟡 Зависит от" :
link.link_type === "required_by" ? "🔵 Требуется для" :
"🔗 Связано с";
const title = link.target_title || link.source_title || "...";
return (
<div key={link.id} className="flex items-center justify-between text-xs p-1.5 bg-[var(--bg)] border border-[var(--border)] rounded">
<span>{label}: <span className="font-medium">{title}</span></span>
<button
onClick={async () => {
await deleteTaskLink(task.id, link.id);
setLinks(links.filter(l => l.id !== link.id));
}}
className="text-red-400 hover:text-red-300 ml-2"
>×</button>
</div>
);
})}
</div>
{showAddLink && (
<div className="flex gap-1 mb-2">
<select value={linkType} onChange={e => setLinkType(e.target.value)}
className="px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded text-xs">
<option value="depends_on">Зависит от</option>
<option value="blocks">Блокирует</option>
<option value="relates_to">Связано с</option>
</select>
<input
value={linkTargetId}
onChange={e => setLinkTargetId(e.target.value)}
placeholder="ID задачи..."
className="flex-1 px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded text-xs outline-none"
/>
<button
onClick={async () => {
if (!linkTargetId) return;
const link = await createTaskLink(task.id, linkTargetId, linkType);
setLinks([...links, link]);
setLinkTargetId("");
setShowAddLink(false);
}}
className="px-2 py-1 bg-[var(--accent)] text-white rounded text-xs"
>+</button>
</div>
)}
</div>
)}
<button
onClick={() => setShowAddLink(!showAddLink)}
className="text-xs text-[var(--accent)] hover:underline mb-2"
>
{showAddLink ? "Скрыть" : "+ Добавить зависимость"}
</button>
{/* Comments */} {/* Comments */}
<div> <div>
<div className="text-sm text-[var(--muted)] mb-2">Комментарии</div> <div className="text-sm text-[var(--muted)] mb-2">Комментарии</div>

View File

@ -427,3 +427,24 @@ export async function addProjectMember(projectId: string, memberId: string): Pro
export async function removeProjectMember(projectId: string, memberId: string): Promise<{ ok: boolean }> { export async function removeProjectMember(projectId: string, memberId: string): Promise<{ ok: boolean }> {
return request(`/api/v1/projects/${projectId}/members/${memberId}`, { method: "DELETE" }); return request(`/api/v1/projects/${projectId}/members/${memberId}`, { method: "DELETE" });
} }
// --- Task Links ---
export interface TaskLink {
id: string;
source_id: string;
target_id: string;
link_type: string;
target_title?: string;
source_title?: string;
}
export async function getTaskLinks(taskId: string): Promise<TaskLink[]> {
return request(`/tasks/${taskId}/links`);
}
export async function createTaskLink(taskId: string, targetId: string, linkType: string): Promise<TaskLink> {
return request(`/tasks/${taskId}/links`, { method: "POST", body: JSON.stringify({ target_id: targetId, link_type: linkType }) });
}
export async function deleteTaskLink(taskId: string, linkId: string): Promise<void> {
return request(`/tasks/${taskId}/links/${linkId}`, { method: "DELETE" });
}