/
alexefan136
/
flowstack
Обзор
Документация
Войти
/
alexefan136
/
flowstack
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
ui/src/components/tasks/TaskList.tsx
1 080 строк
35 KB
Alexander Efanov
upd fix
29 июл 2026, 23:26
29 июл 2026, 23:26
d8ac8ee
Код
Авторство
О чём код?
// src/components/tasks/TaskList.tsx import { AlertCircle, Bot, Calendar, CheckCircle, Circle, Clock, Download, Edit3, LayoutGrid, List, ListChecks, Loader2, Play, Plus, Search, Sparkles, Tag, Trash2, X, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useParams } from "react-router-dom"; import { useAgentStore } from "../../lib/stores/agent-store"; import { useAppStore } from "../../lib/stores/app-store"; import { useTaskStore } from "../../lib/stores/task-store"; import type { Subtask, Task, TaskPriority, TaskStatus, } from "../../lib/types/task-types"; import { cn } from "../../lib/utils"; import { downloadFile, exportTasksToCSV, exportTasksToMarkdown, } from "../../lib/utils/task-export"; import { CreateTaskModal } from "./CreateTaskModal"; import { TaskDetailModal } from "./TaskDetailModal"; import { TaskKanban } from "./TaskKanban"; // ============================================================================ // Constants // ============================================================================ const STATUS_CONFIG: Record< TaskStatus, { icon: typeof Circle; color: string; label: string; bgClass: string } > = { todo: { icon: Circle, color: "text-text-muted", label: "К выполнению", bgClass: "bg-(--glass-bg-default)", }, in_progress: { icon: Clock, color: "text-accent-amber", label: "В работе", bgClass: "bg-accent-amber/10", }, done: { icon: CheckCircle, color: "text-accent-lime", label: "Завершено", bgClass: "bg-accent-lime-soft", }, cancelled: { icon: AlertCircle, color: "text-status-error", label: "Отменено", bgClass: "bg-status-error-soft", }, }; const PRIORITY_CONFIG: Record< string, { color: string; bgClass: string; label: string } > = { low: { color: "text-text-muted", bgClass: "bg-(--glass-bg-default)", label: "Низкий", }, medium: { color: "text-accent-amber", bgClass: "bg-accent-amber/10", label: "Средний", }, high: { color: "text-accent-lime", bgClass: "bg-accent-lime-soft", label: "Высокий", }, urgent: { color: "text-status-error", bgClass: "bg-status-error-soft", label: "Срочный", }, }; const PRIORITY_ORDER: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3, }; type ViewMode = "list" | "kanban"; type SortField = "created_at" | "deadline" | "priority" | "title"; type SortOrder = "asc" | "desc"; // ============================================================================ // Sub-components // ============================================================================ function TaskStatusIcon({ status }: { status: TaskStatus }) { const config = STATUS_CONFIG[status]; const Icon = config.icon; return <Icon className={cn("w-4 h-4", config.color)} />; } function TaskPriorityBadge({ priority }: { priority: string }) { const config = PRIORITY_CONFIG[priority] || PRIORITY_CONFIG.low; return ( <span className={cn( "text-[10px] font-medium px-2 py-0.5 rounded-full inline-flex items-center gap-1", config.bgClass, config.color, )} > {config.label} </span> ); } function TaskRunsPreview({ task }: { task: Task }) { const lastRun = task.runs?.[0]; const formattedDate = lastRun?.completed_at ? new Date(lastRun.completed_at).toLocaleString("ru-RU", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", }) : null; const statusConfig = lastRun ? ({ completed: { color: "text-accent-lime", label: "Успешно" }, failed: { color: "text-status-error", label: "Ошибка" }, running: { color: "text-accent-amber", label: "Выполняется" }, }[lastRun.status] ?? { color: "text-text-muted", label: "Неизвестно" }) : null; if (!lastRun || !statusConfig) return null; return ( <div className="mt-2 p-2 bg-(--glass-bg-default) rounded-lg border border-border-subtle"> <div className="flex items-center justify-between mb-1"> <div className="flex items-center gap-2"> <Sparkles className={cn("w-3 h-3", statusConfig.color)} /> <span className={cn("text-[10px] font-medium", statusConfig.color)}> {statusConfig.label} </span> </div> {formattedDate && ( <span className="text-[10px] text-text-muted">{formattedDate}</span> )} </div> {lastRun.output && ( <div className="text-xs text-text-secondary line-clamp-2"> {lastRun.output.slice(0, 150)} {lastRun.output.length > 150 ? "..." : ""} </div> )} {lastRun.duration_ms && ( <div className="mt-1 text-[10px] text-text-muted flex items-center gap-2"> <span>⏱️ {(lastRun.duration_ms / 1000).toFixed(1)}с</span> {lastRun.tokens_used && <span>• {lastRun.tokens_used} токенов</span>} </div> )} </div> ); } function TaskCard({ task, isProcessing, onStatusChange, onDelete, onExecute, onEdit, onView, }: { task: Task; isProcessing: boolean; onStatusChange: (taskId: string, status: TaskStatus) => void; onDelete: (taskId: string) => void; onExecute: (taskId: string) => void; onEdit: (task: Task) => void; onView: (task: Task) => void; }) { const { getAgentById } = useAgentStore(); const agent = task.assigned_agent_id ? getAgentById(task.assigned_agent_id) : null; const nextStatus = useMemo((): TaskStatus => { const statusMap: Record<TaskStatus, TaskStatus> = { todo: "in_progress", in_progress: "done", done: "todo", cancelled: "todo", }; return statusMap[task.status]; }, [task.status]); const isOverdue = useMemo(() => { if (!task.deadline) return false; if (task.status === "done" || task.status === "cancelled") return false; return new Date(task.deadline) < new Date(); }, [task.deadline, task.status]); const subtasksDone = task.subtasks?.filter((st) => st.completed).length ?? 0; const subtasksTotal = task.subtasks?.length ?? 0; const handleStatusClick = useCallback( () => onStatusChange(task.id, nextStatus), [task.id, nextStatus, onStatusChange], ); const formattedCreatedAt = useMemo( () => new Date(task.created_at).toLocaleDateString("ru-RU"), [task.created_at], ); const formattedDeadline = useMemo( () => task.deadline ? new Date(task.deadline).toLocaleDateString("ru-RU") : null, [task.deadline], ); return ( <div onClick={() => onView(task)} className={cn( "glass rounded-xl p-4 hover:border-border-accent transition-all group cursor-pointer", isProcessing && "opacity-60", )} > <div className="flex items-start gap-3"> <button onClick={(e) => { e.stopPropagation(); handleStatusClick(); }} disabled={isProcessing} className="mt-0.5 hover:scale-110 transition-transform disabled:opacity-40" aria-label={`Изменить статус на ${STATUS_CONFIG[nextStatus].label}`} title={`Статус: ${STATUS_CONFIG[task.status].label} → ${STATUS_CONFIG[nextStatus].label}`} > <TaskStatusIcon status={task.status} /> </button> <div className="flex-1 min-w-0"> <div className="flex items-start justify-between gap-2 mb-1"> <h3 className={cn( "text-sm font-medium truncate flex-1", task.status === "done" && "line-through text-text-muted", )} > {task.title} </h3> <div className="flex items-center gap-1 shrink-0"> {isOverdue && ( <span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-status-error-soft text-status-error inline-flex items-center gap-1"> <AlertCircle className="w-2.5 h-2.5" /> Просрочено </span> )} <TaskPriorityBadge priority={task.priority} /> </div> </div> {task.description && ( <p className="text-xs text-text-muted line-clamp-2 mb-2"> {task.description} </p> )} {task.tags && task.tags.length > 0 && ( <div className="flex flex-wrap gap-1 mb-2"> {task.tags.map((tag) => ( <span key={tag} className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 bg-accent-lime-soft text-accent-lime rounded-full" > <Tag className="w-2.5 h-2.5" /> {tag} </span> ))} </div> )} {agent && ( <div className="flex items-center gap-1 text-xs text-accent-lime mb-2"> <Bot className="w-3 h-3" /> <span>{agent.name}</span> </div> )} <div className="flex items-center gap-2 text-[10px] text-text-muted flex-wrap"> <span className="flex items-center gap-1"> <Calendar className="w-3 h-3" /> {formattedCreatedAt} </span> {formattedDeadline && ( <> <span>•</span> <span className={cn( "flex items-center gap-1", isOverdue && "text-status-error font-medium", )} > <Clock className="w-3 h-3" /> Дедлайн: {formattedDeadline} </span> </> )} {task.estimated_duration_minutes && ( <> <span>•</span> <span>⏱️ {task.estimated_duration_minutes} мин</span> </> )} {subtasksTotal > 0 && ( <> <span>•</span> <span className={cn( "flex items-center gap-1", subtasksDone === subtasksTotal && "text-accent-lime", )} > <ListChecks className="w-3 h-3" /> {subtasksDone}/{subtasksTotal} </span> </> )} </div> <TaskRunsPreview task={task} /> </div> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> {task.assigned_agent_id && task.status !== "done" && ( <button onClick={(e) => { e.stopPropagation(); onExecute(task.id); }} disabled={isProcessing} className="btn btn-ghost btn-icon btn-sm disabled:opacity-40" aria-label="Выполнить" title="Выполнить через агента" > {isProcessing ? ( <Loader2 className="w-3.5 h-3.5 animate-spin" /> ) : ( <Play className="w-3.5 h-3.5" /> )} </button> )} <button onClick={(e) => { e.stopPropagation(); onEdit(task); }} disabled={isProcessing} className="btn btn-ghost btn-icon btn-sm disabled:opacity-40" aria-label="Редактировать" title="Редактировать" > <Edit3 className="w-3.5 h-3.5" /> </button> {task.status !== "done" && task.status !== "cancelled" && ( <button onClick={(e) => { e.stopPropagation(); onStatusChange(task.id, "cancelled"); }} disabled={isProcessing} className="btn btn-ghost btn-icon btn-sm text-text-muted hover:text-status-error hover:bg-status-error-soft disabled:opacity-40" aria-label="Отменить задачу" title="Отменить" > <X className="w-3.5 h-3.5" /> </button> )} <button onClick={(e) => { e.stopPropagation(); onDelete(task.id); }} disabled={isProcessing} className="btn btn-ghost btn-icon btn-sm text-status-error hover:bg-status-error-soft disabled:opacity-40" aria-label="Удалить" title="Удалить" > <Trash2 className="w-3.5 h-3.5" /> </button> </div> </div> </div> ); } function TaskGroup({ title, tasks, processingTaskId, onStatusChange, onDelete, onExecute, onEdit, onView, }: { title: string; tasks: Task[]; processingTaskId: string | null; onStatusChange: (taskId: string, status: TaskStatus) => void; onDelete: (taskId: string) => void; onExecute: (taskId: string) => void; onEdit: (task: Task) => void; onView: (task: Task) => void; }) { if (tasks.length === 0) return null; return ( <div> <h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3"> {title} ({tasks.length}) </h3> <div className="space-y-2"> {tasks.map((task) => ( <TaskCard key={task.id} task={task} isProcessing={processingTaskId === task.id} onStatusChange={onStatusChange} onDelete={onDelete} onExecute={onExecute} onEdit={onEdit} onView={onView} /> ))} </div> </div> ); } function EmptyState({ onCreateTask }: { onCreateTask: () => void }) { return ( <div className="text-center py-16"> <div className="relative inline-block mb-6"> <div className="w-20 h-20 rounded-3xl gradient-primary flex items-center justify-center shadow-lime"> <Sparkles className="w-10 h-10 text-text-on-accent" /> </div> <div className="absolute inset-0 rounded-3xl gradient-primary blur-xl opacity-50 -z-10" /> </div> <h3 className="text-lg font-semibold text-text-primary mb-2"> Нет задач </h3> <p className="text-sm text-text-muted mb-1"> Создайте первую задачу из чата или нажмите кнопку ниже </p> <p className="text-xs text-text-muted/60 mb-6"> Задачи помогают организовать работу и автоматизировать рутину </p> <button onClick={onCreateTask} className="btn btn-primary gap-2"> <Plus className="w-4 h-4" aria-hidden="true" /> Создать первую задачу </button> </div> ); } function LoadingSkeleton() { return ( <div className="space-y-3"> {[1, 2, 3].map((i) => ( <div key={i} className="glass rounded-xl p-4 animate-pulse"> <div className="flex items-start gap-3"> <div className="w-4 h-4 bg-(--glass-bg-strong) rounded mt-0.5" /> <div className="flex-1 space-y-2"> <div className="h-4 bg-(--glass-bg-strong) rounded w-3/4" /> <div className="h-3 bg-(--glass-bg-strong) rounded w-1/2" /> <div className="h-3 bg-(--glass-bg-strong) rounded w-1/4" /> </div> </div> </div> ))} </div> ); } // ============================================================================ // Filters Panel // ============================================================================ function FiltersPanel({ searchQuery, onSearchChange, statusFilter, onStatusFilterChange, priorityFilter, onPriorityFilterChange, sortBy, onSortByChange, sortOrder, onSortOrderChange, hasActiveFilters, onReset, }: { searchQuery: string; onSearchChange: (q: string) => void; statusFilter: TaskStatus | "all"; onStatusFilterChange: (s: TaskStatus | "all") => void; priorityFilter: TaskPriority | "all"; onPriorityFilterChange: (p: TaskPriority | "all") => void; sortBy: SortField; onSortByChange: (f: SortField) => void; sortOrder: SortOrder; onSortOrderChange: (o: SortOrder) => void; hasActiveFilters: boolean; onReset: () => void; }) { const selectClass = "px-2.5 py-1.5 text-xs bg-(--glass-bg-default) border border-border-subtle rounded-lg text-text-primary cursor-pointer hover:bg-(--glass-bg-strong) transition focus:outline-none focus:border-accent-lime"; return ( <div className="flex items-center gap-2 flex-wrap"> {/* Поиск */} <div className="relative flex-1 min-w-40"> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-muted" aria-hidden="true" /> <input type="text" value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} placeholder="Поиск задач..." className="w-full pl-8 pr-8 py-1.5 text-xs bg-(--glass-bg-default) border border-border-subtle rounded-lg text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent-lime transition" /> {searchQuery && ( <button onClick={() => onSearchChange("")} className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary transition" aria-label="Очистить поиск" > <X className="w-3.5 h-3.5" /> </button> )} </div> {/* Статус */} <select value={statusFilter} onChange={(e) => onStatusFilterChange(e.target.value as TaskStatus | "all") } className={selectClass} aria-label="Фильтр по статусу" > <option value="all">Все статусы</option> <option value="todo">К выполнению</option> <option value="in_progress">В работе</option> <option value="done">Завершено</option> <option value="cancelled">Отменено</option> </select> {/* Приоритет */} <select value={priorityFilter} onChange={(e) => onPriorityFilterChange(e.target.value as TaskPriority | "all") } className={selectClass} aria-label="Фильтр по приоритету" > <option value="all">Все приоритеты</option> <option value="urgent">Срочный</option> <option value="high">Высокий</option> <option value="medium">Средний</option> <option value="low">Низкий</option> </select> {/* Сортировка */} <select value={sortBy} onChange={(e) => onSortByChange(e.target.value as SortField)} className={selectClass} aria-label="Сортировка" > <option value="created_at">По дате создания</option> <option value="deadline">По дедлайну</option> <option value="priority">По приоритету</option> <option value="title">По названию</option> </select> {/* Направление сортировки */} <button onClick={() => onSortOrderChange(sortOrder === "asc" ? "desc" : "asc")} className="px-2.5 py-1.5 text-xs bg-(--glass-bg-default) border border-border-subtle rounded-lg text-text-primary hover:bg-(--glass-bg-strong) transition" aria-label="Направление сортировки" title={sortOrder === "asc" ? "По возрастанию" : "По убыванию"} > {sortOrder === "asc" ? "↑" : "↓"} </button> {/* Сброс */} {hasActiveFilters && ( <button onClick={onReset} className="px-2.5 py-1.5 text-xs text-text-muted hover:text-status-error transition" aria-label="Сбросить фильтры" > Сбросить </button> )} </div> ); } // ============================================================================ // Main Component // ============================================================================ export function TaskList() { const { workspace = "default" } = useParams(); const { toast } = useAppStore(); const { getAgentById } = useAgentStore(); const { tasks, isLoading, loadTasks, updateTask, deleteTask, executeTask } = useTaskStore(); // UI state const [showCreateModal, setShowCreateModal] = useState(false); const [editingTask, setEditingTask] = useState<Task | null>(null); const [viewingTask, setViewingTask] = useState<Task | null>(null); const [view, setView] = useState<ViewMode>("list"); const [processingTaskId, setProcessingTaskId] = useState<string | null>(null); const [showExportMenu, setShowExportMenu] = useState(false); // Filters const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState<TaskStatus | "all">("all"); const [priorityFilter, setPriorityFilter] = useState<TaskPriority | "all">( "all", ); const [sortBy, setSortBy] = useState<SortField>("created_at"); const [sortOrder, setSortOrder] = useState<SortOrder>("desc"); useEffect(() => { loadTasks(workspace); }, [workspace, loadTasks]); // ========================================================================== // Filtering + sorting // ========================================================================== const filteredTasks = useMemo(() => { let result = [...tasks]; if (searchQuery.trim()) { const q = searchQuery.toLowerCase(); result = result.filter( (t) => t.title.toLowerCase().includes(q) || t.description.toLowerCase().includes(q) || t.tags?.some((tag) => tag.toLowerCase().includes(q)), ); } if (statusFilter !== "all") result = result.filter((t) => t.status === statusFilter); if (priorityFilter !== "all") result = result.filter((t) => t.priority === priorityFilter); result.sort((a, b) => { let cmp = 0; switch (sortBy) { case "deadline": { const av = a.deadline ? new Date(a.deadline).getTime() : Infinity; const bv = b.deadline ? new Date(b.deadline).getTime() : Infinity; cmp = av - bv; break; } case "priority": cmp = (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99); break; case "title": cmp = a.title.localeCompare(b.title, "ru"); break; default: cmp = new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); } return sortOrder === "asc" ? cmp : -cmp; }); return result; }, [tasks, searchQuery, statusFilter, priorityFilter, sortBy, sortOrder]); const groupedTasks = useMemo( () => ({ in_progress: filteredTasks.filter((t) => t.status === "in_progress"), todo: filteredTasks.filter((t) => t.status === "todo"), done: filteredTasks.filter((t) => t.status === "done"), cancelled: filteredTasks.filter((t) => t.status === "cancelled"), }), [filteredTasks], ); const stats = useMemo( () => ({ total: tasks.length, inProgress: tasks.filter((t) => t.status === "in_progress").length, done: tasks.filter((t) => t.status === "done").length, overdue: tasks.filter( (t) => t.deadline && t.status !== "done" && t.status !== "cancelled" && new Date(t.deadline) < new Date(), ).length, }), [tasks], ); const hasActiveFilters = searchQuery.trim() !== "" || statusFilter !== "all" || priorityFilter !== "all"; // ========================================================================== // Handlers // ========================================================================== const handleResetFilters = useCallback(() => { setSearchQuery(""); setStatusFilter("all"); setPriorityFilter("all"); }, []); const handleStatusChange = useCallback( async (taskId: string, status: TaskStatus) => { setProcessingTaskId(taskId); try { await updateTask(taskId, workspace, { status }); // Обновляем viewingTask если открыта модалка setViewingTask((prev) => prev && prev.id === taskId ? { ...prev, status } : prev, ); toast("Статус задачи обновлён", "success"); } catch { toast("Не удалось обновить статус", "error"); } finally { setProcessingTaskId(null); } }, [updateTask, toast, workspace], ); const handleDelete = useCallback( async (taskId: string) => { if (!confirm("Удалить задачу? Это действие нельзя отменить.")) return; setProcessingTaskId(taskId); try { await deleteTask(taskId, workspace); setViewingTask(null); toast("Задача удалена", "success"); } catch { toast("Не удалось удалить задачу", "error"); } finally { setProcessingTaskId(null); } }, [deleteTask, toast, workspace], ); const handleExecute = useCallback( async (taskId: string) => { setProcessingTaskId(taskId); try { await executeTask(taskId, workspace); toast("Задача выполнена", "success"); } catch { toast("Не удалось выполнить задачу", "error"); } finally { setProcessingTaskId(null); } }, [executeTask, toast, workspace], ); const handleEdit = useCallback((task: Task) => { setViewingTask(null); setEditingTask(task); }, []); const handleView = useCallback((task: Task) => { setViewingTask(task); }, []); // ✅ Обновление подзадач (для TaskDetailModal) const handleUpdateSubtasks = useCallback( async (taskId: string, subtasks: Subtask[]) => { try { await updateTask(taskId, workspace, { subtasks }); setViewingTask((prev) => prev && prev.id === taskId ? { ...prev, subtasks } : prev, ); } catch { toast("Не удалось обновить чеклист", "error"); } }, [updateTask, workspace, toast], ); // ✅ Экспорт задач (с учётом текущих фильтров) const handleExport = useCallback( (format: "markdown" | "csv") => { const getAgentName = (id: string) => getAgentById(id)?.name ?? id; const timestamp = new Date().toISOString().slice(0, 10); if (format === "markdown") { const md = exportTasksToMarkdown(filteredTasks, getAgentName); downloadFile(md, `flowstack-tasks-${timestamp}.md`, "text/markdown"); } else { const csv = exportTasksToCSV(filteredTasks, getAgentName); downloadFile(csv, `flowstack-tasks-${timestamp}.csv`, "text/csv"); } setShowExportMenu(false); toast(`Экспортировано задач: ${filteredTasks.length}`, "success"); }, [filteredTasks, getAgentById, toast], ); // ========================================================================== // Render // ========================================================================== if (isLoading) return <LoadingSkeleton />; if (tasks.length === 0) return <EmptyState onCreateTask={() => setShowCreateModal(true)} />; return ( <div className="space-y-4"> {/* Toolbar: stats + export + view toggle */} <div className="flex items-center justify-between gap-3 flex-wrap"> <div className="flex items-center gap-4 text-xs text-text-muted"> <span>Всего: {stats.total}</span> {stats.inProgress > 0 && ( <span className="flex items-center gap-1"> <Clock className="w-3 h-3 text-accent-amber" /> {stats.inProgress} в работе </span> )} {stats.done > 0 && ( <span className="flex items-center gap-1"> <CheckCircle className="w-3 h-3 text-accent-lime" /> {stats.done} завершено </span> )} {stats.overdue > 0 && ( <span className="flex items-center gap-1 text-status-error font-medium"> <AlertCircle className="w-3 h-3" /> {stats.overdue} просрочено </span> )} </div> <div className="flex items-center gap-2"> {/* Экспорт */} <div className="relative"> <button onClick={() => setShowExportMenu((v) => !v)} className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium bg-(--glass-bg-default) border border-border-subtle rounded-lg text-text-primary hover:bg-(--glass-bg-strong) transition" aria-label="Экспорт задач" > <Download className="w-3.5 h-3.5" /> Экспорт </button> {showExportMenu && ( <> <div className="fixed inset-0 z-40" onClick={() => setShowExportMenu(false)} aria-hidden="true" /> <div className="absolute right-0 top-full mt-1 z-50 w-40 rounded-lg bg-bg-elevated border border-border-strong shadow-2xl overflow-hidden fadein"> <button onClick={() => handleExport("markdown")} className="w-full text-left px-3 py-2 text-xs text-text-secondary hover:bg-(--glass-bg-default) transition" > 📄 Markdown </button> <button onClick={() => handleExport("csv")} className="w-full text-left px-3 py-2 text-xs text-text-secondary hover:bg-(--glass-bg-default) transition" > 📊 CSV (Excel) </button> </div> </> )} </div> {/* Переключатель вида */} <div className="flex items-center gap-1 p-1 bg-(--glass-bg-default) rounded-lg border border-border-subtle"> <button onClick={() => setView("list")} className={cn( "flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition", view === "list" ? "bg-accent-lime-soft text-accent-lime" : "text-text-muted hover:text-text-primary", )} aria-pressed={view === "list"} > <List className="w-3.5 h-3.5" /> Список </button> <button onClick={() => setView("kanban")} className={cn( "flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition", view === "kanban" ? "bg-accent-lime-soft text-accent-lime" : "text-text-muted hover:text-text-primary", )} aria-pressed={view === "kanban"} > <LayoutGrid className="w-3.5 h-3.5" /> Канбан </button> </div> </div> </div> {/* Фильтры */} <FiltersPanel searchQuery={searchQuery} onSearchChange={setSearchQuery} statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} priorityFilter={priorityFilter} onPriorityFilterChange={setPriorityFilter} sortBy={sortBy} onSortByChange={setSortBy} sortOrder={sortOrder} onSortOrderChange={setSortOrder} hasActiveFilters={hasActiveFilters} onReset={handleResetFilters} /> {/* Контент */} {filteredTasks.length === 0 ? ( <div className="text-center py-12 glass rounded-xl"> <Search className="w-8 h-8 text-text-muted/40 mx-auto mb-3" aria-hidden="true" /> <p className="text-sm text-text-muted mb-3"> Ничего не найдено по заданным фильтрам </p> <button onClick={handleResetFilters} className="btn btn-ghost btn-sm"> Сбросить фильтры </button> </div> ) : view === "kanban" ? ( <TaskKanban tasks={filteredTasks} processingTaskId={processingTaskId} onStatusChange={handleStatusChange} onDelete={handleDelete} onExecute={handleExecute} onEdit={handleEdit} onView={handleView} /> ) : ( <div className="space-y-6"> <TaskGroup title="В работе" tasks={groupedTasks.in_progress} processingTaskId={processingTaskId} onStatusChange={handleStatusChange} onDelete={handleDelete} onExecute={handleExecute} onEdit={handleEdit} onView={handleView} /> <TaskGroup title="К выполнению" tasks={groupedTasks.todo} processingTaskId={processingTaskId} onStatusChange={handleStatusChange} onDelete={handleDelete} onExecute={handleExecute} onEdit={handleEdit} onView={handleView} /> <TaskGroup title="Завершено" tasks={groupedTasks.done} processingTaskId={processingTaskId} onStatusChange={handleStatusChange} onDelete={handleDelete} onExecute={handleExecute} onEdit={handleEdit} onView={handleView} /> {groupedTasks.cancelled.length > 0 && ( <TaskGroup title="Отменено" tasks={groupedTasks.cancelled} processingTaskId={processingTaskId} onStatusChange={handleStatusChange} onDelete={handleDelete} onExecute={handleExecute} onEdit={handleEdit} onView={handleView} /> )} </div> )} {/* Модалка создания */} <CreateTaskModal key={showCreateModal ? "create-open" : "create-closed"} isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} initialText="" /> {/* Модалка редактирования */} {editingTask && ( <CreateTaskModal key={`edit-${editingTask.id}`} isOpen={!!editingTask} onClose={() => setEditingTask(null)} task={editingTask} /> )} {/* Модалка детального просмотра */} {viewingTask && ( <TaskDetailModal task={viewingTask} isProcessing={processingTaskId === viewingTask.id} onClose={() => setViewingTask(null)} onEdit={handleEdit} onStatusChange={handleStatusChange} onDelete={handleDelete} onExecute={handleExecute} onUpdateSubtasks={handleUpdateSubtasks} /> )} </div> ); }