568 lines
25 KiB
TypeScript
568 lines
25 KiB
TypeScript
|
||
import { useEffect, useState } from "react";
|
||
import type { Task, Member, Step, Event, TaskLink } from "@/lib/api";
|
||
import CreateTaskModal from "@/components/CreateTaskModal";
|
||
import {
|
||
updateTask, deleteTask, getMembers,
|
||
getSteps, createStep, updateStep, deleteStep as _deleteStepApi,
|
||
getEvents, sendEvent,
|
||
getTaskLinks, createTaskLink, deleteTaskLink,
|
||
searchTasks,
|
||
} from "@/lib/api";
|
||
|
||
const STATUSES = [
|
||
{ key: "backlog", label: "Backlog", color: "#737373" },
|
||
{ key: "todo", label: "TODO", color: "#3b82f6" },
|
||
{ key: "in_progress", label: "In Progress", color: "#f59e0b" },
|
||
{ key: "in_review", label: "Review", color: "#a855f7" },
|
||
{ key: "done", label: "Done", color: "#22c55e" },
|
||
];
|
||
|
||
const PRIORITIES = [
|
||
{ key: "low", label: "Low", color: "#737373" },
|
||
{ key: "medium", label: "Medium", color: "#3b82f6" },
|
||
{ key: "high", label: "High", color: "#f59e0b" },
|
||
{ key: "critical", label: "Critical", color: "#ef4444" },
|
||
];
|
||
|
||
const AUTHOR_ICON: Record<string, string> = {
|
||
human: "👤",
|
||
agent: "🤖",
|
||
system: "⚙️",
|
||
};
|
||
|
||
interface Props {
|
||
task: Task;
|
||
projectId: string;
|
||
projectSlug: string;
|
||
onClose: () => void;
|
||
onUpdated: (task: Task) => void;
|
||
onDeleted: (taskId: string) => void;
|
||
onOpenTask?: (taskId: string) => void;
|
||
}
|
||
|
||
export default function TaskModal({ task, projectId: _projectId, projectSlug: _projectSlug, onClose, onUpdated, onDeleted, onOpenTask }: Props) {
|
||
const [title, setTitle] = useState(task.title);
|
||
const [description, setDescription] = useState(task.description || "");
|
||
const [status, setStatus] = useState(task.status);
|
||
const [priority, setPriority] = useState(task.priority);
|
||
const [assigneeId, setAssigneeId] = useState(task.assignee_id || "");
|
||
const [members, setMembers] = useState<Member[]>([]);
|
||
const [steps, setSteps] = useState<Step[]>(task.steps || []);
|
||
const [links, setLinks] = useState<TaskLink[]>([]);
|
||
const [showAddLink, setShowAddLink] = useState(false);
|
||
const [linkTargetId, setLinkTargetId] = useState("");
|
||
const [linkSearch, setLinkSearch] = useState("");
|
||
const [linkSearchResults, setLinkSearchResults] = useState<Task[]>([]);
|
||
const [linkType, setLinkType] = useState("depends_on");
|
||
const [comments, setComments] = useState<Event[]>([]);
|
||
const [_saving, setSaving] = useState(false);
|
||
const [editingDesc, setEditingDesc] = useState(false);
|
||
const [editingTitle, setEditingTitle] = useState(false);
|
||
const [showCreateSubtask, setShowCreateSubtask] = useState(false);
|
||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||
const [newStep, setNewStep] = useState("");
|
||
const [newComment, setNewComment] = useState("");
|
||
|
||
useEffect(() => {
|
||
getMembers().then(setMembers).catch(() => {});
|
||
getSteps(task.id).then(setSteps).catch(() => {});
|
||
getTaskLinks(task.id).then(setLinks).catch(() => {});
|
||
getEvents({ task_id: task.id }).then(setComments).catch(() => {});
|
||
}, [task.id]);
|
||
|
||
const save = async (patch: Partial<Task>) => {
|
||
setSaving(true);
|
||
try {
|
||
const updated = await updateTask(task.id, patch);
|
||
onUpdated(updated);
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
try {
|
||
await deleteTask(task.id);
|
||
onDeleted(task.id);
|
||
onClose();
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
};
|
||
|
||
const handleAddStep = async () => {
|
||
if (!newStep.trim()) return;
|
||
try {
|
||
const step = await createStep(task.id, newStep.trim());
|
||
setSteps((prev) => [...prev, step]);
|
||
setNewStep("");
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
};
|
||
|
||
const handleToggleStep = async (step: Step) => {
|
||
try {
|
||
const updated = await updateStep(task.id, step.id, { done: !step.done });
|
||
setSteps((prev) => prev.map((s) => (s.id === step.id ? updated : s)));
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
};
|
||
|
||
const handleAddComment = async () => {
|
||
if (!newComment.trim()) return;
|
||
try {
|
||
const event = await sendEvent({
|
||
type: "task_comment",
|
||
task_id: task.id,
|
||
payload: { content: newComment.trim() }
|
||
});
|
||
setComments((prev) => [...prev, event]);
|
||
setNewComment("");
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 bg-black/60 flex items-start justify-center z-50 overflow-y-auto py-8 px-4"
|
||
onClick={onClose}
|
||
>
|
||
<div
|
||
className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Header */}
|
||
<div className="p-4 md:p-6 border-b border-[var(--border)]">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<div className="text-xs text-[var(--muted)]">
|
||
{task.parent_key && task.parent_id ? (
|
||
<span>
|
||
<button
|
||
onClick={() => { onClose(); setTimeout(() => onOpenTask?.(task.parent_id!), 100); }}
|
||
className="text-[var(--accent)] hover:underline"
|
||
>{task.parent_key}</button>
|
||
<span className="mx-1">/</span>
|
||
</span>
|
||
) : null}
|
||
{task.key}
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-[var(--muted)] hover:text-[var(--fg)] transition-colors text-xl leading-none cursor-pointer shrink-0"
|
||
title="Закрыть"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="flex items-start gap-2">
|
||
{editingTitle ? (
|
||
<input
|
||
autoFocus
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
onBlur={() => {
|
||
setEditingTitle(false);
|
||
if (title.trim() && title !== task.title) save({ title: title.trim() });
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||
if (e.key === "Escape") { setTitle(task.title); setEditingTitle(false); }
|
||
}}
|
||
className="w-full text-xl font-bold bg-transparent border-b border-[var(--accent)] outline-none pb-1"
|
||
/>
|
||
) : (
|
||
<h2
|
||
className="text-xl font-bold cursor-pointer hover:text-[var(--accent)] transition-colors"
|
||
onClick={() => setEditingTitle(true)}
|
||
>
|
||
{title}
|
||
</h2>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col md:flex-row">
|
||
{/* Left: Description + Steps + Comments */}
|
||
<div className="flex-1 p-4 md:p-6 border-b md:border-b-0 md:border-r border-[var(--border)] space-y-6">
|
||
{/* Description */}
|
||
<div>
|
||
<div className="text-sm text-[var(--muted)] mb-2 flex items-center justify-between">
|
||
<span>Описание</span>
|
||
{!editingDesc && (
|
||
<button onClick={() => setEditingDesc(true)} className="text-xs text-[var(--accent)] hover:underline">
|
||
Редактировать
|
||
</button>
|
||
)}
|
||
</div>
|
||
{editingDesc ? (
|
||
<div>
|
||
<textarea
|
||
autoFocus
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--accent)] resize-y min-h-[120px]"
|
||
rows={6}
|
||
placeholder="Описание задачи (Markdown)..."
|
||
/>
|
||
<div className="flex gap-2 mt-2">
|
||
<button
|
||
onClick={() => { setEditingDesc(false); save({ description: description.trim() || undefined }); }}
|
||
className="px-3 py-1 bg-[var(--accent)] text-white rounded text-xs"
|
||
>
|
||
Сохранить
|
||
</button>
|
||
<button
|
||
onClick={() => { setDescription(task.description || ""); setEditingDesc(false); }}
|
||
className="px-3 py-1 text-[var(--muted)] text-xs"
|
||
>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : description ? (
|
||
<div className="text-sm whitespace-pre-wrap">{description}</div>
|
||
) : (
|
||
<div className="text-sm text-[var(--muted)] italic">Нет описания</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Steps */}
|
||
<div>
|
||
<div className="text-sm text-[var(--muted)] mb-2">
|
||
Этапы {steps.length > 0 && `(${steps.filter((s) => s.done).length}/${steps.length})`}
|
||
</div>
|
||
<div className="space-y-1">
|
||
{steps.map((step) => (
|
||
<label key={step.id} className="flex items-center gap-2 text-sm cursor-pointer group">
|
||
<input
|
||
type="checkbox"
|
||
checked={step.done}
|
||
onChange={() => handleToggleStep(step)}
|
||
className="accent-[var(--accent)]"
|
||
/>
|
||
<span className={step.done ? "line-through text-[var(--muted)]" : ""}>{step.title}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
<div className="flex gap-2 mt-2">
|
||
<input
|
||
value={newStep}
|
||
onChange={(e) => setNewStep(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && handleAddStep()}
|
||
placeholder="Новый этап..."
|
||
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1 text-xs outline-none focus:border-[var(--accent)]"
|
||
/>
|
||
<button onClick={handleAddStep} className="text-xs text-[var(--accent)]">+</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Subtasks */}
|
||
<div>
|
||
<div className="text-sm text-[var(--muted)] mb-2 flex items-center justify-between">
|
||
<span>Подзадачи{task.subtasks?.length ? ` (${task.subtasks.length})` : ""}</span>
|
||
<button
|
||
onClick={() => setShowCreateSubtask(true)}
|
||
className="text-xs text-[var(--accent)] hover:underline"
|
||
>+ Подзадача</button>
|
||
</div>
|
||
{task.subtasks && task.subtasks.length > 0 && (
|
||
<div className="space-y-1">
|
||
{task.subtasks.map((sub) => (
|
||
<div key={sub.id} className="flex items-center justify-between p-1.5 bg-[var(--bg)] border border-[var(--border)] rounded text-xs">
|
||
<div className="flex items-center gap-2">
|
||
<span className={`w-2 h-2 rounded-full ${
|
||
sub.status === "done" ? "bg-green-500" :
|
||
sub.status === "in_progress" ? "bg-blue-500" :
|
||
sub.status === "in_review" ? "bg-yellow-500" : "bg-gray-500"
|
||
}`} />
|
||
<button
|
||
onClick={() => { onClose(); setTimeout(() => onOpenTask?.(sub.id), 100); }}
|
||
className="text-[var(--accent)] hover:underline font-mono"
|
||
>{sub.key}</button>
|
||
<span>{sub.title}</span>
|
||
</div>
|
||
{sub.assignee && (
|
||
<span className="text-[var(--muted)]">→ {sub.assignee.slug}</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{showCreateSubtask && (
|
||
<CreateTaskModal
|
||
projectId={task.project_id}
|
||
initialStatus="todo"
|
||
parentId={task.id}
|
||
parentKey={task.key}
|
||
onClose={() => setShowCreateSubtask(false)}
|
||
onCreated={(newTask) => {
|
||
// Refresh parent task to show new subtask
|
||
onUpdated({ ...task, subtasks: [...(task.subtasks || []), { id: newTask.id, key: newTask.key, title: newTask.title, status: newTask.status, assignee: newTask.assignee }] });
|
||
setShowCreateSubtask(false);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 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>
|
||
<div className="flex-1 relative">
|
||
<input
|
||
value={linkSearch}
|
||
onChange={async (e) => {
|
||
const v = e.target.value;
|
||
setLinkSearch(v);
|
||
if (v.length >= 1) {
|
||
const results = await searchTasks(task.project_id, v);
|
||
setLinkSearchResults(results.filter(t => t.id !== task.id));
|
||
} else {
|
||
setLinkSearchResults([]);
|
||
}
|
||
}}
|
||
placeholder="Поиск по номеру или названию..."
|
||
className="w-full px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded text-xs outline-none"
|
||
/>
|
||
{linkSearchResults.length > 0 && (
|
||
<div className="absolute top-full left-0 right-0 mt-1 bg-[var(--card)] border border-[var(--border)] rounded shadow-lg z-10 max-h-40 overflow-y-auto">
|
||
{linkSearchResults.map(t => (
|
||
<button
|
||
key={t.id}
|
||
onClick={() => {
|
||
setLinkTargetId(t.id);
|
||
setLinkSearch(`${t.key} ${t.title}`);
|
||
setLinkSearchResults([]);
|
||
}}
|
||
className="w-full text-left px-2 py-1.5 text-xs hover:bg-white/5 flex gap-2"
|
||
>
|
||
<span className="text-[var(--accent)] font-mono shrink-0">{t.key}</span>
|
||
<span className="truncate">{t.title}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={async () => {
|
||
if (!linkTargetId) return;
|
||
const link = await createTaskLink(task.id, linkTargetId, linkType);
|
||
setLinks([...links, link]);
|
||
setLinkTargetId("");
|
||
setLinkSearch("");
|
||
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 */}
|
||
<div>
|
||
<div className="text-sm text-[var(--muted)] mb-2">Комментарии</div>
|
||
<div className="flex gap-2 mb-3">
|
||
<input
|
||
value={newComment}
|
||
onChange={(e) => setNewComment(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && handleAddComment()}
|
||
placeholder="Написать комментарий..."
|
||
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
|
||
/>
|
||
<button onClick={handleAddComment} className="text-xs text-[var(--accent)] px-2">↵</button>
|
||
</div>
|
||
<div className="space-y-3 max-h-[300px] overflow-y-auto">
|
||
{[...comments].reverse().map((event) => {
|
||
// Different rendering based on event type
|
||
if (event.type === "task_comment") {
|
||
const authorType = event.actor ? "agent" : "human";
|
||
return (
|
||
<div key={event.id} className="text-sm">
|
||
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
|
||
<span>{AUTHOR_ICON[authorType] || "👤"}</span>
|
||
<span className="font-medium">{event.actor?.name || event.actor?.slug || "Unknown"}</span>
|
||
<span>·</span>
|
||
<span>{new Date(event.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
|
||
</div>
|
||
<div className="ml-5 whitespace-pre-wrap">{event.payload.content || ""}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// System events (task operations)
|
||
let systemText = "";
|
||
switch (event.type) {
|
||
case "task_created":
|
||
systemText = `Задача создана: ${event.payload.title || "Без названия"}`;
|
||
break;
|
||
case "task_status":
|
||
systemText = `Статус изменён: ${event.payload.from} → ${event.payload.to}`;
|
||
break;
|
||
case "task_assigned":
|
||
systemText = `Назначена на @${event.payload.assignee}`;
|
||
break;
|
||
case "task_unassigned":
|
||
systemText = "Исполнитель снят";
|
||
break;
|
||
default:
|
||
systemText = `Системное событие: ${event.type}`;
|
||
}
|
||
|
||
return (
|
||
<div key={event.id} className="text-sm">
|
||
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
|
||
<span>⚙️</span>
|
||
<span className="font-medium">{event.actor ? `${event.actor.name} — Система` : "Система"}</span>
|
||
<span>·</span>
|
||
<span>{new Date(event.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
|
||
</div>
|
||
<div className="ml-5">{systemText}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{comments.length === 0 && (
|
||
<div className="text-sm text-[var(--muted)] italic">Нет комментариев</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: Properties */}
|
||
<div className="w-full md:w-64 shrink-0 p-4 md:p-6 space-y-4">
|
||
{/* Status */}
|
||
<div>
|
||
<div className="text-xs text-[var(--muted)] mb-1">Статус</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{STATUSES.map((s) => (
|
||
<button
|
||
key={s.key}
|
||
onClick={() => { setStatus(s.key); save({ status: s.key }); }}
|
||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
|
||
${status === s.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
|
||
>
|
||
<span className="w-2 h-2 rounded-full" style={{ background: s.color }} />
|
||
{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Priority */}
|
||
<div>
|
||
<div className="text-xs text-[var(--muted)] mb-1">Приоритет</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{PRIORITIES.map((p) => (
|
||
<button
|
||
key={p.key}
|
||
onClick={() => { setPriority(p.key); save({ priority: p.key }); }}
|
||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
|
||
${priority === p.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
|
||
>
|
||
<span className="w-2 h-2 rounded-full" style={{ background: p.color }} />
|
||
{p.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Assignee */}
|
||
<div>
|
||
<div className="text-xs text-[var(--muted)] mb-1">Исполнитель</div>
|
||
<select
|
||
value={assigneeId}
|
||
onChange={(e) => {
|
||
setAssigneeId(e.target.value);
|
||
save({ assignee_id: e.target.value || undefined } as any);
|
||
}}
|
||
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
|
||
>
|
||
<option value="">Не назначен</option>
|
||
{members.map((m) => (
|
||
<option key={m.id} value={m.id}>
|
||
{m.type === "agent" ? "🤖 " : "👤 "}{m.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Labels */}
|
||
{task.labels && task.labels.length > 0 && (
|
||
<div>
|
||
<div className="text-xs text-[var(--muted)] mb-1">Лейблы</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{task.labels.map((l) => (
|
||
<span key={l} className="text-xs px-2 py-0.5 rounded bg-white/10">{l}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Delete */}
|
||
<div className="pt-4 border-t border-[var(--border)]">
|
||
{confirmDelete ? (
|
||
<div className="flex gap-2">
|
||
<button onClick={handleDelete} className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded text-xs hover:bg-red-500/30">
|
||
Да, удалить
|
||
</button>
|
||
<button onClick={() => setConfirmDelete(false)} className="px-3 py-1.5 text-[var(--muted)] text-xs">
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button onClick={() => setConfirmDelete(true)} className="text-xs text-red-400/60 hover:text-red-400">
|
||
Удалить задачу
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|