Enables multi-user project collaboration mirroring the shared calendar pattern. Includes ProjectMember model with permission levels, task assignment with auto-membership, optimistic locking, field allowlist for assignees, disconnect cascade, delta polling for projects and calendars, and full frontend integration with share sheet, assignment picker, permission gating, and notification handling. Migrations: 057 (indexes + version + comment user_id), 058 (project_members), 059 (project_task_assignments) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
167 lines
5.5 KiB
TypeScript
167 lines
5.5 KiB
TypeScript
import { format, isPast, parseISO } from 'date-fns';
|
|
import { ChevronRight, GripVertical } from 'lucide-react';
|
|
import type { ProjectTask } from '@/types';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { AssigneeAvatars } from './AssignmentPicker';
|
|
|
|
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 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 TaskRowProps {
|
|
task: ProjectTask;
|
|
isSelected: boolean;
|
|
isExpanded: boolean;
|
|
showDragHandle: boolean;
|
|
onSelect: () => void;
|
|
onToggleExpand: () => void;
|
|
onToggleStatus: () => void;
|
|
togglePending: boolean;
|
|
dragHandleProps?: Record<string, unknown>;
|
|
}
|
|
|
|
export default function TaskRow({
|
|
task,
|
|
isSelected,
|
|
isExpanded,
|
|
showDragHandle,
|
|
onSelect,
|
|
onToggleExpand,
|
|
onToggleStatus,
|
|
togglePending,
|
|
dragHandleProps,
|
|
}: TaskRowProps) {
|
|
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
|
|
const completedSubtasks = hasSubtasks
|
|
? task.subtasks.filter((s) => s.status === 'completed').length
|
|
: 0;
|
|
const isOverdue =
|
|
task.due_date && task.status !== 'completed' && isPast(parseISO(task.due_date));
|
|
|
|
return (
|
|
<div
|
|
className={`relative flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-2 rounded-lg cursor-pointer transition-colors duration-150 ${
|
|
isSelected
|
|
? 'bg-accent/5 border-l-2 border-accent'
|
|
: 'border-l-2 border-transparent hover:bg-card-elevated'
|
|
}`}
|
|
onClick={onSelect}
|
|
>
|
|
{/* Drag handle */}
|
|
{showDragHandle && (
|
|
<div
|
|
{...dragHandleProps}
|
|
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<GripVertical className="h-4 w-4" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Expand chevron */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (hasSubtasks) onToggleExpand();
|
|
}}
|
|
className={`shrink-0 transition-colors ${
|
|
hasSubtasks
|
|
? 'text-muted-foreground hover:text-foreground cursor-pointer'
|
|
: 'text-transparent cursor-default'
|
|
}`}
|
|
>
|
|
<ChevronRight
|
|
className={`h-3.5 w-3.5 transition-transform duration-200 ${
|
|
isExpanded ? 'rotate-90' : ''
|
|
}`}
|
|
/>
|
|
</button>
|
|
|
|
{/* Checkbox */}
|
|
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
|
|
<Checkbox
|
|
checked={task.status === 'completed'}
|
|
onChange={onToggleStatus}
|
|
disabled={togglePending}
|
|
/>
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<span
|
|
className={`flex-1 text-sm font-medium truncate ${
|
|
task.status === 'completed' ? 'line-through text-muted-foreground' : ''
|
|
}`}
|
|
>
|
|
{task.title}
|
|
</span>
|
|
|
|
{/* Metadata columns */}
|
|
<Badge className={`text-[9px] px-1.5 py-0.5 shrink-0 w-16 text-center hidden sm:inline-flex ${taskStatusColors[task.status]}`}>
|
|
{task.status.replace('_', ' ')}
|
|
</Badge>
|
|
|
|
<Badge
|
|
className={`text-[9px] px-1.5 py-0.5 rounded-full shrink-0 w-14 text-center hidden sm:inline-flex ${priorityColors[task.priority]}`}
|
|
>
|
|
{task.priority}
|
|
</Badge>
|
|
|
|
<span
|
|
className={`text-[11px] shrink-0 tabular-nums w-12 text-right hidden sm:block ${
|
|
task.due_date
|
|
? isOverdue ? 'text-red-400' : 'text-muted-foreground'
|
|
: 'text-transparent'
|
|
}`}
|
|
>
|
|
{task.due_date ? format(parseISO(task.due_date), 'MMM d') : '—'}
|
|
</span>
|
|
|
|
<span className={`text-[11px] shrink-0 tabular-nums w-8 text-right hidden sm:block ${
|
|
hasSubtasks ? 'text-muted-foreground' : 'text-transparent'
|
|
}`}>
|
|
{hasSubtasks ? `${completedSubtasks}/${task.subtasks.length}` : '—'}
|
|
</span>
|
|
|
|
{/* Assignee avatars */}
|
|
{task.assignments && task.assignments.length > 0 && (
|
|
<span className="hidden sm:flex shrink-0">
|
|
<AssigneeAvatars assignments={task.assignments} max={3} />
|
|
</span>
|
|
)}
|
|
|
|
{/* Mobile-only: compact priority dot + overdue indicator */}
|
|
<div className="flex items-center gap-1.5 sm:hidden shrink-0">
|
|
<div className={`h-2 w-2 rounded-full ${
|
|
task.priority === 'high' ? 'bg-red-400' :
|
|
task.priority === 'medium' ? 'bg-yellow-400' :
|
|
task.priority === 'low' ? 'bg-green-400' : 'bg-gray-500'
|
|
}`} />
|
|
{isOverdue && <span className="text-[10px] text-red-400 tabular-nums">{task.due_date ? format(parseISO(task.due_date), 'M/d') : ''}</span>}
|
|
</div>
|
|
|
|
{/* Subtask progress bar */}
|
|
{hasSubtasks && (
|
|
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-secondary/50 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-accent rounded-full transition-all duration-300"
|
|
style={{ width: `${(completedSubtasks / task.subtasks.length) * 100}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|