import React, { useState, useEffect, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { PriorityType, Task } from '../../entities/Task'; import { Attachment } from '../../entities/Attachment'; import ConfirmDialog from '../Shared/ConfirmDialog'; import DiscardChangesDialog from '../Shared/DiscardChangesDialog'; import { useToast } from '../Shared/ToastContext'; import { Project } from '../../entities/Project'; import { useStore } from '../../store/useStore'; import { fetchTaskByUid } from '../../utils/tasksService'; import { fetchAttachments } from '../../utils/attachmentsService'; import { analyzeTaskName, TaskAnalysis, } from '../../utils/taskIntelligenceService'; import { useTranslation } from 'react-i18next'; import { getTaskIntelligenceEnabled } from '../../utils/profileService'; // Import form sections import TaskTitleSection from './TaskForm/TaskTitleSection'; import TaskContentSection from './TaskForm/TaskContentSection'; import TaskTagsSection from './TaskForm/TaskTagsSection'; import TaskProjectSection from './TaskForm/TaskProjectSection'; import TaskRecurrenceSection from './TaskForm/TaskRecurrenceSection'; import TaskSubtasksSection from './TaskForm/TaskSubtasksSection'; import TaskPrioritySection from './TaskForm/TaskPrioritySection'; import TaskDueDateSection from './TaskForm/TaskDueDateSection'; import TaskDeferUntilSection from './TaskForm/TaskDeferUntilSection'; import TaskAttachmentsSection from './TaskForm/TaskAttachmentsSection'; import TaskSectionToggle from './TaskForm/TaskSectionToggle'; import TaskModalActions from './TaskForm/TaskModalActions'; interface TaskModalProps { isOpen: boolean; onClose: () => void; task: Task; onSave: (task: Task) => void | Promise; onDelete: (taskUid: string) => Promise; projects: Project[]; onCreateProject: (name: string) => Promise; onEditParentTask?: (parentTask: Task) => void; autoFocusSubtasks?: boolean; showToast?: boolean; initialSubtasks?: Task[]; } const TaskModal: React.FC = ({ isOpen, onClose, task, onSave, onDelete, projects, onCreateProject, onEditParentTask, autoFocusSubtasks, showToast = true, initialSubtasks = [], }) => { const { tagsStore } = useStore(); // Avoid calling getTags() during component initialization to prevent remounting const availableTags = tagsStore.tags; const { addNewTags, refreshTags } = tagsStore; const [formData, setFormData] = useState(task); const [tags, setTags] = useState( task.tags?.map((tag) => tag.name) || [] ); const [filteredProjects, setFilteredProjects] = useState([]); const [newProjectName, setNewProjectName] = useState(''); const [isCreatingProject, setIsCreatingProject] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); const modalRef = useRef(null); const [isClosing, setIsClosing] = useState(false); const [isSaving, setIsSaving] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showDiscardDialog, setShowDiscardDialog] = useState(false); const [parentTask, setParentTask] = useState(null); const [parentTaskLoading, setParentTaskLoading] = useState(false); const [taskAnalysis, setTaskAnalysis] = useState(null); const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] = useState(false); const [subtasks, setSubtasks] = useState([]); const [attachments, setAttachments] = useState([]); // Collapsible section states - subtasks is derived from autoFocusSubtasks const [baseSections, setBaseSections] = useState({ tags: false, project: false, priority: false, dueDate: false, deferUntil: false, recurrence: false, subtasks: false, attachments: false, }); // Derive expanded sections with subtasks controlled by autoFocusSubtasks const expandedSections = { ...baseSections, subtasks: baseSections.subtasks || autoFocusSubtasks, }; const { showSuccessToast, showErrorToast } = useToast(); const { t } = useTranslation(); const scrollToSubtasksSection = () => { const attemptScroll = (attempt = 1) => { const subtasksSection = document.querySelector( '[data-section="subtasks"]' ) as HTMLElement; if (subtasksSection) { subtasksSection.scrollIntoView({ behavior: 'smooth', block: 'end', }); } else if (attempt <= 3) { // Retry up to 3 times with increasing delays setTimeout(() => attemptScroll(attempt + 1), 100 * attempt); } }; setTimeout(() => attemptScroll(), 100); }; const toggleSection = useCallback((section: keyof typeof baseSections) => { setBaseSections((prev) => { const newExpanded = { ...prev, [section]: !prev[section], }; // Auto-scroll to show the expanded section if (newExpanded[section]) { // Special handling for subtasks section if (section === 'subtasks') { scrollToSubtasksSection(); } else { setTimeout(() => { const scrollContainer = document.querySelector( '.absolute.inset-0.overflow-y-auto' ); if (scrollContainer) { scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth', }); } }, 100); // Small delay to ensure DOM is updated } } return newExpanded; }); }, []); // Handle task updates only when the task ID changes or modal opens useEffect(() => { if (isOpen) { setFormData(task); setTags(task.tags?.map((tag) => tag.name) || []); // Clear project name - selected project will show as badge setNewProjectName(''); // Reset expandable sections to default state setBaseSections({ tags: false, project: false, priority: false, dueDate: false, deferUntil: false, recurrence: task.recurring_parent_uid ? true : false, subtasks: false, attachments: false, }); } }, [isOpen, task.id, task.project_id, projects]); // Handle task analysis separately useEffect(() => { if (isOpen && task.name && taskIntelligenceEnabled) { const analysis = analyzeTaskName(task.name); setTaskAnalysis(analysis); } else { setTaskAnalysis(null); } }, [isOpen, task.name, taskIntelligenceEnabled]); // Handle parent task fetching separately useEffect(() => { const fetchParentTask = async () => { if (task.recurring_parent_uid && isOpen) { setParentTaskLoading(true); try { const parent = await fetchTaskByUid( task.recurring_parent_uid ); setParentTask(parent); } catch (error) { console.error('Error fetching parent task:', error); setParentTask(null); } finally { setParentTaskLoading(false); } } else { setParentTask(null); } }; fetchParentTask(); }, [task.recurring_parent_uid, isOpen]); // Fetch task intelligence setting from user profile useEffect(() => { const fetchIntelligenceSetting = async () => { try { const enabled = await getTaskIntelligenceEnabled(); setTaskIntelligenceEnabled(enabled); } catch (error) { console.error( 'Error fetching task intelligence setting:', error ); setTaskIntelligenceEnabled(false); // Default to disabled on error } }; if (isOpen) { fetchIntelligenceSetting(); } }, [isOpen]); // Auto-scroll to subtasks section when modal opens with autoFocusSubtasks // But don't auto-scroll for recurring tasks useEffect(() => { const isRecurringTask = task.recurrence_type && task.recurrence_type !== 'none'; if (isOpen && autoFocusSubtasks && !isRecurringTask) { setTimeout(() => { scrollToSubtasksSection(); }, 300); } }, [isOpen, autoFocusSubtasks, task.recurrence_type]); // Load tags when modal opens if not already loaded useEffect(() => { if (isOpen && !tagsStore.hasLoaded && !tagsStore.isLoading) { tagsStore.loadTags(); } }, [isOpen, tagsStore.hasLoaded, tagsStore.isLoading]); const handleEditParent = () => { if (parentTask && onEditParentTask) { onEditParentTask(parentTask); onClose(); // Close current modal } }; const handleParentRecurrenceChange = (field: string, value: any) => { // Update the parent task data in local state if (parentTask) { setParentTask({ ...parentTask, [field]: value }); } // Also update the form data to reflect the change setFormData((prev) => ({ ...prev, [field]: value, update_parent_recurrence: true, })); }; // Note: Tags loading removed to prevent modal closing issues // Tags will be loaded by other components or on app startup const getPriorityString = ( priority: PriorityType | number | undefined ): PriorityType => { if (typeof priority === 'number') { const priorityNames: ('low' | 'medium' | 'high')[] = [ 'low', 'medium', 'high', ]; return priorityNames[priority] || null; } return priority ?? null; }; const handleChange = ( e: React.ChangeEvent< HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement > ) => { const { name, value } = e.target; // Validate defer_until vs due_date if (name === 'defer_until' || name === 'due_date') { const newFormData = { ...formData, [name]: value }; if (newFormData.defer_until && newFormData.due_date) { const deferDate = new Date(newFormData.defer_until); const dueDate = new Date(newFormData.due_date); if (!isNaN(deferDate.getTime()) && !isNaN(dueDate.getTime())) { if (deferDate > dueDate) { showErrorToast( t( 'task.deferAfterDueError', 'Defer until date cannot be after the due date' ) ); return; } } } } setFormData((prev) => ({ ...prev, [name]: value })); // Analyze task name in real-time (only if intelligence is enabled) if (name === 'name' && taskIntelligenceEnabled) { const analysis = analyzeTaskName(value); setTaskAnalysis(analysis); } }; const handleRecurrenceChange = (field: string, value: any) => { setFormData((prev) => ({ ...prev, [field]: value })); }; const handleTagsChange = useCallback((newTags: string[]) => { setTags(newTags); setFormData((prev) => ({ ...prev, tags: newTags.map((name) => ({ name })), })); }, []); const handleProjectSearch = (query: string) => { setNewProjectName(query); setDropdownOpen(true); setFilteredProjects( projects.filter((project) => project.name.toLowerCase().includes(query.toLowerCase()) ) ); // If the user clears the project name, also clear the project_id in form data if (query.trim() === '') { setFormData({ ...formData, project_id: null }); } }; const handleProjectSelection = (project: Project) => { setFormData({ ...formData, project_id: project.id }); setNewProjectName(''); // Clear input after selection (badge will show the project) setDropdownOpen(false); }; const handleClearProject = () => { setFormData({ ...formData, project_id: null }); setNewProjectName(''); setDropdownOpen(false); }; const handleShowAllProjects = () => { setNewProjectName(''); setFilteredProjects(projects); setDropdownOpen(!dropdownOpen); }; // Get the selected project object from the project_id const selectedProject = formData.project_id ? projects.find((p) => p.id === formData.project_id) || null : null; const handleCreateProject = async (name: string) => { if (name.trim() !== '') { setIsCreatingProject(true); try { const newProject = await onCreateProject(name); setFormData({ ...formData, project_id: newProject.id }); setFilteredProjects([...filteredProjects, newProject]); setNewProjectName(''); // Clear input after creation (badge will show the project) setDropdownOpen(false); showSuccessToast(t('success.projectCreated')); } catch (error) { showErrorToast(t('errors.projectCreationFailed')); console.error('Error creating project:', error); } finally { setIsCreatingProject(false); } } }; const handleSubmit = async () => { // Prevent multiple simultaneous submissions if (isSaving) return; setIsSaving(true); try { // Check if due date is in the past if (formData.due_date) { const dueDate = new Date(formData.due_date); const today = new Date(); today.setHours(0, 0, 0, 0); dueDate.setHours(0, 0, 0, 0); if (!isNaN(dueDate.getTime()) && dueDate < today) { showErrorToast( t( 'task.dueDateInPastWarning', 'Warning: You are setting a due date in the past' ) ); } } // Add new tags to the global store const existingTagNames = availableTags.map((tag: any) => tag.name); const newTagNames = tags.filter( (tag) => !existingTagNames.includes(tag) ); if (newTagNames.length > 0) { addNewTags(newTagNames); } // CORRECTION: Use formData.project_id directly instead of the logic based on newProjectName // newProjectName is just a temporary lookup field, not the actual project state const finalFormData = { ...formData, project_id: formData.project_id, tags: tags.map((tag) => ({ name: tag })), subtasks: subtasks, }; await onSave(finalFormData as any); // Refresh tags from server to sync any newly created tags with their proper UIDs if (newTagNames.length > 0) { await refreshTags(); } if (showToast) { const taskLink = ( {t('task.updated', 'Task')}{' '} {formData.name} {' '} {t('task.updatedSuccessfully', 'updated successfully!')} ); showSuccessToast(taskLink); } handleClose(); } catch (error) { console.error('Error saving task:', error); // Don't close modal on error so user can retry } finally { setIsSaving(false); } }; const handleDeleteClick = () => { setShowConfirmDialog(true); }; const handleDeleteConfirm = async () => { if (formData.uid) { try { await onDelete(formData.uid); const taskLink = ( {t('task.deleted', 'Task')}{' '} {formData.name} {' '} {t('task.deletedSuccessfully', 'deleted successfully!')} ); showSuccessToast(taskLink); setShowConfirmDialog(false); handleClose(); } catch (error) { console.error('Failed to delete task:', error); showErrorToast(t('task.deleteError', 'Failed to delete task')); } } }; // Check if there are unsaved changes const hasUnsavedChanges = () => { // Compare formData with original task const formChanged = formData.name !== task.name || formData.note !== task.note || formData.priority !== task.priority || formData.due_date !== task.due_date || formData.project_id !== task.project_id || formData.recurrence_type !== task.recurrence_type || formData.recurrence_interval !== task.recurrence_interval || formData.recurrence_weekday !== task.recurrence_weekday || formData.recurrence_end_date !== task.recurrence_end_date; // Compare tags const originalTags = task.tags?.map((tag) => tag.name) || []; const tagsChanged = tags.length !== originalTags.length || tags.some((tag, index) => tag !== originalTags[index]); // Compare subtasks (check if any were added or modified) const subtasksChanged = subtasks.length !== (initialSubtasks?.length || 0); return formChanged || tagsChanged || subtasksChanged; }; const handleClose = () => { setIsClosing(true); setTimeout(() => { onClose(); setIsClosing(false); setShowDiscardDialog(false); }, 300); }; const handleDiscardChanges = () => { setShowDiscardDialog(false); handleClose(); }; const handleCancelDiscard = () => { setShowDiscardDialog(false); }; // Handle body scroll when modal opens/closes useEffect(() => { if (isOpen) { // Disable body scroll when modal is open document.body.style.overflow = 'hidden'; } else { // Re-enable body scroll when modal is closed document.body.style.overflow = 'unset'; } return () => { // Clean up: re-enable body scroll document.body.style.overflow = 'unset'; }; }, [isOpen]); // Use ref to store hasUnsavedChanges so it's always current in the event handler const hasUnsavedChangesRef = useRef(hasUnsavedChanges); useEffect(() => { hasUnsavedChangesRef.current = hasUnsavedChanges; }); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { // Don't show discard dialog if already showing a dialog if (showConfirmDialog || showDiscardDialog) { // Let the dialog handle its own Escape return; } event.preventDefault(); event.stopPropagation(); // Check for unsaved changes using ref to get current value if (hasUnsavedChangesRef.current()) { setShowDiscardDialog(true); } else { handleClose(); } } }; if (isOpen) { document.addEventListener('keydown', handleKeyDown); } return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [isOpen, showConfirmDialog, showDiscardDialog]); // Load existing subtasks when modal opens - use initialSubtasks if provided, no fetching useEffect(() => { if (isOpen && task.id) { // Always use provided initial subtasks (from parent component) or empty array setSubtasks(initialSubtasks || []); } else if (!isOpen) { // Reset subtasks when modal closes setSubtasks([]); } }, [isOpen, task.id]); // Load attachments when modal opens useEffect(() => { const loadTaskAttachments = async () => { if (isOpen && task.uid) { try { const data = await fetchAttachments(task.uid); setAttachments(data); } catch (error) { console.error('Error loading attachments:', error); setAttachments([]); } } else if (!isOpen) { // Reset attachments when modal closes setAttachments([]); } }; loadTaskAttachments(); }, [isOpen, task.uid]); if (!isOpen) return null; return createPortal( <>
{ // Close modal when clicking on backdrop, but not on the modal content // Use mousedown instead of onClick to prevent issues with text selection dragging if (e.target === e.currentTarget) { handleClose(); } }} >
{ // Close modal when clicking on centering container, but not on the modal content // Use mousedown instead of onClick to prevent issues with text selection dragging if (e.target === e.currentTarget) { handleClose(); } }} >
{/* Main Form Section */}
{ e.preventDefault(); return false; }} >
{/* Task Title Section - Always Visible */} {/* Content Section - Always Visible */} {/* Expandable Sections - Only show when expanded */} {expandedSections.tags && (

{t( 'forms.task.labels.tags', 'Tags' )}

)} {expandedSections.project && (

{t( 'forms.task.labels.project', 'Project' )}

)} {expandedSections.priority && (

{t( 'forms.task.labels.priority', 'Priority' )}

setFormData({ ...formData, priority: value, }) } />
)} {expandedSections.dueDate && (

{t( 'forms.task.labels.dueDate', 'Due Date' )}

{ const event = { target: { name: 'due_date', value, }, } as React.ChangeEvent; handleChange( event ); }} placeholder={t( 'forms.task.dueDatePlaceholder', 'Select due date' )} />
)} {expandedSections.deferUntil && (

{t( 'forms.task.labels.deferUntil', 'Defer Until' )}

{ const event = { target: { name: 'defer_until', value, }, } as React.ChangeEvent; handleChange( event ); }} placeholder={t( 'forms.task.deferUntilPlaceholder', 'Select defer until date and time' )} />
)} {expandedSections.recurrence && (

{t( 'forms.task.recurrence', 'Recurrence' )}

)} {expandedSections.subtasks && (

{t( 'forms.task.subtasks', 'Subtasks' )}

{ // Update the subtask in the local state setSubtasks( (prev) => prev.map( ( st ) => st.id === updatedSubtask.id ? updatedSubtask : st ) ); }} />
)} {expandedSections.attachments && task.uid && (

{t( 'forms.task.attachments', 'Attachments' )}

)}
{/* Section Icons - Above border, split layout */} {/* Action Buttons - Below border with custom layout */}
{showConfirmDialog && ( setShowConfirmDialog(false)} /> )} {showDiscardDialog && ( )} , document.body ); }; export default TaskModal;