Files
adiuva/src/renderer/routes/tasks.tsx

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