feat: task detail modal
All checks were successful
Deploy Web Client / deploy (push) Successful in 36s
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:
parent
6ac4f4450b
commit
34f3f4b43a
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Task, getTasks, updateTask, createTask } from "@/lib/api";
|
import { Task, getTasks, updateTask, createTask } from "@/lib/api";
|
||||||
|
import TaskModal from "@/components/TaskModal";
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
{ key: "draft", label: "Backlog", color: "#737373" },
|
{ key: "draft", label: "Backlog", color: "#737373" },
|
||||||
@ -29,6 +30,7 @@ export default function KanbanBoard({ projectId }: Props) {
|
|||||||
const [addingTo, setAddingTo] = useState<string | null>(null);
|
const [addingTo, setAddingTo] = useState<string | null>(null);
|
||||||
const [draggedTask, setDraggedTask] = useState<string | null>(null);
|
const [draggedTask, setDraggedTask] = useState<string | null>(null);
|
||||||
const [activeColumn, setActiveColumn] = useState<string | null>(null);
|
const [activeColumn, setActiveColumn] = useState<string | null>(null);
|
||||||
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
|
|
||||||
const loadTasks = async () => {
|
const loadTasks = async () => {
|
||||||
try {
|
try {
|
||||||
@ -124,7 +126,9 @@ export default function KanbanBoard({ projectId }: Props) {
|
|||||||
{colTasks.map((task) => (
|
{colTasks.map((task) => (
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
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 className="flex items-start gap-2">
|
||||||
<div
|
<div
|
||||||
@ -210,6 +214,7 @@ export default function KanbanBoard({ projectId }: Props) {
|
|||||||
key={task.id}
|
key={task.id}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={() => setDraggedTask(task.id)}
|
onDragStart={() => setDraggedTask(task.id)}
|
||||||
|
onClick={() => setSelectedTask(task)}
|
||||||
className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 cursor-grab
|
className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 cursor-grab
|
||||||
hover:border-[var(--accent)] transition-colors active:cursor-grabbing"
|
hover:border-[var(--accent)] transition-colors active:cursor-grabbing"
|
||||||
>
|
>
|
||||||
@ -254,6 +259,22 @@ export default function KanbanBoard({ projectId }: Props) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
286
src/components/TaskModal.tsx
Normal file
286
src/components/TaskModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user