feat: task detail modal
All checks were successful
Deploy Web Client / deploy (push) Successful in 36s

- Click card → full task modal
- Edit title (click to edit)
- Edit description (markdown textarea)
- Change status (button group)
- Change priority (button group)
- Assign agent (dropdown)
- Toggle requires PR
- Delete task (with confirmation)
- Dependencies section (placeholder)
- Mobile responsive (stacked layout)
This commit is contained in:
Markov 2026-02-15 20:23:39 +01:00
parent 6ac4f4450b
commit 34f3f4b43a
2 changed files with 308 additions and 1 deletions

View File

@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { Task, getTasks, updateTask, createTask } from "@/lib/api";
import TaskModal from "@/components/TaskModal";
const COLUMNS = [
{ key: "draft", label: "Backlog", color: "#737373" },
@ -29,6 +30,7 @@ export default function KanbanBoard({ projectId }: Props) {
const [addingTo, setAddingTo] = useState<string | null>(null);
const [draggedTask, setDraggedTask] = useState<string | null>(null);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const loadTasks = async () => {
try {
@ -124,7 +126,9 @@ export default function KanbanBoard({ projectId }: Props) {
{colTasks.map((task) => (
<div
key={task.id}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3"
className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 cursor-pointer
hover:border-[var(--accent)] transition-colors"
onClick={() => setSelectedTask(task)}
>
<div className="flex items-start gap-2">
<div
@ -210,6 +214,7 @@ export default function KanbanBoard({ projectId }: Props) {
key={task.id}
draggable
onDragStart={() => setDraggedTask(task.id)}
onClick={() => setSelectedTask(task)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 cursor-grab
hover:border-[var(--accent)] transition-colors active:cursor-grabbing"
>
@ -254,6 +259,22 @@ export default function KanbanBoard({ projectId }: Props) {
);
})}
</div>
{/* Task modal */}
{selectedTask && (
<TaskModal
task={selectedTask}
projectId={projectId}
onClose={() => setSelectedTask(null)}
onUpdated={(updated) => {
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
setSelectedTask(updated);
}}
onDeleted={(id) => {
setTasks((prev) => prev.filter((t) => t.id !== id));
setSelectedTask(null);
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,286 @@
"use client";
import { useEffect, useState } from "react";
import { Task, Agent, updateTask, deleteTask, getAgents, getTasks } from "@/lib/api";
const STATUSES = [
{ key: "draft", label: "Backlog", color: "#737373" },
{ key: "ready", label: "TODO", color: "#3b82f6" },
{ key: "in_progress", label: "In Progress", color: "#f59e0b" },
{ key: "review", label: "Review", color: "#a855f7" },
{ key: "completed", label: "Done", color: "#22c55e" },
{ key: "blocked", label: "Blocked", color: "#ef4444" },
];
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" },
];
interface Props {
task: Task;
projectId: string;
onClose: () => void;
onUpdated: (task: Task) => void;
onDeleted: (taskId: string) => void;
}
export default function TaskModal({ task, projectId, onClose, onUpdated, onDeleted }: 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 [assignedAgentId, setAssignedAgentId] = useState(task.assigned_agent_id || "");
const [agents, setAgents] = useState<Agent[]>([]);
const [allTasks, setAllTasks] = useState<Task[]>([]);
const [saving, setSaving] = useState(false);
const [editingDesc, setEditingDesc] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
useEffect(() => {
getAgents().then(setAgents).catch(() => {});
getTasks(projectId).then((t) => setAllTasks(t.filter((x) => x.id !== task.id))).catch(() => {});
}, []);
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 currentStatus = STATUSES.find((s) => s.key === status);
const currentPriority = PRIORITIES.find((p) => p.key === priority);
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)]">
{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 className="text-xs text-[var(--muted)] mt-1">ID: {task.id.slice(0, 8)}</div>
</div>
<div className="flex flex-col md:flex-row">
{/* Left: Description */}
<div className="flex-1 p-4 md:p-6 border-b md:border-b-0 md:border-r border-[var(--border)]">
<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() || null });
}}
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>
)}
{/* Dependencies placeholder */}
<div className="mt-6">
<div className="text-sm text-[var(--muted)] mb-2">Зависимости</div>
<div className="text-sm text-[var(--muted)] italic">
Пока нет зависимостей
</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={assignedAgentId}
onChange={(e) => {
setAssignedAgentId(e.target.value);
save({ assigned_agent_id: e.target.value || null });
}}
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>
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
{/* PR */}
<div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={task.requires_pr}
onChange={(e) => save({ requires_pr: e.target.checked })}
className="accent-[var(--accent)]"
/>
Требует PR
</label>
{task.pr_url && (
<a href={task.pr_url} target="_blank" className="text-xs text-[var(--accent)] hover:underline mt-1 block">
{task.pr_url}
</a>
)}
</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>
);
}