4a. Touch fallbacks for group-hover actions:
- 9 occurrences across 5 files changed from opacity-0 group-hover:opacity-100
to opacity-100 md:opacity-0 md:group-hover:opacity-100
- CalendarSidebar (3), SharedCalendarSection (2), TaskDetailPanel (2),
NotificationsPage (1), CopyableField (1)
- Action buttons now always visible on touch, hover-revealed on desktop
4b. FullCalendar mobile touch:
- Wheel navigation disabled on touch devices (ontouchstart check)
- Prevents scroll hijacking on mobile, allows native scroll
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
583 lines
21 KiB
TypeScript
583 lines
21 KiB
TypeScript
import { useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { format, formatDistanceToNow, parseISO } from 'date-fns';
|
|
import {
|
|
Pencil, Trash2, Plus, MessageSquare, ClipboardList,
|
|
Calendar, User, Flag, Activity, Send, X, Save,
|
|
} from 'lucide-react';
|
|
import api, { getErrorMessage } from '@/lib/api';
|
|
import { formatUpdatedAt } from '@/components/shared/utils';
|
|
import type { ProjectTask, TaskComment, Person } from '@/types';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Input } from '@/components/ui/input';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
import { Select } from '@/components/ui/select';
|
|
|
|
const taskStatusColors: Record<string, string> = {
|
|
pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
|
|
in_progress: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
|
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
|
|
blocked: 'bg-red-500/10 text-red-400 border-red-500/20',
|
|
review: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
|
on_hold: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
|
|
};
|
|
|
|
const taskStatusLabels: Record<string, string> = {
|
|
pending: 'Pending',
|
|
in_progress: 'In Progress',
|
|
completed: 'Completed',
|
|
blocked: 'Blocked',
|
|
review: 'Review',
|
|
on_hold: 'On Hold',
|
|
};
|
|
|
|
const priorityColors: Record<string, string> = {
|
|
none: 'bg-gray-500/20 text-gray-400',
|
|
low: 'bg-green-500/20 text-green-400',
|
|
medium: 'bg-yellow-500/20 text-yellow-400',
|
|
high: 'bg-red-500/20 text-red-400',
|
|
};
|
|
|
|
interface TaskDetailPanelProps {
|
|
task: ProjectTask | null;
|
|
projectId: number;
|
|
onDelete: (taskId: number) => void;
|
|
onAddSubtask: (parentId: number) => void;
|
|
onClose?: () => void;
|
|
onSelectTask?: (taskId: number) => void;
|
|
}
|
|
|
|
interface EditState {
|
|
title: string;
|
|
status: string;
|
|
priority: string;
|
|
due_date: string;
|
|
person_id: string;
|
|
description: string;
|
|
}
|
|
|
|
function todayLocal(): string {
|
|
const d = new Date();
|
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
}
|
|
|
|
function buildEditState(task: ProjectTask): EditState {
|
|
return {
|
|
title: task.title,
|
|
status: task.status,
|
|
priority: task.priority,
|
|
// Slice to YYYY-MM-DD for date input; backend may return full ISO string
|
|
due_date: task.due_date ? task.due_date.slice(0, 10) : '',
|
|
person_id: task.person_id != null ? String(task.person_id) : '',
|
|
description: task.description ?? '',
|
|
};
|
|
}
|
|
|
|
export default function TaskDetailPanel({
|
|
task,
|
|
projectId,
|
|
onDelete,
|
|
onAddSubtask,
|
|
onClose,
|
|
onSelectTask,
|
|
}: TaskDetailPanelProps) {
|
|
const queryClient = useQueryClient();
|
|
const [commentText, setCommentText] = useState('');
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editState, setEditState] = useState<EditState>(() =>
|
|
task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: todayLocal(), person_id: '', description: '' }
|
|
);
|
|
|
|
const { data: people = [] } = useQuery({
|
|
queryKey: ['people'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<Person[]>('/people');
|
|
return data;
|
|
},
|
|
});
|
|
|
|
// --- Mutations ---
|
|
|
|
const toggleSubtaskMutation = useMutation({
|
|
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
|
|
const newStatus = status === 'completed' ? 'pending' : 'completed';
|
|
const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus });
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
|
},
|
|
});
|
|
|
|
const updateTaskMutation = useMutation({
|
|
mutationFn: async (payload: Record<string, unknown>) => {
|
|
const { data } = await api.put(`/projects/${projectId}/tasks/${task!.id}`, payload);
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
|
setIsEditing(false);
|
|
toast.success('Task updated');
|
|
},
|
|
onError: (error) => {
|
|
toast.error(getErrorMessage(error, 'Failed to update task'));
|
|
},
|
|
});
|
|
|
|
const deleteSubtaskMutation = useMutation({
|
|
mutationFn: async (subtaskId: number) => {
|
|
await api.delete(`/projects/${projectId}/tasks/${subtaskId}`);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
|
toast.success('Subtask deleted');
|
|
},
|
|
onError: (error) => {
|
|
toast.error(getErrorMessage(error, 'Failed to delete subtask'));
|
|
},
|
|
});
|
|
|
|
const addCommentMutation = useMutation({
|
|
mutationFn: async (content: string) => {
|
|
const { data } = await api.post<TaskComment>(
|
|
`/projects/${projectId}/tasks/${task!.id}/comments`,
|
|
{ content }
|
|
);
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
|
setCommentText('');
|
|
},
|
|
onError: (error) => {
|
|
toast.error(getErrorMessage(error, 'Failed to add comment'));
|
|
},
|
|
});
|
|
|
|
const deleteCommentMutation = useMutation({
|
|
mutationFn: async (commentId: number) => {
|
|
await api.delete(`/projects/${projectId}/tasks/${task!.id}/comments/${commentId}`);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
|
toast.success('Comment deleted');
|
|
},
|
|
});
|
|
|
|
// --- Handlers ---
|
|
|
|
const handleAddComment = () => {
|
|
const trimmed = commentText.trim();
|
|
if (!trimmed) return;
|
|
addCommentMutation.mutate(trimmed);
|
|
};
|
|
|
|
const handleEditStart = () => {
|
|
if (!task) return;
|
|
setEditState(buildEditState(task));
|
|
setIsEditing(true);
|
|
};
|
|
|
|
const handleEditCancel = () => {
|
|
setIsEditing(false);
|
|
if (task) setEditState(buildEditState(task));
|
|
};
|
|
|
|
const handleEditSave = () => {
|
|
if (!task) return;
|
|
const payload: Record<string, unknown> = {
|
|
title: editState.title.trim() || task.title,
|
|
status: editState.status,
|
|
priority: editState.priority,
|
|
due_date: editState.due_date || null,
|
|
person_id: editState.person_id ? Number(editState.person_id) : null,
|
|
description: editState.description || null,
|
|
};
|
|
updateTaskMutation.mutate(payload);
|
|
};
|
|
|
|
const handleDeleteSubtask = (subtaskId: number, subtaskTitle: string) => {
|
|
if (!window.confirm(`Delete subtask "${subtaskTitle}"?`)) return;
|
|
deleteSubtaskMutation.mutate(subtaskId);
|
|
};
|
|
|
|
// --- Empty state ---
|
|
|
|
if (!task) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
|
<ClipboardList className="h-8 w-8 mb-3 opacity-40" />
|
|
<p className="text-sm">Select a task to view details</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const assignedPerson = task.person_id ? people.find((p) => p.id === task.person_id) : null;
|
|
const comments = task.comments || [];
|
|
|
|
return (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
{/* Header */}
|
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
|
<div className="flex items-start justify-between gap-3">
|
|
{isEditing ? (
|
|
<Input
|
|
value={editState.title}
|
|
onChange={(e) => setEditState((s) => ({ ...s, title: e.target.value }))}
|
|
className="h-8 text-base font-semibold flex-1"
|
|
autoFocus
|
|
/>
|
|
) : (
|
|
<h3 className="font-heading text-lg font-semibold leading-tight">{task.title}</h3>
|
|
)}
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{isEditing ? (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-green-400 hover:text-green-300"
|
|
onClick={handleEditSave}
|
|
disabled={updateTaskMutation.isPending}
|
|
title="Save changes"
|
|
>
|
|
<Save className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={handleEditCancel}
|
|
title="Cancel editing"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={handleEditStart}
|
|
title="Edit task"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
|
onClick={() => onDelete(task.id)}
|
|
title="Delete task"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
{onClose && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={onClose}
|
|
title="Close panel"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scrollable content */}
|
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
|
{/* Fields grid */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{/* Status */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Activity className="h-3 w-3" />
|
|
Status
|
|
</div>
|
|
{isEditing ? (
|
|
<Select
|
|
value={editState.status}
|
|
onChange={(e) => setEditState((s) => ({ ...s, status: e.target.value }))}
|
|
className="h-8 text-xs"
|
|
>
|
|
<option value="pending">Pending</option>
|
|
<option value="in_progress">In Progress</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="blocked">Blocked</option>
|
|
<option value="review">Review</option>
|
|
<option value="on_hold">On Hold</option>
|
|
</Select>
|
|
) : (
|
|
<Badge className={`text-[9px] px-1.5 py-0.5 ${taskStatusColors[task.status] ?? ''}`}>
|
|
{taskStatusLabels[task.status] ?? task.status}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Priority */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Flag className="h-3 w-3" />
|
|
Priority
|
|
</div>
|
|
{isEditing ? (
|
|
<Select
|
|
value={editState.priority}
|
|
onChange={(e) => setEditState((s) => ({ ...s, priority: e.target.value }))}
|
|
className="h-8 text-xs"
|
|
>
|
|
<option value="none">None</option>
|
|
<option value="low">Low</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="high">High</option>
|
|
</Select>
|
|
) : (
|
|
<Badge className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority] ?? ''}`}>
|
|
{task.priority}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Due Date */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Calendar className="h-3 w-3" />
|
|
Due Date
|
|
</div>
|
|
{isEditing ? (
|
|
<DatePicker
|
|
variant="input"
|
|
value={editState.due_date}
|
|
onChange={(v) => setEditState((s) => ({ ...s, due_date: v }))}
|
|
className="h-8 text-xs"
|
|
/>
|
|
) : (
|
|
<p className="text-sm">
|
|
{task.due_date ? format(parseISO(task.due_date), 'MMM d, yyyy') : '—'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Assigned */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<User className="h-3 w-3" />
|
|
Assigned
|
|
</div>
|
|
{isEditing ? (
|
|
<Select
|
|
value={editState.person_id}
|
|
onChange={(e) => setEditState((s) => ({ ...s, person_id: e.target.value }))}
|
|
className="h-8 text-xs"
|
|
>
|
|
<option value="">Unassigned</option>
|
|
{people.map((p) => (
|
|
<option key={p.id} value={String(p.id)}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
) : (
|
|
<p className="text-sm">{assignedPerson ? assignedPerson.name : '—'}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
{isEditing ? (
|
|
<div className="space-y-1.5">
|
|
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">Description</h4>
|
|
<Textarea
|
|
value={editState.description}
|
|
onChange={(e) => setEditState((s) => ({ ...s, description: e.target.value }))}
|
|
placeholder="Add a description..."
|
|
rows={3}
|
|
className="text-sm resize-none"
|
|
/>
|
|
</div>
|
|
) : task.description ? (
|
|
<div className="space-y-1.5">
|
|
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">Description</h4>
|
|
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
|
{task.description}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Subtasks */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
Subtasks
|
|
{task.subtasks.length > 0 && (
|
|
<span className="ml-1.5 tabular-nums">
|
|
({task.subtasks.filter((s) => s.status === 'completed').length}/
|
|
{task.subtasks.length})
|
|
</span>
|
|
)}
|
|
</h4>
|
|
{/* Hide "Add subtask" for subtasks (tasks that already have a parent) */}
|
|
{!task.parent_task_id && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 text-xs px-2"
|
|
onClick={() => onAddSubtask(task.id)}
|
|
>
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
Add
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{task.subtasks.length > 0 ? (
|
|
<div className="space-y-1">
|
|
{task.subtasks.map((subtask) => (
|
|
<div
|
|
key={subtask.id}
|
|
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150 cursor-pointer group"
|
|
onClick={() => onSelectTask?.(subtask.id)}
|
|
>
|
|
{/* Checkbox stops propagation so clicking it doesn't navigate */}
|
|
<span onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox
|
|
checked={subtask.status === 'completed'}
|
|
onChange={() =>
|
|
toggleSubtaskMutation.mutate({
|
|
taskId: subtask.id,
|
|
status: subtask.status,
|
|
})
|
|
}
|
|
disabled={toggleSubtaskMutation.isPending}
|
|
/>
|
|
</span>
|
|
<span
|
|
className={`text-sm flex-1 ${
|
|
subtask.status === 'completed' ? 'line-through text-muted-foreground' : ''
|
|
}`}
|
|
>
|
|
{subtask.title}
|
|
</span>
|
|
<Badge
|
|
className={`text-[9px] px-1.5 py-0.5 ${taskStatusColors[subtask.status] ?? ''}`}
|
|
>
|
|
{taskStatusLabels[subtask.status] ?? subtask.status}
|
|
</Badge>
|
|
<Badge
|
|
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[subtask.priority] ?? ''}`}
|
|
>
|
|
{subtask.priority}
|
|
</Badge>
|
|
{/* Delete subtask — stops propagation to avoid navigation */}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-5 w-5 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive shrink-0"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteSubtask(subtask.id, subtask.title);
|
|
}}
|
|
disabled={deleteSubtaskMutation.isPending}
|
|
title="Delete subtask"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">No subtasks yet</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Comments */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-1.5">
|
|
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
Comments
|
|
{comments.length > 0 && (
|
|
<span className="ml-1 tabular-nums">({comments.length})</span>
|
|
)}
|
|
</h4>
|
|
</div>
|
|
|
|
{/* Comment list */}
|
|
{comments.length > 0 && (
|
|
<div className="space-y-2">
|
|
{comments.map((comment) => (
|
|
<div key={comment.id} className="group rounded-md bg-secondary/50 px-3 py-2">
|
|
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
|
|
<div className="flex items-center justify-between mt-1.5">
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{formatDistanceToNow(parseISO(comment.created_at), { addSuffix: true })}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-5 w-5 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
|
onClick={() => {
|
|
if (!window.confirm('Delete this comment?')) return;
|
|
deleteCommentMutation.mutate(comment.id);
|
|
}}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add comment */}
|
|
<div className="flex gap-2">
|
|
<Textarea
|
|
value={commentText}
|
|
onChange={(e) => setCommentText(e.target.value)}
|
|
placeholder="Add a comment..."
|
|
rows={2}
|
|
className="flex-1 text-sm resize-none"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
handleAddComment();
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-10 w-10 shrink-0 self-end"
|
|
onClick={handleAddComment}
|
|
disabled={!commentText.trim() || addCommentMutation.isPending}
|
|
>
|
|
<Send className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Updated at footer */}
|
|
{task.updated_at && (
|
|
<div className="pt-2 border-t border-border">
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{formatUpdatedAt(task.updated_at)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|