/** * Tree Sidebar - Hierarchical Navigation + Drag & Drop * * Features: * - Main navigation views (All, Today, Upcoming, Logbook, Trash) * - Hierarchical project tree with drag-and-drop reordering * - User list with task assignment via drag-and-drop * - Collapsible sections */ import React, { useState, useEffect, useCallback, type ReactNode } from 'react' import { VIEWS, VIEW_CONFIG, type CurrentView, type ViewType } from './tree-constants' import { useTreeDragDrop } from './tree-hooks' // ============================================================================ // Types // ============================================================================ interface ProjectNode { id: string title: string color?: string parentId?: string | null children?: ProjectNode[] totalTaskCount?: number totalCompleted?: number } interface User { id: string name?: string email?: string color?: string imageUrl?: string isCustom?: boolean } interface TaskCounts { [key: string]: { total: number; completed: number; uncompleted: number } | number } interface TreeSidebarProps { currentView: CurrentView onViewChange: (view: CurrentView) => void taskCounts?: TaskCounts projectTree?: ProjectNode[] onAddProject?: (parentId: string | null) => void onEditProject?: (project: ProjectNode) => void onReorderProject?: (draggedId: string, targetId: string | null, position: 'before' | 'after' | 'inside') => void onItemDrop?: (itemId: string, projectId: string) => void onItemDropOnUser?: (itemId: string, userId: string | null) => void onItemDragEnd?: () => void allUsers?: User[] currentUser?: User onManageUsers?: () => void width?: number isReadOnly?: boolean getDisplayName?: (user: User) => string header?: ReactNode footer?: ReactNode } // ============================================================================ // Icon Component // ============================================================================ interface IconProps { name: string size?: number className?: string style?: React.CSSProperties } function Icon({ name, size = 16, className = '', style }: IconProps) { const iconMap: Record = { list: ( ), star: ( ), calendar: ( ), 'book-open': ( ), 'trash-2': ( ), 'chevron-right': ( ), 'chevron-down': ( ), plus: ( ), pencil: ( ), users: ( ), 'user-plus': ( ), } return <>{iconMap[name] || } } // ============================================================================ // Project Tree Node // ============================================================================ interface ProjectTreeNodeProps { node: ProjectNode depth: number currentView: CurrentView onViewChange: (view: CurrentView) => void expandedProjects: Record toggleExpand: (id: string) => void onAddProject?: (parentId: string | null) => void onEditProject?: (project: ProjectNode) => void dragState: ReturnType['dragState'] dragHandlers: ReturnType['handlers'] onItemDrop?: (itemId: string, projectId: string) => void onItemDragEnd?: () => void isReadOnly?: boolean } function ProjectTreeNode({ node, depth, currentView, onViewChange, expandedProjects, toggleExpand, onAddProject, onEditProject, dragState, dragHandlers, onItemDrop, onItemDragEnd, isReadOnly, }: ProjectTreeNodeProps) { const [isHovered, setIsHovered] = useState(false) const [isItemDragOver, setIsItemDragOver] = useState(false) const isActive = currentView.type === VIEWS.PROJECT && currentView.id === node.id const isExpanded = expandedProjects[node.id] const hasChildren = node.children && node.children.length > 0 const isDragging = dragState.draggingId === node.id const isDropTarget = dragState.dropTargetId === node.id const dropPosition = isDropTarget ? dragState.dropPosition : null const handleDragOver = (e: React.DragEvent) => { if (isReadOnly) return const isItemDrag = e.dataTransfer.types.includes('application/x-item') if (isItemDrag) { e.preventDefault() e.stopPropagation() e.dataTransfer.dropEffect = 'move' if (!isItemDragOver) setIsItemDragOver(true) } else { dragHandlers.onDragOver(e, node.id) } } const handleDragLeave = (e: React.DragEvent) => { if (isReadOnly) return if (!e.currentTarget.contains(e.relatedTarget as Node)) { setIsItemDragOver(false) dragHandlers.onDragLeave(e) } } const handleDrop = (e: React.DragEvent) => { if (isReadOnly) return const itemId = e.dataTransfer.getData('application/x-item') if (itemId && onItemDrop) { e.preventDefault() e.stopPropagation() onItemDrop(itemId, node.id) setIsItemDragOver(false) if (onItemDragEnd) onItemDragEnd() } else { dragHandlers.onDrop(e, node.id) } } return (
!isReadOnly && dragHandlers.onDragStart(e, node)} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onDragEnd={dragHandlers.onDragEnd} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} className={` flex items-center gap-1 py-1.5 px-2 rounded-md transition-colors cursor-grab relative ${isActive ? 'bg-primary/10' : ''} ${isItemDragOver ? 'bg-primary/10' : ''} `} style={{ paddingLeft: 8 + depth * 16 }} > {dropPosition === 'before' && (
)} {dropPosition === 'after' && (
)} {dropPosition === 'inside' && (
)} {(node.totalTaskCount ?? 0) > 0 && ( {(node.totalTaskCount ?? 0) - (node.totalCompleted ?? 0)}/{node.totalTaskCount} )} {!isReadOnly && (
{onAddProject && ( )} {onEditProject && ( )}
)}
{isExpanded && hasChildren && (
{node.children!.map(child => ( ))}
)}
) } // ============================================================================ // User List Item // ============================================================================ interface UserListItemProps { user: User isActive: boolean isYou: boolean displayName: string taskCount?: { total: number; completed: number } onViewChange: (view: CurrentView) => void onItemDropOnUser?: (itemId: string, userId: string | null) => void onItemDragEnd?: () => void isReadOnly?: boolean } function UserListItem({ user, isActive, isYou, displayName, taskCount, onViewChange, onItemDropOnUser, onItemDragEnd, isReadOnly, }: UserListItemProps) { const [isItemDragOver, setIsItemDragOver] = useState(false) const handleDragOver = (e: React.DragEvent) => { if (isReadOnly) return const isItemDrag = e.dataTransfer.types.includes('application/x-item') if (isItemDrag) { e.preventDefault() e.stopPropagation() e.dataTransfer.dropEffect = 'move' if (!isItemDragOver) setIsItemDragOver(true) } } const handleDragLeave = (e: React.DragEvent) => { if (isReadOnly) return if (!e.currentTarget.contains(e.relatedTarget as Node)) { setIsItemDragOver(false) } } const handleDrop = (e: React.DragEvent) => { if (isReadOnly) return const itemId = e.dataTransfer.getData('application/x-item') if (itemId && onItemDropOnUser) { e.preventDefault() e.stopPropagation() onItemDropOnUser(itemId, user.id) setIsItemDragOver(false) if (onItemDragEnd) onItemDragEnd() } } const total = taskCount?.total ?? 0 const completed = taskCount?.completed ?? 0 return ( ) } // ============================================================================ // Main Sidebar Component // ============================================================================ export default function TreeSidebar({ currentView, onViewChange, taskCounts = {}, projectTree = [], onAddProject, onEditProject, onReorderProject, onItemDrop, onItemDropOnUser, onItemDragEnd, allUsers = [], currentUser, onManageUsers, width = 260, isReadOnly = false, getDisplayName, header, footer, }: TreeSidebarProps) { const [expandedSections, setExpandedSections] = useState({ projects: true, users: true }) const [expandedProjects, setExpandedProjects] = useState>({}) const { dragState, handlers: dragHandlers } = useTreeDragDrop(onReorderProject) // Auto-expand parents of active project useEffect(() => { if (currentView.type === VIEWS.PROJECT && currentView.id) { const findPath = (nodes: ProjectNode[], targetId: string, path: string[] = []): string[] | null => { for (const node of nodes) { if (node.id === targetId) return path if (node.children) { const found = findPath(node.children, targetId, [...path, node.id]) if (found) return found } } return null } const path = findPath(projectTree, currentView.id) if (path) { setExpandedProjects(prev => { const next = { ...prev } path.forEach(id => { next[id] = true }) return next }) } } }, [currentView, projectTree]) const toggleSection = (section: 'projects' | 'users') => { setExpandedSections(prev => ({ ...prev, [section]: !prev[section] })) } const toggleExpand = useCallback((projectId: string) => { setExpandedProjects(prev => ({ ...prev, [projectId]: !prev[projectId] })) }, []) const mainViews: Array<{ type: ViewType; icon: string; color: string }> = [ { type: VIEWS.ALL, icon: 'list', color: '#6366f1' }, { type: VIEWS.TODAY, icon: 'star', color: '#f59e0b' }, { type: VIEWS.UPCOMING, icon: 'calendar', color: '#ef4444' }, { type: VIEWS.LOGBOOK, icon: 'book-open', color: '#9ca3af' }, { type: VIEWS.TRASH, icon: 'trash-2', color: '#9ca3af' }, ] return (
{header}
{/* Projects Section */}
)} {expandedSections.projects && (
e.preventDefault()} onDrop={dragHandlers.onRootDrop} > {projectTree.length > 0 ? ( projectTree.map(node => ( )) ) : (
No projects yet
)}
)}
{/* Users Section */}
)} {expandedSections.users && (
{allUsers.map(user => { const isYou = currentUser?.id === user.id const displayName = getDisplayName ? getDisplayName(user) : (user.name || user.email || 'Unknown') return ( ) })} {!isReadOnly && allUsers.length === 0 && onManageUsers && ( )}
)}
{footer}
) }