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