← Назад
import { authFetch } from '../../lib/api'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogClose, DialogBody } from '../ui/dialog'; import { CheckCircle2, Clock, Lightbulb, Loader2, Shield, Plus, Archive, Trash2, Pencil, Check, X, RotateCcw, Search, Filter, Download } from 'lucide-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useState, useRef } from 'react'; import { CreateTaskModal } from './CreateTaskModal'; import { DndContext, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay, defaultDropAnimationSideEffects, } from '@dnd-kit/core'; import type { DragStartEvent, DragOverEvent, DragEndEvent } from '@dnd-kit/core'; import { SortableContext, rectSortingStrategy, sortableKeyboardCoordinates, } from '@dnd-kit/sortable'; import { SortableTaskCard } from './SortableTaskCard'; type Status = 'proposed' | 'approved' | 'done' | 'verified'; type Priority = 'low' | 'medium' | 'high' | 'critical'; interface KanbanModalProps { open: boolean; onOpenChange: (open: boolean) => void; projectId?: string | null; embedded?: boolean; } interface Task { id: string; title: string; description?: string; priority: Priority; status: Status; category?: string; tags?: string[]; deadline?: string; createdAt?: string; updatedAt?: string; } interface ArchivedTask extends Task { archivedAt: string; } interface TasksData { project: string; tasks: Task[]; archivedTasks?: ArchivedTask[]; stats: { total: number; proposed: number; approved: number; done: number; verified: number; }; } const priorityColors: Record<Priority, string> = { low: 'bg-secondary/20 text-secondary border-secondary/30', medium: 'bg-warning/20 text-warning border-warning/30', high: 'bg-danger/20 text-danger border-danger/30', critical: 'bg-red-600/20 text-red-400 border-red-500/30', }; const priorityLabels: Record<Priority, string> = { low: 'Низкий', medium: 'Средний', high: 'Высокий', critical: 'Критичный', }; const statusLabels: Record<string, string> = { proposed: 'Предложено', approved: 'Одобрено', done: 'Сделано', verified: 'Проверено', }; function formatStatus(status: string): string { return statusLabels[status] || status; } function formatDeadline(deadline: string): { label: string; overdue: boolean } { const d = new Date(deadline); const now = new Date(); const overdue = d < now; const diff = Math.round((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); if (overdue) return { label: `Просрочено (${Math.abs(diff)}д)`, overdue: true }; if (diff === 0) return { label: 'Сегодня', overdue: false }; if (diff === 1) return { label: 'Завтра', overdue: false }; return { label: `${diff}д`, overdue: false }; } async function fetchTasks(projectId: string): Promise<TasksData> { const res = await authFetch(`/api/projects/${projectId}/kanban-tasks`); if (!res.ok) throw new Error('Failed to fetch tasks'); return res.json(); } export function KanbanModal({ open, onOpenChange, projectId, embedded }: KanbanModalProps) { const queryClient = useQueryClient(); const [savingTaskId, setSavingTaskId] = useState<string | null>(null); const [createModalOpen, setCreateModalOpen] = useState(false); const [createModalStatus, setCreateModalStatus] = useState<Status>('proposed'); const [activeTab, setActiveTab] = useState<'board' | 'archive'>('board'); const [searchQuery, setSearchQuery] = useState(''); const [filterPriority, setFilterPriority] = useState<Priority | 'all'>('all'); const [editingTaskId, setEditingTaskId] = useState<string | null>(null); // DND State const [activeId, setActiveId] = useState<string | null>(null); const [activeTask, setActiveTask] = useState<Task | null>(null); const { data, isLoading, error } = useQuery({ queryKey: ['kanban-tasks', projectId], queryFn: () => projectId ? fetchTasks(projectId) : Promise.reject('No project'), enabled: open && !!projectId, }); const tasks = data?.tasks || []; const archivedTasks = data?.archivedTasks || []; // Sensors for DND const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); const { mutate: moveTask } = useMutation({ mutationFn: async ({ taskId, newStatus }: { taskId: string; newStatus: Status }) => { const res = await authFetch(`/api/projects/${projectId}/tasks/${taskId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: newStatus }), }); if (!res.ok) { const errorData = await res.json().catch(() => ({ error: 'Unknown error' })); throw new Error(errorData.error || `HTTP ${res.status}`); } return { taskId, newStatus }; }, onMutate: async ({ taskId, newStatus }) => { if (!projectId) return { previousData: undefined, oldStatus: undefined }; setSavingTaskId(taskId); await queryClient.cancelQueries({ queryKey: ['kanban-tasks', projectId] }); const previousData = queryClient.getQueryData<TasksData>(['kanban-tasks', projectId]); const oldStatus = previousData?.tasks.find((t) => t.id === taskId)?.status; if (previousData) { queryClient.setQueryData<TasksData>(['kanban-tasks', projectId], { ...previousData, tasks: previousData.tasks.map((t: Task) => t.id === taskId ? { ...t, status: newStatus } : t), }); } return { previousData, oldStatus }; }, onError: (err: unknown, _v, context?: { previousData?: TasksData }) => { if (context?.previousData) queryClient.setQueryData(['kanban-tasks', projectId], context.previousData); toast.error('Ошибка сохранения', { description: err instanceof Error ? err.message : 'Неизвестная ошибка', duration: 4000 }); }, onSuccess: (data, _v, context?: { previousData?: TasksData; oldStatus?: Status }) => { toast.success('Статус обновлен', { description: `${formatStatus(context?.oldStatus ?? '?')} → ${formatStatus(data.newStatus)}`, duration: 2000 }); }, onSettled: () => { setSavingTaskId(null); queryClient.invalidateQueries({ queryKey: ['kanban-tasks', projectId] }); }, }); const { mutate: archiveTask } = useMutation({ mutationFn: async (taskId: string) => { const res = await authFetch(`/api/projects/${projectId}/tasks/${taskId}/archive`, { method: 'POST' }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`); return taskId; }, onMutate: async (taskId) => { if (!projectId) return { previousData: undefined }; await queryClient.cancelQueries({ queryKey: ['kanban-tasks', projectId] }); const previousData = queryClient.getQueryData<TasksData>(['kanban-tasks', projectId]); if (previousData) { const task = previousData.tasks.find(t => t.id === taskId); queryClient.setQueryData<TasksData>(['kanban-tasks', projectId], { ...previousData, tasks: previousData.tasks.filter(t => t.id !== taskId), archivedTasks: task ? [...(previousData.archivedTasks || []), { ...task, archivedAt: new Date().toISOString() }] : previousData.archivedTasks, }); } return { previousData }; }, onError: (err: unknown, _t, context?: { previousData?: TasksData }) => { if (context?.previousData) queryClient.setQueryData(['kanban-tasks', projectId], context.previousData); toast.error('Ошибка архивации', { description: err instanceof Error ? err.message : 'Неизвестная ошибка', duration: 4000 }); }, onSuccess: () => toast.success('Задача архивирована', { duration: 2000 }), onSettled: () => queryClient.invalidateQueries({ queryKey: ['kanban-tasks', projectId] }), }); const { mutate: deleteTask } = useMutation({ mutationFn: async (taskId: string) => { const res = await authFetch(`/api/projects/${projectId}/tasks/${taskId}`, { method: 'DELETE' }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`); return taskId; }, onMutate: async (taskId) => { if (!projectId) return { previousData: undefined }; await queryClient.cancelQueries({ queryKey: ['kanban-tasks', projectId] }); const previousData = queryClient.getQueryData<TasksData>(['kanban-tasks', projectId]); if (previousData) { queryClient.setQueryData<TasksData>(['kanban-tasks', projectId], { ...previousData, tasks: previousData.tasks.filter(t => t.id !== taskId), }); } return { previousData }; }, onError: (err: unknown, _t, context?: { previousData?: TasksData }) => { if (context?.previousData) queryClient.setQueryData(['kanban-tasks', projectId], context.previousData); toast.error('Ошибка удаления', { description: err instanceof Error ? err.message : 'Неизвестная ошибка', duration: 4000 }); }, onSuccess: () => toast.success('Задача удалена', { duration: 2000 }), onSettled: () => queryClient.invalidateQueries({ queryKey: ['kanban-tasks', projectId] }), }); const { mutate: deleteArchivedTask } = useMutation({ mutationFn: async (taskId: string) => { const res = await authFetch(`/api/projects/${projectId}/tasks/${taskId}`, { method: 'DELETE' }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`); return taskId; }, onMutate: async (taskId) => { if (!projectId) return { previousData: undefined }; await queryClient.cancelQueries({ queryKey: ['kanban-tasks', projectId] }); const previousData = queryClient.getQueryData<TasksData>(['kanban-tasks', projectId]); if (previousData) { queryClient.setQueryData<TasksData>(['kanban-tasks', projectId], { ...previousData, archivedTasks: (previousData.archivedTasks || []).filter(t => t.id !== taskId), }); } return { previousData }; }, onError: (err: unknown, _t, context?: { previousData?: TasksData }) => { if (context?.previousData) queryClient.setQueryData(['kanban-tasks', projectId], context.previousData); toast.error('Ошибка удаления', { description: err instanceof Error ? err.message : 'Неизвестная ошибка', duration: 4000 }); }, onSuccess: () => toast.success('Задача удалена', { duration: 2000 }), onSettled: () => queryClient.invalidateQueries({ queryKey: ['kanban-tasks', projectId] }), }); const { mutate: restoreTask } = useMutation({ mutationFn: async (taskId: string) => { const res = await authFetch(`/api/projects/${projectId}/tasks/${taskId}/restore`, { method: 'POST' }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`); return res.json(); }, onMutate: async (taskId) => { if (!projectId) return { previousData: undefined }; await queryClient.cancelQueries({ queryKey: ['kanban-tasks', projectId] }); const previousData = queryClient.getQueryData<TasksData>(['kanban-tasks', projectId]); if (previousData) { const task = (previousData.archivedTasks || []).find(t => t.id === taskId); if (task) { const restored: Task = { ...task, status: 'proposed' }; delete (restored as any).archivedAt; queryClient.setQueryData<TasksData>(['kanban-tasks', projectId], { ...previousData, tasks: [...previousData.tasks, restored], archivedTasks: (previousData.archivedTasks || []).filter(t => t.id !== taskId), }); } } return { previousData }; }, onError: (err: unknown, _t, context?: { previousData?: TasksData }) => { if (context?.previousData) queryClient.setQueryData(['kanban-tasks', projectId], context.previousData); toast.error('Ошибка восстановления', { description: err instanceof Error ? err.message : 'Неизвестная ошибка', duration: 4000 }); }, onSuccess: () => toast.success('Задача восстановлена', { duration: 2000 }), onSettled: () => queryClient.invalidateQueries({ queryKey: ['kanban-tasks', projectId] }), }); const { mutate: updateTask } = useMutation({ mutationFn: async ({ taskId, updates }: { taskId: string; updates: Partial<Task> }) => { const res = await authFetch(`/api/projects/${projectId}/tasks/${taskId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`); return res.json(); }, onMutate: async ({ taskId, updates }) => { if (!projectId) return { previousData: undefined }; await queryClient.cancelQueries({ queryKey: ['kanban-tasks', projectId] }); const previousData = queryClient.getQueryData<TasksData>(['kanban-tasks', projectId]); if (previousData) { queryClient.setQueryData<TasksData>(['kanban-tasks', projectId], { ...previousData, tasks: previousData.tasks.map(t => t.id === taskId ? { ...t, ...updates } : t), }); } return { previousData }; }, onError: (err: unknown, _v, context?: { previousData?: TasksData }) => { if (context?.previousData) queryClient.setQueryData(['kanban-tasks', projectId], context.previousData); toast.error('Ошибка сохранения', { description: err instanceof Error ? err.message : 'Неизвестная ошибка', duration: 4000 }); }, onSuccess: () => { toast.success('Задача обновлена', { duration: 2000 }); setEditingTaskId(null); }, onSettled: () => queryClient.invalidateQueries({ queryKey: ['kanban-tasks', projectId] }), }); // Filter tasks const filteredTasks = tasks.filter(t => { if (filterPriority !== 'all' && t.priority !== filterPriority) return false; if (searchQuery && !t.title.toLowerCase().includes(searchQuery.toLowerCase()) && !(t.description?.toLowerCase().includes(searchQuery.toLowerCase()))) return false; return true; }); const proposed = filteredTasks.filter(t => t.status === 'proposed'); const approved = filteredTasks.filter(t => t.status === 'approved'); const done = filteredTasks.filter(t => t.status === 'done'); const verified = filteredTasks.filter(t => t.status === 'verified'); const handleOpenCreateModal = (status: Status) => { setCreateModalStatus(status); setCreateModalOpen(true); }; const handleTaskCreated = (newTask: Task) => { queryClient.setQueryData(['kanban-tasks', projectId], (oldData: TasksData | undefined) => { if (!oldData) return oldData; return { ...oldData, tasks: [...oldData.tasks, newTask] }; }); queryClient.invalidateQueries({ queryKey: ['kanban-tasks', projectId] }); }; // Export tasks const handleExport = (format: 'csv' | 'md') => { if (format === 'csv') { const header = 'id,title,status,priority,deadline,description,createdAt\n'; const rows = tasks.map(t => [t.id, `"${t.title.replace(/"/g, '""')}"`, t.status, t.priority, t.deadline || '', `"${(t.description || '').replace(/"/g, '""')}"`, t.createdAt || ''].join(',') ).join('\n'); downloadFile(`tasks-${projectId}.csv`, header + rows, 'text/csv'); } else { const lines = ['# Задачи ' + (projectNames[projectId || ''] || projectId), '']; const sections: [string, Task[]][] = [['Предложения', tasks.filter(t => t.status === 'proposed')], ['Одобрено', tasks.filter(t => t.status === 'approved')], ['Сделано', tasks.filter(t => t.status === 'done')], ['Проверено', tasks.filter(t => t.status === 'verified')]]; for (const [label, taskList] of sections) { if (taskList.length === 0) continue; lines.push(`## ${label}`); for (const t of taskList) { lines.push(`- **${t.title}** [${priorityLabels[t.priority]}]${t.deadline ? ` 📅 ${t.deadline}` : ''}`); if (t.description) lines.push(` ${t.description}`); } lines.push(''); } downloadFile(`tasks-${projectId}.md`, lines.join('\n'), 'text/markdown'); } }; function downloadFile(filename: string, content: string, mime: string) { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); toast.success(`Экспорт: ${filename}`, { duration: 2000 }); } // --- DnD --- const handleDragStart = (event: DragStartEvent) => { const draggingTask = tasks.find(t => t.id === event.active.id); setActiveId(event.active.id as string); setActiveTask(draggingTask || null); }; const handleDragOver = (_event: DragOverEvent) => {}; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; setActiveId(null); setActiveTask(null); if (!over) return; let newStatus: Status | undefined; const isColumnHeader = ['proposed', 'approved', 'done', 'verified'].includes(over.id as string); if (isColumnHeader) { newStatus = over.id as Status; } else { const targetTask = tasks.find(t => t.id === over.id); if (targetTask) newStatus = targetTask.status; } const currentTask = tasks.find(t => t.id === active.id); if (currentTask && newStatus && currentTask.status !== newStatus) { moveTask({ taskId: currentTask.id, newStatus }); } }; // Task card with edit/archive/delete const TaskCardItem = ({ task, isSaving, isDragging }: { task: Task; isSaving: boolean; isDragging: boolean }) => { const [confirmDelete, setConfirmDelete] = useState(false); const isEditing = editingTaskId === task.id; const [editTitle, setEditTitle] = useState(task.title); const [editDesc, setEditDesc] = useState(task.description || ''); const [editPriority, setEditPriority] = useState<Priority>(task.priority); const [editDeadline, setEditDeadline] = useState(task.deadline || ''); const titleRef = useRef<HTMLInputElement>(null); const deadlineInfo = task.deadline ? formatDeadline(task.deadline) : null; const startEdit = () => { setEditTitle(task.title); setEditDesc(task.description || ''); setEditPriority(task.priority); setEditDeadline(task.deadline || ''); setEditingTaskId(task.id); setTimeout(() => titleRef.current?.focus(), 50); }; const saveEdit = () => { if (!editTitle.trim()) return; updateTask({ taskId: task.id, updates: { title: editTitle.trim(), description: editDesc.trim() || undefined, priority: editPriority, deadline: editDeadline || undefined } }); }; const cancelEdit = () => setEditingTaskId(null); if (isEditing) { return ( <div className="bg-white/10 rounded-lg p-3 border border-primary/40"> <input ref={titleRef} value={editTitle} onChange={e => setEditTitle(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') saveEdit(); if (e.key === 'Escape') cancelEdit(); }} className="w-full bg-white/10 border border-white/20 rounded px-2 py-1 text-sm text-white mb-2 focus:outline-none focus:ring-1 focus:ring-primary" placeholder="Название" /> <textarea value={editDesc} onChange={e => setEditDesc(e.target.value)} className="w-full bg-white/10 border border-white/20 rounded px-2 py-1 text-xs text-gray-300 mb-2 focus:outline-none focus:ring-1 focus:ring-primary resize-none" rows={2} placeholder="Описание" /> <div className="flex gap-2 mb-2"> <select value={editPriority} onChange={e => setEditPriority(e.target.value as Priority)} className="flex-1 bg-white/10 border border-white/20 rounded px-2 py-1 text-xs text-white focus:outline-none" > {(['low', 'medium', 'high', 'critical'] as Priority[]).map(p => ( <option key={p} value={p} className="bg-slate-800">{priorityLabels[p]}</option> ))} </select> <input type="date" value={editDeadline} onChange={e => setEditDeadline(e.target.value)} className="flex-1 bg-white/10 border border-white/20 rounded px-2 py-1 text-xs text-white focus:outline-none" /> </div> <div className="flex gap-1"> <button onClick={saveEdit} className="flex-1 flex items-center justify-center gap-1 py-1 bg-primary/30 text-primary rounded text-xs hover:bg-primary/50 transition-colors" type="button"> <Check className="w-3 h-3" /> Сохранить </button> <button onClick={cancelEdit} className="flex-1 flex items-center justify-center gap-1 py-1 bg-white/10 text-gray-400 rounded text-xs hover:bg-white/20 transition-colors" type="button"> <X className="w-3 h-3" /> Отмена </button> </div> </div> ); } return ( <div className={`bg-white/5 rounded-lg p-3 border transition-colors cursor-grab active:cursor-grabbing group/card ${isSaving ? 'border-primary/50 opacity-70' : isDragging ? 'opacity-0' : 'border-white/10 hover:border-primary/50 hover:bg-white/10'}`}> <div className="text-sm text-white mb-1 flex items-start justify-between gap-2"> <span className="font-medium leading-snug">{task.title}</span> <div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover/card:opacity-100 transition-opacity"> {isSaving && <Loader2 className="w-3 h-3 animate-spin text-primary" />} {!isSaving && !confirmDelete && ( <> <button onClick={startEdit} className="p-1 rounded text-gray-600 hover:text-blue-400 hover:bg-white/10 transition-colors" title="Редактировать" type="button"> <Pencil className="w-3.5 h-3.5" /> </button> <button onClick={() => archiveTask(task.id)} className="p-1 rounded text-gray-600 hover:text-yellow-400 hover:bg-white/10 transition-colors" title="В архив" type="button"> <Archive className="w-3.5 h-3.5" /> </button> <button onClick={() => setConfirmDelete(true)} className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-white/10 transition-colors" title="Удалить" type="button"> <Trash2 className="w-3.5 h-3.5" /> </button> </> )} {confirmDelete && ( <div className="flex items-center gap-1"> <span className="text-[10px] text-red-400 whitespace-nowrap">Удалить?</span> <button onClick={() => { deleteTask(task.id); setConfirmDelete(false); }} className="px-1.5 py-0.5 text-[10px] bg-red-500/20 text-red-400 rounded hover:bg-red-500/40 transition-colors" type="button">Да</button> <button onClick={() => setConfirmDelete(false)} className="px-1.5 py-0.5 text-[10px] bg-white/10 text-gray-400 rounded hover:bg-white/20 transition-colors" type="button">Нет</button> </div> )} </div> </div> {task.description && <div className="text-xs text-gray-400 mb-2 line-clamp-2">{task.description}</div>} <div className="flex items-center justify-between mt-2 gap-2"> <span className={`inline-block px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wide ${priorityColors[task.priority]}`}> {priorityLabels[task.priority]} </span> {deadlineInfo && ( <span className={`text-[10px] px-1.5 py-0.5 rounded ${deadlineInfo.overdue ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-gray-400'}`}> 📅 {deadlineInfo.label} </span> )} </div> {task.tags && task.tags.length > 0 && ( <div className="flex flex-wrap gap-1 mt-2"> {task.tags.slice(0, 3).map(tag => ( <span key={tag} className="text-[10px] px-1 py-0.5 bg-white/10 rounded text-gray-500">#{tag}</span> ))} </div> )} </div> ); }; const renderColumn = (title: string, icon: React.ReactNode, columnTasks: Task[], count: number, status: Status) => ( <SortableContext items={[status, ...columnTasks.map(t => t.id)]} strategy={rectSortingStrategy}> <div className="bg-white/5 rounded-xl p-4 border border-white/10 min-h-[400px] flex flex-col"> <SortableTaskCard id={status}> <div className="flex items-center justify-between mb-4 pb-2 border-b border-white/5"> <h3 className="text-sm font-semibold text-gray-400 uppercase flex items-center gap-2"> {icon} {title} </h3> <div className="flex items-center gap-2"> <button onClick={() => handleOpenCreateModal(status)} className="p-1 hover:bg-primary/20 rounded-lg transition-colors group" title="Создать задачу"> <Plus className="w-4 h-4 text-gray-400 group-hover:text-primary" /> </button> <span className="px-2 py-1 bg-primary rounded-full text-xs text-white font-semibold">{count}</span> </div> </div> </SortableTaskCard> <div className="flex-1 space-y-3 min-h-[50px]"> {columnTasks.map((task) => ( <SortableTaskCard key={task.id} id={task.id}> <TaskCardItem task={task} isSaving={savingTaskId === task.id} isDragging={activeId === task.id} /> </SortableTaskCard> ))} </div> </div> </SortableContext> ); const ArchiveView = () => ( <div> {archivedTasks.length === 0 ? ( <div className="text-center py-20 text-gray-500">Архив пуст</div> ) : ( <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> {archivedTasks.map(task => { const [confirmDel, setConfirmDel] = useState(false); return ( <div key={task.id} className="bg-white/5 rounded-lg p-3 border border-white/10 hover:border-white/20 transition-colors"> <div className="flex items-start justify-between gap-2 mb-1"> <span className="text-sm text-gray-300 font-medium">{task.title}</span> <div className="flex items-center gap-1 shrink-0"> <button onClick={() => restoreTask(task.id)} className="p-1 rounded text-gray-600 hover:text-green-400 hover:bg-white/10 transition-colors" title="Восстановить" type="button"> <RotateCcw className="w-3.5 h-3.5" /> </button> {!confirmDel ? ( <button onClick={() => setConfirmDel(true)} className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-white/10 transition-colors" title="Удалить" type="button"> <Trash2 className="w-3.5 h-3.5" /> </button> ) : ( <div className="flex items-center gap-1"> <button onClick={() => { deleteArchivedTask(task.id); setConfirmDel(false); }} className="px-1.5 py-0.5 text-[10px] bg-red-500/20 text-red-400 rounded" type="button">Да</button> <button onClick={() => setConfirmDel(false)} className="px-1.5 py-0.5 text-[10px] bg-white/10 text-gray-400 rounded" type="button">Нет</button> </div> )} </div> </div> {task.description && <p className="text-xs text-gray-500 mb-2 line-clamp-2">{task.description}</p>} <div className="flex items-center gap-2 text-[10px] text-gray-600"> <span className={`px-1.5 py-0.5 rounded uppercase font-bold ${priorityColors[task.priority]}`}>{priorityLabels[task.priority]}</span> <span>{formatStatus(task.status)}</span> <span className="ml-auto">Архив: {new Date(task.archivedAt).toLocaleDateString('ru')}</span> </div> </div> ); })} </div> )} </div> ); const projectNames: Record<string, string> = { system: 'Задачи дашборда', 'bender-bot': 'Bender Bot', piewell: 'Piewell.com', 'futures-screener': 'Futures Screener', affiliate: 'Affiliate Marketing', ideas: 'Ideas Pipeline', alphapulse: 'AlphaPulse', 'options-screener': 'Опционный скринер', 'youtube-ai-channel': 'YouTube AI Channel', }; const dropAnimation = { sideEffects: defaultDropAnimationSideEffects({ styles: { active: { opacity: '0.5' } } }), }; const overdueCount = tasks.filter(t => t.deadline && new Date(t.deadline) < new Date()).length; const content = ( <> {/* Header row */} <div className="flex items-center justify-between mb-4 flex-wrap gap-2"> {!embedded && ( <DialogTitle className="text-xl"> {projectId ? projectNames[projectId] || `Задачи: ${projectId}` : 'Все задачи'} {overdueCount > 0 && ( <span className="ml-2 px-2 py-0.5 text-xs bg-red-500/20 text-red-400 rounded-full"> {overdueCount} просрочено </span> )} </DialogTitle> )} </div> {/* Tabs */} <div className="flex items-center justify-between mb-4 flex-wrap gap-2"> <div className="flex items-center gap-1 bg-white/5 rounded-lg p-1"> <button onClick={() => setActiveTab('board')} className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${activeTab === 'board' ? 'bg-primary text-white' : 'text-gray-400 hover:text-white'}`} type="button" > Доска </button> <button onClick={() => setActiveTab('archive')} className={`px-3 py-1.5 rounded text-sm font-medium transition-colors flex items-center gap-1.5 ${activeTab === 'archive' ? 'bg-primary text-white' : 'text-gray-400 hover:text-white'}`} type="button" > <Archive className="w-3.5 h-3.5" /> Архив {archivedTasks.length > 0 && ( <span className="px-1.5 py-0.5 text-[10px] bg-white/20 rounded-full">{archivedTasks.length}</span> )} </button> </div> {activeTab === 'board' && ( <div className="flex items-center gap-2 flex-wrap"> {/* Search */} <div className="relative"> <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500" /> <input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Поиск..." className="pl-7 pr-3 py-1.5 bg-white/5 border border-white/10 rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary w-36" /> </div> {/* Priority filter */} <div className="relative flex items-center"> <Filter className="absolute left-2 w-3.5 h-3.5 text-gray-500" /> <select value={filterPriority} onChange={e => setFilterPriority(e.target.value as Priority | 'all')} className="pl-7 pr-3 py-1.5 bg-white/5 border border-white/10 rounded-lg text-sm text-white focus:outline-none appearance-none" > <option value="all" className="bg-slate-800">Все</option> <option value="critical" className="bg-slate-800">Критичный</option> <option value="high" className="bg-slate-800">Высокий</option> <option value="medium" className="bg-slate-800">Средний</option> <option value="low" className="bg-slate-800">Низкий</option> </select> </div> {/* Export */} <div className="flex items-center gap-1"> <button onClick={() => handleExport('md')} className="flex items-center gap-1 px-2 py-1.5 bg-white/5 border border-white/10 rounded-lg text-xs text-gray-400 hover:text-white hover:bg-white/10 transition-colors" title="Экспорт MD" type="button"> <Download className="w-3.5 h-3.5" /> MD </button> <button onClick={() => handleExport('csv')} className="flex items-center gap-1 px-2 py-1.5 bg-white/5 border border-white/10 rounded-lg text-xs text-gray-400 hover:text-white hover:bg-white/10 transition-colors" title="Экспорт CSV" type="button"> <Download className="w-3.5 h-3.5" /> CSV </button> </div> </div> )} </div> <div className="flex-1 w-full h-full relative"> {isLoading && ( <div className="absolute inset-0 flex items-center justify-center bg-black/20 z-10 backdrop-blur-sm rounded-xl"> <Loader2 className="w-8 h-8 animate-spin text-primary" /> </div> )} {error && ( <div className="text-center py-20 text-danger bg-red-500/10 rounded-xl border border-red-500/20"> Ошибка загрузки задач. Проверьте подключение к Secretary API. </div> )} {!error && activeTab === 'archive' && <ArchiveView />} {!error && activeTab === 'board' && ( <DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 h-full"> {renderColumn('Предложения', <Lightbulb className="w-4 h-4 text-emerald-400" />, proposed, proposed.length, 'proposed')} {renderColumn('Одобрено', <Clock className="w-4 h-4 text-blue-400" />, approved, approved.length, 'approved')} {renderColumn('Сделано', <CheckCircle2 className="w-4 h-4 text-purple-400" />, done, done.length, 'done')} {renderColumn('Проверено', <Shield className="w-4 h-4 text-orange-400" />, verified, verified.length, 'verified')} </div> <DragOverlay dropAnimation={dropAnimation}> {activeTask ? ( <div className="bg-slate-800 rounded-xl p-3 border border-primary/50 shadow-2xl shadow-primary/20 transform rotate-3 scale-105 cursor-grabbing"> <div className="text-sm text-white mb-2 font-medium">{activeTask.title}</div> {activeTask.description && <div className="text-xs text-gray-400 mb-2 line-clamp-2">{activeTask.description}</div>} <span className={`inline-block px-2 py-1 rounded text-[10px] uppercase font-bold tracking-wide mt-1 ${priorityColors[activeTask.priority]}`}> {priorityLabels[activeTask.priority]} </span> </div> ) : null} </DragOverlay> </DndContext> )} </div> {projectId && ( <CreateTaskModal open={createModalOpen} onOpenChange={setCreateModalOpen} projectId={projectId} status={createModalStatus} onTaskCreated={handleTaskCreated} /> )} </> ); if (embedded) { return <div className="h-full w-full flex flex-col">{content}</div>; } return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col p-6"> <DialogHeader className="shrink-0"> <DialogTitle> {projectId ? projectNames[projectId] || `Задачи: ${projectId}` : 'Все задачи'} {overdueCount > 0 && ( <span className="ml-2 px-2 py-0.5 text-xs bg-red-500/20 text-red-400 rounded-full"> {overdueCount} просрочено </span> )} </DialogTitle> <DialogClose onClick={() => onOpenChange(false)} /> </DialogHeader> <DialogBody className="flex-1 overflow-y-auto min-h-0 py-4 -mx-6 px-6"> {content} </DialogBody> </DialogContent> </Dialog> ); }