349 lines
14 KiB
JavaScript
349 lines
14 KiB
JavaScript
import { useState } from "react"
|
||
import { motion, AnimatePresence } from "framer-motion"
|
||
import { X, ChevronDown, ChevronUp, Calendar, Clock } from "lucide-react"
|
||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||
import { tasksApi } from "../api/tasks"
|
||
import clsx from "clsx"
|
||
import { format, addDays } from "date-fns"
|
||
|
||
const COLORS = [
|
||
"#6B7280", "#6366f1", "#8b5cf6", "#d946ef", "#ec4899",
|
||
"#f43f5e", "#f97316", "#eab308", "#22c55e", "#0ea5e9",
|
||
]
|
||
|
||
const ICON_CATEGORIES = [
|
||
{ name: "Продуктивность", icons: ["📋", "📝", "✅", "📌", "🎯", "💡", "📅", "⏰"] },
|
||
{ name: "Работа", icons: ["💼", "💻", "📧", "📞", "📊", "📈", "🖥️", "⌨️"] },
|
||
{ name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴", "🛋️"] },
|
||
{ name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦", "🧾"] },
|
||
{ name: "Здоровье", icons: ["💊", "🏃", "🧘", "💪", "🩺", "🦷"] },
|
||
{ name: "Разное", icons: ["⭐", "🎁", "📦", "✈️", "🚗", "📷", "🎉"] },
|
||
]
|
||
|
||
const PRIORITIES = [
|
||
{ value: 0, label: "Без приоритета", color: "bg-gray-100 text-gray-600" },
|
||
{ value: 1, label: "Низкий", color: "bg-blue-100 text-blue-700" },
|
||
{ value: 2, label: "Средний", color: "bg-yellow-100 text-yellow-700" },
|
||
{ value: 3, label: "Высокий", color: "bg-red-100 text-red-700" },
|
||
]
|
||
|
||
export default function CreateTaskModal({ open, onClose, defaultDueDate = null }) {
|
||
const today = format(new Date(), "yyyy-MM-dd")
|
||
const tomorrow = format(addDays(new Date(), 1), "yyyy-MM-dd")
|
||
|
||
const [title, setTitle] = useState("")
|
||
const [description, setDescription] = useState("")
|
||
const [color, setColor] = useState(COLORS[0])
|
||
const [icon, setIcon] = useState("📋")
|
||
const [dueDate, setDueDate] = useState(defaultDueDate || today)
|
||
const [priority, setPriority] = useState(0)
|
||
const [reminderTime, setReminderTime] = useState("")
|
||
const [error, setError] = useState("")
|
||
const [showAllIcons, setShowAllIcons] = useState(false)
|
||
|
||
const queryClient = useQueryClient()
|
||
|
||
const mutation = useMutation({
|
||
mutationFn: (data) => tasksApi.create(data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["tasks"] })
|
||
queryClient.invalidateQueries({ queryKey: ["tasks-today"] })
|
||
handleClose()
|
||
},
|
||
onError: (err) => {
|
||
setError(err.response?.data?.error || "Ошибка создания")
|
||
},
|
||
})
|
||
|
||
const handleClose = () => {
|
||
setTitle("")
|
||
setDescription("")
|
||
setColor(COLORS[0])
|
||
setIcon("📋")
|
||
setDueDate(defaultDueDate || today)
|
||
setPriority(0)
|
||
setReminderTime("")
|
||
setError("")
|
||
setShowAllIcons(false)
|
||
onClose()
|
||
}
|
||
|
||
const handleSubmit = (e) => {
|
||
e.preventDefault()
|
||
if (!title.trim()) {
|
||
setError("Введи название задачи")
|
||
return
|
||
}
|
||
|
||
mutation.mutate({
|
||
title,
|
||
description,
|
||
color,
|
||
icon,
|
||
due_date: dueDate || null,
|
||
priority,
|
||
reminder_time: reminderTime || null,
|
||
})
|
||
}
|
||
|
||
const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
|
||
|
||
return (
|
||
<AnimatePresence>
|
||
{open && (
|
||
<>
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
onClick={handleClose}
|
||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
|
||
/>
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 100 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: 100 }}
|
||
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-0 sm:flex sm:items-center sm:justify-center sm:inset-0"
|
||
>
|
||
<div className="bg-white rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
|
||
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold">Новая задача</h2>
|
||
<button
|
||
onClick={handleClose}
|
||
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
|
||
>
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||
{error && (
|
||
<div className="p-3 rounded-xl bg-red-50 text-red-600 text-sm">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||
Название
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
className="input"
|
||
placeholder="Что нужно сделать?"
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||
Описание (опционально)
|
||
</label>
|
||
<textarea
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
className="input min-h-[80px] resize-none"
|
||
placeholder="Подробности задачи..."
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Срок выполнения
|
||
</label>
|
||
<div className="flex gap-2 mb-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setDueDate(today)}
|
||
className={clsx(
|
||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||
dueDate === today
|
||
? "bg-primary-500 text-white"
|
||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||
)}
|
||
>
|
||
Сегодня
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setDueDate(tomorrow)}
|
||
className={clsx(
|
||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||
dueDate === tomorrow
|
||
? "bg-primary-500 text-white"
|
||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||
)}
|
||
>
|
||
Завтра
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setDueDate("")}
|
||
className={clsx(
|
||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||
!dueDate
|
||
? "bg-primary-500 text-white"
|
||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||
)}
|
||
>
|
||
Без срока
|
||
</button>
|
||
</div>
|
||
<div className="relative">
|
||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||
<input
|
||
type="date"
|
||
value={dueDate}
|
||
onChange={(e) => setDueDate(e.target.value)}
|
||
className="input pl-10"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Напоминание (опционально)
|
||
</label>
|
||
<div className="relative">
|
||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||
<input
|
||
type="time"
|
||
value={reminderTime}
|
||
onChange={(e) => setReminderTime(e.target.value)}
|
||
className="input pl-10"
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Получишь напоминание в Telegram в указанное время
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Приоритет
|
||
</label>
|
||
<div className="flex gap-2 flex-wrap">
|
||
{PRIORITIES.map((p) => (
|
||
<button
|
||
key={p.value}
|
||
type="button"
|
||
onClick={() => setPriority(p.value)}
|
||
className={clsx(
|
||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||
priority === p.value
|
||
? p.color + " ring-2 ring-offset-1 ring-gray-400"
|
||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||
)}
|
||
>
|
||
{p.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Иконка
|
||
</label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{popularIcons.map((ic) => (
|
||
<button
|
||
key={ic}
|
||
type="button"
|
||
onClick={() => setIcon(ic)}
|
||
className={clsx(
|
||
"w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
|
||
icon === ic
|
||
? "bg-primary-100 ring-2 ring-primary-500"
|
||
: "bg-gray-100 hover:bg-gray-200"
|
||
)}
|
||
>
|
||
{ic}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowAllIcons(!showAllIcons)}
|
||
className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
|
||
>
|
||
{showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||
{showAllIcons ? "Скрыть" : "Все иконки"}
|
||
</button>
|
||
|
||
<AnimatePresence>
|
||
{showAllIcons && (
|
||
<motion.div
|
||
initial={{ opacity: 0, height: 0 }}
|
||
animate={{ opacity: 1, height: "auto" }}
|
||
exit={{ opacity: 0, height: 0 }}
|
||
className="mt-3 space-y-3"
|
||
>
|
||
{ICON_CATEGORIES.map((category) => (
|
||
<div key={category.name}>
|
||
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{category.icons.map((ic) => (
|
||
<button
|
||
key={ic}
|
||
type="button"
|
||
onClick={() => setIcon(ic)}
|
||
className={clsx(
|
||
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
|
||
icon === ic
|
||
? "bg-primary-100 ring-2 ring-primary-500"
|
||
: "bg-gray-100 hover:bg-gray-200"
|
||
)}
|
||
>
|
||
{ic}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Цвет
|
||
</label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{COLORS.map((c) => (
|
||
<button
|
||
key={c}
|
||
type="button"
|
||
onClick={() => setColor(c)}
|
||
className={clsx(
|
||
"w-8 h-8 rounded-full transition-all",
|
||
color === c ? "ring-2 ring-offset-2 ring-gray-400 scale-110" : ""
|
||
)}
|
||
style={{ backgroundColor: c }}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pt-2">
|
||
<button
|
||
type="submit"
|
||
disabled={mutation.isPending}
|
||
className="btn btn-primary w-full"
|
||
>
|
||
{mutation.isPending ? "Создаём..." : "Создать задачу"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</motion.div>
|
||
</>
|
||
)}
|
||
</AnimatePresence>
|
||
)
|
||
}
|