diff --git a/coverage/lcov-report/api/finance.js.html b/coverage/lcov-report/api/finance.js.html new file mode 100644 index 0000000..b1ae44c --- /dev/null +++ b/coverage/lcov-report/api/finance.js.html @@ -0,0 +1,226 @@ + + + + +
++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 | + +1x + + +1x +1x + + +1x +1x + + +1x +1x + + +1x + + + + +2x +2x + + +1x +1x + + +1x +1x + + +1x + + + + +1x +1x + + +1x +1x + + + | import client from './client'
+
+export const financeApi = {
+ // Categories
+ listCategories: async () => {
+ const res = await client.get('finance/categories')
+ return res.data
+ },
+ createCategory: async (data) => {
+ const res = await client.post('finance/categories', data)
+ return res.data
+ },
+ updateCategory: async (id, data) => {
+ const res = await client.put(`finance/categories/${id}`, data)
+ return res.data
+ },
+ deleteCategory: async (id) => {
+ await client.delete(`finance/categories/${id}`)
+ },
+
+ // Transactions
+ listTransactions: async (params = {}) => {
+ const res = await client.get('finance/transactions', { params })
+ return res.data
+ },
+ createTransaction: async (data) => {
+ const res = await client.post('finance/transactions', data)
+ return res.data
+ },
+ updateTransaction: async (id, data) => {
+ const res = await client.put(`finance/transactions/${id}`, data)
+ return res.data
+ },
+ deleteTransaction: async (id) => {
+ await client.delete(`finance/transactions/${id}`)
+ },
+
+ // Summary & Analytics
+ getSummary: async (params = {}) => {
+ const res = await client.get('finance/summary', { params })
+ return res.data
+ },
+ getAnalytics: async (params = {}) => {
+ const res = await client.get('finance/analytics', { params })
+ return res.data
+ },
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 | + +1x +1x + +1x +1x +1x + +2x +1x +1x + +1x +1x + + +1x +1x +1x + + | import api from './client'
+
+export const habitsApi = {
+ list: () => api.get('/habits').then(r => r.data),
+ get: (id) => api.get(`/habits/${id}`).then(r => r.data),
+ create: (data) => api.post('/habits', data).then(r => r.data),
+ update: (id, data) => api.put(`/habits/${id}`, data).then(r => r.data),
+ delete: (id) => api.delete(`/habits/${id}`),
+
+ log: (id, data = {}) => api.post(`/habits/${id}/log`, data).then(r => r.data),
+ getLogs: (id, days = 30) => api.get(`/habits/${id}/logs?days=${days}`).then(r => r.data),
+ deleteLog: (habitId, logId) => api.delete(`/habits/${habitId}/logs/${logId}`),
+
+ getStats: () => api.get('/habits/stats').then(r => r.data),
+ getHabitStats: (id) => api.get(`/habits/${id}/stats`).then(r => r.data),
+
+ // Freezes
+ getFreezes: (habitId) => api.get(`/habits/${habitId}/freezes`).then(r => r.data),
+ addFreeze: (habitId, data) => api.post(`/habits/${habitId}/freezes`, data).then(r => r.data),
+ deleteFreeze: (habitId, freezeId) => api.delete(`/habits/${habitId}/freezes/${freezeId}`),
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| finance.js | +
+
+ |
+ 100% | +19/19 | +100% | +3/3 | +100% | +10/10 | +100% | +19/19 | +
| habits.js | +
+
+ |
+ 91.66% | +22/24 | +100% | +2/2 | +91.3% | +21/23 | +92.85% | +13/14 | +
| profile.js | +
+
+ |
+ 100% | +5/5 | +100% | +0/0 | +100% | +2/2 | +100% | +5/5 | +
| savings.js | +
+
+ |
+ 73.52% | +25/34 | +100% | +4/4 | +70% | +21/30 | +69.56% | +16/23 | +
| tasks.js | +
+
+ |
+ 100% | +19/19 | +100% | +3/3 | +100% | +8/8 | +100% | +19/19 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 | + +1x + +1x +1x + + +1x +1x + + + + + | import api from "./client"
+
+export const profileApi = {
+ get: async () => {
+ const { data } = await api.get("/profile")
+ return data
+ },
+ update: async (profileData) => {
+ const { data } = await api.put("/profile", profileData)
+ return data
+ },
+}
+
+export default profileApi
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 | + +1x + +1x +1x +1x + +1x +1x + + + +2x +2x +2x + + +1x + + + + + +1x + + + +1x + +1x + +1x + + + + + +1x + +1x + + + + + + + + + + | import api from "./client"
+
+export const savingsApi = {
+ // Categories
+ listCategories: () => api.get("/savings/categories").then((r) => r.data),
+ getCategory: (id) => api.get(`/savings/categories/${id}`).then((r) => r.data),
+ createCategory: (data) => api.post("/savings/categories", data).then((r) => r.data),
+ updateCategory: (id, data) =>
+ api.put(`/savings/categories/${id}`, data).then((r) => r.data),
+ deleteCategory: (id) => api.delete(`/savings/categories/${id}`),
+
+ // Transactions
+ listTransactions: (categoryId, limit = 100, offset = 0) => {
+ let url = `/savings/transactions?limit=${limit}&offset=${offset}`
+ if (categoryId) url += `&category_id=${categoryId}`
+ return api.get(url).then((r) => r.data)
+ },
+ createTransaction: (data) =>
+ api.post("/savings/transactions", data).then((r) => r.data),
+ updateTransaction: (id, data) =>
+ api.put(`/savings/transactions/${id}`, data).then((r) => r.data),
+ deleteTransaction: (id) => api.delete(`/savings/transactions/${id}`),
+
+ // Stats
+ getStats: () => api.get("/savings/stats").then((r) => r.data),
+
+ // Members
+ getMembers: (categoryId) =>
+ api.get(`/savings/categories/${categoryId}/members`).then((r) => r.data),
+ addMember: (categoryId, userId) =>
+ api
+ .post(`/savings/categories/${categoryId}/members`, { user_id: userId })
+ .then((r) => r.data),
+ removeMember: (categoryId, userId) =>
+ api.delete(`/savings/categories/${categoryId}/members/${userId}`),
+
+ // Recurring Plans
+ getRecurringPlans: (categoryId) =>
+ api
+ .get(`/savings/categories/${categoryId}/recurring-plans`)
+ .then((r) => r.data),
+ createRecurringPlan: (categoryId, data) =>
+ api
+ .post(`/savings/categories/${categoryId}/recurring-plans`, data)
+ .then((r) => r.data),
+ updateRecurringPlan: (planId, data) =>
+ api.put(`/savings/recurring-plans/${planId}`, data).then((r) => r.data),
+ deleteRecurringPlan: (planId) =>
+ api.delete(`/savings/recurring-plans/${planId}`),
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 | + +1x + +3x +3x +2x + +3x +3x + + + +1x +1x + + + +1x +1x + + + +1x +1x + + + +1x +1x + + + +1x + + + +1x +1x + + + +1x +1x + + + | import client from './client'
+
+export const tasksApi = {
+ list: async (completed = null) => {
+ let url = 'tasks'
+ if (completed !== null) {
+ url += `?completed=${completed}`
+ }
+ const res = await client.get(url)
+ return res.data
+ },
+
+ today: async () => {
+ const res = await client.get('tasks/today')
+ return res.data
+ },
+
+ get: async (id) => {
+ const res = await client.get(`tasks/${id}`)
+ return res.data
+ },
+
+ create: async (data) => {
+ const res = await client.post('tasks', data)
+ return res.data
+ },
+
+ update: async (id, data) => {
+ const res = await client.put(`tasks/${id}`, data)
+ return res.data
+ },
+
+ delete: async (id) => {
+ await client.delete(`tasks/${id}`)
+ },
+
+ complete: async (id) => {
+ const res = await client.post(`tasks/${id}/complete`)
+ return res.data
+ },
+
+ uncomplete: async (id) => {
+ const res = await client.post(`tasks/${id}/uncomplete`)
+ return res.data
+ },
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 | + + + + + + + +1x + + + + +1x + + + + + + + + + + + +1x + + + + + + + + + + +12x +12x +12x +12x +12x +12x +12x +12x +12x +12x +12x + +12x + +12x +1x + +1x +1x +1x + + + + + + +12x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x + + +12x +2x +2x +1x +1x + +1x + + + +1x +2x + + + + +1x +1x + + +1x + + +1x + + + +1x + + +12x + + + + + + + +12x + +12x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +110x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +110x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState } from "react"
+import { motion, AnimatePresence } from "framer-motion"
+import { X, ChevronDown, ChevronUp, Clock, Calendar } from "lucide-react"
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { habitsApi } from "../api/habits"
+import { format } from "date-fns"
+import clsx from "clsx"
+
+const COLORS = [
+ "#6366f1", "#8b5cf6", "#d946ef", "#ec4899", "#f43f5e",
+ "#f97316", "#eab308", "#22c55e", "#14b8a6", "#0ea5e9",
+]
+
+const ICON_CATEGORIES = [
+ { name: "Спорт", icons: ["💪", "🏃", "🚴", "🏊", "🧘", "⚽", "🏀", "🎾"] },
+ { name: "Здоровье", icons: ["💊", "💉", "🩺", "🧠", "😴", "💤", "🦷", "👁️"] },
+ { name: "Продуктивность", icons: ["📚", "📖", "✏️", "💻", "🎯", "📝", "📅", "⏰"] },
+ { name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴"] },
+ { name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦"] },
+ { name: "Социальное", icons: ["👥", "💬", "📞", "👪", "❤️"] },
+ { name: "Хобби", icons: ["🎨", "🎵", "🎸", "🎮", "📷", "✈️", "🚗"] },
+ { name: "Еда/вода", icons: ["🥗", "🍎", "🥤", "☕", "🍽️", "💧"] },
+ { name: "Разное", icons: ["⭐", "🎉", "✨", "🔥", "🌟", "💎", "🎁"] },
+]
+
+const DAYS = [
+ { id: 1, short: "Пн", full: "Понедельник" },
+ { id: 2, short: "Вт", full: "Вторник" },
+ { id: 3, short: "Ср", full: "Среда" },
+ { id: 4, short: "Чт", full: "Четверг" },
+ { id: 5, short: "Пт", full: "Пятница" },
+ { id: 6, short: "Сб", full: "Суббота" },
+ { id: 7, short: "Вс", full: "Воскресенье" },
+]
+
+export default function CreateHabitModal({ open, onClose }) {
+ const [name, setName] = useState("")
+ const [description, setDescription] = useState("")
+ const [color, setColor] = useState(COLORS[0])
+ const [icon, setIcon] = useState("✨")
+ const [frequency, setFrequency] = useState("daily")
+ const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7])
+ const [intervalDays, setIntervalDays] = useState(2)
+ const [reminderTime, setReminderTime] = useState("")
+ const [startDate, setStartDate] = useState(format(new Date(), "yyyy-MM-dd"))
+ const [error, setError] = useState("")
+ const [showAllIcons, setShowAllIcons] = useState(false)
+
+ const queryClient = useQueryClient()
+
+ const mutation = useMutation({
+ mutationFn: (data) => habitsApi.create(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["habits"] })
+ queryClient.invalidateQueries({ queryKey: ["stats"] })
+ handleClose()
+ },
+ onError: (err) => {
+ setError(err.response?.data?.error || "Ошибка создания")
+ },
+ })
+
+ const handleClose = () => {
+ setName("")
+ setDescription("")
+ setColor(COLORS[0])
+ setIcon("✨")
+ setFrequency("daily")
+ setTargetDays([1, 2, 3, 4, 5, 6, 7])
+ setIntervalDays(2)
+ setReminderTime("")
+ setStartDate(format(new Date(), "yyyy-MM-dd"))
+ setError("")
+ setShowAllIcons(false)
+ onClose()
+ }
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+ if (!name.trim()) {
+ setError("Введи название привычки")
+ return
+ }
+ Iif (frequency === "weekly" && targetDays.length === 0) {
+ setError("Выбери хотя бы один день недели")
+ return
+ }
+ const interval = parseInt(intervalDays) || 0
+ Iif (frequency === "interval" && (interval < 2 || interval > 30)) {
+ setError("Интервал должен быть от 2 до 30 дней")
+ return
+ }
+
+ const data = { name, description, color, icon, frequency, start_date: startDate }
+ Iif (frequency === "weekly") {
+ data.target_days = targetDays
+ }
+ Iif (frequency === "interval") {
+ data.target_count = parseInt(intervalDays)
+ }
+ Iif (reminderTime) {
+ data.reminder_time = reminderTime
+ }
+
+ mutation.mutate(data)
+ }
+
+ const toggleDay = (dayId) => {
+ setTargetDays(prev =>
+ prev.includes(dayId)
+ ? prev.filter(d => d !== dayId)
+ : [...prev, dayId].sort((a, b) => a - b)
+ )
+ }
+
+ 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 dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
+ <div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 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 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
+ >
+ <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 dark:text-gray-300 mb-1.5">
+ Название
+ </label>
+ <input
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ className="input"
+ placeholder="Например: Пить воду"
+ autoFocus
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
+ Описание (опционально)
+ </label>
+ <input
+ type="text"
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ className="input"
+ placeholder="8 стаканов в день"
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+ Периодичность
+ </label>
+ <div className="flex gap-2">
+ <button
+ type="button"
+ onClick={() => setFrequency("daily")}
+ className={clsx(
+ "flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
+ frequency === "daily"
+ ? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+ )}
+ >
+ Ежедневно
+ </button>
+ <button
+ type="button"
+ onClick={() => setFrequency("weekly")}
+ className={clsx(
+ "flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
+ frequency === "weekly"
+ ? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+ )}
+ >
+ По дням
+ </button>
+ <button
+ type="button"
+ onClick={() => setFrequency("interval")}
+ className={clsx(
+ "flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
+ frequency === "interval"
+ ? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+ )}
+ >
+ Интервал
+ </button>
+ </div>
+ </div>
+
+ {frequency === "weekly" && (
+ <motion.div
+ initial={{ opacity: 0, height: 0 }}
+ animate={{ opacity: 1, height: "auto" }}
+ exit={{ opacity: 0, height: 0 }}
+ >
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+ Дни недели
+ </label>
+ <div className="flex gap-1.5">
+ {DAYS.map((day) => (
+ <button
+ key={day.id}
+ type="button"
+ onClick={() => toggleDay(day.id)}
+ className={clsx(
+ "flex-1 py-2 rounded-lg font-medium text-sm transition-all",
+ targetDays.includes(day.id)
+ ? "bg-primary-500 text-white shadow-md"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500 hover:bg-gray-200"
+ )}
+ >
+ {day.short}
+ </button>
+ ))}
+ </div>
+ </motion.div>
+ )}
+
+ {frequency === "interval" && (
+ <motion.div
+ initial={{ opacity: 0, height: 0 }}
+ animate={{ opacity: 1, height: "auto" }}
+ exit={{ opacity: 0, height: 0 }}
+ >
+ <div className="flex items-center gap-3">
+ <span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">Каждые</span>
+ <input
+ type="number"
+ min="2"
+ max="30"
+ value={intervalDays}
+ onChange={(e) => setIntervalDays(e.target.value === "" ? "" : parseInt(e.target.value) || "")}
+ className="input w-20 text-center"
+ />
+ <span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">дней</span>
+ </div>
+ </motion.div>
+ )}
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+ Дата начала
+ </label>
+ <div className="relative">
+ <Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
+ <input
+ type="date"
+ value={startDate}
+ onChange={(e) => setStartDate(e.target.value)}
+ className="input pl-10"
+ />
+ </div>
+ <p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
+ {frequency === "interval"
+ ? "Интервал считается от этой даты"
+ : "Привычка появится начиная с этой даты"}
+ </p>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+ Напоминание (опционально)
+ </label>
+ <div className="relative">
+ <Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" 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 dark:text-gray-400 dark:text-gray-500 mt-1">
+ Получишь напоминание в Telegram в указанное время
+ </p>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 dark:bg-gray-800 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 dark:text-gray-400 dark: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 dark:bg-gray-800 hover:bg-gray-200"
+ )}
+ >
+ {ic}
+ </button>
+ ))}
+ </div>
+ </div>
+ ))}
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 | + + + + + + + +1x + + + + +1x + + + + + + + + +1x + + + + + + +1x + + + + + + + +12x +12x + +12x +12x +12x +12x +12x +12x +12x +12x +12x + + +12x +12x +12x +12x + +12x + +12x +1x + +1x +1x +1x + + + + + + +12x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x + + +12x +2x +2x +1x +1x + + +1x + + + + + + + + + + +2x + + + + + +1x + + +12x + +12x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +44x + + + + + + + + + + + + + + + + + + + + + + +88x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +110x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState } from "react"
+import { motion, AnimatePresence } from "framer-motion"
+import { X, ChevronDown, ChevronUp, Calendar, Clock, Repeat } 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 dark:bg-gray-800 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" },
+]
+
+const RECURRENCE_TYPES = [
+ { value: "daily", label: "Ежедневно" },
+ { value: "weekly", label: "Еженедельно" },
+ { value: "monthly", label: "Ежемесячно" },
+ { value: "custom", label: "Каждые N дней" },
+]
+
+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)
+
+ // Recurring state
+ const [isRecurring, setIsRecurring] = useState(false)
+ const [recurrenceType, setRecurrenceType] = useState("daily")
+ const [recurrenceInterval, setRecurrenceInterval] = useState(1)
+ const [recurrenceEndDate, setRecurrenceEndDate] = useState("")
+
+ 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)
+ setIsRecurring(false)
+ setRecurrenceType("daily")
+ setRecurrenceInterval(1)
+ setRecurrenceEndDate("")
+ onClose()
+ }
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+ if (!title.trim()) {
+ setError("Введи название задачи")
+ return
+ }
+
+ const data = {
+ title,
+ description,
+ color,
+ icon,
+ due_date: dueDate || null,
+ priority,
+ reminder_time: reminderTime || null,
+ is_recurring: isRecurring,
+ }
+
+ Iif (isRecurring) {
+ data.recurrence_type = recurrenceType
+ data.recurrence_interval = recurrenceType === "custom" ? recurrenceInterval : 1
+ data.recurrence_end_date = recurrenceEndDate || null
+ }
+
+ mutation.mutate(data)
+ }
+
+ 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 dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
+ <div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 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 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
+ >
+ <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 dark:text-gray-300 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 dark:text-gray-300 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 dark:text-gray-300 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 dark:bg-gray-800 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 dark:bg-gray-800 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 dark:bg-gray-800 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 dark:text-gray-500" size={18} />
+ <input
+ type="date"
+ value={dueDate}
+ onChange={(e) => setDueDate(e.target.value)}
+ className="input pl-10"
+ />
+ </div>
+ </div>
+
+ {/* Recurring Section */}
+ <div className="border-t border-gray-100 dark:border-gray-800 pt-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="flex items-center gap-2">
+ <Repeat size={18} className={isRecurring ? "text-primary-500" : "text-gray-400 dark:text-gray-500"} />
+ <label className="text-sm font-medium text-gray-700 dark:text-gray-300">Повторять</label>
+ </div>
+ <button
+ type="button"
+ onClick={() => setIsRecurring(!isRecurring)}
+ className={clsx(
+ "w-12 h-6 rounded-full transition-all relative",
+ isRecurring ? "bg-primary-500" : "bg-gray-200"
+ )}
+ >
+ <div className={clsx(
+ "absolute top-1 w-4 h-4 rounded-full bg-white dark:bg-gray-900 shadow-sm transition-all",
+ isRecurring ? "right-1" : "left-1"
+ )} />
+ </button>
+ </div>
+
+ <AnimatePresence>
+ {isRecurring && (
+ <motion.div
+ initial={{ opacity: 0, height: 0 }}
+ animate={{ opacity: 1, height: "auto" }}
+ exit={{ opacity: 0, height: 0 }}
+ className="space-y-3"
+ >
+ <div className="flex flex-wrap gap-2">
+ {RECURRENCE_TYPES.map((type) => (
+ <button
+ key={type.value}
+ type="button"
+ onClick={() => setRecurrenceType(type.value)}
+ className={clsx(
+ "px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
+ recurrenceType === type.value
+ ? "bg-primary-500 text-white"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+ )}
+ >
+ {type.label}
+ </button>
+ ))}
+ </div>
+
+ {recurrenceType === "custom" && (
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-gray-600">Каждые</span>
+ <input
+ type="number"
+ min="1"
+ max="365"
+ value={recurrenceInterval}
+ onChange={(e) => setRecurrenceInterval(parseInt(e.target.value) || 1)}
+ className="input w-20 text-center"
+ />
+ <span className="text-sm text-gray-600">дней</span>
+ </div>
+ )}
+
+ <div>
+ <label className="block text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1">
+ Повторять до (опционально)
+ </label>
+ <input
+ type="date"
+ value={recurrenceEndDate}
+ onChange={(e) => setRecurrenceEndDate(e.target.value)}
+ className="input"
+ min={dueDate || today}
+ />
+ </div>
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+ Напоминание (опционально)
+ </label>
+ <div className="relative">
+ <Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" 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 dark:text-gray-400 dark:text-gray-500 mt-1">
+ Получишь напоминание в Telegram в указанное время
+ </p>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+ )}
+ >
+ {p.label}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 dark:bg-gray-800 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 dark:text-gray-400 dark: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 dark:bg-gray-800 hover:bg-gray-200"
+ )}
+ >
+ {ic}
+ </button>
+ ))}
+ </div>
+ </div>
+ ))}
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 | + + + + + + + + +1x + + + + +1x + + + + + + + + + + + +1x + + + + + + + + + + +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x + +20x + + +20x + +7x + + + +20x +8x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x + + + + + +7x +7x +7x +7x +7x +7x +7x +7x + + + +20x +1x + +1x +1x +1x + + + + + + +20x +1x + +1x +1x +1x + + + + + + +20x + + + + + + + + + + + + + + +20x + + + + + + + +20x + + + + + + + + +20x +1x +1x + + + +1x + + + +1x +1x + + + + +1x +1x + + +1x + + +1x + +1x + + +20x +1x + + +20x + + + + + + + + + + + + + + + + +20x + + + + + + + +20x +20x +20x + +20x + +20x + +20x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +160x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +160x + + + + + + + + + + + + + + + + + + + + + + + + +2x + + + + + + + + + + + + + + + + | import { useState, useEffect } from "react"
+import { motion, AnimatePresence } from "framer-motion"
+import { X, Trash2, ChevronDown, ChevronUp, Clock, Calendar, Snowflake, Plus } from "lucide-react"
+import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"
+import { habitsApi } from "../api/habits"
+import { format, parseISO, isBefore, isAfter, startOfDay } from "date-fns"
+import { ru } from "date-fns/locale"
+import clsx from "clsx"
+
+const COLORS = [
+ "#6366f1", "#8b5cf6", "#d946ef", "#ec4899", "#f43f5e",
+ "#f97316", "#eab308", "#22c55e", "#14b8a6", "#0ea5e9",
+]
+
+const ICON_CATEGORIES = [
+ { name: "Спорт", icons: ["💪", "🏃", "🚴", "🏊", "🧘", "⚽", "🏀", "🎾"] },
+ { name: "Здоровье", icons: ["💊", "💉", "🩺", "🧠", "😴", "💤", "🦷", "👁️"] },
+ { name: "Продуктивность", icons: ["📚", "📖", "✏️", "💻", "🎯", "📝", "📅", "⏰"] },
+ { name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴"] },
+ { name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦"] },
+ { name: "Социальное", icons: ["👥", "💬", "📞", "👪", "❤️"] },
+ { name: "Хобби", icons: ["🎨", "🎵", "🎸", "🎮", "📷", "✈️", "🚗"] },
+ { name: "Еда/вода", icons: ["🥗", "🍎", "🥤", "☕", "🍽️", "💧"] },
+ { name: "Разное", icons: ["⭐", "🎉", "✨", "🔥", "🌟", "💎", "🎁"] },
+]
+
+const DAYS = [
+ { id: 1, short: "Пн" },
+ { id: 2, short: "Вт" },
+ { id: 3, short: "Ср" },
+ { id: 4, short: "Чт" },
+ { id: 5, short: "Пт" },
+ { id: 6, short: "Сб" },
+ { id: 7, short: "Вс" },
+]
+
+export default function EditHabitModal({ open, onClose, habit }) {
+ const [name, setName] = useState("")
+ const [description, setDescription] = useState("")
+ const [color, setColor] = useState(COLORS[0])
+ const [icon, setIcon] = useState("✨")
+ const [frequency, setFrequency] = useState("daily")
+ const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7])
+ const [intervalDays, setIntervalDays] = useState(3)
+ const [reminderTime, setReminderTime] = useState("")
+ const [startDate, setStartDate] = useState("")
+ const [error, setError] = useState("")
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+ const [showAllIcons, setShowAllIcons] = useState(false)
+ const [showFreezes, setShowFreezes] = useState(false)
+ const [showAddFreeze, setShowAddFreeze] = useState(false)
+ const [freezeStart, setFreezeStart] = useState("")
+ const [freezeEnd, setFreezeEnd] = useState("")
+ const [freezeReason, setFreezeReason] = useState("")
+
+ const queryClient = useQueryClient()
+
+ // Load freezes for this habit
+ const { data: freezes = [], refetch: refetchFreezes } = useQuery({
+ queryKey: ['habit-freezes', habit?.id],
+ queryFn: () => habitsApi.getFreezes(habit.id),
+ enabled: !!habit?.id && open,
+ })
+
+ useEffect(() => {
+ if (habit && open) {
+ setName(habit.name || "")
+ setDescription(habit.description || "")
+ setColor(habit.color || COLORS[0])
+ setIcon(habit.icon || "✨")
+ setFrequency(habit.frequency || "daily")
+ setTargetDays(habit.target_days || [1, 2, 3, 4, 5, 6, 7])
+ setIntervalDays(habit.target_count || 3)
+ setReminderTime(habit.reminder_time || "")
+ if (habit.start_date) {
+ setStartDate(habit.start_date)
+ } else if (Ehabit.created_at) {
+ setStartDate(format(parseISO(habit.created_at), "yyyy-MM-dd"))
+ } else {
+ setStartDate(format(new Date(), "yyyy-MM-dd"))
+ }
+ setError("")
+ setShowDeleteConfirm(false)
+ setShowAllIcons(false)
+ setShowFreezes(false)
+ setShowAddFreeze(false)
+ setFreezeStart("")
+ setFreezeEnd("")
+ setFreezeReason("")
+ }
+ }, [habit, open])
+
+ const updateMutation = useMutation({
+ mutationFn: (data) => habitsApi.update(habit.id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["habits"] })
+ queryClient.invalidateQueries({ queryKey: ["stats"] })
+ onClose()
+ },
+ onError: (err) => {
+ setError(err.response?.data?.error || "Ошибка сохранения")
+ },
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: () => habitsApi.delete(habit.id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["habits"] })
+ queryClient.invalidateQueries({ queryKey: ["stats"] })
+ onClose()
+ },
+ onError: (err) => {
+ setError(err.response?.data?.error || "Ошибка удаления")
+ },
+ })
+
+ const addFreezeMutation = useMutation({
+ mutationFn: (data) => habitsApi.addFreeze(habit.id, data),
+ onSuccess: () => {
+ refetchFreezes()
+ queryClient.invalidateQueries({ queryKey: ["habits"] })
+ setShowAddFreeze(false)
+ setFreezeStart("")
+ setFreezeEnd("")
+ setFreezeReason("")
+ },
+ onError: (err) => {
+ setError(err.response?.data?.error || "Ошибка создания заморозки")
+ },
+ })
+
+ const deleteFreezeMutation = useMutation({
+ mutationFn: (freezeId) => habitsApi.deleteFreeze(habit.id, freezeId),
+ onSuccess: () => {
+ refetchFreezes()
+ queryClient.invalidateQueries({ queryKey: ["habits"] })
+ },
+ })
+
+ const handleClose = () => {
+ setError("")
+ setShowDeleteConfirm(false)
+ setShowAllIcons(false)
+ setShowFreezes(false)
+ setShowAddFreeze(false)
+ onClose()
+ }
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+ Iif (!name.trim()) {
+ setError("Введи название привычки")
+ return
+ }
+ Iif (frequency === "weekly" && targetDays.length === 0) {
+ setError("Выбери хотя бы один день недели")
+ return
+ }
+ const interval = parseInt(intervalDays) || 0
+ Iif (frequency === "interval" && (interval < 2 || interval > 30)) {
+ setError("Интервал должен быть от 2 до 30 дней")
+ return
+ }
+
+ const data = { name, description, color, icon, frequency, start_date: startDate }
+ Iif (frequency === "weekly") {
+ data.target_days = targetDays
+ }
+ Iif (frequency === "interval") {
+ data.target_count = parseInt(intervalDays)
+ }
+ data.reminder_time = reminderTime || null
+
+ updateMutation.mutate(data)
+ }
+
+ const handleDelete = () => {
+ deleteMutation.mutate()
+ }
+
+ const handleAddFreeze = () => {
+ if (!freezeStart || !freezeEnd) {
+ setError("Укажи даты начала и окончания заморозки")
+ return
+ }
+ if (isBefore(parseISO(freezeEnd), parseISO(freezeStart))) {
+ setError("Дата окончания должна быть после даты начала")
+ return
+ }
+ setError("")
+ addFreezeMutation.mutate({
+ start_date: freezeStart,
+ end_date: freezeEnd,
+ reason: freezeReason,
+ })
+ }
+
+ const toggleDay = (dayId) => {
+ setTargetDays(prev =>
+ prev.includes(dayId)
+ ? prev.filter(d => d !== dayId)
+ : [...prev, dayId].sort((a, b) => a - b)
+ )
+ }
+
+ const today = startOfDay(new Date())
+ const activeFreezes = freezes.filter(f => !isBefore(parseISO(f.end_date), today))
+ const pastFreezes = freezes.filter(f => isBefore(parseISO(f.end_date), today))
+
+ const popularIcons = ["✨", "💪", "📚", "🏃", "💧", "🧘", "💤", "🎯", "✏️", "🍎"]
+
+ Iif (!habit) return null
+
+ 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 dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
+ <div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex items-center justify-between z-10">
+ <h2 className="text-lg font-semibold">Редактировать привычку</h2>
+ <button
+ onClick={handleClose}
+ className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
+ >
+ <X size={20} />
+ </button>
+ </div>
+
+ {showDeleteConfirm ? (
+ <div className="p-6 text-center">
+ <div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
+ <Trash2 className="w-8 h-8 text-red-500" />
+ </div>
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Удалить привычку?</h3>
+ <p className="text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-6">
+ Привычка "{habit.name}" и вся её история будут удалены безвозвратно.
+ </p>
+ <div className="flex gap-3">
+ <button
+ onClick={() => setShowDeleteConfirm(false)}
+ className="flex-1 btn bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200"
+ >
+ Отмена
+ </button>
+ <button
+ onClick={handleDelete}
+ disabled={deleteMutation.isPending}
+ className="flex-1 btn bg-red-500 text-white hover:bg-red-600"
+ >
+ {deleteMutation.isPending ? "Удаляем..." : "Удалить"}
+ </button>
+ </div>
+ </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 dark:text-gray-300 mb-1.5">
+ Название
+ </label>
+ <input
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ className="input"
+ placeholder="Например: Пить воду"
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
+ Описание (опционально)
+ </label>
+ <input
+ type="text"
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ className="input"
+ placeholder="8 стаканов в день"
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+ Периодичность
+ </label>
+ <div className="flex gap-2">
+ <button
+ type="button"
+ onClick={() => setFrequency("daily")}
+ className={clsx(
+ "flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
+ frequency === "daily"
+ ? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+ )}
+ >
+ Ежедневно
+ </button>
+ <button
+ type="button"
+ onClick={() => setFrequency("weekly")}
+ className={clsx(
+ "flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
+ frequency === "weekly"
+ ? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+ )}
+ >
+ По дням
+ </button>
+ <button
+ type="button"
+ onClick={() => setFrequency("interval")}
+ className={clsx(
+ "flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
+ frequency === "interval"
+ ? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+ )}
+ >
+ Интервал
+ </button>
+ </div>
+ </div>
+
+ {frequency === "weekly" && (
+ <motion.div
+ initial={{ opacity: 0, height: 0 }}
+ animate={{ opacity: 1, height: "auto" }}
+ exit={{ opacity: 0, height: 0 }}
+ >
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+ Дни недели
+ </label>
+ <div className="flex gap-1.5">
+ {DAYS.map((day) => (
+ <button
+ key={day.id}
+ type="button"
+ onClick={() => toggleDay(day.id)}
+ className={clsx(
+ "flex-1 py-2 rounded-lg font-medium text-sm transition-all",
+ targetDays.includes(day.id)
+ ? "bg-primary-500 text-white shadow-md"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500 hover:bg-gray-200"
+ )}
+ >
+ {day.short}
+ </button>
+ ))}
+ </div>
+ </motion.div>
+ )}
+
+ {frequency === "interval" && (
+ <motion.div
+ initial={{ opacity: 0, height: 0 }}
+ animate={{ opacity: 1, height: "auto" }}
+ exit={{ opacity: 0, height: 0 }}
+ >
+ <div className="flex items-center gap-3">
+ <span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">Каждые</span>
+ <input
+ type="number"
+ min="2"
+ max="30"
+ value={intervalDays}
+ onChange={(e) => setIntervalDays(e.target.value === "" ? "" : parseInt(e.target.value) || "")}
+ className="input w-20 text-center"
+ />
+ <span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">дней</span>
+ </div>
+ </motion.div>
+ )}
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+ Дата начала
+ </label>
+ <div className="relative">
+ <Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
+ <input
+ type="date"
+ value={startDate}
+ onChange={(e) => setStartDate(e.target.value)}
+ className="input pl-10"
+ />
+ </div>
+ <p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
+ {frequency === "interval"
+ ? "Интервал считается от этой даты"
+ : "Привычка появится начиная с этой даты"}
+ </p>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+ Напоминание (опционально)
+ </label>
+ <div className="relative">
+ <Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" 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 dark:text-gray-400 dark:text-gray-500 mt-1">
+ Получишь напоминание в Telegram в указанное время
+ </p>
+ </div>
+
+ {/* Freezes section */}
+ <div className="border-t pt-4">
+ <button
+ type="button"
+ onClick={() => setShowFreezes(!showFreezes)}
+ className="flex items-center justify-between w-full text-left"
+ >
+ <div className="flex items-center gap-2">
+ <Snowflake className="w-5 h-5 text-cyan-500" />
+ <span className="font-medium text-gray-700 dark:text-gray-300">Заморозки</span>
+ {activeFreezes.length > 0 && (
+ <span className="px-2 py-0.5 bg-cyan-100 text-cyan-700 rounded-full text-xs">
+ {activeFreezes.length}
+ </span>
+ )}
+ </div>
+ {showFreezes ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
+ </button>
+ <p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
+ Поставь привычку на паузу на время отпуска или болезни
+ </p>
+
+ <AnimatePresence>
+ {showFreezes && (
+ <motion.div
+ initial={{ opacity: 0, height: 0 }}
+ animate={{ opacity: 1, height: "auto" }}
+ exit={{ opacity: 0, height: 0 }}
+ className="mt-3 space-y-3"
+ >
+ {/* Active freezes */}
+ {activeFreezes.length > 0 && (
+ <div className="space-y-2">
+ {activeFreezes.map((freeze) => {
+ const isActive = !isBefore(parseISO(freeze.end_date), today) &&
+ !isAfter(parseISO(freeze.start_date), today)
+ return (
+ <div
+ key={freeze.id}
+ className={clsx(
+ "flex items-center justify-between p-3 rounded-xl",
+ isActive ? "bg-cyan-50 border border-cyan-200" : "bg-gray-50 dark:bg-gray-800"
+ )}
+ >
+ <div className="flex items-center gap-2">
+ <Snowflake className={clsx(
+ "w-4 h-4",
+ isActive ? "text-cyan-500" : "text-gray-400 dark:text-gray-500"
+ )} />
+ <div>
+ <p className="text-sm font-medium text-gray-700 dark:text-gray-300">
+ {format(parseISO(freeze.start_date), "d MMM", { locale: ru })} — {format(parseISO(freeze.end_date), "d MMM yyyy", { locale: ru })}
+ </p>
+ {freeze.reason && (
+ <p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">{freeze.reason}</p>
+ )}
+ </div>
+ {isActive && (
+ <span className="px-2 py-0.5 bg-cyan-200 text-cyan-800 rounded-full text-xs">
+ активна
+ </span>
+ )}
+ </div>
+ <button
+ type="button"
+ onClick={() => deleteFreezeMutation.mutate(freeze.id)}
+ className="p-1.5 text-gray-400 dark:text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg"
+ >
+ <Trash2 size={16} />
+ </button>
+ </div>
+ )
+ })}
+ </div>
+ )}
+
+ {/* Add freeze form */}
+ {showAddFreeze ? (
+ <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-xl space-y-3">
+ <div className="grid grid-cols-2 gap-2">
+ <div>
+ <label className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">Начало</label>
+ <input
+ type="date"
+ value={freezeStart}
+ onChange={(e) => setFreezeStart(e.target.value)}
+ min={format(new Date(), "yyyy-MM-dd")}
+ className="input text-sm"
+ />
+ </div>
+ <div>
+ <label className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">Окончание</label>
+ <input
+ type="date"
+ value={freezeEnd}
+ onChange={(e) => setFreezeEnd(e.target.value)}
+ min={freezeStart || format(new Date(), "yyyy-MM-dd")}
+ className="input text-sm"
+ />
+ </div>
+ </div>
+ <input
+ type="text"
+ value={freezeReason}
+ onChange={(e) => setFreezeReason(e.target.value)}
+ placeholder="Причина (опционально)"
+ className="input text-sm"
+ />
+ <div className="flex gap-2">
+ <button
+ type="button"
+ onClick={() => setShowAddFreeze(false)}
+ className="flex-1 btn bg-gray-100 dark:bg-gray-800 text-gray-600 text-sm"
+ >
+ Отмена
+ </button>
+ <button
+ type="button"
+ onClick={handleAddFreeze}
+ disabled={addFreezeMutation.isPending}
+ className="flex-1 btn bg-cyan-500 text-white text-sm hover:bg-cyan-600"
+ >
+ {addFreezeMutation.isPending ? "..." : "Добавить"}
+ </button>
+ </div>
+ </div>
+ ) : (
+ <button
+ type="button"
+ onClick={() => setShowAddFreeze(true)}
+ className="flex items-center gap-2 w-full p-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800 rounded-xl text-sm text-gray-600 transition-colors"
+ >
+ <Plus size={16} />
+ Добавить заморозку
+ </button>
+ )}
+
+ {/* Past freezes */}
+ {pastFreezes.length > 0 && (
+ <details className="text-sm">
+ <summary className="text-gray-500 dark:text-gray-400 dark:text-gray-500 cursor-pointer hover:text-gray-700 dark:text-gray-300">
+ Прошлые заморозки ({pastFreezes.length})
+ </summary>
+ <div className="mt-2 space-y-1">
+ {pastFreezes.map((freeze) => (
+ <div key={freeze.id} className="flex items-center justify-between p-2 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500">
+ <span>
+ {format(parseISO(freeze.start_date), "d MMM", { locale: ru })} — {format(parseISO(freeze.end_date), "d MMM yyyy", { locale: ru })}
+ {freeze.reason && " — " + freeze.reason}
+ </span>
+ <button
+ type="button"
+ onClick={() => deleteFreezeMutation.mutate(freeze.id)}
+ className="p-1 text-gray-400 dark:text-gray-500 hover:text-red-500"
+ >
+ <Trash2 size={14} />
+ </button>
+ </div>
+ ))}
+ </div>
+ </details>
+ )}
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 dark:bg-gray-800 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 dark:text-gray-400 dark: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 dark:bg-gray-800 hover:bg-gray-200"
+ )}
+ >
+ {ic}
+ </button>
+ ))}
+ </div>
+ </div>
+ ))}
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 space-y-3">
+ <button
+ type="submit"
+ disabled={updateMutation.isPending}
+ className="btn btn-primary w-full"
+ >
+ {updateMutation.isPending ? "Сохраняем..." : "Сохранить изменения"}
+ </button>
+
+ <button
+ type="button"
+ onClick={() => setShowDeleteConfirm(true)}
+ className="btn w-full bg-red-50 text-red-600 hover:bg-red-100 flex items-center justify-center gap-2"
+ >
+ <Trash2 size={18} />
+ Удалить привычку
+ </button>
+ </div>
+ </form>
+ )}
+ </div>
+ </motion.div>
+ </>
+ )}
+ </AnimatePresence>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 | + + + + + + + +1x + + + + +1x + + + + + + + + +1x + + + + + + +1x + + + + + + + +18x +18x + +18x +18x +18x +18x +18x +18x +18x +18x +18x +18x + + +18x +18x +18x +18x + +18x + +18x +7x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x + + + +18x +1x + +1x +1x +1x + + + + + + +18x +1x + +1x +1x +1x + + + + + + +18x + + + + + + +18x +1x +1x + + + + +1x + + + + + + + + + + + + + +1x + + +18x + +18x + +18x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +68x + + + + + + + + + + + + + + + + + + + + + + +136x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +170x + + + + + + + + + + + + + + + + + + + + + + + + + +2x + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + | import { useState, useEffect } from "react"
+import { motion, AnimatePresence } from "framer-motion"
+import { X, Trash2, ChevronDown, ChevronUp, Calendar, Clock, Repeat } 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 dark:bg-gray-800 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" },
+]
+
+const RECURRENCE_TYPES = [
+ { value: "daily", label: "Ежедневно" },
+ { value: "weekly", label: "Еженедельно" },
+ { value: "monthly", label: "Ежемесячно" },
+ { value: "custom", label: "Каждые N дней" },
+]
+
+export default function EditTaskModal({ open, onClose, task }) {
+ 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("")
+ const [priority, setPriority] = useState(0)
+ const [reminderTime, setReminderTime] = useState("")
+ const [error, setError] = useState("")
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+ const [showAllIcons, setShowAllIcons] = useState(false)
+
+ // Recurring state
+ const [isRecurring, setIsRecurring] = useState(false)
+ const [recurrenceType, setRecurrenceType] = useState("daily")
+ const [recurrenceInterval, setRecurrenceInterval] = useState(1)
+ const [recurrenceEndDate, setRecurrenceEndDate] = useState("")
+
+ const queryClient = useQueryClient()
+
+ useEffect(() => {
+ if (task && open) {
+ setTitle(task.title || "")
+ setDescription(task.description || "")
+ setColor(task.color || COLORS[0])
+ setIcon(task.icon || "📋")
+ setDueDate(task.due_date || "")
+ setPriority(task.priority || 0)
+ setReminderTime(task.reminder_time || "")
+ setIsRecurring(task.is_recurring || false)
+ setRecurrenceType(task.recurrence_type || "daily")
+ setRecurrenceInterval(task.recurrence_interval || 1)
+ setRecurrenceEndDate(task.recurrence_end_date || "")
+ setError("")
+ setShowDeleteConfirm(false)
+ setShowAllIcons(false)
+ }
+ }, [task, open])
+
+ const updateMutation = useMutation({
+ mutationFn: (data) => tasksApi.update(task.id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["tasks"] })
+ queryClient.invalidateQueries({ queryKey: ["tasks-today"] })
+ onClose()
+ },
+ onError: (err) => {
+ setError(err.response?.data?.error || "Ошибка сохранения")
+ },
+ })
+
+ const deleteMutation = useMutation({
+ mutationFn: () => tasksApi.delete(task.id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["tasks"] })
+ queryClient.invalidateQueries({ queryKey: ["tasks-today"] })
+ onClose()
+ },
+ onError: (err) => {
+ setError(err.response?.data?.error || "Ошибка удаления")
+ },
+ })
+
+ const handleClose = () => {
+ setError("")
+ setShowDeleteConfirm(false)
+ setShowAllIcons(false)
+ onClose()
+ }
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+ Iif (!title.trim()) {
+ setError("Введи название задачи")
+ return
+ }
+
+ const data = {
+ title,
+ description,
+ color,
+ icon,
+ due_date: dueDate || null,
+ priority,
+ reminder_time: reminderTime || null,
+ is_recurring: isRecurring,
+ recurrence_type: isRecurring ? recurrenceType : null,
+ recurrence_interval: isRecurring && recurrenceType === "custom" ? recurrenceInterval : 1,
+ recurrence_end_date: isRecurring && recurrenceEndDate ? recurrenceEndDate : null,
+ }
+
+ updateMutation.mutate(data)
+ }
+
+ const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
+
+ Iif (!task) return null
+
+ 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 dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
+ <div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 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 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
+ >
+ <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 dark:text-gray-300 mb-1.5">
+ Название
+ </label>
+ <input
+ type="text"
+ value={title}
+ onChange={(e) => setTitle(e.target.value)}
+ className="input"
+ placeholder="Что нужно сделать?"
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 dark:text-gray-300 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 dark:bg-gray-800 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 dark:bg-gray-800 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 dark:bg-gray-800 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 dark:text-gray-500" size={18} />
+ <input
+ type="date"
+ value={dueDate}
+ onChange={(e) => setDueDate(e.target.value)}
+ className="input pl-10"
+ />
+ </div>
+ </div>
+
+ {/* Recurring Section */}
+ <div className="border-t border-gray-100 dark:border-gray-800 pt-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="flex items-center gap-2">
+ <Repeat size={18} className={isRecurring ? "text-primary-500" : "text-gray-400 dark:text-gray-500"} />
+ <label className="text-sm font-medium text-gray-700 dark:text-gray-300">Повторять</label>
+ </div>
+ <button
+ type="button"
+ onClick={() => setIsRecurring(!isRecurring)}
+ className={clsx(
+ "w-12 h-6 rounded-full transition-all relative",
+ isRecurring ? "bg-primary-500" : "bg-gray-200"
+ )}
+ >
+ <div className={clsx(
+ "absolute top-1 w-4 h-4 rounded-full bg-white dark:bg-gray-900 shadow-sm transition-all",
+ isRecurring ? "right-1" : "left-1"
+ )} />
+ </button>
+ </div>
+
+ <AnimatePresence>
+ {isRecurring && (
+ <motion.div
+ initial={{ opacity: 0, height: 0 }}
+ animate={{ opacity: 1, height: "auto" }}
+ exit={{ opacity: 0, height: 0 }}
+ className="space-y-3"
+ >
+ <div className="flex flex-wrap gap-2">
+ {RECURRENCE_TYPES.map((type) => (
+ <button
+ key={type.value}
+ type="button"
+ onClick={() => setRecurrenceType(type.value)}
+ className={clsx(
+ "px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
+ recurrenceType === type.value
+ ? "bg-primary-500 text-white"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+ )}
+ >
+ {type.label}
+ </button>
+ ))}
+ </div>
+
+ {recurrenceType === "custom" && (
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-gray-600">Каждые</span>
+ <input
+ type="number"
+ min="1"
+ max="365"
+ value={recurrenceInterval}
+ onChange={(e) => setRecurrenceInterval(parseInt(e.target.value) || 1)}
+ className="input w-20 text-center"
+ />
+ <span className="text-sm text-gray-600">дней</span>
+ </div>
+ )}
+
+ <div>
+ <label className="block text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1">
+ Повторять до (опционально)
+ </label>
+ <input
+ type="date"
+ value={recurrenceEndDate}
+ onChange={(e) => setRecurrenceEndDate(e.target.value)}
+ className="input"
+ min={dueDate || today}
+ />
+ </div>
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+ Напоминание (опционально)
+ </label>
+ <div className="relative">
+ <Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" 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 dark:text-gray-400 dark:text-gray-500 mt-1">
+ Получишь напоминание в Telegram в указанное время
+ </p>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+ )}
+ >
+ {p.label}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 dark:bg-gray-800 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 dark:text-gray-400 dark: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 dark:bg-gray-800 hover:bg-gray-200"
+ )}
+ >
+ {ic}
+ </button>
+ ))}
+ </div>
+ </div>
+ ))}
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 space-y-3">
+ <button
+ type="submit"
+ disabled={updateMutation.isPending}
+ className="btn btn-primary w-full"
+ >
+ {updateMutation.isPending ? "Сохраняем..." : "Сохранить"}
+ </button>
+
+ {!showDeleteConfirm ? (
+ <button
+ type="button"
+ onClick={() => setShowDeleteConfirm(true)}
+ className="btn w-full flex items-center justify-center gap-2 text-red-600 hover:bg-red-50"
+ >
+ <Trash2 size={18} />
+ Удалить задачу
+ </button>
+ ) : (
+ <div className="flex gap-2">
+ <button
+ type="button"
+ onClick={() => setShowDeleteConfirm(false)}
+ className="btn flex-1"
+ >
+ Отмена
+ </button>
+ <button
+ type="button"
+ onClick={() => deleteMutation.mutate()}
+ disabled={deleteMutation.isPending}
+ className="btn flex-1 bg-red-500 text-white hover:bg-red-600"
+ >
+ {deleteMutation.isPending ? "Удаляем..." : "Да, удалить"}
+ </button>
+ </div>
+ )}
+ </div>
+ </form>
+ </div>
+ </motion.div>
+ </>
+ )}
+ </AnimatePresence>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 | + + + + + + + +6x +6x +6x + +6x +6x +6x +6x + + + +6x +6x +6x + + + +6x + + +6x +155x + + +6x + + + + + +6x + + + + + + + + + + + + + +6x +6x + +6x + +5x + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +35x + + + + + + + + + +30x + + + + +155x +155x +155x +155x + +155x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState, useMemo } from 'react'
+import { motion, AnimatePresence } from 'framer-motion'
+import { X, ChevronLeft, ChevronRight, Check } from 'lucide-react'
+import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isFuture, startOfDay, subMonths, addMonths, isToday } from 'date-fns'
+import { ru } from 'date-fns/locale'
+import clsx from 'clsx'
+
+export default function LogHabitModal({ open, onClose, habit, completedDates = [], onLogDate }) {
+ const [currentMonth, setCurrentMonth] = useState(new Date())
+ const [selectedDate, setSelectedDate] = useState(null)
+ const [isLogging, setIsLogging] = useState(false)
+
+ const days = useMemo(() => {
+ const start = startOfMonth(currentMonth)
+ const end = endOfMonth(currentMonth)
+ return eachDayOfInterval({ start, end })
+ }, [currentMonth])
+
+ // Convert completedDates to a Set for faster lookup
+ const completedSet = useMemo(() => {
+ const set = new Set()
+ completedDates.forEach(d => {
+ const dateStr = typeof d === 'string' ? d.split('T')[0] : format(d, 'yyyy-MM-dd')
+ set.add(dateStr)
+ })
+ return set
+ }, [completedDates])
+
+ const isDateCompleted = (date) => {
+ return completedSet.has(format(date, 'yyyy-MM-dd'))
+ }
+
+ const handleDateClick = (date) => {
+ if (isFuture(startOfDay(date))) return
+ if (isDateCompleted(date)) return
+ setSelectedDate(date)
+ }
+
+ const handleConfirm = async () => {
+ if (!selectedDate) return
+ setIsLogging(true)
+ try {
+ await onLogDate(habit.id, format(selectedDate, 'yyyy-MM-dd'))
+ onClose()
+ } catch (error) {
+ console.error('Failed to log habit:', error)
+ } finally {
+ setIsLogging(false)
+ }
+ }
+
+ // Get first day of week offset
+ const firstDayOfMonth = startOfMonth(currentMonth)
+ const startOffset = (firstDayOfMonth.getDay() + 6) % 7 // Monday = 0
+
+ if (!open) return null
+
+ return (
+ <AnimatePresence>
+ <motion.div
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ exit={{ opacity: 0 }}
+ className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
+ onClick={onClose}
+ >
+ <motion.div
+ initial={{ opacity: 0, scale: 0.95, y: 20 }}
+ animate={{ opacity: 1, scale: 1, y: 0 }}
+ exit={{ opacity: 0, scale: 0.95, y: 20 }}
+ onClick={e => e.stopPropagation()}
+ className="bg-white dark:bg-gray-900 rounded-3xl shadow-2xl w-full max-w-sm overflow-hidden"
+ >
+ {/* Header */}
+ <div className="p-5 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ <div
+ className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
+ style={{ backgroundColor: habit?.color + '20' }}
+ >
+ {habit?.icon || '✨'}
+ </div>
+ <div>
+ <h2 className="text-lg font-display font-bold text-gray-900 dark:text-white">Отметить привычку</h2>
+ <p className="text-sm text-gray-500 dark:text-gray-400 dark:text-gray-500">{habit?.name}</p>
+ </div>
+ </div>
+ <button
+ onClick={onClose}
+ className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800 rounded-xl transition-colors"
+ >
+ <X size={20} />
+ </button>
+ </div>
+
+ {/* Calendar */}
+ <div className="p-5">
+ {/* Month navigation */}
+ <div className="flex items-center justify-between mb-4">
+ <button
+ onClick={() => setCurrentMonth(m => subMonths(m, 1))}
+ className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800 rounded-xl transition-colors"
+ >
+ <ChevronLeft size={20} />
+ </button>
+ <span className="font-semibold text-gray-900 dark:text-white capitalize">
+ {format(currentMonth, 'LLLL yyyy', { locale: ru })}
+ </span>
+ <button
+ onClick={() => setCurrentMonth(m => addMonths(m, 1))}
+ disabled={isSameDay(startOfMonth(currentMonth), startOfMonth(new Date()))}
+ className={clsx(
+ "p-2 rounded-xl transition-colors",
+ isSameDay(startOfMonth(currentMonth), startOfMonth(new Date()))
+ ? "text-gray-200 cursor-not-allowed"
+ : "text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
+ )}
+ >
+ <ChevronRight size={20} />
+ </button>
+ </div>
+
+ {/* Weekday headers */}
+ <div className="grid grid-cols-7 gap-1 mb-2">
+ {['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
+ <div key={day} className="text-center text-xs font-medium text-gray-400 dark:text-gray-500 py-2">
+ {day}
+ </div>
+ ))}
+ </div>
+
+ {/* Calendar grid */}
+ <div className="grid grid-cols-7 gap-1">
+ {/* Empty cells for offset */}
+ {Array.from({ length: startOffset }).map((_, i) => (
+ <div key={`offset-${i}`} className="aspect-square" />
+ ))}
+
+ {/* Days */}
+ {days.map(day => {
+ const completed = isDateCompleted(day)
+ const future = isFuture(startOfDay(day))
+ const selected = selectedDate && isSameDay(day, selectedDate)
+ const today = isToday(day)
+
+ return (
+ <button
+ key={day.toISOString()}
+ onClick={() => handleDateClick(day)}
+ disabled={future || completed}
+ className={clsx(
+ "aspect-square rounded-xl flex items-center justify-center text-sm font-medium transition-all",
+ future && "text-gray-200 cursor-not-allowed",
+ completed && "bg-green-100 text-green-600 cursor-default",
+ selected && !completed && "bg-primary-500 text-white shadow-lg shadow-primary-500/30",
+ !future && !completed && !selected && "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800",
+ today && !selected && !completed && "ring-2 ring-primary-200"
+ )}
+ >
+ {completed ? (
+ <Check size={16} className="text-green-600" />
+ ) : (
+ format(day, 'd')
+ )}
+ </button>
+ )
+ })}
+ </div>
+
+ {/* Selected date info */}
+ {selectedDate && (
+ <motion.div
+ initial={{ opacity: 0, y: 10 }}
+ animate={{ opacity: 1, y: 0 }}
+ className="mt-4 p-3 bg-primary-50 rounded-xl text-center"
+ >
+ <p className="text-sm text-primary-700">
+ Выбрано: <span className="font-semibold">{format(selectedDate, 'd MMMM yyyy', { locale: ru })}</span>
+ </p>
+ </motion.div>
+ )}
+ </div>
+
+ {/* Actions */}
+ <div className="p-5 pt-0 flex gap-3">
+ <button
+ onClick={onClose}
+ className="flex-1 py-3 px-4 rounded-xl font-semibold text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 transition-colors"
+ >
+ Отмена
+ </button>
+ <button
+ onClick={handleConfirm}
+ disabled={!selectedDate || isLogging}
+ className={clsx(
+ "flex-1 py-3 px-4 rounded-xl font-semibold text-white transition-all",
+ selectedDate && !isLogging
+ ? "bg-primary-500 hover:bg-primary-600 shadow-lg shadow-primary-500/30"
+ : "bg-gray-300 cursor-not-allowed"
+ )}
+ >
+ {isLogging ? 'Сохранение...' : 'Отметить'}
+ </button>
+ </div>
+ </motion.div>
+ </motion.div>
+ </AnimatePresence>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 | + + + + +1x + + +5x +5x + +5x + + + + + + +5x + + + + +20x + + + +20x + + + + + + + + + + + + + + + + + + | import { NavLink } from "react-router-dom"
+import { Home, BarChart3, PiggyBank, Settings } from "lucide-react"
+import { useAuthStore } from "../store/auth"
+import clsx from "clsx"
+
+const OWNER_ID = 1
+
+export default function Navigation() {
+ const user = useAuthStore((s) => s.user)
+ const isOwner = user?.id === OWNER_ID
+
+ const navItems = [
+ { to: "/", icon: Home, label: "Главная" },
+ { to: "/tracker", icon: BarChart3, label: "Трекер" },
+ { to: "/savings", icon: PiggyBank, label: "Накопления" },
+ { to: "/settings", icon: Settings, label: "Настройки" },
+ ].filter(Boolean)
+
+ return (
+ <nav className="fixed bottom-0 left-0 right-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-t border-gray-100 dark:border-gray-800 z-50 transition-colors duration-300">
+ <div className="max-w-lg mx-auto px-2">
+ <div className="flex items-center justify-around py-2">
+ {navItems.map(({ to, icon: Icon, label }) => (
+ <NavLink
+ key={to}
+ to={to}
+ end={to === "/"}
+ className={({ isActive }) =>
+ clsx(
+ "flex flex-col items-center gap-0.5 px-2 py-2 rounded-xl transition-all",
+ isActive
+ ? "text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30"
+ : "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
+ )
+ }
+ >
+ <Icon size={20} />
+ <span className="text-[10px] font-medium">{label}</span>
+ </NavLink>
+ ))}
+ </div>
+ </div>
+ </nav>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 | + + + + + +1x + + + + + +4x + + +5x +5x + +5x +3x +3x + + + +2x + + +5x +3x + + +9x + + + + + + + +2x +1x + + + + + + + + + + + + +1x +1x + + + +1x + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState, useEffect } from "react"
+import {
+ PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
+} from "recharts"
+import { financeApi } from "../../api/finance"
+
+const COLORS = [
+ "#0D4F4F", "#F7B538", "#6366f1", "#22c55e", "#ef4444",
+ "#8b5cf6", "#0ea5e9", "#f97316", "#ec4899", "#14b8a6",
+ "#64748b", "#a855f7", "#78716c",
+]
+
+const fmt = (n) => Number(n).toLocaleString("ru-RU") + " ₽"
+
+export default function FinanceDashboard({ month, year }) {
+ const [summary, setSummary] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ setLoading(true)
+ financeApi
+ .getSummary({ month, year })
+ .then(setSummary)
+ .catch(console.error)
+ .finally(() => setLoading(false))
+ }, [month, year])
+
+ if (loading) {
+ return (
+ <div className="space-y-4">
+ {[1, 2, 3].map((i) => (
+ <div key={i} className="card p-6 animate-pulse">
+ <div className="h-8 bg-gray-200 dark:bg-gray-800 rounded w-1/2" />
+ </div>
+ ))}
+ </div>
+ )
+ }
+
+ if (!summary || (summary.total_income === 0 && summary.total_expense === 0 && (summary.carried_over || 0) === 0)) {
+ return (
+ <div className="card p-12 text-center">
+ <span className="text-5xl block mb-4">📊</span>
+ <h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
+ Нет данных
+ </h3>
+ <p className="text-gray-500 dark:text-gray-400">
+ Добавьте первую транзакцию
+ </p>
+ </div>
+ )
+ }
+
+ const expenseCategories = summary.by_category.filter((c) => c.type === "expense")
+ const pieData = expenseCategories.map((c) => ({
+ name: c.category_emoji + " " + c.category_name,
+ value: c.amount,
+ }))
+ const dailyData = summary.daily.map((d) => ({
+ day: d.date.slice(8, 10),
+ amount: d.amount,
+ }))
+
+ return (
+ <div className="space-y-6">
+ <div className="card p-6 bg-gradient-to-br from-primary-950 to-primary-800 text-white">
+ {summary.carried_over !== 0 && (
+ <p className="text-xs opacity-60 mb-1">
+ Остаток с прошлого месяца: <span className={summary.carried_over > 0 ? "text-green-300" : "text-red-300"}>{fmt(summary.carried_over)}</span>
+ </p>
+ )}
+ <p className="text-sm opacity-70">Баланс</p>
+ <p className="text-3xl font-bold mt-1">{fmt(summary.balance)}</p>
+ <div className="flex gap-6 mt-4">
+ <div>
+ <p className="text-xs opacity-60">Доходы</p>
+ <p className="text-lg font-semibold text-green-300">
+ +{fmt(summary.total_income)}
+ </p>
+ </div>
+ <div>
+ <p className="text-xs opacity-60">Расходы</p>
+ <p className="text-lg font-semibold text-red-300">
+ -{fmt(summary.total_expense)}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {expenseCategories.length > 0 && (
+ <div className="card p-5">
+ <h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
+ Топ расходов
+ </h3>
+ <div className="space-y-3">
+ {expenseCategories.slice(0, 5).map((c, i) => (
+ <div key={i} className="flex items-center gap-3">
+ <span className="text-xl w-8 text-center">
+ {c.category_emoji}
+ </span>
+ <div className="flex-1">
+ <div className="flex justify-between mb-1">
+ <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
+ {c.category_name}
+ </span>
+ <span className="text-sm font-semibold text-gray-900 dark:text-white">
+ {fmt(c.amount)}
+ </span>
+ </div>
+ <div className="h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
+ <div
+ className="h-full rounded-full"
+ style={{
+ width: Math.round(c.percentage) + "%",
+ backgroundColor: COLORS[i % COLORS.length],
+ }}
+ />
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {pieData.length > 0 && (
+ <div className="card p-5">
+ <h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
+ По категориям
+ </h3>
+ <div className="flex items-center gap-4">
+ <div className="w-40 h-40">
+ <ResponsiveContainer>
+ <PieChart>
+ <Pie
+ data={pieData}
+ innerRadius={40}
+ outerRadius={70}
+ dataKey="value"
+ stroke="none"
+ >
+ {pieData.map((_, i) => (
+ <Cell key={i} fill={COLORS[i % COLORS.length]} />
+ ))}
+ </Pie>
+ <Tooltip formatter={(v) => fmt(v)} />
+ </PieChart>
+ </ResponsiveContainer>
+ </div>
+ <div className="flex-1 space-y-1">
+ {pieData.slice(0, 6).map((c, i) => (
+ <div key={i} className="flex items-center gap-2 text-xs">
+ <div
+ className="w-2.5 h-2.5 rounded-full"
+ style={{ backgroundColor: COLORS[i] }}
+ />
+ <span className="text-gray-600 dark:text-gray-400 truncate">
+ {c.name}
+ </span>
+ <span className="ml-auto font-medium text-gray-900 dark:text-white">
+ {Math.round(
+ (c.value / summary.total_expense) * 100
+ )}
+ %
+ </span>
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ )}
+
+ {dailyData.length > 0 && (
+ <div className="card p-5">
+ <h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
+ Расходы по дням
+ </h3>
+ <div className="h-48">
+ <ResponsiveContainer>
+ <LineChart data={dailyData}>
+ <XAxis dataKey="day" tick={{ fontSize: 12 }} stroke="#94a3b8" />
+ <YAxis
+ tick={{ fontSize: 10 }}
+ stroke="#94a3b8"
+ tickFormatter={(v) => v / 1000 + "к"}
+ />
+ <Tooltip formatter={(v) => fmt(v)} />
+ <Line
+ type="monotone"
+ dataKey="amount"
+ stroke="#0D4F4F"
+ strokeWidth={2}
+ dot={{ r: 4, fill: "#0D4F4F" }}
+ />
+ </LineChart>
+ </ResponsiveContainer>
+ </div>
+ </div>
+ )}
+ </div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 | + + +6x + +1x +6x +6x + + + +7x +7x +7x +7x +7x +7x + +7x +4x +4x + + + + + + + + +3x +3x + + +3x + + +7x +6x +6x +6x + +6x + + +7x +6x +6x +6x + + +7x + + + + + +7x +4x + + +16x + + + + + + + +3x + + + + + + + + + + + + + + +9x + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + + + + + + + + + +6x + + + + + +6x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState, useEffect } from "react"
+import { financeApi } from "../../api/finance"
+
+const fmt = (n) => Number(n).toLocaleString("ru-RU") + " ₽"
+
+const formatDate = (d) => {
+ const dt = new Date(d)
+ return dt.toLocaleDateString("ru-RU", { day: "numeric", month: "long" })
+}
+
+export default function TransactionList({ onAdd, month, year }) {
+ const [transactions, setTransactions] = useState([])
+ const [categories, setCategories] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [filter, setFilter] = useState("all")
+ const [catFilter, setCatFilter] = useState(null)
+ const [search, setSearch] = useState("")
+
+ useEffect(() => {
+ setLoading(true)
+ Promise.all([
+ financeApi.listCategories(),
+ financeApi.listTransactions({
+ month,
+ year,
+ limit: 100,
+ }),
+ ])
+ .then(([cats, txs]) => {
+ setCategories(cats || [])
+ setTransactions(txs || [])
+ })
+ .catch(console.error)
+ .finally(() => setLoading(false))
+ }, [month, year])
+
+ const filtered = transactions.filter((t) => {
+ Iif (filter !== "all" && t.type !== filter) return false
+ Iif (catFilter && t.category_id !== catFilter) return false
+ Iif (search && !t.description.toLowerCase().includes(search.toLowerCase()))
+ return false
+ return true
+ })
+
+ const grouped = filtered.reduce((acc, t) => {
+ const d = t.date.slice(0, 10)
+ ;(acc[d] = acc[d] || []).push(t)
+ return acc
+ }, {})
+
+ const handleDelete = async (id) => {
+ if (!confirm("Удалить транзакцию?")) return
+ await financeApi.deleteTransaction(id)
+ setTransactions((txs) => txs.filter((t) => t.id !== id))
+ }
+
+ if (loading) {
+ return (
+ <div className="space-y-3">
+ {[1, 2, 3, 4].map((i) => (
+ <div key={i} className="card p-4 animate-pulse">
+ <div className="h-5 bg-gray-200 dark:bg-gray-800 rounded w-3/4" />
+ </div>
+ ))}
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-4">
+ <input
+ className="w-full px-4 py-2.5 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white placeholder-gray-400 outline-none"
+ placeholder="Поиск по описанию..."
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ />
+
+ <div className="flex gap-2">
+ {[
+ ["all", "Все"],
+ ["income", "Доходы"],
+ ["expense", "Расходы"],
+ ].map(([k, l]) => (
+ <button
+ key={k}
+ onClick={() => setFilter(k)}
+ className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
+ filter === k
+ ? "bg-primary-500 text-white"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+ }`}
+ >
+ {l}
+ </button>
+ ))}
+ </div>
+
+ <div className="flex gap-2 overflow-x-auto pb-1">
+ <button
+ onClick={() => setCatFilter(null)}
+ className={`px-3 py-1 rounded-lg text-xs font-medium whitespace-nowrap transition ${
+ !catFilter
+ ? "bg-accent-500 text-white"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+ }`}
+ >
+ Все
+ </button>
+ {categories.map((c) => (
+ <button
+ key={c.id}
+ onClick={() => setCatFilter(c.id)}
+ className={`px-3 py-1 rounded-lg text-xs font-medium whitespace-nowrap transition ${
+ catFilter === c.id
+ ? "bg-accent-500 text-white"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+ }`}
+ >
+ {c.emoji} {c.name}
+ </button>
+ ))}
+ </div>
+
+ {Object.keys(grouped).length === 0 ? (
+ <div className="card p-12 text-center">
+ <span className="text-4xl block mb-3">🔍</span>
+ <p className="text-gray-500 dark:text-gray-400">Ничего не найдено</p>
+ </div>
+ ) : (
+ Object.entries(grouped).map(([date, txs]) => (
+ <div key={date}>
+ <p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">
+ {formatDate(date)}
+ </p>
+ <div className="card divide-y divide-gray-100 dark:divide-gray-800">
+ {txs.map((t) => (
+ <div
+ key={t.id}
+ className="px-4 py-3 flex items-center gap-3"
+ onClick={() => handleDelete(t.id)}
+ >
+ <span className="text-xl">{t.category_emoji}</span>
+ <div className="flex-1 min-w-0">
+ <p className="text-sm font-medium text-gray-900 dark:text-white truncate">
+ {t.description || t.category_name}
+ </p>
+ <p className="text-xs text-gray-500 dark:text-gray-400">
+ {t.category_emoji} {t.category_name}
+ </p>
+ </div>
+ <span
+ className={`text-sm font-bold ${
+ t.type === "income" ? "text-green-500" : "text-red-500"
+ }`}
+ >
+ {t.type === "income" ? "+" : "-"}
+ {fmt(t.amount)}
+ </span>
+ </div>
+ ))}
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| FinanceDashboard.jsx | +
+
+ |
+ 88.88% | +24/27 | +83.33% | +15/18 | +78.57% | +11/14 | +86.95% | +20/23 | +
| TransactionList.jsx | +
+
+ |
+ 71.42% | +35/49 | +61.11% | +22/36 | +61.9% | +13/21 | +79.06% | +34/43 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| CreateHabitModal.jsx | +
+
+ |
+ 65.82% | +52/79 | +49.15% | +29/59 | +28.57% | +8/28 | +65.82% | +52/79 | +
| CreateTaskModal.jsx | +
+
+ |
+ 71.05% | +54/76 | +54.09% | +33/61 | +32.14% | +9/28 | +71.05% | +54/76 | +
| EditHabitModal.jsx | +
+
+ |
+ 52.94% | +81/153 | +39.23% | +51/130 | +24.07% | +13/54 | +54% | +81/150 | +
| EditTaskModal.jsx | +
+
+ |
+ 68.88% | +62/90 | +57.44% | +54/94 | +37.14% | +13/35 | +69.66% | +62/89 | +
| LogHabitModal.jsx | +
+
+ |
+ 58.33% | +28/48 | +56.52% | +26/46 | +50% | +8/16 | +64.28% | +27/42 | +
| Navigation.jsx | +
+
+ |
+ 100% | +8/8 | +100% | +2/2 | +100% | +4/4 | +100% | +7/7 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 | + +2x + + +20x +15x +15x + + + + +20x +20x + +20x +12x + +8x + + +20x + + +20x +5x + + +20x + + + + + + + +46x +46x +4x + +41x + + | import { createContext, useContext, useEffect, useState } from "react"
+
+const ThemeContext = createContext()
+
+export function ThemeProvider({ children }) {
+ const [theme, setTheme] = useState(() => {
+ Eif (typeof window !== "undefined") {
+ return localStorage.getItem("theme") || "dark"
+ }
+ return "dark"
+ })
+
+ useEffect(() => {
+ const root = window.document.documentElement
+
+ if (theme === "dark") {
+ root.classList.add("dark")
+ } else {
+ root.classList.remove("dark")
+ }
+
+ localStorage.setItem("theme", theme)
+ }, [theme])
+
+ const toggleTheme = () => {
+ setTheme(prev => prev === "dark" ? "light" : "dark")
+ }
+
+ return (
+ <ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
+ {children}
+ </ThemeContext.Provider>
+ )
+}
+
+export function useTheme() {
+ const context = useContext(ThemeContext)
+ if (!context) {
+ throw new Error("useTheme must be used within ThemeProvider")
+ }
+ return context
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| ThemeContext.jsx | +
+
+ |
+ 94.73% | +18/19 | +90% | +9/10 | +100% | +6/6 | +94.44% | +17/18 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| api | +
+
+ |
+ 89.1% | +90/101 | +100% | +12/12 | +84.93% | +62/73 | +90% | +72/80 | +
| components | +
+
+ |
+ 62.77% | +285/454 | +49.74% | +195/392 | +33.33% | +55/165 | +63.88% | +283/443 | +
| components/finance | +
+
+ |
+ 77.63% | +59/76 | +68.51% | +37/54 | +68.57% | +24/35 | +81.81% | +54/66 | +
| contexts | +
+
+ |
+ 94.73% | +18/19 | +90% | +9/10 | +100% | +6/6 | +94.44% | +17/18 | +
| pages | +
+
+ |
+ 46.79% | +452/966 | +40.62% | +325/800 | +32.11% | +105/327 | +50.95% | +427/838 | +
| store | +
+
+ |
+ 100% | +25/25 | +100% | +2/2 | +100% | +5/5 | +100% | +24/24 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 | + + + + + + + +1x + + + + + + +1x + + + + + +14x +14x +14x +14x +14x +14x + +14x + +14x + + + +14x + + + + +14x + +14x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +56x + +4x + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + | import { useState } from "react"
+import Navigation from "../components/Navigation"
+import FinanceDashboard from "../components/finance/FinanceDashboard"
+import TransactionList from "../components/finance/TransactionList"
+import FinanceAnalytics from "../components/finance/FinanceAnalytics"
+import CategoriesManager from "../components/finance/CategoriesManager"
+import AddTransactionModal from "../components/finance/AddTransactionModal"
+
+const tabs = [
+ { key: "dashboard", label: "Обзор", icon: "📊" },
+ { key: "transactions", label: "Транзакции", icon: "📋" },
+ { key: "analytics", label: "Аналитика", icon: "📈" },
+ { key: "categories", label: "Категории", icon: "🏷️" },
+]
+
+const MONTH_NAMES = [
+ "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
+ "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
+]
+
+export default function Finance() {
+ const now = new Date()
+ const [activeTab, setActiveTab] = useState("dashboard")
+ const [showAdd, setShowAdd] = useState(false)
+ const [refreshKey, setRefreshKey] = useState(0)
+ const [month, setMonth] = useState(now.getMonth() + 1)
+ const [year, setYear] = useState(now.getFullYear())
+
+ const refresh = () => setRefreshKey((k) => k + 1)
+
+ const prevMonth = () => {
+ if (month === 1) { setMonth(12); setYear(y => y - 1) }
+ else setMonth(m => m - 1)
+ }
+ const nextMonth = () => {
+ if (month === 12) { setMonth(1); setYear(y => y + 1) }
+ else setMonth(m => m + 1)
+ }
+
+ const isCurrentMonth = month === now.getMonth() + 1 && year === now.getFullYear()
+
+ return (
+ <div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24">
+ <header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
+ <div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
+ <div>
+ <h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">
+ 💰 Финансы
+ </h1>
+ </div>
+ <button
+ onClick={() => setShowAdd(true)}
+ className="w-10 h-10 rounded-xl bg-primary-500 text-white flex items-center justify-center text-xl shadow-lg hover:bg-primary-600 transition"
+ >
+ +
+ </button>
+ </div>
+
+ {/* Month Switcher */}
+ <div className="max-w-lg mx-auto px-4 pb-3">
+ <div className="flex items-center justify-center gap-4">
+ <button
+ onClick={prevMonth}
+ className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 transition text-sm font-bold"
+ >
+ ←
+ </button>
+ <button
+ onClick={() => { setMonth(now.getMonth() + 1); setYear(now.getFullYear()) }}
+ className={"text-sm font-semibold min-w-[140px] text-center " + (isCurrentMonth ? "text-gray-900 dark:text-white" : "text-primary-600 dark:text-primary-400")}
+ >
+ {MONTH_NAMES[month - 1]} {year}
+ </button>
+ <button
+ onClick={nextMonth}
+ className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 transition text-sm font-bold"
+ >
+ →
+ </button>
+ </div>
+ </div>
+
+ <div className="max-w-lg mx-auto px-4 pb-3 flex gap-1.5 overflow-x-auto scrollbar-hide">
+ {tabs.map((t) => (
+ <button
+ key={t.key}
+ onClick={() => setActiveTab(t.key)}
+ className={`flex-1 min-w-0 py-2 rounded-xl text-xs sm:text-sm font-semibold transition whitespace-nowrap px-2 ${
+ activeTab === t.key
+ ? "bg-primary-500 text-white"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+ }`}
+ >
+ {t.icon} {t.label}
+ </button>
+ ))}
+ </div>
+ </header>
+
+ <div className="max-w-lg mx-auto px-4 py-6">
+ {activeTab === "dashboard" && <FinanceDashboard key={refreshKey + "-" + month + "-" + year} month={month} year={year} />}
+ {activeTab === "transactions" && (
+ <TransactionList key={refreshKey + "-" + month + "-" + year} month={month} year={year} onAdd={() => setShowAdd(true)} />
+ )}
+ {activeTab === "analytics" && <FinanceAnalytics key={refreshKey + "-" + month + "-" + year} month={month} year={year} />}
+ {activeTab === "categories" && <CategoriesManager refreshKey={refreshKey} />}
+ </div>
+
+ {showAdd && (
+ <AddTransactionModal
+ onClose={() => setShowAdd(false)}
+ onSaved={() => {
+ setShowAdd(false)
+ refresh()
+ }}
+ />
+ )}
+
+ <Navigation />
+ </div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 | + + + + + + +22x +22x +22x +22x + +22x +5x +5x +5x + +5x +5x +3x + +2x + +5x + + + +22x +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +19x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +5x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState } from 'react'
+import { Link } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import { Mail, ArrowLeft, Zap, CheckCircle } from 'lucide-react'
+import api from '../api/client'
+
+export default function ForgotPassword() {
+ const [email, setEmail] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [sent, setSent] = useState(false)
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ setError('')
+ setLoading(true)
+
+ try {
+ await api.post('/auth/forgot-password', { email })
+ setSent(true)
+ } catch (err) {
+ setError(err.response?.data?.error || 'Ошибка отправки')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (sent) {
+ return (
+ <div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50">
+ <motion.div
+ initial={{ opacity: 0, scale: 0.9 }}
+ animate={{ opacity: 1, scale: 1 }}
+ className="w-full max-w-md"
+ >
+ <div className="card p-10 text-center">
+ <motion.div
+ initial={{ scale: 0 }}
+ animate={{ scale: 1 }}
+ transition={{ type: 'spring', stiffness: 200 }}
+ className="w-20 h-20 rounded-3xl bg-green-100 flex items-center justify-center mx-auto mb-6"
+ >
+ <CheckCircle className="w-10 h-10 text-green-600" />
+ </motion.div>
+ <h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
+ Письмо отправлено! 📬
+ </h1>
+ <p className="text-gray-500 mb-6">
+ Если аккаунт с email <strong>{email}</strong> существует, мы отправили ссылку для сброса пароля.
+ </p>
+ <Link to="/login" className="btn btn-primary">
+ Вернуться ко входу
+ </Link>
+ </div>
+ </motion.div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50">
+ <motion.div
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ className="w-full max-w-md"
+ >
+ <div className="text-center mb-8">
+ <motion.div
+ initial={{ scale: 0 }}
+ animate={{ scale: 1 }}
+ transition={{ type: 'spring', delay: 0.1 }}
+ className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-700 mb-6 shadow-xl shadow-primary-500/30"
+ >
+ <Mail className="w-10 h-10 text-white" />
+ </motion.div>
+ <h1 className="text-3xl font-display font-bold text-gray-900">
+ Забыли пароль?
+ </h1>
+ <p className="text-gray-500 mt-2">
+ Введи email и мы отправим ссылку для сброса
+ </p>
+ </div>
+
+ <div className="card p-8">
+ <form onSubmit={handleSubmit} className="space-y-5">
+ {error && (
+ <motion.div
+ initial={{ opacity: 0, height: 0 }}
+ animate={{ opacity: 1, height: 'auto' }}
+ className="p-4 rounded-2xl bg-red-50 text-red-600 text-sm font-medium"
+ >
+ {error}
+ </motion.div>
+ )}
+
+ <div>
+ <label className="block text-sm font-semibold text-gray-700 mb-2">
+ Email
+ </label>
+ <input
+ type="email"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ className="input"
+ placeholder="your@email.com"
+ required
+ autoFocus
+ />
+ </div>
+
+ <button
+ type="submit"
+ disabled={loading}
+ className="btn btn-primary w-full text-lg"
+ >
+ {loading ? 'Отправляем...' : 'Отправить ссылку'}
+ </button>
+ </form>
+
+ <div className="mt-6 text-center">
+ <Link
+ to="/login"
+ className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700 font-medium text-sm"
+ >
+ <ArrowLeft size={16} />
+ Вернуться ко входу
+ </Link>
+ </div>
+ </div>
+
+ <div className="flex items-center justify-center gap-2 mt-6 text-gray-400">
+ <Zap size={16} />
+ <span className="text-sm font-medium">Pulse</span>
+ </div>
+ </motion.div>
+ </div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 | + + + + + + + + + + + + +11x +11x +11x +11x +11x + +11x + +10x + + +11x + + + + + +11x +9x + + +11x +2x +2x +4x +4x +4x + + +2x + + +11x + + + + + + + + +11x +6x +3x +3x +15x + + + + + + +11x +11x + +11x + + + + + + + +1x + + + + + + + + + + +18x + + + + + + + + + + + + + + + + + + + + + + + + + + + +6x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +6x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState, useEffect } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { motion, AnimatePresence } from 'framer-motion'
+import { Plus, Settings, Flame, Calendar, ChevronRight, Archive, ArchiveRestore } from 'lucide-react'
+import { format } from 'date-fns'
+import { ru } from 'date-fns/locale'
+import { habitsApi } from '../api/habits'
+import CreateHabitModal from '../components/CreateHabitModal'
+import EditHabitModal from '../components/EditHabitModal'
+import Navigation from '../components/Navigation'
+import clsx from 'clsx'
+
+export default function Habits({ embedded = false }) {
+ const [showCreateModal, setShowCreateModal] = useState(false)
+ const [editingHabit, setEditingHabit] = useState(null)
+ const [showArchived, setShowArchived] = useState(false)
+ const [habitStats, setHabitStats] = useState({})
+ const queryClient = useQueryClient()
+
+ const { data: habits = [], isLoading } = useQuery({
+ queryKey: ['habits', showArchived],
+ queryFn: () => habitsApi.list().then(h => showArchived ? h : h.filter(x => !x.is_archived)),
+ })
+
+ const { data: archivedHabits = [] } = useQuery({
+ queryKey: ['habits-archived'],
+ queryFn: () => habitsApi.list().then(h => h.filter(x => x.is_archived)),
+ enabled: showArchived,
+ })
+
+ useEffect(() => {
+ if (habits.length > 0) loadStats()
+ }, [habits])
+
+ const loadStats = async () => {
+ const statsMap = {}
+ await Promise.all(habits.map(async (habit) => {
+ try {
+ const stats = await habitsApi.getHabitStats(habit.id)
+ statsMap[habit.id] = stats
+ } catch (e) {}
+ }))
+ setHabitStats(statsMap)
+ }
+
+ const archiveMutation = useMutation({
+ mutationFn: ({ id, archived }) => habitsApi.update(id, { is_archived: archived }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['habits'] })
+ queryClient.invalidateQueries({ queryKey: ['habits-archived'] })
+ queryClient.invalidateQueries({ queryKey: ['stats'] })
+ },
+ })
+
+ const getFrequencyLabel = (habit) => {
+ if (habit.frequency === 'daily') return 'Ежедневно'
+ Eif (habit.frequency === 'weekly' && habit.target_days) {
+ const days = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
+ return habit.target_days.map(d => days[d - 1]).join(', ')
+ }
+ if (habit.frequency === 'interval') return `Каждые ${habit.target_count} дн.`
+ if (habit.frequency === 'custom') return `Каждые ${habit.target_count} дн.`
+ return habit.frequency
+ }
+
+ const activeHabits = habits.filter(h => !h.is_archived)
+ const archivedList = habits.filter(h => h.is_archived)
+
+ return (
+ <div className={embedded ? "" : "min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300"}>
+ {!embedded && <header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
+ <div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
+ <div>
+ <h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Мои привычки</h1>
+ <p className="text-sm text-gray-500 dark:text-gray-400">{activeHabits.length} активных</p>
+ </div>
+ <button onClick={() => setShowCreateModal(true)} className="btn btn-primary flex items-center gap-2">
+ <Plus size={18} />
+ Новая
+ </button>
+ </div>
+ </header>}
+
+ <main className="max-w-lg mx-auto px-4 py-6 space-y-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-14 h-14 rounded-2xl bg-gray-200 dark:bg-gray-700" />
+ <div className="flex-1">
+ <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/2 mb-2" />
+ <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/3" />
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : activeHabits.length === 0 && !showArchived ? (
+ <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-accent-100 dark:from-primary-900/30 dark:to-accent-900/30 flex items-center justify-center mx-auto mb-5">
+ <Plus className="w-10 h-10 text-primary-600 dark:text-primary-400" />
+ </div>
+ <h3 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-2">Нет привычек</h3>
+ <p className="text-gray-500 dark:text-gray-400 mb-6">Создай свою первую привычку!</p>
+ <button onClick={() => setShowCreateModal(true)} className="btn btn-primary">
+ <Plus size={20} className="mr-2" />
+ Создать привычку
+ </button>
+ </motion.div>
+ ) : (
+ <>
+ <div className="space-y-3">
+ <AnimatePresence>
+ {activeHabits.map((habit, index) => (
+ <HabitListItem
+ key={habit.id}
+ habit={habit}
+ index={index}
+ stats={habitStats[habit.id]}
+ frequencyLabel={getFrequencyLabel(habit)}
+ onEdit={() => setEditingHabit(habit)}
+ onArchive={() => archiveMutation.mutate({ id: habit.id, archived: true })}
+ />
+ ))}
+ </AnimatePresence>
+ </div>
+
+ {archivedList.length > 0 && (
+ <div className="mt-8">
+ <button onClick={() => setShowArchived(!showArchived)} className="flex items-center gap-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 mb-4">
+ <Archive size={18} />
+ <span className="font-medium">Архив ({archivedList.length})</span>
+ <ChevronRight size={18} className={clsx('transition-transform', showArchived && 'rotate-90')} />
+ </button>
+
+ <AnimatePresence>
+ {showArchived && (
+ <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} className="space-y-3">
+ {archivedList.map((habit, index) => (
+ <motion.div
+ key={habit.id}
+ initial={{ opacity: 0, y: 10 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ delay: index * 0.05 }}
+ className="card p-4 opacity-60"
+ >
+ <div className="flex items-center gap-4">
+ <div className="w-12 h-12 rounded-xl flex items-center justify-center text-xl" style={{ backgroundColor: habit.color + '20' }}>
+ {habit.icon || '✨'}
+ </div>
+ <div className="flex-1 min-w-0">
+ <h3 className="font-semibold text-gray-600 dark:text-gray-400 truncate">{habit.name}</h3>
+ <p className="text-sm text-gray-400 dark:text-gray-500">{getFrequencyLabel(habit)}</p>
+ </div>
+ <button onClick={() => archiveMutation.mutate({ id: habit.id, archived: false })} className="p-2 text-gray-400 hover:text-green-500 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-xl transition-all" title="Восстановить">
+ <ArchiveRestore size={20} />
+ </button>
+ </div>
+ </motion.div>
+ ))}
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </div>
+ )}
+ </>
+ )}
+ </main>
+
+ {!embedded && <Navigation />}
+ <CreateHabitModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
+ <EditHabitModal open={!!editingHabit} onClose={() => setEditingHabit(null)} habit={editingHabit} />
+ </div>
+ )
+}
+
+function HabitListItem({ habit, index, stats, frequencyLabel, onEdit, onArchive }) {
+ return (
+ <motion.div
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ exit={{ opacity: 0, x: -100 }}
+ transition={{ delay: index * 0.05 }}
+ onClick={onEdit}
+ className="card p-4 cursor-pointer hover:shadow-lg transition-all"
+ >
+ <div className="flex items-center gap-4">
+ <div className="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0" style={{ backgroundColor: habit.color + '15' }}>
+ {habit.icon || '✨'}
+ </div>
+
+ <div className="flex-1 min-w-0">
+ <h3 className="font-semibold text-gray-900 dark:text-white truncate">{habit.name}</h3>
+ <div className="flex items-center gap-3 mt-1">
+ <span className="text-xs font-medium px-2 py-0.5 rounded-full" style={{ backgroundColor: habit.color + '15', color: habit.color }}>
+ {frequencyLabel}
+ </span>
+ {stats && stats.current_streak > 0 && (
+ <span className="text-xs text-orange-500 flex items-center gap-1">
+ <Flame size={14} />
+ {stats.current_streak} дн.
+ </span>
+ )}
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {stats && (
+ <div className="text-right">
+ <p className="text-sm font-semibold text-gray-900 dark:text-white">{stats.this_month}</p>
+ <p className="text-xs text-gray-400 dark:text-gray-500">в месяц</p>
+ </div>
+ )}
+ <ChevronRight size={20} className="text-gray-300 dark:text-gray-600" />
+ </div>
+ </div>
+ </motion.div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +3x +3x +3x +3x +3x +3x +3x +3x + +3x + + + + +3x + + + + +3x + + + + + +3x +3x + + + + + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + + + + + + + + +3x + + + + + + + + + + + + + +3x + + + + + + + + + + + + + +3x + + + + + + + +3x + + + + + + + +3x + + + + + + + +3x + + + +3x + + + + +3x +3x + + +3x +3x +3x + + +3x +3x +3x +3x + +3x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +9x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState, useEffect, useMemo, useRef } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { motion, AnimatePresence } from 'framer-motion'
+import { Check, Flame, TrendingUp, Zap, Sparkles, Undo2, Plus, Calendar, AlertTriangle, LogOut, Snowflake } from 'lucide-react'
+import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, isPast, startOfDay, isBefore, isAfter } from 'date-fns'
+import { ru } from 'date-fns/locale'
+import { habitsApi } from '../api/habits'
+import { tasksApi } from '../api/tasks'
+import { useAuthStore } from '../store/auth'
+import Navigation from '../components/Navigation'
+import CreateTaskModal from '../components/CreateTaskModal'
+import LogHabitModal from '../components/LogHabitModal'
+import clsx from 'clsx'
+
+// Check if habit is frozen on a specific date
+function isHabitFrozenOnDate(habit, freezes, date) {
+ if (!freezes || freezes.length === 0) return false
+ const checkDate = startOfDay(date)
+ return freezes.some(freeze => {
+ const start = startOfDay(parseISO(freeze.start_date))
+ const end = startOfDay(parseISO(freeze.end_date))
+ return !isBefore(checkDate, start) && !isAfter(checkDate, end)
+ })
+}
+
+// Определение "сегодняшних" привычек
+function shouldShowToday(habit, lastLogDate, freezes) {
+ const today = startOfDay(new Date())
+ const dayOfWeek = today.getDay() || 7
+
+ if (isHabitFrozenOnDate(habit, freezes, today)) return false
+
+ const startDate = habit.start_date
+ ? startOfDay(parseISO(habit.start_date))
+ : startOfDay(parseISO(habit.created_at))
+
+ if (today < startDate) return false
+
+ if (habit.frequency === "daily") return true
+
+ if (habit.frequency === "weekly") {
+ if (habit.target_days && habit.target_days.length > 0) {
+ return habit.target_days.includes(dayOfWeek)
+ }
+ if (!lastLogDate) return true
+ const weekStart = startOfWeek(today, { weekStartsOn: 1 })
+ const lastLog = typeof lastLogDate === "string" ? parseISO(lastLogDate) : lastLogDate
+ return lastLog < weekStart
+ }
+
+ if (habit.frequency === "interval" && habit.target_count > 0) {
+ const daysSinceStart = differenceInDays(today, startDate)
+ return daysSinceStart % habit.target_count === 0
+ }
+
+ if (habit.frequency === "custom" && habit.target_count > 0) {
+ if (!lastLogDate) return today >= startDate
+ const lastLog = typeof lastLogDate === "string" ? parseISO(lastLogDate) : lastLogDate
+ const daysSinceLastLog = differenceInDays(today, startOfDay(lastLog))
+ return daysSinceLastLog >= habit.target_count
+ }
+
+ return true
+}
+
+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 Home() {
+ const [todayLogs, setTodayLogs] = useState({})
+ const [lastLogDates, setLastLogDates] = useState({})
+ const [habitFreezes, setHabitFreezes] = useState({})
+ const [habitLogs, setHabitLogs] = useState({})
+ const [showCreateTask, setShowCreateTask] = useState(false)
+ const [logHabitModal, setLogHabitModal] = useState({ open: false, habit: null })
+ const queryClient = useQueryClient()
+ const { user, logout } = useAuthStore()
+
+ const { data: habits = [], isLoading: habitsLoading } = useQuery({
+ queryKey: ['habits'],
+ queryFn: habitsApi.list,
+ })
+
+ const { data: stats } = useQuery({
+ queryKey: ['stats'],
+ queryFn: habitsApi.getStats,
+ })
+
+ const { data: todayTasks = [], isLoading: tasksLoading } = useQuery({
+ queryKey: ['tasks-today'],
+ queryFn: tasksApi.today,
+ })
+
+
+ useEffect(() => {
+ Iif (habits.length > 0) {
+ loadTodayLogs()
+ loadHabitFreezes()
+ }
+ }, [habits])
+
+ const loadTodayLogs = async () => {
+ const today = format(new Date(), 'yyyy-MM-dd')
+ const logsMap = {}
+ const lastDates = {}
+ const allLogs = {}
+
+ await Promise.all(habits.map(async (habit) => {
+ try {
+ const logs = await habitsApi.getLogs(habit.id, 90)
+ allLogs[habit.id] = logs.map(l => l.date)
+
+ if (logs.length > 0) {
+ const lastLog = logs[0]
+ const logDate = lastLog.date.split('T')[0]
+ lastDates[habit.id] = logDate
+ if (logDate === today) logsMap[habit.id] = lastLog.id
+ }
+ } catch (e) {
+ console.error('Error loading logs for habit', habit.id, e)
+ }
+ }))
+
+ setTodayLogs(logsMap)
+ setLastLogDates(lastDates)
+ setHabitLogs(allLogs)
+ }
+
+ const loadHabitFreezes = async () => {
+ const freezesMap = {}
+ await Promise.all(habits.map(async (habit) => {
+ try {
+ const freezes = await habitsApi.getFreezes(habit.id)
+ freezesMap[habit.id] = freezes
+ } catch (e) {
+ freezesMap[habit.id] = []
+ }
+ }))
+ setHabitFreezes(freezesMap)
+ }
+
+ const logMutation = useMutation({
+ mutationFn: ({ habitId, date }) => habitsApi.log(habitId, date ? { date } : {}),
+ onSuccess: (data, { habitId, date }) => {
+ const logDate = date || format(new Date(), 'yyyy-MM-dd')
+ const today = format(new Date(), 'yyyy-MM-dd')
+
+ if (logDate === today) setTodayLogs(prev => ({ ...prev, [habitId]: data.id }))
+ setLastLogDates(prev => ({ ...prev, [habitId]: logDate }))
+ setHabitLogs(prev => ({ ...prev, [habitId]: [...(prev[habitId] || []), logDate] }))
+ queryClient.invalidateQueries({ queryKey: ['habits'] })
+ queryClient.invalidateQueries({ queryKey: ['stats'] })
+ },
+ })
+
+ const deleteLogMutation = useMutation({
+ mutationFn: ({ habitId, logId }) => habitsApi.deleteLog(habitId, logId),
+ onSuccess: (_, { habitId }) => {
+ setTodayLogs(prev => {
+ const newLogs = { ...prev }
+ delete newLogs[habitId]
+ return newLogs
+ })
+ loadTodayLogs()
+ queryClient.invalidateQueries({ queryKey: ['habits'] })
+ queryClient.invalidateQueries({ queryKey: ['stats'] })
+ },
+ })
+
+ const completeTaskMutation = useMutation({
+ mutationFn: (id) => tasksApi.complete(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
+ queryClient.invalidateQueries({ queryKey: ['tasks'] })
+ },
+ })
+
+ const uncompleteTaskMutation = useMutation({
+ mutationFn: (id) => tasksApi.uncomplete(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
+ queryClient.invalidateQueries({ queryKey: ['tasks'] })
+ },
+ })
+
+ const handleToggleComplete = (habitId) => {
+ if (todayLogs[habitId]) {
+ deleteLogMutation.mutate({ habitId, logId: todayLogs[habitId] })
+ } else {
+ logMutation.mutate({ habitId })
+ }
+ }
+
+ const handleLogHabitDate = async (habitId, date) => {
+ await logMutation.mutateAsync({ habitId, date })
+ }
+
+ const handleToggleTask = (task) => {
+ if (task.completed) uncompleteTaskMutation.mutate(task.id)
+ else completeTaskMutation.mutate(task.id)
+ }
+
+ const todayHabits = useMemo(() => {
+ return habits.filter(habit => shouldShowToday(habit, lastLogDates[habit.id], habitFreezes[habit.id]))
+ }, [habits, lastLogDates, habitFreezes])
+
+ const frozenHabits = useMemo(() => {
+ const today = startOfDay(new Date())
+ return habits.filter(habit => isHabitFrozenOnDate(habit, habitFreezes[habit.id], today))
+ }, [habits, habitFreezes])
+
+ const completedCount = Object.keys(todayLogs).length
+ const totalToday = todayHabits.length
+ const today = format(new Date(), 'EEEE, d MMMM', { locale: ru })
+ const activeTasks = todayTasks.filter(t => !t.completed)
+
+ return (
+ <div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300">
+ <header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
+ <div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
+ <Zap className="w-5 h-5 text-white" />
+ </div>
+ <div>
+ <h1 className="text-lg font-display font-bold text-gray-900 dark:text-white">
+ Привет, {user?.username}!
+ </h1>
+ <p className="text-sm text-gray-500 dark:text-gray-400 capitalize">{today}</p>
+ </div>
+ </div>
+ <button
+ onClick={logout}
+ className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
+ title="Выйти"
+ >
+ <LogOut size={20} />
+ </button>
+ </div>
+ </header>
+
+ <main className="max-w-lg mx-auto px-4 py-6 space-y-6">
+ {/* Progress */}
+ <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-5">
+ <div className="flex items-center justify-between mb-3">
+ <h2 className="font-semibold text-gray-900 dark:text-white">Прогресс на сегодня</h2>
+ <span className="text-sm font-medium text-primary-600 dark:text-primary-400">{completedCount} / {totalToday}</span>
+ </div>
+ <div className="h-3 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
+ <motion.div
+ initial={{ width: 0 }}
+ animate={{ width: totalToday > 0 ? `${(completedCount / totalToday) * 100}%` : '0%' }}
+ transition={{ duration: 0.5, ease: 'easeOut' }}
+ className="h-full bg-gradient-to-r from-primary-500 to-accent-500 rounded-full"
+ />
+ </div>
+ {completedCount === totalToday && totalToday > 0 && (
+ <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-sm text-green-600 dark:text-green-400 mt-2 font-medium">
+ 🎉 Все привычки выполнены!
+ </motion.p>
+ )}
+ {frozenHabits.length > 0 && (
+ <div className="flex items-center gap-2 mt-2 text-sm text-cyan-600 dark:text-cyan-400">
+ <Snowflake size={14} />
+ <span>{frozenHabits.length} привычек на паузе</span>
+ </div>
+ )}
+ </motion.div>
+
+ {/* Stats */}
+ {stats && (
+ <div className="grid grid-cols-2 gap-4">
+ <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-5">
+ <div className="flex items-center gap-4">
+ <div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-500 flex items-center justify-center shadow-lg shadow-accent-400/20">
+ <Flame className="w-6 h-6 text-white" />
+ </div>
+ <div>
+ <p className="text-3xl font-display font-bold text-gray-900 dark:text-white">{stats.today_completed}</p>
+ <p className="text-sm text-gray-500 dark:text-gray-400">Выполнено</p>
+ </div>
+ </div>
+ </motion.div>
+
+ <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="card p-5">
+ <div className="flex items-center gap-4">
+ <div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
+ <TrendingUp className="w-6 h-6 text-white" />
+ </div>
+ <div>
+ <p className="text-3xl font-display font-bold text-gray-900 dark:text-white">{stats.active_habits}</p>
+ <p className="text-sm text-gray-500 dark:text-gray-400">Активных</p>
+ </div>
+ </div>
+ </motion.div>
+ </div>
+ )}
+
+ {/* Tasks */}
+
+ {(activeTasks.length > 0 || !tasksLoading) && (
+ <div>
+ <div className="flex items-center justify-between mb-4">
+ <h2 className="text-xl font-display font-bold text-gray-900 dark:text-white">Задачи на сегодня</h2>
+ <button
+ onClick={() => setShowCreateTask(true)}
+ className="p-2 bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-xl hover:bg-primary-200 dark:hover:bg-primary-800/40 transition-colors"
+ >
+ <Plus size={18} />
+ </button>
+ </div>
+
+ {tasksLoading ? (
+ <div className="card p-5 animate-pulse">
+ <div className="flex items-center gap-4">
+ <div className="w-10 h-10 rounded-xl bg-gray-200 dark:bg-gray-700" />
+ <div className="flex-1">
+ <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/4 mb-2" />
+ <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/4" />
+ </div>
+ </div>
+ </div>
+ ) : activeTasks.length === 0 ? (
+ <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="card p-6 text-center">
+ <p className="text-gray-500 dark:text-gray-400">Нет задач на сегодня</p>
+ <button onClick={() => setShowCreateTask(true)} className="mt-3 text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 font-medium">
+ + Добавить задачу
+ </button>
+ </motion.div>
+ ) : (
+ <div className="space-y-3">
+ <AnimatePresence>
+ {activeTasks.map((task, index) => (
+ <TaskCard key={task.id} task={task} index={index} onToggle={() => handleToggleTask(task)} isLoading={completeTaskMutation.isPending || uncompleteTaskMutation.isPending} />
+ ))}
+ </AnimatePresence>
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* Habits */}
+ <div>
+ <h2 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-5">Привычки</h2>
+
+ {habitsLoading ? (
+ <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-14 h-14 rounded-2xl bg-gray-200 dark:bg-gray-700" />
+ <div className="flex-1">
+ <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/2 mb-2" />
+ <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/3" />
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : todayHabits.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-green-100 to-green-200 dark:from-green-900/30 dark:to-green-800/30 flex items-center justify-center mx-auto mb-5">
+ <Sparkles className="w-10 h-10 text-green-600 dark:text-green-400" />
+ </div>
+ <h3 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-2">Свободный день!</h3>
+ <p className="text-gray-500 dark:text-gray-400">На сегодня нет запланированных привычек.</p>
+ </motion.div>
+ ) : (
+ <div className="space-y-4">
+ <AnimatePresence>
+ {todayHabits.map((habit, index) => (
+ <HabitCard
+ key={habit.id}
+ habit={habit}
+ index={index}
+ isCompleted={!!todayLogs[habit.id]}
+ onToggle={() => handleToggleComplete(habit.id)}
+ onLongPress={() => setLogHabitModal({ open: true, habit })}
+ isLoading={logMutation.isPending || deleteLogMutation.isPending}
+ />
+ ))}
+ </AnimatePresence>
+ </div>
+ )}
+ </div>
+ </main>
+
+ <Navigation />
+ <CreateTaskModal open={showCreateTask} onClose={() => setShowCreateTask(false)} />
+ <LogHabitModal
+ open={logHabitModal.open}
+ onClose={() => setLogHabitModal({ open: false, habit: null })}
+ habit={logHabitModal.habit}
+ completedDates={habitLogs[logHabitModal.habit?.id] || []}
+ onLogDate={handleLogHabitDate}
+ />
+ </div>
+ )
+}
+
+function TaskCard({ task, index, onToggle, isLoading }) {
+ const [showConfetti, setShowConfetti] = useState(false)
+ 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-center 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',
+ 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">
+ <h3 className={clsx("font-semibold truncate", task.completed ? "text-gray-400 line-through" : "text-gray-900 dark:text-white")}>{task.title}</h3>
+ {(dueDateLabel || isOverdue) && (
+ <span className={clsx('inline-flex items-center gap-1 text-xs font-medium mt-1', isOverdue ? 'text-red-600' : 'text-gray-500 dark:text-gray-400')}>
+ {isOverdue && <AlertTriangle size={12} />}
+ <Calendar size={12} />
+ {dueDateLabel}
+ </span>
+ )}
+ </div>
+
+ {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 dark:hover:bg-orange-900/20 rounded-xl transition-all" title="Отменить">
+ <Undo2 size={18} />
+ </motion.button>
+ )}
+ </div>
+ </motion.div>
+ )
+}
+
+function HabitCard({ habit, index, isCompleted, onToggle, onLongPress, isLoading }) {
+ const [showConfetti, setShowConfetti] = useState(false)
+ const longPressTimer = useRef(null)
+ const isLongPress = useRef(false)
+
+ const handleTouchStart = () => {
+ isLongPress.current = false
+ longPressTimer.current = setTimeout(() => { isLongPress.current = true; onLongPress() }, 500)
+ }
+
+ const handleTouchEnd = () => { if (longPressTimer.current) clearTimeout(longPressTimer.current) }
+
+ const handleCheck = (e) => {
+ e.stopPropagation()
+ if (isLoading || isLongPress.current) return
+ if (!isCompleted) { setShowConfetti(true); setTimeout(() => setShowConfetti(false), 1000) }
+ onToggle()
+ }
+
+ const handleContextMenu = (e) => { e.preventDefault(); onLongPress() }
+
+ 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-5 relative overflow-hidden"
+ onContextMenu={handleContextMenu}
+ >
+ {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: habit.color }}
+ />
+ ))}
+ </motion.div>
+ )}
+
+ <div className="flex items-center gap-4">
+ <motion.button
+ onClick={handleCheck}
+ onTouchStart={handleTouchStart}
+ onTouchEnd={handleTouchEnd}
+ onMouseDown={handleTouchStart}
+ onMouseUp={handleTouchEnd}
+ onMouseLeave={handleTouchEnd}
+ disabled={isLoading}
+ whileTap={{ scale: 0.9 }}
+ className={clsx(
+ 'w-14 h-14 rounded-2xl flex items-center justify-center transition-all duration-300 relative flex-shrink-0',
+ isCompleted
+ ? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
+ : 'border-2 hover:shadow-md'
+ )}
+ style={{ borderColor: isCompleted ? undefined : habit.color + '40', backgroundColor: isCompleted ? undefined : habit.color + '10' }}
+ >
+ {isCompleted ? (
+ <motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500 }}>
+ <Check className="w-7 h-7 text-white" strokeWidth={3} />
+ </motion.div>
+ ) : (
+ <span className="text-2xl">{habit.icon || '✨'}</span>
+ )}
+ </motion.button>
+
+ <div className="flex-1 min-w-0">
+ <h3 className={clsx("font-semibold text-lg truncate", isCompleted ? "text-gray-400 line-through" : "text-gray-900 dark:text-white")}>{habit.name}</h3>
+ {habit.description && <p className="text-sm text-gray-500 dark:text-gray-400 truncate">{habit.description}</p>}
+ </div>
+
+ <div className="flex items-center gap-2">
+ <button onClick={(e) => { e.stopPropagation(); onLongPress() }} className="p-2 text-gray-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-xl transition-all" title="Отметить за другой день">
+ <Calendar size={20} />
+ </button>
+ {isCompleted && (
+ <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 dark:hover:bg-orange-900/20 rounded-xl transition-all" title="Отменить">
+ <Undo2 size={20} />
+ </motion.button>
+ )}
+ </div>
+ </div>
+ </motion.div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 | + + + + + + +22x +22x +22x +22x +22x + +22x +22x + +22x +3x +3x +3x + +3x +3x +1x + +2x + +3x + + + +22x + + + + + + + + + + + + + + + + + + + + + + + + +3x + + + + + +3x +2x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import { Eye, EyeOff, Zap } from 'lucide-react'
+import { useAuthStore } from '../store/auth'
+
+export default function Login() {
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [showPassword, setShowPassword] = useState(false)
+ const [error, setError] = useState('')
+ const [loading, setLoading] = useState(false)
+
+ const login = useAuthStore(s => s.login)
+ const navigate = useNavigate()
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ setError('')
+ setLoading(true)
+
+ try {
+ await login(email, password)
+ navigate('/')
+ } catch (err) {
+ setError(err.response?.data?.error || 'Ошибка входа')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+ <div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50 dark:bg-gray-950 transition-colors duration-300">
+ <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} className="w-full max-w-md">
+ <div className="text-center mb-8">
+ <motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', delay: 0.1, stiffness: 200 }} className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-700 mb-6 shadow-xl shadow-primary-500/30">
+ <Zap className="w-10 h-10 text-white" />
+ </motion.div>
+ <motion.h1 initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }} className="text-3xl font-display font-bold text-gray-900 dark:text-white">
+ С возвращением!
+ </motion.h1>
+ <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }} className="text-gray-500 dark:text-gray-400 mt-2">
+ Войди, чтобы продолжить
+ </motion.p>
+ </div>
+
+ <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="card p-8">
+ <form onSubmit={handleSubmit} className="space-y-5">
+ {error && (
+ <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} className="p-4 rounded-2xl bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm font-medium">
+ {error}
+ </motion.div>
+ )}
+
+ <div>
+ <label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Email</label>
+ <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="your@email.com" required />
+ </div>
+
+ <div>
+ <label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Пароль</label>
+ <div className="relative">
+ <input type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} className="input pr-12" placeholder="••••••••" required />
+ <button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
+ {showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
+ </button>
+ </div>
+ </div>
+
+ <div className="flex justify-end">
+ <Link to="/forgot-password" className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 font-medium">Забыли пароль?</Link>
+ </div>
+
+ <button type="submit" disabled={loading} className="btn btn-primary w-full text-lg">
+ {loading ? (
+ <span className="flex items-center gap-2">
+ <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
+ </svg>
+ Входим...
+ </span>
+ ) : 'Войти'}
+ </button>
+ </form>
+
+ <div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800 text-center">
+ <p className="text-gray-500 dark:text-gray-400">
+ Нет аккаунта?{' '}<Link to="/register" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 font-semibold">Зарегистрируйся</Link>
+ </p>
+ </div>
+ </motion.div>
+ </motion.div>
+ </div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 | + + + + + + +22x +22x +22x +22x +22x +22x + +22x +22x + +22x +3x +3x +3x + +3x +3x +1x + +2x + +3x + + + +22x + + + + + + + + + + + + + + + + + + + + +3x + + + + +3x + + + + + +3x +1x + + + + + + + + + + + + + + + + + + + | import { useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import { Eye, EyeOff, Sparkles } from 'lucide-react'
+import { useAuthStore } from '../store/auth'
+
+export default function Register() {
+ const [email, setEmail] = useState('')
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [showPassword, setShowPassword] = useState(false)
+ const [error, setError] = useState('')
+ const [loading, setLoading] = useState(false)
+
+ const register = useAuthStore(s => s.register)
+ const navigate = useNavigate()
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ setError('')
+ setLoading(true)
+
+ try {
+ await register(email, username, password)
+ navigate('/')
+ } catch (err) {
+ setError(err.response?.data?.error || 'Ошибка регистрации')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+ <div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-primary-50 via-white to-accent-50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 transition-colors duration-300">
+ <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="w-full max-w-md">
+ <div className="text-center mb-8">
+ <motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', delay: 0.1 }} className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-accent-500 mb-4">
+ <Sparkles className="w-8 h-8 text-white" />
+ </motion.div>
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-white">Создай аккаунт</h1>
+ <p className="text-gray-500 dark:text-gray-400 mt-1">Начни отслеживать свои привычки</p>
+ </div>
+
+ <div className="card p-6">
+ <form onSubmit={handleSubmit} className="space-y-4">
+ {error && (
+ <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} className="p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm">
+ {error}
+ </motion.div>
+ )}
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Как тебя зовут?</label>
+ <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="input" placeholder="Имя" required />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Email</label>
+ <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="your@email.com" required />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Пароль</label>
+ <div className="relative">
+ <input type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} className="input pr-12" placeholder="Минимум 8 символов" minLength={8} required />
+ <button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
+ {showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
+ </button>
+ </div>
+ </div>
+
+ <button type="submit" disabled={loading} className="btn btn-primary w-full">
+ {loading ? 'Создаём...' : 'Создать аккаунт'}
+ </button>
+ </form>
+
+ <p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
+ Уже есть аккаунт?{' '}<Link to="/login" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 font-medium">Войти</Link>
+ </p>
+ </div>
+ </motion.div>
+ </div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 | + + + + + + +18x +18x +18x +18x +18x +18x +18x + +18x + +18x +4x +4x +1x +1x + + +3x +3x + +3x +3x +2x +2x + +1x + +3x + + + +18x +2x + + + + + + + + + + + + + + + + + + + + + + + +16x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +4x + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState } from 'react'
+import { useSearchParams, Link, useNavigate } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import { Eye, EyeOff, Zap, CheckCircle } from 'lucide-react'
+import api from '../api/client'
+
+export default function ResetPassword() {
+ const [searchParams] = useSearchParams()
+ const [password, setPassword] = useState('')
+ const [showPassword, setShowPassword] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [success, setSuccess] = useState(false)
+ const navigate = useNavigate()
+
+ const token = searchParams.get('token')
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!token) {
+ setError('Токен не найден')
+ return
+ }
+
+ setError('')
+ setLoading(true)
+
+ try {
+ await api.post('/auth/reset-password', { token, new_password: password })
+ setSuccess(true)
+ setTimeout(() => navigate('/login'), 2000)
+ } catch (err) {
+ setError(err.response?.data?.error || 'Ошибка сброса пароля')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (success) {
+ return (
+ <div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50">
+ <motion.div
+ initial={{ opacity: 0, scale: 0.9 }}
+ animate={{ opacity: 1, scale: 1 }}
+ className="card p-10 text-center max-w-md w-full"
+ >
+ <motion.div
+ initial={{ scale: 0 }}
+ animate={{ scale: 1 }}
+ transition={{ type: 'spring', stiffness: 200 }}
+ className="w-20 h-20 rounded-3xl bg-green-100 flex items-center justify-center mx-auto mb-6"
+ >
+ <CheckCircle className="w-10 h-10 text-green-600" />
+ </motion.div>
+ <h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
+ Пароль изменён! 🎉
+ </h1>
+ <p className="text-gray-500">Перенаправляем на страницу входа...</p>
+ </motion.div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50">
+ <motion.div
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ className="w-full max-w-md"
+ >
+ <div className="text-center mb-8">
+ <motion.div
+ initial={{ scale: 0 }}
+ animate={{ scale: 1 }}
+ transition={{ type: 'spring', delay: 0.1 }}
+ className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-700 mb-6 shadow-xl shadow-primary-500/30"
+ >
+ <Zap className="w-10 h-10 text-white" />
+ </motion.div>
+ <h1 className="text-3xl font-display font-bold text-gray-900">
+ Новый пароль
+ </h1>
+ <p className="text-gray-500 mt-2">Придумай новый надёжный пароль</p>
+ </div>
+
+ <div className="card p-8">
+ <form onSubmit={handleSubmit} className="space-y-5">
+ {error && (
+ <motion.div
+ initial={{ opacity: 0, height: 0 }}
+ animate={{ opacity: 1, height: 'auto' }}
+ className="p-4 rounded-2xl bg-red-50 text-red-600 text-sm font-medium"
+ >
+ {error}
+ </motion.div>
+ )}
+
+ <div>
+ <label className="block text-sm font-semibold text-gray-700 mb-2">
+ Новый пароль
+ </label>
+ <div className="relative">
+ <input
+ type={showPassword ? 'text' : 'password'}
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ className="input pr-12"
+ placeholder="Минимум 8 символов"
+ minLength={8}
+ required
+ />
+ <button
+ type="button"
+ onClick={() => setShowPassword(!showPassword)}
+ className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
+ >
+ {showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
+ </button>
+ </div>
+ </div>
+
+ <button
+ type="submit"
+ disabled={loading}
+ className="btn btn-primary w-full text-lg"
+ >
+ {loading ? 'Сохраняем...' : 'Сохранить пароль'}
+ </button>
+ </form>
+
+ <div className="mt-6 text-center">
+ <Link to="/login" className="text-primary-600 hover:text-primary-700 font-medium text-sm">
+ Вернуться ко входу
+ </Link>
+ </div>
+ </div>
+ </motion.div>
+ </div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +915 +916 +917 +918 +919 +920 +921 +922 +923 +924 +925 +926 +927 +928 +929 +930 +931 +932 +933 +934 +935 +936 +937 +938 +939 +940 +941 +942 +943 +944 +945 +946 +947 +948 +949 +950 +951 +952 +953 +954 +955 +956 +957 +958 +959 +960 +961 +962 +963 +964 +965 +966 +967 +968 +969 +970 +971 +972 +973 +974 +975 +976 +977 +978 +979 +980 +981 +982 +983 +984 +985 +986 +987 +988 +989 +990 +991 +992 +993 +994 +995 +996 +997 +998 +999 +1000 +1001 +1002 +1003 +1004 +1005 +1006 +1007 +1008 +1009 +1010 +1011 +1012 +1013 +1014 +1015 +1016 +1017 +1018 +1019 +1020 +1021 +1022 +1023 +1024 +1025 +1026 +1027 +1028 +1029 +1030 +1031 +1032 +1033 +1034 +1035 +1036 +1037 +1038 +1039 +1040 +1041 +1042 +1043 +1044 +1045 +1046 +1047 +1048 +1049 +1050 +1051 +1052 +1053 +1054 +1055 +1056 +1057 +1058 +1059 +1060 +1061 +1062 +1063 +1064 +1065 +1066 +1067 +1068 +1069 +1070 +1071 +1072 +1073 +1074 +1075 +1076 +1077 +1078 +1079 +1080 +1081 +1082 +1083 +1084 +1085 +1086 +1087 +1088 +1089 +1090 +1091 +1092 +1093 +1094 +1095 +1096 +1097 +1098 +1099 +1100 +1101 +1102 +1103 +1104 +1105 +1106 +1107 +1108 +1109 +1110 +1111 +1112 +1113 +1114 +1115 +1116 +1117 +1118 +1119 +1120 +1121 +1122 +1123 +1124 +1125 +1126 +1127 +1128 +1129 +1130 +1131 +1132 +1133 +1134 +1135 +1136 +1137 +1138 +1139 +1140 +1141 +1142 +1143 +1144 +1145 +1146 +1147 +1148 +1149 +1150 +1151 +1152 +1153 +1154 +1155 +1156 +1157 +1158 +1159 +1160 +1161 +1162 +1163 +1164 +1165 +1166 +1167 +1168 +1169 +1170 +1171 +1172 +1173 +1174 +1175 +1176 +1177 +1178 +1179 +1180 +1181 +1182 +1183 +1184 +1185 +1186 +1187 +1188 +1189 +1190 +1191 +1192 +1193 +1194 +1195 +1196 +1197 +1198 +1199 +1200 +1201 +1202 +1203 +1204 +1205 +1206 +1207 +1208 +1209 +1210 +1211 +1212 +1213 +1214 +1215 +1216 +1217 +1218 +1219 +1220 +1221 +1222 +1223 +1224 +1225 +1226 +1227 +1228 +1229 +1230 +1231 +1232 +1233 +1234 +1235 +1236 +1237 +1238 +1239 +1240 +1241 +1242 +1243 +1244 +1245 +1246 +1247 +1248 +1249 +1250 +1251 +1252 +1253 +1254 +1255 +1256 +1257 +1258 +1259 +1260 +1261 +1262 +1263 +1264 +1265 +1266 +1267 +1268 +1269 +1270 +1271 +1272 +1273 +1274 +1275 +1276 +1277 +1278 +1279 +1280 +1281 +1282 +1283 +1284 +1285 +1286 +1287 +1288 +1289 +1290 +1291 +1292 +1293 +1294 +1295 +1296 +1297 +1298 +1299 +1300 +1301 +1302 +1303 +1304 +1305 +1306 +1307 +1308 +1309 +1310 +1311 +1312 +1313 +1314 +1315 +1316 +1317 +1318 +1319 +1320 +1321 +1322 +1323 +1324 +1325 +1326 +1327 +1328 +1329 +1330 +1331 +1332 +1333 +1334 +1335 +1336 +1337 +1338 +1339 +1340 +1341 +1342 +1343 +1344 +1345 +1346 +1347 +1348 +1349 +1350 +1351 +1352 +1353 +1354 +1355 +1356 +1357 +1358 +1359 +1360 +1361 +1362 +1363 +1364 +1365 +1366 +1367 +1368 +1369 +1370 +1371 +1372 +1373 +1374 +1375 +1376 +1377 +1378 +1379 +1380 +1381 +1382 +1383 +1384 +1385 +1386 +1387 +1388 +1389 +1390 +1391 +1392 +1393 +1394 +1395 +1396 +1397 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +4x + + + + + + + + +5x + + + + + +5x + + +15x + + + + + + + + + + + + + + + + + + + +1x +1x +1x +1x + +1x + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x + +5x + + + + +5x + +4x + + +5x + + + + +5x + + + + + + + + +5x + + + + + + + + +5x + + + + + + + + +5x + + + + + + + +5x + + + + + + + +5x + + + + + + + +5x + + + + +5x + + + + +5x + + + + +5x + + + + + + + +5x + + + + +5x + + + + +5x + + + + + + + +5x + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +12x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState, useEffect } from "react"
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
+import { motion, AnimatePresence } from "framer-motion"
+import {
+ Plus,
+ PiggyBank,
+ TrendingUp,
+ Wallet,
+ Target,
+ Users,
+ X,
+ Calendar,
+ ArrowUpCircle,
+ ArrowDownCircle,
+ AlertTriangle,
+ CreditCard,
+ Edit2,
+ Trash2,
+ LayoutDashboard,
+ FolderOpen,
+ Receipt,
+ User,
+ Settings,
+} from "lucide-react"
+import { format } from "date-fns"
+import { ru } from "date-fns/locale"
+import { savingsApi } from "../api/savings"
+import Navigation from "../components/Navigation"
+import clsx from "clsx"
+
+function formatCurrency(amount) {
+ return new Intl.NumberFormat("ru-RU", {
+ style: "currency",
+ currency: "RUB",
+ maximumFractionDigits: 0,
+ }).format(amount)
+}
+
+// ==================== TAB NAVIGATION ====================
+function TabNav({ activeTab, setActiveTab }) {
+ const tabs = [
+ { id: "dashboard", label: "Обзор", icon: LayoutDashboard },
+ { id: "categories", label: "Категории", icon: FolderOpen },
+ { id: "transactions", label: "Операции", icon: Receipt },
+ ]
+
+ return (
+ <div className="flex gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-xl mb-6">
+ {tabs.map((tab) => (
+ <button
+ key={tab.id}
+ onClick={() => setActiveTab(tab.id)}
+ className={clsx(
+ "flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg font-medium transition-all text-sm",
+ activeTab === tab.id
+ ? "bg-white dark:bg-gray-700 text-violet-600 dark:text-violet-400 shadow-sm"
+ : "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
+ )}
+ >
+ <tab.icon size={18} />
+ <span className="hidden sm:inline">{tab.label}</span>
+ </button>
+ ))}
+ </div>
+ )
+}
+
+// ==================== DASHBOARD TAB ====================
+function DashboardTab({ categories, stats }) {
+ const totalBalance = stats?.total_balance || 0
+ const monthlyPaymentDetails = stats?.monthly_payment_details || []
+ const overdues = stats?.overdues || []
+ const activeCategories = categories.filter((c) => !c.is_closed)
+
+ return (
+ <div className="space-y-6">
+ {/* 1. Total Balance Card */}
+ <div className="card p-5 bg-gradient-to-br from-violet-500 to-purple-600 text-white">
+ <div className="text-sm opacity-80 mb-1">Общий баланс</div>
+ <div className="text-3xl font-bold">{formatCurrency(totalBalance)}</div>
+ <div className="mt-3 flex gap-4 text-sm opacity-80">
+ <span>📥 Пополнения: {formatCurrency(stats?.total_deposits || 0)}</span>
+ <span>📤 Снятия: {formatCurrency(stats?.total_withdrawals || 0)}</span>
+ </div>
+ </div>
+
+ {/* 2. Active Categories Count */}
+ <div className="grid grid-cols-2 gap-3">
+ <div className="card p-4 text-center">
+ <div className="text-2xl font-bold text-gray-900 dark:text-white">
+ {activeCategories.length}
+ </div>
+ <div className="text-sm text-gray-500 dark:text-gray-400">
+ Активных категорий
+ </div>
+ </div>
+ <div className="card p-4 text-center">
+ <div className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
+ {categories.filter((c) => c.is_deposit && !c.is_closed).length}
+ </div>
+ <div className="text-sm text-gray-500 dark:text-gray-400">Депозитов</div>
+ </div>
+ </div>
+
+ {/* 3. Category Progress */}
+ <div className="card p-4">
+ <h3 className="font-semibold text-gray-900 dark:text-white mb-3">
+ Прогресс по категориям
+ </h3>
+ <div className="space-y-3">
+ {activeCategories.length === 0 ? (
+ <p className="text-gray-500 dark:text-gray-400">Нет активных категорий</p>
+ ) : (
+ activeCategories.slice(0, 6).map((cat) => (
+ <div key={cat.id} className="flex items-center gap-3">
+ <div
+ className={clsx(
+ "w-8 h-8 rounded-lg flex items-center justify-center text-white",
+ cat.is_deposit
+ ? "bg-amber-500"
+ : cat.is_credit
+ ? "bg-orange-500"
+ : cat.is_recurring
+ ? "bg-emerald-500"
+ : "bg-violet-500"
+ )}
+ >
+ {cat.is_deposit ? (
+ <TrendingUp size={16} />
+ ) : cat.is_credit ? (
+ <CreditCard size={16} />
+ ) : cat.is_recurring ? (
+ <Target size={16} />
+ ) : (
+ <PiggyBank size={16} />
+ )}
+ </div>
+ <div className="flex-1 min-w-0">
+ <div className="text-sm font-medium text-gray-900 dark:text-white truncate flex items-center gap-1">
+ {cat.name}
+ {cat.is_multi && (
+ <span className="text-xs text-blue-500">(общая)</span>
+ )}
+ </div>
+ </div>
+ <div className="text-sm font-semibold text-gray-900 dark:text-white">
+ {formatCurrency(cat.current_amount)}
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+ </div>
+
+ {/* 4. Monthly Payments Block */}
+ {monthlyPaymentDetails.length > 0 && (
+ <div className="card p-4">
+ <h3 className="font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
+ <Calendar className="w-5 h-5 text-blue-500" />
+ Ежемесячные платежи
+ <span className="ml-auto text-lg text-blue-600 dark:text-blue-400 font-bold">
+ {formatCurrency(stats?.monthly_payments || 0)}
+ </span>
+ </h3>
+ <div className="space-y-3">
+ {monthlyPaymentDetails.map((detail) => (
+ <div
+ key={detail.category_id}
+ className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-800 last:border-0"
+ >
+ <div>
+ <div className="font-medium text-gray-900 dark:text-white">
+ {detail.category_name}
+ </div>
+ <div className="text-xs text-gray-500 dark:text-gray-400">
+ К оплате до {detail.day} числа
+ </div>
+ </div>
+ <div className="text-right">
+ <div className="font-semibold text-blue-600 dark:text-blue-400">
+ {formatCurrency(detail.amount)}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 5. Overdues Block */}
+ {overdues.length > 0 && (
+ <div className="card p-4 border-2 border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20">
+ <h3 className="font-semibold text-red-600 dark:text-red-400 mb-3 flex items-center gap-2">
+ <AlertTriangle className="w-5 h-5" />
+ Просрочки ({overdues.length})
+ </h3>
+ <div className="space-y-3">
+ {overdues.map((overdue) => (
+ <div
+ key={`${overdue.category_id}-${overdue.month || overdue.days_overdue}`}
+ className="flex items-center justify-between py-2 border-b border-red-200 dark:border-red-800 last:border-0"
+ >
+ <div>
+ <div className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
+ {overdue.category_name}
+ {overdue.month && (
+ <span className="text-xs font-normal text-red-400 bg-red-100 dark:bg-red-900/40 px-1.5 py-0.5 rounded">
+ {overdue.month}
+ </span>
+ )}
+ </div>
+ <div className="text-xs text-gray-500 dark:text-gray-400">
+ Просрочено {overdue.days_overdue} дн. · до {overdue.due_day} числа
+ </div>
+ </div>
+ <div className="text-right">
+ <div className="font-semibold text-red-600 dark:text-red-400">
+ {formatCurrency(overdue.amount)}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ )
+}
+
+// ==================== CATEGORIES TAB ====================
+function CategoriesTab({ categories, onEdit, onDelete, onManagePlans }) {
+ const [activeSubTab, setActiveSubTab] = useState("active")
+
+ const filteredCategories = categories.filter((c) =>
+ activeSubTab === "active" ? !c.is_closed : c.is_closed
+ )
+
+ const getTypeInfo = (category) => {
+ if (category.is_deposit)
+ return { icon: TrendingUp, label: "Депозит", color: "from-amber-500 to-orange-500" }
+ if (category.is_credit)
+ return { icon: CreditCard, label: "Кредит", color: "from-orange-500 to-red-500" }
+ if (category.is_account)
+ return { icon: Wallet, label: "Счёт", color: "from-blue-500 to-indigo-500" }
+ if (category.is_recurring)
+ return { icon: Target, label: "Регулярные", color: "from-emerald-500 to-teal-500" }
+ return { icon: PiggyBank, label: "Накопление", color: "from-violet-500 to-purple-500" }
+ }
+
+ return (
+ <div className="space-y-4">
+ {/* Sub-tabs */}
+ <div className="flex gap-2">
+ <button
+ onClick={() => setActiveSubTab("active")}
+ className={clsx(
+ "px-4 py-2 rounded-lg text-sm font-medium transition",
+ activeSubTab === "active"
+ ? "bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+ )}
+ >
+ 📂 Активные ({categories.filter((c) => !c.is_closed).length})
+ </button>
+ <button
+ onClick={() => setActiveSubTab("closed")}
+ className={clsx(
+ "px-4 py-2 rounded-lg text-sm font-medium transition",
+ activeSubTab === "closed"
+ ? "bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+ )}
+ >
+ 🗃️ Закрытые ({categories.filter((c) => c.is_closed).length})
+ </button>
+ </div>
+
+ {/* Categories List */}
+ {filteredCategories.length === 0 ? (
+ <div className="card p-8 text-center">
+ <PiggyBank className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
+ <p className="text-gray-500 dark:text-gray-400">
+ {activeSubTab === "active" ? "Нет активных категорий" : "Нет закрытых категорий"}
+ </p>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {filteredCategories.map((category) => {
+ const typeInfo = getTypeInfo(category)
+ const Icon = typeInfo.icon
+
+ return (
+ <motion.div
+ key={category.id}
+ initial={{ opacity: 0, y: 10 }}
+ animate={{ opacity: 1, y: 0 }}
+ className="card p-4"
+ >
+ <div className="flex items-start gap-4">
+ <div
+ className={clsx(
+ "w-12 h-12 rounded-xl bg-gradient-to-br flex items-center justify-center text-white",
+ typeInfo.color
+ )}
+ >
+ <Icon className="w-6 h-6" />
+ </div>
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 mb-1">
+ <h3 className="font-semibold text-gray-900 dark:text-white truncate">
+ {category.name}
+ </h3>
+ {category.is_multi && (
+ <span className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 px-2 py-0.5 rounded-full">
+ <Users size={12} />
+ </span>
+ )}
+ </div>
+ <div className="text-xs text-gray-500 dark:text-gray-400 mb-2">
+ {typeInfo.label}
+ </div>
+ <div className="text-xl font-bold text-gray-900 dark:text-white">
+ {formatCurrency(category.current_amount)}
+ </div>
+
+ {/* Deposit info */}
+ {category.is_deposit && category.interest_rate > 0 && (
+ <div className="mt-2 text-xs text-amber-600 dark:text-amber-400">
+ 📈 {category.interest_rate}% годовых
+ {category.deposit_term > 0 && ` · ${category.deposit_term} мес.`}
+ </div>
+ )}
+ </div>
+
+ {/* Actions */}
+ {!category.is_closed && (
+ <div className="flex gap-1">
+ {category.is_recurring && (
+ <button
+ onClick={() => onManagePlans(category)}
+ className="p-2 text-gray-400 hover:text-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded-lg transition"
+ title="Управление планами"
+ >
+ <Settings size={18} />
+ </button>
+ )}
+ <button
+ onClick={() => onEdit(category)}
+ className="p-2 text-gray-400 hover:text-violet-500 hover:bg-violet-50 dark:hover:bg-violet-900/20 rounded-lg transition"
+ >
+ <Edit2 size={18} />
+ </button>
+ <button
+ onClick={() => onDelete(category)}
+ className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition"
+ >
+ <Trash2 size={18} />
+ </button>
+ </div>
+ )}
+ </div>
+ </motion.div>
+ )
+ })}
+ </div>
+ )}
+ </div>
+ )
+}
+
+// ==================== TRANSACTIONS TAB ====================
+function TransactionsTab({ transactions, categories, onAddTransaction, onEditTransaction, onDeleteTransaction }) {
+ const [filter, setFilter] = useState("")
+
+ const filteredTransactions = filter
+ ? transactions.filter((t) => t.category_id === parseInt(filter))
+ : transactions
+
+ // Debug: log to check for duplicates
+ useEffect(() => {
+ console.log("[Savings] Transactions loaded:", transactions.length, transactions)
+ }, [transactions])
+
+ return (
+ <div className="space-y-4">
+ {/* Filter */}
+ <div className="flex gap-3">
+ <select
+ value={filter}
+ onChange={(e) => setFilter(e.target.value)}
+ className="input flex-1"
+ >
+ <option value="">Все категории</option>
+ {categories.map((c) => (
+ <option key={c.id} value={c.id}>
+ {c.name}
+ </option>
+ ))}
+ </select>
+ <button onClick={onAddTransaction} className="btn btn-primary">
+ <Plus size={18} />
+ </button>
+ </div>
+
+ {/* Transactions List */}
+ <div className="card divide-y divide-gray-100 dark:divide-gray-800">
+ {filteredTransactions.length === 0 ? (
+ <div className="p-8 text-center">
+ <Receipt className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
+ <p className="text-gray-500 dark:text-gray-400">Нет операций</p>
+ </div>
+ ) : (
+ filteredTransactions.slice(0, 50).map((tx) => {
+ const isDeposit = tx.type === "deposit"
+ return (
+ <div key={tx.id} className="flex items-center gap-4 p-4 group">
+ <div
+ className={clsx(
+ "w-10 h-10 rounded-xl flex items-center justify-center",
+ isDeposit
+ ? "bg-emerald-100 dark:bg-emerald-900/30"
+ : "bg-rose-100 dark:bg-rose-900/30"
+ )}
+ >
+ {isDeposit ? (
+ <ArrowDownCircle className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
+ ) : (
+ <ArrowUpCircle className="w-5 h-5 text-rose-600 dark:text-rose-400" />
+ )}
+ </div>
+ <div className="flex-1 min-w-0">
+ <div className="font-medium text-gray-900 dark:text-white truncate">
+ {tx.category_name || "Транзакция"}
+ </div>
+ <div className="text-xs text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-x-2">
+ <span className="flex items-center gap-1">
+ <Calendar size={10} />
+ {format(new Date(tx.date), "d MMM yyyy", { locale: ru })}
+ </span>
+ {tx.user_name && (
+ <span className="flex items-center gap-1">
+ <User size={10} />
+ {tx.user_name}
+ </span>
+ )}
+ {tx.description && (
+ <span className="truncate max-w-[150px]">· {tx.description}</span>
+ )}
+ </div>
+ </div>
+ <div
+ className={clsx(
+ "font-semibold",
+ isDeposit
+ ? "text-emerald-600 dark:text-emerald-400"
+ : "text-rose-600 dark:text-rose-400"
+ )}
+ >
+ {isDeposit ? "+" : "-"}
+ {formatCurrency(tx.amount)}
+ </div>
+ {/* Edit/Delete buttons */}
+ <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+ <button
+ onClick={() => onEditTransaction(tx)}
+ className="p-1.5 text-gray-400 hover:text-violet-500 hover:bg-violet-50 dark:hover:bg-violet-900/20 rounded-lg transition"
+ >
+ <Edit2 size={14} />
+ </button>
+ <button
+ onClick={() => onDeleteTransaction(tx)}
+ className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition"
+ >
+ <Trash2 size={14} />
+ </button>
+ </div>
+ </div>
+ )
+ })
+ )}
+ </div>
+ </div>
+ )
+}
+
+// ==================== MODALS ====================
+
+// Transaction Modal (Create/Edit)
+function TransactionModal({ isOpen, onClose, categories, onSubmit, transaction }) {
+ const [form, setForm] = useState({
+ category_id: "",
+ amount: "",
+ type: "deposit",
+ description: "",
+ date: format(new Date(), "yyyy-MM-dd"),
+ })
+
+ useEffect(() => {
+ if (transaction) {
+ setForm({
+ category_id: transaction.category_id?.toString() || "",
+ amount: transaction.amount?.toString() || "",
+ type: transaction.type || "deposit",
+ description: transaction.description || "",
+ date: transaction.date ? format(new Date(transaction.date), "yyyy-MM-dd") : format(new Date(), "yyyy-MM-dd"),
+ })
+ } else {
+ setForm({
+ category_id: "",
+ amount: "",
+ type: "deposit",
+ description: "",
+ date: format(new Date(), "yyyy-MM-dd"),
+ })
+ }
+ }, [transaction, isOpen])
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+ onSubmit({
+ ...form,
+ category_id: parseInt(form.category_id),
+ amount: parseFloat(form.amount),
+ }, transaction?.id)
+ onClose()
+ }
+
+ if (!isOpen) return null
+
+ return (
+ <div
+ className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/50"
+ onClick={onClose}
+ >
+ <motion.div
+ initial={{ opacity: 0, y: 100 }}
+ animate={{ opacity: 1, y: 0 }}
+ exit={{ opacity: 0, y: 100 }}
+ className="w-full max-w-lg bg-white dark:bg-gray-900 rounded-2xl p-6"
+ onClick={(e) => e.stopPropagation()}
+ >
+ <div className="flex justify-between items-center mb-6">
+ <h2 className="text-xl font-bold text-gray-900 dark:text-white">
+ {transaction ? "Редактировать операцию" : "Новая операция"}
+ </h2>
+ <button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
+ <X size={20} />
+ </button>
+ </div>
+
+ <form onSubmit={handleSubmit} className="space-y-4">
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Категория</label>
+ <select
+ value={form.category_id}
+ onChange={(e) => setForm({ ...form, category_id: e.target.value })}
+ className="input w-full"
+ required
+ disabled={!!transaction}
+ >
+ <option value="">Выберите категорию</option>
+ {categories.filter((c) => !c.is_closed).map((c) => (
+ <option key={c.id} value={c.id}>{c.name}</option>
+ ))}
+ </select>
+ </div>
+
+ <div className="flex gap-2">
+ <button
+ type="button"
+ onClick={() => setForm({ ...form, type: "deposit" })}
+ className={clsx(
+ "flex-1 py-3 rounded-xl flex items-center justify-center gap-2 font-medium transition",
+ form.type === "deposit"
+ ? "bg-emerald-500 text-white"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+ )}
+ >
+ <ArrowDownCircle size={20} /> Пополнение
+ </button>
+ <button
+ type="button"
+ onClick={() => setForm({ ...form, type: "withdrawal" })}
+ className={clsx(
+ "flex-1 py-3 rounded-xl flex items-center justify-center gap-2 font-medium transition",
+ form.type === "withdrawal"
+ ? "bg-rose-500 text-white"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+ )}
+ >
+ <ArrowUpCircle size={20} /> Снятие
+ </button>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Сумма</label>
+ <input
+ type="number"
+ value={form.amount}
+ onChange={(e) => setForm({ ...form, amount: e.target.value })}
+ className="input w-full"
+ placeholder="0"
+ min="0.01"
+ step="0.01"
+ required
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Дата</label>
+ <input
+ type="date"
+ value={form.date}
+ onChange={(e) => setForm({ ...form, date: e.target.value })}
+ className="input w-full"
+ required
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Описание (опционально)</label>
+ <input
+ type="text"
+ value={form.description}
+ onChange={(e) => setForm({ ...form, description: e.target.value })}
+ className="input w-full"
+ placeholder="Комментарий"
+ />
+ </div>
+
+ <button type="submit" className="btn btn-primary w-full">
+ {transaction ? "Сохранить" : "Добавить операцию"}
+ </button>
+ </form>
+ </motion.div>
+ </div>
+ )
+}
+
+// Delete Transaction Confirmation Modal
+function DeleteTransactionModal({ isOpen, onClose, transaction, onConfirm }) {
+ if (!isOpen || !transaction) return null
+
+ return (
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={onClose}>
+ <motion.div
+ initial={{ opacity: 0, scale: 0.95 }}
+ animate={{ opacity: 1, scale: 1 }}
+ className="w-full max-w-sm bg-white dark:bg-gray-900 rounded-2xl p-6"
+ onClick={(e) => e.stopPropagation()}
+ >
+ <div className="text-center">
+ <div className="w-16 h-16 mx-auto mb-4 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
+ <AlertTriangle className="w-8 h-8 text-red-500" />
+ </div>
+ <h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">Удалить операцию?</h3>
+ <p className="text-gray-500 dark:text-gray-400 mb-6">
+ Операция на сумму {formatCurrency(transaction.amount)} будет удалена безвозвратно.
+ </p>
+ <div className="flex gap-3">
+ <button onClick={onClose} className="btn flex-1">Отмена</button>
+ <button
+ onClick={() => { onConfirm(transaction.id); onClose() }}
+ className="btn bg-red-500 hover:bg-red-600 text-white flex-1"
+ >
+ Удалить
+ </button>
+ </div>
+ </div>
+ </motion.div>
+ </div>
+ )
+}
+
+// Category Edit Modal
+function CategoryEditModal({ isOpen, onClose, category, onSubmit }) {
+ const [form, setForm] = useState({
+ name: "",
+ description: "",
+ is_deposit: false,
+ is_credit: false,
+ is_account: false,
+ is_recurring: false,
+ is_multi: false,
+ initial_capital: "",
+ deposit_amount: "",
+ interest_rate: "",
+ deposit_start_date: "",
+ deposit_term: "",
+ credit_amount: "",
+ credit_term: "",
+ credit_rate: "",
+ credit_start_date: "",
+ recurring_amount: "",
+ recurring_day: "",
+ recurring_start_date: "",
+ })
+
+ useEffect(() => {
+ if (category) {
+ setForm({
+ name: category.name || "",
+ description: category.description || "",
+ is_deposit: category.is_deposit || false,
+ is_credit: category.is_credit || false,
+ is_account: category.is_account || false,
+ is_recurring: category.is_recurring || false,
+ is_multi: category.is_multi || false,
+ initial_capital: category.initial_capital?.toString() || "",
+ deposit_amount: category.deposit_amount?.toString() || "",
+ interest_rate: category.interest_rate?.toString() || "",
+ deposit_start_date: category.deposit_start_date?.split("T")[0] || "",
+ deposit_term: category.deposit_term?.toString() || "",
+ credit_amount: category.credit_amount?.toString() || "",
+ credit_term: category.credit_term?.toString() || "",
+ credit_rate: category.credit_rate?.toString() || "",
+ credit_start_date: category.credit_start_date?.split("T")[0] || "",
+ recurring_amount: category.recurring_amount?.toString() || "",
+ recurring_day: category.recurring_day?.toString() || "",
+ recurring_start_date: category.recurring_start_date?.split("T")[0] || "",
+ })
+ } else {
+ setForm({
+ name: "", description: "", is_deposit: false, is_credit: false, is_account: false,
+ is_recurring: false, is_multi: false, initial_capital: "", deposit_amount: "",
+ interest_rate: "", deposit_start_date: "", deposit_term: "", credit_amount: "",
+ credit_term: "", credit_rate: "", credit_start_date: "", recurring_amount: "",
+ recurring_day: "", recurring_start_date: "",
+ })
+ }
+ }, [category, isOpen])
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+ const data = {
+ name: form.name,
+ description: form.description,
+ is_deposit: form.is_deposit,
+ is_credit: form.is_credit,
+ is_account: form.is_account,
+ is_recurring: form.is_recurring,
+ is_multi: form.is_multi,
+ }
+
+ if (form.initial_capital) data.initial_capital = parseFloat(form.initial_capital)
+
+ if (form.is_deposit) {
+ if (form.deposit_amount) data.deposit_amount = parseFloat(form.deposit_amount)
+ if (form.interest_rate) data.interest_rate = parseFloat(form.interest_rate)
+ if (form.deposit_start_date) data.deposit_start_date = form.deposit_start_date
+ if (form.deposit_term) data.deposit_term = parseInt(form.deposit_term)
+ }
+
+ if (form.is_credit) {
+ if (form.credit_amount) data.credit_amount = parseFloat(form.credit_amount)
+ if (form.credit_term) data.credit_term = parseInt(form.credit_term)
+ if (form.credit_rate) data.credit_rate = parseFloat(form.credit_rate)
+ if (form.credit_start_date) data.credit_start_date = form.credit_start_date
+ }
+
+ if (form.is_recurring) {
+ if (form.recurring_amount) data.recurring_amount = parseFloat(form.recurring_amount)
+ if (form.recurring_day) data.recurring_day = parseInt(form.recurring_day)
+ if (form.recurring_start_date) data.recurring_start_date = form.recurring_start_date
+ }
+
+ onSubmit(category?.id, data)
+ onClose()
+ }
+
+ if (!isOpen) return null
+
+ return (
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 overflow-y-auto" onClick={onClose}>
+ <motion.div
+ initial={{ opacity: 0, scale: 0.95 }}
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 0, scale: 0.95 }}
+ className="w-full max-w-lg bg-white dark:bg-gray-900 rounded-2xl p-6 my-8"
+ onClick={(e) => e.stopPropagation()}
+ >
+ <div className="flex justify-between items-center mb-6">
+ <h2 className="text-xl font-bold text-gray-900 dark:text-white">
+ {category ? "Редактировать категорию" : "Новая категория"}
+ </h2>
+ <button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
+ <X size={20} />
+ </button>
+ </div>
+
+ <form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto">
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Название</label>
+ <input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="input w-full" required />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Описание</label>
+ <textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="input w-full" rows={2} />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Начальный капитал</label>
+ <input type="number" value={form.initial_capital} onChange={(e) => setForm({ ...form, initial_capital: e.target.value })} className="input w-full" step="1000" />
+ </div>
+
+ {/* Type toggles */}
+ <div className="space-y-3">
+ <label className="flex items-center gap-3 cursor-pointer">
+ <input type="checkbox" checked={form.is_deposit} onChange={(e) => setForm({ ...form, is_deposit: e.target.checked, is_credit: false })} className="w-5 h-5 rounded border-gray-300" />
+ <span className="text-gray-700 dark:text-gray-300">💰 Депозит</span>
+ </label>
+ <label className="flex items-center gap-3 cursor-pointer">
+ <input type="checkbox" checked={form.is_credit} onChange={(e) => setForm({ ...form, is_credit: e.target.checked, is_deposit: false })} className="w-5 h-5 rounded border-gray-300" />
+ <span className="text-gray-700 dark:text-gray-300">💳 Кредит/Рассрочка</span>
+ </label>
+ <label className="flex items-center gap-3 cursor-pointer">
+ <input type="checkbox" checked={form.is_recurring} onChange={(e) => setForm({ ...form, is_recurring: e.target.checked })} className="w-5 h-5 rounded border-gray-300" />
+ <span className="text-gray-700 dark:text-gray-300">🔄 Повторяющаяся категория</span>
+ </label>
+ <label className="flex items-center gap-3 cursor-pointer">
+ <input type="checkbox" checked={form.is_multi} onChange={(e) => setForm({ ...form, is_multi: e.target.checked })} className="w-5 h-5 rounded border-gray-300" />
+ <span className="text-gray-700 dark:text-gray-300">👥 Несколько участников</span>
+ </label>
+ </div>
+
+ {/* Deposit fields */}
+ {form.is_deposit && (
+ <div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-xl space-y-3">
+ <h4 className="font-medium text-amber-800 dark:text-amber-300">Параметры депозита</h4>
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Сумма депозита</label>
+ <input type="number" value={form.deposit_amount} onChange={(e) => setForm({ ...form, deposit_amount: e.target.value })} className="input w-full text-sm" step="1000" />
+ </div>
+ <div>
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Ставка %</label>
+ <input type="number" value={form.interest_rate} onChange={(e) => setForm({ ...form, interest_rate: e.target.value })} className="input w-full text-sm" step="0.1" />
+ </div>
+ <div>
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Срок (мес)</label>
+ <input type="number" value={form.deposit_term} onChange={(e) => setForm({ ...form, deposit_term: e.target.value })} className="input w-full text-sm" />
+ </div>
+ <div>
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Дата начала</label>
+ <input type="date" value={form.deposit_start_date} onChange={(e) => setForm({ ...form, deposit_start_date: e.target.value })} className="input w-full text-sm" />
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Credit fields */}
+ {form.is_credit && (
+ <div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-xl space-y-3">
+ <h4 className="font-medium text-orange-800 dark:text-orange-300">Параметры кредита</h4>
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Сумма кредита</label>
+ <input type="number" value={form.credit_amount} onChange={(e) => setForm({ ...form, credit_amount: e.target.value })} className="input w-full text-sm" step="1000" />
+ </div>
+ <div>
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Ставка %</label>
+ <input type="number" value={form.credit_rate} onChange={(e) => setForm({ ...form, credit_rate: e.target.value })} className="input w-full text-sm" step="0.1" />
+ </div>
+ <div>
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Срок (мес)</label>
+ <input type="number" value={form.credit_term} onChange={(e) => setForm({ ...form, credit_term: e.target.value })} className="input w-full text-sm" />
+ </div>
+ <div>
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Дата начала</label>
+ <input type="date" value={form.credit_start_date} onChange={(e) => setForm({ ...form, credit_start_date: e.target.value })} className="input w-full text-sm" />
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Recurring fields */}
+ {form.is_recurring && (
+ <div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl space-y-3">
+ <h4 className="font-medium text-emerald-800 dark:text-emerald-300">Параметры повторения</h4>
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Сумма/месяц</label>
+ <input type="number" value={form.recurring_amount} onChange={(e) => setForm({ ...form, recurring_amount: e.target.value })} className="input w-full text-sm" step="1000" />
+ </div>
+ <div>
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">День месяца</label>
+ <input type="number" value={form.recurring_day} onChange={(e) => setForm({ ...form, recurring_day: e.target.value })} className="input w-full text-sm" min="1" max="28" />
+ </div>
+ <div className="col-span-2">
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Дата начала</label>
+ <input type="date" value={form.recurring_start_date} onChange={(e) => setForm({ ...form, recurring_start_date: e.target.value })} className="input w-full text-sm" />
+ </div>
+ </div>
+ </div>
+ )}
+
+ <button type="submit" className="btn btn-primary w-full">
+ {category ? "Сохранить изменения" : "Создать категорию"}
+ </button>
+ </form>
+ </motion.div>
+ </div>
+ )
+}
+
+// Delete Confirmation Modal
+function DeleteModal({ isOpen, onClose, category, onConfirm }) {
+ if (!isOpen || !category) return null
+
+ return (
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={onClose}>
+ <motion.div
+ initial={{ opacity: 0, scale: 0.95 }}
+ animate={{ opacity: 1, scale: 1 }}
+ className="w-full max-w-sm bg-white dark:bg-gray-900 rounded-2xl p-6"
+ onClick={(e) => e.stopPropagation()}
+ >
+ <div className="text-center">
+ <div className="w-16 h-16 mx-auto mb-4 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
+ <AlertTriangle className="w-8 h-8 text-red-500" />
+ </div>
+ <h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">Удалить категорию?</h3>
+ <p className="text-gray-500 dark:text-gray-400 mb-6">
+ Категория «{category.name}» и все связанные транзакции будут удалены безвозвратно.
+ </p>
+ <div className="flex gap-3">
+ <button onClick={onClose} className="btn flex-1">Отмена</button>
+ <button
+ onClick={() => { onConfirm(category.id); onClose() }}
+ className="btn bg-red-500 hover:bg-red-600 text-white flex-1"
+ >
+ Удалить
+ </button>
+ </div>
+ </div>
+ </motion.div>
+ </div>
+ )
+}
+
+// Recurring Plans Management Modal
+function RecurringPlansModal({ isOpen, onClose, category, queryClient }) {
+ const [showAddForm, setShowAddForm] = useState(false)
+ const [editingPlan, setEditingPlan] = useState(null)
+ const [planForm, setPlanForm] = useState({ effective: "", amount: "", day: "1", user_id: "" })
+
+ const { data: plans = [], isLoading } = useQuery({
+ queryKey: ["recurring-plans", category?.id],
+ queryFn: () => savingsApi.getRecurringPlans(category.id),
+ enabled: !!category?.id && isOpen,
+ })
+
+ const { data: members = [] } = useQuery({
+ queryKey: ["category-members", category?.id],
+ queryFn: () => savingsApi.getMembers(category.id),
+ enabled: !!category?.id && category?.is_multi && isOpen,
+ })
+
+ const createPlanMutation = useMutation({
+ mutationFn: (data) => savingsApi.createRecurringPlan(category.id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["recurring-plans", category.id] })
+ queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+ setShowAddForm(false)
+ setPlanForm({ effective: "", amount: "", day: "1", user_id: "" })
+ },
+ })
+
+ const updatePlanMutation = useMutation({
+ mutationFn: ({ id, data }) => savingsApi.updateRecurringPlan(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["recurring-plans", category.id] })
+ queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+ setEditingPlan(null)
+ },
+ })
+
+ const deletePlanMutation = useMutation({
+ mutationFn: (id) => savingsApi.deleteRecurringPlan(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["recurring-plans", category.id] })
+ queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+ },
+ })
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+ const data = {
+ effective: planForm.effective,
+ amount: parseFloat(planForm.amount),
+ day: parseInt(planForm.day) || 1,
+ }
+ if (planForm.user_id) {
+ data.user_id = parseInt(planForm.user_id)
+ }
+
+ if (editingPlan) {
+ updatePlanMutation.mutate({ id: editingPlan.id, data })
+ } else {
+ createPlanMutation.mutate(data)
+ }
+ }
+
+ const startEdit = (plan) => {
+ setEditingPlan(plan)
+ setPlanForm({
+ effective: plan.effective?.split("T")[0] || "",
+ amount: plan.amount?.toString() || "",
+ day: plan.day?.toString() || "1",
+ user_id: plan.user_id?.toString() || "",
+ })
+ setShowAddForm(true)
+ }
+
+ if (!isOpen || !category) return null
+
+ return (
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 overflow-y-auto" onClick={onClose}>
+ <motion.div
+ initial={{ opacity: 0, scale: 0.95 }}
+ animate={{ opacity: 1, scale: 1 }}
+ className="w-full max-w-lg bg-white dark:bg-gray-900 rounded-2xl p-6 my-8"
+ onClick={(e) => e.stopPropagation()}
+ >
+ <div className="flex justify-between items-center mb-6">
+ <h2 className="text-xl font-bold text-gray-900 dark:text-white">
+ Планы: {category.name}
+ </h2>
+ <button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
+ <X size={20} />
+ </button>
+ </div>
+
+ {/* Plans List */}
+ <div className="space-y-3 mb-4 max-h-[300px] overflow-y-auto">
+ {isLoading ? (
+ <p className="text-center text-gray-500">Загрузка...</p>
+ ) : plans.length === 0 ? (
+ <p className="text-center text-gray-500 py-4">Нет планов</p>
+ ) : (
+ plans.map((plan) => (
+ <div key={plan.id} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
+ <div>
+ <div className="font-medium text-gray-900 dark:text-white">
+ {formatCurrency(plan.amount)} / мес
+ </div>
+ <div className="text-xs text-gray-500">
+ С {format(new Date(plan.effective), "d MMM yyyy", { locale: ru })} · {plan.day} числа
+ {plan.user_id && members.length > 0 && (
+ <span className="ml-1">
+ · {members.find(m => m.user_id === plan.user_id)?.user_name || `ID: ${plan.user_id}`}
+ </span>
+ )}
+ </div>
+ </div>
+ <div className="flex gap-1">
+ <button onClick={() => startEdit(plan)} className="p-1.5 text-gray-400 hover:text-violet-500 rounded">
+ <Edit2 size={16} />
+ </button>
+ <button onClick={() => deletePlanMutation.mutate(plan.id)} className="p-1.5 text-gray-400 hover:text-red-500 rounded">
+ <Trash2 size={16} />
+ </button>
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+
+ {/* Add/Edit Form */}
+ {showAddForm ? (
+ <form onSubmit={handleSubmit} className="space-y-3 p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl">
+ <h4 className="font-medium text-emerald-800 dark:text-emerald-300">
+ {editingPlan ? "Редактировать план" : "Добавить план"}
+ </h4>
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Дата начала</label>
+ <input
+ type="date"
+ value={planForm.effective}
+ onChange={(e) => setPlanForm({ ...planForm, effective: e.target.value })}
+ className="input w-full text-sm"
+ required
+ />
+ </div>
+ <div>
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Сумма</label>
+ <input
+ type="number"
+ value={planForm.amount}
+ onChange={(e) => setPlanForm({ ...planForm, amount: e.target.value })}
+ className="input w-full text-sm"
+ step="100"
+ required
+ />
+ </div>
+ <div>
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">День месяца</label>
+ <input
+ type="number"
+ value={planForm.day}
+ onChange={(e) => setPlanForm({ ...planForm, day: e.target.value })}
+ className="input w-full text-sm"
+ min="1"
+ max="28"
+ />
+ </div>
+ {category.is_multi && members.length > 0 && (
+ <div>
+ <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Участник</label>
+ <select
+ value={planForm.user_id}
+ onChange={(e) => setPlanForm({ ...planForm, user_id: e.target.value })}
+ className="input w-full text-sm"
+ >
+ <option value="">Общий план</option>
+ {members.map((m) => (
+ <option key={m.user_id} value={m.user_id}>{m.user_name}</option>
+ ))}
+ </select>
+ </div>
+ )}
+ </div>
+ <div className="flex gap-2">
+ <button type="submit" className="btn btn-primary flex-1">
+ {editingPlan ? "Сохранить" : "Добавить"}
+ </button>
+ <button
+ type="button"
+ onClick={() => { setShowAddForm(false); setEditingPlan(null); setPlanForm({ effective: "", amount: "", day: "1", user_id: "" }) }}
+ className="btn flex-1"
+ >
+ Отмена
+ </button>
+ </div>
+ </form>
+ ) : (
+ <button
+ onClick={() => setShowAddForm(true)}
+ className="btn btn-primary w-full flex items-center justify-center gap-2"
+ >
+ <Plus size={18} /> Добавить план
+ </button>
+ )}
+ </motion.div>
+ </div>
+ )
+}
+
+// ==================== MAIN COMPONENT ====================
+export default function Savings() {
+ const [activeTab, setActiveTab] = useState("dashboard")
+ const [showTransactionModal, setShowTransactionModal] = useState(false)
+ const [showCategoryModal, setShowCategoryModal] = useState(false)
+ const [showDeleteModal, setShowDeleteModal] = useState(false)
+ const [showDeleteTransactionModal, setShowDeleteTransactionModal] = useState(false)
+ const [showPlansModal, setShowPlansModal] = useState(false)
+ const [selectedCategory, setSelectedCategory] = useState(null)
+ const [categoryToDelete, setCategoryToDelete] = useState(null)
+ const [selectedTransaction, setSelectedTransaction] = useState(null)
+ const [transactionToDelete, setTransactionToDelete] = useState(null)
+ const [categoryForPlans, setCategoryForPlans] = useState(null)
+ const queryClient = useQueryClient()
+
+ const { data: categories = [], isLoading } = useQuery({
+ queryKey: ["savings-categories"],
+ queryFn: savingsApi.listCategories,
+ })
+
+ const { data: transactions = [] } = useQuery({
+ queryKey: ["savings-transactions"],
+ queryFn: () => savingsApi.listTransactions(null, 100),
+ })
+
+ const { data: stats } = useQuery({
+ queryKey: ["savings-stats"],
+ queryFn: savingsApi.getStats,
+ })
+
+ const createTransactionMutation = useMutation({
+ mutationFn: savingsApi.createTransaction,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["savings-categories"] })
+ queryClient.invalidateQueries({ queryKey: ["savings-transactions"] })
+ queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+ },
+ })
+
+ const updateTransactionMutation = useMutation({
+ mutationFn: ({ id, data }) => savingsApi.updateTransaction(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["savings-categories"] })
+ queryClient.invalidateQueries({ queryKey: ["savings-transactions"] })
+ queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+ },
+ })
+
+ const deleteTransactionMutation = useMutation({
+ mutationFn: savingsApi.deleteTransaction,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["savings-categories"] })
+ queryClient.invalidateQueries({ queryKey: ["savings-transactions"] })
+ queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+ },
+ })
+
+ const createCategoryMutation = useMutation({
+ mutationFn: savingsApi.createCategory,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["savings-categories"] })
+ queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+ },
+ })
+
+ const updateCategoryMutation = useMutation({
+ mutationFn: ({ id, data }) => savingsApi.updateCategory(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["savings-categories"] })
+ queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+ },
+ })
+
+ const deleteCategoryMutation = useMutation({
+ mutationFn: savingsApi.deleteCategory,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["savings-categories"] })
+ queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+ },
+ })
+
+ const handleEditCategory = (category) => {
+ setSelectedCategory(category)
+ setShowCategoryModal(true)
+ }
+
+ const handleDeleteCategory = (category) => {
+ setCategoryToDelete(category)
+ setShowDeleteModal(true)
+ }
+
+ const handleManagePlans = (category) => {
+ setCategoryForPlans(category)
+ setShowPlansModal(true)
+ }
+
+ const handleSaveCategory = (id, data) => {
+ if (id) {
+ updateCategoryMutation.mutate({ id, data })
+ } else {
+ createCategoryMutation.mutate(data)
+ }
+ }
+
+ const handleEditTransaction = (transaction) => {
+ setSelectedTransaction(transaction)
+ setShowTransactionModal(true)
+ }
+
+ const handleDeleteTransaction = (transaction) => {
+ setTransactionToDelete(transaction)
+ setShowDeleteTransactionModal(true)
+ }
+
+ const handleSaveTransaction = (data, id) => {
+ if (id) {
+ updateTransactionMutation.mutate({ id, data })
+ } else {
+ createTransactionMutation.mutate(data)
+ }
+ }
+
+ return (
+ <div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300">
+ <header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
+ <div className="max-w-lg mx-auto px-4 py-4">
+ <div className="flex items-center justify-between mb-4">
+ <div>
+ <h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Накопления</h1>
+ <p className="text-sm text-gray-500 dark:text-gray-400">
+ {categories.filter((c) => !c.is_closed).length} категорий
+ </p>
+ </div>
+ <div className="flex gap-2">
+ {activeTab === "categories" && (
+ <button
+ onClick={() => { setSelectedCategory(null); setShowCategoryModal(true) }}
+ className="btn btn-primary flex items-center gap-2"
+ >
+ <Plus size={18} />
+ <span className="hidden sm:inline">Категория</span>
+ </button>
+ )}
+ {activeTab !== "categories" && (
+ <button
+ onClick={() => { setSelectedTransaction(null); setShowTransactionModal(true) }}
+ className="btn btn-primary flex items-center gap-2"
+ >
+ <Plus size={18} />
+ <span className="hidden sm:inline">Операция</span>
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ </header>
+
+ <main className="max-w-lg mx-auto px-4 py-6">
+ <TabNav activeTab={activeTab} setActiveTab={setActiveTab} />
+
+ {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-12 h-12 rounded-xl bg-gray-200 dark:bg-gray-700" />
+ <div className="flex-1">
+ <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/2 mb-2" />
+ <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/3" />
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : (
+ <>
+ {activeTab === "dashboard" && <DashboardTab categories={categories} stats={stats} />}
+ {activeTab === "categories" && (
+ <CategoriesTab
+ categories={categories}
+ onEdit={handleEditCategory}
+ onDelete={handleDeleteCategory}
+ onManagePlans={handleManagePlans}
+ />
+ )}
+ {activeTab === "transactions" && (
+ <TransactionsTab
+ transactions={transactions}
+ categories={categories}
+ onAddTransaction={() => { setSelectedTransaction(null); setShowTransactionModal(true) }}
+ onEditTransaction={handleEditTransaction}
+ onDeleteTransaction={handleDeleteTransaction}
+ />
+ )}
+ </>
+ )}
+ </main>
+
+ <Navigation />
+
+ <AnimatePresence>
+ {showTransactionModal && (
+ <TransactionModal
+ isOpen={showTransactionModal}
+ onClose={() => { setShowTransactionModal(false); setSelectedTransaction(null) }}
+ categories={categories}
+ onSubmit={handleSaveTransaction}
+ transaction={selectedTransaction}
+ />
+ )}
+ {showDeleteTransactionModal && (
+ <DeleteTransactionModal
+ isOpen={showDeleteTransactionModal}
+ onClose={() => { setShowDeleteTransactionModal(false); setTransactionToDelete(null) }}
+ transaction={transactionToDelete}
+ onConfirm={(id) => deleteTransactionMutation.mutate(id)}
+ />
+ )}
+ {showCategoryModal && (
+ <CategoryEditModal
+ isOpen={showCategoryModal}
+ onClose={() => { setShowCategoryModal(false); setSelectedCategory(null) }}
+ category={selectedCategory}
+ onSubmit={handleSaveCategory}
+ />
+ )}
+ {showDeleteModal && (
+ <DeleteModal
+ isOpen={showDeleteModal}
+ onClose={() => { setShowDeleteModal(false); setCategoryToDelete(null) }}
+ category={categoryToDelete}
+ onConfirm={(id) => deleteCategoryMutation.mutate(id)}
+ />
+ )}
+ {showPlansModal && (
+ <RecurringPlansModal
+ isOpen={showPlansModal}
+ onClose={() => { setShowPlansModal(false); setCategoryForPlans(null) }}
+ category={categoryForPlans}
+ queryClient={queryClient}
+ />
+ )}
+ </AnimatePresence>
+ </div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 | + + + + + + + +1x + + + + + + + + + + + + + + + + + + + +30x +30x +30x +30x +30x +30x +30x +30x +30x +30x + +30x + + + + +30x +15x +7x +7x +7x +7x +7x +7x + + + +30x +23x + +15x + + + + + +15x + + + +30x + + +1x +1x + + + +30x +1x + + + + + + +1x +1x + + +1x +1x + + +1x + + +30x + + + + + +30x +8x + + + + + + +22x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +352x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState, useEffect } from "react"
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
+import { ArrowLeft, Bell, MessageCircle, Globe, Save, Copy, Check, User, Sun, Moon, Palette } from "lucide-react"
+import { Link } from "react-router-dom"
+import { profileApi } from "../api/profile"
+import { useTheme } from "../contexts/ThemeContext"
+import Navigation from "../components/Navigation"
+
+const TIMEZONES = [
+ { value: "Europe/Moscow", label: "Москва (UTC+3)" },
+ { value: "Europe/Kaliningrad", label: "Калининград (UTC+2)" },
+ { value: "Europe/Samara", label: "Самара (UTC+4)" },
+ { value: "Asia/Yekaterinburg", label: "Екатеринбург (UTC+5)" },
+ { value: "Asia/Omsk", label: "Омск (UTC+6)" },
+ { value: "Asia/Krasnoyarsk", label: "Красноярск (UTC+7)" },
+ { value: "Asia/Irkutsk", label: "Иркутск (UTC+8)" },
+ { value: "Asia/Yakutsk", label: "Якутск (UTC+9)" },
+ { value: "Asia/Vladivostok", label: "Владивосток (UTC+10)" },
+ { value: "Asia/Magadan", label: "Магадан (UTC+11)" },
+ { value: "Asia/Kamchatka", label: "Камчатка (UTC+12)" },
+ { value: "Asia/Tokyo", label: "Токио (UTC+9)" },
+ { value: "Europe/London", label: "Лондон (UTC+0)" },
+ { value: "Europe/Berlin", label: "Берлин (UTC+1)" },
+ { value: "America/New_York", label: "Нью-Йорк (UTC-5)" },
+ { value: "America/Los_Angeles", label: "Лос-Анджелес (UTC-8)" },
+]
+
+export default function Settings() {
+ const queryClient = useQueryClient()
+ const { theme, toggleTheme } = useTheme()
+ const [copied, setCopied] = useState(false)
+ const [username, setUsername] = useState("")
+ const [chatId, setChatId] = useState("")
+ const [notificationsEnabled, setNotificationsEnabled] = useState(true)
+ const [timezone, setTimezone] = useState("Europe/Moscow")
+ const [morningTime, setMorningTime] = useState("09:00")
+ const [eveningTime, setEveningTime] = useState("21:00")
+ const [hasChanges, setHasChanges] = useState(false)
+
+ const { data: profile, isLoading } = useQuery({
+ queryKey: ["profile"],
+ queryFn: profileApi.get,
+ })
+
+ useEffect(() => {
+ if (profile) {
+ setUsername(profile.username || "")
+ setChatId(profile.telegram_chat_id?.toString() || "")
+ setNotificationsEnabled(profile.notifications_enabled ?? true)
+ setTimezone(profile.timezone || "Europe/Moscow")
+ setMorningTime(profile.morning_reminder_time || "09:00")
+ setEveningTime(profile.evening_reminder_time || "21:00")
+ }
+ }, [profile])
+
+ useEffect(() => {
+ if (profile) {
+ const changed =
+ username !== (profile.username || "") ||
+ (chatId !== (profile.telegram_chat_id?.toString() || "")) ||
+ notificationsEnabled !== (profile.notifications_enabled ?? true) ||
+ timezone !== (profile.timezone || "Europe/Moscow") ||
+ morningTime !== (profile.morning_reminder_time || "09:00") ||
+ eveningTime !== (profile.evening_reminder_time || "21:00")
+ setHasChanges(changed)
+ }
+ }, [username, chatId, notificationsEnabled, timezone, morningTime, eveningTime, profile])
+
+ const mutation = useMutation({
+ mutationFn: profileApi.update,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["profile"] })
+ setHasChanges(false)
+ },
+ })
+
+ const handleSave = () => {
+ const data = {
+ notifications_enabled: notificationsEnabled,
+ timezone: timezone,
+ morning_reminder_time: morningTime,
+ evening_reminder_time: eveningTime,
+ }
+
+ Eif (username && username !== profile?.username) {
+ data.username = username
+ }
+
+ Eif (chatId) {
+ data.telegram_chat_id = parseInt(chatId, 10)
+ }
+
+ mutation.mutate(data)
+ }
+
+ const copyInstruction = () => {
+ navigator.clipboard.writeText("@pulse_tracking_bot")
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ if (isLoading) {
+ return (
+ <div className="min-h-screen bg-surface-50 dark:bg-gray-950 flex items-center justify-center">
+ <div className="w-10 h-10 border-4 border-primary-500 border-t-transparent rounded-full animate-spin" />
+ </div>
+ )
+ }
+
+ return (
+ <div className="min-h-screen bg-surface-50 dark:bg-gray-950 pb-24 transition-colors duration-300">
+ {/* Header */}
+ <header className="bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl sticky top-0 z-40 border-b border-gray-100 dark:border-gray-800">
+ <div className="max-w-lg mx-auto px-4 py-4 flex items-center gap-3">
+ <Link to="/" className="p-2 -ml-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800">
+ <ArrowLeft size={20} />
+ </Link>
+ <h1 className="text-xl font-bold dark:text-white">Настройки</h1>
+ </div>
+ </header>
+
+ <main className="max-w-lg mx-auto px-4 py-6 space-y-6">
+
+ {/* Theme Section */}
+ <section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
+ <div className="flex items-center gap-3 mb-4">
+ <div className="w-10 h-10 rounded-xl bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
+ <Palette className="text-violet-600 dark:text-violet-400" size={20} />
+ </div>
+ <div>
+ <h2 className="font-semibold dark:text-white">Оформление</h2>
+ <p className="text-sm text-gray-500 dark:text-gray-400">Выбери тему приложения</p>
+ </div>
+ </div>
+
+ <button
+ onClick={toggleTheme}
+ className="w-full flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-xl transition-all hover:bg-gray-100 dark:hover:bg-gray-700"
+ >
+ <div className="flex items-center gap-3">
+ {theme === "dark" ? (
+ <Moon className="text-primary-500" size={22} />
+ ) : (
+ <Sun className="text-amber-500" size={22} />
+ )}
+ <span className="font-medium text-gray-900 dark:text-white">
+ {theme === "dark" ? "Тёмная тема" : "Светлая тема"}
+ </span>
+ </div>
+ <div className="relative">
+ <div className={`w-14 h-8 rounded-full transition-colors duration-300 ${theme === "dark" ? "bg-primary-500" : "bg-gray-300"}`}>
+ <div className={`absolute top-1 w-6 h-6 bg-white rounded-full shadow-md transition-transform duration-300 flex items-center justify-center ${theme === "dark" ? "translate-x-7" : "translate-x-1"}`}>
+ {theme === "dark" ? (
+ <Moon className="text-primary-600" size={14} />
+ ) : (
+ <Sun className="text-amber-500" size={14} />
+ )}
+ </div>
+ </div>
+ </div>
+ </button>
+ </section>
+
+ {/* Profile Section */}
+ <section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
+ <div className="flex items-center gap-3 mb-4">
+ <div className="w-10 h-10 rounded-xl bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
+ <User className="text-green-600 dark:text-green-400" size={20} />
+ </div>
+ <div>
+ <h2 className="font-semibold dark:text-white">Профиль</h2>
+ <p className="text-sm text-gray-500 dark:text-gray-400">Основная информация</p>
+ </div>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
+ Имя пользователя
+ </label>
+ <input
+ type="text"
+ value={username}
+ onChange={(e) => setUsername(e.target.value)}
+ placeholder="Ваше имя"
+ className="input"
+ />
+ </div>
+ </section>
+
+ {/* Telegram Section */}
+ <section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
+ <div className="flex items-center gap-3 mb-4">
+ <div className="w-10 h-10 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
+ <MessageCircle className="text-blue-600 dark:text-blue-400" size={20} />
+ </div>
+ <div>
+ <h2 className="font-semibold dark:text-white">Telegram</h2>
+ <p className="text-sm text-gray-500 dark:text-gray-400">Получай уведомления в Telegram</p>
+ </div>
+ </div>
+
+ <div className="space-y-4">
+ <div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
+ <p className="text-sm text-blue-800 dark:text-blue-300 mb-2">
+ 1. Напиши <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/start</code> боту в Telegram
+ </p>
+ <button
+ onClick={copyInstruction}
+ className="flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
+ >
+ {copied ? <Check size={16} /> : <Copy size={16} />}
+ {copied ? "Скопировано!" : "@pulse_tracking_bot"}
+ </button>
+ <p className="text-sm text-blue-800 dark:text-blue-300 mt-2">
+ 2. Скопируй Chat ID из ответа бота и вставь ниже
+ </p>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
+ Chat ID
+ </label>
+ <input
+ type="text"
+ value={chatId}
+ onChange={(e) => setChatId(e.target.value.replace(/\D/g, ""))}
+ placeholder="Например: 123456789"
+ className="input"
+ />
+ </div>
+ </div>
+ </section>
+
+ {/* Notifications Section */}
+ <section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
+ <div className="flex items-center gap-3 mb-4">
+ <div className="w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
+ <Bell className="text-orange-600 dark:text-orange-400" size={20} />
+ </div>
+ <div>
+ <h2 className="font-semibold dark:text-white">Уведомления</h2>
+ <p className="text-sm text-gray-500 dark:text-gray-400">Настрой ежедневные уведомления</p>
+ </div>
+ </div>
+
+ <div className="space-y-4">
+ <label className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-xl cursor-pointer">
+ <span className="text-sm font-medium dark:text-white">Включить уведомления</span>
+ <div className="relative">
+ <input
+ type="checkbox"
+ checked={notificationsEnabled}
+ onChange={(e) => setNotificationsEnabled(e.target.checked)}
+ className="sr-only peer"
+ />
+ <div className="w-11 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary-500 transition-colors"></div>
+ <div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full peer-checked:translate-x-5 transition-transform"></div>
+ </div>
+ </label>
+
+ {notificationsEnabled && (
+ <>
+ <div className="flex items-center gap-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-xl">
+ <Sun className="text-yellow-600 dark:text-yellow-400" size={20} />
+ <div className="flex-1">
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
+ Утреннее уведомление
+ </label>
+ <p className="text-xs text-gray-500 dark:text-gray-400">Задачи и привычки на сегодня</p>
+ </div>
+ <input
+ type="time"
+ value={morningTime}
+ onChange={(e) => setMorningTime(e.target.value)}
+ className="px-3 py-1.5 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-lg text-sm"
+ />
+ </div>
+
+ <div className="flex items-center gap-3 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-xl">
+ <Moon className="text-indigo-600 dark:text-indigo-400" size={20} />
+ <div className="flex-1">
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
+ Вечернее уведомление
+ </label>
+ <p className="text-xs text-gray-500 dark:text-gray-400">Итоги дня: выполнено / осталось</p>
+ </div>
+ <input
+ type="time"
+ value={eveningTime}
+ onChange={(e) => setEveningTime(e.target.value)}
+ className="px-3 py-1.5 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-lg text-sm"
+ />
+ </div>
+ </>
+ )}
+ </div>
+ </section>
+
+ {/* Timezone Section */}
+ <section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
+ <div className="flex items-center gap-3 mb-4">
+ <div className="w-10 h-10 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
+ <Globe className="text-purple-600 dark:text-purple-400" size={20} />
+ </div>
+ <div>
+ <h2 className="font-semibold dark:text-white">Часовой пояс</h2>
+ <p className="text-sm text-gray-500 dark:text-gray-400">Для корректных напоминаний</p>
+ </div>
+ </div>
+
+ <select
+ value={timezone}
+ onChange={(e) => setTimezone(e.target.value)}
+ className="input"
+ >
+ {TIMEZONES.map((tz) => (
+ <option key={tz.value} value={tz.value}>
+ {tz.label}
+ </option>
+ ))}
+ </select>
+ </section>
+
+ {/* Save Button */}
+ {hasChanges && (
+ <button
+ onClick={handleSave}
+ disabled={mutation.isPending}
+ className="btn btn-primary w-full flex items-center justify-center gap-2"
+ >
+ <Save size={18} />
+ {mutation.isPending ? "Сохраняем..." : "Сохранить изменения"}
+ </button>
+ )}
+
+ {mutation.isSuccess && !hasChanges && (
+ <div className="p-3 rounded-xl bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 text-sm text-center">
+ ✅ Настройки сохранены
+ </div>
+ )}
+ </main>
+
+ <Navigation />
+ </div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 | + + + + + + + + + + + + +348x +348x +348x + + + + +8x + + + + + + + + + + +348x +348x +348x +8x + +8x +348x +8x + + + +8x + + + +8x + + + +1x + + + + + + + + + + + + +1x +24x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x +504x +504x + +504x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x +12x + + + + + + + + + + + +7x +7x +7x +7x +7x + +7x + + + + +7x +5x + + +7x +1x +1x +1x + +1x +1x +1x + + + + + + + + +1x +1x +1x + + + +1x +1x +1x + + + +7x +6x + + + +6x +6x +6x +6x + +6x +2x +2x +2x + +2x + +2x +60x +60x + + +2x + + + + + +6x + +6x + + + +7x +6x +6x +6x + + +6x +6x + +6x +504x +504x +504x + +504x + + + +504x +168x +168x + +168x +168x + + +504x + + + + +7x +6x +6x +6x +504x +504x +18x +18x + + +6x + + + +7x +6x +6x +180x +180x +180x +180x + +180x + + + +180x +60x +60x + +60x +60x + + +180x +180x + +180x + + + +7x +6x +2x +2x + +2x +2x +60x +60x + + +2x + + + + +2x + +2x + + + + + + + + + + + +7x +504x +2x +2x + + + + + + +7x + + +7x +6x +6x +72x + +6x + + +7x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +18x + + + + + + + + + + + + + + + + +42x + + + + + + + + +72x + +504x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState, useEffect, useMemo, useRef } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { motion, AnimatePresence } from 'framer-motion'
+import { ChevronDown, Flame, Trophy, CheckCircle2, TrendingUp, BarChart3, Calendar, Sparkles, Target, Zap } from 'lucide-react'
+import { format, subDays, parseISO, startOfDay, differenceInDays, isBefore, isAfter, eachDayOfInterval, startOfMonth, getDay, addDays } from 'date-fns'
+import { ru } from 'date-fns/locale'
+import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, Area, AreaChart, CartesianGrid } from 'recharts'
+import { habitsApi } from '../api/habits'
+import Navigation from '../components/Navigation'
+import clsx from 'clsx'
+
+// Получить дату начала привычки
+function getHabitStartDate(habit) {
+ Iif (habit.start_date) return startOfDay(parseISO(habit.start_date))
+ Iif (habit.created_at) return startOfDay(parseISO(habit.created_at))
+ return startOfDay(new Date())
+}
+
+// Check if habit is frozen on date
+function isHabitFrozenOnDate(freezes, date) {
+ Eif (!freezes || freezes.length === 0) return false
+ const checkDate = startOfDay(date)
+ return freezes.some(freeze => {
+ const start = startOfDay(parseISO(freeze.start_date))
+ const end = startOfDay(parseISO(freeze.end_date))
+ return !isBefore(checkDate, start) && !isAfter(checkDate, end)
+ })
+}
+
+// Проверить ожидается ли привычка в дату
+function isHabitExpectedOnDate(habit, date, freezes) {
+ const checkDate = startOfDay(date)
+ const startDate = getHabitStartDate(habit)
+ if (checkDate < startDate || checkDate > startOfDay(new Date())) return false
+ Iif (isHabitFrozenOnDate(freezes, date)) return false
+
+ const dayOfWeek = checkDate.getDay() || 7
+ Iif (habit.frequency === "daily") return true
+ Iif (habit.frequency === "weekly") {
+ if (habit.target_days?.length > 0) return habit.target_days.includes(dayOfWeek)
+ return true
+ }
+ Iif (habit.frequency === "interval" && habit.target_count > 0) {
+ const daysSinceStart = differenceInDays(checkDate, startDate)
+ return daysSinceStart % habit.target_count === 0
+ }
+ return true
+}
+
+// Custom Tooltip Component
+const CustomTooltip = ({ active, payload, label }) => {
+ if (active && payload && payload.length) {
+ return (
+ <div className="bg-gray-900/95 backdrop-blur-sm px-4 py-3 rounded-xl shadow-2xl border border-gray-700/50">
+ <p className="text-gray-400 text-xs mb-1">{label}</p>
+ <p className="text-white font-bold text-lg">{payload[0].value}%</p>
+ </div>
+ )
+ }
+ return null
+}
+
+// Stat Card Component
+const StatCard = ({ icon, value, label, emoji, color, delay = 0 }) => (
+ <motion.div
+ initial={{ opacity: 0, y: 20, scale: 0.95 }}
+ animate={{ opacity: 1, y: 0, scale: 1 }}
+ transition={{ delay, duration: 0.4, ease: "easeOut" }}
+ className="relative overflow-hidden group"
+ >
+ <div className="absolute inset-0 bg-gradient-to-br from-white/80 to-white/40 dark:from-gray-800/80 dark:to-gray-900/40 backdrop-blur-xl rounded-2xl" />
+ <div className={clsx(
+ "absolute inset-0 opacity-[0.03] group-hover:opacity-[0.06] transition-opacity duration-500",
+ `bg-gradient-to-br ${color}`
+ )} />
+ <div className="relative p-5 flex items-center gap-4">
+ <div className={clsx(
+ "w-14 h-14 rounded-2xl flex items-center justify-center text-2xl",
+ "bg-gradient-to-br shadow-lg",
+ color
+ )}>
+ {emoji}
+ </div>
+ <div className="flex-1">
+ <p className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">
+ {value}
+ </p>
+ <p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{label}</p>
+ </div>
+ </div>
+ <div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r opacity-50 rounded-b-2xl"
+ style={{ background: `linear-gradient(to right, var(--tw-gradient-stops))` }} />
+ </motion.div>
+)
+
+// Heatmap Cell Component
+const HeatmapCell = ({ day, getColor, index }) => {
+ const [showTooltip, setShowTooltip] = useState(false)
+ const cellRef = useRef(null)
+
+ return (
+ <div className="relative" ref={cellRef}>
+ <motion.div
+ initial={{ scale: 0, opacity: 0 }}
+ animate={{ scale: 1, opacity: 1 }}
+ transition={{ delay: index * 0.003, duration: 0.2 }}
+ className={clsx(
+ "w-full aspect-square rounded-[4px] cursor-pointer transition-all duration-200",
+ "hover:ring-2 hover:ring-primary-400 hover:ring-offset-2 hover:ring-offset-gray-900",
+ "hover:scale-110 hover:z-10",
+ getColor(day.count, day.expected)
+ )}
+ onMouseEnter={() => setShowTooltip(true)}
+ onMouseLeave={() => setShowTooltip(false)}
+ />
+ <AnimatePresence>
+ {showTooltip && (
+ <motion.div
+ initial={{ opacity: 0, y: 5, scale: 0.9 }}
+ animate={{ opacity: 1, y: 0, scale: 1 }}
+ exit={{ opacity: 0, y: 5, scale: 0.9 }}
+ className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 pointer-events-none"
+ >
+ <div className="bg-gray-900 text-white px-3 py-2 rounded-lg shadow-xl text-xs whitespace-nowrap">
+ <p className="font-medium">{format(day.date, 'd MMMM', { locale: ru })}</p>
+ <p className="text-primary-400 mt-0.5">
+ {day.count}/{day.expected} выполнено
+ </p>
+ </div>
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </div>
+ )
+}
+
+// Section Header Component
+const SectionHeader = ({ icon: Icon, title, subtitle }) => (
+ <div className="flex items-center gap-3 mb-5">
+ <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500/20 to-primary-600/10 flex items-center justify-center">
+ <Icon className="text-primary-500" size={20} />
+ </div>
+ <div>
+ <h3 className="text-lg font-bold text-gray-900 dark:text-white">{title}</h3>
+ {subtitle && <p className="text-xs text-gray-500 dark:text-gray-400">{subtitle}</p>}
+ </div>
+ </div>
+)
+
+export default function Stats({ embedded = false }) {
+ const [selectedHabitId, setSelectedHabitId] = useState(null)
+ const [allHabitLogs, setAllHabitLogs] = useState({})
+ const [allHabitStats, setAllHabitStats] = useState({})
+ const [allHabitFreezes, setAllHabitFreezes] = useState({})
+ const [dropdownOpen, setDropdownOpen] = useState(false)
+
+ const { data: habits = [] } = useQuery({
+ queryKey: ['habits'],
+ queryFn: habitsApi.list,
+ })
+
+ useEffect(() => {
+ if (habits.length > 0) loadAllHabitsData()
+ }, [habits])
+
+ const loadAllHabitsData = async () => {
+ const logsMap = {}
+ const statsMap = {}
+ const freezesMap = {}
+
+ await Promise.all(habits.map(async (habit) => {
+ try {
+ const [logs, stats, freezes] = await Promise.all([
+ habitsApi.getLogs(habit.id, 90),
+ habitsApi.getHabitStats(habit.id),
+ habitsApi.getFreezes(habit.id),
+ ])
+ logsMap[habit.id] = logs
+ statsMap[habit.id] = stats
+ freezesMap[habit.id] = freezes
+ } catch (e) {
+ logsMap[habit.id] = []
+ statsMap[habit.id] = null
+ freezesMap[habit.id] = []
+ }
+ }))
+
+ setAllHabitLogs(logsMap)
+ setAllHabitStats(statsMap)
+ setAllHabitFreezes(freezesMap)
+ }
+
+ // Combined stats for all or selected habit
+ const computedStats = useMemo(() => {
+ const targetHabits = selectedHabitId
+ ? habits.filter(h => h.id === selectedHabitId)
+ : habits
+
+ let totalLogs = 0
+ let totalExpected = 0
+ let currentStreak = 0
+ let bestStreak = 0
+
+ targetHabits.forEach(habit => {
+ const logs = allHabitLogs[habit.id] || []
+ const stats = allHabitStats[habit.id]
+ const freezes = allHabitFreezes[habit.id] || []
+
+ totalLogs += logs.length
+
+ for (let i = 0; i < 30; i++) {
+ const date = subDays(new Date(), i)
+ if (isHabitExpectedOnDate(habit, date, freezes)) totalExpected++
+ }
+
+ Iif (stats) {
+ currentStreak = Math.max(currentStreak, stats.current_streak || 0)
+ bestStreak = Math.max(bestStreak, stats.longest_streak || 0)
+ }
+ })
+
+ const rate = totalExpected > 0 ? Math.round((totalLogs / totalExpected) * 100) : 0
+
+ return { totalLogs, currentStreak, bestStreak, rate }
+ }, [selectedHabitId, habits, allHabitLogs, allHabitStats, allHabitFreezes])
+
+ // Heatmap data (12 weeks = 84 days)
+ const heatmapData = useMemo(() => {
+ const today = startOfDay(new Date())
+ const startDate = subDays(today, 83)
+ const days = eachDayOfInterval({ start: startDate, end: today })
+
+ // Align to week start (Monday)
+ const firstDayOfWeek = getDay(startDate) || 7
+ const paddingDays = firstDayOfWeek - 1
+
+ return days.map(day => {
+ const dateStr = format(day, 'yyyy-MM-dd')
+ let count = 0
+ let expected = 0
+
+ const targetHabits = selectedHabitId
+ ? habits.filter(h => h.id === selectedHabitId)
+ : habits
+
+ targetHabits.forEach(habit => {
+ const logs = allHabitLogs[habit.id] || []
+ const freezes = allHabitFreezes[habit.id] || []
+
+ Iif (logs.some(l => l.date.split('T')[0] === dateStr)) count++
+ if (isHabitExpectedOnDate(habit, day, freezes)) expected++
+ })
+
+ return { date: day, dateStr, count, expected }
+ })
+ }, [selectedHabitId, habits, allHabitLogs, allHabitFreezes])
+
+ // Get unique months for heatmap labels
+ const heatmapMonths = useMemo(() => {
+ const months = []
+ let currentMonth = null
+ heatmapData.forEach((day, index) => {
+ const month = format(day.date, 'MMM', { locale: ru })
+ if (month !== currentMonth) {
+ months.push({ month, index: Math.floor(index / 7) })
+ currentMonth = month
+ }
+ })
+ return months
+ }, [heatmapData])
+
+ // Line chart data (30 days completion rate)
+ const lineChartData = useMemo(() => {
+ const data = []
+ for (let i = 29; i >= 0; i--) {
+ const date = subDays(new Date(), i)
+ const dateStr = format(date, 'yyyy-MM-dd')
+ let completed = 0
+ let expected = 0
+
+ const targetHabits = selectedHabitId
+ ? habits.filter(h => h.id === selectedHabitId)
+ : habits
+
+ targetHabits.forEach(habit => {
+ const logs = allHabitLogs[habit.id] || []
+ const freezes = allHabitFreezes[habit.id] || []
+
+ Iif (logs.some(l => l.date.split('T')[0] === dateStr)) completed++
+ if (isHabitExpectedOnDate(habit, date, freezes)) expected++
+ })
+
+ const rate = expected > 0 ? Math.round((completed / expected) * 100) : 0
+ data.push({ date: format(date, 'dd.MM'), fullDate: format(date, 'd MMM', { locale: ru }), rate, completed, expected })
+ }
+ return data
+ }, [selectedHabitId, habits, allHabitLogs, allHabitFreezes])
+
+ // Bar chart data (habits comparison)
+ const barChartData = useMemo(() => {
+ return habits.map(habit => {
+ const logs = allHabitLogs[habit.id] || []
+ const freezes = allHabitFreezes[habit.id] || []
+
+ let expected = 0
+ for (let i = 0; i < 30; i++) {
+ const date = subDays(new Date(), i)
+ if (isHabitExpectedOnDate(habit, date, freezes)) expected++
+ }
+
+ const completed = logs.filter(l => {
+ const logDate = parseISO(l.date.split('T')[0])
+ return differenceInDays(new Date(), logDate) < 30
+ }).length
+
+ const rate = expected > 0 ? Math.round((completed / expected) * 100) : 0
+
+ return {
+ name: habit.name,
+ icon: habit.icon,
+ rate,
+ color: habit.color || '#0d9488',
+ completed,
+ expected
+ }
+ }).sort((a, b) => b.rate - a.rate)
+ }, [habits, allHabitLogs, allHabitFreezes])
+
+ // Heatmap intensity color - 5 levels
+ const getHeatmapColor = (count, expected) => {
+ if (expected === 0) return 'bg-[#1a1a1a]'
+ const ratio = count / expected
+ Eif (ratio === 0) return 'bg-[#1f1f1f] dark:bg-[#1a1a1a]'
+ if (ratio < 0.4) return 'bg-teal-900/80'
+ if (ratio < 0.7) return 'bg-teal-700'
+ if (ratio < 1) return 'bg-teal-600'
+ return 'bg-teal-400 shadow-sm shadow-teal-400/30'
+ }
+
+ const selectedHabit = habits.find(h => h.id === selectedHabitId)
+
+ // Group heatmap by weeks (columns)
+ const heatmapWeeks = useMemo(() => {
+ const weeks = []
+ for (let i = 0; i < heatmapData.length; i += 7) {
+ weeks.push(heatmapData.slice(i, i + 7))
+ }
+ return weeks
+ }, [heatmapData])
+
+ return (
+ <div className={embedded ? "" : "min-h-screen bg-gray-950 pb-24 transition-colors duration-300"}>
+ {/* Gradient Background */}
+ <div className="fixed inset-0 pointer-events-none">
+ <div className="absolute top-0 left-1/4 w-96 h-96 bg-primary-500/10 rounded-full blur-3xl" />
+ <div className="absolute bottom-1/4 right-0 w-80 h-80 bg-teal-500/5 rounded-full blur-3xl" />
+ </div>
+
+ {!embedded && <header className="relative bg-gray-900/50 backdrop-blur-xl border-b border-gray-800/50 sticky top-0 z-20">
+ <div className="max-w-lg mx-auto px-5 py-5">
+ <div className="flex items-center gap-3">
+ <div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-primary-500 to-teal-600 flex items-center justify-center shadow-lg shadow-primary-500/25">
+ <BarChart3 className="text-white" size={22} />
+ </div>
+ <div>
+ <h1 className="text-xl font-bold text-white flex items-center gap-2">
+ Статистика
+ <Sparkles className="text-primary-400" size={16} />
+ </h1>
+ <p className="text-sm text-gray-400">Отслеживай свой прогресс</p>
+ </div>
+ </div>
+ </div>
+ </header>}
+
+ <main className="relative max-w-lg mx-auto px-5 py-6 space-y-8">
+
+ {/* Habit Selector Dropdown */}
+ <motion.div
+ initial={{ opacity: 0, y: -10 }}
+ animate={{ opacity: 1, y: 0 }}
+ className="relative"
+ >
+ <button
+ onClick={() => setDropdownOpen(!dropdownOpen)}
+ className="w-full bg-gray-900/80 backdrop-blur-xl border border-gray-800 rounded-2xl p-4 flex items-center justify-between hover:border-gray-700 transition-all duration-300"
+ >
+ <div className="flex items-center gap-3">
+ {selectedHabit ? (
+ <>
+ <div
+ className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
+ style={{ backgroundColor: selectedHabit.color + '20' }}
+ >
+ {selectedHabit.icon}
+ </div>
+ <div className="text-left">
+ <span className="font-semibold text-white block">{selectedHabit.name}</span>
+ <span className="text-xs text-gray-500">Выбранная привычка</span>
+ </div>
+ </>
+ ) : (
+ <>
+ <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500/20 to-teal-500/10 flex items-center justify-center">
+ <Target className="text-primary-400" size={20} />
+ </div>
+ <div className="text-left">
+ <span className="font-semibold text-white block">Все привычки</span>
+ <span className="text-xs text-gray-500">{habits.length} привычек</span>
+ </div>
+ </>
+ )}
+ </div>
+ <ChevronDown className={clsx(
+ "text-gray-500 transition-transform duration-300",
+ dropdownOpen && "rotate-180"
+ )} size={20} />
+ </button>
+
+ <AnimatePresence>
+ {dropdownOpen && (
+ <motion.div
+ initial={{ opacity: 0, y: -10, scale: 0.95 }}
+ animate={{ opacity: 1, y: 0, scale: 1 }}
+ exit={{ opacity: 0, y: -10, scale: 0.95 }}
+ transition={{ duration: 0.2 }}
+ className="absolute top-full left-0 right-0 mt-2 bg-gray-900 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-800 z-30 overflow-hidden max-h-80 overflow-y-auto"
+ >
+ <button
+ onClick={() => { setSelectedHabitId(null); setDropdownOpen(false) }}
+ className={clsx(
+ "w-full p-4 flex items-center gap-3 hover:bg-gray-800 transition-colors border-b border-gray-800/50",
+ !selectedHabitId && "bg-primary-500/10"
+ )}
+ >
+ <Target className="text-primary-400" size={20} />
+ <span className="font-medium text-white">Все привычки</span>
+ {!selectedHabitId && <div className="ml-auto w-2 h-2 rounded-full bg-primary-400" />}
+ </button>
+ {habits.map(habit => (
+ <button
+ key={habit.id}
+ onClick={() => { setSelectedHabitId(habit.id); setDropdownOpen(false) }}
+ className={clsx(
+ "w-full p-4 flex items-center gap-3 hover:bg-gray-800 transition-colors",
+ selectedHabitId === habit.id && "bg-primary-500/10"
+ )}
+ >
+ <span className="text-xl">{habit.icon}</span>
+ <span className="font-medium text-white">{habit.name}</span>
+ {selectedHabitId === habit.id && <div className="ml-auto w-2 h-2 rounded-full bg-primary-400" />}
+ </button>
+ ))}
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </motion.div>
+
+ {/* Stats Cards */}
+ <section>
+ <div className="grid grid-cols-2 gap-4">
+ <StatCard
+ emoji="🔥"
+ value={computedStats.currentStreak}
+ label="Текущий streak"
+ color="from-orange-500/20 to-red-500/10"
+ delay={0}
+ />
+ <StatCard
+ emoji="🏆"
+ value={computedStats.bestStreak}
+ label="Лучший streak"
+ color="from-yellow-500/20 to-amber-500/10"
+ delay={0.1}
+ />
+ <StatCard
+ emoji="✅"
+ value={computedStats.totalLogs}
+ label="Всего выполнено"
+ color="from-green-500/20 to-emerald-500/10"
+ delay={0.2}
+ />
+ <StatCard
+ emoji="📈"
+ value={`${computedStats.rate}%`}
+ label="Completion rate"
+ color="from-primary-500/20 to-teal-500/10"
+ delay={0.3}
+ />
+ </div>
+ </section>
+
+ {/* Heatmap Calendar */}
+ <motion.section
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ delay: 0.2 }}
+ className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-6"
+ >
+ <SectionHeader
+ icon={Calendar}
+ title="Активность"
+ subtitle="Последние 12 недель"
+ />
+
+ {/* Month labels */}
+ <div className="flex mb-2 ml-8">
+ {heatmapMonths.map((m, i) => (
+ <div
+ key={i}
+ className="text-[10px] text-gray-500 capitalize"
+ style={{
+ position: 'absolute',
+ left: `${32 + m.index * 18}px`
+ }}
+ >
+ {m.month}
+ </div>
+ ))}
+ </div>
+
+ <div className="flex gap-1 mt-6">
+ {/* Day labels */}
+ <div className="flex flex-col gap-[3px] pr-2">
+ {['Пн', '', 'Ср', '', 'Пт', '', 'Вс'].map((d, i) => (
+ <div key={i} className="h-[14px] text-[10px] text-gray-500 flex items-center">
+ {d}
+ </div>
+ ))}
+ </div>
+
+ {/* Heatmap grid */}
+ <div className="flex gap-[3px] flex-1">
+ {heatmapWeeks.map((week, weekIndex) => (
+ <div key={weekIndex} className="flex flex-col gap-[3px] flex-1">
+ {week.map((day, dayIndex) => (
+ <HeatmapCell
+ key={day.dateStr}
+ day={day}
+ getColor={getHeatmapColor}
+ index={weekIndex * 7 + dayIndex}
+ />
+ ))}
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* Legend */}
+ <div className="flex items-center justify-end gap-2 mt-5 pt-4 border-t border-gray-800/50">
+ <span className="text-xs text-gray-500">Меньше</span>
+ <div className="flex gap-1">
+ <div className="w-3.5 h-3.5 rounded-[3px] bg-[#1f1f1f]" />
+ <div className="w-3.5 h-3.5 rounded-[3px] bg-teal-900/80" />
+ <div className="w-3.5 h-3.5 rounded-[3px] bg-teal-700" />
+ <div className="w-3.5 h-3.5 rounded-[3px] bg-teal-600" />
+ <div className="w-3.5 h-3.5 rounded-[3px] bg-teal-400" />
+ </div>
+ <span className="text-xs text-gray-500">Больше</span>
+ </div>
+ </motion.section>
+
+ {/* Line Chart - Completion Rate */}
+ <motion.section
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ delay: 0.3 }}
+ className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-6"
+ >
+ <SectionHeader
+ icon={TrendingUp}
+ title="Completion Rate"
+ subtitle="Динамика за 30 дней"
+ />
+
+ <div className="h-56">
+ <ResponsiveContainer width="100%" height="100%">
+ <AreaChart data={lineChartData}>
+ <defs>
+ <linearGradient id="colorRate" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0%" stopColor="#0d9488" stopOpacity={0.4} />
+ <stop offset="100%" stopColor="#0d9488" stopOpacity={0} />
+ </linearGradient>
+ </defs>
+ <CartesianGrid
+ strokeDasharray="3 3"
+ stroke="#374151"
+ strokeOpacity={0.3}
+ vertical={false}
+ />
+ <XAxis
+ dataKey="date"
+ tick={{ fontSize: 10, fill: '#6b7280' }}
+ axisLine={false}
+ tickLine={false}
+ interval="preserveStartEnd"
+ />
+ <YAxis
+ tick={{ fontSize: 10, fill: '#6b7280' }}
+ axisLine={false}
+ tickLine={false}
+ domain={[0, 100]}
+ width={30}
+ tickFormatter={(v) => `${v}%`}
+ />
+ <Tooltip content={<CustomTooltip />} />
+ <Area
+ type="monotone"
+ dataKey="rate"
+ stroke="#0d9488"
+ strokeWidth={2.5}
+ fill="url(#colorRate)"
+ dot={false}
+ activeDot={{
+ r: 6,
+ fill: '#0d9488',
+ stroke: '#fff',
+ strokeWidth: 2,
+ className: 'drop-shadow-lg'
+ }}
+ />
+ </AreaChart>
+ </ResponsiveContainer>
+ </div>
+ </motion.section>
+
+ {/* Bar Chart - Habits Comparison */}
+ {!selectedHabitId && habits.length > 1 && (
+ <motion.section
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ delay: 0.4 }}
+ className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-6"
+ >
+ <SectionHeader
+ icon={BarChart3}
+ title="По привычкам"
+ subtitle="Рейтинг за 30 дней"
+ />
+
+ <div className="space-y-3">
+ {barChartData.map((habit, index) => (
+ <motion.div
+ key={habit.name}
+ initial={{ opacity: 0, x: -20 }}
+ animate={{ opacity: 1, x: 0 }}
+ transition={{ delay: 0.1 * index }}
+ className="relative"
+ >
+ <div className="flex items-center gap-3 mb-2">
+ <span className="text-lg">{habit.icon}</span>
+ <span className="text-sm font-medium text-white flex-1 truncate">{habit.name}</span>
+ <span className="text-sm font-bold text-primary-400">{habit.rate}%</span>
+ </div>
+ <div className="h-2 bg-gray-800 rounded-full overflow-hidden">
+ <motion.div
+ initial={{ width: 0 }}
+ animate={{ width: `${habit.rate}%` }}
+ transition={{ delay: 0.2 + 0.1 * index, duration: 0.8, ease: "easeOut" }}
+ className="h-full rounded-full"
+ style={{
+ backgroundColor: habit.color,
+ boxShadow: `0 0 10px ${habit.color}50`
+ }}
+ />
+ </div>
+ <div className="flex justify-between mt-1">
+ <span className="text-[10px] text-gray-500">{habit.completed} выполнено</span>
+ <span className="text-[10px] text-gray-500">{habit.expected} ожидалось</span>
+ </div>
+ </motion.div>
+ ))}
+ </div>
+ </motion.section>
+ )}
+
+ {habits.length === 0 && (
+ <motion.div
+ initial={{ opacity: 0, scale: 0.95 }}
+ animate={{ opacity: 1, scale: 1 }}
+ className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-12 text-center"
+ >
+ <div className="w-16 h-16 rounded-2xl bg-gray-800 flex items-center justify-center mx-auto mb-4">
+ <Zap className="text-gray-600" size={32} />
+ </div>
+ <p className="text-gray-400 font-medium">Создайте привычки,</p>
+ <p className="text-gray-500 text-sm">чтобы видеть статистику</p>
+ </motion.div>
+ )}
+ </main>
+
+ {!embedded && <Navigation />}
+ </div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 | + + + + + + + + + + + +1x + + + + + + +1x + + + + + + + +4x +2x +2x +2x +2x + + + +10x +10x +10x +10x + +10x + + +7x +7x + + + +10x + + + + + + + +10x + + + + + + + +10x + + + + +10x + + + + + + + + + + + + + + + + +27x + + + + + + + + + + + + + + + + + + + + +21x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +4x + + + + + + + + + + + + + + +4x +4x +4x +4x + +4x + + + + + + +4x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | 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, Repeat } 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 dark:bg-blue-900/30 dark:text-blue-400' },
+ 2: { label: 'Средний', class: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' },
+ 3: { label: 'Высокий', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' },
+}
+
+const RECURRENCE_LABELS = {
+ daily: 'Ежедневно',
+ weekly: 'Еженедельно',
+ monthly: 'Ежемесячно',
+ custom: 'Повтор',
+}
+
+function formatDueDate(dateStr) {
+ if (!dateStr) return null
+ const date = parseISO(dateStr)
+ Iif (isToday(date)) return 'Сегодня'
+ Iif (isTomorrow(date)) return 'Завтра'
+ return format(date, 'd MMM', { locale: ru })
+}
+
+export default function Tasks({ embedded = false }) {
+ const [showCreate, setShowCreate] = useState(false)
+ const [editingTask, setEditingTask] = useState(null)
+ const [filter, setFilter] = useState('active')
+ const queryClient = useQueryClient()
+
+ const { data: tasks = [], isLoading } = useQuery({
+ queryKey: ['tasks', filter],
+ queryFn: () => {
+ Iif (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)
+ }
+
+ return (
+ <div className={embedded ? "" : "min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300"}>
+ {!embedded && <header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 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 dark:text-white">Задачи</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 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
+ )}
+ >
+ {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 dark:bg-gray-700" />
+ <div className="flex-1">
+ <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/4 mb-2" />
+ <div className="h-4 bg-gray-200 dark:bg-gray-700 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 dark:from-primary-900/30 dark:to-primary-800/30 flex items-center justify-center mx-auto mb-5">
+ <Check className="w-10 h-10 text-primary-600 dark:text-primary-400" />
+ </div>
+ <h3 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-2">
+ {filter === 'active' ? 'Нет активных задач' : filter === 'completed' ? 'Нет выполненных задач' : 'Нет задач'}
+ </h3>
+ <p className="text-gray-500 dark:text-gray-400 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>
+
+ {!embedded && <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}>
+ <div className="flex items-center gap-2">
+ <h3 className={clsx("font-semibold truncate cursor-pointer hover:text-primary-600 dark:hover:text-primary-400", task.completed ? "text-gray-400 line-through" : "text-gray-900 dark:text-white")}>{task.title}</h3>
+ {task.is_recurring && <span className="text-sm" title={RECURRENCE_LABELS[task.recurrence_type] || 'Повторяется'}>🔄</span>}
+ </div>
+
+ {task.description && <p className="text-sm text-gray-500 dark:text-gray-400 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 dark:bg-red-900/30 dark:text-red-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400')}>
+ {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>}
+
+ {task.is_recurring && task.recurrence_type && (
+ <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
+ <Repeat size={12} />
+ {RECURRENCE_LABELS[task.recurrence_type]}
+ </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 dark:hover:bg-primary-900/20 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 dark:hover:bg-orange-900/20 rounded-xl transition-all" title="Отменить">
+ <Undo2 size={16} />
+ </motion.button>
+ )}
+ </div>
+ </div>
+ </motion.div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 | + + + + + + + +1x + + + + + + +11x + +11x + + + + + + + + + +33x + +4x + + + + + + + + + + + + + + + + + + + + + + + | import { useState, lazy, Suspense } from "react"
+import Navigation from "../components/Navigation"
+
+// Import pages as components (they render their own content but we strip their Navigation)
+import HabitsContent from "./Habits"
+import TasksContent from "./Tasks"
+import StatsContent from "./Stats"
+
+const tabs = [
+ { key: "habits", label: "Привычки", icon: "🎯" },
+ { key: "tasks", label: "Задачи", icon: "✅" },
+ { key: "stats", label: "Статистика", icon: "📊" },
+]
+
+export default function Tracker() {
+ const [activeTab, setActiveTab] = useState("habits")
+
+ return (
+ <div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24">
+ <header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
+ <div className="max-w-lg mx-auto px-4 py-4">
+ <h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">
+ 📊 Трекер
+ </h1>
+ </div>
+ <div className="max-w-lg mx-auto px-4 pb-3 flex gap-2">
+ {tabs.map((t) => (
+ <button
+ key={t.key}
+ onClick={() => setActiveTab(t.key)}
+ className={`flex-1 py-2 rounded-xl text-sm font-semibold transition ${
+ activeTab === t.key
+ ? "bg-primary-500 text-white"
+ : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+ }`}
+ >
+ {t.icon} {t.label}
+ </button>
+ ))}
+ </div>
+ </header>
+
+ <div>
+ {activeTab === "habits" && <HabitsContent embedded />}
+ {activeTab === "tasks" && <TasksContent embedded />}
+ {activeTab === "stats" && <StatsContent embedded />}
+ </div>
+
+ <Navigation />
+ </div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 | + + + + + + +11x +11x +11x + +11x + +11x +6x +1x +1x +1x + + +5x +5x +5x +3x +3x + +1x +1x + + + +5x + + +11x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useEffect, useState } from 'react'
+import { useSearchParams, Link } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import { CheckCircle, XCircle, Loader2, Zap } from 'lucide-react'
+import api from '../api/client'
+
+export default function VerifyEmail() {
+ const [searchParams] = useSearchParams()
+ const [status, setStatus] = useState('loading') // loading, success, error
+ const [message, setMessage] = useState('')
+
+ const token = searchParams.get('token')
+
+ useEffect(() => {
+ if (!token) {
+ setStatus('error')
+ setMessage('Токен не найден')
+ return
+ }
+
+ const verify = async () => {
+ try {
+ await api.post('/auth/verify-email', { token })
+ setStatus('success')
+ setMessage('Email успешно подтверждён!')
+ } catch (err) {
+ setStatus('error')
+ setMessage(err.response?.data?.error || 'Ошибка верификации')
+ }
+ }
+
+ verify()
+ }, [token])
+
+ return (
+ <div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50">
+ <motion.div
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ className="w-full max-w-md"
+ >
+ <div className="card p-10 text-center">
+ {status === 'loading' && (
+ <>
+ <div className="w-20 h-20 rounded-3xl bg-primary-100 flex items-center justify-center mx-auto mb-6">
+ <Loader2 className="w-10 h-10 text-primary-600 animate-spin" />
+ </div>
+ <h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
+ Проверяем...
+ </h1>
+ <p className="text-gray-500">Подожди секунду</p>
+ </>
+ )}
+
+ {status === 'success' && (
+ <>
+ <motion.div
+ initial={{ scale: 0 }}
+ animate={{ scale: 1 }}
+ transition={{ type: 'spring', stiffness: 200 }}
+ className="w-20 h-20 rounded-3xl bg-green-100 flex items-center justify-center mx-auto mb-6"
+ >
+ <CheckCircle className="w-10 h-10 text-green-600" />
+ </motion.div>
+ <h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
+ Готово! 🎉
+ </h1>
+ <p className="text-gray-500 mb-6">{message}</p>
+ <Link to="/login" className="btn btn-primary">
+ Войти в аккаунт
+ </Link>
+ </>
+ )}
+
+ {status === 'error' && (
+ <>
+ <motion.div
+ initial={{ scale: 0 }}
+ animate={{ scale: 1 }}
+ transition={{ type: 'spring', stiffness: 200 }}
+ className="w-20 h-20 rounded-3xl bg-red-100 flex items-center justify-center mx-auto mb-6"
+ >
+ <XCircle className="w-10 h-10 text-red-600" />
+ </motion.div>
+ <h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
+ Ошибка
+ </h1>
+ <p className="text-gray-500 mb-6">{message}</p>
+ <Link to="/login" className="btn btn-secondary">
+ На главную
+ </Link>
+ </>
+ )}
+ </div>
+
+ <div className="flex items-center justify-center gap-2 mt-6 text-gray-400">
+ <Zap size={16} />
+ <span className="text-sm font-medium">Pulse</span>
+ </div>
+ </motion.div>
+ </div>
+ )
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| Finance.jsx | +
+
+ |
+ 44.44% | +16/36 | +75% | +15/20 | +25% | +4/16 | +64% | +16/25 | +
| ForgotPassword.jsx | +
+
+ |
+ 100% | +17/17 | +100% | +8/8 | +100% | +3/3 | +100% | +17/17 | +
| Habits.jsx | +
+
+ |
+ 64.91% | +37/57 | +65.9% | +29/44 | +53.57% | +15/28 | +65.21% | +30/46 | +
| Home.jsx | +
+
+ |
+ 17.46% | +33/189 | +8.84% | +13/147 | +9.25% | +5/54 | +21.15% | +33/156 | +
| Login.jsx | +
+
+ |
+ 100% | +21/21 | +100% | +10/10 | +100% | +6/6 | +100% | +20/20 | +
| Register.jsx | +
+
+ |
+ 100% | +23/23 | +100% | +10/10 | +100% | +7/7 | +100% | +22/22 | +
| ResetPassword.jsx | +
+
+ |
+ 96.29% | +26/27 | +92.85% | +13/14 | +80% | +4/5 | +100% | +26/26 | +
| Savings.jsx | +
+
+ |
+ 15.88% | +44/277 | +14.07% | +38/270 | +9.09% | +11/121 | +17.4% | +43/247 | +
| Settings.jsx | +
+
+ |
+ 81.63% | +40/49 | +78.18% | +43/55 | +50% | +7/14 | +83.33% | +40/48 | +
| Stats.jsx | +
+
+ |
+ 75.39% | +144/191 | +65.89% | +85/129 | +66.66% | +30/45 | +79.75% | +130/163 | +
| Tasks.jsx | +
+
+ |
+ 50% | +28/56 | +58.66% | +44/75 | +31.81% | +7/22 | +60% | +27/45 | +
| Tracker.jsx | +
+
+ |
+ 100% | +5/5 | +100% | +8/8 | +100% | +3/3 | +100% | +5/5 | +
| VerifyEmail.jsx | +
+
+ |
+ 100% | +18/18 | +90% | +9/10 | +100% | +3/3 | +100% | +18/18 | +