← Назад
import { authFetch } from '../../lib/api'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import type { ReactNode } from 'react'; import { useState } from 'react'; import { ChevronUp, ChevronDown, GripVertical, ChevronRight, Palette, PlusCircle, GitBranch } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; interface SortableProjectCardProps { id: string; children: ReactNode; onMoveUp?: () => void; onMoveDown?: () => void; isFirst?: boolean; isLast?: boolean; isCollapsed?: boolean; onToggleCollapse?: () => void; cardColor?: string; onColorChange?: (color: string) => void; onAddTask?: () => void; } const PRESET_COLORS = [ '#3b82f6', // blue (primary) '#10b981', // emerald '#f59e0b', // amber '#ef4444', // red '#8b5cf6', // violet '#ec4899', // pink '#0088cc', // telegram blue '#ff0000', // youtube red '#6b7280', // gray ]; async function fetchTaskStats(projectId: string) { const res = await authFetch(`/api/projects/${projectId}/kanban-tasks`); if (!res.ok) return null; const data = await res.json(); return data.stats as { total: number; proposed: number; approved: number }; } async function fetchDeployLog(projectId: string) { const res = await authFetch(`/api/deploy-logs/${projectId}`); if (!res.ok) return null; const logs = await res.json(); return logs[0] ?? null; // most recent } export function SortableProjectCard({ id, children, onMoveUp, onMoveDown, isFirst, isLast, isCollapsed, onToggleCollapse, cardColor, onColorChange, onAddTask, }: SortableProjectCardProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id }); const [showColorPicker, setShowColorPicker] = useState(false); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1, zIndex: isDragging ? 50 : 1, }; const { data: stats } = useQuery({ queryKey: ['kanban-tasks-stats', id], queryFn: () => fetchTaskStats(id), refetchInterval: 60000, staleTime: 30000, }); const { data: deployLog } = useQuery({ queryKey: ['deploy-log', id], queryFn: () => fetchDeployLog(id), refetchInterval: 120000, staleTime: 60000, }); // Overdue count calculation — placeholder for future implementation // const overdueCount = useMemo(() => { ... }, [tasks]); const deployStatusColor = !deployLog ? '' : deployLog.status === 'success' ? 'text-emerald-400' : deployLog.status === 'failure' ? 'text-red-400' : 'text-gray-400'; const deployRelative = deployLog ? (() => { const diff = Math.round((Date.now() - new Date(deployLog.timestamp).getTime()) / 60000); if (diff < 60) return `${diff}м назад`; if (diff < 1440) return `${Math.round(diff / 60)}ч назад`; return `${Math.round(diff / 1440)}д назад`; })() : null; return ( <div ref={setNodeRef} style={style}> {/* Control strip */} <div className="flex items-center justify-between px-1 pb-1 gap-1"> {/* Left: drag handle + collapse */} <div className="flex items-center gap-0.5"> <div {...attributes} {...listeners} className="flex items-center gap-1 px-1 py-0.5 rounded cursor-grab active:cursor-grabbing text-gray-600 hover:text-gray-400 hover:bg-white/5 transition-colors touch-none select-none" title="Перетащить" > <GripVertical className="w-4 h-4" /> </div> <button onClick={onToggleCollapse} className="p-1 rounded text-gray-600 hover:text-gray-300 hover:bg-white/5 transition-colors" title={isCollapsed ? 'Развернуть' : 'Свернуть'} type="button" > <ChevronRight className={`w-4 h-4 transition-transform duration-200 ${isCollapsed ? '' : 'rotate-90'}`} /> </button> </div> {/* Center: task count + deploy badge */} <div className="flex items-center gap-2 flex-1 min-w-0"> {stats && stats.total > 0 && ( <div className="flex items-center gap-1 text-[10px] text-gray-500"> <span className="px-1.5 py-0.5 bg-white/5 rounded-full"> {stats.proposed > 0 && <span className="text-blue-400">{stats.proposed}</span>} {stats.proposed > 0 && stats.approved > 0 && <span className="text-gray-600"> / </span>} {stats.approved > 0 && <span className="text-emerald-400">{stats.approved}</span>} {(stats.proposed > 0 || stats.approved > 0) && <span className="text-gray-600"> задач</span>} </span> </div> )} {deployLog && ( <div className={`flex items-center gap-1 text-[10px] ${deployStatusColor} min-w-0`} title={`${deployLog.branch} · ${deployLog.commit} · ${deployLog.actor}`}> <GitBranch className="w-3 h-3 shrink-0" /> <span className="truncate">{deployRelative}</span> </div> )} </div> {/* Right: color picker + quick-create + arrows */} <div className="flex items-center gap-0.5"> {onAddTask && ( <button onClick={onAddTask} className="p-1 rounded text-gray-600 hover:text-primary hover:bg-white/5 transition-colors" title="Быстрая задача" type="button" > <PlusCircle className="w-4 h-4" /> </button> )} <div className="relative"> <button onClick={() => setShowColorPicker(v => !v)} className="p-1 rounded text-gray-600 hover:text-gray-300 hover:bg-white/5 transition-colors" title="Цвет карточки" type="button" > <Palette className="w-4 h-4" style={cardColor ? { color: cardColor } : undefined} /> </button> {showColorPicker && ( <div className="absolute right-0 top-7 z-50 bg-slate-800 border border-white/10 rounded-xl p-2 shadow-xl flex flex-wrap gap-1 w-[108px]"> {PRESET_COLORS.map(c => ( <button key={c} type="button" onClick={() => { onColorChange?.(c); setShowColorPicker(false); }} className={`w-7 h-7 rounded-full border-2 transition-transform hover:scale-110 ${cardColor === c ? 'border-white' : 'border-transparent'}`} style={{ background: c }} title={c} /> ))} <button type="button" onClick={() => { onColorChange?.(''); setShowColorPicker(false); }} className="w-full text-[10px] text-gray-500 hover:text-gray-300 pt-1 transition-colors" > Сбросить </button> </div> )} </div> <button onClick={onMoveUp} disabled={isFirst} className="p-1 rounded text-gray-600 hover:text-gray-200 hover:bg-white/10 disabled:opacity-20 disabled:cursor-not-allowed transition-colors" title="Переместить вверх" type="button" > <ChevronUp className="w-4 h-4" /> </button> <button onClick={onMoveDown} disabled={isLast} className="p-1 rounded text-gray-600 hover:text-gray-200 hover:bg-white/10 disabled:opacity-20 disabled:cursor-not-allowed transition-colors" title="Переместить вниз" type="button" > <ChevronDown className="w-4 h-4" /> </button> </div> </div> {/* Card content */} {!isCollapsed && ( <div className={isDragging ? 'shadow-2xl scale-[1.02] transition-transform' : ''} style={cardColor ? { '--card-accent': cardColor, borderLeftColor: cardColor } as React.CSSProperties : undefined} > {children} </div> )} {/* Collapsed placeholder */} {isCollapsed && ( <div className="bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 flex items-center gap-2 text-sm text-gray-500 cursor-pointer hover:bg-white/8 transition-colors" onClick={onToggleCollapse} style={cardColor ? { borderLeftColor: cardColor, borderLeftWidth: '4px' } : undefined} > <ChevronRight className="w-4 h-4" /> <span className="font-medium text-gray-400">{id}</span> {stats && stats.total > 0 && ( <span className="ml-auto text-[11px] text-gray-600">{stats.total} задач</span> )} </div> )} </div> ); }