tududi/frontend/components/Project/ProjectDetails.tsx
Chris 08c23d2f96
Fix today pagination (#596)
* fixup! Feat notifications (#594)

* Add pagination to today

* Add defer to search

* fixup! Add defer to search

* Add preuploaded banners

* fixup! Add preuploaded banners

* Fix project banner

* fixup! Fix project banner

* fixup! fixup! Fix project banner
2025-11-26 23:00:50 +02:00

1218 lines
51 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
MagnifyingGlassIcon,
LightBulbIcon,
ClipboardDocumentListIcon,
PlayIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
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,
toggleTaskToday,
} 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<Project[]>([]);
const [project, setProject] = useState<Project | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [notes, setNotes] = useState<Note[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const [selectedNote, setSelectedNote] = useState<Note | null>(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<string>('status:inProgressFirst');
const [taskSearchQuery, setTaskSearchQuery] = useState('');
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const {
isOpen: isModalOpen,
openModal,
closeModal,
} = usePersistedModal(project?.id);
const editButtonRef = useRef<HTMLButtonElement>(null);
const sortOptions = useMemo(
() => [
{
value: 'status:inProgressFirst',
label: t('tasks.status', 'Status'),
},
{ value: 'created_at:desc', label: 'Created at' },
{ value: 'due_date:asc', label: 'Due date' },
{ value: 'priority:desc', label: '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 = (
<span>
{t('task.created', 'Task')}{' '}
<a
href={`/task/${newTask.uid}`}
className="text-green-200 underline hover:text-green-100"
>
{newTask.name}
</a>{' '}
{t('task.createdSuccessfully', 'created successfully!')}
</span>
);
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 ||
updatedTask.Subtasks ||
task.subtasks ||
task.Subtasks ||
[],
Subtasks:
updatedTask.subtasks ||
updatedTask.Subtasks ||
task.subtasks ||
task.Subtasks ||
[],
}
: task
)
);
return;
}
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();
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 ||
savedTask.Subtasks ||
updatedTask.subtasks ||
updatedTask.Subtasks ||
task.subtasks ||
task.Subtasks ||
[],
Subtasks:
savedTask.subtasks ||
savedTask.Subtasks ||
updatedTask.subtasks ||
updatedTask.Subtasks ||
task.subtasks ||
task.Subtasks ||
[],
}
: task
)
);
}
};
const handleTaskDelete = async (taskId: number | undefined) => {
if (!taskId) return;
await deleteTask(taskId);
setTasks(tasks.filter((task) => task.id !== taskId));
};
const handleTaskCompletionToggle = (updatedTask: Task) => {
if (!updatedTask.id) return;
setTasks((prev) =>
prev.map((task) =>
task.id === updatedTask.id
? {
...task,
...updatedTask,
subtasks:
updatedTask.subtasks ||
updatedTask.Subtasks ||
task.subtasks ||
task.Subtasks ||
[],
Subtasks:
updatedTask.subtasks ||
updatedTask.Subtasks ||
task.subtasks ||
task.Subtasks ||
[],
}
: task
)
);
};
const handleToggleToday = async (taskId: number, task?: Task) => {
try {
const updatedTask = await toggleTaskToday(taskId, task);
setTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
today: updatedTask.today,
today_move_count: updatedTask.today_move_count,
}
: t
)
);
} catch {
if (!uidSlug) return;
try {
const projectData = await fetchProjectBySlug(uidSlug);
setProject(projectData);
setTasks(projectData.tasks || projectData.Tasks || []);
const fetchedNotes =
projectData.notes || projectData.Notes || [];
setNotes(
fetchedNotes.map((note) => {
if (note.Tags && !note.tags) note.tags = note.Tags;
return note;
})
);
} catch {
// silent
}
}
};
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,
}));
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,
}));
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 = (
<span>
{t('task.created', 'Task')}{' '}
<a
href={`/task/${newTask.uid}`}
className="text-green-200 underline hover:text-green-100"
>
{newTask.name}
</a>{' '}
{t('task.createdSuccessfully', 'created successfully!')}
</span>
);
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,
eisenhower,
weeklyPace,
monthlyCompleted,
} = useProjectMetrics(tasks, handleTaskUpdate, t);
const getStateIcon = (state: string) => {
switch (state) {
case 'idea':
return (
<LightBulbIcon className="h-3 w-3 text-yellow-500 flex-shrink-0 mt-0.5" />
);
case 'planned':
return (
<ClipboardDocumentListIcon className="h-3 w-3 text-blue-500 flex-shrink-0 mt-0.5" />
);
case 'in_progress':
case 'active':
return (
<PlayIcon className="h-3 w-3 text-green-500 flex-shrink-0 mt-0.5" />
);
case 'blocked':
return (
<ExclamationTriangleIcon className="h-3 w-3 text-red-500 flex-shrink-0 mt-0.5" />
);
case 'completed':
return (
<CheckCircleIcon className="h-3 w-3 text-gray-500 flex-shrink-0 mt-0.5" />
);
default:
return (
<PlayIcon className="h-3 w-3 text-white/70 flex-shrink-0 mt-0.5" />
);
}
};
if (loading) return <LoadingSpinner message="Loading project details..." />;
if (error)
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-red-500 text-lg">
Failed to load project details.
</div>
</div>
);
if (!project)
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-red-500 text-lg">Project not found.</div>
</div>
);
const renderStatusFilter = () => (
<div className="space-y-3">
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
{t('tasks.show', 'Show')}
</div>
<div className="py-1 space-y-1">
{[
{ 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 (
<button
key={opt.key}
type="button"
onClick={() =>
handleTaskStatusFilterChange(
opt.key as
| 'all'
| 'active'
| 'completed'
)
}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
isActive
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>{opt.label}</span>
{isActive && <CheckIcon className="h-4 w-4" />}
</button>
);
})}
</div>
</div>
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
{t('tasks.direction', 'Direction')}
</div>
<div className="py-1">
{[
{
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 (
<button
key={dir.key}
onClick={() => {
const [field] = orderBy.split(':');
const newOrderBy = `${field}:${dir.key}`;
handleSortChange(newOrderBy);
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
isActive
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>{dir.label}</span>
{isActive && <CheckIcon className="h-4 w-4" />}
</button>
);
})}
</div>
</div>
</div>
);
return (
<div className="w-full pb-12">
<ProjectBanner
project={project}
areas={areas}
t={t}
getStateIcon={getStateIcon}
onDeleteClick={() => {
setNoteToDelete(null);
setIsConfirmDialogOpen(true);
}}
editButtonRef={editButtonRef}
onEditBannerClick={handleEditBannerClick}
/>
<div className="w-full px-4 sm:px-6 lg:px-10">
<div className="w-full">
<div className="mb-4">
<div className="hidden sm:flex items-center justify-between min-h-[2.5rem]">
<div className="flex items-center space-x-6">
<button
onClick={() => setActiveTab('tasks')}
className={`flex items-center space-x-2 text-sm font-medium transition-colors ${
activeTab === 'tasks'
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
>
<span>{t('sidebar.tasks', 'Tasks')}</span>
{displayTasks.length > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-gray-200 dark:bg-gray-600 rounded-full">
{displayTasks.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('notes')}
className={`flex items-center space-x-2 text-sm font-medium transition-colors ${
activeTab === 'notes'
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
>
<span>{t('sidebar.notes', 'Notes')}</span>
{notes.length > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-gray-200 dark:bg-gray-600 rounded-full">
{notes.length}
</span>
)}
</button>
</div>
{activeTab === 'tasks' && (
<div className="flex items-center gap-4">
<button
onClick={toggleMetrics}
className={`flex items-center transition-all duration-300 focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-inset rounded-lg p-2 ${
showMetrics
? 'bg-blue-100 dark:bg-blue-900/30 shadow-sm'
: 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
aria-pressed={showMetrics}
aria-label={
showMetrics
? t(
'projects.hideMetrics',
'Hide metrics'
)
: t(
'projects.showMetrics',
'Show metrics'
)
}
title={
showMetrics
? t(
'projects.hideMetrics',
'Hide metrics'
)
: t(
'projects.showMetrics',
'Show metrics'
)
}
>
<ChartBarIcon
className={`h-5 w-5 ${
showMetrics
? 'text-blue-600 dark:text-blue-200'
: 'text-gray-600 dark:text-gray-200'
}`}
/>
</button>
<button
onClick={() =>
setIsSearchExpanded((v) => !v)
}
className={`flex items-center transition-all duration-300 focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-inset rounded-lg p-2 ${
isSearchExpanded
? 'bg-blue-50/70 dark:bg-blue-900/20'
: 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
aria-expanded={isSearchExpanded}
aria-label={
isSearchExpanded
? 'Collapse search panel'
: 'Show search input'
}
>
<MagnifyingGlassIcon className="h-5 w-5 text-gray-600 dark:text-gray-200" />
</button>
<IconSortDropdown
options={sortOptions}
value={orderBy}
onChange={handleSortChange}
ariaLabel={t(
'projects.sortTasks',
'Sort tasks'
)}
title={t(
'projects.sortTasks',
'Sort tasks'
)}
dropdownLabel={t(
'tasks.sortBy',
'Sort by'
)}
footerContent={renderStatusFilter()}
/>
</div>
)}
</div>
</div>
{activeTab === 'tasks' && (
<>
<div
className={`transition-all duration-300 ease-in-out ${
isSearchExpanded
? 'max-h-24 opacity-100 mb-4'
: 'max-h-0 opacity-0 mb-0'
} overflow-hidden`}
>
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-4 py-3">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-600 dark:text-gray-400 mr-2" />
<input
type="text"
placeholder={t(
'tasks.searchPlaceholder',
'Search tasks...'
)}
value={taskSearchQuery}
onChange={(e) =>
setTaskSearchQuery(e.target.value)
}
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
/>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6 items-start transition-all duration-300">
<div
className={`flex justify-center transition-all duration-300 ${
showMetrics
? 'xl:col-span-2 translate-x-0'
: 'xl:col-span-3 translate-x-0'
}`}
>
<div
className={`w-full max-w-5xl transition-all duration-300 ${
showMetrics
? 'xl:translate-x-0'
: 'xl:translate-x-6'
}`}
>
<ProjectTasksSection
project={project}
displayTasks={displayTasks}
showAutoSuggestForm={
showAutoSuggestForm
}
onAddNextAction={
handleCreateNextAction
}
onDismissNextAction={
handleSkipNextAction
}
onTaskCreate={handleTaskCreate}
onTaskUpdate={handleTaskUpdate}
onTaskCompletionToggle={
handleTaskCompletionToggle
}
onTaskDelete={handleTaskDelete}
onToggleToday={handleToggleToday}
allProjects={allProjects}
showCompleted={
taskStatusFilter !== 'active'
}
taskSearchQuery={taskSearchQuery}
t={t}
/>
</div>
</div>
<div className="xl:col-span-1">
<div
className={`transition-all duration-300 ease-in-out ${
showMetrics
? 'max-h-[2000px] opacity-100 translate-x-0'
: 'max-h-0 opacity-0 translate-x-8 pointer-events-none'
}`}
style={{ overflow: 'hidden' }}
aria-hidden={!showMetrics}
>
<ProjectInsightsPanel
taskStats={taskStats}
completionGradient={
completionGradient
}
dueBuckets={dueBuckets}
dueHighlights={dueHighlights}
nextBestAction={nextBestAction}
getDueDescriptor={getDueDescriptor}
onStartNextAction={
handleStartNextAction
}
t={t}
completionTrend={completionTrend}
upcomingDueTrend={upcomingDueTrend}
createdTrend={createdTrend}
upcomingInsights={upcomingInsights}
eisenhower={eisenhower}
weeklyPace={weeklyPace}
monthlyCompleted={monthlyCompleted}
/>
</div>
</div>
</div>
</>
)}
{activeTab === 'notes' && project && (
<ProjectNotesSection
project={project}
notes={notes}
t={t}
onCreateNote={() => {
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);
}}
/>
)}
<ProjectModal
isOpen={isModalOpen}
onClose={closeModal}
onSave={handleSaveProject}
project={project}
areas={areas}
/>
<BannerEditModal
isOpen={isBannerEditModalOpen}
onClose={() => setIsBannerEditModalOpen(false)}
onSave={handleSaveBanner}
currentImageUrl={project.image_url}
/>
<NoteModal
isOpen={isNoteModalOpen}
onClose={() => {
setIsNoteModalOpen(false);
setSelectedNote(null);
}}
onSave={handleSaveNote}
note={selectedNote}
projects={allProjects}
/>
{isConfirmDialogOpen && noteToDelete && (
<ConfirmDialog
title="Delete Note"
message={`Are you sure you want to delete the note "${noteToDelete.title}"?`}
onConfirm={() => {
const identifier =
noteToDelete?.uid ??
(noteToDelete?.id !== undefined
? String(noteToDelete.id)
: null);
if (identifier) handleDeleteNote(identifier);
}}
onCancel={() => {
setIsConfirmDialogOpen(false);
setNoteToDelete(null);
}}
/>
)}
{isConfirmDialogOpen && !noteToDelete && (
<ConfirmDialog
title={t(
'modals.deleteProject.title',
'Delete Project'
)}
message={t(
'modals.deleteProject.message',
'Deleting this project will remove the project only. All items inside will be retained but will no longer belong to any project. Continue?'
)}
onConfirm={handleDeleteProject}
onCancel={() => setIsConfirmDialogOpen(false)}
/>
)}
</div>
</div>
</div>
);
};
export default ProjectDetails;