Files
leocrm/frontend/src/pages/Tasks.tsx
T
Agent Zero 2c9e74776e feat(5.21): Tasks Plugin — activities with status, priority, due dates, ARQ reminders
- New builtin plugin app/plugins/builtins/tasks/ with full CRUD
- Task model with TenantMixin (title, description, status, priority, due_date, assigned_to, contact_id)
- Routes: GET/POST /tasks, GET/PATCH/DELETE /tasks/{id}, POST /tasks/{id}/assign, POST /tasks/{id}/status
- All routes RBAC-protected (tasks:read, tasks:write, tasks:delete)
- ARQ cron job tasks_due_reminder (daily 8:00) sends notifications for due tasks
- Migration 0001_initial.sql creates tasks table with indexes
- Frontend: Tasks.tsx page with list, filter, create/edit modal, detail modal
- Frontend: api/tasks.ts with React Query hooks
- Route /tasks in routes/index.tsx, sidebar entry via plugin manifest
- i18n keys for nav.tasks and tasks.* in de.json and en.json
- Tests: test_tasks.py (11 tests) + Tasks.test.tsx (3 tests)
- Registered TasksPlugin in conftest.py and worker.py
2026-07-23 23:41:34 +02:00

418 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tasks page — list with filter, create modal, detail.
*/
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Modal } from '@/components/ui/Modal';
import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState';
import { useToast } from '@/components/ui/Toast';
import { Loader2, Plus, CheckSquare, Clock, AlertCircle } from 'lucide-react';
import {
useTasks,
useCreateTask,
useUpdateTask,
useDeleteTask,
useUpdateTaskStatus,
type Task,
type TaskFilter,
} from '@/api/tasks';
const STATUS_COLORS: Record<string, 'secondary' | 'info' | 'success'> = {
open: 'secondary',
in_progress: 'info',
done: 'success',
};
const PRIORITY_COLORS: Record<string, 'secondary' | 'info' | 'warning' | 'danger'> = {
low: 'secondary',
medium: 'info',
high: 'warning',
urgent: 'danger',
};
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
try {
return new Date(dateStr).toLocaleDateString();
} catch {
return dateStr;
}
}
function isOverdue(dateStr: string | null, status: string): boolean {
if (!dateStr || status === 'done') return false;
try {
return new Date(dateStr) < new Date();
} catch {
return false;
}
}
export function TasksPage() {
const { t } = useTranslation();
const toast = useToast();
const [page, setPage] = useState(1);
const [filter, setFilter] = useState<TaskFilter>({});
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
// Debounce search
React.useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);
const effectiveFilter = useMemo(
() => ({ ...filter, search: debouncedSearch || undefined }),
[filter, debouncedSearch],
);
const { data, isLoading } = useTasks(page, 25, effectiveFilter);
const createMutation = useCreateTask();
const updateMutation = useUpdateTask();
const deleteMutation = useDeleteTask();
const statusMutation = useUpdateTaskStatus();
const tasks = data?.items || [];
const handleCreate = async (formData: Partial<Task>) => {
try {
await createMutation.mutateAsync({
title: formData.title || '',
description: formData.description || null,
priority: formData.priority || 'medium',
status: formData.status || 'open',
due_date: formData.due_date || null,
contact_id: formData.contact_id || null,
});
toast.success(t('tasks.created'));
setCreateModalOpen(false);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleUpdate = async (formData: Partial<Task>) => {
if (!editingTask) return;
try {
await updateMutation.mutateAsync({ id: editingTask.id, data: formData });
toast.success(t('tasks.updated'));
setEditingTask(null);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleDelete = async (task: Task) => {
if (!window.confirm(t('tasks.deleteConfirm'))) return;
try {
await deleteMutation.mutateAsync(task.id);
toast.success(t('tasks.deleted'));
setSelectedTask(null);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleStatusChange = async (task: Task, newStatus: string) => {
try {
await statusMutation.mutateAsync({ id: task.id, status: newStatus });
toast.success(t('tasks.statusUpdated'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
return (
<div className="flex flex-col h-full" data-testid="tasks-page">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-200 bg-white">
<h1 className="text-lg font-semibold text-secondary-900 flex items-center gap-2">
<CheckSquare className="w-5 h-5" />
{t('tasks.title')}
</h1>
<Button size="sm" onClick={() => { setEditingTask(null); setCreateModalOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />
{t('tasks.create')}
</Button>
</div>
{/* Filters */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-200 bg-white flex-wrap">
<Input
type="text"
placeholder={t('common.search')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-48"
/>
<Select
options={[
{ value: '', label: t('common.all') },
{ value: 'open', label: t('tasks.statusOpen') },
{ value: 'in_progress', label: t('tasks.statusInProgress') },
{ value: 'done', label: t('tasks.statusDone') },
]}
value={filter.status || ''}
onChange={(e) => { setFilter(f => ({ ...f, status: e.target.value || undefined })); setPage(1); }}
className="w-40"
/>
<Select
options={[
{ value: '', label: t('common.all') },
{ value: 'low', label: t('tasks.priorityLow') },
{ value: 'medium', label: t('tasks.priorityMedium') },
{ value: 'high', label: t('tasks.priorityHigh') },
{ value: 'urgent', label: t('tasks.priorityUrgent') },
]}
value={filter.priority || ''}
onChange={(e) => { setFilter(f => ({ ...f, priority: e.target.value || undefined })); setPage(1); }}
className="w-40"
/>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" />
</div>
) : tasks.length === 0 ? (
<EmptyState title={t('tasks.noTasks')} description={t('tasks.noTasksDesc')} />
) : (
<ul className="divide-y divide-secondary-100" role="list">
{tasks.map((task) => {
const overdue = isOverdue(task.due_date, task.status);
return (
<li
key={task.id}
className="px-4 py-3 hover:bg-secondary-50 cursor-pointer"
onClick={() => setSelectedTask(task)}
data-testid={`task-item-${task.id}`}
>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-secondary-900">{task.title}</span>
{overdue && (
<AlertCircle className="w-4 h-4 text-danger-500" />
)}
</div>
{task.description && (
<p className="text-xs text-secondary-500 truncate mt-0.5">{task.description}</p>
)}
<div className="flex items-center gap-2 mt-1">
<Badge variant={STATUS_COLORS[task.status] || 'secondary'}>
{t(`tasks.status${task.status.charAt(0).toUpperCase() + task.status.slice(1).replace('_', '')}`)}
</Badge>
<Badge variant={PRIORITY_COLORS[task.priority] || 'secondary'}>
{t(`tasks.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`)}
</Badge>
{task.due_date && (
<span className={`text-xs flex items-center gap-1 ${overdue ? 'text-danger-600' : 'text-secondary-500'}`}>
<Clock className="w-3 h-3" />
{formatDate(task.due_date)}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{task.status !== 'done' && (
<button
onClick={(e) => { e.stopPropagation(); handleStatusChange(task, 'done'); }}
className="text-xs text-success-600 hover:text-success-700 px-2 py-1 min-h-touch"
>
{t('tasks.markDone')}
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); setEditingTask(task); }}
className="text-xs text-primary-600 hover:text-primary-700 px-2 py-1 min-h-touch"
>
{t('common.edit')}
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(task); }}
className="text-xs text-danger-600 hover:text-danger-700 px-2 py-1 min-h-touch"
>
{t('common.delete')}
</button>
</div>
</div>
</li>
);
})}
</ul>
)}
{/* Pagination */}
{data && data.total > 25 && (
<div className="flex items-center justify-between px-4 py-2 border-t border-secondary-200">
<span className="text-xs text-secondary-500">
{((page - 1) * 25) + 1}{Math.min(page * 25, data.total)} / {data.total}
</span>
<div className="flex gap-2">
<Button size="sm" variant="secondary" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
{t('common.back')}
</Button>
<Button size="sm" variant="secondary" disabled={page * 25 >= data.total} onClick={() => setPage(p => p + 1)}>
{t('common.next')}
</Button>
</div>
</div>
)}
</div>
{/* Create/Edit Modal */}
<TaskModal
open={createModalOpen || !!editingTask}
onClose={() => { setCreateModalOpen(false); setEditingTask(null); }}
task={editingTask}
onSubmit={editingTask ? handleUpdate : handleCreate}
/>
{/* Detail Modal */}
{selectedTask && (
<Modal open={!!selectedTask} onClose={() => setSelectedTask(null)} title={selectedTask.title} size="lg">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Badge variant={STATUS_COLORS[selectedTask.status] || 'secondary'}>
{t(`tasks.status${selectedTask.status.charAt(0).toUpperCase() + selectedTask.status.slice(1).replace('_', '')}`)}
</Badge>
<Badge variant={PRIORITY_COLORS[selectedTask.priority] || 'secondary'}>
{t(`tasks.priority${selectedTask.priority.charAt(0).toUpperCase() + selectedTask.priority.slice(1)}`)}
</Badge>
</div>
{selectedTask.description && (
<p className="text-sm text-secondary-700">{selectedTask.description}</p>
)}
<div className="text-xs text-secondary-500">
<span className="font-medium">{t('tasks.dueDate')}:</span> {formatDate(selectedTask.due_date)}
</div>
<div className="flex gap-2 pt-2">
{selectedTask.status !== 'done' && (
<Button size="sm" onClick={() => { handleStatusChange(selectedTask, 'done'); setSelectedTask(null); }}>
{t('tasks.markDone')}
</Button>
)}
<Button size="sm" variant="secondary" onClick={() => { setEditingTask(selectedTask); setSelectedTask(null); }}>
{t('common.edit')}
</Button>
<Button size="sm" variant="danger" onClick={() => handleDelete(selectedTask)}>
{t('common.delete')}
</Button>
</div>
</div>
</Modal>
)}
</div>
);
}
// ── Task Create/Edit Modal ──
function TaskModal({
open,
onClose,
task,
onSubmit,
}: {
open: boolean;
onClose: () => void;
task?: Task | null;
onSubmit: (data: Partial<Task>) => void;
}) {
const { t } = useTranslation();
const [title, setTitle] = useState(task?.title || '');
const [description, setDescription] = useState(task?.description || '');
const [priority, setPriority] = useState<string>(task?.priority || 'medium');
const [status, setStatus] = useState<string>(task?.status || 'open');
const [dueDate, setDueDate] = useState(task?.due_date ? task.due_date.slice(0, 10) : '');
React.useEffect(() => {
if (open) {
setTitle(task?.title || '');
setDescription(task?.description || '');
setPriority(task?.priority || 'medium');
setStatus(task?.status || 'open');
setDueDate(task?.due_date ? task.due_date.slice(0, 10) : '');
}
}, [open, task]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
onSubmit({
title: title.trim(),
description: description || null,
priority: priority as any,
status: status as any,
due_date: dueDate ? new Date(dueDate).toISOString() : null,
});
};
return (
<Modal open={open} onClose={onClose} title={task ? t('tasks.edit') : t('tasks.create')} size="md">
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label={t('tasks.title_field')}
value={title}
onChange={(e) => setTitle(e.target.value)}
required
data-testid="task-title-input"
/>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('tasks.description')}</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-20"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Select
label={t('tasks.status_field')}
options={[
{ value: 'open', label: t('tasks.statusOpen') },
{ value: 'in_progress', label: t('tasks.statusInProgress') },
{ value: 'done', label: t('tasks.statusDone') },
]}
value={status}
onChange={(e) => setStatus(e.target.value)}
/>
<Select
label={t('tasks.priority_field')}
options={[
{ value: 'low', label: t('tasks.priorityLow') },
{ value: 'medium', label: t('tasks.priorityMedium') },
{ value: 'high', label: t('tasks.priorityHigh') },
{ value: 'urgent', label: t('tasks.priorityUrgent') },
]}
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<Input
label={t('tasks.dueDate')}
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button type="submit" data-testid="task-submit-btn">{t('common.save')}</Button>
</div>
</form>
</Modal>
);
}