- Add member_count to ProjectResponse via model_validator (computed from
eagerly loaded members relationship). Shows on ProjectCard for both
owners ("2 members") and shared users ("Shared with you").
- Fix share button badge positioning (add relative class).
- Add dedicated showTaskAssignedToast with blue ClipboardList icon,
"View Project" action button, and 15s duration.
- Wire task_assigned into both initial-load and new-notification toast
dispatch flows in NotificationToaster.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
781 lines
30 KiB
TypeScript
781 lines
30 KiB
TypeScript
import { useState, useMemo, useCallback } from 'react';
|
|
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { format, isPast, parseISO } from 'date-fns';
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from '@dnd-kit/core';
|
|
import {
|
|
SortableContext,
|
|
verticalListSortingStrategy,
|
|
useSortable,
|
|
arrayMove,
|
|
} from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import {
|
|
ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin,
|
|
Calendar, CheckCircle2, PlayCircle, AlertTriangle,
|
|
List, Columns3, ArrowUpDown, Users, Eye,
|
|
} from 'lucide-react';
|
|
import api from '@/lib/api';
|
|
import type { Project, ProjectTask, ProjectMember } from '@/types';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Select } from '@/components/ui/select';
|
|
import { ListSkeleton } from '@/components/ui/skeleton';
|
|
import { EmptyState } from '@/components/ui/empty-state';
|
|
import { useSettings } from '@/hooks/useSettings';
|
|
import { useDeltaPoll } from '@/hooks/useDeltaPoll';
|
|
import TaskRow from './TaskRow';
|
|
import TaskDetailPanel from './TaskDetailPanel';
|
|
import KanbanBoard from './KanbanBoard';
|
|
import TaskForm from './TaskForm';
|
|
import ProjectForm from './ProjectForm';
|
|
import { ProjectShareSheet } from './ProjectShareSheet';
|
|
import { statusColors, statusLabels } from './constants';
|
|
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
|
|
|
|
type SortMode = 'manual' | 'priority' | 'due_date';
|
|
type ViewMode = 'list' | 'kanban';
|
|
|
|
const PRIORITY_ORDER: Record<string, number> = { high: 0, medium: 1, low: 2, none: 3 };
|
|
|
|
function SortableTaskRow({
|
|
task,
|
|
isSelected,
|
|
isExpanded,
|
|
showDragHandle,
|
|
onSelect,
|
|
onToggleExpand,
|
|
onToggleStatus,
|
|
togglePending,
|
|
}: {
|
|
task: ProjectTask;
|
|
isSelected: boolean;
|
|
isExpanded: boolean;
|
|
showDragHandle: boolean;
|
|
onSelect: () => void;
|
|
onToggleExpand: () => void;
|
|
onToggleStatus: () => void;
|
|
togglePending: boolean;
|
|
}) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: task.id });
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
};
|
|
|
|
return (
|
|
<div ref={setNodeRef} style={style} {...attributes}>
|
|
<TaskRow
|
|
task={task}
|
|
isSelected={isSelected}
|
|
isExpanded={isExpanded}
|
|
showDragHandle={showDragHandle}
|
|
onSelect={onSelect}
|
|
onToggleExpand={onToggleExpand}
|
|
onToggleStatus={onToggleStatus}
|
|
togglePending={togglePending}
|
|
dragHandleProps={listeners}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ProjectDetail() {
|
|
const { id } = useParams();
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [showTaskForm, setShowTaskForm] = useState(false);
|
|
const [showProjectForm, setShowProjectForm] = useState(false);
|
|
const [editingTask, setEditingTask] = useState<ProjectTask | null>(null);
|
|
const [subtaskParentId, setSubtaskParentId] = useState<number | null>(null);
|
|
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
|
|
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null);
|
|
const [kanbanParentTaskId, setKanbanParentTaskId] = useState<number | null>(null);
|
|
const [sortMode, setSortMode] = useState<SortMode>('manual');
|
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
|
const [showShareSheet, setShowShareSheet] = useState(false);
|
|
const { settings } = useSettings();
|
|
const currentUserId = settings?.user_id ?? 0;
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
useSensor(KeyboardSensor)
|
|
);
|
|
|
|
const toggleExpand = (taskId: number) => {
|
|
setExpandedTasks((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(taskId)) next.delete(taskId);
|
|
else next.add(taskId);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const { data: project, isLoading } = useQuery({
|
|
queryKey: ['projects', id],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<Project>(`/projects/${id}`);
|
|
return data;
|
|
},
|
|
});
|
|
|
|
// Permission derivation
|
|
const isOwner = project ? project.user_id === currentUserId : true;
|
|
const isShared = project ? project.user_id !== currentUserId : false;
|
|
// For now, if they can see the project but don't own it, check if they can edit
|
|
// The backend enforces actual permissions — this is just UI gating
|
|
const canEdit = isOwner; // Members with create_modify can also edit tasks (handled per-task)
|
|
const canManageProject = isOwner;
|
|
|
|
// Delta polling for real-time sync on shared projects
|
|
const pollKey = useMemo(() => ['projects', id], [id]);
|
|
useDeltaPoll(
|
|
id ? `/projects/${id}/poll` : null,
|
|
pollKey,
|
|
5000,
|
|
);
|
|
|
|
// Fetch members for shared projects
|
|
const { data: members = [] } = useQuery<ProjectMember[]>({
|
|
queryKey: ['project-members', id],
|
|
queryFn: async () => {
|
|
const { data } = await api.get(`/projects/${id}/members`);
|
|
return data;
|
|
},
|
|
enabled: !!id,
|
|
});
|
|
const acceptedMembers = members.filter((m) => m.status === 'accepted');
|
|
|
|
const toggleTaskMutation = useMutation({
|
|
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
|
|
const newStatus = status === 'completed' ? 'pending' : 'completed';
|
|
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus });
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
|
},
|
|
onError: () => {
|
|
toast.error('Failed to update task');
|
|
},
|
|
});
|
|
|
|
const deleteTaskMutation = useMutation({
|
|
mutationFn: async (taskId: number) => {
|
|
await api.delete(`/projects/${id}/tasks/${taskId}`);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
|
toast.success('Task deleted');
|
|
setSelectedTaskId(null);
|
|
},
|
|
});
|
|
|
|
const deleteProjectMutation = useMutation({
|
|
mutationFn: async () => {
|
|
await api.delete(`/projects/${id}`);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
|
toast.success('Project deleted');
|
|
navigate('/projects');
|
|
},
|
|
onError: () => {
|
|
toast.error('Failed to delete project');
|
|
},
|
|
});
|
|
|
|
const toggleTrackMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const { data } = await api.put(`/projects/${id}`, { is_tracked: !project?.is_tracked });
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
|
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
|
queryClient.invalidateQueries({ queryKey: ['tracked-tasks'] });
|
|
toast.success(project?.is_tracked ? 'Project untracked' : 'Project tracked');
|
|
},
|
|
onError: () => {
|
|
toast.error('Failed to update tracking');
|
|
},
|
|
});
|
|
|
|
const reorderMutation = useMutation({
|
|
mutationFn: async (items: { id: number; sort_order: number }[]) => {
|
|
await api.put(`/projects/${id}/tasks/reorder`, items);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
|
},
|
|
});
|
|
|
|
const updateTaskStatusMutation = useMutation({
|
|
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
|
|
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status });
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
|
},
|
|
onError: () => {
|
|
toast.error('Failed to update task status');
|
|
},
|
|
});
|
|
|
|
const allTasks = project?.tasks || [];
|
|
const topLevelTasks = useMemo(
|
|
() => allTasks.filter((t) => !t.parent_task_id),
|
|
[allTasks]
|
|
);
|
|
|
|
const sortSubtasks = useCallback(
|
|
(subtasks: ProjectTask[]): ProjectTask[] => {
|
|
if (sortMode === 'manual') return subtasks;
|
|
const sorted = [...subtasks];
|
|
if (sortMode === 'priority') {
|
|
sorted.sort((a, b) => (PRIORITY_ORDER[a.priority] ?? 3) - (PRIORITY_ORDER[b.priority] ?? 3));
|
|
} else if (sortMode === 'due_date') {
|
|
sorted.sort((a, b) => {
|
|
if (!a.due_date && !b.due_date) return 0;
|
|
if (!a.due_date) return 1;
|
|
if (!b.due_date) return -1;
|
|
return a.due_date.localeCompare(b.due_date);
|
|
});
|
|
}
|
|
return sorted;
|
|
},
|
|
[sortMode]
|
|
);
|
|
|
|
const sortedTasks = useMemo(() => {
|
|
const tasks = [...topLevelTasks].map((t) => ({
|
|
...t,
|
|
subtasks: sortSubtasks(t.subtasks || []),
|
|
}));
|
|
switch (sortMode) {
|
|
case 'priority':
|
|
return tasks.sort(
|
|
(a, b) => (PRIORITY_ORDER[a.priority] ?? 3) - (PRIORITY_ORDER[b.priority] ?? 3)
|
|
);
|
|
case 'due_date':
|
|
return tasks.sort((a, b) => {
|
|
if (!a.due_date && !b.due_date) return 0;
|
|
if (!a.due_date) return 1;
|
|
if (!b.due_date) return -1;
|
|
return a.due_date.localeCompare(b.due_date);
|
|
});
|
|
case 'manual':
|
|
default:
|
|
return tasks.sort((a, b) => a.sort_order - b.sort_order);
|
|
}
|
|
}, [topLevelTasks, sortMode, sortSubtasks]);
|
|
|
|
const isDesktop = useMediaQuery(DESKTOP);
|
|
|
|
const selectedTask = useMemo(() => {
|
|
if (!selectedTaskId) return null;
|
|
// Search top-level and subtasks
|
|
for (const task of allTasks) {
|
|
if (task.id === selectedTaskId) return task;
|
|
if (task.subtasks) {
|
|
const sub = task.subtasks.find((s) => s.id === selectedTaskId);
|
|
if (sub) return sub;
|
|
}
|
|
}
|
|
return null;
|
|
}, [selectedTaskId, allTasks]);
|
|
|
|
const kanbanParentTask = useMemo(() => {
|
|
if (!kanbanParentTaskId) return null;
|
|
return topLevelTasks.find((t) => t.id === kanbanParentTaskId) || null;
|
|
}, [kanbanParentTaskId, topLevelTasks]);
|
|
|
|
const handleKanbanSelectTask = useCallback(
|
|
(taskId: number) => {
|
|
setSelectedTaskId(taskId);
|
|
// Only enter subtask view when clicking a top-level task with subtasks
|
|
// and we're not already in subtask view
|
|
if (!kanbanParentTaskId) {
|
|
const task = topLevelTasks.find((t) => t.id === taskId);
|
|
if (task && task.subtasks && task.subtasks.length > 0) {
|
|
setKanbanParentTaskId(taskId);
|
|
}
|
|
}
|
|
},
|
|
[kanbanParentTaskId, topLevelTasks]
|
|
);
|
|
|
|
const handleBackToAllTasks = useCallback(() => {
|
|
setKanbanParentTaskId(null);
|
|
setSelectedTaskId(null);
|
|
}, []);
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
|
|
const oldIndex = sortedTasks.findIndex((t) => t.id === active.id);
|
|
const newIndex = sortedTasks.findIndex((t) => t.id === over.id);
|
|
const reordered = arrayMove(sortedTasks, oldIndex, newIndex);
|
|
|
|
const items = reordered.map((task, index) => ({
|
|
id: task.id,
|
|
sort_order: index,
|
|
}));
|
|
|
|
// Optimistic update
|
|
queryClient.setQueryData(['projects', id], (old: Project | undefined) => {
|
|
if (!old) return old;
|
|
const updated = { ...old, tasks: [...old.tasks] };
|
|
for (const item of items) {
|
|
const t = updated.tasks.find((tt) => tt.id === item.id);
|
|
if (t) t.sort_order = item.sort_order;
|
|
}
|
|
return updated;
|
|
});
|
|
|
|
reorderMutation.mutate(items);
|
|
},
|
|
[sortedTasks, id, queryClient, reorderMutation]
|
|
);
|
|
|
|
const openTaskForm = (task: ProjectTask | null, parentId: number | null) => {
|
|
setEditingTask(task);
|
|
setSubtaskParentId(parentId);
|
|
setShowTaskForm(true);
|
|
};
|
|
|
|
const closeTaskForm = () => {
|
|
setShowTaskForm(false);
|
|
setEditingTask(null);
|
|
setSubtaskParentId(null);
|
|
};
|
|
|
|
const handleDeleteTask = (taskId: number) => {
|
|
if (!window.confirm('Delete this task and all its subtasks?')) return;
|
|
deleteTaskMutation.mutate(taskId);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex flex-col h-full animate-fade-in">
|
|
<div className="border-b bg-card px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
|
|
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Button>
|
|
<h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight flex-1">Loading...</h1>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-5">
|
|
<ListSkeleton rows={4} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!project) {
|
|
return <div className="p-6 text-center text-muted-foreground">Project not found</div>;
|
|
}
|
|
|
|
const completedTasks = allTasks.filter((t) => t.status === 'completed').length;
|
|
const inProgressTasks = allTasks.filter((t) => t.status === 'in_progress').length;
|
|
const overdueTasks = allTasks.filter(
|
|
(t) => t.due_date && t.status !== 'completed' && isPast(parseISO(t.due_date))
|
|
).length;
|
|
const totalTasks = allTasks.length;
|
|
const progressPercent = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0;
|
|
const isProjectOverdue =
|
|
project.due_date && project.status !== 'completed' && isPast(parseISO(project.due_date));
|
|
|
|
return (
|
|
<div className="flex flex-col h-full animate-fade-in">
|
|
{/* Header */}
|
|
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-2 md:gap-4 shrink-0">
|
|
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Button>
|
|
<h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight flex-1 truncate min-w-0">
|
|
{project.name}
|
|
</h1>
|
|
<Badge className={`shrink-0 hidden sm:inline-flex ${statusColors[project.status]}`}>
|
|
{statusLabels[project.status]}
|
|
</Badge>
|
|
{/* Permission badge for non-owners */}
|
|
{isShared && (
|
|
<Badge className="shrink-0 bg-blue-500/10 text-blue-400 border-0">
|
|
{acceptedMembers.find((m) => m.user_id === currentUserId)?.permission === 'create_modify' ? 'Editor' : 'Viewer'}
|
|
</Badge>
|
|
)}
|
|
{canManageProject && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => toggleTrackMutation.mutate()}
|
|
disabled={toggleTrackMutation.isPending}
|
|
className={`shrink-0 ${project.is_tracked ? 'text-accent' : 'text-muted-foreground'}`}
|
|
title={project.is_tracked ? 'Untrack project' : 'Track project'}
|
|
>
|
|
<Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0 text-muted-foreground relative"
|
|
onClick={() => setShowShareSheet(true)}
|
|
title="Project members"
|
|
>
|
|
<Users className="h-4 w-4" />
|
|
{acceptedMembers.length > 0 && (
|
|
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-accent text-[9px] font-bold flex items-center justify-center text-background">
|
|
{acceptedMembers.length}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
{canManageProject && (
|
|
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setShowProjectForm(true)}>
|
|
<Pencil className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Edit</span>
|
|
</Button>
|
|
)}
|
|
{canManageProject && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="shrink-0 text-destructive hover:bg-destructive/10"
|
|
onClick={() => {
|
|
if (!window.confirm('Delete this project and all its tasks?')) return;
|
|
deleteProjectMutation.mutate();
|
|
}}
|
|
disabled={deleteProjectMutation.isPending}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Delete</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Content area */}
|
|
<div className="flex-1 overflow-hidden flex flex-col">
|
|
{/* Summary section - scrolls with left panel on small, fixed on large */}
|
|
<div className="px-4 md:px-6 py-5 space-y-5 shrink-0 overflow-y-auto max-h-[50vh] lg:max-h-none lg:overflow-visible">
|
|
{/* Description */}
|
|
{project.description && (
|
|
<p className="text-sm text-muted-foreground">{project.description}</p>
|
|
)}
|
|
|
|
{/* Project Summary Card */}
|
|
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
|
<CardContent className="p-5">
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6">
|
|
<div className="flex-1">
|
|
<div className="flex items-baseline justify-between mb-2">
|
|
<span className="text-sm text-muted-foreground">Overall Progress</span>
|
|
<span className="font-heading text-lg font-bold tabular-nums">
|
|
{Math.round(progressPercent)}%
|
|
</span>
|
|
</div>
|
|
<div className="h-2.5 bg-secondary rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-accent rounded-full transition-all duration-300"
|
|
style={{ width: `${progressPercent}%` }}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1.5 tabular-nums">
|
|
{completedTasks} of {totalTasks} tasks completed
|
|
</p>
|
|
</div>
|
|
<div className="hidden sm:block w-px h-16 bg-border" />
|
|
<div className="flex items-center gap-5">
|
|
<div className="text-center">
|
|
<div className="p-1.5 rounded-md bg-blue-500/10 mx-auto w-fit mb-1">
|
|
<ListChecks className="h-4 w-4 text-blue-400" />
|
|
</div>
|
|
<p className="font-heading text-lg font-bold tabular-nums">{totalTasks}</p>
|
|
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Total</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="p-1.5 rounded-md bg-purple-500/10 mx-auto w-fit mb-1">
|
|
<PlayCircle className="h-4 w-4 text-purple-400" />
|
|
</div>
|
|
<p className="font-heading text-lg font-bold tabular-nums">{inProgressTasks}</p>
|
|
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Active</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="p-1.5 rounded-md bg-green-500/10 mx-auto w-fit mb-1">
|
|
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
|
</div>
|
|
<p className="font-heading text-lg font-bold tabular-nums">{completedTasks}</p>
|
|
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Done</p>
|
|
</div>
|
|
{overdueTasks > 0 && (
|
|
<div className="text-center">
|
|
<div className="p-1.5 rounded-md bg-red-500/10 mx-auto w-fit mb-1">
|
|
<AlertTriangle className="h-4 w-4 text-red-400" />
|
|
</div>
|
|
<p className="font-heading text-lg font-bold tabular-nums text-red-400">{overdueTasks}</p>
|
|
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Overdue</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{project.due_date && (
|
|
<div className={`flex items-center gap-2 text-sm mt-4 pt-3 border-t border-border ${isProjectOverdue ? 'text-red-400' : 'text-muted-foreground'}`}>
|
|
<Calendar className="h-4 w-4" />
|
|
Due {format(parseISO(project.due_date), 'MMM d, yyyy')}
|
|
{isProjectOverdue && <span className="text-xs font-medium">(Overdue)</span>}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Read-only banner for viewers */}
|
|
{isShared && !canEdit && (
|
|
<div className="mx-4 md:mx-6 mb-3 px-3 py-2 rounded-md bg-secondary/50 border border-border flex items-center gap-2">
|
|
<Eye className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
<span className="text-xs text-muted-foreground">You have view-only access to this project</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Task list header + view controls */}
|
|
<div className="px-4 md:px-6 pb-3 flex items-center justify-between flex-wrap gap-2 shrink-0">
|
|
<h2 className="font-heading text-lg font-semibold">Tasks</h2>
|
|
<div className="flex items-center gap-2">
|
|
{/* View toggle */}
|
|
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
|
<button
|
|
onClick={() => { setViewMode('list'); setKanbanParentTaskId(null); }}
|
|
className={`px-2.5 py-1.5 transition-colors ${
|
|
viewMode === 'list'
|
|
? 'bg-accent/15 text-accent'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
|
}`}
|
|
>
|
|
<List className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('kanban')}
|
|
className={`px-2.5 py-1.5 transition-colors ${
|
|
viewMode === 'kanban'
|
|
? 'bg-accent/15 text-accent'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
|
}`}
|
|
>
|
|
<Columns3 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Sort dropdown (list view only) */}
|
|
{viewMode === 'list' && (
|
|
<div className="flex items-center gap-1.5">
|
|
<ArrowUpDown className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<Select
|
|
value={sortMode}
|
|
onChange={(e) => setSortMode(e.target.value as SortMode)}
|
|
className="h-8 text-xs w-auto min-w-[100px]"
|
|
>
|
|
<option value="manual">Manual</option>
|
|
<option value="priority">Priority</option>
|
|
<option value="due_date">Due Date</option>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{(isOwner || acceptedMembers.find((m) => m.user_id === currentUserId)?.permission === 'create_modify') && (
|
|
<Button size="sm" onClick={() => openTaskForm(null, null)}>
|
|
<Plus className="mr-2 h-3.5 w-3.5" />
|
|
Add Task
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main content: task list/kanban + detail panel */}
|
|
<div className="flex-1 overflow-hidden flex">
|
|
{/* Left panel: task list or kanban */}
|
|
<div className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${selectedTaskId ? 'w-full lg:w-[55%]' : 'w-full'}`}>
|
|
<div className="px-4 md:px-6 pb-6">
|
|
{topLevelTasks.length === 0 ? (
|
|
<EmptyState
|
|
icon={ListChecks}
|
|
title="No tasks yet"
|
|
description="Break this project down into tasks to track your progress."
|
|
actionLabel="Add Task"
|
|
onAction={() => openTaskForm(null, null)}
|
|
/>
|
|
) : viewMode === 'kanban' ? (
|
|
<KanbanBoard
|
|
tasks={topLevelTasks}
|
|
selectedTaskId={selectedTaskId}
|
|
kanbanParentTask={kanbanParentTask}
|
|
onSelectTask={handleKanbanSelectTask}
|
|
onStatusChange={(taskId, status) =>
|
|
updateTaskStatusMutation.mutate({ taskId, status })
|
|
}
|
|
onBackToAllTasks={handleBackToAllTasks}
|
|
/>
|
|
) : (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={sortedTasks.map((t) => t.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
disabled={sortMode !== 'manual'}
|
|
>
|
|
<div className="space-y-1">
|
|
{sortedTasks.map((task) => {
|
|
const isExpanded = expandedTasks.has(task.id);
|
|
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
|
|
|
|
return (
|
|
<div key={task.id}>
|
|
<SortableTaskRow
|
|
task={task}
|
|
isSelected={selectedTaskId === task.id}
|
|
isExpanded={isExpanded}
|
|
showDragHandle={sortMode === 'manual'}
|
|
onSelect={() => setSelectedTaskId(task.id)}
|
|
onToggleExpand={() => toggleExpand(task.id)}
|
|
onToggleStatus={() =>
|
|
toggleTaskMutation.mutate({
|
|
taskId: task.id,
|
|
status: task.status,
|
|
})
|
|
}
|
|
togglePending={toggleTaskMutation.isPending}
|
|
/>
|
|
{/* Expanded subtasks */}
|
|
{isExpanded && hasSubtasks && (
|
|
<div className="ml-5 sm:ml-10 mt-0.5 space-y-0.5">
|
|
{task.subtasks.map((subtask) => (
|
|
<TaskRow
|
|
key={subtask.id}
|
|
task={subtask}
|
|
isSelected={selectedTaskId === subtask.id}
|
|
isExpanded={false}
|
|
showDragHandle={false}
|
|
onSelect={() => setSelectedTaskId(subtask.id)}
|
|
onToggleExpand={() => {}}
|
|
onToggleStatus={() =>
|
|
toggleTaskMutation.mutate({
|
|
taskId: subtask.id,
|
|
status: subtask.status,
|
|
})
|
|
}
|
|
togglePending={toggleTaskMutation.isPending}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right panel: task detail (desktop only) */}
|
|
{selectedTaskId && isDesktop && (
|
|
<div
|
|
className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] border-l border-border bg-card flex w-[45%]"
|
|
>
|
|
<div className="flex-1 overflow-hidden min-w-[360px]">
|
|
<TaskDetailPanel
|
|
task={selectedTask}
|
|
projectId={parseInt(id!)}
|
|
onDelete={handleDeleteTask}
|
|
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
|
|
onClose={() => setSelectedTaskId(null)}
|
|
onSelectTask={setSelectedTaskId}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile: show detail panel as overlay when task selected on small screens */}
|
|
{selectedTaskId && selectedTask && !isDesktop && (
|
|
<MobileDetailOverlay open={true} onClose={() => setSelectedTaskId(null)}>
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
<span className="text-sm font-medium text-muted-foreground">Task Details</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSelectedTaskId(null)}
|
|
>
|
|
Close
|
|
</Button>
|
|
</div>
|
|
<div className="h-[calc(100%-49px)]">
|
|
<TaskDetailPanel
|
|
task={selectedTask}
|
|
projectId={parseInt(id!)}
|
|
onDelete={handleDeleteTask}
|
|
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
|
|
onClose={() => setSelectedTaskId(null)}
|
|
onSelectTask={setSelectedTaskId}
|
|
/>
|
|
</div>
|
|
</MobileDetailOverlay>
|
|
)}
|
|
|
|
{showTaskForm && (
|
|
<TaskForm
|
|
projectId={parseInt(id!)}
|
|
task={editingTask}
|
|
parentTaskId={subtaskParentId}
|
|
defaultDueDate={
|
|
subtaskParentId
|
|
? allTasks.find((t) => t.id === subtaskParentId)?.due_date
|
|
: project?.due_date
|
|
}
|
|
onClose={closeTaskForm}
|
|
/>
|
|
)}
|
|
|
|
{showProjectForm && (
|
|
<ProjectForm
|
|
project={project}
|
|
onClose={() => setShowProjectForm(false)}
|
|
/>
|
|
)}
|
|
|
|
<ProjectShareSheet
|
|
open={showShareSheet}
|
|
onOpenChange={setShowShareSheet}
|
|
projectId={parseInt(id!)}
|
|
isOwner={isOwner}
|
|
ownerName={settings?.preferred_name || 'Owner'}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|