feat: add PriorityBadge component and integrate into TaskRow

- Implemented PriorityBadge component to display task priority with icons.
- Created TaskRow component to represent individual tasks with metadata.
- Added breadcrumb navigation for task hierarchy.
- Enhanced checkbox component to support indeterminate state.
- Introduced InputGroup for better input handling in task search.
- Updated tasks route to utilize new components and improve UI.
- Added empty state representation for task list.
This commit is contained in:
Roberto Musso
2026-02-20 22:23:46 +01:00
parent ab517549a9
commit 5bd9d72cc6
12 changed files with 1714 additions and 287 deletions

View File

@@ -5,29 +5,25 @@ import {
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 { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';
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';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
export const Route = createFileRoute('/tasks')({
component: TasksPage,
@@ -42,18 +38,13 @@ const ORDER_LABELS: Record<OrderBy, string> = {
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 [editTask, setEditTask] = useState<TaskItem | null>(null);
const debounceTimer = useMemo(() => ({ id: null as ReturnType<typeof setTimeout> | null }), []);
@@ -85,6 +76,12 @@ function TasksPage() {
},
});
const deleteTask = trpc.tasks.delete.useMutation({
onSuccess: () => {
void utils.tasks.list.invalidate();
},
});
const tasksList = filteredTasks ?? [];
// Compute stats from all tasks (unfiltered)
@@ -100,10 +97,10 @@ function TasksPage() {
const handleCheckboxToggle = useCallback(
(taskId: string, currentStatus: string | null) => {
updateTask.mutate({
id: taskId,
status: currentStatus === 'done' ? 'todo' : 'done',
});
const nextStatus =
currentStatus === 'todo' ? 'in_progress' :
currentStatus === 'in_progress' ? 'done' : 'todo';
updateTask.mutate({ id: taskId, status: nextStatus });
},
[updateTask],
);
@@ -112,51 +109,80 @@ function TasksPage() {
<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} />
<Item variant="muted">
<ItemMedia variant="icon">
<ClipboardCheck />
</ItemMedia>
<ItemContent>
<ItemTitle>{stats.total}</ItemTitle>
<ItemDescription>Total Tasks</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemMedia variant="icon">
<ListTodo />
</ItemMedia>
<ItemContent>
<ItemTitle>{stats.todo}</ItemTitle>
<ItemDescription>To Do</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" className="bg-sky-50">
<ItemMedia variant="icon">
<Loader2 />
</ItemMedia>
<ItemContent>
<ItemTitle>{stats.inProgress}</ItemTitle>
<ItemDescription>In Progress</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" className="bg-green-50">
<ItemMedia variant="icon">
<CheckCircle2 />
</ItemMedia>
<ItemContent>
<ItemTitle>{stats.completed}</ItemTitle>
<ItemDescription>Completed</ItemDescription>
</ItemContent>
</Item>
</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
<InputGroup className="flex-1">
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
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">
</InputGroup>
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Order by" />
</SelectTrigger>
<SelectContent>
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
<DropdownMenuItem key={key} onClick={() => setOrderBy(key)}>
<SelectItem key={key} value={key}>
{label}
</DropdownMenuItem>
</SelectItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SelectContent>
</Select>
</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">
{/* Status Filter Tabs + New Task Button */}
<div className="flex items-center justify-between">
<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>
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
New Task
@@ -166,160 +192,37 @@ function TasksPage() {
{/* 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>
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ClipboardCheck />
</EmptyMedia>
<EmptyTitle>No tasks found</EmptyTitle>
<EmptyDescription>
Create a new task to get started or adjust your filters.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
tasksList.map((task) => (
<TaskRow
key={task.id}
task={task}
onToggle={handleCheckboxToggle}
onEdit={setEditTask}
onDelete={(id) => deleteTask.mutate({ id })}
/>
))
)}
</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)}
<EditTaskDialog
task={editTask}
open={!!editTask}
onOpenChange={(open: boolean) => { if (!open) setEditTask(null); }}
/>
{/* 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>
);
}