feat: US-011 — Global Tasks view UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Roberto Musso
2026-02-20 12:43:42 +01:00
parent 3f1208f5ad
commit e92d58a46e
13 changed files with 1156 additions and 4 deletions

View File

@@ -1,13 +1,325 @@
import { createFileRoute } from '@tanstack/react-router';
import { useState, useCallback, useMemo } from 'react';
import {
ClipboardCheck,
ListTodo,
Loader2,
CheckCircle2,
ArrowUp,
ArrowRight,
ArrowDown,
Calendar,
ChevronRight,
User,
Plus,
Search,
} from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
export const Route = createFileRoute('/tasks')({
component: TasksPage,
});
type StatusFilter = 'all' | 'todo' | 'in_progress' | 'done';
type OrderBy = 'dueDate' | 'priority' | 'createdAt';
const ORDER_LABELS: Record<OrderBy, string> = {
dueDate: 'Due Date',
priority: 'Priority',
createdAt: 'Created Date',
};
function formatDueDate(timestamp: number): string {
const d = new Date(timestamp);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `Due ${months[d.getMonth()]} ${d.getDate()}`;
}
function TasksPage() {
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [orderBy, setOrderBy] = useState<OrderBy>('createdAt');
const [dialogOpen, setDialogOpen] = useState(false);
const debounceTimer = useMemo(() => ({ id: null as ReturnType<typeof setTimeout> | null }), []);
const handleSearchChange = useCallback(
(value: string) => {
setSearch(value);
if (debounceTimer.id) clearTimeout(debounceTimer.id);
debounceTimer.id = setTimeout(() => setDebouncedSearch(value), 300);
},
[debounceTimer],
);
const queryInput = useMemo(
() => ({
...(statusFilter !== 'all' ? { status: statusFilter as 'todo' | 'in_progress' | 'done' } : {}),
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
orderBy,
}),
[statusFilter, debouncedSearch, orderBy],
);
const { data: allTasks } = trpc.tasks.list.useQuery({});
const { data: filteredTasks } = trpc.tasks.list.useQuery(queryInput);
const utils = trpc.useUtils();
const updateTask = trpc.tasks.update.useMutation({
onSuccess: () => {
void utils.tasks.list.invalidate();
},
});
const tasksList = filteredTasks ?? [];
// Compute stats from all tasks (unfiltered)
const stats = useMemo(() => {
const all = allTasks ?? [];
return {
total: all.length,
todo: all.filter((t) => t.status === 'todo').length,
inProgress: all.filter((t) => t.status === 'in_progress').length,
completed: all.filter((t) => t.status === 'done').length,
};
}, [allTasks]);
const handleCheckboxToggle = useCallback(
(taskId: string, currentStatus: string | null) => {
updateTask.mutate({
id: taskId,
status: currentStatus === 'done' ? 'todo' : 'done',
});
},
[updateTask],
);
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Tasks coming in US-007
<div className="flex flex-col gap-6 p-6 max-w-[1200px] mx-auto w-full">
{/* Stat Cards */}
<div className="grid grid-cols-4 gap-4">
<StatCard icon={<ClipboardCheck className="h-5 w-5 text-muted-foreground" />} label="Total Tasks" value={stats.total} />
<StatCard icon={<ListTodo className="h-5 w-5 text-blue-500" />} label="To Do" value={stats.todo} />
<StatCard icon={<Loader2 className="h-5 w-5 text-yellow-500" />} label="In Progress" value={stats.inProgress} />
<StatCard icon={<CheckCircle2 className="h-5 w-5 text-green-500" />} label="Completed" value={stats.completed} />
</div>
{/* Search + Order By */}
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tasks or projects..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-9"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Order by: {ORDER_LABELS[orderBy]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
<DropdownMenuItem key={key} onClick={() => setOrderBy(key)}>
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Status Filter Tabs */}
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="todo">To Do</TabsTrigger>
<TabsTrigger value="in_progress">In Progress</TabsTrigger>
<TabsTrigger value="done">Completed</TabsTrigger>
</TabsList>
</Tabs>
{/* New Task Button */}
<div className="flex justify-end">
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
New Task
</Button>
</div>
{/* Task List */}
<div className="flex flex-col gap-1">
{tasksList.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-12">
No tasks found.
</div>
) : (
tasksList.map((task) => (
<TaskRow
key={task.id}
task={task}
onToggle={handleCheckboxToggle}
/>
))
)}
</div>
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</div>
);
}
function StatCard({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: number;
}) {
return (
<Card className="py-4">
<CardHeader className="pb-0 pt-0">
<div className="flex items-center gap-2">
{icon}
<CardTitle className="text-sm font-medium text-muted-foreground">
{label}
</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="text-2xl font-semibold">{value}</div>
</CardContent>
</Card>
);
}
type TaskItem = {
id: string;
title: string;
description: string | null;
status: string | null;
priority: string | null;
assignee: string | null;
dueDate: number | null;
projectName: string | null;
clientName: string | null;
subClientName: string | null;
};
function PriorityBadge({ priority }: { priority: string | null }) {
switch (priority) {
case 'high':
return (
<Badge variant="destructive" className="text-xs gap-1">
<ArrowUp className="h-3 w-3" />
HIGH
</Badge>
);
case 'medium':
return (
<Badge variant="secondary" className="text-xs gap-1">
<ArrowRight className="h-3 w-3" />
MEDIUM
</Badge>
);
case 'low':
return (
<Badge variant="outline" className="text-xs gap-1 text-green-600 border-green-300">
<ArrowDown className="h-3 w-3" />
LOW
</Badge>
);
default:
return null;
}
}
function TaskRow({
task,
onToggle,
}: {
task: TaskItem;
onToggle: (id: string, status: string | null) => void;
}) {
const isDone = task.status === 'done';
const breadcrumb: string[] = [];
if (task.clientName) breadcrumb.push(task.clientName);
if (task.subClientName) breadcrumb.push(task.subClientName);
if (task.projectName) breadcrumb.push(task.projectName);
return (
<div
className={`flex items-center gap-4 px-4 py-3 rounded-md border ${
isDone ? 'bg-green-50 border-green-200' : 'bg-white border-border'
}`}
>
{/* Checkbox */}
<Checkbox
checked={isDone}
onCheckedChange={() => onToggle(task.id, task.status)}
/>
{/* Title + Description */}
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold ${isDone ? 'line-through text-muted-foreground' : ''}`}>
{task.title}
</div>
{task.description && (
<div className="text-sm text-muted-foreground truncate">
{task.description}
</div>
)}
</div>
{/* Priority Chip */}
<PriorityBadge priority={task.priority} />
{/* Due Date Chip */}
{task.dueDate && (
<Badge variant="outline" className="text-xs gap-1 shrink-0">
<Calendar className="h-3 w-3" />
{formatDueDate(task.dueDate)}
</Badge>
)}
{/* Breadcrumb */}
{breadcrumb.length > 0 && (
<div className="hidden lg:flex items-center gap-1 text-xs text-muted-foreground shrink-0">
{breadcrumb.map((part, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="h-3 w-3" />}
{part}
</span>
))}
</div>
)}
{/* Assignee */}
{task.assignee && (
<div className="hidden md:flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<User className="h-3 w-3" />
{task.assignee}
</div>
)}
</div>
);
}