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, } 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 { isAuthError } from '../../utils/authUtils'; import { getAutoSuggestNextActionsEnabled } from '../../utils/profileService'; import AutoSuggestNextActionBox from './AutoSuggestNextActionBox'; import SortFilterButton, { SortOption } from '../Shared/SortFilterButton'; import LoadingSpinner from '../Shared/LoadingSpinner'; 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 } = 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); const [isModalOpen, setIsModalOpen] = useState(false); 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: '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(`/api/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 if (savedTask.project_id !== project?.id) { 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): Promise => { try { const updatedTask = await toggleTaskToday(taskId); // 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 } } } }; const handleEditProject = () => { setIsModalOpen(true); }; const handleSaveProject = async (updatedProject: Project) => { if (!updatedProject.id) { return; } try { const savedProject = await updateProject( updatedProject.id, updatedProject ); setProject(savedProject); setIsModalOpen(false); } catch { // Error saving project - silently handled } }; const handleCreateNextAction = async ( projectId: number, actionDescription: string ) => { try { const newTask = await createTask({ name: actionDescription, status: 'not_started', project_id: projectId, priority: '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(`/api/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?.id) { return; } try { await deleteProject(project.id); 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(`/api/note/${note.id}`, { 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 (noteId: number) => { try { await apiDeleteNote(noteId); setNotes(notes.filter((n) => n.id !== noteId)); setNoteToDelete(null); setIsConfirmDialogOpen(false); } catch { // Error deleting note - silently handled } }; const handleUpdateNote = async (noteData: Partial) => { try { if (selectedNote?.id) { const updatedNote = await updateNote( selectedNote.id, noteData as Note ); // Normalize tags field - backend returns 'Tags' but frontend expects 'tags' if (updatedNote.Tags && !updatedNote.tags) { updatedNote.tags = updatedNote.Tags; } setNotes( notes.map((n) => n.id === selectedNote.id ? updatedNote : n ) ); setIsNoteModalOpen(false); setSelectedNote(null); } } catch { // Error updating 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]); 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}

)}
{/* Tags Display - Bottom Left */} {project.tags && project.tags.length > 0 && (
{project.tags.map((tag, index) => ( {index < (project.tags?.length || 0) - 1 && ( ,{' '} )} ))}
)} {/* 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' && (
{notes.length > 0 ? (
{notes.map((note) => ( { setNoteToDelete(note); setIsConfirmDialogOpen(true); }} showActions={true} showProject={false} /> ))}
) : (

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

)}
)} setIsModalOpen(false)} onSave={handleSaveProject} project={project} areas={areas} /> {/* NoteModal */} {isNoteModalOpen && ( { setIsNoteModalOpen(false); setSelectedNote(null); }} onSave={handleUpdateNote} note={selectedNote} projects={allProjects} /> )} {isConfirmDialogOpen && noteToDelete && ( handleDeleteNote(noteToDelete.id!)} onCancel={() => { setIsConfirmDialogOpen(false); setNoteToDelete(null); }} /> )} {isConfirmDialogOpen && !noteToDelete && ( setIsConfirmDialogOpen(false)} /> )}
); }; export default ProjectDetails;