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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user