← Назад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>
);
}