web-client-vite/src/components/TaskModal.tsx

568 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}