UMBRA/frontend/src/components/projects/TaskDetailPanel.tsx
Kyle Pope 4169c245c2 Global enhancements: none priority, optional remind_at, required labels, textarea flex, remove color picker
- Add "none" priority (grey) to task/todo schemas, types, and all priority color maps
- Make remind_at optional on reminders (schema, model, migration 010)
- Add required prop to Label component with red asterisk indicator
- Add invalid:ring-red-500 to Input, Select, Textarea base classes
- Mark mandatory fields with required labels across all forms
- Replace fixed textarea rows with min-h + flex-1 for auto-expand
- Remove color picker from ProjectForm
- Align TaskRow metadata into fixed-width columns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 11:58:19 +08:00

366 lines
12 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,
} from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
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';
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',
};
const taskStatusLabels: Record<string, string> = {
pending: 'Pending',
in_progress: 'In Progress',
completed: 'Completed',
};
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;
onEdit: (task: ProjectTask) => void;
onDelete: (taskId: number) => void;
onAddSubtask: (parentId: number) => void;
onClose?: () => void;
}
export default function TaskDetailPanel({
task,
projectId,
onEdit,
onDelete,
onAddSubtask,
onClose,
}: TaskDetailPanelProps) {
const queryClient = useQueryClient();
const [commentText, setCommentText] = useState('');
const { data: people = [] } = useQuery({
queryKey: ['people'],
queryFn: async () => {
const { data } = await api.get<Person[]>('/people');
return data;
},
});
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 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');
},
});
const handleAddComment = () => {
const trimmed = commentText.trim();
if (!trimmed) return;
addCommentMutation.mutate(trimmed);
};
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">
<h3 className="font-heading text-lg font-semibold leading-tight">
{task.title}
</h3>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onEdit(task)}
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">
<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>
<Badge className={`text-[9px] px-1.5 py-0.5 ${taskStatusColors[task.status]}`}>
{taskStatusLabels[task.status]}
</Badge>
</div>
<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>
<Badge
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority]}`}
>
{task.priority}
</Badge>
</div>
<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>
<p className="text-sm">
{task.due_date
? format(parseISO(task.due_date), 'MMM d, yyyy')
: '—'}
</p>
</div>
<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>
<p className="text-sm">
{assignedPerson ? assignedPerson.name : '—'}
</p>
</div>
</div>
{/* Description */}
{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>
)}
{/* 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>
{!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"
>
<Checkbox
checked={subtask.status === 'completed'}
onChange={() =>
toggleSubtaskMutation.mutate({
taskId: subtask.id,
status: subtask.status,
})
}
disabled={toggleSubtaskMutation.isPending}
/>
<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 rounded-full ${
priorityColors[subtask.priority]
}`}
>
{subtask.priority}
</Badge>
</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-0 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>
</div>
</div>
</div>
);
}