import React, { useEffect, useState, useRef, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useToast } from '../Shared/ToastContext'; import { PencilSquareIcon, TrashIcon, TagIcon, PlusCircleIcon, Squares2X2Icon, PlayIcon, LightBulbIcon, ClipboardDocumentListIcon, ExclamationTriangleIcon, CheckCircleIcon, ShareIcon, } from '@heroicons/react/24/outline'; import TaskList from '../Task/TaskList'; import ProjectModal from '../Project/ProjectModal'; import ConfirmDialog from '../Shared/ConfirmDialog'; import NoteModal from '../Note/NoteModal'; import { useStore } from '../../store/useStore'; import NewTask from '../Task/NewTask'; import { Project } from '../../entities/Project'; import NoteCard from '../Shared/NoteCard'; import { Task } from '../../entities/Task'; import { Note } from '../../entities/Note'; import { fetchProjectBySlug, updateProject, deleteProject, fetchProjects, } from '../../utils/projectsService'; import { createTask, deleteTask, toggleTaskToday, } from '../../utils/tasksService'; import { updateNote, deleteNote as apiDeleteNote, } from '../../utils/notesService'; import { createNote } from '../../utils/notesService'; import { isAuthError } from '../../utils/authUtils'; import { getAutoSuggestNextActionsEnabled } from '../../utils/profileService'; import AutoSuggestNextActionBox from './AutoSuggestNextActionBox'; import SortFilterButton, { SortOption } from '../Shared/SortFilterButton'; import LoadingSpinner from '../Shared/LoadingSpinner'; import { usePersistedModal } from '../../hooks/usePersistedModal'; import BannerBadge from '../Shared/BannerBadge'; import { getApiPath } from '../../config/paths'; const ProjectDetails: React.FC = () => { const { uidSlug } = useParams<{ uidSlug: string }>(); const navigate = useNavigate(); const { t } = useTranslation(); const { showSuccessToast } = useToast(); // Load areas from store (similar to how we handle tags) const { areasStore, projectsStore } = useStore(); const areas = areasStore.areas; // Load areas when component mounts useEffect(() => { if (!areasStore.hasLoaded && !areasStore.isLoading) { areasStore.loadAreas(); } }, [areasStore.hasLoaded, areasStore.isLoading, areasStore.loadAreas]); const [allProjects, setAllProjects] = useState([]); // Use local state to isolate from global store changes that cause remounting const [project, setProject] = useState(null); const [tasks, setTasks] = useState([]); const [notes, setNotes] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(false); // Use persisted modal state that survives component remounts const { isOpen: isModalOpen, openModal, closeModal, } = usePersistedModal(project?.id); const editButtonRef = useRef(null); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [showCompleted, setShowCompleted] = useState(false); const [showAutoSuggestForm, setShowAutoSuggestForm] = useState(false); const [autoSuggestEnabled, setAutoSuggestEnabled] = useState(false); const hasCheckedAutoSuggest = useRef(false); const [orderBy, setOrderBy] = useState('created_at:desc'); const [activeTab, setActiveTab] = useState<'tasks' | 'notes'>('tasks'); // Sort options for tasks const sortOptions: SortOption[] = [ { value: 'created_at:desc', label: 'Created at' }, { value: 'due_date:asc', label: 'Due date' }, { value: 'priority:desc', label: 'Priority' }, ]; // Note modal state const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); const [noteToDelete, setNoteToDelete] = useState(null); const [selectedNote, setSelectedNote] = useState(null); // Dispatch global modal events useEffect(() => { const fetchAutoSuggestSetting = async () => { if (!hasCheckedAutoSuggest.current) { hasCheckedAutoSuggest.current = true; const enabled = await getAutoSuggestNextActionsEnabled(); setAutoSuggestEnabled(enabled); } }; fetchAutoSuggestSetting(); }, []); // Load projects if not already loaded useEffect(() => { const loadProjectsIfNeeded = async () => { if (allProjects.length === 0) { try { const projectsData = await fetchProjects(); setAllProjects(projectsData); } catch (error) { console.error('Failed to fetch projects:', error); } } }; loadProjectsIfNeeded(); }, [allProjects.length]); // Check if we should show auto-suggest form for projects with no tasks useEffect(() => { if ( project && tasks.length === 0 && !loading && !showCompleted && autoSuggestEnabled ) { setShowAutoSuggestForm(true); } else { setShowAutoSuggestForm(false); } }, [project, tasks.length, loading, showCompleted, autoSuggestEnabled]); // Load initial sort order from localStorage (URL params removed to prevent conflicts) useEffect(() => { const sortParam = localStorage.getItem('project_order_by') || 'created_at:desc'; setOrderBy(sortParam); }, []); // Fetch project data when uidSlug changes useEffect(() => { if (!uidSlug) return; // Skip loading if we already have the project data for this uidSlug if ( project && project.uid && `${project.uid}-${project.name ?.toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '')}` === uidSlug ) { return; } const loadProjectData = async () => { try { // Only show loading if we don't have any project data yet if (!project) { setLoading(true); } setError(false); const projectData = await fetchProjectBySlug(uidSlug); setProject(projectData); setTasks(projectData.tasks || projectData.Tasks || []); // Load saved preferences from project data if (projectData.task_show_completed !== undefined) { setShowCompleted(projectData.task_show_completed); } if (projectData.task_sort_order) { setOrderBy(projectData.task_sort_order); } const fetchedNotes = projectData.notes || projectData.Notes || []; // Normalize tags field - backend returns 'Tags' but frontend expects 'tags' const normalizedNotes = fetchedNotes.map((note) => { if (note.Tags && !note.tags) { note.tags = note.Tags; } return note; }); setNotes(normalizedNotes); setLoading(false); } catch { setError(true); setLoading(false); } }; loadProjectData(); }, [uidSlug]); const handleTaskCreate = async (taskName: string) => { if (!project) { throw new Error('Cannot create task: Project is missing'); } try { const newTask = await createTask({ name: taskName, status: 0, // Use numeric status: 0 = not_started project_id: project.id, completed_at: null, }); setTasks([...tasks, newTask]); // Show success toast with task link const taskLink = ( {t('task.created', 'Task')}{' '} {newTask.name} {' '} {t('task.createdSuccessfully', 'created successfully!')} ); showSuccessToast(taskLink); } catch (err: any) { // Check if it's an authentication error if (isAuthError(err)) { return; } throw err; // Re-throw to allow proper error handling by NewTask component } }; const handleTaskUpdate = async (updatedTask: Task) => { if (!updatedTask.id) { return; } // Only skip API call for specific operations that already have fresh data from the server // (like toggleTaskCompletion), not for general modal updates const hasUpdatedData = updatedTask.parent_child_logic_executed !== undefined; if (hasUpdatedData) { // Use the provided data directly, preserving existing subtasks if not included setTasks( tasks.map((task) => task.id === updatedTask.id ? { ...task, ...updatedTask, // Explicitly preserve subtasks data subtasks: updatedTask.subtasks || updatedTask.Subtasks || task.subtasks || task.Subtasks || [], Subtasks: updatedTask.subtasks || updatedTask.Subtasks || task.subtasks || task.Subtasks || [], } : task ) ); return; } try { // Use direct fetch call like Tasks.tsx to ensure proper tag saving const response = await fetch(getApiPath(`task/${updatedTask.id}`), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(updatedTask), }); if (!response.ok) { await response.json(); throw new Error('Failed to update task'); } const savedTask = await response.json(); // If the task's project was changed/cleared and no longer belongs to this project, remove it // Handle both null and undefined project_id values const savedTaskProjectId = savedTask.project_id ?? null; const currentProjectId = project?.id ?? null; if (savedTaskProjectId !== currentProjectId) { setTasks(tasks.filter((task) => task.id !== updatedTask.id)); } else { // Otherwise, update the task in place setTasks( tasks.map((task) => task.id === updatedTask.id ? { ...task, ...savedTask, // Explicitly preserve subtasks data subtasks: savedTask.subtasks || savedTask.Subtasks || updatedTask.subtasks || updatedTask.Subtasks || task.subtasks || task.Subtasks || [], Subtasks: savedTask.subtasks || savedTask.Subtasks || updatedTask.subtasks || updatedTask.Subtasks || task.subtasks || task.Subtasks || [], } : task ) ); } } catch { // Error updating task - silently handled } }; const handleTaskDelete = async (taskId: number | undefined) => { if (!taskId) { return; } try { await deleteTask(taskId); setTasks(tasks.filter((task) => task.id !== taskId)); } catch { // Error deleting task - silently handled } }; const handleToggleToday = async ( taskId: number, task?: Task ): Promise => { try { const updatedTask = await toggleTaskToday(taskId, task); // Update the task in the local state immediately to avoid UI flashing setTasks( tasks.map((task) => task.id === taskId ? { ...task, today: updatedTask.today, today_move_count: updatedTask.today_move_count, } : task ) ); } catch { // Optionally refetch data on error to ensure consistency if (uidSlug) { // Refetch project data on error to ensure consistency try { const projectData = await fetchProjectBySlug(uidSlug); setProject(projectData); setTasks(projectData.tasks || projectData.Tasks || []); const fetchedNotes = projectData.notes || projectData.Notes || []; // Normalize tags field - backend returns 'Tags' but frontend expects 'tags' const normalizedNotes = fetchedNotes.map((note) => { if (note.Tags && !note.tags) { note.tags = note.Tags; } return note; }); setNotes(normalizedNotes); } catch { // Error refetching project data - silently handled } } } }; // Setup native event listener for edit button to avoid React event system conflicts useEffect(() => { const button = editButtonRef.current; if (button) { const handleClick = (e: Event) => { e.preventDefault(); e.stopPropagation(); openModal(); }; button.addEventListener('click', handleClick); return () => { button.removeEventListener('click', handleClick); }; } }, [openModal]); const handleSaveProject = async (updatedProject: Project) => { if (!updatedProject.uid) { return; } try { const savedProject = await updateProject( updatedProject.uid, updatedProject ); // Merge the saved project with existing project to preserve area data setProject((prevProject) => ({ ...savedProject, // Preserve area info if it's missing from the response area: savedProject.area || prevProject?.area, Area: (savedProject as any).Area || (prevProject as any)?.Area, })); closeModal(); } catch { // Error saving project - silently handled } }; const handleCreateNextAction = async ( projectId: number, actionDescription: string ) => { try { const newTask = await createTask({ name: actionDescription, status: 0, // Use numeric status: 0 = not_started project_id: projectId, priority: 0, // Use numeric priority: 0 = low completed_at: null, }); // Update the tasks list to include the new task setTasks([...tasks, newTask]); setShowAutoSuggestForm(false); // Show success toast with task link const taskLink = ( {t('task.created', 'Task')}{' '} {newTask.name} {' '} {t('task.createdSuccessfully', 'created successfully!')} ); showSuccessToast(taskLink); } catch { // Error creating next action - silently handled } }; const handleSkipNextAction = () => { setShowAutoSuggestForm(false); }; const saveProjectPreferences = async ( showCompleted: boolean, orderBy: string ) => { if (!project?.id) return; try { // Save preferences directly via API call const response = await fetch(getApiPath(`project/${project.id}`), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ task_show_completed: showCompleted, task_sort_order: orderBy, }), }); if (!response.ok) { throw new Error('Failed to save project preferences'); } } catch (error) { console.error('Error saving project preferences:', error); } }; const handleShowCompletedChange = (checked: boolean) => { setShowCompleted(checked); // Save to project (remove navigation to prevent re-render) saveProjectPreferences(checked, orderBy); }; const handleSortChange = (newOrderBy: string) => { setOrderBy(newOrderBy); // Save to project saveProjectPreferences(showCompleted, newOrderBy); }; const handleDeleteProject = async () => { if (!project?.uid) { return; } try { await deleteProject(project.uid); // Update the global projects store to remove the deleted project const currentProjects = projectsStore.projects; const updatedProjects = currentProjects.filter( (p) => p.uid !== project.uid ); projectsStore.setProjects(updatedProjects); navigate('/projects'); } catch { // Error deleting project - silently handled } }; // Note handlers const handleEditNote = async (note: Note) => { try { // Fetch the complete note data including tags const response = await fetch(getApiPath(`note/${note.uid}`), { credentials: 'include', headers: { Accept: 'application/json' }, }); if (response.ok) { const fullNote = await response.json(); setSelectedNote(fullNote); } else { // Fallback to the original note if fetch fails setSelectedNote(note); } } catch (error) { // Fallback to the original note if fetch fails console.error('Error fetching note details:', error); setSelectedNote(note); } setIsNoteModalOpen(true); }; const handleDeleteNote = async (noteIdentifier: string) => { try { await apiDeleteNote(noteIdentifier); setNotes( notes.filter((n) => { const currentIdentifier = n.uid ?? (n.id !== undefined ? String(n.id) : undefined); return currentIdentifier !== noteIdentifier; }) ); setNoteToDelete(null); setIsConfirmDialogOpen(false); } catch { // Error deleting note - silently handled } }; // Create or update note and keep local notes list in sync const handleSaveNote = async (noteData: Note) => { try { let savedNote: Note; const noteIdentifier = noteData.uid ?? (noteData.id !== undefined ? String(noteData.id) : null); let isUpdate = false; if (noteIdentifier) { savedNote = await updateNote(noteIdentifier, noteData); isUpdate = true; } else { savedNote = await createNote(noteData); } // Normalize tags field - backend returns 'Tags' but frontend expects 'tags' if ((savedNote as any).Tags && !(savedNote as any).tags) { (savedNote as any).tags = (savedNote as any).Tags; } // If updated note moved to another project, remove it from this list // Handle both null and undefined project_id values const savedNoteProjectId = savedNote.project_id ?? null; const currentProjectId = project?.id ?? null; if (savedNote.id && savedNoteProjectId !== currentProjectId) { setNotes(notes.filter((n) => n.id !== savedNote.id)); } else if (isUpdate) { const savedIdentifier = savedNote.uid ?? (savedNote.id !== undefined ? String(savedNote.id) : null); setNotes( notes.map((n) => { const currentIdentifier = n.uid ?? (n.id !== undefined ? String(n.id) : undefined); return currentIdentifier === savedIdentifier ? savedNote : n; }) ); } else { setNotes([savedNote, ...notes]); } setIsNoteModalOpen(false); setSelectedNote(null); } catch { // Error saving note - silently handled } }; // Filter and sort tasks (backend filtering/sorting not working reliably) const displayTasks = useMemo(() => { // First, filter tasks based on completed state let filteredTasks; if (showCompleted) { // Show only completed tasks (done=2 or archived=3) filteredTasks = tasks.filter( (task) => task.status === 'done' || task.status === 'archived' || task.status === 2 || task.status === 3 ); } else { // Show only non-completed tasks (not_started=0, in_progress=1) filteredTasks = tasks.filter( (task) => task.status === 'not_started' || task.status === 'in_progress' || task.status === 0 || task.status === 1 ); } // Then, sort the filtered tasks const sortedTasks = [...filteredTasks].sort((a, b) => { const [field, direction] = orderBy.split(':'); const isAsc = direction === 'asc'; let valueA, valueB; switch (field) { case 'name': valueA = a.name?.toLowerCase() || ''; valueB = b.name?.toLowerCase() || ''; break; case 'due_date': valueA = a.due_date ? new Date(a.due_date).getTime() : 0; valueB = b.due_date ? new Date(b.due_date).getTime() : 0; break; case 'priority': { // Convert priority to numeric for sorting (high=2, medium=1, low=0) const priorityMap = { high: 2, medium: 1, low: 0 }; valueA = typeof a.priority === 'string' ? priorityMap[a.priority] || 0 : a.priority || 0; valueB = typeof b.priority === 'string' ? priorityMap[b.priority] || 0 : b.priority || 0; break; } case 'status': valueA = typeof a.status === 'string' ? a.status : a.status || 0; valueB = typeof b.status === 'string' ? b.status : b.status || 0; break; case 'created_at': default: valueA = a.created_at ? new Date(a.created_at).getTime() : 0; valueB = b.created_at ? new Date(b.created_at).getTime() : 0; break; } if (valueA < valueB) return isAsc ? -1 : 1; if (valueA > valueB) return isAsc ? 1 : -1; return 0; }); return sortedTasks; }, [tasks, showCompleted, orderBy]); // Function to get the appropriate icon for project state const getStateIcon = (state: string) => { switch (state) { case 'idea': return ( ); case 'planned': return ( ); case 'in_progress': return ( ); case 'blocked': return ( ); case 'completed': return ( ); default: return ( ); } }; if (loading) { return ; } if (error) { return (
Failed to load project details.
); } if (!project) { return (
Project not found.
); } return (
{/* Project Banner - Unified for both with and without images */}
{/* Background - Image or Gradient */} {project.image_url ? ( {project.name} ) : (
)} {/* Title Overlay */}

{project.name}

{project.description && (

{project.description}

)}
{/* State, Tags and Area Display - Bottom Left */}
{/* Project State Display */} {project.state && ( {getStateIcon(project.state)} {t(`projects.states.${project.state}`)} )} {/* Tags Display */} {project.tags && project.tags.length > 0 && ( {project.tags.map((tag, index) => ( {index < (project.tags?.length || 0) - 1 && ( ,{' '} )} ))} )} {/* Area Display */} {(project.area || (project as any).Area) && ( )} {/* Shared Badge */} {project.is_shared && ( {t('projects.shared', 'Shared')} )}
{/* Edit/Delete Buttons - Bottom Right */}
{/* Header with Tab Links and Controls */}
{/* Mobile Layout */}
{/* Tab Navigation Links */}
{/* Inline Controls - Always visible for tasks tab */} {activeTab === 'tasks' && (
{/* Show Completed Toggle */}
Show completed
{/* Sort Filter */}
)}
{/* Desktop Layout */}
{/* Tab Navigation Links */}
{/* Inline Controls - Always visible for tasks tab */} {activeTab === 'tasks' && (
{/* Show Completed Toggle */}
Show completed
{/* Sort Filter */}
)}
{/* Auto-suggest form for tasks with no items */} {activeTab === 'tasks' && showAutoSuggestForm && (
{ if (project?.id) { handleCreateNextAction( project.id, actionDescription ); } }} onDismiss={handleSkipNextAction} />
)} {/* Tasks Tab Content */} {activeTab === 'tasks' && ( <>
{displayTasks.length > 0 ? (
) : (

{showCompleted ? t( 'project.noCompletedTasks', 'No completed tasks.' ) : t('project.noTasks', 'No tasks.')}

)}
)} {/* Notes Content */} {activeTab === 'notes' && (
{/* Create New Note Button - Always visible */}
{/* Notes Grid or Empty State */} {notes.length > 0 ? (
{notes.map((note) => ( { setNoteToDelete(note); setIsConfirmDialogOpen(true); }} showActions={true} showProject={false} /> ))}
) : (

{t( 'project.noNotes', 'No notes for this project.' )}

)}
)} {/* NoteModal */} { setIsNoteModalOpen(false); setSelectedNote(null); }} onSave={handleSaveNote} note={selectedNote} projects={allProjects} /> {isConfirmDialogOpen && noteToDelete && ( { const identifier = noteToDelete?.uid ?? (noteToDelete?.id !== undefined ? String(noteToDelete.id) : null); if (identifier) { handleDeleteNote(identifier); } }} onCancel={() => { setIsConfirmDialogOpen(false); setNoteToDelete(null); }} /> )} {isConfirmDialogOpen && !noteToDelete && ( setIsConfirmDialogOpen(false)} /> )}
); }; export default ProjectDetails;