318 lines
11 KiB
JavaScript
318 lines
11 KiB
JavaScript
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>
|
||
)
|
||
}
|