import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { MagnifyingGlassIcon, EllipsisHorizontalCircleIcon, ClipboardDocumentListIcon, PlayIcon, ClockIcon, CheckCircleIcon, XCircleIcon, ChartBarIcon, CheckIcon, } from '@heroicons/react/24/outline'; import { useToast } from '../Shared/ToastContext'; import ProjectModal from './ProjectModal'; import ConfirmDialog from '../Shared/ConfirmDialog'; import NoteModal from '../Note/NoteModal'; import { useStore } from '../../store/useStore'; import { Project } from '../../entities/Project'; import { Task } from '../../entities/Task'; import { Note } from '../../entities/Note'; import { fetchProjectBySlug, updateProject, deleteProject, fetchProjects, } from '../../utils/projectsService'; import { createTask, deleteTask } from '../../utils/tasksService'; import { updateNote, deleteNote as apiDeleteNote, } from '../../utils/notesService'; import { createNote } from '../../utils/notesService'; import { getAutoSuggestNextActionsEnabled } from '../../utils/profileService'; import IconSortDropdown from '../Shared/IconSortDropdown'; import LoadingSpinner from '../Shared/LoadingSpinner'; import { usePersistedModal } from '../../hooks/usePersistedModal'; import { getApiPath } from '../../config/paths'; import ProjectInsightsPanel from './ProjectInsightsPanel'; import ProjectBanner from './ProjectBanner'; import BannerEditModal from './BannerEditModal'; import ProjectTasksSection from './ProjectTasksSection'; import ProjectNotesSection from './ProjectNotesSection'; import { useProjectMetrics } from './useProjectMetrics'; const ProjectDetails: React.FC = () => { const UI_OPTIONS_KEY = 'ui_app_options'; const { uidSlug } = useParams<{ uidSlug: string }>(); const navigate = useNavigate(); const { t } = useTranslation(); const { showSuccessToast } = useToast(); const { areasStore, projectsStore } = useStore(); const areas = areasStore.areas; const [allProjects, setAllProjects] = useState([]); const [project, setProject] = useState(null); const [tasks, setTasks] = useState([]); const [notes, setNotes] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(false); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [noteToDelete, setNoteToDelete] = useState(null); const [selectedNote, setSelectedNote] = useState(null); const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); const [isBannerEditModalOpen, setIsBannerEditModalOpen] = useState(false); const [activeTab, setActiveTab] = useState<'tasks' | 'notes'>('tasks'); const [taskStatusFilter, setTaskStatusFilter] = useState< 'all' | 'active' | 'completed' >(() => { const saved = localStorage.getItem('project_task_status_filter'); return (saved as 'all' | 'active' | 'completed') || 'active'; }); const [showMetrics, setShowMetrics] = useState(true); const [showAutoSuggestForm, setShowAutoSuggestForm] = useState(false); const [autoSuggestEnabled, setAutoSuggestEnabled] = useState(false); const hasCheckedAutoSuggest = useRef(false); const [orderBy, setOrderBy] = useState('status:inProgressFirst'); const [taskSearchQuery, setTaskSearchQuery] = useState(''); const [isSearchExpanded, setIsSearchExpanded] = useState(false); const { isOpen: isModalOpen, openModal, closeModal, } = usePersistedModal(project?.id); const editButtonRef = useRef(null); const sortOptions = useMemo( () => [ { value: 'status:inProgressFirst', label: t('sort.status', 'Status'), }, { value: 'created_at:desc', label: t('sort.created_at', 'Created At'), }, { value: 'due_date:asc', label: t('sort.due_date', 'Due Date') }, { value: 'priority:desc', label: t('sort.priority', 'Priority') }, ], [t] ); useEffect(() => { if (!areasStore.hasLoaded && !areasStore.isLoading) { areasStore.loadAreas(); } }, [areasStore]); useEffect(() => { if (!hasCheckedAutoSuggest.current) { hasCheckedAutoSuggest.current = true; getAutoSuggestNextActionsEnabled().then(setAutoSuggestEnabled); } }, []); useEffect(() => { // Load persisted UI options (local or remote) const load = async () => { let localShow: boolean | undefined; try { const stored = localStorage.getItem(UI_OPTIONS_KEY); if (stored) { const parsed = JSON.parse(stored); if (typeof parsed.showMetrics === 'boolean') { localShow = parsed.showMetrics; setShowMetrics(parsed.showMetrics); } } } catch { // ignore parse errors } try { const response = await fetch(getApiPath('profile'), { credentials: 'include', }); if (response.ok) { const profile = await response.json(); if ( profile.ui_settings && typeof profile.ui_settings.project?.details ?.showMetrics === 'boolean' ) { setShowMetrics( profile.ui_settings.project.details.showMetrics ); localStorage.setItem( UI_OPTIONS_KEY, JSON.stringify({ showMetrics: profile.ui_settings.project.details .showMetrics, }) ); } else if (localShow === undefined) { setShowMetrics(true); } } else if (localShow === undefined) { setShowMetrics(true); } } catch { if (localShow === undefined) setShowMetrics(true); } }; load(); }, [getApiPath]); const persistUiSettings = async (nextShowMetrics: boolean) => { try { localStorage.setItem( UI_OPTIONS_KEY, JSON.stringify({ showMetrics: nextShowMetrics }) ); } catch { // ignore storage errors } try { await fetch(getApiPath('profile/ui-settings'), { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ project: { details: { showMetrics: nextShowMetrics, }, }, }), }); } catch { // ignore network errors } }; const toggleMetrics = () => { setShowMetrics((prev) => { const next = !prev; persistUiSettings(next); return next; }); }; useEffect(() => { if (allProjects.length === 0) { fetchProjects() .then(setAllProjects) .catch(() => undefined); } }, [allProjects.length]); useEffect(() => { const storedSort = localStorage.getItem('project_order_by'); const defaultSort = 'status:inProgressFirst'; if (!storedSort || storedSort === 'created_at:desc') { setOrderBy(defaultSort); localStorage.setItem('project_order_by', defaultSort); } else { setOrderBy(storedSort); } }, []); useEffect(() => { if (!uidSlug) return; const loadProjectData = async () => { try { if (!project) setLoading(true); setError(false); const projectData = await fetchProjectBySlug(uidSlug); setProject(projectData); setTasks(projectData.tasks || projectData.Tasks || []); const savedSort = localStorage.getItem('project_order_by'); if (!savedSort && projectData.task_sort_order) { setOrderBy(projectData.task_sort_order); } const fetchedNotes = projectData.notes || projectData.Notes || []; setNotes( fetchedNotes.map((note) => { if (note.Tags && !note.tags) note.tags = note.Tags; return note; }) ); setLoading(false); } catch { setError(true); setLoading(false); } }; loadProjectData(); }, [uidSlug]); useEffect(() => { const button = editButtonRef.current; if (!button) return; const handleClick = (e: Event) => { e.preventDefault(); e.stopPropagation(); openModal(); }; button.addEventListener('click', handleClick); return () => button.removeEventListener('click', handleClick); }, [openModal]); useEffect(() => { if ( project && tasks.length === 0 && !loading && taskStatusFilter === 'active' && autoSuggestEnabled ) { setShowAutoSuggestForm(true); } else { setShowAutoSuggestForm(false); } }, [project, tasks.length, loading, taskStatusFilter, autoSuggestEnabled]); const handleTaskCreate = async (taskName: string) => { if (!project) throw new Error('Cannot create task: Project is missing'); const newTask = await createTask({ name: taskName, status: 0, project_id: project.id, completed_at: null, }); setTasks([...tasks, newTask]); const taskLink = ( {t('task.created', 'Task')}{' '} {newTask.name} {' '} {t('task.createdSuccessfully', 'created successfully!')} ); showSuccessToast(taskLink); }; const handleTaskUpdate = async (updatedTask: Task) => { if (!updatedTask.id) return; const hasUpdatedData = updatedTask.parent_child_logic_executed !== undefined; if (hasUpdatedData) { setTasks((prev) => prev.map((task) => task.id === updatedTask.id ? { ...task, ...updatedTask, subtasks: updatedTask.subtasks || task.subtasks || [], } : task ) ); return; } const response = await fetch(getApiPath(`task/${updatedTask.uid}`), { 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(); const savedTaskProjectId = savedTask.project_id ?? null; const currentProjectId = project?.id ?? null; if (savedTaskProjectId !== currentProjectId) { setTasks(tasks.filter((task) => task.id !== updatedTask.id)); } else { setTasks((prev) => prev.map((task) => task.id === updatedTask.id ? { ...task, ...savedTask, subtasks: savedTask.subtasks || updatedTask.subtasks || task.subtasks || [], } : task ) ); } }; const handleTaskDelete = async (taskUid: string | undefined) => { if (!taskUid) return; await deleteTask(taskUid); setTasks(tasks.filter((task) => task.uid !== taskUid)); }; const handleTaskCompletionToggle = (updatedTask: Task) => { if (!updatedTask.id) return; setTasks((prev) => prev.map((task) => task.id === updatedTask.id ? { ...task, ...updatedTask, subtasks: updatedTask.subtasks || task.subtasks || [], } : task ) ); }; const handleSaveProject = async (updatedProject: Project) => { if (!updatedProject.uid) return; const savedProject = await updateProject( updatedProject.uid, updatedProject ); setProject((prev) => ({ ...savedProject, area: savedProject.area || prev?.area, Area: (savedProject as any).Area || (prev as any)?.Area, })); const currentProjects = projectsStore.projects; const updatedProjects = currentProjects.map((p) => p.id === savedProject.id ? savedProject : p ); projectsStore.setProjects(updatedProjects); closeModal(); }; const handleEditBannerClick = () => { setIsBannerEditModalOpen(true); }; const handleSaveBanner = async (imageUrl: string) => { if (!project || !project.uid) return; const updatedProject = await updateProject(project.uid, { ...project, image_url: imageUrl, }); setProject((prev) => ({ ...updatedProject, area: updatedProject.area || prev?.area, Area: (updatedProject as any).Area || (prev as any)?.Area, })); // Update the global projects store const currentProjects = projectsStore.projects; const updatedProjects = currentProjects.map((p) => p.id === updatedProject.id ? { ...p, image_url: imageUrl } : p ); projectsStore.setProjects(updatedProjects); showSuccessToast( t('success.bannerUpdated', 'Banner updated successfully!') ); }; const handleCreateNextAction = async ( projectId: number, actionDescription: string ) => { const newTask = await createTask({ name: actionDescription, status: 0, project_id: projectId, priority: 0, completed_at: null, }); setTasks([...tasks, newTask]); setShowAutoSuggestForm(false); const taskLink = ( {t('task.created', 'Task')}{' '} {newTask.name} {' '} {t('task.createdSuccessfully', 'created successfully!')} ); showSuccessToast(taskLink); }; const handleSkipNextAction = () => setShowAutoSuggestForm(false); const handleTaskStatusFilterChange = ( status: 'all' | 'active' | 'completed' ) => { setTaskStatusFilter(status); localStorage.setItem('project_task_status_filter', status); }; const handleSortChange = (newOrderBy: string) => { setOrderBy(newOrderBy); localStorage.setItem('project_order_by', newOrderBy); }; const handleDeleteProject = async () => { if (!project?.uid) return; await deleteProject(project.uid); const updatedProjects = projectsStore.projects.filter( (p) => p.uid !== project.uid ); projectsStore.setProjects(updatedProjects); navigate('/projects'); }; const handleEditNote = async (note: Note) => { try { 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 { setSelectedNote(note); } } catch (error) { console.error('Error fetching note details:', error); setSelectedNote(note); } setIsNoteModalOpen(true); }; const handleDeleteNote = async (noteIdentifier: string) => { await apiDeleteNote(noteIdentifier); setNotes( notes.filter((n) => { const currentIdentifier = n.uid ?? (n.id !== undefined ? String(n.id) : undefined); return currentIdentifier !== noteIdentifier; }) ); const globalNotes = useStore.getState().notesStore.notes; useStore.getState().notesStore.setNotes( globalNotes.filter((note) => { const currentIdentifier = note.uid ?? (note.id !== undefined ? String(note.id) : undefined); return currentIdentifier !== noteIdentifier; }) ); setNoteToDelete(null); setIsConfirmDialogOpen(false); }; 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); } if ((savedNote as any).Tags && !(savedNote as any).tags) { (savedNote as any).tags = (savedNote as any).Tags; } const savedNoteProjectId = savedNote.project_id ?? null; const currentProjectId = project?.id ?? null; if (savedNote.id && savedNoteProjectId !== currentProjectId) { setNotes(notes.filter((n) => n.id !== savedNote.id)); const globalNotes = useStore.getState().notesStore.notes; useStore .getState() .notesStore.setNotes( globalNotes.map((note) => note.uid === savedNote.uid ? savedNote : note ) ); } 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; }) ); const globalNotes = useStore.getState().notesStore.notes; useStore .getState() .notesStore.setNotes( globalNotes.map((note) => note.uid === savedNote.uid ? savedNote : note ) ); } else { setNotes([savedNote, ...notes]); const globalNotes = useStore.getState().notesStore.notes; useStore .getState() .notesStore.setNotes([savedNote, ...globalNotes]); } setIsNoteModalOpen(false); setSelectedNote(null); } catch { // silent } }; const displayTasks = useMemo(() => { let filteredTasks: Task[]; if (taskStatusFilter === 'completed') { filteredTasks = tasks.filter( (task) => task.status === 'done' || task.status === 'archived' || task.status === 2 || task.status === 3 ); } else if (taskStatusFilter === 'active') { filteredTasks = tasks.filter( (task) => task.status === 'not_started' || task.status === 'in_progress' || task.status === 'waiting' || task.status === 0 || task.status === 1 || task.status === 4 ); } else { // taskStatusFilter === 'all' filteredTasks = tasks; } if (taskSearchQuery.trim()) { const query = taskSearchQuery.toLowerCase(); filteredTasks = filteredTasks.filter( (task) => task.name.toLowerCase().includes(query) || task.original_name?.toLowerCase().includes(query) || task.note?.toLowerCase().includes(query) ); } const getStatusRank = (status: Task['status']) => { if (status === 'in_progress' || status === 1) return 0; if (status === 'not_started' || status === 0) return 1; if (status === 'waiting' || status === 4) return 2; if (status === 'done' || status === 2) return 3; if (status === 'archived' || status === 3) return 4; return 5; }; return [...filteredTasks].sort((a, b) => { if (orderBy === 'status:inProgressFirst') { const rankA = getStatusRank(a.status); const rankB = getStatusRank(b.status); if (rankA !== rankB) return rankA - rankB; const dueA = a.due_date ? new Date(a.due_date).getTime() : Number.MAX_SAFE_INTEGER; const dueB = b.due_date ? new Date(b.due_date).getTime() : Number.MAX_SAFE_INTEGER; if (dueA !== dueB) return dueA - dueB; return (a.id || 0) - (b.id || 0); } const [field, direction] = orderBy.split(':'); const isAsc = direction === 'asc'; const compare = (valueA: any, valueB: any) => { if (valueA < valueB) return isAsc ? -1 : 1; if (valueA > valueB) return isAsc ? 1 : -1; return 0; }; switch (field) { case 'name': return compare( a.name?.toLowerCase() || '', b.name?.toLowerCase() || '' ); case 'due_date': return compare( a.due_date ? new Date(a.due_date).getTime() : 0, b.due_date ? new Date(b.due_date).getTime() : 0 ); case 'priority': { const priorityMap = { high: 2, medium: 1, low: 0 }; const valueA = typeof a.priority === 'string' ? priorityMap[a.priority] || 0 : a.priority || 0; const valueB = typeof b.priority === 'string' ? priorityMap[b.priority] || 0 : b.priority || 0; return compare(valueA, valueB); } case 'status': return compare( typeof a.status === 'string' ? a.status : a.status || 0, typeof b.status === 'string' ? b.status : b.status || 0 ); case 'created_at': default: return compare( a.created_at ? new Date(a.created_at).getTime() : 0, b.created_at ? new Date(b.created_at).getTime() : 0 ); } }); }, [tasks, taskStatusFilter, orderBy, taskSearchQuery]); const { taskStats, completionGradient, dueBuckets, dueHighlights, nextBestAction, getDueDescriptor, handleStartNextAction, completionTrend, upcomingDueTrend, createdTrend, upcomingInsights, weeklyPace, monthlyCompleted, } = useProjectMetrics(tasks, handleTaskUpdate, t, showSuccessToast); const getStatusIcon = (status: string) => { switch (status) { case 'not_started': return ( ); case 'planned': return ( ); case 'in_progress': return ( ); case 'waiting': return ( ); case 'done': return ( ); case 'cancelled': return ( ); default: return ( ); } }; if (loading) return ; if (error) return (
Failed to load project details.
); if (!project) return (
Project not found.
); const renderStatusFilter = () => (
{t('tasks.show', 'Show')}
{[ { key: 'active', label: t('tasks.open', 'Open') }, { key: 'all', label: t('tasks.all', 'All') }, { key: 'completed', label: t('tasks.completed', 'Completed'), }, ].map((opt) => { const isActive = taskStatusFilter === opt.key; return ( ); })}
{t('tasks.direction', 'Direction')}
{[ { key: 'asc', label: t('tasks.ascending', 'Ascending'), }, { key: 'desc', label: t('tasks.descending', 'Descending'), }, ].map((dir) => { const currentDirection = orderBy.split(':')[1] || 'asc'; const isActive = currentDirection === dir.key; return ( ); })}
); return (
{ setNoteToDelete(null); setIsConfirmDialogOpen(true); }} editButtonRef={editButtonRef} onEditBannerClick={handleEditBannerClick} />
{activeTab === 'tasks' && (
)}
{activeTab === 'tasks' && ( <>
setTaskSearchQuery(e.target.value) } className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white" />
)} {activeTab === 'notes' && project && ( { setSelectedNote({ title: '', content: '', tags: [], project_id: project.id, project: { id: project.id, name: project.name, uid: project.uid, }, project_uid: project.uid, }); setIsNoteModalOpen(true); }} onEditNote={handleEditNote} onDeleteNote={(note) => { setNoteToDelete(note); setIsConfirmDialogOpen(true); }} /> )} setIsBannerEditModalOpen(false)} onSave={handleSaveBanner} currentImageUrl={project.image_url} /> { 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;