250 lines
8.4 KiB
TypeScript
250 lines
8.4 KiB
TypeScript
import { createFileRoute } from '@tanstack/react-router';
|
|
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
|
import { useFloatingChat } from '@/context/FloatingChatContext';
|
|
import {
|
|
ClipboardCheck,
|
|
ListTodo,
|
|
Loader2,
|
|
CheckCircle2,
|
|
Plus,
|
|
Search,
|
|
} from 'lucide-react';
|
|
import { trpc } from '@/lib/trpc';
|
|
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 { Button } from '@/components/ui/button';
|
|
import {
|
|
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 { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
|
|
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
|
|
|
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 TasksPage() {
|
|
// Temporary test: register section for floating AI chat
|
|
const testRef = useRef<HTMLDivElement>(null);
|
|
const { registerSection, unregisterSection } = useFloatingChat();
|
|
useEffect(() => {
|
|
registerSection({ id: 'test', label: 'Tasks', ref: testRef });
|
|
return () => unregisterSection('test');
|
|
}, [registerSection, unregisterSection]);
|
|
|
|
const [search, setSearch] = useState('');
|
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
|
const [orderBy, setOrderBy] = useState<OrderBy>('dueDate');
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
|
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
|
|
|
|
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 deleteTask = trpc.tasks.delete.useMutation({
|
|
onSuccess: () => {
|
|
void utils.tasks.list.invalidate();
|
|
},
|
|
});
|
|
|
|
const tasksList = (filteredTasks ?? []).filter(
|
|
(t) => !(t.isAiSuggested === 1 && t.isApproved === 0),
|
|
);
|
|
|
|
// 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) => {
|
|
const nextStatus =
|
|
currentStatus === 'todo' ? 'in_progress' :
|
|
currentStatus === 'in_progress' ? 'done' : 'todo';
|
|
updateTask.mutate({ id: taskId, status: nextStatus });
|
|
},
|
|
[updateTask],
|
|
);
|
|
|
|
return (
|
|
<div ref={testRef} data-ai-section="test" className="flex flex-col gap-6 p-6 pe-8 w-full">
|
|
{/* Stat Cards */}
|
|
<div className="grid grid-cols-4 gap-4">
|
|
<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 dark:bg-sky-950/30">
|
|
<ItemMedia variant="icon">
|
|
<Loader2 />
|
|
</ItemMedia>
|
|
<ItemContent>
|
|
<ItemTitle>{stats.inProgress}</ItemTitle>
|
|
<ItemDescription>In Progress</ItemDescription>
|
|
</ItemContent>
|
|
</Item>
|
|
<Item variant="muted" className="bg-green-50 dark:bg-green-950/30">
|
|
<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">
|
|
<InputGroup className="flex-1">
|
|
<InputGroupAddon>
|
|
<Search />
|
|
</InputGroupAddon>
|
|
<InputGroupInput
|
|
placeholder="Search tasks or projects..."
|
|
value={search}
|
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
/>
|
|
</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]) => (
|
|
<SelectItem key={key} value={key}>
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 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
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Task List */}
|
|
<div className="flex flex-col gap-1">
|
|
{tasksList.length === 0 ? (
|
|
<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 })}
|
|
onClick={setViewTask}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
|
<EditTaskDialog
|
|
task={editTask}
|
|
open={!!editTask}
|
|
onOpenChange={(open: boolean) => { if (!open) setEditTask(null); }}
|
|
/>
|
|
<TaskDetailDialog
|
|
task={viewTask}
|
|
open={!!viewTask}
|
|
onOpenChange={(open) => { if (!open) setViewTask(null); }}
|
|
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
|
|
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|