Files
pulse-web/src/pages/Tasks.jsx
2026-02-06 11:19:55 +00:00

318 lines
11 KiB
JavaScript
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.
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Plus, Check, Circle, Calendar, AlertTriangle, Undo2, Edit2 } from 'lucide-react'
import { format, parseISO, isToday, isTomorrow, isPast } from 'date-fns'
import { ru } from 'date-fns/locale'
import { tasksApi } from '../api/tasks'
import Navigation from '../components/Navigation'
import CreateTaskModal from '../components/CreateTaskModal'
import EditTaskModal from '../components/EditTaskModal'
import clsx from 'clsx'
const PRIORITY_LABELS = {
0: null,
1: { label: 'Низкий', class: 'bg-blue-100 text-blue-700' },
2: { label: 'Средний', class: 'bg-yellow-100 text-yellow-700' },
3: { label: 'Высокий', class: 'bg-red-100 text-red-700' },
}
function formatDueDate(dateStr) {
if (!dateStr) return null
const date = parseISO(dateStr)
if (isToday(date)) return 'Сегодня'
if (isTomorrow(date)) return 'Завтра'
return format(date, 'd MMM', { locale: ru })
}
export default function Tasks() {
const [showCreate, setShowCreate] = useState(false)
const [editingTask, setEditingTask] = useState(null)
const [filter, setFilter] = useState('active') // all, active, completed
const queryClient = useQueryClient()
const { data: tasks = [], isLoading } = useQuery({
queryKey: ['tasks', filter],
queryFn: () => {
if (filter === 'all') return tasksApi.list()
return tasksApi.list(filter === 'completed')
},
})
const completeMutation = useMutation({
mutationFn: (id) => tasksApi.complete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
},
})
const uncompleteMutation = useMutation({
mutationFn: (id) => tasksApi.uncomplete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
},
})
const handleToggle = (task) => {
if (task.completed) {
uncompleteMutation.mutate(task.id)
} else {
completeMutation.mutate(task.id)
}
}
const activeTasks = tasks.filter(t => !t.completed)
const completedTasks = tasks.filter(t => t.completed)
return (
<div className="min-h-screen bg-surface-50 gradient-mesh pb-24">
<header className="bg-white/70 backdrop-blur-xl border-b border-gray-100/50 sticky top-0 z-10">
<div className="max-w-lg mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-display font-bold text-gray-900">Задачи</h1>
<button
onClick={() => setShowCreate(true)}
className="p-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors shadow-lg shadow-primary-500/30"
>
<Plus size={22} />
</button>
</div>
{/* Фильтры */}
<div className="flex gap-2 mt-4">
{[
{ key: 'active', label: 'Активные' },
{ key: 'completed', label: 'Выполненные' },
{ key: 'all', label: 'Все' },
].map(({ key, label }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={clsx(
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
filter === key
? 'bg-primary-500 text-white shadow-lg shadow-primary-500/30'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
)}
>
{label}
</button>
))}
</div>
</div>
</header>
<main className="max-w-lg mx-auto px-4 py-6">
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="card p-5 animate-pulse">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-gray-200" />
<div className="flex-1">
<div className="h-5 bg-gray-200 rounded-lg w-3/4 mb-2" />
<div className="h-4 bg-gray-200 rounded-lg w-1/4" />
</div>
</div>
</div>
))}
</div>
) : tasks.length === 0 ? (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="card p-10 text-center"
>
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center mx-auto mb-5">
<Check className="w-10 h-10 text-primary-600" />
</div>
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">
{filter === 'active' ? 'Нет активных задач' : filter === 'completed' ? 'Нет выполненных задач' : 'Нет задач'}
</h3>
<p className="text-gray-500 mb-6">
{filter === 'active' ? 'Добавь новую задачу или выбери другой фильтр' : 'Выполняй задачи и они появятся здесь'}
</p>
{filter === 'active' && (
<button
onClick={() => setShowCreate(true)}
className="btn btn-primary"
>
<Plus size={18} />
Добавить задачу
</button>
)}
</motion.div>
) : (
<div className="space-y-4">
<AnimatePresence>
{tasks.map((task, index) => (
<TaskCard
key={task.id}
task={task}
index={index}
onToggle={() => handleToggle(task)}
onEdit={() => setEditingTask(task)}
isLoading={completeMutation.isPending || uncompleteMutation.isPending}
/>
))}
</AnimatePresence>
</div>
)}
</main>
<Navigation />
<CreateTaskModal
open={showCreate}
onClose={() => setShowCreate(false)}
/>
<EditTaskModal
open={!!editingTask}
onClose={() => setEditingTask(null)}
task={editingTask}
/>
</div>
)
}
function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
const [showConfetti, setShowConfetti] = useState(false)
const priorityInfo = PRIORITY_LABELS[task.priority]
const dueDateLabel = formatDueDate(task.due_date)
const isOverdue = task.due_date && isPast(parseISO(task.due_date)) && !isToday(parseISO(task.due_date)) && !task.completed
const handleCheck = (e) => {
e.stopPropagation()
if (isLoading) return
if (!task.completed) {
setShowConfetti(true)
setTimeout(() => setShowConfetti(false), 1000)
}
onToggle()
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -100 }}
transition={{ delay: index * 0.05 }}
className="card p-4 relative overflow-hidden"
>
{showConfetti && (
<motion.div
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: 1 }}
className="absolute inset-0 pointer-events-none"
>
{[...Array(6)].map((_, i) => (
<motion.div
key={i}
initial={{ x: '50%', y: '50%', scale: 0 }}
animate={{
x: `${Math.random() * 100}%`,
y: `${Math.random() * 100}%`,
scale: [0, 1, 0]
}}
transition={{ duration: 0.6, delay: i * 0.05 }}
className="absolute w-2 h-2 rounded-full"
style={{ backgroundColor: task.color }}
/>
))}
</motion.div>
)}
<div className="flex items-start gap-3">
<motion.button
onClick={handleCheck}
disabled={isLoading}
whileTap={{ scale: 0.9 }}
className={clsx(
'w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 flex-shrink-0 mt-0.5',
task.completed
? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
: 'border-2 hover:shadow-md'
)}
style={{
borderColor: task.completed ? undefined : task.color + '40',
backgroundColor: task.completed ? undefined : task.color + '10'
}}
>
{task.completed ? (
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', stiffness: 500 }}
>
<Check className="w-5 h-5 text-white" strokeWidth={3} />
</motion.div>
) : (
<span className="text-lg">{task.icon || '📋'}</span>
)}
</motion.button>
<div className="flex-1 min-w-0" onClick={onEdit}>
<h3 className={clsx(
"font-semibold truncate cursor-pointer hover:text-primary-600",
task.completed ? "text-gray-400 line-through" : "text-gray-900"
)}>{task.title}</h3>
{task.description && (
<p className="text-sm text-gray-500 truncate mt-0.5">{task.description}</p>
)}
<div className="flex items-center gap-2 mt-2 flex-wrap">
{dueDateLabel && (
<span className={clsx(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium',
isOverdue
? 'bg-red-100 text-red-700'
: 'bg-gray-100 text-gray-600'
)}>
{isOverdue && <AlertTriangle size={12} />}
<Calendar size={12} />
{dueDateLabel}
</span>
)}
{priorityInfo && (
<span className={clsx(
'px-2 py-0.5 rounded-md text-xs font-medium',
priorityInfo.class
)}>
{priorityInfo.label}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={onEdit}
className="p-2 text-gray-400 hover:text-primary-500 hover:bg-primary-50 rounded-xl transition-all"
>
<Edit2 size={16} />
</button>
{task.completed && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
onClick={handleCheck}
disabled={isLoading}
className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 rounded-xl transition-all"
title="Отменить"
>
<Undo2 size={16} />
</motion.button>
)}
</div>
</div>
</motion.div>
)
}