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:
parent
a9da3672b9
commit
be3007f40a
@ -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>
|
||||||
|
|||||||
@ -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" });
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user