Kyle Pope bef856fd15 Add collaborative project sharing, task assignments, and delta polling
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>
2026-03-17 03:18:35 +08:00

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>
);
}