Compare commits

..

170 Commits

Author SHA1 Message Date
poignatov
60a0efafad 6.27.3: Фикс сброса автовыполнения и прогресс
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:21:45 +03:00
poignatov
48fa192cdc 6.27.2: Fitbit не меняет auto_complete в драфтах
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:13:33 +03:00
poignatov
06cc1a4b3b 6.27.1: Fitbit не меняет auto_complete в драфтах
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:12:34 +03:00
poignatov
e2966aedd1 6.27.0: Автовыполнение и прогрессия по-умолчанию
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m32s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:04:45 +03:00
poignatov
d561683e12 6.26.1: Автовыполнение задач сортируется наверх
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m12s
2026-03-20 11:24:13 +03:00
poignatov
fdc3e01443 6.26.0: Объединённый диалог товаров
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m17s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:21:47 +03:00
poignatov
d4f0064aa7 6.25.5: Стиль остатка как у подзадач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m10s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:38:23 +03:00
poignatov
87126a480a 6.25.4: Стиль остатка как у подзадач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 38s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:36:22 +03:00
poignatov
837ddbe4ed 6.25.4: Карандаш вместо троеточия на карточках
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m8s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:28:56 +03:00
poignatov
44bbb46a1a 6.25.3: Остаток в скобках вместо тильды
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:23:40 +03:00
poignatov
1795a66ee1 6.25.2: Выравнивание текста в селекторе доски
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:22:06 +03:00
poignatov
84b5aa9390 6.25.1: Иконка архива в селекторе доски
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m9s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:20:37 +03:00
poignatov
e8a766205f 6.25.0: Меню действий для досок вместо кнопок
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:10:34 +03:00
poignatov
6e7ebb9aa3 6.24.0: Кнопка архива рядом с создать доску
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
2026-03-19 19:22:10 +03:00
poignatov
101f4e27ed 6.23.0: Архивация досок желаний и товаров
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m26s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:27:19 +03:00
poignatov
f1c12fd81a 6.22.0: Авторасчёт сроков товаров по истории
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:37:39 +03:00
poignatov
664adcfaa5 6.21.3: Сортировка задач по алфавиту
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 09:36:45 +03:00
poignatov
8acfaf19ac 6.21.2: Увеличен градиент нижней панели желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 21:49:33 +03:00
poignatov
2fde471076 6.21.1: Меню действий на экране желания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 21:46:46 +03:00
poignatov
5f05b77d36 6.21.0: Копирование товаров и меню действий
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 21:43:04 +03:00
poignatov
eb68eca63f 6.20.0: Копирование задач и меню действий
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 21:39:15 +03:00
poignatov
b82db8d80f 6.19.13: Тест сборки
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m9s
2026-03-18 20:30:52 +03:00
poignatov
b3403ff23a 6.19.12: Тест сборки
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m36s
2026-03-18 20:28:05 +03:00
poignatov
b8373eb986 6.19.11: Тест сборки
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m18s
2026-03-18 20:23:45 +03:00
poignatov
df17ecf943 6.19.10: Увеличена область клика кнопки переноса
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m18s
2026-03-18 18:38:14 +03:00
poignatov
42ea241b7c 6.19.9: Ускорение run.sh с BuildKit
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m14s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 17:06:02 +03:00
poignatov
3a06d9148c 6.19.8: Кнопки сохранения досок внизу экрана
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m16s
2026-03-18 16:59:18 +03:00
poignatov
b1f4fdd449 6.19.7: Сброс скролла и загрузка при смене проекта
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m16s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:41:55 +03:00
poignatov
01cd0e9003 6.19.6: Кеш-first и отмена запросов при смене доски
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m17s
2026-03-18 16:24:32 +03:00
poignatov
6dc3ec828f 6.19.5: Поднятие патч версии
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m15s
2026-03-17 21:25:23 +03:00
poignatov
0a8ff4dfab 6.19.4: Поднятие патч версии
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m4s
2026-03-17 21:23:01 +03:00
poignatov
dff929c52c 6.19.3: Навигация на редактирование из диалога задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m15s
2026-03-17 10:37:02 +03:00
poignatov
2104fea5e2 6.19.2: Фикс кнопки сохранения в приоритетах
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m18s
2026-03-17 10:29:52 +03:00
poignatov
caa8ac6ebb 6.19.1: Фикс сброса скролла в статистике
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m16s
2026-03-17 10:19:31 +03:00
poignatov
49f67ec36d 6.19.0: Унификация кнопок сохранения в формах
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m18s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:08:23 +03:00
poignatov
171befdf05 6.18.17: Откат: скобки и без жирного в подзадачах
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m14s
2026-03-16 16:20:34 +03:00
poignatov
0c6ba5c8fb 6.18.16: Отступ между карточкой и кнопкой в задаче
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m17s
2026-03-16 16:19:08 +03:00
poignatov
1b7e2cd887 6.18.15: Фикс цвета кнопки и отступа снизу в задаче
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m13s
2026-03-16 13:59:29 +03:00
poignatov
53593fdc3d 6.18.14: Убран лишний отступ снизу в карточке задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m14s
2026-03-16 13:56:11 +03:00
poignatov
b888d056e4 Убран лишний отступ снизу в карточке задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 45s
2026-03-16 13:55:14 +03:00
poignatov
cbf20ce679 6.18.13: Фикс стилей кнопок в редактировании задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-03-16 13:54:09 +03:00
poignatov
91bba98b65 6.18.12: Стили кнопок и отступ в редактировании задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m14s
2026-03-16 13:34:06 +03:00
poignatov
c927f55fd6 6.18.11: Фикс сброса скролла на статистике
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m15s
2026-03-16 13:29:55 +03:00
poignatov
ae13b2bcac 6.18.10: Горизонтальный скролл для чипсов на статистике
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m13s
2026-03-16 13:26:59 +03:00
poignatov
85e9a6f48b 6.18.9: Убраны скобки у счётчика подзадач
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m16s
2026-03-16 13:22:40 +03:00
poignatov
7309deb98f 6.18.8: Жирный счётчик выполненных подзадач
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m13s
2026-03-16 08:54:41 +03:00
poignatov
10f370b0da 6.18.7: Фикс замены $name в автовыполнении
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-03-16 08:53:21 +03:00
poignatov
f59453783a 6.18.6: Отступ снизу для кнопки в тесте и форме задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m16s
2026-03-16 08:48:32 +03:00
poignatov
5ea58476cb 6.18.5: Фиксированные кнопки в формах задачи и желания
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m19s
2026-03-16 07:49:56 +03:00
poignatov
1876595005 6.18.4: Фикс загрузки товаров при создании доски
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m14s
2026-03-15 20:00:12 +03:00
poignatov
a4dcc62a37 6.18.3: Фиксированная кнопка Начать/Завершить в тесте
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m15s
2026-03-15 19:55:50 +03:00
poignatov
8749f21ac8 6.18.2: Фикс навигации назад после создания задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m16s
2026-03-15 18:48:34 +03:00
poignatov
912ae7a857 6.18.1: Фикс логики минуса в полях прогрессии
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m15s
2026-03-15 18:14:41 +03:00
poignatov
7ec76ea59b 6.18.0: Улучшения диалога товара и кнопки прогрессии
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m13s
2026-03-15 18:13:20 +03:00
poignatov
4f69481efe 6.17.5: Кнопка выполнения и тайтл в диалоге товара
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m13s
2026-03-15 18:09:26 +03:00
poignatov
b85b85a27f 6.17.4: Фикс закрытия экрана закупки через history.back
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m14s
2026-03-15 18:04:54 +03:00
poignatov
710adff385 6.17.3: Фикс навигации при редактировании из закупки
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m14s
2026-03-15 18:01:28 +03:00
poignatov
9c915d4675 6.17.2: Навигация закупки и next_show_at для товара
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m29s
2026-03-15 17:54:23 +03:00
poignatov
7e0f979ae3 6.17.1: Общая стратегия по умолчанию в задаче-желании
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m30s
2026-03-15 17:41:56 +03:00
poignatov
d42535f36e 6.17.0: Счётчик подзадач с учётом драфта
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-03-15 17:41:46 +03:00
poignatov
95985f97f2 6.16.5: Оптимизация выполнения задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 13:14:25 +03:00
poignatov
7889922d9b 6.16.4: Синяя обводка для разблокированных желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-03-13 22:05:05 +03:00
poignatov
97753f4465 6.16.3: Разблокировано вместо Готово
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s
2026-03-13 22:00:46 +03:00
poignatov
b1ffb7ba7d 6.16.2: В конце дня вместо скоро
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-13 21:58:21 +03:00
poignatov
c232bb40a3 6.16.1: Обновление статистики после редактирования задачи
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-03-13 21:57:11 +03:00
poignatov
3bd864d41a 6.16.0: Редактирование записей в статистике
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m24s
2026-03-13 21:48:11 +03:00
poignatov
193b4138d9 6.15.6: Открытие редактирования задачи из статистики
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-13 15:49:09 +03:00
poignatov
06b7c614ed 6.15.5: Драфты в статистике текущей недели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m29s
2026-03-13 15:41:10 +03:00
poignatov
b51b9421be 6.15.4: Подзадачи в драфтах авто-выполнения
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s
2026-03-13 15:28:50 +03:00
poignatov
0dca57964d 6.15.3: Фикс обновления данных при редактировании завершённого желания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
2026-03-13 15:15:01 +03:00
poignatov
95ed1b48fe 6.15.2: Общая стратегия по умолчанию для задач-желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m2s
2026-03-13 14:54:16 +03:00
poignatov
6f76c4a25c 6.15.1: Фикс race condition при смене доски желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s
2026-03-13 14:52:07 +03:00
poignatov
c8a47ff408 6.15.0: Драфты авто-выполнения в статистике
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 14:43:19 +03:00
poignatov
4ce8ba66cc 6.14.2: Фикс nginx маршрута /priorities/confirm на проде
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-12 17:27:18 +03:00
poignatov
c42cdfe35b 6.14.1: Фикс nginx маршрута /priorities/confirm на проде
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-03-12 17:23:34 +03:00
poignatov
4971b2a305 6.14.1: Убран хинт 'Открыть статистику'
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-03-12 17:18:36 +03:00
poignatov
64493b9c1f 6.14.0: Еженедельное подтверждение приоритетов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-12 17:16:57 +03:00
poignatov
1df00bbefd 6.13.1: Фикс обновления текущего дня в статистике
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-12 15:53:22 +03:00
poignatov
0b5106458a 6.13.0: Переменные $name и $subtaskName в сообщениях награды
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-12 15:28:25 +03:00
poignatov
02c8b7537a 6.12.1: Фикс диалога ошибки загрузки
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m8s
2026-03-12 15:20:27 +03:00
poignatov
2a61b17187 6.12.0: Улучшения блока выбора доски в товарах и желаниях
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-11 09:46:41 +03:00
poignatov
3624cfffbd 6.11.5: Стили блока выбора доски в товарах и желаниях
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-11 09:41:27 +03:00
poignatov
a35797a1f9 6.11.4: Товары перенесены из таба в профиль
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
2026-03-11 09:34:36 +03:00
poignatov
20778d6d39 6.11.3: Настройки доски внутри селекта
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-11 09:18:56 +03:00
poignatov
ac1f6c3a47 6.11.2: Фикс стилей блока особенностей задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-11 09:14:07 +03:00
poignatov
25f193a061 6.11.1: Фикс моргания кнопки завершения закупки
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-03-11 09:08:40 +03:00
poignatov
ea1720506a 6.11.0: Фикс навигации и переключение типов задач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m8s
2026-03-11 09:04:11 +03:00
poignatov
dc50433eb1 6.10.0: Синий блок настроек для задач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:49:24 +03:00
poignatov
4b28d90d68 6.9.2: Фикс стилей чекбоксов закупки
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:31:25 +03:00
poignatov
fb1ccd7831 6.9.1: Фикс сброса типа задачи при создании
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m8s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:08:52 +03:00
poignatov
636f53eb04 6.9.0: Задачи-закупки
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:37:03 +03:00
poignatov
786a03bf86 6.8.3: Последний объём из истории в плейсхолдере
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
2026-03-10 17:57:03 +03:00
poignatov
eb5e5a5476 6.8.2: Перестановка галочки и инпута объёма
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:50:55 +03:00
poignatov
ebd1398a81 6.8.1: Фикс истории навигации при закрытии крестиком
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-10 17:18:14 +03:00
poignatov
3cac8d0452 6.8.0: История покупок товаров
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:05:59 +03:00
poignatov
e962f49407 6.7.0: Описание товаров и редизайн выполнения
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m24s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:39:23 +03:00
poignatov
79fa0538f9 6.6.4: Очистка истории при закрытии диалога желания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:17:03 +03:00
poignatov
99b0eba701 6.6.3: Фикс закрытия желания при выполнении задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:12:39 +03:00
poignatov
23f16a8bef 6.6.2: Фикс навигации при создании задачи из желания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-10 15:59:05 +03:00
poignatov
a441a3d1e7 6.6.1: Скрытие прошедших дат в товарах
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:48:09 +03:00
poignatov
7957776f53 6.6.0: Извлечение ссылки из текста в желании
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-10 15:41:34 +03:00
poignatov
a693d3fa4b 6.5.0: Табы вместо селекта в форме цели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:23:41 +03:00
poignatov
3a1b836ece 6.4.13: Фикс кнопки назад для диалога цели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:08:24 +03:00
poignatov
17e6bbf9f1 6.4.12: Навигация назад по стеку после создания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:42:18 +03:00
poignatov
c9a8b994eb 6.4.11: Фикс кнопки назад для диалога желания
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-09 21:36:34 +03:00
poignatov
b47d50f51c 6.4.10: Фикс закрытия диалога желания при создании задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-03-09 21:10:02 +03:00
poignatov
37d5c87a55 6.4.9: Скрытие даты для товаров с прошедшей датой
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
2026-03-09 21:04:21 +03:00
poignatov
c911950cc1 6.4.8: Фикс кнопки назад при создании словаря
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-03-08 19:56:25 +03:00
poignatov
2ec5860d78 6.4.7: Фикс закрытия диалога желания при закрытии задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-08 19:51:36 +03:00
poignatov
5d257cd0f8 6.4.6: Поднятие патч версии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-08 19:37:39 +03:00
poignatov
e7ce7b2092 6.4.5: Фикс чипса Сегодня для товаров без даты
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-03-08 19:37:06 +03:00
poignatov
81de26e586 6.4.4: Логика чипсов и кнопки Без даты
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-03-08 19:36:45 +03:00
poignatov
4169285394 6.4.3: Фоновое обновление досок при наличии кеша
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-03-08 19:30:56 +03:00
poignatov
c8fead4034 6.4.2: Закрытие диалогов кнопкой назад (товары)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-08 19:25:35 +03:00
poignatov
01631ff13b 6.4.1: Сброс формы товара при закрытии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-08 19:20:33 +03:00
poignatov
60fca2d93c 6.4.0: Экран товаров (Shopping List)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-03-08 16:11:08 +03:00
poignatov
cd51b097c8 6.3.10: Фикс зоны клика кнопок прогрессии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:17:38 +03:00
poignatov
fd416b4bd5 6.3.9: Поднятие патч версии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-08 14:13:25 +03:00
poignatov
537ad9e06e 6.3.8: Фикс визуала кнопок прогрессии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-03-08 14:00:27 +03:00
poignatov
b0155e6cbe 6.3.8: Увеличена зона клика кнопок +/- прогрессии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-03-08 13:57:56 +03:00
poignatov
999fa15267 6.3.7: Поднятие патч версии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-08 13:53:54 +03:00
poignatov
67334dde3c 6.3.6: Фикс подстановки base_progress при авто-выполнении
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
2026-03-08 13:52:06 +03:00
poignatov
7e51b0cb9f 6.3.6: Авто-подстановка base_progress в драфт
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-08 12:57:59 +03:00
poignatov
3a6f223aac 6.3.5: Фикс пустого таба и ошибки после авторизации
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-08 12:51:44 +03:00
poignatov
e1b6fcb918 6.3.4: Сброс на current при рестарте PWA
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m8s
2026-03-08 12:41:25 +03:00
poignatov
f54a0fff14 6.3.3: Фикс навигации при перезапуске PWA
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-07 20:13:16 +03:00
poignatov
80800da839 6.3.2: Буст × 1.2 для группы остальных приоритетов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-07 19:35:36 +03:00
poignatov
c7b684491c 6.3.1: Буст Group2 × 1.2, max 120%
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m32s
2026-03-07 19:29:19 +03:00
poignatov
7f51411175 6.3.0: Готовые желания на экране прогресса недели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-03-05 19:41:43 +03:00
poignatov
25317997e5 6.2.0: Срок разблокировки в днях в карточках желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-03-05 18:34:40 +03:00
poignatov
7c5b80b314 6.1.0: Редизайн карточек желаний на экране прогресса
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
2026-03-05 18:30:49 +03:00
poignatov
368f10bcdd 6.0.2: Учёт pending-баллов на желаниях, чёрный текст 2026-03-05 18:08:29 +03:00
poignatov
ff16f98736 6.0.1: Срок разблокировки цели при редактировании
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-03-05 12:59:31 +03:00
poignatov
027063dfb9 6.0.0: Прогресс «Остальные» 100% только при 100%
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m17s
2026-03-05 12:49:21 +03:00
poignatov
7fdcbb75da 5.13.0: Карточки проектов и желаний на экране недели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m10s
2026-03-05 12:42:52 +03:00
poignatov
2b7b056562 5.12.0: Желания в карточках проектов на неделе
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
2026-03-04 19:07:39 +03:00
poignatov
20773a29b7 5.11.2: Случайная сторона карточки при каждом показе слова
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-03-04 18:28:08 +03:00
poignatov
6caed05c9f 5.11.1: Стили TaskList и карточек Wishlist
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-03-04 18:22:58 +03:00
poignatov
92453def91 5.11.0: Кнопки +/- для прогрессии в TaskDetail
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m6s
2026-03-04 18:17:11 +03:00
poignatov
9f3637113d 5.10.2: Исправлен сброс прогрессии в null
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-03-04 18:09:50 +03:00
poignatov
98427f5d0e 5.10.1: Не сбрасывать подзадачи при быстрой прогрессии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-03-04 15:11:11 +03:00
poignatov
81dc23b501 5.10.0: Капсула прогрессии в списке задач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
2026-03-04 12:24:34 +03:00
poignatov
91d4a7337c 5.9.0: Статус Отклонено для желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
2026-03-04 12:04:26 +03:00
poignatov
0f1f5e3943 5.8.0: Окно переноса и логика next_show_at
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-03-04 11:39:03 +03:00
poignatov
8ea71ef95f 5.7.0: Драфты учитываются в today_change статистики
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
2026-03-04 10:19:41 +03:00
poignatov
e457113fc9 5.6.0: Учёт баллов из драфтов в статистике недели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m26s
2026-03-04 10:13:58 +03:00
poignatov
c04422ed69 5.5.0: Срок разблокировки из min_goal_score
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m24s
2026-02-24 17:29:02 +03:00
poignatov
7bbd732d72 5.4.0: Картинка желания по ссылке и единая высота инпутов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-02-24 17:06:44 +03:00
poignatov
ad52cf93ea 5.3.4: Поиск — группировка справа, крестик слева
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m9s
2026-02-24 16:27:20 +03:00
poignatov
ba6d823354 5.3.3: Скрытие даты в списке для сегодня и прошлого
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m17s
2026-02-24 15:59:51 +03:00
poignatov
e41abb2bff Перезагрузка списка записей при открытии экрана прогресса
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-02-24 15:57:13 +03:00
poignatov
2236f95ffa 5.3.1: Обновление списка задач Fitbit при открытии меню
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m13s
2026-02-24 15:50:41 +03:00
poignatov
07c4deaf70 5.3.0: Кнопка «По плану», логика чипа «Сегодня»
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-02-23 12:29:19 +03:00
poignatov
cea2c341a2 5.2.2: Логирование пути и версии миграций
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
2026-02-23 12:02:18 +03:00
poignatov
bff62c0b8f 5.2.1: Миграция удаления weekly_goals.max_score
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s
2026-02-23 11:28:42 +03:00
poignatov
a5e3396017 5.2.0: min_goal по среднему вместо медианы
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m27s
2026-02-22 14:26:18 +03:00
poignatov
41f8df36a9 5.1.3: Сброс данных при выходе с экрана Fitbit
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-02-09 17:43:21 +03:00
poignatov
76049b3da5 5.1.2: Достижение цели Fitbit для задачи без подзадачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
2026-02-09 17:40:29 +03:00
poignatov
9cfb988960 5.1.1: Интерфейс Fitbit и синхронизация в 23:50
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
2026-02-09 17:33:05 +03:00
poignatov
242183a422 5.1.0: Fitbit: привязки к задачам, цели из API
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
2026-02-09 17:06:08 +03:00
poignatov
29bd50acab 5.0.9: Ожидание бэкенда при завершении теста
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
2026-02-09 16:07:36 +03:00
poignatov
72da547b80 5.0.8: часовой пояс, разблокировка по удалению задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
2026-02-09 15:47:57 +03:00
poignatov
1fe3819be6 5.0.6: Удаление max_score, норма по max_goal_score
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
2026-02-09 15:28:44 +03:00
poignatov
1f5f3299f8 5.0.5: normalized для текущей недели в статистике
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-02-09 14:52:45 +03:00
poignatov
7012f1c8ed 5.0.4: Добавление записей для не-админов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-02-09 14:41:39 +03:00
poignatov
2128e1b69c 5.0.2: Серая filled-иконка группировки в списке задач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m2s
2026-02-09 14:24:47 +03:00
poignatov
5b53615d1a 5.0.1: Группа в форме задачи, next_show_at не меняется при редакт.
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m41s
2026-02-09 14:16:57 +03:00
poignatov
b05bd51b5b Вынос play-life-llm в отдельный скрипт runLLM.sh
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 21s
2026-02-09 12:24:37 +03:00
poignatov-home
9345b5ab5c Слияние с origin/main, сохранена локальная версия
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m35s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 17:04:00 +03:00
poignatov-home
bad198ce29 Первоначальный коммит
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 17:01:36 +03:00
112 changed files with 14885 additions and 2058 deletions

BIN
.DS_Store vendored

Binary file not shown.

8
.claude/settings.json Normal file
View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8080/priorities/confirm)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" -X POST http://localhost:8080/priorities/confirm -H \"Content-Type: application/json\" -d '[]')"
]
}
}

View File

@@ -14,6 +14,13 @@
"type": "shell", "type": "shell",
"cwd": "${workspaceFolder}" "cwd": "${workspaceFolder}"
}, },
{
"name": "runLLM",
"description": "Запуск/перезапуск play-life-llm (обычно на отдельной машине)",
"command": "./runLLM.sh",
"type": "shell",
"cwd": "${workspaceFolder}"
},
{ {
"name": "backupFromProd", "name": "backupFromProd",
"description": "Создание дампа базы данных с продакшена", "description": "Создание дампа базы данных с продакшена",

View File

@@ -0,0 +1,37 @@
---
name: normalized_total_score fix migration
overview: "Новая миграция 000023: пересоздать weekly_report_mv с max_goal_score и удалить max_score из weekly_goals."
todos:
- id: migration-up
content: "Добавить 000023 up: DROP MV, CREATE MV с max_goal_score (из 000020), DROP COLUMN IF EXISTS max_score"
status: pending
- id: migration-down
content: "Добавить 000023 down: восстановить MV со старой формулой (max_score) и колонку max_score"
status: pending
- id: verify-local
content: Применить миграцию локально и проверить Релокация 2026-08 (normalized 32.74 и 21.55)
status: pending
isProject: false
---
# План: Исправить normalized_total_score через новую миграцию
## Проблема
На проде (и в локальной копии продовой БД) `normalized_total_score` не учитывает `max_goal_score`: в определении материализованного представления `weekly_report_mv` до сих пор используется колонка `wg.max_score`, которая не заполняется (всегда NULL) → формула всегда даёт `normalized_total_score = total_score`.
## Решение
Новая миграция (не менять 000020/000022):
1. **Пересоздать `weekly_report_mv**` с определением из 000020: в формуле использовать `max_goal_score`, тот же подзапрос по `n.created_date` и фильтр «только прошлые недели».
2. **Удалить колонку `max_score` из `weekly_goals**`, если есть: `ALTER TABLE weekly_goals DROP COLUMN IF EXISTS max_score;`
После применения и `REFRESH` (или при следующем кроне) для прошлых недель normalized будет ограничиваться целями (например, Релокация 2026-08: 39.14 → 32.74).
## Todos
- **migration-up** — Добавить миграцию `000023_fix_weekly_report_mv_use_max_goal_score.up.sql`: DROP MV, CREATE MV с max_goal_score (копия определения из 000020), DROP COLUMN IF EXISTS max_score в weekly_goals
- **migration-down** — Добавить `000023_fix_weekly_report_mv_use_max_goal_score.down.sql`: восстановить MV со старой формулой (max_score) и колонку max_score в weekly_goals
- **verify-local** — Применить миграцию локально и проверить по Релокации за 2026-08: normalized_total_score = 32.74 (project_id 27) и 21.55 (project_id 592)

View File

@@ -13,4 +13,6 @@ alwaysApply: true
- React компонентами и стилями в `play-life-web/src/` - React компонентами и стилями в `play-life-web/src/`
- Docker конфигурациями (`docker-compose.yml`, `Dockerfile`) - Docker конфигурациями (`docker-compose.yml`, `Dockerfile`)
**Команда для перезапуска:** `./run.sh` или `bash run.sh` в корне проекта. При изменениях в `play-life-llm/` (если LLM запущен на этой машине) выполни `./runLLM.sh`.
**Команды для перезапуска:** `./run.sh` (web + backend + db) или `bash run.sh` в корне проекта. Для LLM на этой машине: `./runLLM.sh`.

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"claudeCode.allowDangerouslySkipPermissions": true
}

19
.vscode/tasks.json vendored
View File

@@ -39,6 +39,25 @@
"problemMatcher": [], "problemMatcher": [],
"detail": "Перезапуск Play Life: перезапуск всех контейнеров" "detail": "Перезапуск Play Life: перезапуск всех контейнеров"
}, },
{
"label": "runLLM",
"type": "shell",
"command": "./runLLM.sh",
"group": {
"kind": "build",
"isDefault": false
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"showReuseMessage": true,
"clear": false
},
"problemMatcher": [],
"detail": "Запуск/перезапуск play-life-llm (обычно на отдельной машине)"
},
{ {
"label": "backupFromProd", "label": "backupFromProd",
"type": "shell", "type": "shell",

65
CLAUDE.md Normal file
View File

@@ -0,0 +1,65 @@
# Правила проекта
## Миграции базы данных
**ВАЖНО:** Если меняется структура базы данных — пиши НОВУЮ миграцию.
НИ В КОЕМ СЛУЧАЕ не меняй старые миграции, можно добавлять только новые.
Старой миграцией считается та, что была уже ранее закоммичена.
## Перезапуск приложения после изменений
После применения всех изменений в бэкенде (`play-life-backend/`) или фронтенде (`play-life-web/`), а также после изменений в `docker-compose.yml`, **ОБЯЗАТЕЛЬНО** выполни команду `./run.sh` для перезапуска всех сервисов.
Применяется при работе с:
- Go кодом в `play-life-backend/`
- Миграциями базы данных в `play-life-backend/migrations/`
- React компонентами и стилями в `play-life-web/src/`
- Docker конфигурациями (`docker-compose.yml`, `Dockerfile`)
При изменениях в `play-life-llm/` (если LLM запущен на этой машине) выполни `./runLLM.sh`.
**Команды:** `./run.sh` (web + backend + db) или `bash run.sh` в корне проекта. Для LLM: `./runLLM.sh`.
## Поднятие версии и пуш
Когда пользователь просит **поднять версию и запушить**:
### 1. Определи тип версии
- **major** — первая цифра (1.1.1 → 2.0.0), минор и патч обнуляются
- **minor** — вторая цифра (1.0.1 → 1.1.0), патч обнуляется
- **patch** — третья цифра (1.0.0 → 1.0.1)
Любая часть версии может быть больше 9 (10, 11, 12 и т.д.).
**Если тип версии непонятен из контекста — обязательно спроси у пользователя!**
### 2. Обнови версию в файлах
- `VERSION` (в корне проекта)
- `play-life-web/package.json` (поле `"version"`)
### 3. Составь commit message
Выполни `git diff --staged` и `git diff`, проанализируй изменения. Составь **короткий commit message** (максимум 50 символов) на русском языке. Формат: `"1.2.3: Описание изменений"`.
### 4. Закоммить и запушить
```bash
git add -A
git commit -m "<commit message>"
git push
```
## Пуш без поднятия версии
Когда пользователь просит просто запушить (без поднятия версии):
1. Выполни `git diff --staged` и `git diff`, составь короткий commit message на русском (максимум 50 символов)
2. `git add -A && git commit -m "<commit message>" && git push`
**Примеры:**
- "Подними патч и запушь" → поднять patch
- "Bump minor and push" → поднять minor
- "Подними версию и запушь" → спросить какой тип
- "Запушь изменения" → пушить без изменения версии

View File

@@ -1 +1 @@
4.27.3 6.27.3

View File

@@ -61,6 +61,18 @@ services:
env_file: env_file:
- .env - .env
# LLM сервис (Ollama + Tavily), свой Docker и свой env
llm:
build:
context: ./play-life-llm
dockerfile: Dockerfile
container_name: play-life-llm
ports:
- "8090:8090"
restart: unless-stopped
env_file:
- ./play-life-llm/.env
volumes: volumes:
postgres_data: postgres_data:
name: play-life_postgres_data name: play-life_postgres_data

View File

@@ -47,12 +47,12 @@ docker images | grep -E "postgres:(15|16|17|18|latest)" | awk '{print $3}' | xar
echo -e "${GREEN} ✅ Старые образы postgres удалены${NC}" echo -e "${GREEN} ✅ Старые образы postgres удалены${NC}"
echo "" echo ""
# 2. Поднятие всех сервисов # 2. Поднятие сервисов (без LLM — он обычно на отдельной машине, см. ./runLLM.sh)
echo -e "${YELLOW}2. Поднятие сервисов через Docker Compose...${NC}" echo -e "${YELLOW}2. Поднятие сервисов через Docker Compose...${NC}"
echo " - База данных PostgreSQL 18.0 (порт: $DB_PORT)" echo " - База данных PostgreSQL 18.0 (порт: $DB_PORT)"
echo " - Backend сервер (порт: $PORT)" echo " - Backend сервер (порт: $PORT)"
echo " - Frontend приложение (порт: $WEB_PORT)" echo " - Frontend приложение (порт: $WEB_PORT)"
docker-compose up -d --build docker-compose up -d --build db backend play-life-web
echo -e "${GREEN} ✅ Сервисы запущены${NC}" echo -e "${GREEN} ✅ Сервисы запущены${NC}"
echo "" echo ""

View File

@@ -76,7 +76,7 @@ server {
} }
# Proxy other API endpoints to backend # Proxy other API endpoints to backend
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|message/post|weekly_goals/setup)$ { location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|message/post|weekly_goals/setup|project_score_sample_mv/refresh|priorities/confirm)$ {
proxy_pass http://localhost:8080; proxy_pass http://localhost:8080;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;

View File

@@ -222,6 +222,19 @@
<button onclick="setupWeeklyGoals()">Обновить цели</button> <button onclick="setupWeeklyGoals()">Обновить цели</button>
<div id="goalsResult"></div> <div id="goalsResult"></div>
</div> </div>
<!-- Project score sample MV Card -->
<div class="card">
<h2>
📊 project_score_sample_mv
<span class="status" id="mvStatus" style="display: none;"></span>
</h2>
<p style="margin-bottom: 15px; color: #666;">
Обновить материализованное представление и показать данные текущего пользователя (по одному представителю на вариант баллов проекта).
</p>
<button onclick="refreshProjectScoreSampleMv()">Обновить project_score_sample_mv</button>
<div id="mvResult"></div>
</div>
</div> </div>
</div> </div>
@@ -346,6 +359,35 @@
} }
} }
async function refreshProjectScoreSampleMv() {
showStatus('mvStatus', 'loading', 'Обновление...');
showResult('mvResult', null, false, true);
try {
const response = await fetch(`${getApiUrl()}/project_score_sample_mv/refresh`, {
method: 'POST',
headers: getAuthHeaders()
});
if (handleAuthError(response)) {
return;
}
const data = await response.json();
if (response.ok) {
showStatus('mvStatus', 'success', 'Успешно');
showResult('mvResult', data, false);
} else {
showStatus('mvStatus', 'error', 'Ошибка');
showResult('mvResult', data, true);
}
} catch (error) {
showStatus('mvStatus', 'error', 'Ошибка');
showResult('mvResult', { error: error.message }, true);
}
}
</script> </script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
-- Migration: Drop project_score_sample_mv materialized view
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;

View File

@@ -0,0 +1,31 @@
-- Migration: Add project_score_sample_mv materialized view
--
-- One row per (project_id, score, user_id): sum of nodes.score per entry,
-- representative entry_message (latest by date). Used for admin display and reporting.
CREATE MATERIALIZED VIEW project_score_sample_mv AS
WITH entry_scores AS (
SELECT
n.project_id,
n.entry_id,
n.user_id,
SUM(n.score) AS score,
MAX(n.created_date) AS created_date
FROM nodes n
GROUP BY n.project_id, n.entry_id, n.user_id
)
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
es.project_id,
es.score,
e.text AS entry_message,
es.user_id,
es.created_date
FROM entry_scores es
JOIN entries e ON e.id = es.entry_id
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
WITH DATA;
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): sum of nodes per entry, representative entry_message (latest by date).';

View File

@@ -0,0 +1,30 @@
-- Revert to previous MV definition (one row per project_id, score, user_id)
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
CREATE MATERIALIZED VIEW project_score_sample_mv AS
WITH entry_scores AS (
SELECT
n.project_id,
n.entry_id,
n.user_id,
SUM(n.score) AS score,
MAX(n.created_date) AS created_date
FROM nodes n
GROUP BY n.project_id, n.entry_id, n.user_id
)
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
es.project_id,
es.score,
e.text AS entry_message,
es.user_id,
es.created_date
FROM entry_scores es
JOIN entries e ON e.id = es.entry_id
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
WITH DATA;
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): sum of nodes per entry, representative entry_message (latest by date).';

View File

@@ -0,0 +1,42 @@
-- Migration: Make entry_message unique per (project_id, user_id) in project_score_sample_mv
--
-- One row per (project_id, user_id, entry_message): choose the row with latest created_date.
-- Ensures the same entry_message does not repeat for different score values.
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
CREATE MATERIALIZED VIEW project_score_sample_mv AS
WITH entry_scores AS (
SELECT
n.project_id,
n.entry_id,
n.user_id,
SUM(n.score) AS score,
MAX(n.created_date) AS created_date
FROM nodes n
GROUP BY n.project_id, n.entry_id, n.user_id
),
with_message AS (
SELECT
es.project_id,
es.score,
e.text AS entry_message,
es.user_id,
es.created_date
FROM entry_scores es
JOIN entries e ON e.id = es.entry_id
)
SELECT DISTINCT ON (project_id, user_id, entry_message)
project_id,
score,
entry_message,
user_id,
created_date
FROM with_message
ORDER BY project_id, user_id, entry_message, created_date DESC
WITH DATA;
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, user_id, entry_message): representative row (latest by date). entry_message is unique per project and user.';

View File

@@ -0,0 +1,39 @@
-- Revert to one row per (project_id, user_id, entry_message)
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
CREATE MATERIALIZED VIEW project_score_sample_mv AS
WITH entry_scores AS (
SELECT
n.project_id,
n.entry_id,
n.user_id,
SUM(n.score) AS score,
MAX(n.created_date) AS created_date
FROM nodes n
GROUP BY n.project_id, n.entry_id, n.user_id
),
with_message AS (
SELECT
es.project_id,
es.score,
e.text AS entry_message,
es.user_id,
es.created_date
FROM entry_scores es
JOIN entries e ON e.id = es.entry_id
)
SELECT DISTINCT ON (project_id, user_id, entry_message)
project_id,
score,
entry_message,
user_id,
created_date
FROM with_message
ORDER BY project_id, user_id, entry_message, created_date DESC
WITH DATA;
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, user_id, entry_message): representative row (latest by date).';

View File

@@ -0,0 +1,32 @@
-- Migration: One row per (project_id, user_id, score) in project_score_sample_mv
--
-- For each score value (per project and user) exactly one record; representative entry_message (latest by date).
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
CREATE MATERIALIZED VIEW project_score_sample_mv AS
WITH entry_scores AS (
SELECT
n.project_id,
n.entry_id,
n.user_id,
SUM(n.score) AS score,
MAX(n.created_date) AS created_date
FROM nodes n
GROUP BY n.project_id, n.entry_id, n.user_id
)
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
es.project_id,
es.score,
e.text AS entry_message,
es.user_id,
es.created_date
FROM entry_scores es
JOIN entries e ON e.id = es.entry_id
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
WITH DATA;
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): one record per score, representative entry_message (latest by date).';

View File

@@ -0,0 +1,30 @@
-- Revert to one row per (project_id, score, user_id)
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
CREATE MATERIALIZED VIEW project_score_sample_mv AS
WITH entry_scores AS (
SELECT
n.project_id,
n.entry_id,
n.user_id,
SUM(n.score) AS score,
MAX(n.created_date) AS created_date
FROM nodes n
GROUP BY n.project_id, n.entry_id, n.user_id
)
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
es.project_id,
es.score,
e.text AS entry_message,
es.user_id,
es.created_date
FROM entry_scores es
JOIN entries e ON e.id = es.entry_id
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
WITH DATA;
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): one record per score, representative entry_message (latest by date).';

View File

@@ -0,0 +1,42 @@
-- Migration: One entry_message per (project_id, user_id) in project_score_sample_mv
--
-- One record per score (per project, user) and one record per entry_message per project.
-- DISTINCT ON (project_id, user_id, entry_message): same message with different scores → one row (latest by date).
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
CREATE MATERIALIZED VIEW project_score_sample_mv AS
WITH entry_scores AS (
SELECT
n.project_id,
n.entry_id,
n.user_id,
SUM(n.score) AS score,
MAX(n.created_date) AS created_date
FROM nodes n
GROUP BY n.project_id, n.entry_id, n.user_id
),
with_message AS (
SELECT
es.project_id,
es.score,
e.text AS entry_message,
es.user_id,
es.created_date
FROM entry_scores es
JOIN entries e ON e.id = es.entry_id
)
SELECT DISTINCT ON (project_id, user_id, entry_message)
project_id,
score,
entry_message,
user_id,
created_date
FROM with_message
ORDER BY project_id, user_id, entry_message, created_date DESC
WITH DATA;
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, user_id, entry_message): one record per score (chosen row), one entry_message per project; representative = latest by date.';

View File

@@ -0,0 +1,51 @@
-- Migration: Restore max_score column and MV using max_score for normalized_total_score
ALTER TABLE weekly_goals ADD COLUMN max_score NUMERIC(10,4);
UPDATE weekly_goals SET max_score = max_goal_score WHERE max_score IS NULL;
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
COALESCE(agg.total_score, 0.0000) AS total_score,
CASE
WHEN wg.max_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_score)
END AS normalized_total_score
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM n.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM n.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
WHERE
(EXTRACT(ISOYEAR FROM n.created_date)::INTEGER < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
OR (EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND EXTRACT(WEEK FROM n.created_date)::INTEGER < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
LEFT JOIN
weekly_goals wg
ON wg.project_id = p.id
AND wg.goal_year = agg.report_year
AND wg.goal_week = agg.report_week
WHERE
p.deleted = FALSE
ORDER BY
p.id, agg.report_year, agg.report_week
WITH DATA;
CREATE INDEX idx_weekly_report_mv_project_year_week
ON weekly_report_mv(project_id, report_year, report_week);
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_score snapshot. Contains only historical data (excludes current week). Uses nodes.created_date (denormalized) instead of entries.created_date.';

View File

@@ -0,0 +1,51 @@
-- Migration: Remove max_score from weekly_goals, use max_goal_score for normalized_total_score
-- normalized_total_score is now computed from max_goal_score (current goal) instead of max_score (snapshot).
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
COALESCE(agg.total_score, 0.0000) AS total_score,
CASE
WHEN wg.max_goal_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_goal_score)
END AS normalized_total_score
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM n.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM n.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
WHERE
(EXTRACT(ISOYEAR FROM n.created_date)::INTEGER < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
OR (EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND EXTRACT(WEEK FROM n.created_date)::INTEGER < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
LEFT JOIN
weekly_goals wg
ON wg.project_id = p.id
AND wg.goal_year = agg.report_year
AND wg.goal_week = agg.report_week
WHERE
p.deleted = FALSE
ORDER BY
p.id, agg.report_year, agg.report_week
WITH DATA;
CREATE INDEX idx_weekly_report_mv_project_year_week
ON weekly_report_mv(project_id, report_year, report_week);
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_goal_score. Contains only historical data (excludes current week). Uses nodes.created_date (denormalized) instead of entries.created_date.';
ALTER TABLE weekly_goals DROP COLUMN max_score;

View File

@@ -0,0 +1,20 @@
-- Откат: удаляем новые колонки
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS steps_task_id;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS floors_task_id;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS steps_goal_task_id;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS steps_goal_subtask_id;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS floors_goal_task_id;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS floors_goal_subtask_id;
ALTER TABLE fitbit_daily_stats DROP COLUMN IF EXISTS goal_steps;
ALTER TABLE fitbit_daily_stats DROP COLUMN IF EXISTS goal_floors;
-- Восстанавливаем старые колонки
ALTER TABLE fitbit_daily_stats ADD COLUMN active_zone_minutes INTEGER DEFAULT 0;
ALTER TABLE fitbit_integrations ADD COLUMN goal_steps_min INTEGER DEFAULT 8000;
ALTER TABLE fitbit_integrations ADD COLUMN goal_steps_max INTEGER DEFAULT 10000;
ALTER TABLE fitbit_integrations ADD COLUMN goal_floors_min INTEGER DEFAULT 8;
ALTER TABLE fitbit_integrations ADD COLUMN goal_floors_max INTEGER DEFAULT 10;
ALTER TABLE fitbit_integrations ADD COLUMN goal_azm_min INTEGER DEFAULT 22;
ALTER TABLE fitbit_integrations ADD COLUMN goal_azm_max INTEGER DEFAULT 44;

View File

@@ -0,0 +1,42 @@
-- =============================================
-- Удаляем старые колонки целей (goals) из fitbit_integrations
-- Теперь цели берутся из Fitbit API
-- =============================================
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_steps_min;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_steps_max;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_floors_min;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_floors_max;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_azm_min;
ALTER TABLE fitbit_integrations DROP COLUMN IF EXISTS goal_azm_max;
-- =============================================
-- Удаляем AZM колонку из fitbit_daily_stats
-- =============================================
ALTER TABLE fitbit_daily_stats DROP COLUMN IF EXISTS active_zone_minutes;
-- =============================================
-- Добавляем колонки для кэширования целей из Fitbit API
-- =============================================
ALTER TABLE fitbit_daily_stats ADD COLUMN goal_steps INTEGER;
ALTER TABLE fitbit_daily_stats ADD COLUMN goal_floors INTEGER;
-- =============================================
-- Добавляем привязки к задачам для записи прогресса
-- steps_task_id - задача куда записывать шаги как progression_value
-- floors_task_id - задача куда записывать этажи как progression_value
-- =============================================
ALTER TABLE fitbit_integrations ADD COLUMN steps_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL;
ALTER TABLE fitbit_integrations ADD COLUMN floors_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL;
-- =============================================
-- Добавляем привязки для целей (goals)
-- Для каждой цели храним И задачу И подзадачу
-- steps_goal_task_id - родительская задача для цели шагов
-- steps_goal_subtask_id - подзадача внутри неё, которая будет checked/unchecked
-- floors_goal_task_id - родительская задача для цели этажей
-- floors_goal_subtask_id - подзадача внутри неё
-- =============================================
ALTER TABLE fitbit_integrations ADD COLUMN steps_goal_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL;
ALTER TABLE fitbit_integrations ADD COLUMN steps_goal_subtask_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL;
ALTER TABLE fitbit_integrations ADD COLUMN floors_goal_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL;
ALTER TABLE fitbit_integrations ADD COLUMN floors_goal_subtask_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL;

View File

@@ -0,0 +1,4 @@
-- Restore max_score for rollback (snapshot of goal; can be repopulated from max_goal_score)
ALTER TABLE weekly_goals ADD COLUMN IF NOT EXISTS max_score NUMERIC(10,4);
UPDATE weekly_goals SET max_score = max_goal_score WHERE max_score IS NULL;

View File

@@ -0,0 +1,4 @@
-- Migration: Drop weekly_goals.max_score if still present (e.g. prod where 000020 wasn't applied)
-- normalized_total_score in weekly_report_mv uses max_goal_score; max_score is unused.
ALTER TABLE weekly_goals DROP COLUMN IF EXISTS max_score;

View File

@@ -0,0 +1,81 @@
-- Migration: Rollback to MV using max_score and restore max_score column.
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
ALTER TABLE weekly_goals ADD COLUMN IF NOT EXISTS max_score NUMERIC(10,4);
UPDATE weekly_goals SET max_score = max_goal_score WHERE max_score IS NULL;
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
COALESCE(agg.total_score, 0.0000) AS total_score,
CASE
WHEN wg.max_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_score)
END AS normalized_total_score
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM n.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM n.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
WHERE
(EXTRACT(ISOYEAR FROM n.created_date)::INTEGER < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
OR (EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND EXTRACT(WEEK FROM n.created_date)::INTEGER < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
LEFT JOIN
weekly_goals wg
ON wg.project_id = p.id
AND wg.goal_year = agg.report_year
AND wg.goal_week = agg.report_week
WHERE
p.deleted = FALSE
ORDER BY
p.id, agg.report_year, agg.report_week
WITH DATA;
CREATE INDEX idx_weekly_report_mv_project_year_week
ON weekly_report_mv(project_id, report_year, report_week);
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_score snapshot. Contains only historical data (excludes current week). Uses nodes.created_date (denormalized) instead of entries.created_date.';
-- Recreate projects_median_mv (last 4 weeks per 000008)
CREATE MATERIALIZED VIEW projects_median_mv AS
SELECT
p.id AS project_id,
p.user_id,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY normalized_total_score) AS median_score
FROM (
SELECT
project_id,
normalized_total_score,
report_year,
report_week,
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
FROM weekly_report_mv
WHERE
(report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
) sub
JOIN projects p ON p.id = sub.project_id
WHERE rn <= 4 AND p.deleted = FALSE
GROUP BY p.id, p.user_id
WITH DATA;
CREATE INDEX idx_projects_median_mv_project_id ON projects_median_mv(project_id);
CREATE INDEX idx_projects_median_mv_user_id ON projects_median_mv(user_id);
COMMENT ON MATERIALIZED VIEW projects_median_mv IS 'Materialized view calculating median score for each project based on last 4 weeks of historical data. Includes user_id for multi-tenant support.';

View File

@@ -0,0 +1,82 @@
-- Migration: Fix weekly_report_mv to use max_goal_score for normalized_total_score.
-- Safe to run on DBs where 000020 was not applied (MV still uses max_score, column exists but is NULL).
-- projects_median_mv depends on weekly_report_mv, so we drop and recreate it.
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
COALESCE(agg.total_score, 0.0000) AS total_score,
CASE
WHEN wg.max_goal_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_goal_score)
END AS normalized_total_score
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM n.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM n.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
WHERE
(EXTRACT(ISOYEAR FROM n.created_date)::INTEGER < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
OR (EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND EXTRACT(WEEK FROM n.created_date)::INTEGER < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
LEFT JOIN
weekly_goals wg
ON wg.project_id = p.id
AND wg.goal_year = agg.report_year
AND wg.goal_week = agg.report_week
WHERE
p.deleted = FALSE
ORDER BY
p.id, agg.report_year, agg.report_week
WITH DATA;
CREATE INDEX idx_weekly_report_mv_project_year_week
ON weekly_report_mv(project_id, report_year, report_week);
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_goal_score. Contains only historical data (excludes current week). Uses nodes.created_date (denormalized) instead of entries.created_date.';
-- Recreate projects_median_mv (depends on weekly_report_mv, last 4 weeks per 000008)
CREATE MATERIALIZED VIEW projects_median_mv AS
SELECT
p.id AS project_id,
p.user_id,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY normalized_total_score) AS median_score
FROM (
SELECT
project_id,
normalized_total_score,
report_year,
report_week,
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
FROM weekly_report_mv
WHERE
(report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
) sub
JOIN projects p ON p.id = sub.project_id
WHERE rn <= 4 AND p.deleted = FALSE
GROUP BY p.id, p.user_id
WITH DATA;
CREATE INDEX idx_projects_median_mv_project_id ON projects_median_mv(project_id);
CREATE INDEX idx_projects_median_mv_user_id ON projects_median_mv(user_id);
COMMENT ON MATERIALIZED VIEW projects_median_mv IS 'Materialized view calculating median score for each project based on last 4 weeks of historical data. Includes user_id for multi-tenant support.';
ALTER TABLE weekly_goals DROP COLUMN IF EXISTS max_score;

View File

@@ -0,0 +1,30 @@
-- Migration: Recreate projects_median_mv (rollback of 000024)
-- Definition: last 4 weeks per 000008/000023
CREATE MATERIALIZED VIEW projects_median_mv AS
SELECT
p.id AS project_id,
p.user_id,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY normalized_total_score) AS median_score
FROM (
SELECT
project_id,
normalized_total_score,
report_year,
report_week,
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
FROM weekly_report_mv
WHERE
(report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
) sub
JOIN projects p ON p.id = sub.project_id
WHERE rn <= 4 AND p.deleted = FALSE
GROUP BY p.id, p.user_id
WITH DATA;
CREATE INDEX idx_projects_median_mv_project_id ON projects_median_mv(project_id);
CREATE INDEX idx_projects_median_mv_user_id ON projects_median_mv(user_id);
COMMENT ON MATERIALIZED VIEW projects_median_mv IS 'Materialized view calculating median score for each project based on last 4 weeks of historical data. Includes user_id for multi-tenant support.';

View File

@@ -0,0 +1,4 @@
-- Migration: Drop projects_median_mv (unlock weeks now use weekly_goals.min_goal_score)
-- Date: 2026-02-24
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;

View File

@@ -0,0 +1,3 @@
-- Remove rejected column from wishlist_items
DROP INDEX IF EXISTS idx_wishlist_items_rejected;
ALTER TABLE wishlist_items DROP COLUMN IF EXISTS rejected;

View File

@@ -0,0 +1,5 @@
-- Add rejected column to wishlist_items
ALTER TABLE wishlist_items ADD COLUMN rejected BOOLEAN DEFAULT FALSE;
-- Create index for filtering by rejected status
CREATE INDEX idx_wishlist_items_rejected ON wishlist_items(rejected) WHERE rejected = TRUE;

View File

@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS shopping_items;
DROP TABLE IF EXISTS shopping_board_members;
DROP TABLE IF EXISTS shopping_boards;

View File

@@ -0,0 +1,50 @@
-- Shopping boards (аналог wishlist_boards)
CREATE TABLE shopping_boards (
id SERIAL PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
invite_token VARCHAR(64) UNIQUE,
invite_enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN DEFAULT FALSE
);
CREATE INDEX idx_shopping_boards_owner_id ON shopping_boards(owner_id);
CREATE INDEX idx_shopping_boards_invite_token ON shopping_boards(invite_token) WHERE invite_token IS NOT NULL;
CREATE INDEX idx_shopping_boards_owner_deleted ON shopping_boards(owner_id, deleted);
-- Shopping board members (аналог wishlist_board_members)
CREATE TABLE shopping_board_members (
id SERIAL PRIMARY KEY,
board_id INTEGER NOT NULL REFERENCES shopping_boards(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_shopping_board_member UNIQUE (board_id, user_id)
);
CREATE INDEX idx_shopping_board_members_board_id ON shopping_board_members(board_id);
CREATE INDEX idx_shopping_board_members_user_id ON shopping_board_members(user_id);
-- Shopping items (товары)
CREATE TABLE shopping_items (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
board_id INTEGER NOT NULL REFERENCES shopping_boards(id) ON DELETE CASCADE,
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
group_name VARCHAR(255),
volume_base NUMERIC(10,4) NOT NULL DEFAULT 1,
repetition_period INTERVAL,
next_show_at TIMESTAMP WITH TIME ZONE,
completed INTEGER DEFAULT 0,
last_completed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN DEFAULT FALSE
);
CREATE INDEX idx_shopping_items_board_id ON shopping_items(board_id);
CREATE INDEX idx_shopping_items_user_id ON shopping_items(user_id);
CREATE INDEX idx_shopping_items_deleted ON shopping_items(deleted);
CREATE INDEX idx_shopping_items_next_show_at ON shopping_items(next_show_at);

View File

@@ -0,0 +1 @@
ALTER TABLE shopping_items DROP COLUMN description;

View File

@@ -0,0 +1 @@
ALTER TABLE shopping_items ADD COLUMN description TEXT;

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS shopping_item_history;

View File

@@ -0,0 +1,10 @@
CREATE TABLE shopping_item_history (
id SERIAL PRIMARY KEY,
item_id INTEGER NOT NULL REFERENCES shopping_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
name VARCHAR(255) NOT NULL,
volume NUMERIC(10,4) NOT NULL DEFAULT 1,
completed_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_shopping_item_history_item_id ON shopping_item_history(item_id);

View File

@@ -0,0 +1,3 @@
ALTER TABLE tasks DROP COLUMN IF EXISTS purchase_config_id;
DROP TABLE IF EXISTS purchase_config_boards;
DROP TABLE IF EXISTS purchase_configs;

View File

@@ -0,0 +1,24 @@
-- Purchase task configurations
CREATE TABLE purchase_configs (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_purchase_configs_user_id ON purchase_configs(user_id);
-- Purchase config board/group associations
CREATE TABLE purchase_config_boards (
id SERIAL PRIMARY KEY,
purchase_config_id INTEGER NOT NULL REFERENCES purchase_configs(id) ON DELETE CASCADE,
board_id INTEGER NOT NULL REFERENCES shopping_boards(id) ON DELETE CASCADE,
group_name VARCHAR(255),
UNIQUE (purchase_config_id, board_id, group_name)
);
CREATE INDEX idx_purchase_config_boards_config_id ON purchase_config_boards(purchase_config_id);
CREATE INDEX idx_purchase_config_boards_board_id ON purchase_config_boards(board_id);
-- Add purchase_config_id to tasks
ALTER TABLE tasks ADD COLUMN purchase_config_id INTEGER REFERENCES purchase_configs(id) ON DELETE SET NULL;
CREATE INDEX idx_tasks_purchase_config_id ON tasks(purchase_config_id);

View File

@@ -0,0 +1,3 @@
ALTER TABLE users
DROP COLUMN IF EXISTS priorities_confirmed_year,
DROP COLUMN IF EXISTS priorities_confirmed_week;

View File

@@ -0,0 +1,3 @@
ALTER TABLE users
ADD COLUMN priorities_confirmed_year INTEGER NOT NULL DEFAULT 0,
ADD COLUMN priorities_confirmed_week INTEGER NOT NULL DEFAULT 0;

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS shopping_volume_records;

View File

@@ -0,0 +1,19 @@
-- Отдельная таблица записей об остатках (создаётся при каждом выполнении и переносе)
CREATE TABLE shopping_volume_records (
id SERIAL PRIMARY KEY,
item_id INTEGER NOT NULL REFERENCES shopping_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
action_type VARCHAR(20) NOT NULL,
volume_remaining NUMERIC(10,4),
volume_purchased NUMERIC(10,4),
daily_consumption NUMERIC(10,4),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_shopping_volume_records_item_id ON shopping_volume_records(item_id);
-- Создаём начальные записи для всех существующих товаров (остаток 0, дата = created_at)
INSERT INTO shopping_volume_records (item_id, user_id, action_type, volume_remaining, volume_purchased, created_at)
SELECT id, user_id, 'create', 0, 0, created_at
FROM shopping_items
WHERE deleted = FALSE;

View File

@@ -0,0 +1 @@
ALTER TABLE shopping_volume_records DROP COLUMN IF EXISTS next_show_at;

View File

@@ -0,0 +1 @@
ALTER TABLE shopping_volume_records ADD COLUMN next_show_at TIMESTAMP;

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS board_archives;

View File

@@ -0,0 +1,10 @@
CREATE TABLE board_archives (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
board_type VARCHAR(20) NOT NULL, -- 'wishlist' or 'shopping'
board_id INTEGER NOT NULL,
archived_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(user_id, board_type, board_id)
);
CREATE INDEX idx_board_archives_user_type ON board_archives(user_id, board_type);

View File

@@ -0,0 +1,2 @@
ALTER TABLE tasks DROP COLUMN default_auto_complete;
ALTER TABLE tasks DROP COLUMN default_progress;

View File

@@ -0,0 +1,4 @@
ALTER TABLE tasks ADD COLUMN default_auto_complete BOOLEAN DEFAULT FALSE;
ALTER TABLE tasks ADD COLUMN default_progress NUMERIC(10,4);
-- Для существующих задач: default_progress = progression_base
UPDATE tasks SET default_progress = progression_base WHERE progression_base IS NOT NULL;

View File

@@ -45,7 +45,6 @@ docker-compose exec db psql -U playeng -d playeng -f /migrations/001_create_sche
- `goal_week` (INTEGER NOT NULL) - `goal_week` (INTEGER NOT NULL)
- `min_goal_score` (NUMERIC(10,4) NOT NULL, DEFAULT 0) - `min_goal_score` (NUMERIC(10,4) NOT NULL, DEFAULT 0)
- `max_goal_score` (NUMERIC(10,4)) - `max_goal_score` (NUMERIC(10,4))
- `max_score` (NUMERIC(10,4), NULL) — snapshot max на неделю (заполняется только для новых недель)
- `priority` (SMALLINT) - `priority` (SMALLINT)
- UNIQUE CONSTRAINT: `(project_id, goal_year, goal_week)` - UNIQUE CONSTRAINT: `(project_id, goal_year, goal_week)`
@@ -56,7 +55,7 @@ docker-compose exec db psql -U playeng -d playeng -f /migrations/001_create_sche
- `report_year` (INTEGER) - `report_year` (INTEGER)
- `report_week` (INTEGER) - `report_week` (INTEGER)
- `total_score` (NUMERIC) - `total_score` (NUMERIC)
- `normalized_total_score` (NUMERIC) - `normalized_total_score` (NUMERIC) — ограничение total_score по `max_goal_score` (миграция 000020 удалила колонку `max_score`, normalized считается по `max_goal_score`)
## Миграции ## Миграции

Binary file not shown.

12
play-life-llm/.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# Env with secrets (Tavily API key, etc.)
.env
# Binary
play-life-llm
*.exe
# IDE / OS
.idea/
.vscode/
*.swp
.DS_Store

19
play-life-llm/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /app
ENV GOPROXY=https://proxy.golang.org,direct
ENV GOSUMDB=sum.golang.org
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o play-life-llm .
# Runtime stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates wget
WORKDIR /app
COPY --from=builder /app/play-life-llm .
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -q -O- http://localhost:8090/health || exit 1
CMD ["./play-life-llm"]

12
play-life-llm/env.example Normal file
View File

@@ -0,0 +1,12 @@
# Ollama API base URL (default: http://localhost:11434)
# For Docker on Mac/Windows use: http://host.docker.internal:11434
OLLAMA_HOST=http://localhost:11434
# Tavily API key for web search (required when model uses web_search tool)
TAVILY_API_KEY=
# HTTP server port (default: 8090)
PORT=8090
# Default Ollama model (default: llama3.1:70b)
OLLAMA_MODEL=llama3.1:70b

5
play-life-llm/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module play-life-llm
go 1.24.0
require github.com/gorilla/mux v1.8.1

2
play-life-llm/go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=

View File

@@ -0,0 +1,177 @@
package handler
import (
"encoding/json"
"log"
"net/http"
"play-life-llm/internal/ollama"
"play-life-llm/internal/tavily"
)
// AskRequest is the POST /ask body.
type AskRequest struct {
Prompt string `json:"prompt"`
ResponseSchema interface{} `json:"response_schema"`
Model string `json:"model,omitempty"`
// AllowWebSearch: если true, в запрос к Ollama добавляются tools (web_search), и при вызове модели выполняется поиск через Tavily. Если false (по умолчанию), tools не передаются — модель просто возвращает JSON по схеме (подходит для простых запросов без интернета).
AllowWebSearch bool `json:"allow_web_search,omitempty"`
}
// AskResponse is the successful response (result is JSON by schema).
type AskResponse struct {
Result json.RawMessage `json:"result"`
}
// AskHandler handles POST /ask: prompt + response_schema -> LLM with optional web search, returns JSON.
type AskHandler struct {
Ollama *ollama.Client
Tavily *tavily.Client
DefaultModel string
}
// ServeHTTP implements http.Handler.
func (h *AskHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req AskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendError(w, "invalid JSON body", http.StatusBadRequest)
return
}
if req.Prompt == "" {
sendError(w, "prompt is required", http.StatusBadRequest)
return
}
if req.ResponseSchema == nil {
sendError(w, "response_schema is required", http.StatusBadRequest)
return
}
model := req.Model
if model == "" {
model = h.DefaultModel
}
if model == "" {
model = "llama3.1:70b"
}
var tools []ollama.Tool
if req.AllowWebSearch {
tools = []ollama.Tool{ollama.WebSearchTool()}
}
messages := []ollama.ChatMessage{}
if req.AllowWebSearch {
messages = append(messages, ollama.ChatMessage{
Role: "system",
Content: "When the user asks for current, recent, or real-time information (weather, prices, news, etc.), you MUST call the web_search tool with a suitable query. Do not answer from memory — use the tool and then summarize the results in your response.",
})
// Гарантированный запрос в Tavily: предпоиск по промпту пользователя, результат подмешивается в контекст.
searchQuery := req.Prompt
if len(searchQuery) > 200 {
searchQuery = searchQuery[:200]
}
log.Printf("tavily pre-search: query=%q", searchQuery)
preSearchResult, err := h.Tavily.Search(searchQuery)
if err != nil {
log.Printf("tavily pre-search error: %v", err)
preSearchResult = "search failed: " + err.Error()
} else {
log.Printf("tavily pre-search ok: %d bytes", len(preSearchResult))
}
messages = append(messages, ollama.ChatMessage{
Role: "system",
Content: "Relevant web search result for the user's question (use this to answer; if not enough, you may call web_search again):\n\n" + preSearchResult,
})
}
messages = append(messages, ollama.ChatMessage{
Role: "user", Content: req.Prompt,
})
const maxToolRounds = 20
for round := 0; round < maxToolRounds; round++ {
chatReq := &ollama.ChatRequest{
Model: model,
Messages: messages,
Stream: false,
Format: req.ResponseSchema,
Tools: tools,
}
resp, err := h.Ollama.Chat(chatReq)
if err != nil {
log.Printf("ollama chat error: %v", err)
sendError(w, "ollama request failed: "+err.Error(), http.StatusBadGateway)
return
}
messages = append(messages, resp.Message)
if n := len(resp.Message.ToolCalls); n > 0 {
log.Printf("ollama returned %d tool_calls", n)
}
if len(resp.Message.ToolCalls) == 0 {
// Final answer: message.content is JSON by schema
content := resp.Message.Content
if content == "" {
sendError(w, "empty response from model", http.StatusBadGateway)
return
}
// Return as { "result": <parsed JSON> } so client gets valid JSON
var raw json.RawMessage
if err := json.Unmarshal([]byte(content), &raw); err != nil {
// If not valid JSON, return as string inside result
raw = json.RawMessage(`"` + escapeJSONString(content) + `"`)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(AskResponse{Result: raw})
return
}
// Execute tool calls (web_search via Tavily)
for _, tc := range resp.Message.ToolCalls {
if tc.Function.Name != "web_search" {
messages = append(messages, ollama.ChatMessage{
Role: "tool", ToolName: tc.Function.Name, Content: "unknown tool",
})
continue
}
query := ollama.QueryFromToolCall(tc)
if query == "" {
// Некоторые модели подставляют в arguments не "query", а другие поля — используем промпт пользователя как поисковый запрос
query = req.Prompt
if len(query) > 200 {
query = query[:200]
}
log.Printf("web_search: query empty in tool_call, using user prompt (first 200 chars)")
}
log.Printf("tavily search: query=%q", query)
searchResult, err := h.Tavily.Search(query)
if err != nil {
log.Printf("tavily search error: %v", err)
searchResult = "search failed: " + err.Error()
} else {
log.Printf("tavily search ok: %d bytes", len(searchResult))
}
messages = append(messages, ollama.ChatMessage{
Role: "tool", ToolName: "web_search", Content: searchResult,
})
}
}
// Too many tool rounds
sendError(w, "too many tool-call rounds", http.StatusBadGateway)
}
func sendError(w http.ResponseWriter, msg string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func escapeJSONString(s string) string {
b, _ := json.Marshal(s)
return string(b[1 : len(b)-1])
}

View File

@@ -0,0 +1,17 @@
package handler
import (
"encoding/json"
"net/http"
)
// Health returns 200 with {"status": "ok"} for Docker healthcheck.
func Health(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

View File

@@ -0,0 +1,148 @@
package ollama
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const defaultTimeout = 10 * time.Minute
// Client calls Ollama /api/chat.
type Client struct {
BaseURL string
HTTPClient *http.Client
}
// NewClient creates an Ollama client. baseURL is e.g. "http://localhost:11434".
func NewClient(baseURL string) *Client {
return &Client{
BaseURL: baseURL,
HTTPClient: &http.Client{
Timeout: defaultTimeout,
},
}
}
// ChatRequest matches Ollama POST /api/chat body.
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Stream bool `json:"stream"`
Format interface{} `json:"format,omitempty"` // "json" or JSON schema object
Tools []Tool `json:"tools,omitempty"`
}
// ChatMessage is one message in the conversation.
type ChatMessage struct {
Role string `json:"role"` // "user", "assistant", "system", "tool"
Content string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolName string `json:"tool_name,omitempty"` // for role "tool"
}
// Tool defines a function the model may call.
type Tool struct {
Type string `json:"type"`
Function ToolFunc `json:"function"`
}
// ToolFunc describes the function.
type ToolFunc struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters interface{} `json:"parameters"`
}
// ToolCall is a model request to call a tool.
type ToolCall struct {
Type string `json:"type"`
Function ToolCallFn `json:"function"`
}
// ToolCallFn holds name and arguments.
// Arguments may come from Ollama as a JSON object or as a JSON string.
type ToolCallFn struct {
Name string `json:"name"`
Arguments interface{} `json:"arguments"` // object or string
}
// QueryFromToolCall returns the "query" argument from a web_search tool call.
// Ollama may send arguments as a map or as a JSON string.
func QueryFromToolCall(tc ToolCall) string {
switch v := tc.Function.Arguments.(type) {
case map[string]interface{}:
if q, _ := v["query"].(string); q != "" {
return q
}
case string:
var m map[string]interface{}
if json.Unmarshal([]byte(v), &m) == nil {
if q, _ := m["query"].(string); q != "" {
return q
}
}
}
return ""
}
// ChatResponse is the Ollama /api/chat response.
type ChatResponse struct {
Message ChatMessage `json:"message"`
Done bool `json:"done"`
}
// Chat sends a chat request and returns the response.
func (c *Client) Chat(req *ChatRequest) (*ChatResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
url := c.BaseURL + "/api/chat"
httpReq, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ollama returned %d: %s", resp.StatusCode, string(b))
}
var out ChatResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &out, nil
}
// WebSearchTool returns the tool definition for web_search (Tavily).
func WebSearchTool() Tool {
return Tool{
Type: "function",
Function: ToolFunc{
Name: "web_search",
Description: "Search the web for current information. Use when you need up-to-date or factual information from the internet.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{
"type": "string",
"description": "Search query",
},
},
"required": []string{"query"},
},
},
}
}

View File

@@ -0,0 +1,35 @@
package server
import (
"net/http"
"play-life-llm/internal/handler"
"play-life-llm/internal/ollama"
"play-life-llm/internal/tavily"
"github.com/gorilla/mux"
)
// Config holds server and client configuration.
type Config struct {
OllamaHost string
TavilyAPIKey string
DefaultModel string
}
// NewRouter returns an HTTP router with /health and /ask registered.
func NewRouter(cfg Config) http.Handler {
ollamaClient := ollama.NewClient(cfg.OllamaHost)
tavilyClient := tavily.NewClient(cfg.TavilyAPIKey)
askHandler := &handler.AskHandler{
Ollama: ollamaClient,
Tavily: tavilyClient,
DefaultModel: cfg.DefaultModel,
}
r := mux.NewRouter()
r.HandleFunc("/health", handler.Health).Methods(http.MethodGet)
r.Handle("/ask", askHandler).Methods(http.MethodPost)
return r
}

View File

@@ -0,0 +1,104 @@
package tavily
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
)
const (
baseURL = "https://api.tavily.com"
searchPath = "/search"
timeout = 30 * time.Second
)
// Client calls Tavily Search API.
type Client struct {
APIKey string
HTTPClient *http.Client
}
// NewClient creates a Tavily client. apiKey is required for search.
func NewClient(apiKey string) *Client {
return &Client{
APIKey: apiKey,
HTTPClient: &http.Client{
Timeout: timeout,
},
}
}
// SearchRequest is the POST body for /search.
type SearchRequest struct {
Query string `json:"query"`
SearchDepth string `json:"search_depth,omitempty"` // basic, advanced, etc.
MaxResults int `json:"max_results,omitempty"`
}
// SearchResult is one result item.
type SearchResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
}
// SearchResponse is the Tavily search response.
type SearchResponse struct {
Query string `json:"query"`
Answer string `json:"answer,omitempty"`
Results []SearchResult `json:"results"`
}
// Search runs a web search and returns a single text suitable for passing to Ollama as tool result.
func (c *Client) Search(query string) (string, error) {
if c.APIKey == "" {
return "", fmt.Errorf("tavily: API key not set")
}
body, err := json.Marshal(SearchRequest{
Query: query,
MaxResults: 5,
})
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
url := baseURL + searchPath
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("tavily returned %d", resp.StatusCode)
}
var out SearchResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
// Build a single text for the model: prefer answer if present, else concatenate results.
if out.Answer != "" {
return out.Answer, nil
}
var b bytes.Buffer
for i, r := range out.Results {
if i > 0 {
b.WriteString("\n\n")
}
b.WriteString(r.Title)
b.WriteString(": ")
b.WriteString(r.Content)
}
return b.String(), nil
}

36
play-life-llm/main.go Normal file
View File

@@ -0,0 +1,36 @@
package main
import (
"log"
"net/http"
"os"
"play-life-llm/internal/server"
)
func main() {
ollamaHost := getEnv("OLLAMA_HOST", "http://localhost:11434")
tavilyAPIKey := getEnv("TAVILY_API_KEY", "")
port := getEnv("PORT", "8090")
defaultModel := getEnv("OLLAMA_MODEL", "llama3.1:70b")
cfg := server.Config{
OllamaHost: ollamaHost,
TavilyAPIKey: tavilyAPIKey,
DefaultModel: defaultModel,
}
router := server.NewRouter(cfg)
addr := ":" + port
log.Printf("play-life-llm listening on %s", addr)
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatal(err)
}
}
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}

View File

@@ -50,7 +50,7 @@ server {
} }
# Proxy other API endpoints to backend # Proxy other API endpoints to backend
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|message/post|webhook/|weekly_goals/setup)$ { location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|message/post|webhook/|weekly_goals/setup|project_score_sample_mv/refresh|priorities/confirm)$ {
proxy_pass http://backend:8080; proxy_pass http://backend:8080;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "4.27.3", "version": "6.27.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -14,6 +14,12 @@ import WishlistForm from './components/WishlistForm'
import WishlistDetail from './components/WishlistDetail' import WishlistDetail from './components/WishlistDetail'
import BoardForm from './components/BoardForm' import BoardForm from './components/BoardForm'
import BoardJoinPreview from './components/BoardJoinPreview' import BoardJoinPreview from './components/BoardJoinPreview'
import ShoppingList from './components/ShoppingList'
import ShoppingItemForm from './components/ShoppingItemForm'
import ShoppingBoardForm from './components/ShoppingBoardForm'
import ShoppingBoardJoinPreview from './components/ShoppingBoardJoinPreview'
import ShoppingItemHistory from './components/ShoppingItemHistory'
import PurchaseScreen from './components/PurchaseScreen'
import TodoistIntegration from './components/TodoistIntegration' import TodoistIntegration from './components/TodoistIntegration'
import TelegramIntegration from './components/TelegramIntegration' import TelegramIntegration from './components/TelegramIntegration'
import FitbitIntegration from './components/FitbitIntegration' import FitbitIntegration from './components/FitbitIntegration'
@@ -30,7 +36,23 @@ const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
// Определяем основные табы (без крестика) и глубокие табы (с крестиком) // Определяем основные табы (без крестика) и глубокие табы (с крестиком)
const mainTabs = ['current', 'tasks', 'wishlist', 'profile'] const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite'] const deepTabs = ['add-words', 'test', 'purchase', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history']
/**
* Гарантирует базовую запись истории для главного экрана перед глубоким табом.
* После долгого бездействия PWA может перезапуститься с одной записью в истории;
* кнопка "назад" тогда закрывает приложение. Эта функция добавляет запись для
* экрана 'current', чтобы "назад" возвращала на главный экран.
*/
function ensureBaseHistory(deepTab, params = {}, url) {
if (typeof window === 'undefined' || !deepTabs.includes(deepTab)) return
if (window.history.length <= 1) {
window.history.replaceState({ tab: 'current' }, '', '/')
window.history.pushState({ tab: deepTab, params, previousTab: 'current' }, '', url)
} else {
window.history.replaceState({ tab: deepTab, params, previousTab: 'current' }, '', url)
}
}
function AppContent() { function AppContent() {
const { authFetch, isAuthenticated, loading: authLoading } = useAuth() const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
@@ -61,6 +83,12 @@ function AppContent() {
tracking: false, tracking: false,
'tracking-access': false, 'tracking-access': false,
'tracking-invite': false, 'tracking-invite': false,
shopping: false,
'shopping-item-form': false,
'shopping-board-form': false,
'shopping-board-join': false,
'shopping-item-history': false,
purchase: false,
}) })
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок) // Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
@@ -86,6 +114,12 @@ function AppContent() {
tracking: false, tracking: false,
'tracking-access': false, 'tracking-access': false,
'tracking-invite': false, 'tracking-invite': false,
shopping: false,
'shopping-item-form': false,
'shopping-board-form': false,
'shopping-board-join': false,
'shopping-item-history': false,
purchase: false,
}) })
// Параметры для навигации между вкладками // Параметры для навигации между вкладками
@@ -94,12 +128,16 @@ function AppContent() {
// Предыдущий таб для возврата из модальных окон // Предыдущий таб для возврата из модальных окон
const [previousTab, setPreviousTab] = useState(null) const [previousTab, setPreviousTab] = useState(null)
// Модальное окно выбора типа задачи // Счётчик для сброса формы товара при каждом открытии
const [showAddModal, setShowAddModal] = useState(false) const [shoppingItemFormKey, setShoppingItemFormKey] = useState(0)
// Ref для функции открытия модала добавления записи в CurrentWeek // Ref для функции открытия модала добавления записи в CurrentWeek
const currentWeekAddModalRef = useRef(null) const currentWeekAddModalRef = useRef(null)
// Подтверждение приоритетов на текущей неделе (null = неизвестно, true/false = известно)
const [prioritiesConfirmed, setPrioritiesConfirmed] = useState(null)
const prioritiesOverlayPushedRef = useRef(false)
// Кеширование данных // Кеширование данных
const [currentWeekData, setCurrentWeekData] = useState(null) const [currentWeekData, setCurrentWeekData] = useState(null)
const [fullStatisticsData, setFullStatisticsData] = useState(null) const [fullStatisticsData, setFullStatisticsData] = useState(null)
@@ -133,22 +171,61 @@ function AppContent() {
const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0) const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0)
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0) const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0) const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0)
const [shoppingRefreshTrigger, setShoppingRefreshTrigger] = useState(0)
// Восстанавливаем последний выбранный таб после перезагрузки // Восстанавливаем последний выбранный таб после перезагрузки
const [isInitialized, setIsInitialized] = useState(false) const [isInitialized, setIsInitialized] = useState(false)
// Управление историей для оверлея приоритетов
useEffect(() => {
const overlayVisible = activeTab === 'current' && prioritiesConfirmed === false
if (overlayVisible && !prioritiesOverlayPushedRef.current) {
prioritiesOverlayPushedRef.current = true
// Заменяем текущую запись { tab: 'current' } на { tab: 'tasks' },
// затем добавляем запись оверлея. Так системная кнопка "Назад" вернёт на tasks.
window.history.replaceState({ tab: 'tasks' }, '', '/')
window.history.pushState({ tab: 'current', prioritiesOverlay: true }, '', '/')
}
if (!overlayVisible) {
prioritiesOverlayPushedRef.current = false
}
}, [activeTab, prioritiesConfirmed])
// Переключение на экран прогрессии после успешной авторизации // Переключение на экран прогрессии после успешной авторизации
useEffect(() => { useEffect(() => {
// Обновляем ref только после того, как authLoading стал false // Обновляем ref только после того, как authLoading стал false
if (!authLoading) { if (!authLoading) {
const wasNotAuthenticated = prevIsAuthenticatedRef.current === false const wasNotAuthenticated = prevIsAuthenticatedRef.current === false
// Обновляем ref только если инициализация завершена,
// чтобы не потерять переход false→true при ожидании isInitialized
if (isInitialized) {
prevIsAuthenticatedRef.current = isAuthenticated prevIsAuthenticatedRef.current = isAuthenticated
}
// Проверяем, что это новая авторизация (переход с false на true) // Проверяем, что это новая авторизация (переход с false на true)
// и что инициализация уже завершена (чтобы не конфликтовать с восстановлением из URL/localStorage) // и что инициализация уже завершена (чтобы не конфликтовать с восстановлением из URL/localStorage)
if (wasNotAuthenticated && isAuthenticated && isInitialized) { if (wasNotAuthenticated && isAuthenticated && isInitialized) {
// Сбрасываем ошибки, кеш данных и состояние инициализации табов при повторной авторизации
setCurrentWeekError(null)
setFullStatisticsError(null)
setPrioritiesError(null)
setTasksError(null)
setTodayEntriesError(null)
setCurrentWeekData(null)
setFullStatisticsData(null)
setTasksData(null)
setTodayEntriesData(null)
setPrioritiesConfirmed(null)
// Сбрасываем инициализацию табов, чтобы данные загрузились заново
Object.keys(tabsInitializedRef.current).forEach(key => {
tabsInitializedRef.current[key] = false
})
cacheRef.current = { current: null, full: null, tasks: null, todayEntries: null }
lastLoadedTabRef.current = null
// Переключаемся на экран прогресса только если нет таба в URL // Переключаемся на экран прогресса только если нет таба в URL
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
const tabFromUrl = urlParams.get('tab') const tabFromUrl = urlParams.get('tab')
@@ -179,12 +256,26 @@ function AppContent() {
if (path.startsWith('/invite/')) { if (path.startsWith('/invite/')) {
const token = path.replace('/invite/', '') const token = path.replace('/invite/', '')
if (token) { if (token) {
const url = '/?tab=board-join&inviteToken=' + token
ensureBaseHistory('board-join', { inviteToken: token }, url)
setActiveTab('board-join') setActiveTab('board-join')
setLoadedTabs(prev => ({ ...prev, 'board-join': true })) setLoadedTabs(prev => ({ ...prev, 'board-join': true }))
setTabParams({ inviteToken: token }) setTabParams({ inviteToken: token })
setIsInitialized(true) setIsInitialized(true)
// Очищаем путь, оставляем только параметры return
window.history.replaceState({}, '', '/?tab=board-join&inviteToken=' + token) }
}
// Проверяем путь /shopping-invite/:token для присоединения к shopping доске
if (path.startsWith('/shopping-invite/')) {
const token = path.replace('/shopping-invite/', '')
if (token) {
const url = '/?tab=shopping-board-join&inviteToken=' + token
ensureBaseHistory('shopping-board-join', { inviteToken: token }, url)
setActiveTab('shopping-board-join')
setLoadedTabs(prev => ({ ...prev, 'shopping-board-join': true }))
setTabParams({ inviteToken: token })
setIsInitialized(true)
return return
} }
} }
@@ -193,11 +284,12 @@ function AppContent() {
if (path.startsWith('/tracking/invite/')) { if (path.startsWith('/tracking/invite/')) {
const token = path.replace('/tracking/invite/', '') const token = path.replace('/tracking/invite/', '')
if (token) { if (token) {
const url = '/?tab=tracking-invite&inviteToken=' + token
ensureBaseHistory('tracking-invite', { inviteToken: token }, url)
setActiveTab('tracking-invite') setActiveTab('tracking-invite')
setLoadedTabs(prev => ({ ...prev, 'tracking-invite': true })) setLoadedTabs(prev => ({ ...prev, 'tracking-invite': true }))
setTabParams({ inviteToken: token }) setTabParams({ inviteToken: token })
setIsInitialized(true) setIsInitialized(true)
window.history.replaceState({}, '', '/?tab=tracking-invite&inviteToken=' + token)
return return
} }
} }
@@ -206,29 +298,27 @@ function AppContent() {
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
const integration = urlParams.get('integration') const integration = urlParams.get('integration')
if (integration === 'fitbit') { if (integration === 'fitbit') {
setActiveTab('fitbit-integration')
setLoadedTabs(prev => ({ ...prev, 'fitbit-integration': true }))
setIsInitialized(true)
// Перезаписываем URL с tab параметром и сохраняем integration/status для компонента
const status = urlParams.get('status') const status = urlParams.get('status')
const message = urlParams.get('message') const message = urlParams.get('message')
let newUrl = '/?tab=fitbit-integration&integration=fitbit' let newUrl = '/?tab=fitbit-integration&integration=fitbit'
if (status) newUrl += `&status=${status}` if (status) newUrl += `&status=${status}`
if (message) newUrl += `&message=${message}` if (message) newUrl += `&message=${message}`
window.history.replaceState({}, '', newUrl) const fitbitParams = { integration: 'fitbit' }
if (status) fitbitParams.status = status
if (message) fitbitParams.message = message
ensureBaseHistory('fitbit-integration', fitbitParams, newUrl)
setActiveTab('fitbit-integration')
setLoadedTabs(prev => ({ ...prev, 'fitbit-integration': true }))
setIsInitialized(true)
return return
} }
// Проверяем URL только для глубоких табов // Проверяем URL только для глубоких табов
const tabFromUrl = urlParams.get('tab') const tabFromUrl = urlParams.get('tab')
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'tracking', 'tracking-access', 'tracking-invite'] const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'purchase', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history']
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) { if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl) && window.history.length > 1) {
// Если в URL есть глубокий таб, восстанавливаем его // Восстанавливаем глубокий таб из URL только если есть история (не рестарт PWA)
setActiveTab(tabFromUrl)
setLoadedTabs(prev => ({ ...prev, [tabFromUrl]: true }))
// Восстанавливаем параметры из URL
const params = {} const params = {}
urlParams.forEach((value, key) => { urlParams.forEach((value, key) => {
if (key !== 'tab') { if (key !== 'tab') {
@@ -239,19 +329,34 @@ function AppContent() {
} }
} }
}) })
const deepTabUrl = window.location.pathname + window.location.search
ensureBaseHistory(tabFromUrl, params, deepTabUrl)
setActiveTab(tabFromUrl)
setLoadedTabs(prev => ({ ...prev, [tabFromUrl]: true }))
if (Object.keys(params).length > 0) { if (Object.keys(params).length > 0) {
setTabParams(params) setTabParams(params)
// Если это экран full с selectedProject, восстанавливаем его
if (tabFromUrl === 'full' && params.selectedProject) { if (tabFromUrl === 'full' && params.selectedProject) {
setSelectedProject(params.selectedProject) setSelectedProject(params.selectedProject)
} }
} }
} else { } else {
// Если в URL нет глубокого таба, проверяем localStorage для основного таба // При рестарте PWA (history.length <= 1) с deep tab в URL — сбрасываем на current
if (tabFromUrl && deepTabs.includes(tabFromUrl) && window.history.length <= 1) {
window.history.replaceState({ tab: 'current' }, '', '/')
setActiveTab('current')
setLoadedTabs(prev => ({ ...prev, 'current': true }))
} else {
// Проверяем localStorage для основного таба
const savedTab = window.localStorage?.getItem('activeTab') const savedTab = window.localStorage?.getItem('activeTab')
if (savedTab && validTabs.includes(savedTab)) { if (savedTab && validTabs.includes(savedTab) && mainTabs.includes(savedTab)) {
setActiveTab(savedTab) setActiveTab(savedTab)
setLoadedTabs(prev => ({ ...prev, [savedTab]: true })) setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
// Сохраняем таб в history state для корректной работы кнопки "назад"
window.history.replaceState({ tab: savedTab }, '', window.location.href)
} else {
// Если нет сохранённого таба — активируем current по умолчанию
setLoadedTabs(prev => ({ ...prev, current: true }))
window.history.replaceState({ tab: 'current' }, '', window.location.href)
} }
// Очищаем URL от параметров таба, если это основной таб // Очищаем URL от параметров таба, если это основной таб
if (tabFromUrl && mainTabs.includes(tabFromUrl)) { if (tabFromUrl && mainTabs.includes(tabFromUrl)) {
@@ -263,6 +368,7 @@ function AppContent() {
window.history.replaceState({}, '', url) window.history.replaceState({}, '', url)
} }
} }
}
setIsInitialized(true) setIsInitialized(true)
} catch (err) { } catch (err) {
console.warn('Не удалось прочитать активный таб', err) console.warn('Не удалось прочитать активный таб', err)
@@ -275,7 +381,7 @@ function AppContent() {
}, []) }, [])
// Функция для обновления URL (только для глубоких табов) // Функция для обновления URL (только для глубоких табов)
const updateUrl = useCallback((tab, params = {}, previousTab = null) => { const updateUrl = useCallback((tab, params = {}, previousTab = null, replace = false) => {
if (!deepTabs.includes(tab)) { if (!deepTabs.includes(tab)) {
// Для основных табов не обновляем URL // Для основных табов не обновляем URL
return return
@@ -300,8 +406,17 @@ function AppContent() {
} }
}) })
// Если стек пуст (после перезапуска PWA), добавляем базовую запись 'current'
if (window.history.length <= 1) {
window.history.replaceState({ tab: 'current' }, '', '/')
}
// Сохраняем предыдущий таб в state для восстановления при "Назад" // Сохраняем предыдущий таб в state для восстановления при "Назад"
if (replace) {
window.history.replaceState({ tab, params, previousTab }, '', url)
} else {
window.history.pushState({ tab, params, previousTab }, '', url) window.history.pushState({ tab, params, previousTab }, '', url)
}
}, []) // deepTabs - константа, не нужно в зависимостях }, []) // deepTabs - константа, не нужно в зависимостях
// Функция для очистки URL (при возврате к основному табу) // Функция для очистки URL (при возврате к основному табу)
@@ -407,13 +522,26 @@ function AppContent() {
groupProgress0 = jsonData.group_progress_0 !== undefined ? jsonData.group_progress_0 : null groupProgress0 = jsonData.group_progress_0 !== undefined ? jsonData.group_progress_0 : null
} }
// Получаем желания и pending-баллы по проектам из ответа
const wishes = jsonData?.wishes || []
const pendingScoresByProject = jsonData?.pending_scores_by_project && typeof jsonData.pending_scores_by_project === 'object' ? jsonData.pending_scores_by_project : {}
const rootData = (Array.isArray(jsonData) && jsonData.length > 0) ? jsonData[0] : jsonData
const prioritiesConfirmedValue = rootData?.priorities_confirmed ?? null
setCurrentWeekData({ setCurrentWeekData({
projects: Array.isArray(projects) ? projects : [], projects: Array.isArray(projects) ? projects : [],
total: total, total: total,
group_progress_1: groupProgress1, group_progress_1: groupProgress1,
group_progress_2: groupProgress2, group_progress_2: groupProgress2,
group_progress_0: groupProgress0 group_progress_0: groupProgress0,
wishes: wishes,
pending_scores_by_project: pendingScoresByProject
}) })
if (prioritiesConfirmedValue !== null) {
setPrioritiesConfirmed(prioritiesConfirmedValue)
}
} catch (err) { } catch (err) {
setCurrentWeekError(err.message) setCurrentWeekError(err.message)
console.error('Ошибка загрузки данных текущей недели:', err) console.error('Ошибка загрузки данных текущей недели:', err)
@@ -549,6 +677,8 @@ function AppContent() {
// Refs для отслеживания активного таба // Refs для отслеживания активного таба
const prevActiveTabRef = useRef(null) const prevActiveTabRef = useRef(null)
const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки
const fullStatisticsScrollRef = useRef(null)
const lastFullProjectRef = useRef(selectedProject)
// Обновляем ref при изменении данных // Обновляем ref при изменении данных
useEffect(() => { useEffect(() => {
@@ -678,9 +808,10 @@ function AppContent() {
// Проверяем, есть ли открытые модальные окна в DOM // Проверяем, есть ли открытые модальные окна в DOM
const taskDetailModal = document.querySelector('.task-detail-modal-overlay') const taskDetailModal = document.querySelector('.task-detail-modal-overlay')
const wishlistDetailModal = document.querySelector('.wishlist-detail-modal-overlay') const wishlistDetailModal = document.querySelector('.wishlist-detail-modal-overlay')
const conditionFormOverlay = document.querySelector('.condition-form-overlay')
// Если есть открытые модальные окна, не обрабатываем здесь - компоненты сами закроют их // Если есть открытые модальные окна, не обрабатываем здесь - компоненты сами закроют их
if (taskDetailModal || wishlistDetailModal) { if (taskDetailModal || wishlistDetailModal || conditionFormOverlay) {
return return
} }
@@ -693,7 +824,7 @@ function AppContent() {
return return
} }
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite'] const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'purchase', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history']
// Проверяем state текущей записи истории (куда мы вернулись) // Проверяем state текущей записи истории (куда мы вернулись)
if (event.state && event.state.tab) { if (event.state && event.state.tab) {
@@ -745,9 +876,9 @@ function AppContent() {
setSelectedProject(null) setSelectedProject(null)
clearUrl(event.state.tab) clearUrl(event.state.tab)
} else { } else {
// Если state пустой, используем сохраненный таб из localStorage // Если state пустой, используем сохраненный таб из localStorage (только основные табы)
const savedTab = window.localStorage?.getItem('activeTab') const savedTab = window.localStorage?.getItem('activeTab')
const validMainTab = savedTab && validTabs.includes(savedTab) ? savedTab : 'current' const validMainTab = savedTab && mainTabs.includes(savedTab) ? savedTab : 'current'
setActiveTab(validMainTab) setActiveTab(validMainTab)
setTabParams({}) setTabParams({})
markTabAsLoaded(validMainTab) markTabAsLoaded(validMainTab)
@@ -791,14 +922,14 @@ function AppContent() {
setActiveTab('full') setActiveTab('full')
} }
const handleTabChange = (tab, params = {}) => { const handleTabChange = (tab, params = {}, options = {}) => {
if (tab === 'full' && activeTab === 'full') { if (tab === 'full' && activeTab === 'full') {
// При повторном клике на "Полная статистика" сбрасываем выбранный проект // При повторном клике на "Полная статистика" сбрасываем выбранный проект
setSelectedProject(null) setSelectedProject(null)
setTabParams({}) setTabParams({})
updateUrl('full', {}, activeTab) updateUrl('full', {}, activeTab)
} else if (tab !== activeTab || tab === 'task-form' || tab === 'wishlist-form' || (tab === 'words' && Object.keys(params).length > 0)) { } else if (tab !== activeTab || tab === 'task-form' || tab === 'wishlist-form' || tab === 'shopping-item-form' || (tab === 'words' && Object.keys(params).length > 0)) {
// Для task-form и wishlist-form всегда обновляем параметры, даже если это тот же таб // Для task-form, wishlist-form и shopping-item-form всегда обновляем параметры, даже если это тот же таб
markTabAsLoaded(tab) markTabAsLoaded(tab)
// Определяем, является ли текущий таб глубоким // Определяем, является ли текущий таб глубоким
@@ -809,8 +940,8 @@ function AppContent() {
{ {
// Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров // Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров
// task-form может иметь taskId (редактирование), wishlistId (создание из желания), returnTo (возврат после создания), или isTest (создание теста) // task-form может иметь taskId (редактирование), wishlistId (создание из желания), returnTo (возврат после создания)
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined && params.isTest === undefined const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined
// Проверяем, что boardId не null и не undefined (null означает "нет доски", но это валидное значение) // Проверяем, что boardId не null и не undefined (null означает "нет доски", но это валидное значение)
const hasBoardId = params.boardId !== null && params.boardId !== undefined const hasBoardId = params.boardId !== null && params.boardId !== undefined
const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined && !hasBoardId const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined && !hasBoardId
@@ -819,16 +950,28 @@ function AppContent() {
if (isNewTabMain) { if (isNewTabMain) {
clearUrl() clearUrl()
} else if (isNewTabDeep) { } else if (isNewTabDeep) {
// Если текущая запись — модальное окно, заменяем её вместо push
const currentState = window.history.state || {}
const isFromModal = currentState.modalOpen === true || currentState.conditionForm === true
if (isFromModal) {
const url = new URL(window.location)
url.searchParams.set('tab', tab)
const keysToRemove = []
url.searchParams.forEach((value, key) => { if (key !== 'tab') keysToRemove.push(key) })
keysToRemove.forEach(key => url.searchParams.delete(key))
window.history.replaceState({ tab, params: {}, previousTab: activeTab }, '', url)
} else {
updateUrl(tab, {}, activeTab) updateUrl(tab, {}, activeTab)
} }
}
} else { } else {
setTabParams(params) setTabParams(params)
// Обновляем URL только для глубоких табов // Обновляем URL только для глубоких табов
if (isNewTabDeep) { if (isNewTabDeep) {
// Проверяем, была ли последняя запись в истории от модального окна // Проверяем, была ли последняя запись в истории от модального окна
const currentState = window.history.state || {} const currentState = window.history.state || {}
const isFromModal = currentState.modalOpen === true const isFromModal = currentState.modalOpen === true || currentState.conditionForm === true
const isNavigatingToForm = tab === 'task-form' || tab === 'wishlist-form' const isNavigatingToForm = tab === 'task-form' || tab === 'wishlist-form' || tab === 'shopping-item-form'
if (isFromModal && isNavigatingToForm) { if (isFromModal && isNavigatingToForm) {
// Заменяем запись модального окна на запись формы редактирования // Заменяем запись модального окна на запись формы редактирования
@@ -850,7 +993,7 @@ function AppContent() {
window.history.replaceState({ tab, params, previousTab: activeTab }, '', url) window.history.replaceState({ tab, params, previousTab: activeTab }, '', url)
} else { } else {
// Сохраняем текущий таб как предыдущий при переходе на глубокий таб // Сохраняем текущий таб как предыдущий при переходе на глубокий таб
updateUrl(tab, params, activeTab) updateUrl(tab, params, activeTab, options.replace)
} }
} else if (isNewTabMain && isCurrentTabDeep) { } else if (isNewTabMain && isCurrentTabDeep) {
// При переходе с глубокого таба на основной - очищаем URL и сохраняем таб в state // При переходе с глубокого таба на основной - очищаем URL и сохраняем таб в state
@@ -877,7 +1020,7 @@ function AppContent() {
} }
// Обновляем список задач при возврате из экрана редактирования или теста // Обновляем список задач при возврате из экрана редактирования или теста
// Используем фоновую загрузку, чтобы не показывать индикатор загрузки // Используем фоновую загрузку, чтобы не показывать индикатор загрузки
if ((activeTab === 'task-form' || activeTab === 'test') && tab === 'tasks') { if ((activeTab === 'task-form' || activeTab === 'test' || activeTab === 'purchase') && tab === 'tasks') {
fetchTasksData(true) fetchTasksData(true)
} }
// Сохраняем предыдущий таб при открытии wishlist-form или wishlist-detail // Сохраняем предыдущий таб при открытии wishlist-form или wishlist-detail
@@ -904,28 +1047,33 @@ function AppContent() {
setWishlistRefreshTrigger(prev => prev + 1) setWishlistRefreshTrigger(prev => prev + 1)
} }
} }
// Сохраняем предыдущий таб при открытии shopping-item-form
if (tab === 'shopping-item-form' && activeTab !== tab) {
setPreviousTab(activeTab)
setShoppingItemFormKey(prev => prev + 1)
}
// Обновляем список товаров при возврате из экрана редактирования
if ((activeTab === 'shopping-item-form' || activeTab === 'shopping-board-form') && (tab === 'shopping' || tab === 'purchase')) {
const savedBoardId = params.boardId || tabParams.boardId
if (savedBoardId) {
setTabParams(prev => ({ ...prev, boardId: savedBoardId }))
}
setShoppingRefreshTrigger(prev => prev + 1)
}
// Загрузка данных произойдет в useEffect при изменении activeTab // Загрузка данных произойдет в useEffect при изменении activeTab
} }
} }
// Обработчики для кнопки добавления задачи // Обработчик для кнопки добавления задачи
const handleAddClick = () => { const handleAddClick = () => {
setShowAddModal(true) handleNavigate('task-form', { taskId: undefined })
}
const handleAddTask = () => {
setShowAddModal(false)
handleNavigate('task-form', { taskId: undefined, isTest: false })
}
const handleAddTest = () => {
setShowAddModal(false)
handleNavigate('task-form', { taskId: undefined, isTest: true })
} }
// Обработчик навигации для компонентов // Обработчик навигации для компонентов
const handleNavigate = (tab, params = {}) => { const handleNavigate = (tab, params = {}, options = {}) => {
handleTabChange(tab, params) handleTabChange(tab, params, options)
} }
// Загружаем данные при открытии таба (когда таб становится активным) // Загружаем данные при открытии таба (когда таб становится активным)
@@ -946,16 +1094,25 @@ function AppContent() {
setWordsRefreshTrigger(prev => prev + 1) setWordsRefreshTrigger(prev => prev + 1)
} }
// Обновляем список желаний при возврате из формы редактирования
if (prevActiveTabRef.current === 'wishlist-form' && activeTab === 'wishlist') {
setWishlistRefreshTrigger(prev => prev + 1)
}
if (isFirstLoad) { if (isFirstLoad) {
// Первая загрузка таба // Первая загрузка таба
lastLoadedTabRef.current = tabKey lastLoadedTabRef.current = tabKey
const projectName = activeTab === 'full' ? selectedProject : null const projectName = activeTab === 'full' ? selectedProject : null
loadTabData(activeTab, false, projectName) loadTabData(activeTab, false, projectName)
if (activeTab === 'full') lastFullProjectRef.current = selectedProject
} else if (isReturningToTab) { } else if (isReturningToTab) {
// Возврат на таб - фоновая загрузка // Возврат на таб
lastLoadedTabRef.current = tabKey lastLoadedTabRef.current = tabKey
const projectName = activeTab === 'full' ? selectedProject : null const projectName = activeTab === 'full' ? selectedProject : null
loadTabData(activeTab, true, projectName) // Если проект изменился - загрузка с индикатором, иначе фоновая
const isBackground = activeTab === 'full' && lastFullProjectRef.current !== selectedProject ? false : true
loadTabData(activeTab, isBackground, projectName)
if (activeTab === 'full') lastFullProjectRef.current = selectedProject
} }
prevActiveTabRef.current = activeTab prevActiveTabRef.current = activeTab
@@ -977,6 +1134,13 @@ function AppContent() {
const isAnyLoading = currentWeekLoading || fullStatisticsLoading || prioritiesLoading || isRefreshing const isAnyLoading = currentWeekLoading || fullStatisticsLoading || prioritiesLoading || isRefreshing
const hasAnyError = currentWeekError || fullStatisticsError || prioritiesError const hasAnyError = currentWeekError || fullStatisticsError || prioritiesError
// Сбрасываем скролл экрана статистики при его открытии
useEffect(() => {
if (activeTab === 'full' && fullStatisticsScrollRef.current) {
fullStatisticsScrollRef.current.scrollTop = 0
}
}, [activeTab])
// Сохраняем выбранный таб, чтобы восстановить его после перезагрузки // Сохраняем выбранный таб, чтобы восстановить его после перезагрузки
useEffect(() => { useEffect(() => {
try { try {
@@ -1002,7 +1166,7 @@ function AppContent() {
} }
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов) // Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'fitbit-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite' const isFullscreenTab = activeTab === 'test' || activeTab === 'purchase' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'fitbit-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite' || activeTab === 'shopping' || activeTab === 'shopping-item-form' || activeTab === 'shopping-board-form' || activeTab === 'shopping-board-join' || activeTab === 'shopping-item-history' || activeTab === 'board-form'
// Функция для получения классов скролл-контейнера для каждого таба // Функция для получения классов скролл-контейнера для каждого таба
// Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла // Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла
@@ -1016,7 +1180,7 @@ function AppContent() {
let paddingClasses = '' let paddingClasses = ''
if (tabName === 'current' || tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') { if (tabName === 'current' || tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') {
paddingClasses = 'pb-20' paddingClasses = 'pb-20'
} else if (tabName === 'words' || tabName === 'dictionaries') { } else if (tabName === 'words' || tabName === 'dictionaries' || tabName === 'shopping') {
paddingClasses = 'pb-16' paddingClasses = 'pb-16'
} }
@@ -1031,7 +1195,10 @@ function AppContent() {
if (tabName === 'current') { if (tabName === 'current') {
return 'max-w-7xl mx-auto p-4 md:p-6' return 'max-w-7xl mx-auto p-4 md:p-6'
} }
if (tabName === 'full' || tabName === 'priorities' || tabName === 'dictionaries' || tabName === 'words') { if (tabName === 'priorities') {
return 'max-w-2xl mx-auto px-4 h-full'
}
if (tabName === 'full' || tabName === 'dictionaries' || tabName === 'words' || tabName === 'shopping' || tabName === 'shopping-item-history' || tabName === 'purchase') {
return 'max-w-7xl mx-auto px-4 md:px-8 py-0' return 'max-w-7xl mx-auto px-4 md:px-8 py-0'
} }
// Fullscreen табы без отступов // Fullscreen табы без отступов
@@ -1072,13 +1239,19 @@ function AppContent() {
onErrorChange={setPrioritiesError} onErrorChange={setPrioritiesError}
refreshTrigger={prioritiesRefreshTrigger} refreshTrigger={prioritiesRefreshTrigger}
onNavigate={handleNavigate} onNavigate={handleNavigate}
onConfirmed={async () => {
await fetchCurrentWeekData(false)
setPrioritiesConfirmed(true)
markTabAsLoaded('current')
setActiveTab('current')
}}
/> />
</div> </div>
</div> </div>
)} )}
{loadedTabs.full && ( {loadedTabs.full && (
<div className={getTabContainerClasses('full')}> <div ref={fullStatisticsScrollRef} className={getTabContainerClasses('full')}>
<div className={getInnerContainerClasses('full')}> <div className={getInnerContainerClasses('full')}>
<FullStatistics <FullStatistics
selectedProject={selectedProject} selectedProject={selectedProject}
@@ -1091,12 +1264,13 @@ function AppContent() {
loading={fullStatisticsLoading} loading={fullStatisticsLoading}
error={fullStatisticsError} error={fullStatisticsError}
todayEntries={todayEntriesData} todayEntries={todayEntriesData}
todayEntriesLoading={todayEntriesLoading || todayEntriesBackgroundLoading} todayEntriesLoading={todayEntriesLoading}
todayEntriesError={todayEntriesError} todayEntriesError={todayEntriesError}
onRetryTodayEntries={() => fetchTodayEntries(false, selectedProject, null)} onRetryTodayEntries={() => fetchTodayEntries(false, selectedProject, null)}
fetchTodayEntries={fetchTodayEntries} fetchTodayEntries={fetchTodayEntries}
onRetry={fetchFullStatisticsData} onRetry={fetchFullStatisticsData}
currentWeekData={currentWeekData} currentWeekData={currentWeekData}
fetchCurrentWeekData={fetchCurrentWeekData}
onNavigate={handleNavigate} onNavigate={handleNavigate}
activeTab={activeTab} activeTab={activeTab}
/> />
@@ -1149,6 +1323,20 @@ function AppContent() {
configId={tabParams.configId} configId={tabParams.configId}
maxCards={tabParams.maxCards} maxCards={tabParams.maxCards}
taskId={tabParams.taskId} taskId={tabParams.taskId}
isActive={activeTab === 'test'}
/>
</div>
</div>
)}
{loadedTabs.purchase && (
<div className={getTabContainerClasses('purchase')}>
<div className={getInnerContainerClasses('purchase')}>
<PurchaseScreen
onNavigate={handleNavigate}
purchaseConfigId={tabParams.purchaseConfigId}
taskId={tabParams.taskId}
taskName={tabParams.taskName}
/> />
</div> </div>
</div> </div>
@@ -1164,7 +1352,13 @@ function AppContent() {
backgroundLoading={tasksBackgroundLoading} backgroundLoading={tasksBackgroundLoading}
error={tasksError} error={tasksError}
onRetry={() => fetchTasksData(false)} onRetry={() => fetchTasksData(false)}
onRefresh={(isBackground = false) => fetchTasksData(isBackground)} onRefresh={(tasksOrBackground) => {
if (Array.isArray(tasksOrBackground)) {
setTasksData(tasksOrBackground)
} else {
fetchTasksData(tasksOrBackground === true)
}
}}
/> />
</div> </div>
</div> </div>
@@ -1174,13 +1368,13 @@ function AppContent() {
<div className={getTabContainerClasses('task-form')}> <div className={getTabContainerClasses('task-form')}>
<div className={getInnerContainerClasses('task-form')}> <div className={getInnerContainerClasses('task-form')}>
<TaskForm <TaskForm
key={tabParams.taskId || 'new'} key={tabParams.taskId || 'new-task'}
onNavigate={handleNavigate} onNavigate={handleNavigate}
taskId={tabParams.taskId} taskId={tabParams.taskId}
wishlistId={tabParams.wishlistId} wishlistId={tabParams.wishlistId}
isTest={tabParams.isTest}
returnTo={tabParams.returnTo} returnTo={tabParams.returnTo}
returnWishlistId={tabParams.returnWishlistId} returnWishlistId={tabParams.returnWishlistId}
isActive={activeTab === 'task-form'}
/> />
</div> </div>
</div> </div>
@@ -1210,6 +1404,7 @@ function AppContent() {
editConditionIndex={tabParams.editConditionIndex} editConditionIndex={tabParams.editConditionIndex}
newTaskId={tabParams.newTaskId} newTaskId={tabParams.newTaskId}
boardId={tabParams.boardId} boardId={tabParams.boardId}
isActive={activeTab === 'wishlist-form'}
/> />
</div> </div>
</div> </div>
@@ -1223,6 +1418,7 @@ function AppContent() {
onNavigate={handleNavigate} onNavigate={handleNavigate}
boardId={tabParams.boardId} boardId={tabParams.boardId}
onSaved={() => setWishlistRefreshTrigger(prev => prev + 1)} onSaved={() => setWishlistRefreshTrigger(prev => prev + 1)}
isActive={activeTab === 'board-form'}
/> />
</div> </div>
</div> </div>
@@ -1240,6 +1436,74 @@ function AppContent() {
</div> </div>
)} )}
{loadedTabs.shopping && (
<div className={getTabContainerClasses('shopping')}>
<div className={getInnerContainerClasses('shopping')}>
<ShoppingList
onNavigate={handleNavigate}
refreshTrigger={shoppingRefreshTrigger}
isActive={activeTab === 'shopping'}
initialBoardId={tabParams.boardId}
boardDeleted={tabParams.boardDeleted}
/>
</div>
</div>
)}
{loadedTabs['shopping-item-form'] && (
<div className={getTabContainerClasses('shopping-item-form')}>
<div className={getInnerContainerClasses('shopping-item-form')}>
<ShoppingItemForm
key={tabParams.itemId || `new-${shoppingItemFormKey}`}
onNavigate={handleNavigate}
itemId={tabParams.itemId}
boardId={tabParams.boardId}
previousTab={previousTab}
onSaved={() => setShoppingRefreshTrigger(prev => prev + 1)}
isActive={activeTab === 'shopping-item-form'}
/>
</div>
</div>
)}
{loadedTabs['shopping-board-form'] && (
<div className={getTabContainerClasses('shopping-board-form')}>
<div className={getInnerContainerClasses('shopping-board-form')}>
<ShoppingBoardForm
key={tabParams.boardId || 'new'}
onNavigate={handleNavigate}
boardId={tabParams.boardId}
onSaved={() => setShoppingRefreshTrigger(prev => prev + 1)}
isActive={activeTab === 'shopping-board-form'}
/>
</div>
</div>
)}
{loadedTabs['shopping-board-join'] && (
<div className={getTabContainerClasses('shopping-board-join')}>
<div className={getInnerContainerClasses('shopping-board-join')}>
<ShoppingBoardJoinPreview
key={tabParams.inviteToken}
onNavigate={handleNavigate}
inviteToken={tabParams.inviteToken}
/>
</div>
</div>
)}
{loadedTabs['shopping-item-history'] && (
<div className={getTabContainerClasses('shopping-item-history')}>
<div className={getInnerContainerClasses('shopping-item-history')}>
<ShoppingItemHistory
key={tabParams.itemId}
itemId={tabParams.itemId}
onNavigate={handleNavigate}
/>
</div>
</div>
)}
{loadedTabs.profile && ( {loadedTabs.profile && (
<div className={getTabContainerClasses('profile')}> <div className={getTabContainerClasses('profile')}>
<div className={getInnerContainerClasses('profile')}> <div className={getInnerContainerClasses('profile')}>
@@ -1362,6 +1626,42 @@ function AppContent() {
</button> </button>
)} )}
{/* Кнопка добавления товара (только для таба shopping) */}
{activeTab === 'shopping' && (
<button
onClick={() => {
let boardId = tabParams.boardId
if (!boardId) {
try {
const saved = localStorage.getItem('shopping_selected_board_id')
if (saved) {
const parsed = parseInt(saved, 10)
if (!isNaN(parsed)) boardId = parsed
}
} catch (err) {
console.error('Error loading boardId from localStorage:', err)
}
}
handleNavigate('shopping-item-form', { itemId: undefined, boardId: boardId })
}}
className="fixed bottom-4 right-4 z-20 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white w-[61px] h-[61px] rounded-2xl shadow-lg transition-all duration-200 hover:scale-105 flex items-center justify-center"
title="Добавить товар"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
)}
{/* Кнопка добавления записи (только для таба current - экран прогресса) */} {/* Кнопка добавления записи (только для таба current - экран прогресса) */}
{!isFullscreenTab && activeTab === 'current' && ( {!isFullscreenTab && activeTab === 'current' && (
<button <button
@@ -1522,40 +1822,31 @@ function AppContent() {
</div> </div>
)} )}
{/* Модальное окно выбора типа задачи */} {/* Оверлей подтверждения приоритетов — показывается поверх экрана прогресса недели */}
{showAddModal && ( {activeTab === 'current' && prioritiesConfirmed === false && (
<div className="task-add-modal-overlay" onClick={() => setShowAddModal(false)}> <div className="fixed inset-0 bg-white z-50 overflow-y-auto">
<div className="task-add-modal" onClick={(e) => e.stopPropagation()}> <div className="max-w-2xl mx-auto px-4 h-full">
<div className="task-add-modal-header"> <ProjectPriorityManager
<h3>Что добавить?</h3> allProjectsData={fullStatisticsData}
</div> currentWeekData={currentWeekData}
<div className="task-add-modal-buttons"> shouldLoad={true}
<button onLoadingChange={setPrioritiesLoading}
className="task-add-modal-button task-add-modal-button-task" onErrorChange={setPrioritiesError}
onClick={handleAddTask} refreshTrigger={Math.max(prioritiesRefreshTrigger, 1)}
> onNavigate={handleNavigate}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> onConfirmed={async () => {
<path d="M9 11l3 3L22 4"></path> await fetchCurrentWeekData(false)
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path> setPrioritiesConfirmed(true)
</svg> }}
Задача onClose={() => {
</button> // history.back() переходит к { tab: 'tasks' }, popstate обработает переключение
<button window.history.back()
className="task-add-modal-button task-add-modal-button-test" }}
onClick={handleAddTest} />
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
<path d="M8 7h6"></path>
<path d="M8 11h4"></path>
</svg>
Тест
</button>
</div>
</div> </div>
</div> </div>
)} )}
</div> </div>
) )
} }

View File

@@ -0,0 +1,101 @@
.archived-boards {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
position: relative;
}
.archived-boards h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.archived-boards-loading {
display: flex;
justify-content: center;
padding: 3rem 0;
}
.archived-boards-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 3rem 0;
color: #9ca3af;
font-size: 0.95rem;
}
.archived-boards-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.archived-board-card {
display: flex;
align-items: center;
background: white;
border-radius: 0.5rem;
padding: 0.875rem 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
gap: 0.75rem;
}
.archived-board-info {
flex: 1;
min-width: 0;
cursor: pointer;
}
.archived-board-name {
font-weight: 500;
color: #1f2937;
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.archived-board-meta {
font-size: 0.8rem;
color: #9ca3af;
margin-top: 0.15rem;
}
.archived-board-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.archived-board-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s;
background: transparent;
}
.archived-board-restore {
color: #6366f1;
}
.archived-board-restore:hover {
background: #eef2ff;
}
.archived-board-delete {
color: #ef4444;
}
.archived-board-delete:hover {
background: #fef2f2;
}

View File

@@ -0,0 +1,171 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import Toast from './Toast'
import './ArchivedBoards.css'
function ArchivedBoards({ boardType, onNavigate, onSaved }) {
const { authFetch } = useAuth()
const [boards, setBoards] = useState([])
const [loading, setLoading] = useState(true)
const [toastMessage, setToastMessage] = useState(null)
const isWishlist = boardType === 'wishlist'
const apiBase = isWishlist ? '/api/wishlist' : '/api/shopping'
const returnTab = isWishlist ? 'wishlist' : 'shopping'
useEffect(() => {
fetchArchivedBoards()
}, [])
const fetchArchivedBoards = async () => {
setLoading(true)
try {
const res = await authFetch(`${apiBase}/boards/archived`)
if (res.ok) {
const data = await res.json()
setBoards(Array.isArray(data) ? data : [])
}
} catch (err) {
console.error('Error fetching archived boards:', err)
} finally {
setLoading(false)
}
}
const handleUnarchive = async (boardId) => {
try {
const res = await authFetch(`${apiBase}/boards/${boardId}/unarchive`, {
method: 'POST'
})
if (res.ok) {
setBoards(prev => prev.filter(b => b.id !== boardId))
setToastMessage({ text: 'Доска восстановлена', type: 'success' })
onSaved?.()
} else {
setToastMessage({ text: 'Ошибка восстановления', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка восстановления', type: 'error' })
}
}
const handleDelete = async (board) => {
if (board.is_owner) {
if (!window.confirm(`Удалить доску "${board.name}"? Все ${isWishlist ? 'желания' : 'товары'} на ней будут удалены.`)) return
try {
const res = await authFetch(`${apiBase}/boards/${board.id}`, {
method: 'DELETE'
})
if (res.ok) {
setBoards(prev => prev.filter(b => b.id !== board.id))
setToastMessage({ text: 'Доска удалена', type: 'success' })
onSaved?.()
} else {
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
}
} else {
if (!window.confirm(`Покинуть доску "${board.name}"?`)) return
try {
const res = await authFetch(`${apiBase}/boards/${board.id}/leave`, {
method: 'POST'
})
if (res.ok) {
// Также убираем из архива
await authFetch(`${apiBase}/boards/${board.id}/unarchive`, { method: 'POST' })
setBoards(prev => prev.filter(b => b.id !== board.id))
setToastMessage({ text: 'Вы покинули доску', type: 'success' })
onSaved?.()
} else {
setToastMessage({ text: 'Ошибка', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка', type: 'error' })
}
}
}
const handleOpenBoard = (boardId) => {
onNavigate(returnTab, { boardId })
}
const handleClose = () => {
window.history.back()
}
return (
<div className="archived-boards">
<button className="close-x-button" onClick={handleClose}>
</button>
<h2>Архив</h2>
{loading ? (
<div className="archived-boards-loading">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
) : boards.length === 0 ? (
<div className="archived-boards-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#d1d5db" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="21 8 21 21 3 21 3 8"></polyline>
<rect x="1" y="3" width="22" height="5"></rect>
<line x1="10" y1="12" x2="14" y2="12"></line>
</svg>
<p>Архив пуст</p>
</div>
) : (
<div className="archived-boards-list">
{boards.map(board => (
<div key={board.id} className="archived-board-card">
<div className="archived-board-info" onClick={() => handleOpenBoard(board.id)}>
<div className="archived-board-name">{board.name}</div>
<div className="archived-board-meta">
{board.is_owner ? 'Моя доска' : `Доска ${board.owner_name}`}
{' · '}
{board.member_count + 1} {board.member_count + 1 === 1 ? 'участник' : 'участников'}
</div>
</div>
<div className="archived-board-actions">
<button
className="archived-board-btn archived-board-restore"
onClick={() => handleUnarchive(board.id)}
title="Восстановить"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="1 4 1 10 7 10"></polyline>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
</svg>
</button>
<button
className="archived-board-btn archived-board-delete"
onClick={() => handleDelete(board)}
title={board.is_owner ? 'Удалить' : 'Покинуть'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</div>
))}
</div>
)}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default ArchivedBoards

View File

@@ -130,3 +130,38 @@
font-size: 0.85rem; font-size: 0.85rem;
color: #6b7280; color: #6b7280;
} }
/* Board action buttons (for archive, leave) */
.board-actions-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.board-action-button {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.875rem 0.5rem;
background: none;
border: none;
border-radius: 0.375rem;
font-size: 0.95rem;
color: #374151;
cursor: pointer;
transition: background 0.15s;
text-align: left;
}
.board-action-button:hover {
background: #f3f4f6;
}
.board-action-button.board-action-danger {
color: #ef4444;
}
.board-action-button.board-action-danger:hover {
background: #fef2f2;
}

View File

@@ -1,13 +1,13 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext' import { useAuth } from './auth/AuthContext'
import BoardMembers from './BoardMembers' import BoardMembers from './BoardMembers'
import Toast from './Toast' import Toast from './Toast'
import SubmitButton from './SubmitButton'
import DeleteButton from './DeleteButton'
import './Buttons.css' import './Buttons.css'
import './BoardForm.css' import './BoardForm.css'
import './Wishlist.css'
function BoardForm({ boardId, onNavigate, onSaved }) { function BoardForm({ boardId, onNavigate, onSaved, isActive }) {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const [name, setName] = useState('') const [name, setName] = useState('')
const [inviteEnabled, setInviteEnabled] = useState(false) const [inviteEnabled, setInviteEnabled] = useState(false)
@@ -17,6 +17,11 @@ function BoardForm({ boardId, onNavigate, onSaved }) {
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [toastMessage, setToastMessage] = useState(null) const [toastMessage, setToastMessage] = useState(null)
const [isOwner, setIsOwner] = useState(true)
const [isArchived, setIsArchived] = useState(false)
const [showActionMenu, setShowActionMenu] = useState(false)
const actionMenuHistoryRef = useRef(false)
const savedHistoryStateRef = useRef(null)
const isEdit = !!boardId const isEdit = !!boardId
@@ -35,6 +40,8 @@ function BoardForm({ boardId, onNavigate, onSaved }) {
setName(data.name) setName(data.name)
setInviteEnabled(data.invite_enabled) setInviteEnabled(data.invite_enabled)
setInviteURL(data.invite_url || '') setInviteURL(data.invite_url || '')
setIsOwner(data.is_owner)
setIsArchived(data.is_archived || false)
} else { } else {
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' }) setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
} }
@@ -133,6 +140,17 @@ function BoardForm({ boardId, onNavigate, onSaved }) {
} }
} }
// Навигация после действия из action menu: убрать обе записи (action menu + board-form)
const navigateBackFromActionMenu = () => {
setShowActionMenu(false)
if (actionMenuHistoryRef.current) {
actionMenuHistoryRef.current = false
window.history.go(-2)
} else {
window.history.back()
}
}
const handleDelete = async () => { const handleDelete = async () => {
if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return
@@ -143,8 +161,7 @@ function BoardForm({ boardId, onNavigate, onSaved }) {
}) })
if (res.ok) { if (res.ok) {
onSaved?.() onSaved?.()
// Передаём флаг, что доска удалена, чтобы Wishlist выбрал первую доступную navigateBackFromActionMenu()
onNavigate('wishlist', { boardDeleted: true })
} else { } else {
setToastMessage({ text: 'Ошибка удаления', type: 'error' }) setToastMessage({ text: 'Ошибка удаления', type: 'error' })
setIsDeleting(false) setIsDeleting(false)
@@ -155,6 +172,94 @@ function BoardForm({ boardId, onNavigate, onSaved }) {
} }
} }
const handleLeave = async () => {
if (!window.confirm('Покинуть доску? Вы больше не будете видеть её желания.')) return
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/leave`, {
method: 'POST'
})
if (res.ok) {
onSaved?.()
onNavigate('wishlist', { boardDeleted: true }, { replace: true })
} else {
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
}
}
const handleArchive = async () => {
if (!window.confirm('Архивировать доску? Она переместится в архив.')) return
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/archive`, {
method: 'POST'
})
if (res.ok) {
onSaved?.()
navigateBackFromActionMenu()
} else {
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
}
}
const handleUnarchive = async () => {
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/unarchive`, {
method: 'POST'
})
if (res.ok) {
onSaved?.()
navigateBackFromActionMenu()
} else {
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
}
}
const openActionMenu = () => {
setShowActionMenu(true)
savedHistoryStateRef.current = window.history.state
window.history.pushState({ actionMenu: true }, '')
actionMenuHistoryRef.current = true
}
const closeActionMenu = () => {
setShowActionMenu(false)
if (actionMenuHistoryRef.current) {
actionMenuHistoryRef.current = false
window.history.back()
}
}
// Закрыть меню без popstate — заменяем запись в истории на сохранённое состояние
const dismissActionMenu = () => {
setShowActionMenu(false)
if (actionMenuHistoryRef.current) {
actionMenuHistoryRef.current = false
window.history.replaceState(savedHistoryStateRef.current, '', window.location.href)
}
}
// Обработка popstate для закрытия action menu кнопкой назад
useEffect(() => {
const handlePopState = () => {
if (showActionMenu) {
actionMenuHistoryRef.current = false
setShowActionMenu(false)
}
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [showActionMenu])
const handleClose = () => { const handleClose = () => {
window.history.back() window.history.back()
} }
@@ -249,26 +354,10 @@ function BoardForm({ boardId, onNavigate, onSaved }) {
setToastMessage({ text: 'Участник удалён', type: 'success' }) setToastMessage({ text: 'Участник удалён', type: 'success' })
}} }}
/> />
</> </>
)} )}
<div className="form-actions">
<SubmitButton
onClick={handleSave}
loading={loading}
disabled={!name.trim()}
>
Сохранить
</SubmitButton>
{isEdit && (
<DeleteButton
onClick={handleDelete}
loading={isDeleting}
disabled={loading}
title="Удалить доску"
/>
)}
</div>
</div> </div>
{toastMessage && ( {toastMessage && (
@@ -278,9 +367,100 @@ function BoardForm({ boardId, onNavigate, onSaved }) {
onClose={() => setToastMessage(null)} onClose={() => setToastMessage(null)}
/> />
)} )}
{isActive ? createPortal(
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
padding: '0.75rem 1rem',
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
background: 'linear-gradient(to top, white 60%, rgba(255,255,255,0))',
zIndex: 1500,
display: 'flex',
justifyContent: 'center',
gap: '0.75rem',
}}>
<button
onClick={handleSave}
disabled={loading || isDeleting || !name.trim()}
style={{
flex: 1,
maxWidth: '42rem',
padding: '0.875rem',
background: (loading || !name.trim()) ? undefined : 'linear-gradient(to right, #10b981, #059669)',
backgroundColor: (loading || !name.trim()) ? '#9ca3af' : undefined,
color: 'white',
border: 'none',
borderRadius: '0.5rem',
fontSize: '1rem',
fontWeight: 600,
cursor: (loading || isDeleting || !name.trim()) ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1,
transition: 'all 0.2s',
}}
>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
{isEdit && (
<button
type="button"
onClick={openActionMenu}
disabled={loading || isDeleting}
style={{
width: '52px',
height: '52px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
color: '#059669',
border: '2px solid #059669',
borderRadius: '0.5rem',
fontSize: '1.25rem',
fontWeight: 700,
cursor: (loading || isDeleting) ? 'not-allowed' : 'pointer',
lineHeight: 1,
flexShrink: 0,
padding: 0,
boxSizing: 'border-box',
transition: 'all 0.2s',
}}
title="Действия"
>
</button>
)}
</div>,
document.body
) : null}
{showActionMenu && createPortal(
<div className="wishlist-modal-overlay" style={{ zIndex: 2000 }} onClick={closeActionMenu}>
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
<div className="wishlist-modal-header">
<h3>{name}</h3>
</div>
<div className="wishlist-modal-actions">
{isArchived ? (
<button className="wishlist-modal-copy" onClick={handleUnarchive}>
Разархивировать
</button>
) : (
<button className="wishlist-modal-copy" onClick={handleArchive}>
Архивировать
</button>
)}
<button className="wishlist-modal-delete" onClick={handleDelete}>
Удалить
</button>
</div>
</div>
</div>,
document.body
)}
</div> </div>
) )
} }
export default BoardForm export default BoardForm

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext' import { useAuth } from './auth/AuthContext'
import './BoardMembers.css' import './BoardMembers.css'
function BoardMembers({ boardId, onMemberRemoved }) { function BoardMembers({ boardId, onMemberRemoved, apiBase = '/api/wishlist' }) {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const [members, setMembers] = useState([]) const [members, setMembers] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -16,7 +16,7 @@ function BoardMembers({ boardId, onMemberRemoved }) {
const fetchMembers = async () => { const fetchMembers = async () => {
try { try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/members`) const res = await authFetch(`${apiBase}/boards/${boardId}/members`)
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
setMembers(data || []) setMembers(data || [])
@@ -33,7 +33,7 @@ function BoardMembers({ boardId, onMemberRemoved }) {
setRemovingId(userId) setRemovingId(userId)
try { try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/members/${userId}`, { const res = await authFetch(`${apiBase}/boards/${boardId}/members/${userId}`, {
method: 'DELETE' method: 'DELETE'
}) })
if (res.ok) { if (res.ok) {

View File

@@ -15,7 +15,6 @@
.board-header { .board-header {
display: flex; display: flex;
gap: 12px;
align-items: center; align-items: center;
} }
@@ -25,12 +24,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
height: 52px; height: 40px;
padding: 0 20px; padding: 0 10px 0 16px;
background: white; background: white;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 26px; border-radius: 20px;
font-size: 17px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: #1f2937; color: #1f2937;
cursor: pointer; cursor: pointer;
@@ -68,50 +67,42 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
line-height: 1;
} }
.chevron { .board-label-archived {
color: #9ca3af; color: #9ca3af;
}
.board-label-archive-icon {
flex-shrink: 0; flex-shrink: 0;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); color: #9ca3af;
} }
.chevron.rotated { /* Иконка настроек/выхода внутри pill */
transform: rotate(180deg); .pill-action-btn {
}
/* Кнопка действия (настройки/выход) */
.board-action-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 52px; width: 18px;
height: 52px; height: 18px;
padding: 0; margin-left: 6px;
background: white; border-left: 1px solid #e5e7eb;
border: 1px solid #e5e7eb; padding-left: 6px;
border-radius: 50%; color: #9ca3af;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
color: #6b7280;
flex-shrink: 0; flex-shrink: 0;
cursor: pointer;
transition: color 0.15s ease;
box-sizing: content-box;
} }
.board-action-btn:hover { .board-pill:hover:not(:disabled) .pill-action-btn,
background: #f9fafb; .board-pill.open .pill-action-btn {
color: #374151; border-left-color: rgba(99, 102, 241, 0.3);
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
transform: translateY(-1px);
} }
.board-action-btn:active { .pill-action-btn:hover {
transform: translateY(0); color: #4f46e5;
}
.board-action-btn svg {
width: 22px;
height: 22px;
} }
/* Выпадающий список */ /* Выпадающий список */
@@ -238,11 +229,26 @@
color: #4f46e5; color: #4f46e5;
} }
/* Строка с кнопками добавления и архива */
.board-actions-row {
display: flex;
align-items: center;
margin-top: 6px;
border-top: 1px solid #f3f4f6;
}
.board-actions-separator {
width: 1px;
height: 24px;
background: #e5e7eb;
flex-shrink: 0;
}
/* Кнопка добавления доски */ /* Кнопка добавления доски */
.dropdown-item.add-board { .dropdown-item.add-board {
margin-top: 6px; flex: 1;
padding-top: 14px; padding-top: 14px;
border-top: 1px solid #f3f4f6; padding-bottom: 14px;
border-radius: 0 0 12px 12px; border-radius: 0 0 12px 12px;
color: #667eea; color: #667eea;
font-weight: 500; font-weight: 500;
@@ -250,6 +256,10 @@
justify-content: flex-start; justify-content: flex-start;
} }
.board-actions-row .dropdown-item.add-board:not(:last-child) {
border-radius: 0 0 0 12px;
}
.dropdown-item.add-board:hover { .dropdown-item.add-board:hover {
background: linear-gradient(135deg, #667eea08 0%, #764ba208 100%); background: linear-gradient(135deg, #667eea08 0%, #764ba208 100%);
} }
@@ -259,3 +269,86 @@
width: 20px; width: 20px;
height: 20px; height: 20px;
} }
/* Кнопка настроек/выхода в дропдауне */
.dropdown-item.board-action-item {
margin-top: 2px;
padding-top: 14px;
border-top: 1px solid #f3f4f6;
border-radius: 0 0 12px 12px;
color: #6b7280;
font-weight: 500;
gap: 12px;
justify-content: flex-start;
}
.dropdown-item.board-action-item:hover {
background: #f3f4f6;
color: #374151;
}
.dropdown-item.board-action-item svg {
flex-shrink: 0;
width: 20px;
height: 20px;
}
/* Кнопка архива в строке действий */
.dropdown-item.archive-toggle {
flex-shrink: 0;
width: auto;
padding: 14px 14px;
border-radius: 0 0 12px 0;
color: #6b7280;
font-weight: 500;
gap: 6px;
justify-content: center;
}
.dropdown-item.archive-toggle:hover {
background: #f3f4f6;
color: #374151;
}
.dropdown-item.archive-toggle svg {
flex-shrink: 0;
width: 20px;
height: 20px;
}
.archive-toggle-icon {
font-size: 8px;
color: #9ca3af;
}
.archive-list {
padding: 0 4px 4px;
border-top: 1px solid #f3f4f6;
}
.archive-loading {
display: flex;
justify-content: center;
padding: 12px 0;
}
.archive-empty {
padding: 10px 16px;
text-align: center;
color: #9ca3af;
font-size: 14px;
}
.dropdown-item.archive-item {
padding: 14px 16px;
color: #9ca3af;
}
.dropdown-item.archive-item:hover {
background: #f3f4f6;
color: #6b7280;
}
.archive-item-name {
font-weight: 500;
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef, useMemo } from 'react'
import './BoardSelector.css' import './BoardSelector.css'
function BoardSelector({ function BoardSelector({
@@ -7,11 +7,16 @@ function BoardSelector({
onBoardChange, onBoardChange,
onBoardEdit, onBoardEdit,
onAddBoard, onAddBoard,
loading loading,
showBoardAction = true
}) { }) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [archiveExpanded, setArchiveExpanded] = useState(false)
const dropdownRef = useRef(null) const dropdownRef = useRef(null)
const activeBoards = useMemo(() => boards.filter(b => !b.is_archived), [boards])
const archivedBoards = useMemo(() => boards.filter(b => b.is_archived), [boards])
const selectedBoard = boards.find(b => b.id === selectedBoardId) const selectedBoard = boards.find(b => b.id === selectedBoardId)
// Закрытие при клике снаружи // Закрытие при клике снаружи
@@ -25,11 +30,23 @@ function BoardSelector({
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, []) }, [])
useEffect(() => {
if (!isOpen) {
setArchiveExpanded(false)
}
}, [isOpen])
const handleSelectBoard = (board) => { const handleSelectBoard = (board) => {
onBoardChange(board.id) onBoardChange(board.id)
setIsOpen(false) setIsOpen(false)
} }
const handleBoardAction = (e) => {
e.stopPropagation()
setIsOpen(false)
onBoardEdit()
}
return ( return (
<div className="board-selector" ref={dropdownRef}> <div className="board-selector" ref={dropdownRef}>
<div className="board-header"> <div className="board-header">
@@ -38,58 +55,44 @@ function BoardSelector({
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
disabled={loading} disabled={loading}
> >
<span className="board-label"> {!loading && selectedBoard?.is_archived && (
<svg className="board-label-archive-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="21 8 21 21 3 21 3 8"></polyline>
<rect x="1" y="3" width="22" height="5"></rect>
<line x1="10" y1="12" x2="14" y2="12"></line>
</svg>
)}
<span className={`board-label ${selectedBoard?.is_archived ? 'board-label-archived' : ''}`}>
{loading ? 'Загрузка...' : (selectedBoard?.name || 'Выберите доску')} {loading ? 'Загрузка...' : (selectedBoard?.name || 'Выберите доску')}
</span> </span>
<svg
className={`chevron ${isOpen ? 'rotated' : ''}`}
width="14"
height="14"
viewBox="0 0 12 12"
>
<path
d="M2.5 4.5L6 8L9.5 4.5"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{selectedBoard && ( {selectedBoard && (
<button <span
className="board-action-btn" className="pill-action-btn"
onClick={onBoardEdit} role="button"
title={selectedBoard.is_owner ? 'Настройки доски' : 'Покинуть доску'} tabIndex={0}
title="Настройки доски"
onClick={handleBoardAction}
onKeyDown={(e) => e.key === 'Enter' && handleBoardAction(e)}
> >
{selectedBoard.is_owner ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1.5"></circle> <circle cx="12" cy="12" r="1.5"></circle>
<circle cx="19" cy="12" r="1.5"></circle> <circle cx="19" cy="12" r="1.5"></circle>
<circle cx="5" cy="12" r="1.5"></circle> <circle cx="5" cy="12" r="1.5"></circle>
</svg> </svg>
) : ( </span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
)} )}
</button> </button>
)}
</div> </div>
<div className={`board-dropdown ${isOpen ? 'visible' : ''}`}> <div className={`board-dropdown ${isOpen ? 'visible' : ''}`}>
<div className="dropdown-content"> <div className="dropdown-content">
{boards.length === 0 ? ( {activeBoards.length === 0 && archivedBoards.length === 0 ? (
<div className="dropdown-empty"> <div className="dropdown-empty">
Нет досок Нет досок
</div> </div>
) : ( ) : (
<div className="dropdown-list"> <div className="dropdown-list">
{boards.map(board => ( {activeBoards.map(board => (
<button <button
key={board.id} key={board.id}
className={`dropdown-item ${board.id === selectedBoardId ? 'selected' : ''}`} className={`dropdown-item ${board.id === selectedBoardId ? 'selected' : ''}`}
@@ -104,6 +107,7 @@ function BoardSelector({
</div> </div>
)} )}
<div className="board-actions-row">
<button className="dropdown-item add-board" onClick={onAddBoard}> <button className="dropdown-item add-board" onClick={onAddBoard}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"></circle> <circle cx="12" cy="12" r="10"></circle>
@@ -112,6 +116,43 @@ function BoardSelector({
</svg> </svg>
<span>Создать доску</span> <span>Создать доску</span>
</button> </button>
{archivedBoards.length > 0 && (
<>
<div className="board-actions-separator" />
<button
className="dropdown-item archive-toggle"
onClick={() => setArchiveExpanded(!archiveExpanded)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="21 8 21 21 3 21 3 8"></polyline>
<rect x="1" y="3" width="22" height="5"></rect>
<line x1="10" y1="12" x2="14" y2="12"></line>
</svg>
<span className="archive-toggle-icon">
{archiveExpanded ? '▼' : '▶'}
</span>
</button>
</>
)}
</div>
{archiveExpanded && archivedBoards.length > 0 && (
<div className="archive-list">
{archivedBoards.map(board => (
<button
key={board.id}
className={`dropdown-item archive-item ${board.id === selectedBoardId ? 'selected' : ''}`}
onClick={() => handleSelectBoard(board)}
>
<span className="item-name archive-item-name">{board.name}</span>
<div className="item-meta">
<span className={`item-members ${board.is_owner ? 'filled' : 'outline'}`}>{board.member_count + 1}</span>
</div>
</button>
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -7,17 +7,17 @@
} }
.submit-button { .submit-button {
background: linear-gradient(to right, #6366f1, #8b5cf6); background: linear-gradient(to right, #10b981, #059669);
color: white; color: white;
padding: 0.75rem 1.5rem; padding: 0.875rem 1.5rem;
border: none; border: none;
border-radius: 0.375rem; border-radius: 0.5rem;
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
flex: 1; flex: 1;
height: 44px; height: 52px;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -26,7 +26,7 @@
.submit-button:hover:not(:disabled) { .submit-button:hover:not(:disabled) {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
} }
.submit-button:disabled { .submit-button:disabled {
@@ -39,7 +39,7 @@
color: white; color: white;
padding: 0; padding: 0;
border: none; border: none;
border-radius: 0.375rem; border-radius: 0.5rem;
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -47,9 +47,9 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 44px; width: 52px;
width: 44px; height: 52px;
height: 44px; flex-shrink: 0;
box-sizing: border-box; box-sizing: border-box;
} }

View File

@@ -174,3 +174,250 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* Внешний контейнер для карточки проекта — без общей тени и рамки */
.project-card-wrapper {
background-color: transparent;
}
/* Карточка с инфой по проекту — своя тень */
.project-card-inner {
box-shadow: 0 1px 3px 0 rgb(99 102 241 / 0.08);
transition: box-shadow 0.3s;
}
.project-card-inner:hover {
box-shadow: 0 2px 6px 0 rgb(99 102 241 / 0.12);
}
/* Блок с инфой по проекту: при наличии желаний убираем нижние закругления и добавляем отступ снизу */
.project-card-inner-with-wishes {
border-radius: 1.5rem 1.5rem 0 0 !important;
margin-bottom: 0.5rem;
}
/* Блок списка желаний: отдельная карточка со своей тенью */
.project-wishes-block {
background-color: #fff;
border-radius: 0 0 1.5rem 1.5rem;
box-shadow: 0 1px 3px 0 rgb(99 102 241 / 0.08);
}
/* Стили для горизонтального скролла желаний в карточке проекта */
.project-wishes-scroll {
display: flex;
gap: 0.5rem;
overflow-x: auto;
overflow-y: hidden;
padding: 0.5rem 1rem 0.75rem 1rem;
scrollbar-width: none;
-ms-overflow-style: none;
}
.project-wishes-scroll::-webkit-scrollbar {
display: none;
}
/* Мини-карточка желания */
.mini-wish-card {
flex-shrink: 0;
width: 50px;
cursor: pointer;
transition: transform 0.2s, opacity 0.2s;
}
.mini-wish-card:hover {
transform: scale(1.05);
}
.mini-wish-card:active {
transform: scale(0.95);
}
.mini-wish-image {
width: 50px;
height: 60px;
background: #f0f0f0;
border-radius: 8px;
overflow: hidden;
position: relative;
container-type: inline-size;
}
.mini-wish-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mini-wish-overlay {
position: absolute;
inset: 0;
z-index: 1;
background: rgba(255, 255, 255, 0.65);
pointer-events: none;
}
/* Текст баллов поверх пелены (отдельный слой, выше по z-index) */
.mini-wish-unlock-points {
position: absolute;
top: 0;
left: 4px;
right: 4px;
bottom: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: calc(100% - 8px);
max-width: calc(100% - 8px);
color: #000;
/* font-size задаётся в JS по количеству цифр (auto-size) */
font-weight: 700;
line-height: 1.2;
pointer-events: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-sizing: border-box;
text-align: center;
}
.mini-wish-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.mini-wish-name {
font-size: 0.625rem;
font-weight: 500;
color: #6b7280;
margin-top: 0.125rem;
line-height: 1.1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
/* Вертикальный список желаний (для 1-2 элементов) */
.project-wishes-vertical {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Карточка желания в виде строки */
.wish-row-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
background-color: #fff;
cursor: pointer;
transition: background-color 0.2s;
box-shadow: 0 1px 3px 0 rgb(99 102 241 / 0.08);
}
.wish-row-card:hover {
background-color: #f9fafb;
}
.wish-row-card:active {
background-color: #f3f4f6;
}
/* Логика скруглений: карточка проекта всегда сверху, последнее желание - снизу */
.wish-row-card-last {
border-radius: 0 0 1.5rem 1.5rem;
}
.wish-row-card-middle {
border-radius: 0;
}
/* Изображение желания */
.wish-row-image {
flex-shrink: 0;
width: 50px;
height: 60px;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
background: #f0f0f0;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.wish-row-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.wish-row-overlay {
position: absolute;
inset: 0;
z-index: 1;
background: rgba(255, 255, 255, 0.65);
pointer-events: none;
}
.wish-row-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
/* Блок с информацией о желании */
.wish-row-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.wish-row-title {
font-size: 1rem;
font-weight: 600;
color: #1f2937;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wish-row-unlock {
font-size: 0.875rem;
color: #6b7280;
}
/* Текст "Готово!" синим для WishRowCard */
.wish-row-unlock.ready {
color: #3b82f6;
font-weight: 600;
}
/* Синяя обводка для готового MiniWishCard */
.mini-wish-image.ready {
border: 2px solid #3b82f6;
}
/* Синяя обводка для готового WishRowCard */
.wish-row-image.ready {
border: 2px solid #3b82f6;
}

View File

@@ -4,6 +4,7 @@ import { useAuth } from './auth/AuthContext'
import ProjectProgressBar from './ProjectProgressBar' import ProjectProgressBar from './ProjectProgressBar'
import LoadingError from './LoadingError' import LoadingError from './LoadingError'
import Toast from './Toast' import Toast from './Toast'
import WishlistDetail from './WishlistDetail'
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils' import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
import { CircularProgressbar, buildStyles } from 'react-circular-progressbar' import { CircularProgressbar, buildStyles } from 'react-circular-progressbar'
import 'react-circular-progressbar/dist/styles.css' import 'react-circular-progressbar/dist/styles.css'
@@ -94,8 +95,137 @@ function CircularProgressBar({ progress, size = 120, strokeWidth = 8, showCheckm
) )
} }
// Компонент мини-карточки желания для отображения внутри карточки проекта
function MiniWishCard({ wish, onClick, pendingScoresByProject = {} }) {
const handleClick = (e) => {
e.stopPropagation()
if (onClick) {
onClick(wish)
}
}
// Желание помечено как готовое на бэкенде
const isReady = wish.is_ready === true
// Для готовых желаний берём условие из unlock_conditions, иначе из first_locked_condition
const cond = isReady
? wish.unlock_conditions?.find(c => c.type === 'project_points')
: wish.first_locked_condition
const isPointsCondition = cond?.type === 'project_points'
const required = cond?.required_points ?? 0
const current = cond?.current_points ?? 0
const projectId = cond?.project_id
const pending = (projectId != null && pendingScoresByProject[projectId] != null) ? Number(pendingScoresByProject[projectId]) : 0
const remaining = isPointsCondition ? (required - current - pending) : 0
const showUnlockPoints = remaining > 0
// Auto-size: уменьшаем шрифт при большом количестве цифр, чтобы текст влезал
const digits = String(Math.round(remaining)).length
const fontSizePx = digits <= 1 ? 22 : digits === 2 ? 19 : digits === 3 ? 16 : 14
return (
<div className="mini-wish-card" onClick={handleClick}>
<div className={`mini-wish-image ${isReady ? 'ready' : ''}`}>
{wish.image_url ? (
<img src={wish.image_url} alt={wish.name} />
) : (
<div className="mini-wish-placeholder">🎁</div>
)}
{!isReady && <div className="mini-wish-overlay" aria-hidden="true" />}
{showUnlockPoints && !isReady && (
<div
className="mini-wish-unlock-points"
style={{ fontSize: `${fontSizePx}px` }}
aria-hidden="true"
>
{Math.round(remaining)}
</div>
)}
</div>
</div>
)
}
// Компонент карточки желания в виде строки (для отображения 1-2 желаний)
function WishRowCard({ wish, onClick, pendingScoresByProject = {}, position, minGoalScore }) {
const handleClick = (e) => {
e.stopPropagation()
if (onClick) {
onClick(wish)
}
}
// Желание помечено как готовое на бэкенде
const isReady = wish.is_ready === true
// Для готовых желаний берём условие из unlock_conditions, иначе из first_locked_condition
const cond = isReady
? wish.unlock_conditions?.find(c => c.type === 'project_points')
: wish.first_locked_condition
const isPointsCondition = cond?.type === 'project_points'
const required = cond?.required_points ?? 0
const current = cond?.current_points ?? 0
const projectId = cond?.project_id
const pending = (projectId != null && pendingScoresByProject[projectId] != null) ? Number(pendingScoresByProject[projectId]) : 0
const remaining = isPointsCondition ? (required - current - pending) : 0
const formatDaysText = (days) => {
if (days < 1) return '<1 дня'
const daysRounded = Math.round(days)
const lastDigit = daysRounded % 10
const lastTwoDigits = daysRounded % 100
let dayWord
if (lastTwoDigits >= 11 && lastTwoDigits <= 14) {
dayWord = 'дней'
} else if (lastDigit === 1) {
dayWord = 'день'
} else if (lastDigit >= 2 && lastDigit <= 4) {
dayWord = 'дня'
} else {
dayWord = 'дней'
}
return `${daysRounded} ${dayWord}`
}
const getUnlockText = () => {
if (isReady) {
return 'Разблокировано!'
}
if (remaining <= 0) {
return 'в конце дня'
}
const pointsText = `${Math.round(remaining)} баллов`
const safeMinGoal = Number.isFinite(minGoalScore) && minGoalScore > 0 ? minGoalScore : 0
if (safeMinGoal > 0) {
const weeks = remaining / safeMinGoal
const days = weeks * 7
return `${pointsText} (${formatDaysText(days)})`
}
return pointsText
}
const positionClass = position === 'last' ? 'wish-row-card-last' : 'wish-row-card-middle'
return (
<div className={`wish-row-card ${positionClass}`} onClick={handleClick}>
<div className={`wish-row-image ${isReady ? 'ready' : ''}`}>
{wish.image_url ? (
<img src={wish.image_url} alt={wish.name} />
) : (
<div className="wish-row-placeholder">🎁</div>
)}
{!isReady && <div className="wish-row-overlay" aria-hidden="true" />}
</div>
<div className="wish-row-info">
<div className="wish-row-title">{wish.name}</div>
<div className={`wish-row-unlock ${isReady ? 'ready' : ''}`}>{getUnlockText()}</div>
</div>
</div>
)
}
// Компонент карточки проекта с круглым прогрессбаром // Компонент карточки проекта с круглым прогрессбаром
function ProjectCard({ project, projectColor, onProjectClick }) { function ProjectCard({ project, projectColor, onProjectClick, wishes = [], onWishClick, pendingScoresByProject = {} }) {
const { project_name, total_score, min_goal_score, max_goal_score, priority, today_change } = project const { project_name, total_score, min_goal_score, max_goal_score, priority, today_change } = project
// Вычисляем прогресс по оригинальной логике из ProjectProgressBar // Вычисляем прогресс по оригинальной логике из ProjectProgressBar
@@ -176,10 +306,13 @@ function ProjectCard({ project, projectColor, onProjectClick }) {
} }
} }
const hasWishes = wishes && wishes.length > 0
return ( return (
<div className="project-card-wrapper">
<div <div
onClick={handleClick} onClick={handleClick}
className="bg-white rounded-3xl py-3 px-4 shadow-sm hover:shadow-md transition-all duration-300 cursor-pointer border border-gray-200 hover:border-indigo-300" className={`project-card-inner bg-white py-3 px-4 transition-all duration-300 cursor-pointer ${hasWishes ? 'rounded-t-3xl project-card-inner-with-wishes' : 'rounded-3xl'}`}
> >
{/* Верхняя часть с названием и прогрессом */} {/* Верхняя часть с названием и прогрессом */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -217,11 +350,47 @@ function ProjectCard({ project, projectColor, onProjectClick }) {
</div> </div>
</div> </div>
</div> </div>
{/* Список желаний: горизонтальный скролл для 3+, вертикальный список для 1-2 */}
{hasWishes && (
wishes.length >= 3 ? (
<div className="project-wishes-block">
<div className="project-wishes-scroll">
{wishes.map((wish) => (
<MiniWishCard
key={wish.id}
wish={wish}
onClick={onWishClick}
pendingScoresByProject={pendingScoresByProject || {}}
/>
))}
</div>
</div>
) : (
<div className="project-wishes-vertical">
{wishes.map((wish, index) => {
const isLast = index === wishes.length - 1
const position = isLast ? 'last' : 'middle'
return (
<WishRowCard
key={wish.id}
wish={wish}
onClick={onWishClick}
pendingScoresByProject={pendingScoresByProject || {}}
position={position}
minGoalScore={min_goal_score}
/>
)
})}
</div>
)
)}
</div>
) )
} }
// Компонент группы проектов по приоритету // Компонент группы проектов по приоритету
function PriorityGroup({ title, subtitle, projects, allProjects, onProjectClick }) { function PriorityGroup({ title, subtitle, projects, allProjects, onProjectClick, getWishesForProject, onWishClick, pendingScoresByProject = {} }) {
if (projects.length === 0) return null if (projects.length === 0) return null
return ( return (
@@ -239,6 +408,7 @@ function PriorityGroup({ title, subtitle, projects, allProjects, onProjectClick
if (!project || !project.project_name) return null if (!project || !project.project_name) return null
const projectColor = getProjectColor(project.project_name, allProjects, project.color) const projectColor = getProjectColor(project.project_name, allProjects, project.color)
const projectWishes = getWishesForProject ? getWishesForProject(project.project_id) : []
return ( return (
<ProjectCard <ProjectCard
@@ -246,6 +416,9 @@ function PriorityGroup({ title, subtitle, projects, allProjects, onProjectClick
project={project} project={project}
projectColor={projectColor} projectColor={projectColor}
onProjectClick={onProjectClick} onProjectClick={onProjectClick}
wishes={projectWishes}
onWishClick={onWishClick}
pendingScoresByProject={pendingScoresByProject}
/> />
) )
})} })}
@@ -509,6 +682,62 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
const { authFetch } = useAuth() const { authFetch } = useAuth()
const [isAddModalOpen, setIsAddModalOpen] = useState(false) const [isAddModalOpen, setIsAddModalOpen] = useState(false)
const [toastMessage, setToastMessage] = useState(null) const [toastMessage, setToastMessage] = useState(null)
const [selectedWishlistId, setSelectedWishlistId] = useState(null)
// Желания и pending-баллы по проектам приходят вместе с данными
const wishes = data?.wishes || []
const pendingScoresByProject = data?.pending_scores_by_project && typeof data.pending_scores_by_project === 'object' ? data.pending_scores_by_project : {}
// Функция для получения числового значения срока из текста
const getWeeksValue = (weeksText) => {
if (!weeksText) return Infinity
if (weeksText === '<1 недели') return 0
if (weeksText === '1 неделя') return 1
const match = weeksText.match(/(\d+)/)
return match ? parseInt(match[1], 10) : Infinity
}
// Функция фильтрации желаний для проекта
// Фильтрация уже выполнена на бэкенде, здесь только группируем по проекту
const getWishesForProject = (projectId) => {
// Вспомогательная функция для получения projectId из желания
const getWishProjectId = (wish) => {
// Сначала пробуем first_locked_condition
if (wish.first_locked_condition?.project_id) {
return wish.first_locked_condition.project_id
}
// Иначе ищем в unlock_conditions (для готовых/разблокированных желаний)
if (wish.unlock_conditions) {
const cond = wish.unlock_conditions.find(c => c.type === 'project_points')
return cond?.project_id
}
return null
}
const filtered = wishes.filter(wish => {
return getWishProjectId(wish) === projectId
})
// Сортируем: готовые желания первыми, затем по сроку разблокировки
return filtered.sort((a, b) => {
// Готовые желания показываем первыми
if (a.is_ready && !b.is_ready) return -1
if (!a.is_ready && b.is_ready) return 1
const weeksA = getWeeksValue(a.first_locked_condition?.weeks_text)
const weeksB = getWeeksValue(b.first_locked_condition?.weeks_text)
return weeksA - weeksB
})
}
// Обработчик клика на желание
const handleWishClick = (wish) => {
setSelectedWishlistId(wish.id)
}
// Закрытие модального окна детализации желания
const handleCloseWishDetail = () => {
setSelectedWishlistId(null)
}
// Экспортируем функцию открытия модала для использования из App.jsx // Экспортируем функцию открытия модала для использования из App.jsx
useEffect(() => { useEffect(() => {
@@ -650,12 +879,6 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
showCheckmark={true} showCheckmark={true}
displayProgress={displayOverallProgress} displayProgress={displayOverallProgress}
/> />
{/* Подсказка при наведении */}
<div className="absolute inset-0 rounded-full opacity-0 hover:opacity-100 transition-opacity duration-200 bg-black bg-opacity-10 flex items-center justify-center">
<span className="text-xs text-gray-600 font-medium bg-white px-2 py-1 rounded shadow-sm">
Открыть статистику
</span>
</div>
</div> </div>
</div> </div>
@@ -667,6 +890,9 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
projects={priorityGroups.main} projects={priorityGroups.main}
allProjects={allProjects} allProjects={allProjects}
onProjectClick={onProjectClick} onProjectClick={onProjectClick}
getWishesForProject={getWishesForProject}
onWishClick={handleWishClick}
pendingScoresByProject={pendingScoresByProject}
/> />
<PriorityGroup <PriorityGroup
@@ -675,6 +901,9 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
projects={priorityGroups.important} projects={priorityGroups.important}
allProjects={allProjects} allProjects={allProjects}
onProjectClick={onProjectClick} onProjectClick={onProjectClick}
getWishesForProject={getWishesForProject}
onWishClick={handleWishClick}
pendingScoresByProject={pendingScoresByProject}
/> />
<PriorityGroup <PriorityGroup
@@ -683,9 +912,22 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
projects={priorityGroups.others} projects={priorityGroups.others}
allProjects={allProjects} allProjects={allProjects}
onProjectClick={onProjectClick} onProjectClick={onProjectClick}
getWishesForProject={getWishesForProject}
onWishClick={handleWishClick}
pendingScoresByProject={pendingScoresByProject}
/> />
</div> </div>
{/* Модальное окно детализации желания */}
{selectedWishlistId && (
<WishlistDetail
wishlistId={selectedWishlistId}
onNavigate={onNavigate}
onClose={handleCloseWishDetail}
onRefresh={refreshData}
/>
)}
{/* Модальное окно добавления записи */} {/* Модальное окно добавления записи */}
{isAddModalOpen && ( {isAddModalOpen && (
<AddEntryModal <AddEntryModal

View File

@@ -13,25 +13,33 @@ function FitbitIntegration({ onNavigate }) {
const [oauthError, setOauthError] = useState('') const [oauthError, setOauthError] = useState('')
const [toastMessage, setToastMessage] = useState(null) const [toastMessage, setToastMessage] = useState(null)
const [isLoadingError, setIsLoadingError] = useState(false) const [isLoadingError, setIsLoadingError] = useState(false)
const [goals, setGoals] = useState({
steps: { min: 8000, max: 10000 },
floors: { min: 8, max: 10 },
azm: { min: 22, max: 44 }
})
const [stats, setStats] = useState({
steps: { value: 0, goal: { min: 8000, max: 10000 } },
floors: { value: 0, goal: { min: 8, max: 10 } },
azm: { value: 0, goal: { min: 22, max: 44 } }
})
const [isEditingGoals, setIsEditingGoals] = useState(false)
const [editedGoals, setEditedGoals] = useState(goals)
const [syncing, setSyncing] = useState(false) const [syncing, setSyncing] = useState(false)
// Сохраняем OAuth статус из URL в ref, чтобы проверить после checkStatus const [stats, setStats] = useState({
steps: { value: 0, goal: 10000 },
floors: { value: 0, goal: 10 }
})
const [bindings, setBindings] = useState({
steps_task_id: null,
floors_task_id: null,
steps_goal_task_id: null,
steps_goal_subtask_id: null,
floors_goal_task_id: null,
floors_goal_subtask_id: null
})
const [editedBindings, setEditedBindings] = useState(bindings)
const [savingStepsBindings, setSavingStepsBindings] = useState(false)
const [savingFloorsBindings, setSavingFloorsBindings] = useState(false)
const [tasks, setTasks] = useState([])
const [loadingTasks, setLoadingTasks] = useState(false)
const [stepsGoalSubtasks, setStepsGoalSubtasks] = useState([])
const [floorsGoalSubtasks, setFloorsGoalSubtasks] = useState([])
const oauthStatusRef = React.useRef(null) const oauthStatusRef = React.useRef(null)
useEffect(() => { useEffect(() => {
// Проверяем URL параметры для сообщений ДО вызова checkStatus
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const integration = params.get('integration') const integration = params.get('integration')
const status = params.get('status') const status = params.get('status')
@@ -52,7 +60,6 @@ function FitbitIntegration({ onNavigate }) {
} }
setOauthError(errorMessages[errorMsg] || `Ошибка: ${errorMsg}`) setOauthError(errorMessages[errorMsg] || `Ошибка: ${errorMsg}`)
} }
// Очищаем URL параметры
window.history.replaceState({}, '', window.location.pathname) window.history.replaceState({}, '', window.location.pathname)
} }
checkStatus() checkStatus()
@@ -61,9 +68,52 @@ function FitbitIntegration({ onNavigate }) {
useEffect(() => { useEffect(() => {
if (connected) { if (connected) {
loadStats() loadStats()
loadTasks()
} }
}, [connected]) }, [connected])
useEffect(() => {
if (!editedBindings.steps_goal_task_id) {
setStepsGoalSubtasks([])
return
}
let cancelled = false
authFetch(`/api/tasks/${editedBindings.steps_goal_task_id}`)
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!cancelled && data?.subtasks) {
setStepsGoalSubtasks(data.subtasks.map((s) => ({ id: s.task.id, name: s.task.name })))
} else if (!cancelled) {
setStepsGoalSubtasks([])
}
})
.catch(() => {
if (!cancelled) setStepsGoalSubtasks([])
})
return () => { cancelled = true }
}, [editedBindings.steps_goal_task_id, authFetch])
useEffect(() => {
if (!editedBindings.floors_goal_task_id) {
setFloorsGoalSubtasks([])
return
}
let cancelled = false
authFetch(`/api/tasks/${editedBindings.floors_goal_task_id}`)
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!cancelled && data?.subtasks) {
setFloorsGoalSubtasks(data.subtasks.map((s) => ({ id: s.task.id, name: s.task.name })))
} else if (!cancelled) {
setFloorsGoalSubtasks([])
}
})
.catch(() => {
if (!cancelled) setFloorsGoalSubtasks([])
})
return () => { cancelled = true }
}, [editedBindings.floors_goal_task_id, authFetch])
const checkStatus = async () => { const checkStatus = async () => {
try { try {
setLoading(true) setLoading(true)
@@ -74,13 +124,21 @@ function FitbitIntegration({ onNavigate }) {
} }
const data = await response.json() const data = await response.json()
setConnected(data.connected || false) setConnected(data.connected || false)
if (data.connected && data.goals) { if (data.connected && data.bindings) {
setGoals(data.goals) const b = data.bindings
setEditedGoals(data.goals) const normalized = {
steps_task_id: b.steps_task_id ?? null,
floors_task_id: b.floors_task_id ?? null,
steps_goal_task_id: b.steps_goal_task_id ?? null,
steps_goal_subtask_id: b.steps_goal_subtask_id ?? null,
floors_goal_task_id: b.floors_goal_task_id ?? null,
floors_goal_subtask_id: b.floors_goal_subtask_id ?? null
}
setBindings(normalized)
setEditedBindings(normalized)
} }
// Если OAuth вернул status=connected, но бэкенд не подтвердил подключение
if (oauthStatusRef.current === 'connected' && !data.connected) { if (oauthStatusRef.current === 'connected' && !data.connected) {
setOauthError('Авторизация в Fitbit прошла, но подключение не сохранилось. Попробуйте ещё раз или обратитесь к администратору.') setOauthError('Авторизация в Fitbit прошла, но подключение не сохранилось. Попробуйте ещё раз.')
setMessage('') setMessage('')
} }
oauthStatusRef.current = null oauthStatusRef.current = null
@@ -100,39 +158,34 @@ function FitbitIntegration({ onNavigate }) {
throw new Error('Ошибка при загрузке статистики') throw new Error('Ошибка при загрузке статистики')
} }
const data = await response.json() const data = await response.json()
// Нормализуем данные, чтобы избежать undefined setStats({
const defaultGoal = { min: 0, max: 0 }
const normalizedStats = {
steps: { steps: {
value: data.steps?.value ?? 0, value: data.steps?.value ?? 0,
goal: data.steps?.goal ?? defaultGoal goal: data.steps?.goal ?? 10000
}, },
floors: { floors: {
value: data.floors?.value ?? 0, value: data.floors?.value ?? 0,
goal: data.floors?.goal ?? defaultGoal goal: data.floors?.goal ?? 10
},
azm: {
value: data.azm?.value ?? 0,
goal: data.azm?.goal ?? defaultGoal
} }
}
setStats(normalizedStats)
// Обновляем цели из ответа
if (data.steps?.goal) {
setGoals({
steps: data.steps.goal,
floors: data.floors?.goal ?? defaultGoal,
azm: data.azm?.goal ?? defaultGoal
}) })
setEditedGoals({
steps: data.steps.goal,
floors: data.floors?.goal ?? defaultGoal,
azm: data.azm?.goal ?? defaultGoal
})
}
} catch (error) { } catch (error) {
console.error('Error loading stats:', error) console.error('Error loading stats:', error)
// Не показываем ошибку, просто не обновляем статистику }
}
const loadTasks = async (silent = false) => {
try {
if (!silent) setLoadingTasks(true)
const response = await authFetch('/api/tasks')
if (!response.ok) {
throw new Error('Ошибка при загрузке задач')
}
const data = await response.json()
setTasks(data || [])
} catch (error) {
if (!silent) console.error('Error loading tasks:', error)
} finally {
if (!silent) setLoadingTasks(false)
} }
} }
@@ -162,7 +215,6 @@ function FitbitIntegration({ onNavigate }) {
if (!window.confirm('Вы уверены, что хотите отключить Fitbit?')) { if (!window.confirm('Вы уверены, что хотите отключить Fitbit?')) {
return return
} }
try { try {
setLoading(true) setLoading(true)
setError('') setError('')
@@ -175,9 +227,8 @@ function FitbitIntegration({ onNavigate }) {
} }
setConnected(false) setConnected(false)
setStats({ setStats({
steps: { value: 0, goal: { min: 8000, max: 10000 } }, steps: { value: 0, goal: 10000 },
floors: { value: 0, goal: { min: 8, max: 10 } }, floors: { value: 0, goal: 10 }
azm: { value: 0, goal: { min: 22, max: 44 } }
}) })
setToastMessage({ text: 'Fitbit отключен', type: 'success' }) setToastMessage({ text: 'Fitbit отключен', type: 'success' })
} catch (error) { } catch (error) {
@@ -208,54 +259,106 @@ function FitbitIntegration({ onNavigate }) {
} }
} }
const handleSaveGoals = async () => { const handleSaveStepsBindings = async () => {
try { try {
const response = await authFetch('/api/integrations/fitbit/goals', { setSavingStepsBindings(true)
const response = await authFetch('/api/integrations/fitbit/bindings/steps', {
method: 'PUT', method: 'PUT',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
steps: editedGoals.steps, steps_task_id: editedBindings.steps_task_id,
floors: editedGoals.floors, steps_goal_task_id: editedBindings.steps_goal_task_id,
azm: editedGoals.azm, steps_goal_subtask_id: editedBindings.steps_goal_subtask_id
}), })
}) })
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})) const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при сохранении целей') throw new Error(errorData.error || 'Ошибка при сохранении привязок')
} }
setGoals(editedGoals) setBindings(prev => ({
setIsEditingGoals(false) ...prev,
setToastMessage({ text: 'Цели сохранены', type: 'success' }) steps_task_id: editedBindings.steps_task_id,
await loadStats() steps_goal_task_id: editedBindings.steps_goal_task_id,
steps_goal_subtask_id: editedBindings.steps_goal_subtask_id
}))
setToastMessage({ text: 'Привязки шагов сохранены', type: 'success' })
} catch (error) { } catch (error) {
console.error('Error saving goals:', error) console.error('Error saving steps bindings:', error)
setToastMessage({ text: error.message || 'Не удалось сохранить цели', type: 'error' }) setToastMessage({ text: error.message || 'Не удалось сохранить привязки', type: 'error' })
} finally {
setSavingStepsBindings(false)
} }
} }
const handleCancelEdit = () => { const handleSaveFloorsBindings = async () => {
setEditedGoals(goals) try {
setIsEditingGoals(false) setSavingFloorsBindings(true)
const response = await authFetch('/api/integrations/fitbit/bindings/floors', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
floors_task_id: editedBindings.floors_task_id,
floors_goal_task_id: editedBindings.floors_goal_task_id,
floors_goal_subtask_id: editedBindings.floors_goal_subtask_id
})
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при сохранении привязок')
}
setBindings(prev => ({
...prev,
floors_task_id: editedBindings.floors_task_id,
floors_goal_task_id: editedBindings.floors_goal_task_id,
floors_goal_subtask_id: editedBindings.floors_goal_subtask_id
}))
setToastMessage({ text: 'Привязки этажей сохранены', type: 'success' })
} catch (error) {
console.error('Error saving floors bindings:', error)
setToastMessage({ text: error.message || 'Не удалось сохранить привязки', type: 'error' })
} finally {
setSavingFloorsBindings(false)
}
} }
const getProgressPercent = (value, min, max) => { const getProgressTasks = () => {
if (value >= max) return 100 return tasks.filter(t => t.has_progression || t.progression_base != null)
if (value <= min) return (value / min) * 50
return 50 + ((value - min) / (max - min)) * 50
} }
const getProgressColor = (value, min, max) => { const getParentTasks = () => {
if (value >= max) return 'text-green-600' return tasks.filter(t => (t.subtasks_count ?? 0) > 0)
if (value >= min) return 'text-blue-600' }
const getProgressPercent = (value, goal) => {
if (!goal || goal === 0) return 0
return Math.min(100, (value / goal) * 100)
}
const getProgressColor = (value, goal) => {
if (value >= goal) return 'text-green-600'
if (value >= goal * 0.5) return 'text-blue-600'
return 'text-gray-600' return 'text-gray-600'
} }
const isStepsBindingsDirty =
(editedBindings.steps_task_id ?? null) !== (bindings.steps_task_id ?? null) ||
(editedBindings.steps_goal_task_id ?? null) !== (bindings.steps_goal_task_id ?? null) ||
(editedBindings.steps_goal_subtask_id ?? null) !== (bindings.steps_goal_subtask_id ?? null)
const isFloorsBindingsDirty =
(editedBindings.floors_task_id ?? null) !== (bindings.floors_task_id ?? null) ||
(editedBindings.floors_goal_task_id ?? null) !== (bindings.floors_goal_task_id ?? null) ||
(editedBindings.floors_goal_subtask_id ?? null) !== (bindings.floors_goal_subtask_id ?? null)
const handleClose = () => {
setEditedBindings(bindings)
onNavigate?.('profile')
}
if (isLoadingError && !loading) { if (isLoadingError && !loading) {
return ( return (
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть"> <button className="close-x-button" onClick={handleClose} title="Закрыть">
</button> </button>
<LoadingError onRetry={checkStatus} /> <LoadingError onRetry={checkStatus} />
@@ -265,211 +368,232 @@ function FitbitIntegration({ onNavigate }) {
return ( return (
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть"> <button className="close-x-button" onClick={handleClose} title="Закрыть">
</button> </button>
<h1 className="text-2xl font-bold mb-6">Fitbit интеграция</h1> <h1 className="text-2xl font-bold mb-6">Fitbit</h1>
{loading ? (
<div className="fixed inset-0 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
) : connected ? (
<div>
{message && ( {message && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6"> <div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-800">{message}</p> <p className="text-green-800">{message}</p>
</div> <button onClick={() => setMessage('')} className="text-green-600 text-sm underline mt-2">Скрыть</button>
)}
{oauthError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-800">{oauthError}</p>
<button onClick={() => setOauthError('')} className="text-red-600 text-sm underline mt-1">Скрыть</button>
</div> </div>
)} )}
{/* Статистика */} {loading ? (
<div className="flex justify-center items-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
) : connected ? (
<div>
{/* Группа: Шаги */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6"> <div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex justify-between items-center mb-4"> <h2 className="text-lg font-semibold mb-4">Шаги</h2>
<h2 className="text-lg font-semibold">Статистика за сегодня</h2>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-gray-600 text-sm">Сегодня</span>
<span className={`font-bold ${getProgressColor(stats.steps.value, stats.steps.goal)}`}>
{stats.steps.value.toLocaleString()} / {stats.steps.goal.toLocaleString()}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-indigo-600 h-3 rounded-full transition-all"
style={{ width: `${getProgressPercent(stats.steps.value, stats.steps.goal)}%` }}
></div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Прогресс шагов</label>
<p className="text-xs text-gray-500 mb-1">Задача</p>
<select
value={editedBindings.steps_task_id ?? ''}
onChange={(e) => setEditedBindings({
...editedBindings,
steps_task_id: e.target.value ? parseInt(e.target.value, 10) : null
})}
onFocus={() => loadTasks(true)}
disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
>
<option value="">{loadingTasks ? 'Загрузка...' : 'Не выбрано'}</option>
{!loadingTasks && getProgressTasks().map(task => (
<option key={task.id} value={task.id}>{task.name}</option>
))}
</select>
</div>
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Достижение цели</h3>
<div className="pl-0 space-y-2">
<div>
<label className="block text-sm text-gray-600 mb-1">Задача</label>
<select
value={editedBindings.steps_goal_task_id ?? ''}
onChange={(e) => setEditedBindings({
...editedBindings,
steps_goal_task_id: e.target.value ? parseInt(e.target.value, 10) : null,
steps_goal_subtask_id: null
})}
onFocus={() => loadTasks(true)}
disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
>
<option value="">{loadingTasks ? 'Загрузка...' : 'Не выбрано'}</option>
{!loadingTasks && tasks.map(task => (
<option key={task.id} value={task.id}>{task.name}</option>
))}
</select>
</div>
{editedBindings.steps_goal_task_id && stepsGoalSubtasks.length > 0 && (
<div>
<label className="block text-sm text-gray-600 mb-1">Подзадача</label>
<select
value={editedBindings.steps_goal_subtask_id ?? ''}
onChange={(e) => setEditedBindings({
...editedBindings,
steps_goal_subtask_id: e.target.value ? parseInt(e.target.value, 10) : null
})}
disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
>
<option value="">Не выбрано</option>
{stepsGoalSubtasks.map(subtask => (
<option key={subtask.id} value={subtask.id}>{subtask.name}</option>
))}
</select>
</div>
)}
</div>
</div>
{isStepsBindingsDirty && (
<button
onClick={handleSaveStepsBindings}
disabled={savingStepsBindings}
className="mt-3 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
>
{savingStepsBindings ? 'Сохранение...' : 'Сохранить'}
</button>
)}
</div>
</div>
{/* Группа: Этажи */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Этажи</h2>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-gray-600 text-sm">Сегодня</span>
<span className={`font-bold ${getProgressColor(stats.floors.value, stats.floors.goal)}`}>
{stats.floors.value} / {stats.floors.goal}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-indigo-600 h-3 rounded-full transition-all"
style={{ width: `${getProgressPercent(stats.floors.value, stats.floors.goal)}%` }}
></div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Прогресс этажей</label>
<p className="text-xs text-gray-500 mb-1">Задача</p>
<select
value={editedBindings.floors_task_id ?? ''}
onChange={(e) => setEditedBindings({
...editedBindings,
floors_task_id: e.target.value ? parseInt(e.target.value, 10) : null
})}
onFocus={() => loadTasks(true)}
disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
>
<option value="">{loadingTasks ? 'Загрузка...' : 'Не выбрано'}</option>
{!loadingTasks && getProgressTasks().map(task => (
<option key={task.id} value={task.id}>{task.name}</option>
))}
</select>
</div>
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Достижение цели</h3>
<div className="pl-0 space-y-2">
<div>
<label className="block text-sm text-gray-600 mb-1">Задача</label>
<select
value={editedBindings.floors_goal_task_id ?? ''}
onChange={(e) => setEditedBindings({
...editedBindings,
floors_goal_task_id: e.target.value ? parseInt(e.target.value, 10) : null,
floors_goal_subtask_id: null
})}
onFocus={() => loadTasks(true)}
disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
>
<option value="">{loadingTasks ? 'Загрузка...' : 'Не выбрано'}</option>
{!loadingTasks && tasks.map(task => (
<option key={task.id} value={task.id}>{task.name}</option>
))}
</select>
</div>
{editedBindings.floors_goal_task_id && floorsGoalSubtasks.length > 0 && (
<div>
<label className="block text-sm text-gray-600 mb-1">Подзадача</label>
<select
value={editedBindings.floors_goal_subtask_id ?? ''}
onChange={(e) => setEditedBindings({
...editedBindings,
floors_goal_subtask_id: e.target.value ? parseInt(e.target.value, 10) : null
})}
disabled={loadingTasks}
className="w-full px-3 py-2 border border-gray-300 rounded-lg disabled:bg-gray-100"
>
<option value="">Не выбрано</option>
{floorsGoalSubtasks.map(subtask => (
<option key={subtask.id} value={subtask.id}>{subtask.name}</option>
))}
</select>
</div>
)}
</div>
</div>
{isFloorsBindingsDirty && (
<button
onClick={handleSaveFloorsBindings}
disabled={savingFloorsBindings}
className="mt-3 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
>
{savingFloorsBindings ? 'Сохранение...' : 'Сохранить'}
</button>
)}
</div>
</div>
<button <button
onClick={handleSync} onClick={handleSync}
disabled={syncing} disabled={syncing}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm" className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 mb-6"
> >
{syncing ? 'Синхронизация...' : 'Синхронизировать'} {syncing ? 'Синхронизация...' : 'Синхронизировать'}
</button> </button>
</div>
{/* Шаги */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">Шаги</span>
<span className={`font-bold ${getProgressColor(stats.steps?.value ?? 0, stats.steps?.goal?.min ?? 0, stats.steps?.goal?.max ?? 0)}`}>
{(stats.steps?.value ?? 0).toLocaleString()} / {stats.steps?.goal?.min ?? 0}-{stats.steps?.goal?.max ?? 0}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-indigo-600 h-3 rounded-full transition-all"
style={{ width: `${Math.min(100, getProgressPercent(stats.steps?.value ?? 0, stats.steps?.goal?.min ?? 0, stats.steps?.goal?.max ?? 0))}%` }}
></div>
</div>
</div>
{/* Этажи */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">Этажи</span>
<span className={`font-bold ${getProgressColor(stats.floors?.value ?? 0, stats.floors?.goal?.min ?? 0, stats.floors?.goal?.max ?? 0)}`}>
{stats.floors?.value ?? 0} / {stats.floors?.goal?.min ?? 0}-{stats.floors?.goal?.max ?? 0}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-indigo-600 h-3 rounded-full transition-all"
style={{ width: `${Math.min(100, getProgressPercent(stats.floors?.value ?? 0, stats.floors?.goal?.min ?? 0, stats.floors?.goal?.max ?? 0))}%` }}
></div>
</div>
</div>
{/* Баллы кардио (AZM) */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">Баллы кардио</span>
<span className={`font-bold ${getProgressColor(stats.azm?.value ?? 0, stats.azm?.goal?.min ?? 0, stats.azm?.goal?.max ?? 0)}`}>
{stats.azm?.value ?? 0} / {stats.azm?.goal?.min ?? 0}-{stats.azm?.goal?.max ?? 0}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-indigo-600 h-3 rounded-full transition-all"
style={{ width: `${Math.min(100, getProgressPercent(stats.azm?.value ?? 0, stats.azm?.goal?.min ?? 0, stats.azm?.goal?.max ?? 0))}%` }}
></div>
</div>
</div>
</div>
{/* Настройка целей */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Дневные цели</h2>
{!isEditingGoals && (
<button
onClick={() => setIsEditingGoals(true)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors text-sm"
>
Изменить
</button>
)}
</div>
{isEditingGoals ? (
<div className="space-y-4">
{/* Шаги */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Шаги (мин - макс)</label>
<div className="flex gap-2">
<input
type="number"
value={editedGoals.steps.min}
onChange={(e) => setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, min: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
<input
type="number"
value={editedGoals.steps.max}
onChange={(e) => setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, max: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
{/* Этажи */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Этажи (мин - макс)</label>
<div className="flex gap-2">
<input
type="number"
value={editedGoals.floors.min}
onChange={(e) => setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, min: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
<input
type="number"
value={editedGoals.floors.max}
onChange={(e) => setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, max: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
{/* Баллы кардио */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Баллы кардио (мин - макс)</label>
<div className="flex gap-2">
<input
type="number"
value={editedGoals.azm.min}
onChange={(e) => setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, min: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
<input
type="number"
value={editedGoals.azm.max}
onChange={(e) => setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, max: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleSaveGoals}
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
Сохранить
</button>
<button
onClick={handleCancelEdit}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Отмена
</button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Шаги:</span>
<span className="font-medium">{goals.steps.min} - {goals.steps.max}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Этажи:</span>
<span className="font-medium">{goals.floors.min} - {goals.floors.max}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Баллы кардио:</span>
<span className="font-medium">{goals.azm.min} - {goals.azm.max}</span>
</div>
</div>
)}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900"> <h3 className="text-lg font-semibold mb-3 text-blue-900">Как это работает</h3>
Как это работает <ul className="text-gray-700 space-y-2 text-sm">
</h3> <li> Данные синхронизируются автоматически каждые 4 часа</li>
<p className="text-gray-700 mb-2"> <li> При синхронизации данные записываются в привязанные задачи</li>
Fitbit подключен! Данные синхронизируются автоматически каждые 4 часа. <li> Задачи автоматически выполняются в конце дня (23:55)</li>
</p> </ul>
<p className="text-gray-600 text-sm">
Вы также можете синхронизировать данные вручную, нажав кнопку "Синхронизировать".
</p>
</div> </div>
<button <button
@@ -491,7 +615,7 @@ function FitbitIntegration({ onNavigate }) {
<div className="bg-white rounded-lg shadow-md p-6 mb-6"> <div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Подключение Fitbit</h2> <h2 className="text-lg font-semibold mb-4">Подключение Fitbit</h2>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
Подключите свой Fitbit аккаунт для отслеживания шагов, этажей и баллов кардионагрузки. Подключите свой Fitbit аккаунт для автоматической синхронизации шагов и этажей с вашими задачами.
</p> </p>
<button <button
onClick={handleConnect} onClick={handleConnect}
@@ -502,18 +626,17 @@ function FitbitIntegration({ onNavigate }) {
</div> </div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900"> <h3 className="text-lg font-semibold mb-3 text-blue-900">Что нужно сделать</h3>
Что нужно сделать
</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700"> <ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Нажмите кнопку "Подключить Fitbit"</li> <li>Нажмите кнопку "Подключить Fitbit"</li>
<li>Авторизуйтесь в Fitbit</li> <li>Авторизуйтесь в Fitbit</li>
<li>Разрешите доступ к данным о физической активности</li> <li>Разрешите доступ к данным о физической активности</li>
<li>Готово! Данные будут синхронизироваться автоматически</li> <li>Настройте привязки к задачам</li>
</ol> </ol>
</div> </div>
</div> </div>
)} )}
{toastMessage && ( {toastMessage && (
<Toast <Toast
message={toastMessage.text} message={toastMessage.text}

View File

@@ -37,20 +37,31 @@ const formatDate = (date) => {
// Названия дней недели // Названия дней недели
const dayNames = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'] const dayNames = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс']
function FullStatistics({ selectedProject, onClearSelection, data, loading, error, onRetry, currentWeekData, onNavigate, todayEntries, todayEntriesLoading, todayEntriesError, onRetryTodayEntries, fetchTodayEntries, activeTab }) { function FullStatistics({ selectedProject, onClearSelection, data, loading, error, onRetry, currentWeekData, onNavigate, todayEntries, todayEntriesLoading, todayEntriesError, onRetryTodayEntries, fetchTodayEntries, fetchCurrentWeekData, activeTab }) {
const [selectedDate, setSelectedDate] = useState(null) const [selectedDate, setSelectedDate] = useState(null)
const prevActiveTabRef = React.useRef(activeTab) const prevActiveTabRef = React.useRef(activeTab)
const componentJustOpenedRef = React.useRef(false) const componentJustOpenedRef = React.useRef(false)
const lastActiveProjectRef = React.useRef(selectedProject)
const [projectChanged, setProjectChanged] = React.useState(false)
// Пересчитываем текущий день и даты недели при каждом открытии экрана
const [now, setNow] = useState(() => new Date())
useEffect(() => {
if (activeTab === 'full') {
setNow(new Date())
}
}, [activeTab])
// Получаем даты текущей недели // Получаем даты текущей недели
const weekDates = getCurrentWeekDates() const weekDates = React.useMemo(() => getCurrentWeekDates(), [now]) // eslint-disable-line react-hooks/exhaustive-deps
// Определяем текущий день (используем useMemo для стабильности) // Определяем текущий день
const today = React.useMemo(() => { const today = React.useMemo(() => {
const date = new Date() const date = new Date(now)
date.setHours(0, 0, 0, 0) date.setHours(0, 0, 0, 0)
return date return date
}, []) }, [now])
// Получаем строковое представление сегодняшней даты // Получаем строковое представление сегодняшней даты
const todayDateStr = React.useMemo(() => formatDate(today), [today]) const todayDateStr = React.useMemo(() => formatDate(today), [today])
@@ -90,23 +101,39 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
// Когда компонент открывается (activeTab становится 'full'), помечаем это // Когда компонент открывается (activeTab становится 'full'), помечаем это
if (activeTab === 'full' && prevActiveTabRef.current !== 'full') { if (activeTab === 'full' && prevActiveTabRef.current !== 'full') {
componentJustOpenedRef.current = true componentJustOpenedRef.current = true
// Проверяем, изменился ли проект с прошлого открытия
if (lastActiveProjectRef.current !== selectedProject) {
setProjectChanged(true)
}
}
// Запоминаем текущий проект при закрытии экрана
if (prevActiveTabRef.current === 'full' && activeTab !== 'full') {
lastActiveProjectRef.current = selectedProject
} }
prevActiveTabRef.current = activeTab prevActiveTabRef.current = activeTab
}, [activeTab]) }, [activeTab, selectedProject])
// Загружаем данные при изменении selectedDate или selectedProject // Загружаем данные при открытии экрана, при изменении selectedDate или selectedProject
useEffect(() => { useEffect(() => {
if (selectedDate && fetchTodayEntries) { if (activeTab === 'full' && selectedDate && fetchTodayEntries) {
// Если компонент только что открылся - используем фоновую загрузку // Если компонент только что открылся
if (componentJustOpenedRef.current) { if (componentJustOpenedRef.current) {
componentJustOpenedRef.current = false componentJustOpenedRef.current = false
// Если проект изменился - показываем загрузку (не фоновую)
if (projectChanged) {
setProjectChanged(false)
lastActiveProjectRef.current = selectedProject
fetchTodayEntries(false, selectedProject, selectedDate)
} else {
// Тот же проект - фоновая загрузка
fetchTodayEntries(true, selectedProject, selectedDate) fetchTodayEntries(true, selectedProject, selectedDate)
}
} else { } else {
// При изменении даты или проекта - используем обычную загрузку (не фоновую) // При изменении даты или проекта - используем обычную загрузку (не фоновую)
fetchTodayEntries(false, selectedProject, selectedDate) fetchTodayEntries(false, selectedProject, selectedDate)
} }
} }
}, [selectedDate, selectedProject, fetchTodayEntries]) }, [activeTab, selectedDate, selectedProject, fetchTodayEntries, projectChanged])
// Обработчик выбора дня // Обработчик выбора дня
const handleDaySelect = useCallback((date) => { const handleDaySelect = useCallback((date) => {
@@ -153,7 +180,7 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
{/* Чипсы дней недели */} {/* Чипсы дней недели */}
{pastDays.length > 0 && ( {pastDays.length > 0 && (
<div className="mt-3 mb-2"> <div className="mt-3 mb-2">
<div className="flex flex-wrap gap-2.5"> <div className="flex flex-nowrap gap-2.5 overflow-x-auto" style={{scrollbarWidth: 'none', msOverflowStyle: 'none'}}>
{pastDays.map((date, index) => { {pastDays.map((date, index) => {
const dateStr = formatDate(date) const dateStr = formatDate(date)
const dayOfWeek = index + 1 // 1 = понедельник const dayOfWeek = index + 1 // 1 = понедельник
@@ -190,7 +217,12 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
loading={todayEntriesLoading} loading={todayEntriesLoading}
error={todayEntriesError} error={todayEntriesError}
onRetry={() => fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate)} onRetry={() => fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate)}
onDelete={() => fetchTodayEntries && fetchTodayEntries(false, selectedProject, selectedDate)} onDelete={() => {
fetchTodayEntries && fetchTodayEntries(true, selectedProject, selectedDate)
fetchCurrentWeekData && fetchCurrentWeekData(true)
onRetry && onRetry(true)
}}
onNavigate={onNavigate}
/> />
</> </>
)} )}

View File

@@ -1,28 +1,11 @@
.loading-error-container { .loading-error-container {
position: fixed; padding: 0.5rem 0;
top: 0;
left: 0;
right: 0;
bottom: 80px; /* Отступ для нижнего бара */
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
}
/* Учитываем safe-area для мобильных устройств */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.loading-error-container {
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
}
} }
.loading-error-content { .loading-error-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; gap: 0.75rem;
text-align: center;
gap: 1rem;
} }
.loading-error-text { .loading-error-text {
@@ -32,6 +15,7 @@
} }
.loading-error-button { .loading-error-button {
width: 100%;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: linear-gradient(to right, #4f46e5, #9333ea); background: linear-gradient(to right, #4f46e5, #9333ea);
color: white; color: white;

View File

@@ -5,7 +5,6 @@ function LoadingError({ onRetry }) {
return ( return (
<div className="loading-error-container"> <div className="loading-error-container">
<div className="loading-error-content"> <div className="loading-error-content">
<div className="loading-error-text">Ошибка, повторите позже</div>
{onRetry && ( {onRetry && (
<button <button
onClick={onRetry} onClick={onRetry}

View File

@@ -120,6 +120,29 @@ function Profile({ onNavigate }) {
</svg> </svg>
</div> </div>
</button> </button>
<button
onClick={() => onNavigate?.('shopping')}
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-indigo-200 group"
>
<div className="flex items-center justify-between">
<span className="text-gray-800 font-medium group-hover:text-indigo-600 transition-colors">
Товары
</span>
<svg
className="w-5 h-5 text-gray-400 group-hover:text-indigo-500 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
</div> </div>
</div> </div>

View File

@@ -27,10 +27,10 @@ import './Integrations.css'
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite) // API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
const PROJECTS_API_URL = '/projects' const PROJECTS_API_URL = '/projects'
const PRIORITY_UPDATE_API_URL = '/project/priority'
const PROJECT_COLOR_API_URL = '/project/color' const PROJECT_COLOR_API_URL = '/project/color'
const PROJECT_MOVE_API_URL = '/project/move' const PROJECT_MOVE_API_URL = '/project/move'
const PROJECT_CREATE_API_URL = '/project/create' const PROJECT_CREATE_API_URL = '/project/create'
const PRIORITIES_CONFIRM_API_URL = '/priorities/confirm'
// Компонент экрана добавления проекта // Компонент экрана добавления проекта
function AddProjectScreen({ onClose, onSuccess, onError }) { function AddProjectScreen({ onClose, onSuccess, onError }) {
@@ -387,12 +387,13 @@ function PrioritySlot({ title, projects, allProjects, onMenuClick, maxItems = nu
) )
} }
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) { function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate, onConfirmed, onClose }) {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const [projectsLoading, setProjectsLoading] = useState(false) const [projectsLoading, setProjectsLoading] = useState(false)
const [projectsError, setProjectsError] = useState(null) const [projectsError, setProjectsError] = useState(null)
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
const [toastMessage, setToastMessage] = useState(null) const [toastMessage, setToastMessage] = useState(null)
const [isSaving, setIsSaving] = useState(false)
// Уведомляем родительский компонент об изменении состояния загрузки // Уведомляем родительский компонент об изменении состояния загрузки
useEffect(() => { useEffect(() => {
@@ -421,9 +422,8 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
const scrollContainerRef = useRef(null) const scrollContainerRef = useRef(null)
const hasFetchedRef = useRef(false) const hasFetchedRef = useRef(false)
const skipNextEffectRef = useRef(false) const lastRefreshTriggerRef = useRef(0)
const lastRefreshTriggerRef = useRef(0) // Отслеживаем последний обработанный refreshTrigger const isLoadingRef = useRef(false)
const isLoadingRef = useRef(false) // Отслеживаем, идет ли сейчас загрузка
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -608,60 +608,30 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
return map return map
}, [lowPriority, maxPriority, mediumPriority]) }, [lowPriority, maxPriority, mediumPriority])
const prevAssignmentsRef = useRef(new Map()) const handleSave = useCallback(async () => {
const initializedAssignmentsRef = useRef(false) const assignments = buildAssignments()
const changes = []
assignments.forEach(({ id, priority }) => {
if (id) changes.push({ id, priority })
})
const sendPriorityChanges = useCallback(async (changes) => { setIsSaving(true)
if (!changes.length) return
try { try {
await authFetch(PRIORITY_UPDATE_API_URL, { const response = await authFetch(PRIORITIES_CONFIRM_API_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes), body: JSON.stringify(changes),
}) })
if (!response.ok) {
const errText = await response.text().catch(() => '')
throw new Error(`Ошибка сохранения (${response.status})${errText ? ': ' + errText : ''}`)
}
if (onConfirmed) onConfirmed()
} catch (e) { } catch (e) {
console.error('Ошибка отправки изменений приоритета', e) setToastMessage({ text: e.message || 'Ошибка сохранения', type: 'error' })
setIsSaving(false)
} }
}, []) }, [authFetch, buildAssignments, onConfirmed])
useEffect(() => {
const current = buildAssignments()
if (!initializedAssignmentsRef.current) {
prevAssignmentsRef.current = current
initializedAssignmentsRef.current = true
return
}
if (skipNextEffectRef.current) {
skipNextEffectRef.current = false
prevAssignmentsRef.current = current
return
}
const prev = prevAssignmentsRef.current
const allKeys = new Set([...prev.keys(), ...current.keys()])
const changes = []
allKeys.forEach(key => {
const prevItem = prev.get(key)
const currItem = current.get(key)
const prevPriority = prevItem?.priority ?? null
const currPriority = currItem?.priority ?? null
const id = currItem?.id ?? prevItem?.id
if (!id) return
if (prevPriority !== currPriority) {
changes.push({ id, priority: currPriority })
}
})
if (changes.length) {
sendPriorityChanges(changes)
}
prevAssignmentsRef.current = current
}, [buildAssignments, sendPriorityChanges])
const findProjectContainer = (projectName) => { const findProjectContainer = (projectName) => {
if (maxPriority.find(p => p.name === projectName)) return 'max' if (maxPriority.find(p => p.name === projectName)) return 'max'
@@ -919,9 +889,9 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
return ( return (
<div className="max-w-2xl mx-auto flex flex-col h-full"> <div className="max-w-2xl mx-auto flex flex-col h-full">
{onNavigate && ( {(onNavigate || onClose) && (
<button <button
onClick={() => window.history.back()} onClick={() => onClose ? onClose() : window.history.back()}
className="close-x-button" className="close-x-button"
title="Закрыть" title="Закрыть"
> >
@@ -1090,6 +1060,40 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
/> />
)} )}
{!(projectsLoading && !maxPriority.length && !mediumPriority.length && !lowPriority.length) && <div style={{
position: 'sticky',
bottom: 0,
left: 0,
right: 0,
padding: '0.75rem 0',
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
background: 'linear-gradient(to top, white 60%, rgba(255,255,255,0))',
display: 'flex',
justifyContent: 'center',
}}>
<button
onClick={handleSave}
disabled={isSaving}
style={{
width: '100%',
maxWidth: '42rem',
padding: '0.875rem',
background: isSaving ? undefined : 'linear-gradient(to right, #10b981, #059669)',
backgroundColor: isSaving ? '#9ca3af' : undefined,
color: 'white',
border: 'none',
borderRadius: '0.5rem',
fontSize: '1rem',
fontWeight: 600,
cursor: isSaving ? 'not-allowed' : 'pointer',
opacity: isSaving ? 0.6 : 1,
transition: 'all 0.2s',
}}
>
{isSaving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>}
{toastMessage && ( {toastMessage && (
<Toast <Toast
message={toastMessage.text} message={toastMessage.text}

View File

@@ -0,0 +1,383 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import ShoppingItemDetail from './ShoppingItemDetail'
import Toast from './Toast'
import './TaskList.css'
import './TaskDetail.css'
import './ShoppingList.css'
// Форматирование даты для отображения
const formatDateForDisplay = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
if (isNaN(date.getTime())) return ''
const now = new Date()
now.setHours(0, 0, 0, 0)
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const diffDays = Math.round((target - now) / (1000 * 60 * 60 * 24))
if (diffDays === 0) return 'Сегодня'
if (diffDays === 1) return 'Завтра'
const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
return `${date.getDate()} ${months[date.getMonth()]}`
}
function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
const { authFetch } = useAuth()
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [selectedItemForDetail, setSelectedItemForDetail] = useState(null)
const [toast, setToast] = useState(null)
const [expandedFuture, setExpandedFuture] = useState({})
const [isCompleting, setIsCompleting] = useState(false)
const historyPushedForDetailRef = useRef(false)
const selectedItemForDetailRef = useRef(null)
const fetchItems = async () => {
if (!purchaseConfigId) return
try {
setLoading(true)
setError(false)
const response = await authFetch(`/api/purchase/items/${purchaseConfigId}`)
if (response.ok) {
const data = await response.json()
setItems(Array.isArray(data) ? data : [])
} else {
setError(true)
}
} catch (err) {
console.error('Error loading purchase items:', err)
setError(true)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchItems()
}, [purchaseConfigId])
const handleRefresh = () => {
fetchItems()
}
const handleClose = () => {
window.history.back()
}
const handleCompleteTask = async () => {
if (!taskId || isCompleting) return
setIsCompleting(true)
try {
const response = await authFetch(`/api/tasks/${taskId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
})
if (response.ok) {
setTimeout(() => window.history.back(), 500)
return
} else {
const errorData = await response.json().catch(() => ({}))
setToast({ message: errorData.error || 'Ошибка выполнения', type: 'error' })
}
} catch (err) {
setToast({ message: 'Ошибка выполнения', type: 'error' })
}
setIsCompleting(false)
}
// Синхронизация refs для диалогов
useEffect(() => {
selectedItemForDetailRef.current = selectedItemForDetail
}, [selectedItemForDetail])
// Пуш в историю при открытии модалок и обработка popstate
useEffect(() => {
if (selectedItemForDetail && !historyPushedForDetailRef.current) {
window.history.pushState({ modalOpen: true, type: 'purchase-detail' }, '', window.location.href)
historyPushedForDetailRef.current = true
} else if (!selectedItemForDetail) {
historyPushedForDetailRef.current = false
}
if (!selectedItemForDetail) return
const handlePopState = () => {
const currentDetail = selectedItemForDetailRef.current
if (currentDetail) {
setSelectedItemForDetail(null)
historyPushedForDetailRef.current = false
}
}
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
}, [selectedItemForDetail])
// Фильтрация и группировка
const groupedItems = useMemo(() => {
const now = new Date()
now.setHours(0, 0, 0, 0)
const todayEnd = new Date(now)
todayEnd.setHours(23, 59, 59, 999)
const groups = {}
items.forEach(item => {
const groupKey = item.group_name || 'Остальные'
if (!groups[groupKey]) {
groups[groupKey] = { active: [], future: [] }
}
if (!item.next_show_at) {
groups[groupKey].future.push(item)
return
}
const showAt = new Date(item.next_show_at)
if (showAt > todayEnd) {
groups[groupKey].future.push(item)
return
}
groups[groupKey].active.push(item)
})
Object.values(groups).forEach(group => {
group.future.sort((a, b) => {
if (!a.next_show_at) return 1
if (!b.next_show_at) return -1
return new Date(a.next_show_at) - new Date(b.next_show_at)
})
})
return groups
}, [items])
const groupNames = useMemo(() => {
const names = Object.keys(groupedItems)
return names.sort((a, b) => {
const groupA = groupedItems[a]
const groupB = groupedItems[b]
const hasActiveA = groupA.active.length > 0
const hasActiveB = groupB.active.length > 0
if (hasActiveA && !hasActiveB) return -1
if (!hasActiveA && hasActiveB) return 1
if (a === 'Остальные') return 1
if (b === 'Остальные') return -1
return a.localeCompare(b, 'ru')
})
}, [groupedItems])
const toggleFuture = (groupName) => {
setExpandedFuture(prev => ({
...prev,
[groupName]: !prev[groupName]
}))
}
const handleCloseDetail = (skipHistoryBack) => {
if (!skipHistoryBack && historyPushedForDetailRef.current) {
window.history.back()
} else {
setSelectedItemForDetail(null)
historyPushedForDetailRef.current = false
}
}
const renderItem = (item) => {
let dateDisplay = null
if (item.next_show_at) {
const itemDate = new Date(item.next_show_at)
const now = new Date()
now.setHours(0, 0, 0, 0)
const target = new Date(itemDate.getFullYear(), itemDate.getMonth(), itemDate.getDate())
if (target > now) {
dateDisplay = formatDateForDisplay(item.next_show_at)
}
}
return (
<div
key={item.id}
className="task-item"
onClick={() => setSelectedItemForDetail(item.id)}
>
<div className="task-item-content">
<div
className="task-checkmark"
onClick={(e) => {
e.stopPropagation()
setSelectedItemForDetail(item.id)
}}
title="Выполнить"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
</svg>
</div>
<div className="task-name-container">
<div className="task-name-wrapper">
<div className="task-name">
{item.name}
{item.estimated_remaining > 0 && (
<span className="task-subtasks-count">
({Math.round(item.estimated_remaining * 10) / 10})
</span>
)}
</div>
{dateDisplay && (
<div className="task-next-show-date">{dateDisplay}</div>
)}
</div>
</div>
</div>
</div>
)
}
return (
<div className="max-w-2xl mx-auto" style={{ paddingBottom: taskId ? '5rem' : '2.5rem' }}>
<button className="close-x-button" onClick={handleClose}></button>
<h2 className="text-2xl font-semibold text-gray-800 mb-6" style={{ marginTop: '1.25rem' }}>{taskName || 'Закупка'}</h2>
{loading && items.length === 0 && (
<div className="shopping-loading">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
)}
{error && (
<div className="shopping-empty">
<p>Ошибка загрузки</p>
<button onClick={handleRefresh} style={{ marginTop: '8px', color: 'var(--accent-color)' }}>
Повторить
</button>
</div>
)}
{!loading && !error && items.length === 0 && (
<div className="shopping-empty">
<p>Нет товаров</p>
</div>
)}
{groupNames.map(groupName => {
const group = groupedItems[groupName]
const hasActive = group.active.length > 0
const hasFuture = group.future.length > 0
const isFutureExpanded = expandedFuture[groupName]
return (
<div key={groupName} className={`project-group ${!hasActive ? 'project-group-no-tasks' : ''}`}>
<div
className={`project-group-header ${hasFuture ? 'project-group-header-clickable' : ''}`}
onClick={hasFuture ? () => toggleFuture(groupName) : undefined}
>
<h3 className={`project-group-title ${!hasActive ? 'project-group-title-empty' : ''}`}>{groupName}</h3>
{hasFuture ? (
<button
className="completed-toggle-header"
onClick={(e) => {
e.stopPropagation()
toggleFuture(groupName)
}}
title={isFutureExpanded ? 'Скрыть ожидающие' : 'Показать ожидающие'}
>
<span className="completed-toggle-icon">
{isFutureExpanded ? '▼' : '▶'}
</span>
</button>
) : (
<div className="completed-toggle-header" style={{ visibility: 'hidden', pointerEvents: 'none' }}>
<span className="completed-toggle-icon"></span>
</div>
)}
</div>
{hasActive && (
<div className="task-group">
{group.active.map(item => renderItem(item))}
</div>
)}
{hasFuture && isFutureExpanded && (
<div className="task-group completed-tasks">
{group.future.map(item => renderItem(item))}
</div>
)}
</div>
)
})}
{/* Кнопка завершения задачи — фиксированная внизу */}
{!loading && !error && taskId && createPortal(
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
padding: '0.75rem 1rem',
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
background: 'linear-gradient(to top, white 60%, rgba(255,255,255,0))',
zIndex: 1500,
display: 'flex',
justifyContent: 'center',
}}>
<button
onClick={handleCompleteTask}
disabled={isCompleting}
style={{
width: '100%',
maxWidth: '42rem',
padding: '0.875rem',
background: 'linear-gradient(to right, #10b981, #059669)',
color: 'white',
border: 'none',
borderRadius: '0.5rem',
fontSize: '1rem',
fontWeight: 600,
cursor: isCompleting ? 'not-allowed' : 'pointer',
opacity: isCompleting ? 0.6 : 1,
transition: 'all 0.2s',
}}
>
{isCompleting ? 'Выполняется...' : 'Завершить'}
</button>
</div>,
document.body
)}
{/* Модалка выполнения */}
{selectedItemForDetail && (
<ShoppingItemDetail
itemId={selectedItemForDetail}
onClose={handleCloseDetail}
onRefresh={handleRefresh}
onItemCompleted={() => setToast({ message: 'Товар выполнен', type: 'success' })}
onNavigate={onNavigate}
/>
)}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
)
}
export default PurchaseScreen

View File

@@ -0,0 +1,460 @@
import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import BoardMembers from './BoardMembers'
import Toast from './Toast'
import './Buttons.css'
import './BoardForm.css'
import './Wishlist.css'
function ShoppingBoardForm({ boardId, onNavigate, onSaved, isActive }) {
const { authFetch } = useAuth()
const [name, setName] = useState('')
const [inviteEnabled, setInviteEnabled] = useState(false)
const [inviteURL, setInviteURL] = useState('')
const [loading, setLoading] = useState(false)
const [loadingBoard, setLoadingBoard] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [copied, setCopied] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
const [isOwner, setIsOwner] = useState(true)
const [isArchived, setIsArchived] = useState(false)
const [showActionMenu, setShowActionMenu] = useState(false)
const actionMenuHistoryRef = useRef(false)
const savedHistoryStateRef = useRef(null)
const isEdit = !!boardId
useEffect(() => {
if (boardId) {
fetchBoard()
}
}, [boardId])
const fetchBoard = async () => {
setLoadingBoard(true)
try {
const res = await authFetch(`/api/shopping/boards/${boardId}`)
if (res.ok) {
const data = await res.json()
setName(data.name)
setInviteEnabled(data.invite_enabled)
setInviteURL(data.invite_url || '')
setIsOwner(data.is_owner)
setIsArchived(data.is_archived || false)
} else {
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка загрузки', type: 'error' })
} finally {
setLoadingBoard(false)
}
}
const handleSave = async () => {
if (!name.trim()) {
setToastMessage({ text: 'Введите название доски', type: 'error' })
return
}
setLoading(true)
try {
const url = boardId
? `/api/shopping/boards/${boardId}`
: '/api/shopping/boards'
const res = await authFetch(url, {
method: boardId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
invite_enabled: inviteEnabled
})
})
if (res.ok) {
const data = await res.json()
if (data.invite_url) {
setInviteURL(data.invite_url)
}
onSaved?.()
if (!boardId) {
onNavigate('shopping', { boardId: data.id })
} else {
onNavigate('shopping', { boardId: boardId })
}
} else {
const err = await res.json()
setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка сохранения', type: 'error' })
} finally {
setLoading(false)
}
}
const generateInviteLink = async () => {
try {
const res = await authFetch(`/api/shopping/boards/${boardId}/regenerate-invite`, {
method: 'POST'
})
if (res.ok) {
const data = await res.json()
setInviteURL(data.invite_url)
setInviteEnabled(true)
}
} catch (err) {
console.error('Error generating invite link:', err)
}
}
const handleCopyLink = () => {
navigator.clipboard.writeText(inviteURL)
setCopied(true)
setToastMessage({ text: 'Ссылка скопирована', type: 'success' })
setTimeout(() => setCopied(false), 2000)
}
const handleToggleInvite = async (enabled) => {
setInviteEnabled(enabled)
if (boardId && enabled && !inviteURL) {
await generateInviteLink()
} else if (boardId) {
try {
await authFetch(`/api/shopping/boards/${boardId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite_enabled: enabled })
})
} catch (err) {
console.error('Error updating invite status:', err)
}
}
}
// Навигация после действия из action menu: убрать обе записи (action menu + board-form)
const navigateBackFromActionMenu = () => {
setShowActionMenu(false)
if (actionMenuHistoryRef.current) {
actionMenuHistoryRef.current = false
window.history.go(-2)
} else {
window.history.back()
}
}
const handleDelete = async () => {
if (!window.confirm('Удалить доску? Все товары на ней будут удалены.')) return
setIsDeleting(true)
try {
const res = await authFetch(`/api/shopping/boards/${boardId}`, {
method: 'DELETE'
})
if (res.ok) {
onSaved?.()
navigateBackFromActionMenu()
} else {
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
setIsDeleting(false)
}
} catch (err) {
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
setIsDeleting(false)
}
}
const handleLeave = async () => {
if (!window.confirm('Покинуть доску? Вы больше не будете видеть её товары.')) return
try {
const res = await authFetch(`/api/shopping/boards/${boardId}/leave`, {
method: 'POST'
})
if (res.ok) {
onSaved?.()
onNavigate('shopping', { boardDeleted: true }, { replace: true })
} else {
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка выхода', type: 'error' })
}
}
const handleArchive = async () => {
if (!window.confirm('Архивировать доску? Она переместится в архив.')) return
try {
const res = await authFetch(`/api/shopping/boards/${boardId}/archive`, {
method: 'POST'
})
if (res.ok) {
onSaved?.()
navigateBackFromActionMenu()
} else {
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка архивации', type: 'error' })
}
}
const handleUnarchive = async () => {
try {
const res = await authFetch(`/api/shopping/boards/${boardId}/unarchive`, {
method: 'POST'
})
if (res.ok) {
onSaved?.()
navigateBackFromActionMenu()
} else {
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка разархивации', type: 'error' })
}
}
const openActionMenu = () => {
setShowActionMenu(true)
savedHistoryStateRef.current = window.history.state
window.history.pushState({ actionMenu: true }, '')
actionMenuHistoryRef.current = true
}
const closeActionMenu = () => {
setShowActionMenu(false)
if (actionMenuHistoryRef.current) {
actionMenuHistoryRef.current = false
window.history.back()
}
}
// Закрыть меню без popstate — заменяем запись в истории на сохранённое состояние
const dismissActionMenu = () => {
setShowActionMenu(false)
if (actionMenuHistoryRef.current) {
actionMenuHistoryRef.current = false
window.history.replaceState(savedHistoryStateRef.current, '', window.location.href)
}
}
// Обработка popstate для закрытия action menu кнопкой назад
useEffect(() => {
const handlePopState = () => {
if (showActionMenu) {
actionMenuHistoryRef.current = false
setShowActionMenu(false)
}
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [showActionMenu])
const handleClose = () => {
window.history.back()
}
if (loadingBoard) {
return (
<div className="board-form">
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
</div>
)
}
return (
<div className="board-form">
<button className="close-x-button" onClick={handleClose}>
</button>
<h2>{isEdit ? 'Настройки доски' : 'Новая доска'}</h2>
<div className="form-card">
<div className="form-group">
<label htmlFor="board-name">Название</label>
<input
id="board-name"
type="text"
className="form-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Название доски"
/>
</div>
{isEdit && (
<>
<div className="form-section">
<h3>Доступ по ссылке</h3>
<label className="toggle-field">
<input
type="checkbox"
checked={inviteEnabled}
onChange={e => handleToggleInvite(e.target.checked)}
/>
<span className="toggle-slider"></span>
<span className="toggle-label">Разрешить присоединение по ссылке</span>
</label>
{inviteEnabled && inviteURL && (
<div className="invite-link-section">
<div className="invite-url-row">
<input
type="text"
className="invite-url-input"
value={inviteURL}
readOnly
/>
<button
className="copy-btn"
onClick={handleCopyLink}
title="Копировать ссылку"
>
{copied ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6L9 17l-5-5"></path>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
)}
</button>
</div>
<p className="invite-hint">
Пользователь, открывший ссылку, сможет присоединиться к доске
</p>
</div>
)}
</div>
<BoardMembers
boardId={boardId}
apiBase="/api/shopping"
onMemberRemoved={() => {
setToastMessage({ text: 'Участник удалён', type: 'success' })
}}
/>
</>
)}
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
{isActive ? createPortal(
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
padding: '0.75rem 1rem',
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
background: 'linear-gradient(to top, white 60%, rgba(255,255,255,0))',
zIndex: 1500,
display: 'flex',
justifyContent: 'center',
gap: '0.75rem',
}}>
<button
onClick={handleSave}
disabled={loading || isDeleting || !name.trim()}
style={{
flex: 1,
maxWidth: '42rem',
padding: '0.875rem',
background: (loading || !name.trim()) ? undefined : 'linear-gradient(to right, #10b981, #059669)',
backgroundColor: (loading || !name.trim()) ? '#9ca3af' : undefined,
color: 'white',
border: 'none',
borderRadius: '0.5rem',
fontSize: '1rem',
fontWeight: 600,
cursor: (loading || isDeleting || !name.trim()) ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1,
transition: 'all 0.2s',
}}
>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
{isEdit && (
<button
type="button"
onClick={openActionMenu}
disabled={loading || isDeleting}
style={{
width: '52px',
height: '52px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
color: '#059669',
border: '2px solid #059669',
borderRadius: '0.5rem',
fontSize: '1.25rem',
fontWeight: 700,
cursor: (loading || isDeleting) ? 'not-allowed' : 'pointer',
lineHeight: 1,
flexShrink: 0,
padding: 0,
boxSizing: 'border-box',
transition: 'all 0.2s',
}}
title="Действия"
>
</button>
)}
</div>,
document.body
) : null}
{showActionMenu && createPortal(
<div className="wishlist-modal-overlay" style={{ zIndex: 2000 }} onClick={closeActionMenu}>
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
<div className="wishlist-modal-header">
<h3>{name}</h3>
</div>
<div className="wishlist-modal-actions">
{isArchived ? (
<button className="wishlist-modal-copy" onClick={handleUnarchive}>
Разархивировать
</button>
) : (
<button className="wishlist-modal-copy" onClick={handleArchive}>
Архивировать
</button>
)}
<button className="wishlist-modal-delete" onClick={handleDelete}>
Удалить
</button>
</div>
</div>
</div>,
document.body
)}
</div>
)
}
export default ShoppingBoardForm

View File

@@ -0,0 +1,149 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './BoardJoinPreview.css'
function ShoppingBoardJoinPreview({ inviteToken, onNavigate }) {
const { authFetch, user } = useAuth()
const [board, setBoard] = useState(null)
const [loading, setLoading] = useState(true)
const [joining, setJoining] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (inviteToken) {
fetchBoardInfo()
}
}, [inviteToken])
const fetchBoardInfo = async () => {
try {
const res = await authFetch(`/api/shopping/invite/${inviteToken}`)
if (res.ok) {
setBoard(await res.json())
} else {
const err = await res.json()
setError(err.error || 'Ссылка недействительна или устарела')
}
} catch (err) {
setError('Ошибка загрузки')
} finally {
setLoading(false)
}
}
const handleJoin = async () => {
if (!user) {
sessionStorage.setItem('pendingShoppingInviteToken', inviteToken)
onNavigate('login')
return
}
setJoining(true)
setError('')
try {
const res = await authFetch(`/api/shopping/invite/${inviteToken}/join`, {
method: 'POST'
})
if (res.ok) {
const data = await res.json()
onNavigate('shopping', { boardId: data.board.id })
} else {
const err = await res.json()
setError(err.error || 'Ошибка при присоединении')
}
} catch (err) {
setError('Ошибка при присоединении')
} finally {
setJoining(false)
}
}
const handleGoBack = () => {
onNavigate('shopping')
}
if (loading) {
return (
<div className="board-join-preview">
<div className="preview-loading">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
<p>Загрузка...</p>
</div>
</div>
)
}
if (error && !board) {
return (
<div className="board-join-preview">
<div className="preview-card error-card">
<div className="error-icon">X</div>
<h2>Ошибка</h2>
<p className="error-text">{error}</p>
<button className="back-btn" onClick={handleGoBack}>
Вернуться к товарам
</button>
</div>
</div>
)
}
return (
<div className="board-join-preview">
<div className="preview-card">
<h2>Приглашение на доску</h2>
<div className="board-info">
<div className="board-name">{board.name}</div>
<div className="board-owner">
<span className="label">Владелец:</span>
<span className="value">{board.owner_name}</span>
</div>
{board.member_count > 0 && (
<div className="board-members">
<span className="label">Участников:</span>
<span className="value">{board.member_count}</span>
</div>
)}
</div>
{error && (
<div className="join-error">{error}</div>
)}
{user ? (
<button
className="join-btn"
onClick={handleJoin}
disabled={joining}
>
{joining ? (
<>
<span className="spinner-small"></span>
<span>Присоединение...</span>
</>
) : (
<span>Присоединиться</span>
)}
</button>
) : (
<div className="login-prompt">
<p>Для присоединения необходимо войти в аккаунт</p>
<button className="login-btn" onClick={() => onNavigate('login')}>
Войти
</button>
</div>
)}
<button className="cancel-link" onClick={handleGoBack}>
Отмена
</button>
</div>
</div>
)
}
export default ShoppingBoardJoinPreview

View File

@@ -0,0 +1,395 @@
import React, { useState, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import Toast from './Toast'
import { DayPicker } from 'react-day-picker'
import { ru } from 'react-day-picker/locale'
import 'react-day-picker/style.css'
import './TaskDetail.css'
import './TaskList.css'
const formatDateToLocal = (date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const formatShortDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr + 'T00:00:00')
if (isNaN(date.getTime())) return ''
const now = new Date()
now.setHours(0, 0, 0, 0)
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const diffDays = Math.floor((target - now) / (1000 * 60 * 60 * 24))
if (diffDays === 0) return 'Сегодня'
if (diffDays === 1) return 'Завтра'
const monthNames = ['янв.', 'фев.', 'мар.', 'апр.', 'мая', 'июн.', 'июл.', 'авг.', 'сен.', 'окт.', 'ноя.', 'дек.']
return `${target.getDate()} ${monthNames[target.getMonth()]}`
}
function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNavigate }) {
const { authFetch } = useAuth()
const [item, setItem] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [volumeRemaining, setVolumeRemaining] = useState('')
const [volumePurchased, setVolumePurchased] = useState('')
const [selectedDate, setSelectedDate] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
const fetchItem = useCallback(async () => {
try {
setLoading(true)
setError(null)
const response = await authFetch(`/api/shopping/items/${itemId}`)
if (!response.ok) throw new Error('Ошибка загрузки товара')
const data = await response.json()
setItem(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [itemId, authFetch])
useEffect(() => {
if (itemId) {
fetchItem()
} else {
setItem(null)
setLoading(true)
setError(null)
setVolumeRemaining('')
setVolumePurchased('')
setSelectedDate('')
}
}, [itemId, fetchItem])
const calculateDate = useCallback((itm, remaining, purchased) => {
if (itm.daily_consumption > 0) {
const rem = remaining.trim() ? parseFloat(remaining) : (itm.estimated_remaining ?? 0)
const pur = purchased.trim() ? parseFloat(purchased) : 0
if (!isNaN(rem) && !isNaN(pur) && rem >= 0) {
const total = rem + pur
const daily = itm.daily_consumption
const daysUntilEmpty = total / daily
const daysUntilShow = Math.max(1, Math.ceil(daysUntilEmpty) - 3)
const target = new Date()
target.setHours(0, 0, 0, 0)
target.setDate(target.getDate() + daysUntilShow)
return formatDateToLocal(target)
}
}
const tomorrow = new Date()
tomorrow.setHours(0, 0, 0, 0)
tomorrow.setDate(tomorrow.getDate() + 1)
return formatDateToLocal(tomorrow)
}, [])
// Auto-update calendar when volumes change
useEffect(() => {
if (!item) return
setSelectedDate(calculateDate(item, volumeRemaining, volumePurchased))
}, [volumeRemaining, volumePurchased, item, calculateDate])
const handleSubmit = async (dateOverride) => {
if (!item) return
setIsSubmitting(true)
try {
const remaining = volumeRemaining.trim() ? parseFloat(volumeRemaining) : (item.estimated_remaining ?? 0)
const purchased = volumePurchased.trim() ? parseFloat(volumePurchased) : 0
const date = dateOverride || selectedDate
if (isNaN(remaining)) throw new Error('Неверное значение остатка')
if (isNaN(purchased)) throw new Error('Неверное значение докупки')
if (purchased > 0) {
const response = await authFetch(`/api/shopping/items/${itemId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ volume_remaining: remaining, volume_purchased: purchased }),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при выполнении')
}
onItemCompleted?.()
} else {
const payload = {
next_show_at: date ? new Date(date + 'T00:00:00').toISOString() : null,
}
if (volumeRemaining.trim()) {
payload.volume_remaining = remaining
}
const response = await authFetch(`/api/shopping/items/${itemId}/postpone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) throw new Error('Ошибка переноса')
}
onRefresh?.()
onClose?.()
} catch (err) {
setToastMessage({ text: err.message || 'Ошибка', type: 'error' })
} finally {
setIsSubmitting(false)
}
}
const byRemainingDate = item && item.daily_consumption > 0 ? calculateDate(item, volumeRemaining, volumePurchased) : null
const handleByRemainingClick = () => {
if (byRemainingDate) submitWithDate(byRemainingDate)
}
const handleTodayClick = () => {
submitWithDate(formatDateToLocal(new Date()))
}
const handleTomorrowClick = () => {
const tomorrow = new Date()
tomorrow.setHours(0, 0, 0, 0)
tomorrow.setDate(tomorrow.getDate() + 1)
submitWithDate(formatDateToLocal(tomorrow))
}
const handleDayClick = (date) => {
if (date) submitWithDate(formatDateToLocal(date))
}
const submitWithDate = (dateStr) => {
setSelectedDate(dateStr)
handleSubmit(dateStr)
}
if (!itemId) return null
const today = new Date()
today.setHours(0, 0, 0, 0)
const modalContent = (
<div className="task-detail-modal-overlay" onClick={() => onClose?.()}>
<div className="task-detail-modal task-detail-modal-fit" onClick={(e) => e.stopPropagation()}>
<div className="task-detail-modal-header">
<h2
className="task-detail-title"
onClick={item ? () => {
onClose?.(true)
onNavigate?.('shopping-item-form', { itemId: itemId, boardId: item.board_id })
} : undefined}
style={{ cursor: item ? 'pointer' : 'default' }}
>
{loading ? 'Загрузка...' : error ? 'Ошибка' : item ? (
<>
{item.name}
<svg
className="task-detail-edit-icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
</>
) : 'Товар'}
</h2>
<button onClick={() => onClose?.()} className="task-detail-close-button">
</button>
</div>
<div className="task-detail-modal-content">
{loading && (
<div className="loading">Загрузка...</div>
)}
{error && !loading && (
<LoadingError onRetry={fetchItem} />
)}
{!loading && !error && item && (
<>
<div className="shopping-item-description-card">
<div className="shopping-item-description">
{item.description ? (
item.description.split(/(https?:\/\/[^\s<>"'`,;!)\]]+)/gi).map((part, i) => {
if (/^https?:\/\//i.test(part)) {
let host
try {
host = new URL(part).host.replace(/^www\./, '')
} catch {
host = 'Открыть ссылку'
}
return (
<a key={i} href={part} target="_blank" rel="noopener noreferrer" className="shopping-item-description-link">
{host}
</a>
)
}
return <span key={i}>{part}</span>
})
) : (
<span style={{ color: '#9ca3af' }}>Описание отсутствует</span>
)}
</div>
<button
type="button"
className="shopping-item-history-button"
onClick={() => {
onClose?.(true)
onNavigate?.('shopping-item-history', { itemId: itemId })
}}
title="История"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
</button>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end', marginBottom: '0.75rem' }}>
<div style={{ flex: 1 }}>
<label className="progression-label">Остаток</label>
<div className="progression-input-wrapper">
<input
type="number"
step="any"
value={volumeRemaining}
onChange={(e) => setVolumeRemaining(e.target.value)}
placeholder={item.estimated_remaining != null ? Math.round(item.estimated_remaining * 10) / 10 + '' : '0'}
className="progression-input"
/>
{volumeRemaining && (
<button
type="button"
onClick={() => setVolumeRemaining('')}
style={{
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
color: '#9ca3af',
cursor: 'pointer',
fontSize: '1.1rem',
padding: '4px',
lineHeight: 1,
}}
>
</button>
)}
</div>
</div>
<div style={{ flex: 1 }}>
<label className="progression-label">Докуплено</label>
<div className="progression-input-wrapper">
<input
type="number"
step="any"
value={volumePurchased}
onChange={(e) => setVolumePurchased(e.target.value)}
placeholder="0"
className="progression-input"
/>
<div className="progression-controls-capsule">
<button
type="button"
className="progression-control-btn progression-control-minus"
onClick={() => {
const current = volumePurchased.trim() ? parseFloat(volumePurchased) : 0
const step = item.volume_base || 1
setVolumePurchased(Math.max(0, current - step).toString())
}}
>
</button>
<button
type="button"
className="progression-control-btn progression-control-plus"
onClick={() => {
const current = volumePurchased.trim() ? parseFloat(volumePurchased) : 0
const step = item.volume_base || 1
setVolumePurchased((current + step).toString())
}}
>
+
</button>
</div>
</div>
</div>
</div>
<div className="task-postpone-calendar">
<DayPicker
mode="single"
selected={selectedDate ? new Date(selectedDate + 'T00:00:00') : undefined}
onSelect={(date) => { if (date) setSelectedDate(formatDateToLocal(date)) }}
onDayClick={handleDayClick}
disabled={{ before: today }}
locale={ru}
/>
</div>
<div className="task-postpone-quick-buttons">
{item.daily_consumption > 0 && byRemainingDate && (
<button
onClick={handleByRemainingClick}
className="task-postpone-quick-button task-postpone-quick-button-primary"
disabled={isSubmitting}
>
{formatShortDate(byRemainingDate)}
</button>
)}
{item.next_show_at && new Date(item.next_show_at) > today && (
<button
onClick={handleTodayClick}
className="task-postpone-quick-button"
disabled={isSubmitting}
>
Сегодня
</button>
)}
<button
onClick={handleTomorrowClick}
className="task-postpone-quick-button"
disabled={isSubmitting}
>
Завтра
</button>
</div>
</>
)}
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
</div>
)
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent
}
export default ShoppingItemDetail

View File

@@ -0,0 +1,19 @@
.shopping-item-form {
padding: 20px;
max-width: 600px;
margin: 0 auto;
position: relative;
padding-bottom: 5rem;
}
.shopping-item-form h2 {
font-size: 1.5rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 24px;
}
.shopping-item-form .repetition-label {
font-size: 1rem;
white-space: nowrap;
}

View File

@@ -0,0 +1,369 @@
import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import Toast from './Toast'
import SubmitButton from './SubmitButton'
import './Wishlist.css'
import './ShoppingItemForm.css'
function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, isActive }) {
const { authFetch } = useAuth()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [groupName, setGroupName] = useState('')
const [groupSuggestions, setGroupSuggestions] = useState([])
const [volumeBase, setVolumeBase] = useState('')
const [loading, setLoading] = useState(false)
const [loadingItem, setLoadingItem] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isCopying, setIsCopying] = useState(false)
const [showActionMenu, setShowActionMenu] = useState(false)
const actionMenuHistoryRef = useRef(false)
const [toastMessage, setToastMessage] = useState(null)
const isEdit = !!itemId
useEffect(() => {
loadGroupSuggestions()
}, [])
useEffect(() => {
if (itemId) {
fetchItem()
}
}, [itemId])
const loadGroupSuggestions = async () => {
try {
const res = await authFetch('/api/shopping/groups')
if (res.ok) {
const data = await res.json()
setGroupSuggestions(Array.isArray(data) ? data : [])
}
} catch (err) {
console.error('Error loading group suggestions:', err)
}
}
const fetchItem = async () => {
setLoadingItem(true)
try {
const res = await authFetch(`/api/shopping/items/${itemId}`)
if (res.ok) {
const data = await res.json()
setName(data.name)
setDescription(data.description || '')
setGroupName(data.group_name || '')
if (data.volume_base && data.volume_base !== 1) {
setVolumeBase(data.volume_base.toString())
}
} else {
setToastMessage({ text: 'Ошибка загрузки товара', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка загрузки', type: 'error' })
} finally {
setLoadingItem(false)
}
}
const handleSave = async () => {
if (!name.trim()) {
setToastMessage({ text: 'Введите название товара', type: 'error' })
return
}
setLoading(true)
try {
const vb = volumeBase.trim() ? parseFloat(volumeBase.trim()) : null
const payload = {
name: name.trim(),
description: description.trim() || null,
group_name: groupName.trim() || null,
volume_base: vb && vb > 0 ? vb : null,
}
let url, method
if (isEdit) {
url = `/api/shopping/items/${itemId}`
method = 'PUT'
} else {
url = `/api/shopping/boards/${boardId}/items`
method = 'POST'
}
const res = await authFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (res.ok) {
onSaved?.()
window.history.back()
} else {
const err = await res.json()
setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка сохранения', type: 'error' })
} finally {
setLoading(false)
}
}
const openActionMenu = () => {
setShowActionMenu(true)
window.history.pushState({ actionMenu: true }, '')
actionMenuHistoryRef.current = true
}
const closeActionMenu = () => {
setShowActionMenu(false)
if (actionMenuHistoryRef.current) {
actionMenuHistoryRef.current = false
window.history.back()
}
}
// Обработка popstate для закрытия action menu кнопкой назад
useEffect(() => {
const handlePopState = () => {
if (showActionMenu) {
actionMenuHistoryRef.current = false
setShowActionMenu(false)
}
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [showActionMenu])
const handleDelete = async () => {
if (!itemId) return
setShowActionMenu(false)
if (actionMenuHistoryRef.current) {
actionMenuHistoryRef.current = false
window.history.go(-2)
} else {
window.history.back()
}
setIsDeleting(true)
try {
const res = await authFetch(`/api/shopping/items/${itemId}`, { method: 'DELETE' })
if (res.ok) {
onSaved?.()
}
} catch (err) {
console.error('Error deleting item:', err)
}
}
const handleCopy = async () => {
if (!itemId) return
setShowActionMenu(false)
if (actionMenuHistoryRef.current) {
actionMenuHistoryRef.current = false
window.history.go(-2)
} else {
window.history.back()
}
setIsCopying(true)
try {
const res = await authFetch(`/api/shopping/items/${itemId}/copy`, { method: 'POST' })
if (!res.ok) {
const errorText = await res.text().catch(() => '')
throw new Error(errorText || 'Ошибка при копировании товара')
}
onSaved?.()
} catch (err) {
console.error('Error copying item:', err)
}
}
const handleClose = () => {
window.history.back()
}
if (loadingItem) {
return (
<div className="shopping-item-form">
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
</div>
)
}
return (
<>
<div className="shopping-item-form">
<button className="close-x-button" onClick={handleClose}>
</button>
<h2>{isEdit ? 'Редактировать товар' : 'Новый товар'}</h2>
<div className="form-card">
<div className="form-group">
<label htmlFor="item-name">Название</label>
<input
id="item-name"
type="text"
className="form-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Название товара"
/>
</div>
<div className="form-group">
<label htmlFor="item-description">Описание</label>
<textarea
id="item-description"
className="form-input"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Описание товара"
rows={3}
/>
</div>
<div className="form-group">
<label htmlFor="item-group">Группа</label>
<input
id="item-group"
type="text"
className="form-input"
value={groupName}
onChange={e => setGroupName(e.target.value)}
placeholder="Группа товара"
list="shopping-group-suggestions"
/>
<datalist id="shopping-group-suggestions">
{groupSuggestions.map((g, i) => (
<option key={i} value={g} />
))}
</datalist>
</div>
<div className="form-group">
<label htmlFor="item-volume">Шаги объёма</label>
<input
id="item-volume"
type="number"
step="any"
min="0"
className="form-input"
value={volumeBase}
onChange={e => setVolumeBase(e.target.value)}
placeholder="1"
/>
</div>
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
{isActive ? createPortal(
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
padding: '0.75rem 1rem',
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
background: 'linear-gradient(to top, white 60%, rgba(255,255,255,0))',
zIndex: 1500,
display: 'flex',
justifyContent: 'center',
gap: '0.75rem',
}}>
<button
onClick={handleSave}
disabled={loading || isDeleting || isCopying || !name.trim()}
style={{
flex: 1,
maxWidth: '42rem',
padding: '0.875rem',
background: loading ? undefined : 'linear-gradient(to right, #10b981, #059669)',
backgroundColor: loading ? '#9ca3af' : undefined,
color: 'white',
border: 'none',
borderRadius: '0.5rem',
fontSize: '1rem',
fontWeight: 600,
cursor: (loading || isDeleting || isCopying) ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1,
transition: 'all 0.2s',
}}
>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
{isEdit && (
<button
type="button"
onClick={openActionMenu}
disabled={loading || isDeleting || isCopying}
style={{
width: '52px',
height: '52px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
color: '#059669',
border: '2px solid #059669',
borderRadius: '0.5rem',
fontSize: '1.25rem',
fontWeight: 700,
cursor: (loading || isDeleting || isCopying) ? 'not-allowed' : 'pointer',
lineHeight: 1,
flexShrink: 0,
padding: 0,
boxSizing: 'border-box',
transition: 'all 0.2s',
}}
title="Действия"
>
</button>
)}
</div>,
document.body
) : null}
{showActionMenu && createPortal(
<div className="wishlist-modal-overlay" style={{ zIndex: 2000 }} onClick={closeActionMenu}>
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
<div className="wishlist-modal-header">
<h3>{name}</h3>
</div>
<div className="wishlist-modal-actions">
<button className="wishlist-modal-copy" onClick={handleCopy}>
Копировать
</button>
<button className="wishlist-modal-delete" onClick={handleDelete}>
Удалить
</button>
</div>
</div>
</div>,
document.body
)}
</>
)
}
export default ShoppingItemForm

View File

@@ -0,0 +1,157 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import './Integrations.css'
function ShoppingItemHistory({ itemId, onNavigate }) {
const { authFetch } = useAuth()
const [records, setRecords] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const fetchRecords = useCallback(async () => {
if (!itemId) return
try {
setLoading(true)
setError(null)
const response = await authFetch(`/api/shopping/items/${itemId}/volume-records`)
if (!response.ok) {
throw new Error('Ошибка загрузки истории')
}
const data = await response.json()
setRecords(Array.isArray(data) ? data : [])
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [itemId, authFetch])
useEffect(() => {
fetchRecords()
}, [fetchRecords])
const formatDate = (dateStr) => {
const date = new Date(dateStr)
const day = date.getDate()
const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
const month = months[date.getMonth()]
const year = date.getFullYear()
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${day} ${month} ${year}, ${hours}:${minutes}`
}
const formatVolume = (volume) => {
if (volume == null) return '—'
const rounded = Math.round(volume * 10) / 10
return rounded.toString()
}
const getActionLabel = (actionType) => {
switch (actionType) {
case 'purchase': return 'Закупка'
case 'postpone': return 'Перенос'
case 'create': return 'Создание'
default: return actionType
}
}
const getActionColor = (actionType) => {
switch (actionType) {
case 'purchase': return '#059669'
case 'postpone': return '#d97706'
case 'create': return '#6b7280'
default: return '#6b7280'
}
}
return (
<div className="max-w-2xl mx-auto">
{onNavigate && (
<button
onClick={() => window.history.back()}
className="close-x-button"
title="Закрыть"
>
</button>
)}
{loading ? (
<div className="fixed inset-0 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
) : error ? (
<LoadingError onRetry={fetchRecords} />
) : records.length === 0 ? (
<>
<h2 className="text-2xl font-semibold text-gray-800 mb-6" style={{ marginTop: '1.25rem' }}>История</h2>
<div className="flex justify-center items-center py-16">
<div className="text-gray-500 text-lg">История пуста</div>
</div>
</>
) : (
<div>
<h2 className="text-2xl font-semibold text-gray-800 mb-6" style={{ marginTop: '1.25rem' }}>История</h2>
<div className="space-y-3">
{records.map((record) => {
const total = (record.volume_remaining || 0) + (record.volume_purchased || 0)
return (
<div
key={record.id}
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200"
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: getActionColor(record.action_type), fontWeight: 600, fontSize: '0.9rem' }}>
{getActionLabel(record.action_type)}
</span>
<span className="text-xs text-gray-500">
{formatDate(record.created_at)}
</span>
</div>
{record.action_type === 'purchase' && (
<div className="text-gray-800 mt-2">
{formatVolume(record.volume_remaining)} {formatVolume(total)}
</div>
)}
{record.action_type === 'postpone' && (
<div className="text-gray-800 mt-2">
{record.next_show_at ? (() => {
const d = new Date(record.next_show_at)
const day = d.getDate()
const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
return `на ${day} ${months[d.getMonth()]}`
})() : 'Без даты'}
{record.volume_remaining != null && (
<span className="text-gray-500" style={{ marginLeft: '8px' }}>
(остаток: {formatVolume(record.volume_remaining)})
</span>
)}
</div>
)}
{record.action_type === 'create' && (
<div className="text-gray-500 text-sm mt-1">
Остаток: {formatVolume(record.volume_remaining)}
</div>
)}
{record.daily_consumption != null && record.daily_consumption > 0 && (
<div className="text-gray-500 text-xs mt-1">
~{formatVolume(record.daily_consumption)}/день
</div>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)
}
export default ShoppingItemHistory

View File

@@ -0,0 +1,72 @@
.shopping-list {
max-width: 42rem;
margin: 0 auto;
padding-bottom: 2.5rem;
}
.shopping-header {
display: flex;
align-items: flex-start;
gap: 8px;
max-width: 42rem;
margin: 0 auto;
padding-top: 1rem;
}
.shopping-header .board-selector {
flex: 1;
max-width: none;
margin: 0;
padding-left: 0;
padding-right: 0;
}
.shopping-close-btn {
flex-shrink: 0;
background: rgba(255, 255, 255, 0.9);
border: none;
font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s, color 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.shopping-close-btn:hover {
background-color: #ffffff;
color: #2c3e50;
}
.shopping-header .board-pill {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-color: transparent;
}
.shopping-empty {
text-align: center;
padding: 3rem 1rem;
color: #94a3b8;
}
.shopping-empty p:first-child {
font-size: 1.125rem;
font-weight: 600;
color: #64748b;
margin-bottom: 0.5rem;
}
.shopping-empty-hint {
font-size: 0.875rem;
}
.shopping-loading {
display: flex;
justify-content: center;
padding: 3rem 0;
}

View File

@@ -0,0 +1,660 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import BoardSelector from './BoardSelector'
import ShoppingItemDetail from './ShoppingItemDetail'
import LoadingError from './LoadingError'
import Toast from './Toast'
import './TaskList.css'
import './TaskDetail.css'
import './ShoppingList.css'
import './Wishlist.css'
const BOARDS_CACHE_KEY = 'shopping_boards_cache'
const ITEMS_CACHE_KEY = 'shopping_items_cache'
const SELECTED_BOARD_KEY = 'shopping_selected_board_id'
// Форматирование даты для отображения
const formatDateForDisplay = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
if (isNaN(date.getTime())) return ''
const now = new Date()
now.setHours(0, 0, 0, 0)
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const diffDays = Math.floor((target - now) / (1000 * 60 * 60 * 24))
if (diffDays === 0) return 'Сегодня'
if (diffDays === 1) return 'Завтра'
if (diffDays === -1) return 'Вчера'
if (diffDays > 0 && diffDays <= 7) {
const dayNames = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']
return dayNames[target.getDay()]
}
const monthNames = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
if (target.getFullYear() === now.getFullYear()) {
return `${target.getDate()} ${monthNames[target.getMonth()]}`
}
return `${target.getDate()} ${monthNames[target.getMonth()]} ${target.getFullYear()}`
}
function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initialBoardId = null, boardDeleted = false }) {
const { authFetch } = useAuth()
const [boards, setBoards] = useState([])
const getInitialBoardId = () => {
if (initialBoardId) return initialBoardId
try {
const saved = localStorage.getItem(SELECTED_BOARD_KEY)
if (saved) {
const boardId = parseInt(saved, 10)
if (!isNaN(boardId)) return boardId
}
} catch (err) {}
return null
}
const hasBoardsCache = () => {
try {
const cached = localStorage.getItem(BOARDS_CACHE_KEY)
if (cached) {
const data = JSON.parse(cached)
return !!(data.boards && data.boards.length >= 0)
}
} catch (err) {}
return false
}
const [selectedBoardId, setSelectedBoardIdState] = useState(getInitialBoardId)
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [boardsLoading, setBoardsLoading] = useState(!hasBoardsCache())
const [error, setError] = useState('')
const [selectedItemForDetail, setSelectedItemForDetail] = useState(null)
const [toast, setToast] = useState(null)
const [showBoardActionMenu, setShowBoardActionMenu] = useState(false)
const initialFetchDoneRef = useRef(false)
const prevIsActiveRef = useRef(isActive)
const itemsAbortRef = useRef(null)
// Refs для закрытия диалогов кнопкой "Назад"
const historyPushedForDetailRef = useRef(false)
const selectedItemForDetailRef = useRef(selectedItemForDetail)
const setSelectedBoardId = (boardId) => {
setSelectedBoardIdState(boardId)
try {
if (boardId) {
localStorage.setItem(SELECTED_BOARD_KEY, String(boardId))
} else {
localStorage.removeItem(SELECTED_BOARD_KEY)
}
} catch (err) {}
}
// Загрузка досок
const fetchBoards = async (showLoading = true, preferBoardId = null) => {
if (showLoading) setBoardsLoading(true)
try {
const res = await authFetch('/api/shopping/boards')
if (res.ok) {
const data = await res.json()
const boardsList = Array.isArray(data) ? data : []
setBoards(boardsList)
try {
localStorage.setItem(BOARDS_CACHE_KEY, JSON.stringify({ boards: boardsList }))
} catch (err) {}
const effectiveBoardId = preferBoardId || selectedBoardId
if (boardDeleted || !boardsList.some(b => b.id === effectiveBoardId)) {
if (boardsList.length > 0) {
setSelectedBoardId(boardsList[0].id)
} else {
setSelectedBoardId(null)
}
} else if (preferBoardId && preferBoardId !== selectedBoardId) {
setSelectedBoardId(preferBoardId)
}
}
} catch (err) {
setError('Ошибка загрузки досок')
} finally {
setBoardsLoading(false)
}
}
// Загрузка товаров
const fetchItems = async (boardId, { background = false } = {}) => {
if (!boardId) return
// Отменяем предыдущий запрос если есть
if (itemsAbortRef.current) {
itemsAbortRef.current.abort()
}
const abortController = new AbortController()
itemsAbortRef.current = abortController
if (!background) {
// Показываем loading только если нет кеша
try {
const cached = localStorage.getItem(`${ITEMS_CACHE_KEY}_${boardId}`)
if (cached) {
setItems(JSON.parse(cached) || [])
setLoading(false)
} else {
setLoading(true)
}
} catch (err) {
setLoading(true)
}
}
setError('')
try {
const res = await authFetch(`/api/shopping/boards/${boardId}/items`, {
signal: abortController.signal
})
if (abortController.signal.aborted) return
if (res.ok) {
const data = await res.json()
if (abortController.signal.aborted) return
setItems(Array.isArray(data) ? data : [])
try {
localStorage.setItem(`${ITEMS_CACHE_KEY}_${boardId}`, JSON.stringify(data))
} catch (err) {}
} else {
setError('Ошибка загрузки товаров')
}
} catch (err) {
if (err.name === 'AbortError') return
setError('Ошибка загрузки товаров')
} finally {
if (!abortController.signal.aborted) {
setLoading(false)
}
}
}
// Загрузка досок из кэша
useEffect(() => {
try {
const cached = localStorage.getItem(BOARDS_CACHE_KEY)
if (cached) {
const data = JSON.parse(cached)
if (data.boards) setBoards(data.boards)
}
} catch (err) {}
}, [])
// Начальная загрузка
useEffect(() => {
const hasCache = hasBoardsCache()
fetchBoards(!hasCache, initialBoardId)
initialFetchDoneRef.current = true
}, [])
// Загрузка при смене доски
useEffect(() => {
if (selectedBoardId) {
fetchItems(selectedBoardId)
} else {
setItems([])
setLoading(false)
}
// Отменяем запрос при размонтировании или смене доски
return () => {
if (itemsAbortRef.current) {
itemsAbortRef.current.abort()
}
}
}, [selectedBoardId])
// Рефреш при возврате на таб
useEffect(() => {
if (isActive && !prevIsActiveRef.current && initialFetchDoneRef.current) {
fetchBoards(false)
if (selectedBoardId) fetchItems(selectedBoardId, { background: true })
}
prevIsActiveRef.current = isActive
}, [isActive])
// Рефреш по триггеру
useEffect(() => {
if (refreshTrigger > 0) {
fetchBoards(false)
if (selectedBoardId) fetchItems(selectedBoardId, { background: true })
}
}, [refreshTrigger])
// Синхронизация refs для диалогов
useEffect(() => {
selectedItemForDetailRef.current = selectedItemForDetail
}, [selectedItemForDetail])
// Закрытие диалогов кнопкой "Назад" (browser history API)
useEffect(() => {
if (selectedItemForDetail && !historyPushedForDetailRef.current) {
window.history.pushState({ modalOpen: true, type: 'shopping-detail' }, '', window.location.href)
historyPushedForDetailRef.current = true
} else if (!selectedItemForDetail) {
historyPushedForDetailRef.current = false
}
if (!selectedItemForDetail) return
const handlePopState = () => {
const currentDetail = selectedItemForDetailRef.current
if (currentDetail) {
setSelectedItemForDetail(null)
historyPushedForDetailRef.current = false
}
}
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
}, [selectedItemForDetail])
// Фильтрация и группировка на клиенте
const groupedItems = useMemo(() => {
const now = new Date()
now.setHours(0, 0, 0, 0)
const todayEnd = new Date(now)
todayEnd.setHours(23, 59, 59, 999)
const groups = {}
items.forEach(item => {
const groupKey = item.group_name || 'Остальные'
if (!groups[groupKey]) {
groups[groupKey] = { active: [], future: [] }
}
if (!item.next_show_at) {
groups[groupKey].future.push(item)
return
}
const showAt = new Date(item.next_show_at)
if (showAt > todayEnd) {
groups[groupKey].future.push(item)
return
}
groups[groupKey].active.push(item)
})
// Сортируем future по next_show_at ASC
Object.values(groups).forEach(group => {
group.future.sort((a, b) => {
if (!a.next_show_at) return 1
if (!b.next_show_at) return -1
return new Date(a.next_show_at) - new Date(b.next_show_at)
})
})
return groups
}, [items])
const [expandedFuture, setExpandedFuture] = useState({})
const handleBoardChange = (boardId) => {
setSelectedBoardId(boardId)
}
const handleBoardEdit = (boardId) => {
const id = boardId || selectedBoardId
if (!id) return
const board = boards.find(b => b.id === id)
if (board && !board.is_owner) {
openBoardActionMenu()
return
}
onNavigate('shopping-board-form', { boardId: id })
}
const handleAddBoard = () => {
onNavigate('shopping-board-form')
}
const openBoardActionMenu = () => {
setShowBoardActionMenu(true)
}
const closeBoardActionMenu = () => {
setShowBoardActionMenu(false)
}
const handleBoardArchive = async () => {
const board = boards.find(b => b.id === selectedBoardId)
if (!board) return
if (board.is_archived) {
setShowBoardActionMenu(false)
try {
const res = await authFetch(`/api/shopping/boards/${selectedBoardId}/unarchive`, {
method: 'POST'
})
if (res.ok) {
fetchBoards()
fetchItems(selectedBoardId)
}
} catch (err) {
console.error('Error unarchiving board:', err)
}
} else {
if (!window.confirm('Архивировать доску? Она переместится в архив.')) return
setShowBoardActionMenu(false)
try {
const res = await authFetch(`/api/shopping/boards/${selectedBoardId}/archive`, {
method: 'POST'
})
if (res.ok) {
fetchBoards()
}
} catch (err) {
console.error('Error archiving board:', err)
}
}
}
const handleBoardLeave = async () => {
if (!window.confirm('Покинуть доску? Вы больше не будете видеть её товары.')) return
setShowBoardActionMenu(false)
try {
const res = await authFetch(`/api/shopping/boards/${selectedBoardId}/leave`, {
method: 'POST'
})
if (res.ok) {
fetchBoards()
}
} catch (err) {
console.error('Error leaving board:', err)
}
}
const handleRefresh = () => {
if (selectedBoardId) fetchItems(selectedBoardId)
}
const handleCloseDetail = (skipHistoryBack = false) => {
if (!skipHistoryBack && historyPushedForDetailRef.current) {
window.history.back()
} else {
historyPushedForDetailRef.current = false
setSelectedItemForDetail(null)
}
}
const groupNames = useMemo(() => {
const names = Object.keys(groupedItems)
return names.sort((a, b) => {
const groupA = groupedItems[a]
const groupB = groupedItems[b]
const hasActiveA = groupA.active.length > 0
const hasActiveB = groupB.active.length > 0
if (hasActiveA && !hasActiveB) return -1
if (!hasActiveA && hasActiveB) return 1
if (a === 'Остальные') return 1
if (b === 'Остальные') return -1
return a.localeCompare(b, 'ru')
})
}, [groupedItems])
const toggleFuture = (groupName) => {
setExpandedFuture(prev => ({
...prev,
[groupName]: !prev[groupName]
}))
}
return (
<div className="shopping-list">
<div className="shopping-header">
<BoardSelector
boards={boards}
selectedBoardId={selectedBoardId}
onBoardChange={handleBoardChange}
onBoardEdit={handleBoardEdit}
onAddBoard={handleAddBoard}
loading={boardsLoading}
showBoardAction={false}
/>
<button className="shopping-close-btn" onClick={() => window.history.back()}></button>
</div>
{boards.length === 0 && !boardsLoading && (
<div className="shopping-empty">
<p>Нет досок</p>
<p className="shopping-empty-hint">Создайте доску, чтобы начать добавлять товары</p>
</div>
)}
{selectedBoardId && error && (
<LoadingError onRetry={handleRefresh} />
)}
{selectedBoardId && !error && (
<>
{loading && items.length === 0 && (
<div className="shopping-loading">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
)}
{!loading && items.length === 0 && (
<div className="shopping-empty">
<p>Нет товаров</p>
</div>
)}
{groupNames.map(groupName => {
const group = groupedItems[groupName]
const hasActive = group.active.length > 0
const hasFuture = group.future.length > 0
const isFutureExpanded = expandedFuture[groupName]
return (
<div key={groupName} className={`project-group ${!hasActive ? 'project-group-no-tasks' : ''}`}>
<div
className={`project-group-header ${hasFuture ? 'project-group-header-clickable' : ''}`}
onClick={hasFuture ? () => toggleFuture(groupName) : undefined}
>
<h3 className={`project-group-title ${!hasActive ? 'project-group-title-empty' : ''}`}>{groupName}</h3>
{hasFuture ? (
<button
className="completed-toggle-header"
onClick={(e) => {
e.stopPropagation()
toggleFuture(groupName)
}}
title={isFutureExpanded ? 'Скрыть ожидающие' : 'Показать ожидающие'}
>
<span className="completed-toggle-icon">
{isFutureExpanded ? '▼' : '▶'}
</span>
</button>
) : (
<div className="completed-toggle-header" style={{ visibility: 'hidden', pointerEvents: 'none' }}>
<span className="completed-toggle-icon"></span>
</div>
)}
</div>
{hasActive && (
<div className="task-group">
{group.active.map(item => {
let dateDisplay = null
if (item.next_show_at) {
const itemDate = new Date(item.next_show_at)
const now = new Date()
now.setHours(0, 0, 0, 0)
const target = new Date(itemDate.getFullYear(), itemDate.getMonth(), itemDate.getDate())
if (target > now) {
dateDisplay = formatDateForDisplay(item.next_show_at)
}
}
return (
<div
key={item.id}
className="task-item"
onClick={() => setSelectedItemForDetail(item.id)}
>
<div className="task-item-content">
<div
className="task-checkmark"
onClick={(e) => {
e.stopPropagation()
setSelectedItemForDetail(item.id)
}}
title="Выполнить"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
</svg>
</div>
<div className="task-name-container">
<div className="task-name-wrapper">
<div className="task-name">
{item.name}
{item.estimated_remaining > 0 && (
<span className="task-subtasks-count">
({Math.round(item.estimated_remaining * 10) / 10})
</span>
)}
</div>
{dateDisplay && (
<div className="task-next-show-date">{dateDisplay}</div>
)}
</div>
</div>
</div>
</div>
)
})}
</div>
)}
{hasFuture && isFutureExpanded && (
<div className="task-group completed-tasks">
{group.future.map(item => {
let dateDisplay = null
if (item.next_show_at) {
const itemDate = new Date(item.next_show_at)
const now = new Date()
now.setHours(0, 0, 0, 0)
const target = new Date(itemDate.getFullYear(), itemDate.getMonth(), itemDate.getDate())
if (target > now) {
dateDisplay = formatDateForDisplay(item.next_show_at)
}
}
return (
<div
key={item.id}
className="task-item"
onClick={() => setSelectedItemForDetail(item.id)}
>
<div className="task-item-content">
<div
className="task-checkmark"
onClick={(e) => {
e.stopPropagation()
setSelectedItemForDetail(item.id)
}}
title="Выполнить"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
</svg>
</div>
<div className="task-name-container">
<div className="task-name-wrapper">
<div className="task-name">
{item.name}
{item.estimated_remaining > 0 && (
<span className="task-subtasks-count">
({Math.round(item.estimated_remaining * 10) / 10})
</span>
)}
</div>
{dateDisplay && (
<div className="task-next-show-date">{dateDisplay}</div>
)}
</div>
</div>
</div>
</div>
)})}
</div>
)}
</div>
)
})}
</>
)}
{/* Модалка выполнения */}
{selectedItemForDetail && (
<ShoppingItemDetail
itemId={selectedItemForDetail}
onClose={handleCloseDetail}
onRefresh={handleRefresh}
onItemCompleted={() => setToast({ message: 'Товар выполнен', type: 'success' })}
onNavigate={onNavigate}
/>
)}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
{showBoardActionMenu && createPortal(
<div className="wishlist-modal-overlay" style={{ zIndex: 2000 }} onClick={closeBoardActionMenu}>
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
<div className="wishlist-modal-header">
<h3>{boards.find(b => b.id === selectedBoardId)?.name}</h3>
</div>
<div className="wishlist-modal-actions">
{boards.find(b => b.id === selectedBoardId)?.is_archived ? (
<button className="wishlist-modal-copy" onClick={handleBoardArchive}>
Разархивировать
</button>
) : (
<button className="wishlist-modal-copy" onClick={handleBoardArchive}>
Архивировать
</button>
)}
<button className="wishlist-modal-delete" onClick={handleBoardLeave}>
Выйти
</button>
</div>
</div>
</div>,
document.body
)}
</div>
)
}
export default ShoppingList

View File

@@ -25,6 +25,23 @@
overflow: hidden; overflow: hidden;
} }
.task-detail-modal.task-detail-modal-fit {
max-width: none;
width: auto;
}
.task-detail-modal-fit .task-detail-modal-content {
width: min-content;
}
.task-detail-modal-fit .task-detail-modal-content > * {
min-width: 0;
}
.task-detail-modal-fit .task-postpone-calendar {
width: max-content;
}
.task-detail-modal-header { .task-detail-modal-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -157,6 +174,111 @@
border-radius: 0.25rem; border-radius: 0.25rem;
} }
.shopping-item-description-card {
background: #f3f4f6;
border-radius: 10px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.shopping-item-description {
flex: 1;
font-size: 0.95rem;
color: #374151;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
min-width: 0;
}
.shopping-item-history-button {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: #9ca3af;
border-radius: 4px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
}
.shopping-item-history-button:hover {
color: #4f46e5;
background-color: rgba(79, 70, 229, 0.1);
}
.shopping-item-description-link {
color: #3498db;
text-decoration: none;
transition: color 0.2s;
}
.shopping-item-description-link:hover {
color: #2980b9;
text-decoration: underline;
}
.shopping-item-complete-row {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.shopping-item-complete-row .progression-input-wrapper {
flex: 1;
}
.shopping-item-complete-row .progression-label {
margin-bottom: 0;
}
.shopping-item-complete-row + .task-action-left {
margin-top: 0.75rem;
}
.shopping-item-complete-button {
width: 48px;
height: 48px;
border-radius: 0.375rem;
border: none;
background: linear-gradient(135deg, #10b981, #059669);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
}
.shopping-item-complete-button:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
.shopping-item-complete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.shopping-item-complete-spinner {
width: 20px;
height: 20px;
border: 2.5px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.progression-section { .progression-section {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@@ -169,13 +291,27 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.progression-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.progression-input { .progression-input {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
padding-right: 4.75rem;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.375rem; border-radius: 0.375rem;
font-size: 1rem; font-size: 1rem;
box-sizing: border-box; box-sizing: border-box;
-moz-appearance: textfield;
}
.progression-input::-webkit-outer-spin-button,
.progression-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
} }
.progression-input:focus { .progression-input:focus {
@@ -184,6 +320,64 @@
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
} }
.progression-controls-capsule {
position: absolute;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: stretch;
flex-shrink: 0;
}
.progression-control-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 2.375rem;
border: none;
background: transparent;
font-size: 1.125rem;
font-weight: 500;
color: #6b7280;
cursor: pointer;
transition: color 0.2s;
z-index: 1;
}
.progression-control-btn::after {
content: '';
position: absolute;
width: 2rem;
height: 2rem;
background: #f3f4f6;
transition: background 0.2s;
z-index: -1;
}
.progression-control-btn:hover {
color: #374151;
}
.progression-control-btn:hover::after {
background: #e5e7eb;
}
.progression-control-btn:active::after {
background: #d1d5db;
}
.progression-control-minus::after {
border-radius: 9999px 0 0 9999px;
right: 0;
}
.progression-control-plus::after {
border-radius: 0 9999px 9999px 0;
left: 0;
}
.task-detail-divider { .task-detail-divider {
height: 1px; height: 1px;
background: #e5e7eb; background: #e5e7eb;
@@ -408,22 +602,10 @@
font-weight: 500; font-weight: 500;
} }
.task-wishlist-link-button { .task-wishlist-link-name {
background: none;
border: none;
color: #6366f1; color: #6366f1;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 500; font-weight: 500;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: all 0.2s;
text-decoration: underline;
margin-left: auto; margin-left: auto;
} }
.task-wishlist-link-button:hover {
background-color: rgba(99, 102, 241, 0.1);
text-decoration: none;
}

View File

@@ -291,7 +291,7 @@ const formatTelegramMessage = (task, rewards, subtasks, selectedSubtasks, progre
}) })
// Функция для замены плейсхолдеров // Функция для замены плейсхолдеров
const replacePlaceholders = (message, rewardStrings) => { const replacePlaceholders = (message, rewardStrings, taskName, subtaskName) => {
let result = message let result = message
// Сначала защищаем экранированные плейсхолдеры // Сначала защищаем экранированные плейсхолдеры
const escapedMarkers = {} const escapedMarkers = {}
@@ -303,6 +303,12 @@ const formatTelegramMessage = (task, rewards, subtasks, selectedSubtasks, progre
result = result.replace(new RegExp(escaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), marker) result = result.replace(new RegExp(escaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), marker)
} }
} }
// Заменяем $subtaskName именем подзадачи (если задано)
if (subtaskName) {
result = result.replace(/\$subtaskName/g, subtaskName)
}
// Заменяем $name именем задачи
result = result.replace(/\$name/g, taskName || '')
// Заменяем ${0}, ${1}, и т.д. // Заменяем ${0}, ${1}, и т.д.
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
const placeholder = `\${${i}}` const placeholder = `\${${i}}`
@@ -327,7 +333,7 @@ const formatTelegramMessage = (task, rewards, subtasks, selectedSubtasks, progre
// Формируем сообщение основной задачи // Формируем сообщение основной задачи
let mainTaskMessage = task.reward_message && task.reward_message.trim() !== '' let mainTaskMessage = task.reward_message && task.reward_message.trim() !== ''
? replacePlaceholders(task.reward_message, rewardStrings) ? replacePlaceholders(task.reward_message, rewardStrings, task.name)
: task.name : task.name
// Формируем сообщения подзадач // Формируем сообщения подзадач
@@ -361,7 +367,7 @@ const formatTelegramMessage = (task, rewards, subtasks, selectedSubtasks, progre
subtaskRewardStrings[reward.position] = scoreStr subtaskRewardStrings[reward.position] = scoreStr
}) })
const subtaskMessage = replacePlaceholders(subtask.task.reward_message, subtaskRewardStrings) const subtaskMessage = replacePlaceholders(subtask.task.reward_message, subtaskRewardStrings, task.name, subtask.task.name)
subtaskMessages.push(subtaskMessage) subtaskMessages.push(subtaskMessage)
}) })
@@ -477,7 +483,7 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
children_task_ids: Array.from(selectedSubtasks) children_task_ids: Array.from(selectedSubtasks)
} }
// Если есть прогрессия, отправляем значение (или progression_base, если не введено) // Если есть прогрессия, отправляем значение (или сбрасываем, если не введено)
if (taskDetail.task.progression_base != null) { if (taskDetail.task.progression_base != null) {
if (progressionValue.trim()) { if (progressionValue.trim()) {
const parsedValue = parseFloat(progressionValue) const parsedValue = parseFloat(progressionValue)
@@ -485,9 +491,10 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
throw new Error('Неверное значение') throw new Error('Неверное значение')
} }
payload.progression_value = parsedValue payload.progression_value = parsedValue
} else { } else if (!autoComplete) {
// Если прогрессия не введена - используем progression_base // Если прогрессия не введена и нет авто-выполнения - сбрасываем в null
payload.progression_value = taskDetail.task.progression_base // При авто-выполнении бэкенд сам подставит progression_base
payload.clear_progression_value = true
} }
} else { } else {
// Если нет progression_base, но пользователь ввел значение - отправляем его // Если нет progression_base, но пользователь ввел значение - отправляем его
@@ -558,7 +565,7 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
children_task_ids: Array.from(selectedSubtasks) children_task_ids: Array.from(selectedSubtasks)
} }
// Если есть прогрессия, отправляем значение (или progression_base, если не введено) // Если есть прогрессия, отправляем значение (или default_progress/progression_base, если не введено)
if (taskDetail.task.progression_base != null) { if (taskDetail.task.progression_base != null) {
if (progressionValue.trim()) { if (progressionValue.trim()) {
payload.value = parseFloat(progressionValue) payload.value = parseFloat(progressionValue)
@@ -566,8 +573,8 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
throw new Error('Неверное значение') throw new Error('Неверное значение')
} }
} else { } else {
// Если прогрессия не введена - используем progression_base // Если прогрессия не введена - используем default_progress или progression_base
payload.value = taskDetail.task.progression_base payload.value = taskDetail.task.default_progress ?? taskDetail.task.progression_base
} }
} }
@@ -586,13 +593,17 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
throw new Error(errorData.message || 'Ошибка при выполнении задачи') throw new Error(errorData.message || 'Ошибка при выполнении задачи')
} }
const data = await response.json().catch(() => ({}))
// Показываем уведомление о выполнении // Показываем уведомление о выполнении
if (onTaskCompleted) { if (onTaskCompleted) {
onTaskCompleted() onTaskCompleted()
} }
// Обновляем список и закрываем модальное окно // Если бэкенд вернул обновлённый список — передаём его, иначе делаем повторный GET
if (onRefresh) { if (data.tasks && onRefresh) {
onRefresh(data.tasks)
} else if (onRefresh) {
onRefresh() onRefresh()
} }
if (onClose) { if (onClose) {
@@ -621,7 +632,7 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
children_task_ids: Array.from(selectedSubtasks) children_task_ids: Array.from(selectedSubtasks)
} }
// Если есть прогрессия, отправляем значение (или progression_base, если не введено) // Если есть прогрессия, отправляем значение (или default_progress/progression_base, если не введено)
if (taskDetail.task.progression_base != null) { if (taskDetail.task.progression_base != null) {
if (progressionValue.trim()) { if (progressionValue.trim()) {
payload.value = parseFloat(progressionValue) payload.value = parseFloat(progressionValue)
@@ -629,8 +640,8 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
throw new Error('Неверное значение') throw new Error('Неверное значение')
} }
} else { } else {
// Если прогрессия не введена - используем progression_base // Если прогрессия не введена - используем default_progress или progression_base
payload.value = taskDetail.task.progression_base payload.value = taskDetail.task.default_progress ?? taskDetail.task.progression_base
} }
} }
@@ -649,13 +660,17 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
throw new Error(errorData.message || 'Ошибка при выполнении задачи') throw new Error(errorData.message || 'Ошибка при выполнении задачи')
} }
const data = await response.json().catch(() => ({}))
// Показываем уведомление о выполнении // Показываем уведомление о выполнении
if (onTaskCompleted) { if (onTaskCompleted) {
onTaskCompleted() onTaskCompleted()
} }
// Обновляем список и закрываем модальное окно // Если бэкенд вернул обновлённый список — передаём его, иначе делаем повторный GET
if (onRefresh) { if (data.tasks && onRefresh) {
onRefresh(data.tasks)
} else if (onRefresh) {
onRefresh() onRefresh()
} }
if (onClose) { if (onClose) {
@@ -718,8 +733,11 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
// Обновляем значение чекбокса при изменении taskDetail // Обновляем значение чекбокса при изменении taskDetail
useEffect(() => { useEffect(() => {
if (taskDetail && taskDetail.task) { if (taskDetail && taskDetail.task) {
const autoCompleteValue = Boolean(taskDetail.task.auto_complete) // Если есть драфт, используем значение из драфта
console.log('useEffect: Updating completeAtEndOfDay from taskDetail:', autoCompleteValue, 'task.auto_complete:', taskDetail.task.auto_complete) // Иначе используем default_auto_complete как начальное значение
const autoCompleteValue = taskDetail.has_draft
? Boolean(taskDetail.task.auto_complete)
: Boolean(taskDetail.task.default_auto_complete)
setCompleteAtEndOfDay(autoCompleteValue) setCompleteAtEndOfDay(autoCompleteValue)
} else { } else {
setCompleteAtEndOfDay(false) setCompleteAtEndOfDay(false)
@@ -729,7 +747,7 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
const modalContent = ( const modalContent = (
<div className="task-detail-modal-overlay" onClick={onClose}> <div className="task-detail-modal-overlay" onClick={() => onClose?.()}>
<div className="task-detail-modal" onClick={(e) => e.stopPropagation()}> <div className="task-detail-modal" onClick={(e) => e.stopPropagation()}>
<div className="task-detail-modal-header"> <div className="task-detail-modal-header">
<h2 <h2
@@ -761,7 +779,7 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
</> </>
) : 'Задача'} ) : 'Задача'}
</h2> </h2>
<button onClick={onClose} className="task-detail-close-button"> <button onClick={() => onClose?.()} className="task-detail-close-button">
</button> </button>
</div> </div>
@@ -789,17 +807,9 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path> <path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
</svg> </svg>
<span className="task-wishlist-link-label">Связано с желанием:</span> <span className="task-wishlist-link-label">Связано с желанием:</span>
<button <span className="task-wishlist-link-name">
onClick={() => {
if (onClose) onClose()
if (onNavigate && wishlistInfo) {
onNavigate('wishlist-detail', { wishlistId: wishlistInfo.id })
}
}}
className="task-wishlist-link-button"
>
{wishlistInfo.name} {wishlistInfo.name}
</button> </span>
</div> </div>
</div> </div>
)} )}
@@ -808,14 +818,42 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
{hasProgression && ( {hasProgression && (
<div className="progression-section"> <div className="progression-section">
<label className="progression-label">Значение прогрессии</label> <label className="progression-label">Значение прогрессии</label>
<div className="progression-input-wrapper">
<input <input
type="number" type="number"
step="any" step="any"
value={progressionValue} value={progressionValue}
onChange={(e) => setProgressionValue(e.target.value)} onChange={(e) => setProgressionValue(e.target.value)}
placeholder={task.progression_base?.toString() || ''} placeholder={(task.default_progress ?? task.progression_base)?.toString() || ''}
className="progression-input" className="progression-input"
/> />
<div className="progression-controls-capsule">
<button
type="button"
className="progression-control-btn progression-control-minus"
onClick={() => {
const base = task.default_progress ?? task.progression_base ?? 1
const current = progressionValue.trim() ? parseFloat(progressionValue) : base
const step = task.progression_base || 1
setProgressionValue((current - step).toString())
}}
>
</button>
<button
type="button"
className="progression-control-btn progression-control-plus"
onClick={() => {
const base = task.default_progress ?? task.progression_base ?? 1
const current = progressionValue.trim() ? parseFloat(progressionValue) : base
const step = task.progression_base || 1
setProgressionValue((current + step).toString())
}}
>
+
</button>
</div>
</div>
</div> </div>
)} )}

View File

@@ -3,6 +3,7 @@
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
position: relative; position: relative;
padding-bottom: 5rem;
} }
.close-x-button { .close-x-button {
@@ -48,6 +49,10 @@
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.task-form form > *:last-child {
margin-bottom: 0;
}
.form-group label { .form-group label {
display: block; display: block;
font-weight: 500; font-weight: 500;
@@ -227,23 +232,24 @@
} }
.add-subtask-button { .add-subtask-button {
padding: 0.375rem; padding: 0.5rem;
background: #6366f1; background: white;
color: white; color: #3498db;
border: none; border: 1px dashed #bae6fd;
border-radius: 0.375rem; border-radius: 0.375rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; width: 100%;
min-width: 2rem; height: 2.5rem;
height: 2rem; margin-top: 0.5rem;
} }
.add-subtask-button:hover { .add-subtask-button:hover {
background: #4f46e5; background: #e0f2fe;
border-color: #3498db;
} }
.subtask-form-item { .subtask-form-item {
@@ -311,9 +317,9 @@
} }
.remove-subtask-button { .remove-subtask-button {
padding: 0.5rem; padding: 0.25rem;
background: #ef4444; background: none;
color: white; color: #9ca3af;
border: none; border: none;
border-radius: 0.375rem; border-radius: 0.375rem;
cursor: pointer; cursor: pointer;
@@ -322,12 +328,12 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
min-width: 2.5rem; min-width: 2rem;
height: 2.5rem; height: 2rem;
} }
.remove-subtask-button:hover { .remove-subtask-button:hover {
background: #dc2626; color: #ef4444;
} }
.error-message { .error-message {
@@ -347,9 +353,7 @@
border-top: 1px solid #e5e7eb; border-top: 1px solid #e5e7eb;
} }
.cancel-button, .cancel-button {
.submit-button,
.delete-button {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border: none; border: none;
border-radius: 0.375rem; border-radius: 0.375rem;
@@ -357,9 +361,6 @@
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
}
.cancel-button {
background: #f3f4f6; background: #f3f4f6;
color: #374151; color: #374151;
} }
@@ -368,42 +369,6 @@
background: #e5e7eb; background: #e5e7eb;
} }
.submit-button {
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
flex: 1;
}
.submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete-button {
background: #ef4444;
color: white;
padding: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
width: 44px;
}
.delete-button:hover:not(:disabled) {
background: #dc2626;
}
.delete-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading { .loading {
text-align: center; text-align: center;
padding: 3rem 1rem; padding: 3rem 1rem;
@@ -451,6 +416,57 @@
color: #ef4444; color: #ef4444;
} }
/* Task type tabs */
.task-type-tabs-section {
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 0.5rem;
padding: 0;
overflow: hidden;
}
.task-type-tabs {
display: flex;
border-bottom: 1px solid #bae6fd;
}
.task-type-tab {
flex: 1;
padding: 0.625rem 0;
border: none;
background: transparent;
font-size: 0.875rem;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
}
.task-type-tab:not(:last-child) {
border-right: 1px solid #bae6fd;
}
.task-type-tab-active {
color: #3498db;
font-weight: 600;
background: rgba(52, 152, 219, 0.08);
}
.task-type-tab-active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: #3498db;
}
.task-type-content {
padding: 1rem;
}
/* Test configuration styles */ /* Test configuration styles */
.test-config-section { .test-config-section {
background: #f0f9ff; background: #f0f9ff;
@@ -459,13 +475,23 @@
padding: 1rem; padding: 1rem;
} }
.test-config-section > label { .test-config-section > label,
.test-config-section > .subtasks-header > label {
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: #3498db; color: #3498db;
margin-bottom: 1rem !important; margin-bottom: 1rem !important;
} }
.test-config-section > .subtasks-header {
margin-bottom: 0.5rem;
}
.test-config-section .subtask-form-item {
background: white;
border-color: #bae6fd;
}
.test-config-fields { .test-config-fields {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -476,7 +502,7 @@
.test-field-group { .test-field-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0;
} }
.test-field-group label { .test-field-group label {
@@ -500,20 +526,27 @@
.test-dictionaries-list { .test-dictionaries-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0;
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
padding: 0.5rem; padding: 0.5rem 0.25rem;
background: white; background: white;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.375rem; border-radius: 0.375rem;
} }
.test-dictionary-item { .test-dictionaries-list > div {
margin: 0;
padding: 0;
}
label.test-dictionary-item,
.form-group label.test-dictionary-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.25rem;
padding: 0.5rem; padding: 0.4rem 0.5rem;
margin-bottom: 0;
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
@@ -526,13 +559,15 @@
.test-dictionary-item input[type="checkbox"] { .test-dictionary-item input[type="checkbox"] {
width: 18px; width: 18px;
height: 18px; height: 18px;
flex-shrink: 0;
margin: 0;
accent-color: #3498db; accent-color: #3498db;
} }
.test-dictionary-name { .test-dictionary-name {
flex: 1;
font-weight: 500; font-weight: 500;
color: #374151; color: #374151;
line-height: 18px;
} }
.test-dictionary-count { .test-dictionary-count {

View File

@@ -1,18 +1,21 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext' import { useAuth } from './auth/AuthContext'
import Toast from './Toast' import Toast from './Toast'
import SubmitButton from './SubmitButton' import SubmitButton from './SubmitButton'
import DeleteButton from './DeleteButton' import './Wishlist.css'
import './TaskForm.css' import './TaskForm.css'
const API_URL = '/api/tasks' const API_URL = '/api/tasks'
const PROJECTS_API_URL = '/projects' const PROJECTS_API_URL = '/projects'
function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = false, returnTo, returnWishlistId }) { function TaskForm({ onNavigate, taskId, wishlistId, returnTo, returnWishlistId, isActive }) {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const [name, setName] = useState('') const [name, setName] = useState('')
const [progressionBase, setProgressionBase] = useState('') const [progressionBase, setProgressionBase] = useState('')
const [rewardMessage, setRewardMessage] = useState('') const [defaultProgress, setDefaultProgress] = useState('')
const [defaultAutoComplete, setDefaultAutoComplete] = useState(false)
const [rewardMessage, setRewardMessage] = useState('$name')
const [repetitionPeriodValue, setRepetitionPeriodValue] = useState('') const [repetitionPeriodValue, setRepetitionPeriodValue] = useState('')
const [repetitionPeriodType, setRepetitionPeriodType] = useState('day') const [repetitionPeriodType, setRepetitionPeriodType] = useState('day')
const [repetitionMode, setRepetitionMode] = useState('after') // 'after' = Через, 'each' = Каждое const [repetitionMode, setRepetitionMode] = useState('after') // 'after' = Через, 'each' = Каждое
@@ -26,15 +29,22 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
const [toastMessage, setToastMessage] = useState(null) const [toastMessage, setToastMessage] = useState(null)
const [loadingTask, setLoadingTask] = useState(false) const [loadingTask, setLoadingTask] = useState(false)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [isCopying, setIsCopying] = useState(false)
const [showActionMenu, setShowActionMenu] = useState(false)
const actionMenuHistoryRef = useRef(false)
const [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании const [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании
const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи
const [rewardPolicy, setRewardPolicy] = useState('personal') // Политика награждения: 'personal' или 'general' const [rewardPolicy, setRewardPolicy] = useState('general') // Политика награждения: 'personal' или 'general'
// Test-specific state // Test-specific state
const [isTest, setIsTest] = useState(isTestFromProps) const [isTest, setIsTest] = useState(false)
const [wordsCount, setWordsCount] = useState('10') const [wordsCount, setWordsCount] = useState('10')
const [maxCards, setMaxCards] = useState('') const [maxCards, setMaxCards] = useState('')
const [selectedDictionaryIDs, setSelectedDictionaryIDs] = useState([]) const [selectedDictionaryIDs, setSelectedDictionaryIDs] = useState([])
const [availableDictionaries, setAvailableDictionaries] = useState([]) const [availableDictionaries, setAvailableDictionaries] = useState([])
// Purchase-specific state
const [isPurchase, setIsPurchase] = useState(false)
const [availableBoards, setAvailableBoards] = useState([])
const [selectedPurchaseBoards, setSelectedPurchaseBoards] = useState([])
const debounceTimer = useRef(null) const debounceTimer = useRef(null)
// Загрузка проектов для автокомплита // Загрузка проектов для автокомплита
@@ -85,23 +95,43 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
loadDictionaries() loadDictionaries()
}, []) }, [])
// Загрузка досок для закупок
useEffect(() => {
const loadBoards = async () => {
try {
const response = await authFetch('/api/purchase/boards-info')
if (response.ok) {
const data = await response.json()
setAvailableBoards(Array.isArray(data.boards) ? data.boards : [])
}
} catch (err) {
console.error('Error loading boards for purchase:', err)
}
}
loadBoards()
}, [])
// Функция сброса формы // Функция сброса формы
const resetForm = () => { const resetForm = () => {
setName('') setName('')
setRewardMessage('') setRewardMessage('$name')
setProgressionBase('') setProgressionBase('')
setRepetitionPeriodValue('') setRepetitionPeriodValue('')
setRepetitionPeriodType('day') setRepetitionPeriodType('day')
setRepetitionMode('after') setRepetitionMode('after')
setRewards([]) setRewards([])
setSubtasks([]) setSubtasks([])
setGroupName('')
setError('') setError('')
setLoadingTask(false) setLoadingTask(false)
// Reset test-specific fields // Reset test-specific fields
setIsTest(isTestFromProps) setIsTest(false)
setWordsCount('10') setWordsCount('10')
setMaxCards('') setMaxCards('')
setSelectedDictionaryIDs([]) setSelectedDictionaryIDs([])
// Reset purchase-specific fields
setIsPurchase(false)
setSelectedPurchaseBoards([])
if (debounceTimer.current) { if (debounceTimer.current) {
clearTimeout(debounceTimer.current) clearTimeout(debounceTimer.current)
debounceTimer.current = null debounceTimer.current = null
@@ -132,7 +162,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
} }
// Предзаполняем сообщение награды // Предзаполняем сообщение награды
if (data.name) { if (data.name) {
setRewardMessage(`Выполнить желание: ${data.name}`) setRewardMessage('Выполнить желание: $name')
} }
} }
} catch (err) { } catch (err) {
@@ -157,8 +187,11 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
} }
const data = await response.json() const data = await response.json()
setName(data.task.name) setName(data.task.name)
setRewardMessage(data.task.reward_message || '') setRewardMessage(data.task.reward_message || '$name')
setProgressionBase(data.task.progression_base ? String(data.task.progression_base) : '') setProgressionBase(data.task.progression_base ? String(data.task.progression_base) : '')
setDefaultProgress(data.task.default_progress ? String(data.task.default_progress) : '')
setDefaultAutoComplete(data.task.default_auto_complete || false)
setGroupName(data.task.group_name ?? '')
// Проверяем, является ли задача бесконечной (оба поля = 0) // Проверяем, является ли задача бесконечной (оба поля = 0)
const periodStr = data.task.repetition_period ? data.task.repetition_period.trim() : '' const periodStr = data.task.repetition_period ? data.task.repetition_period.trim() : ''
@@ -334,7 +367,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
setSubtasks(data.subtasks.map((st, index) => ({ setSubtasks(data.subtasks.map((st, index) => ({
id: st.task.id, id: st.task.id,
name: st.task.name || '', name: st.task.name || '',
reward_message: st.task.reward_message || '', reward_message: st.task.reward_message || '$subtaskName',
position: st.task.position !== undefined && st.task.position !== null ? st.task.position : index, position: st.task.position !== undefined && st.task.position !== null ? st.task.position : index,
rewards: st.rewards.map(r => ({ rewards: st.rewards.map(r => ({
position: r.position, position: r.position,
@@ -366,14 +399,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
if (data.task.reward_policy) { if (data.task.reward_policy) {
setRewardPolicy(data.task.reward_policy) setRewardPolicy(data.task.reward_policy)
} else { } else {
setRewardPolicy('personal') // Значение по умолчанию setRewardPolicy('general') // Значение по умолчанию
}
// Загружаем группу
if (data.task.group_name) {
setGroupName(data.task.group_name)
} else {
setGroupName('')
} }
} else { } else {
setCurrentWishlistId(null) setCurrentWishlistId(null)
@@ -404,6 +430,23 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
setMaxCards('') setMaxCards('')
setSelectedDictionaryIDs([]) setSelectedDictionaryIDs([])
} }
// Загружаем информацию о закупке, если есть purchase_config_id
if (data.task.purchase_config_id) {
setIsPurchase(true)
if (data.purchase_boards && Array.isArray(data.purchase_boards)) {
setSelectedPurchaseBoards(data.purchase_boards.map(pb => ({
board_id: pb.board_id,
group_name: pb.group_name || null
})))
}
// Закупки не могут иметь прогрессию и подзадачи
setProgressionBase('')
setSubtasks([])
} else {
setIsPurchase(false)
setSelectedPurchaseBoards([])
}
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@@ -411,12 +454,8 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
} }
} }
// Очистка подзадач при переключении задачи в режим теста // Подзадачи, словари и товары сохраняются в памяти при переключении типа.
useEffect(() => { // При сохранении используются только данные текущего активного типа.
if (isTest && subtasks.length > 0) {
setSubtasks([])
}
}, [isTest])
// Пересчет rewards при изменении reward_message (debounce) // Пересчет rewards при изменении reward_message (debounce)
useEffect(() => { useEffect(() => {
@@ -508,7 +547,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
setSubtasks([...subtasks, { setSubtasks([...subtasks, {
id: null, id: null,
name: '', name: '',
reward_message: '', reward_message: '$subtaskName',
position: subtasks.length, position: subtasks.length,
rewards: [] rewards: []
}]) }])
@@ -602,6 +641,13 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
} }
} }
// Валидация закупки
if (isPurchase && selectedPurchaseBoards.length === 0) {
setError('Выберите хотя бы одну доску или группу для закупки')
setLoading(false)
return
}
// Проверяем, что задача с привязанным желанием не может быть периодической // Проверяем, что задача с привязанным желанием не может быть периодической
const isLinkedToWishlist = wishlistInfo !== null || (taskId && currentWishlistId) const isLinkedToWishlist = wishlistInfo !== null || (taskId && currentWishlistId)
if (isLinkedToWishlist && repetitionPeriodValue && repetitionPeriodValue.trim() !== '') { if (isLinkedToWishlist && repetitionPeriodValue && repetitionPeriodValue.trim() !== '') {
@@ -696,8 +742,10 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
const payload = { const payload = {
name: name.trim(), name: name.trim(),
reward_message: rewardMessage.trim() || null, reward_message: rewardMessage.trim() || null,
// Тесты и задачи с желанием не могут иметь прогрессию // Тесты, закупки и задачи с желанием не могут иметь прогрессию
progression_base: (isLinkedToWishlist || isTest) ? null : (progressionBase ? parseFloat(progressionBase) : null), progression_base: (isLinkedToWishlist || isTest || isPurchase) ? null : (progressionBase ? parseFloat(progressionBase) : null),
default_progress: (isLinkedToWishlist || isTest || isPurchase) ? null : (defaultProgress ? parseFloat(defaultProgress) : (progressionBase ? parseFloat(progressionBase) : null)),
default_auto_complete: defaultAutoComplete,
repetition_period: repetitionPeriod, repetition_period: repetitionPeriod,
repetition_date: repetitionDate, repetition_date: repetitionDate,
// При создании: отправляем currentWishlistId если указан (уже число) // При создании: отправляем currentWishlistId если указан (уже число)
@@ -714,9 +762,9 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
position: r.position, position: r.position,
project_name: r.project_name.trim(), project_name: r.project_name.trim(),
value: parseFloat(r.value) || 0, value: parseFloat(r.value) || 0,
use_progression: !!(progressionBase && r.use_progression) use_progression: !!(progressionBase && !isTest && !isPurchase && r.use_progression)
})), })),
subtasks: isTest ? [] : subtasks.map((st, index) => ({ subtasks: (isTest || isPurchase) ? [] : subtasks.map((st, index) => ({
id: st.id || undefined, id: st.id || undefined,
name: st.name.trim() || null, name: st.name.trim() || null,
reward_message: st.reward_message.trim() || null, reward_message: st.reward_message.trim() || null,
@@ -725,14 +773,17 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
position: r.position, position: r.position,
project_name: r.project_name.trim(), project_name: r.project_name.trim(),
value: parseFloat(r.value) || 0, value: parseFloat(r.value) || 0,
use_progression: !!(progressionBase && r.use_progression) use_progression: !!(progressionBase && !isTest && !isPurchase && r.use_progression)
})) }))
})), })),
// Test-specific fields // Test-specific fields
is_test: isTest, is_test: isTest,
words_count: isTest ? parseInt(wordsCount, 10) : undefined, words_count: isTest ? parseInt(wordsCount, 10) : undefined,
max_cards: isTest && maxCards ? parseInt(maxCards, 10) : undefined, max_cards: isTest && maxCards ? parseInt(maxCards, 10) : undefined,
dictionary_ids: isTest ? selectedDictionaryIDs : undefined dictionary_ids: isTest ? selectedDictionaryIDs : undefined,
// Purchase-specific fields
is_purchase: isPurchase,
purchase_boards: isPurchase ? selectedPurchaseBoards : undefined
} }
const url = taskId ? `${API_URL}/${taskId}` : API_URL const url = taskId ? `${API_URL}/${taskId}` : API_URL
@@ -773,15 +824,19 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
// Если был returnTo, возвращаемся на форму желания с ID новой задачи // Если был returnTo, возвращаемся на форму желания с ID новой задачи
if (returnTo === 'wishlist-form') { if (returnTo === 'wishlist-form') {
console.log('[TaskForm] Navigating back to wishlist-form with newTaskId:', newTaskId) console.log('[TaskForm] Saving newTaskId to sessionStorage and going back:', newTaskId)
onNavigate?.(returnTo, { // Сохраняем newTaskId в sessionStorage, чтобы WishlistForm мог его прочитать
wishlistId: returnWishlistId, sessionStorage.setItem('wishlistFormNewTaskId', String(newTaskId))
newTaskId: newTaskId, window.history.back()
})
} else { } else {
console.log('[TaskForm] No returnTo, navigating to tasks') console.log('[TaskForm] No returnTo, going back in history')
// Стандартное поведение - возврат к списку задач // Возвращаемся назад, если есть предыдущая запись
onNavigate?.('tasks') const state = window.history.state
if ((state && state.previousTab) || window.history.length > 1) {
window.history.back()
} else {
onNavigate('tasks')
}
} }
} catch (err) { } catch (err) {
setToastMessage({ text: err.message || 'Ошибка при сохранении задачи', type: 'error' }) setToastMessage({ text: err.message || 'Ошибка при сохранении задачи', type: 'error' })
@@ -800,14 +855,56 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
const handleCancel = () => { const handleCancel = () => {
resetForm() resetForm()
// Проверяем, есть ли предыдущая запись в стеке для history.back()
const state = window.history.state
if (state && state.previousTab) {
// Есть предыдущая запись — можно безопасно вернуться
window.history.back()
} else if (window.history.length > 1) {
window.history.back()
} else {
// Стек пуст — прямой переход
onNavigate('tasks')
}
}
const openActionMenu = () => {
setShowActionMenu(true)
window.history.pushState({ actionMenu: true }, '')
actionMenuHistoryRef.current = true
}
const closeActionMenu = () => {
setShowActionMenu(false)
if (actionMenuHistoryRef.current) {
actionMenuHistoryRef.current = false
window.history.back() window.history.back()
} }
}
// Обработка popstate для закрытия action menu кнопкой назад
useEffect(() => {
const handlePopState = (e) => {
if (showActionMenu) {
actionMenuHistoryRef.current = false
setShowActionMenu(false)
}
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [showActionMenu])
const handleDelete = async () => { const handleDelete = async () => {
if (!taskId) return if (!taskId) return
if (!window.confirm(`Вы уверены, что хотите удалить задачу "${name}"?`)) { setShowActionMenu(false)
return // Убираем запись action menu из истории + закрываем экран редактирования
if (actionMenuHistoryRef.current) {
actionMenuHistoryRef.current = false
// go(-2): action menu + task form
window.history.go(-2)
} else {
window.history.back()
} }
setIsDeleting(true) setIsDeleting(true)
@@ -819,17 +916,40 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
if (!response.ok) { if (!response.ok) {
throw new Error('Ошибка при удалении задачи') throw new Error('Ошибка при удалении задачи')
} }
// Возвращаемся к списку задач
onNavigate?.('tasks')
} catch (err) { } catch (err) {
console.error('Error deleting task:', err) console.error('Error deleting task:', err)
setToastMessage({ text: err.message || 'Ошибка при удалении задачи', type: 'error' }) }
setIsDeleting(false) }
const handleCopy = async () => {
if (!taskId) return
setShowActionMenu(false)
// Убираем запись action menu из истории + закрываем экран редактирования
if (actionMenuHistoryRef.current) {
actionMenuHistoryRef.current = false
window.history.go(-2)
} else {
window.history.back()
}
setIsCopying(true)
try {
const response = await authFetch(`${API_URL}/${taskId}/copy`, {
method: 'POST',
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
throw new Error(errorText || 'Ошибка при копировании задачи')
}
} catch (err) {
console.error('Error copying task:', err)
} }
} }
return ( return (
<>
<div className="task-form"> <div className="task-form">
<button className="close-x-button" onClick={handleCancel}> <button className="close-x-button" onClick={handleCancel}>
@@ -845,7 +965,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
<> <>
<h2>{taskId ? 'Редактировать задачу' : 'Новая задача'}</h2> <h2>{taskId ? 'Редактировать задачу' : 'Новая задача'}</h2>
<form onSubmit={handleSubmit}> <form id="task-form-element" onSubmit={handleSubmit}>
<div className="form-group"> <div className="form-group">
<label htmlFor="name">Название задачи *</label> <label htmlFor="name">Название задачи *</label>
<input <input
@@ -868,64 +988,193 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
/> />
</div> </div>
{/* Информация о связанном желании */} {/* Task type tabs */}
{wishlistInfo && ( {!wishlistInfo && (
<div className="form-group"> <div className="form-group task-type-tabs-section">
<div className="wishlist-link-info"> <div className="task-type-tabs">
<span className="wishlist-link-text"> <button
Связана с желанием: <strong>{wishlistInfo.name}</strong> type="button"
</span> className={`task-type-tab ${!isTest && !isPurchase ? 'task-type-tab-active' : ''}`}
<div style={{ marginTop: '12px' }}> onClick={() => { setIsTest(false); setIsPurchase(false) }}
<label htmlFor="reward_policy" style={{ display: 'block', marginBottom: '4px' }}>Политика награждения:</label>
<select
id="reward_policy"
value={rewardPolicy}
onChange={(e) => setRewardPolicy(e.target.value)}
className="form-input"
> >
<option value="personal">Личная</option> Задача
<option value="general">Общая</option> </button>
</select> <button
<small style={{ color: '#666', fontSize: '0.9em', display: 'block', marginTop: '4px' }}> type="button"
{rewardPolicy === 'personal' className={`task-type-tab ${isTest ? 'task-type-tab-active' : ''}`}
? 'Задача выполняется только если вы сами завершили желание. Если другой пользователь завершит желание, задача будет удалена.' onClick={() => { setIsTest(true); setIsPurchase(false) }}
: 'Задача выполняется если кто-либо (неважно кто) отметил желание завершённым.'} >
</small> Тест
</button>
<button
type="button"
className={`task-type-tab ${isPurchase ? 'task-type-tab-active' : ''}`}
onClick={() => { setIsPurchase(true); setIsTest(false) }}
>
Закупка
</button>
</div> </div>
</div>
</div>
)}
{!isTest && !wishlistInfo && ( {/* Задача */}
<div className="form-group"> {!isTest && !isPurchase && (
<div className="task-type-content">
<div className="test-field-group" style={{ marginBottom: '1rem' }}>
<label htmlFor="progression_base">Прогрессия</label> <label htmlFor="progression_base">Прогрессия</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input <input
id="progression_base" id="progression_base"
type="number" type="number"
step="any" step="any"
value={progressionBase} value={progressionBase}
onChange={(e) => { onChange={(e) => setProgressionBase(e.target.value)}
if (!wishlistInfo) {
setProgressionBase(e.target.value)
}
}}
placeholder="Базовое значение" placeholder="Базовое значение"
className="form-input" className="form-input"
disabled={wishlistInfo !== null} style={{ flex: 1 }}
/> />
<small style={{ color: wishlistInfo ? '#e74c3c' : '#666', fontSize: '0.9em' }}> <input
{wishlistInfo ? 'Задачи, привязанные к желанию, не могут иметь прогрессию' : 'Оставьте пустым, если прогрессия не используется'} id="default_progress"
type="number"
step="any"
value={defaultProgress}
onChange={(e) => setDefaultProgress(e.target.value)}
placeholder={progressionBase || 'По-умолчанию'}
className="form-input"
style={{ flex: 1 }}
/>
</div>
<small style={{ color: '#666', fontSize: '0.9em' }}>
Оставьте пустым, если прогрессия не используется
</small> </small>
</div> </div>
<label style={{ fontSize: '0.875rem' }}>Подзадачи</label>
{subtasks.map((subtask, index) => (
<div key={index} className="subtask-form-item">
<div className="subtask-header-row">
<div className="subtask-position-controls">
<button
type="button"
onClick={() => handleMoveSubtaskUp(index)}
className="move-subtask-button"
disabled={index === 0}
title="Переместить вверх"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
<button
type="button"
onClick={() => handleMoveSubtaskDown(index)}
className="move-subtask-button"
disabled={index === subtasks.length - 1}
title="Переместить вниз"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
</div>
<input
type="text"
value={subtask.name}
onChange={(e) => handleSubtaskChange(index, 'name', e.target.value)}
placeholder="Название подзадачи"
className="form-input subtask-name-input"
/>
<button
type="button"
onClick={() => handleRemoveSubtask(index)}
className="remove-subtask-button"
title="Удалить подзадачу"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<textarea
value={subtask.reward_message}
onChange={(e) => handleSubtaskRewardMessageChange(index, e.target.value)}
placeholder="Используйте $subtaskName для имени подзадачи, $name для имени задачи"
className="form-textarea"
rows={2}
/>
{subtask.rewards && subtask.rewards.length > 0 && (
<div className="subtask-rewards">
{subtask.rewards.map((reward, rIndex) => {
return (
<div key={rIndex} className="reward-item">
<span className="reward-number">{rIndex}</span>
<input
type="text"
value={reward.project_name}
onChange={(e) => {
const newSubtasks = [...subtasks]
newSubtasks[index].rewards[rIndex].project_name = e.target.value
setSubtasks(newSubtasks)
}}
placeholder="Проект"
className="form-input reward-project-input"
list={`subtask-projects-${index}-${rIndex}`}
/>
<datalist id={`subtask-projects-${index}-${rIndex}`}>
{projects.map(p => (
<option key={p.project_id} value={p.project_name} />
))}
</datalist>
<input
type="number"
step="any"
value={reward.value}
onChange={(e) => {
const newSubtasks = [...subtasks]
newSubtasks[index].rewards[rIndex].value = e.target.value
setSubtasks(newSubtasks)
}}
placeholder="Score"
className="form-input reward-score-input"
/>
{progressionBase && !isTest && !isPurchase && (
<button
type="button"
tabIndex={0}
className={`progression-button progression-button-subtask ${reward.use_progression ? 'progression-button-filled' : 'progression-button-outlined'}`}
onMouseDown={(e) => {
e.preventDefault()
const newSubtasks = [...subtasks]
newSubtasks[index].rewards[rIndex].use_progression = !newSubtasks[index].rewards[rIndex].use_progression
setSubtasks(newSubtasks)
}}
title={reward.use_progression ? 'Отключить прогрессию' : 'Включить прогрессию'}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
</button>
)}
</div>
)})}
</div>
)}
</div>
))}
<button type="button" onClick={handleAddSubtask} className="add-subtask-button" title="Добавить подзадачу">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
)} )}
{/* Test-specific fields */} {/* Тест */}
{isTest && ( {isTest && (
<div className="form-group test-config-section"> <div className="task-type-content">
<label>Настройки теста</label>
<div className="test-config-fields"> <div className="test-config-fields">
<div className="test-field-group"> <div className="test-field-group">
<label htmlFor="words_count">Количество слов *</label> <label htmlFor="words_count">Количество слов</label>
<input <input
id="words_count" id="words_count"
type="number" type="number"
@@ -950,7 +1199,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
</div> </div>
</div> </div>
<div className="test-dictionaries-section"> <div className="test-dictionaries-section">
<label>Словари *</label> <label>Словари</label>
<div className="test-dictionaries-list"> <div className="test-dictionaries-list">
{availableDictionaries.map(dict => ( {availableDictionaries.map(dict => (
<label key={dict.id} className="test-dictionary-item"> <label key={dict.id} className="test-dictionary-item">
@@ -979,6 +1228,95 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
</div> </div>
)} )}
{/* Закупка */}
{isPurchase && (
<div className="task-type-content">
<div>
<label style={{ fontSize: '0.875rem', fontWeight: 500, color: '#374151', marginBottom: '0.5rem', display: 'block' }}>Доски и группы</label>
<div className="test-dictionaries-list">
{availableBoards.map(board => (
<div key={board.id}>
<label className="test-dictionary-item">
<input
type="checkbox"
checked={selectedPurchaseBoards.some(pb => pb.board_id === board.id && pb.group_name === null)}
onChange={(e) => {
if (e.target.checked) {
setSelectedPurchaseBoards(prev => [
...prev.filter(pb => pb.board_id !== board.id),
{ board_id: board.id, group_name: null }
])
} else {
setSelectedPurchaseBoards(prev => prev.filter(pb => !(pb.board_id === board.id && pb.group_name === null)))
}
}}
/>
<span className="test-dictionary-name">{board.name}</span>
<span className="test-dictionary-count">(вся доска)</span>
</label>
{board.groups.length > 0 && !selectedPurchaseBoards.some(pb => pb.board_id === board.id && pb.group_name === null) && (
<div style={{ paddingLeft: '1.25rem', marginTop: '2px' }}>
{board.groups.map(group => (
<label key={group || '__ungrouped'} className="test-dictionary-item">
<input
type="checkbox"
checked={selectedPurchaseBoards.some(pb => pb.board_id === board.id && pb.group_name === (group || ''))}
onChange={(e) => {
const groupValue = group || ''
if (e.target.checked) {
setSelectedPurchaseBoards(prev => [...prev, { board_id: board.id, group_name: groupValue }])
} else {
setSelectedPurchaseBoards(prev => prev.filter(pb => !(pb.board_id === board.id && pb.group_name === groupValue)))
}
}}
/>
<span className="test-dictionary-name">{group || 'Остальные'}</span>
</label>
))}
</div>
)}
</div>
))}
{availableBoards.length === 0 && (
<div className="test-no-dictionaries">
Нет доступных досок. Создайте доску в разделе "Товары".
</div>
)}
</div>
</div>
</div>
)}
</div>
)}
{/* Информация о связанном желании */}
{wishlistInfo && (
<div className="form-group">
<div className="wishlist-link-info">
<span className="wishlist-link-text">
Связана с желанием: <strong>{wishlistInfo.name}</strong>
</span>
<div style={{ marginTop: '12px' }}>
<label htmlFor="reward_policy" style={{ display: 'block', marginBottom: '4px' }}>Политика награждения:</label>
<select
id="reward_policy"
value={rewardPolicy}
onChange={(e) => setRewardPolicy(e.target.value)}
className="form-input"
>
<option value="personal">Личная</option>
<option value="general">Общая</option>
</select>
<small style={{ color: '#666', fontSize: '0.9em', display: 'block', marginTop: '4px' }}>
{rewardPolicy === 'personal'
? 'Задача выполняется только если вы сами завершили желание. Если другой пользователь завершит желание, задача будет удалена.'
: 'Задача выполняется если кто-либо (неважно кто) отметил желание завершённым.'}
</small>
</div>
</div>
</div>
)}
{!wishlistInfo && ( {!wishlistInfo && (
<div className="form-group"> <div className="form-group">
<label htmlFor="repetition_period">Повторения</label> <label htmlFor="repetition_period">Повторения</label>
@@ -1075,7 +1413,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
id="reward_message" id="reward_message"
value={rewardMessage} value={rewardMessage}
onChange={(e) => setRewardMessage(e.target.value)} onChange={(e) => setRewardMessage(e.target.value)}
placeholder="Используйте ${0}, $0 для указания проектов (\\$0 для экранирования)" placeholder="Используйте $name для имени задачи, ${0}, $0 для проектов (\\$0 для экранирования)"
className="form-textarea" className="form-textarea"
rows={3} rows={3}
/> />
@@ -1105,7 +1443,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
placeholder="Score" placeholder="Score"
className="form-input reward-score-input" className="form-input reward-score-input"
/> />
{progressionBase && ( {progressionBase && !isTest && !isPurchase && (
<button <button
type="button" type="button"
tabIndex={0} tabIndex={0}
@@ -1128,133 +1466,19 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
)} )}
</div> </div>
{!isTest && ( {!isTest && !isPurchase && (
<div className="form-group"> <div className="complete-at-end-of-day-checkbox" style={{ marginTop: '1rem' }}>
<div className="subtasks-header"> <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.875rem', cursor: 'pointer' }}>
<label>Подзадачи</label>
<button type="button" onClick={handleAddSubtask} className="add-subtask-button" title="Добавить подзадачу">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
{subtasks.map((subtask, index) => (
<div key={index} className="subtask-form-item">
<div className="subtask-header-row">
<div className="subtask-position-controls">
<button
type="button"
onClick={() => handleMoveSubtaskUp(index)}
className="move-subtask-button"
disabled={index === 0}
title="Переместить вверх"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
<button
type="button"
onClick={() => handleMoveSubtaskDown(index)}
className="move-subtask-button"
disabled={index === subtasks.length - 1}
title="Переместить вниз"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
</div>
<input <input
type="text" type="checkbox"
value={subtask.name} checked={defaultAutoComplete}
onChange={(e) => handleSubtaskChange(index, 'name', e.target.value)} onChange={(e) => setDefaultAutoComplete(e.target.checked)}
placeholder="Название подзадачи"
className="form-input subtask-name-input"
/> />
<button Автовыполнение по-умолчанию
type="button" </label>
onClick={() => handleRemoveSubtask(index)} <small style={{ color: '#666', fontSize: '0.8em', marginLeft: '1.5rem' }}>
className="remove-subtask-button" Задача будет выполняться автоматически в конце каждого дня
title="Удалить подзадачу" </small>
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
<textarea
value={subtask.reward_message}
onChange={(e) => handleSubtaskRewardMessageChange(index, e.target.value)}
placeholder="Сообщение награды (опционально)"
className="form-textarea"
rows={2}
/>
{subtask.rewards && subtask.rewards.length > 0 && (
<div className="subtask-rewards">
{subtask.rewards.map((reward, rIndex) => {
return (
<div key={rIndex} className="reward-item">
<span className="reward-number">{rIndex}</span>
<input
type="text"
value={reward.project_name}
onChange={(e) => {
const newSubtasks = [...subtasks]
newSubtasks[index].rewards[rIndex].project_name = e.target.value
setSubtasks(newSubtasks)
}}
placeholder="Проект"
className="form-input reward-project-input"
list={`subtask-projects-${index}-${rIndex}`}
/>
<datalist id={`subtask-projects-${index}-${rIndex}`}>
{projects.map(p => (
<option key={p.project_id} value={p.project_name} />
))}
</datalist>
<input
type="number"
step="any"
value={reward.value}
onChange={(e) => {
const newSubtasks = [...subtasks]
newSubtasks[index].rewards[rIndex].value = e.target.value
setSubtasks(newSubtasks)
}}
placeholder="Score"
className="form-input reward-score-input"
/>
{progressionBase && (
<button
type="button"
tabIndex={0}
className={`progression-button progression-button-subtask ${reward.use_progression ? 'progression-button-filled' : 'progression-button-outlined'}`}
onMouseDown={(e) => {
e.preventDefault()
const newSubtasks = [...subtasks]
newSubtasks[index].rewards[rIndex].use_progression = !newSubtasks[index].rewards[rIndex].use_progression
setSubtasks(newSubtasks)
}}
title={reward.use_progression ? 'Отключить прогрессию' : 'Включить прогрессию'}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
</button>
)}
</div>
)})}
</div>
)}
</div>
))}
</div> </div>
)} )}
@@ -1263,23 +1487,6 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
<div className="error-message">{error}</div> <div className="error-message">{error}</div>
)} )}
<div className="form-actions">
<SubmitButton
type="submit"
loading={loading}
disabled={isDeleting}
>
Сохранить
</SubmitButton>
{taskId && (
<DeleteButton
onClick={handleDelete}
loading={isDeleting}
disabled={loading}
title="Удалить задачу"
/>
)}
</div>
</form> </form>
{toastMessage && ( {toastMessage && (
<Toast <Toast
@@ -1291,6 +1498,95 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
</> </>
)} )}
</div> </div>
{isActive ? createPortal(
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
padding: '0.75rem 1rem',
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
background: 'linear-gradient(to top, white 60%, rgba(255,255,255,0))',
zIndex: 1500,
display: 'flex',
justifyContent: 'center',
gap: '0.75rem',
}}>
<button
type="submit"
form="task-form-element"
disabled={loading || isDeleting}
style={{
flex: 1,
maxWidth: '42rem',
padding: '0.875rem',
background: loading ? undefined : 'linear-gradient(to right, #10b981, #059669)',
backgroundColor: loading ? '#9ca3af' : undefined,
color: 'white',
border: 'none',
borderRadius: '0.5rem',
fontSize: '1rem',
fontWeight: 600,
cursor: (loading || isDeleting) ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1,
transition: 'all 0.2s',
}}
>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
{taskId && (
<button
type="button"
onClick={openActionMenu}
disabled={loading || isDeleting || isCopying}
style={{
width: '52px',
height: '52px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
color: '#059669',
border: '2px solid #059669',
borderRadius: '0.5rem',
fontSize: '1.25rem',
fontWeight: 700,
cursor: (loading || isDeleting || isCopying) ? 'not-allowed' : 'pointer',
lineHeight: 1,
flexShrink: 0,
padding: 0,
boxSizing: 'border-box',
transition: 'all 0.2s',
}}
title="Действия"
>
</button>
)}
</div>,
document.body
) : null}
{showActionMenu && createPortal(
<div className="wishlist-modal-overlay" style={{ zIndex: 2000 }} onClick={closeActionMenu}>
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
<div className="wishlist-modal-header">
<h3>{name}</h3>
</div>
<div className="wishlist-modal-actions">
{!currentWishlistId && (
<button className="wishlist-modal-copy" onClick={handleCopy}>
Копировать
</button>
)}
<button className="wishlist-modal-delete" onClick={handleDelete}>
Удалить
</button>
</div>
</div>
</div>,
document.body
)}
</>
) )
} }

View File

@@ -21,7 +21,7 @@
.task-search-input { .task-search-input {
width: 100%; width: 100%;
padding: 0.75rem 5rem 0.75rem 3rem; padding: 0.75rem 5.5rem 0.75rem 3rem;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 1rem; font-size: 1rem;
@@ -40,15 +40,15 @@
color: #9ca3af; color: #9ca3af;
} }
/* Кнопка переключения группировки */ /* Кнопка переключения группировки — всегда у правого края */
.task-grouping-toggle { .task-grouping-toggle {
position: absolute; position: absolute;
right: 1rem; /* Такой же отступ, как у иконки лупы */ right: 1rem;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
background: none; background: none;
border: none; border: none;
color: #6366f1; color: #9ca3af;
cursor: pointer; cursor: pointer;
padding: 0.25rem; padding: 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;
@@ -61,13 +61,14 @@
} }
.task-grouping-toggle:hover { .task-grouping-toggle:hover {
background: rgba(99, 102, 241, 0.1); background: #f3f4f6;
color: #4f46e5; color: #6b7280;
} }
/* Крестик очистки — слева от кнопки группировки, когда есть текст */
.task-search-clear { .task-search-clear {
position: absolute; position: absolute;
right: 0.75rem; /* Остаётся на месте */ right: 3.5rem;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
background: none; background: none;
@@ -255,11 +256,41 @@
margin-left: 0.25rem; margin-left: 0.25rem;
} }
.task-progression-capsule {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
background: #f3f4f6;
border-radius: 9999px;
padding: 0.15rem 0.5rem;
cursor: pointer;
transition: background 0.2s;
min-height: 1.5rem;
}
.task-progression-capsule:hover {
background: #e5e7eb;
}
.task-progression-capsule--saving {
opacity: 0.6;
cursor: not-allowed;
}
.task-progression-icon { .task-progression-icon {
color: #9ca3af; color: #9ca3af;
flex-shrink: 0; flex-shrink: 0;
} }
.task-progression-value {
font-size: 0.8rem;
font-weight: 700;
color: #9ca3af;
min-width: 1rem;
text-align: center;
}
.task-infinite-icon { .task-infinite-icon {
color: #9ca3af; color: #9ca3af;
flex-shrink: 0; flex-shrink: 0;
@@ -288,7 +319,8 @@
border: none; border: none;
color: #6b7280; color: #6b7280;
cursor: pointer; cursor: pointer;
padding: 0.25rem; padding: 1rem;
margin: -1rem;
border-radius: 0.25rem; border-radius: 0.25rem;
transition: all 0.2s; transition: all 0.2s;
display: flex; display: flex;
@@ -318,9 +350,10 @@
.task-postpone-modal { .task-postpone-modal {
background: white; background: white;
border-radius: 0.5rem; border-radius: 0.5rem;
max-width: 400px; width: fit-content;
width: 90%; max-width: min(90%, 350px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
overflow: hidden;
} }
.task-postpone-modal-header { .task-postpone-modal-header {
@@ -490,6 +523,16 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.5rem; margin-top: 0.5rem;
overflow-x: auto;
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
width: 100%;
}
.task-postpone-quick-buttons::-webkit-scrollbar {
display: none;
} }
.task-postpone-quick-button { .task-postpone-quick-button {
@@ -502,6 +545,8 @@
background: white; background: white;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
flex-shrink: 0;
white-space: nowrap;
} }
.task-postpone-quick-button:hover:not(:disabled) { .task-postpone-quick-button:hover:not(:disabled) {
@@ -515,6 +560,21 @@
cursor: not-allowed; cursor: not-allowed;
} }
.task-postpone-quick-button-primary {
background: #3b82f6;
color: white;
border-color: #3b82f6;
display: flex;
align-items: center;
gap: 4px;
}
.task-postpone-quick-button-primary:hover:not(:disabled) {
background: #2563eb;
border-color: #2563eb;
color: white;
}
.task-postpone-input-group { .task-postpone-input-group {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -874,3 +934,13 @@
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3); box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
} }
.task-add-modal-button-purchase {
background: linear-gradient(to right, #27ae60, #229954);
color: white;
}
.task-add-modal-button-purchase:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.3);
}

View File

@@ -23,6 +23,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
const [isPostponing, setIsPostponing] = useState(false) const [isPostponing, setIsPostponing] = useState(false)
const [toast, setToast] = useState(null) const [toast, setToast] = useState(null)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [savingProgressionTaskId, setSavingProgressionTaskId] = useState(null)
// Режим группировки: 'project' (по проекту - по умолчанию) или 'group' (по группе) // Режим группировки: 'project' (по проекту - по умолчанию) или 'group' (по группе)
const [groupingMode, setGroupingMode] = useState(() => { const [groupingMode, setGroupingMode] = useState(() => {
// Восстанавливаем из localStorage, по умолчанию 'project' // Восстанавливаем из localStorage, по умолчанию 'project'
@@ -86,6 +87,17 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
return return
} }
// Для задач-закупок открываем экран закупок
const isPurchase = task.purchase_config_id != null
if (isPurchase) {
onNavigate?.('purchase', {
purchaseConfigId: task.purchase_config_id,
taskId: task.id,
taskName: task.name
})
return
}
// Для обычных задач открываем диалог подтверждения // Для обычных задач открываем диалог подтверждения
setSelectedTaskForDetail(task.id) setSelectedTaskForDetail(task.id)
} }
@@ -407,7 +419,19 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
} }
defaultDate.setHours(0, 0, 0, 0) defaultDate.setHours(0, 0, 0, 0)
setPostponeDate(formatDateToLocal(defaultDate)) const plannedStr = formatDateToLocal(defaultDate)
// Предвыбираем дату только если она не совпадает с текущей next_show_at (т.е. если чипс "По плану" будет показан)
let nextShowStr = null
if (task.next_show_at) {
const d = new Date(task.next_show_at)
d.setHours(0, 0, 0, 0)
nextShowStr = formatDateToLocal(d)
}
if (plannedStr !== nextShowStr) {
setPostponeDate(plannedStr)
} else {
setPostponeDate('')
}
} }
const handlePostponeSubmit = async () => { const handlePostponeSubmit = async () => {
@@ -429,12 +453,8 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
const handleDateSelect = (date) => { const handleDateSelect = (date) => {
if (!date) return if (!date) return
// Преобразуем дату в формат YYYY-MM-DD
const formattedDate = formatDateToLocal(date) const formattedDate = formatDateToLocal(date)
setPostponeDate(formattedDate) setPostponeDate(formattedDate)
// Применяем дату и закрываем модальное окно
if (selectedTaskForPostpone) { if (selectedTaskForPostpone) {
handlePostponeSubmitWithDate(formattedDate) handlePostponeSubmitWithDate(formattedDate)
} }
@@ -512,6 +532,42 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
} }
} }
const handleWithoutDateClick = async () => {
if (!selectedTaskForPostpone) return
setIsPostponing(true)
try {
const response = await authFetch(`${API_URL}/${selectedTaskForPostpone.id}/postpone`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ next_show_at: null }),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || 'Ошибка при переносе задачи')
}
if (onRefresh) {
onRefresh()
}
if (historyPushedForPostponeRef.current) {
window.history.back()
} else {
setSelectedTaskForPostpone(null)
setPostponeDate('')
}
} catch (err) {
console.error('Error postponing task:', err)
setToast({ message: err.message || 'Ошибка при переносе задачи', type: 'error' })
} finally {
setIsPostponing(false)
}
}
const toggleCompletedExpanded = (projectName) => { const toggleCompletedExpanded = (projectName) => {
setExpandedCompleted(prev => ({ setExpandedCompleted(prev => ({
...prev, ...prev,
@@ -519,6 +575,43 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
})) }))
} }
const handleProgressionChange = async (task, delta) => {
if (savingProgressionTaskId === task.id) return
const currentValue = task.draft_progression_value ?? 0
const newValue = currentValue + delta
setSavingProgressionTaskId(task.id)
try {
const response = await authFetch(`${API_URL}/${task.id}/draft`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
progression_value: newValue
}),
})
if (!response.ok) {
throw new Error('Ошибка при сохранении прогрессии')
}
setTasks(prevTasks =>
prevTasks.map(t =>
t.id === task.id
? { ...t, draft_progression_value: newValue }
: t
)
)
} catch (err) {
console.error('Error saving progression:', err)
setToast({ message: err.message || 'Ошибка при сохранении прогрессии', type: 'error' })
} finally {
setSavingProgressionTaskId(null)
}
}
// Получаем все проекты из задачи (теперь они приходят в task.project_names) // Получаем все проекты из задачи (теперь они приходят в task.project_names)
const getTaskProjects = (task) => { const getTaskProjects = (task) => {
if (task.project_names && Array.isArray(task.project_names)) { if (task.project_names && Array.isArray(task.project_names)) {
@@ -626,7 +719,8 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
nextShowDate.setHours(0, 0, 0, 0) nextShowDate.setHours(0, 0, 0, 0)
isCompleted = nextShowDate.getTime() > today.getTime() isCompleted = nextShowDate.getTime() > today.getTime()
} else { } else {
isCompleted = false // Задачи без даты (next_show_at = null) идут в выполненные
isCompleted = true
} }
groupKeys.forEach(groupKey => { groupKeys.forEach(groupKey => {
@@ -650,16 +744,29 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
Object.keys(groups).forEach(projectName => { Object.keys(groups).forEach(projectName => {
const group = groups[projectName] const group = groups[projectName]
// Сортируем невыполненные задачи: по completed DESC (больше завершений выше), затем по id ASC (раньше добавленные выше) // Сортируем невыполненные задачи: автовыполнение первыми, затем по алфавиту (name ASC), затем по id ASC
group.notCompleted.sort((a, b) => { group.notCompleted.sort((a, b) => {
if (b.completed !== a.completed) { // Задачи с автовыполнением (включая default_auto_complete) идут первыми
return b.completed - a.completed // DESC const aAuto = a.has_draft ? a.auto_complete : a.auto_complete || a.default_auto_complete
const bAuto = b.has_draft ? b.auto_complete : b.auto_complete || b.default_auto_complete
if (aAuto && !bAuto) return -1
if (!aAuto && bAuto) return 1
const nameCompare = (a.name || '').localeCompare(b.name || '')
if (nameCompare !== 0) {
return nameCompare
} }
return a.id - b.id // ASC return a.id - b.id // ASC
}) })
// Сортируем выполненные задачи: бесконечные первыми, затем по next_show_at ASC (ранние в начале), NULL в начале // Сортируем выполненные задачи: автовыполнение первыми, затем бесконечные, затем по next_show_at ASC (ранние в начале), NULL в начале
group.completed.sort((a, b) => { group.completed.sort((a, b) => {
// Задачи с автовыполнением (включая default_auto_complete) идут первыми
const aAuto = a.has_draft ? a.auto_complete : a.auto_complete || a.default_auto_complete
const bAuto = b.has_draft ? b.auto_complete : b.auto_complete || b.default_auto_complete
if (aAuto && !bAuto) return -1
if (!aAuto && bAuto) return 1
// Проверяем, является ли задача бесконечной // Проверяем, является ли задача бесконечной
const hasZeroPeriodA = a.repetition_period && isZeroPeriod(a.repetition_period) const hasZeroPeriodA = a.repetition_period && isZeroPeriod(a.repetition_period)
const hasZeroDateA = a.repetition_date && isZeroDate(a.repetition_date) const hasZeroDateA = a.repetition_date && isZeroDate(a.repetition_date)
@@ -674,10 +781,10 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
if (!isInfiniteA && isInfiniteB) return 1 if (!isInfiniteA && isInfiniteB) return 1
if (isInfiniteA && isInfiniteB) return 0 if (isInfiniteA && isInfiniteB) return 0
// Для остальных: NULL значения идут первыми // Для остальных: NULL значения идут последними
if (!a.next_show_at && !b.next_show_at) return 0 if (!a.next_show_at && !b.next_show_at) return 0
if (!a.next_show_at) return -1 if (!a.next_show_at) return 1
if (!b.next_show_at) return 1 if (!b.next_show_at) return -1
// Сравниваем даты // Сравниваем даты
const dateA = new Date(a.next_show_at).getTime() const dateA = new Date(a.next_show_at).getTime()
@@ -720,7 +827,8 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
const hasProgression = task.has_progression || task.progression_base != null const hasProgression = task.has_progression || task.progression_base != null
const hasSubtasks = task.subtasks_count > 0 const hasSubtasks = task.subtasks_count > 0
const isTest = task.config_id != null const isTest = task.config_id != null
const showDetailOnCheckmark = !isTest const isPurchase = task.purchase_config_id != null
const showDetailOnCheckmark = !isTest && !isPurchase
const isWishlist = task.wishlist_id != null const isWishlist = task.wishlist_id != null
// Проверяем бесконечную задачу: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует) // Проверяем бесконечную задачу: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует)
@@ -743,9 +851,9 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
> >
<div className="task-item-content"> <div className="task-item-content">
<div <div
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''} ${task.auto_complete ? 'task-checkmark-auto-complete' : ''}`} className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''} ${(task.has_draft ? task.auto_complete : task.auto_complete || task.default_auto_complete) ? 'task-checkmark-auto-complete' : ''}`}
onClick={(e) => handleCheckmarkClick(task, e)} onClick={(e) => handleCheckmarkClick(task, e)}
title={isTest ? 'Запустить тест' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу')} title={isTest ? 'Запустить тест' : (isPurchase ? 'Открыть закупки' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу'))}
> >
{isTest ? ( {isTest ? (
<svg <svg
@@ -761,6 +869,20 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path> <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path> <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg> </svg>
) : isPurchase ? (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2 7h20l-2 13a2 2 0 0 1-2 1.5H6a2 2 0 0 1-2-1.5L2 7z"></path>
<path d="M9 7V6a3 3 0 0 1 6 0v1"></path>
</svg>
) : isWishlist ? ( ) : isWishlist ? (
<> <>
<svg <svg
@@ -797,7 +919,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" /> <path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
</svg> </svg>
)} )}
{task.auto_complete && !isTest && !isWishlist && ( {(task.has_draft ? task.auto_complete : task.auto_complete || task.default_auto_complete) && !isTest && !isWishlist && (
<svg <svg
className="task-checkmark-auto-complete-icon" className="task-checkmark-auto-complete-icon"
width="16" width="16"
@@ -815,7 +937,11 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
<div className="task-name"> <div className="task-name">
{task.name} {task.name}
{hasSubtasks && ( {hasSubtasks && (
<span className="task-subtasks-count">(+{task.subtasks_count})</span> <span className="task-subtasks-count">
{task.draft_subtasks_count != null && task.draft_subtasks_count > 0
? `(${task.draft_subtasks_count}/${task.subtasks_count})`
: `(${task.subtasks_count})`}
</span>
)} )}
<span className="task-badge-bar"> <span className="task-badge-bar">
{!isOneTime && !isInfinite && !isWishlist && ( {!isOneTime && !isInfinite && !isWishlist && (
@@ -855,21 +981,34 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
</svg> </svg>
)} )}
{hasProgression && ( {hasProgression && (
<span
className={`task-progression-capsule ${savingProgressionTaskId === task.id ? 'task-progression-capsule--saving' : ''}`}
onClick={(e) => {
e.stopPropagation()
if (savingProgressionTaskId !== task.id) {
handleProgressionChange(task, task.progression_base)
}
}}
title="Задача с прогрессией"
>
<svg <svg
className="task-progression-icon" className="task-progression-icon"
width="16" width="14"
height="16" height="14"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
title="Задача с прогрессией"
> >
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline> <polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
<polyline points="17 6 23 6 23 12"></polyline> <polyline points="17 6 23 6 23 12"></polyline>
</svg> </svg>
{(task.draft_progression_value != null || (task.has_draft ? task.auto_complete : task.auto_complete || task.default_auto_complete)) && (
<span className="task-progression-value">{task.draft_progression_value ?? task.default_progress ?? task.progression_base}</span>
)}
</span>
)} )}
</span> </span>
</div> </div>
@@ -885,8 +1024,8 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
const tomorrowNormalized = new Date(todayNormalized) const tomorrowNormalized = new Date(todayNormalized)
tomorrowNormalized.setDate(tomorrowNormalized.getDate() + 1) tomorrowNormalized.setDate(tomorrowNormalized.getDate() + 1)
// Не показываем текст если дата равна сегодня // Не показываем дату если она сегодня или в прошлом
if (showDateNormalized.getTime() === todayNormalized.getTime()) { if (showDateNormalized.getTime() <= todayNormalized.getTime()) {
return null return null
} }
@@ -997,15 +1136,14 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
title={groupingMode === 'project' ? 'Группировка по проекту' : 'Группировка по группе'} title={groupingMode === 'project' ? 'Группировка по проекту' : 'Группировка по группе'}
> >
{groupingMode === 'project' ? ( {groupingMode === 'project' ? (
// Иконка "папка" для группировки по проекту // Иконка "папка" для группировки по проекту (filled)
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path> <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg> </svg>
) : ( ) : (
// Иконка "тег" для группировки по группе // Иконка "тег" для группировки по группе (filled, с вырезом под дырку)
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path> <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z M7 5.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"/>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg> </svg>
)} )}
</button> </button>
@@ -1109,6 +1247,31 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
const isToday = nextShowAtStr === todayStr const isToday = nextShowAtStr === todayStr
const isTomorrow = nextShowAtStr === tomorrowStr const isTomorrow = nextShowAtStr === tomorrowStr
// Не показывать «Сегодня», если next_show_at уже сегодня или в прошлом
const showTodayChip = !nextShowAtStr || nextShowAtStr > todayStr
// Дата "по плану" (repetition_date / repetition_period или завтра)
const task = selectedTaskForPostpone
let plannedDate
const now = new Date()
now.setHours(0, 0, 0, 0)
if (task.repetition_date) {
const nextDate = calculateNextDateFromRepetitionDate(task.repetition_date)
if (nextDate) plannedDate = nextDate
} else if (task.repetition_period && !isZeroPeriod(task.repetition_period)) {
const nextDate = calculateNextDateFromRepetitionPeriod(task.repetition_period)
if (nextDate) plannedDate = nextDate
}
if (!plannedDate) {
plannedDate = new Date(now)
plannedDate.setDate(plannedDate.getDate() + 1)
}
plannedDate.setHours(0, 0, 0, 0)
const plannedDateStr = formatDateToLocal(plannedDate)
const plannedNorm = plannedDateStr.slice(0, 10)
const nextShowNorm = nextShowAtStr ? String(nextShowAtStr).slice(0, 10) : ''
// Показываем кнопку, если текущий next_show_at не совпадает с датой по плану
const isCurrentDatePlanned = plannedNorm && nextShowNorm && plannedNorm === nextShowNorm
const modalContent = ( const modalContent = (
<div className="task-postpone-modal-overlay" onClick={handlePostponeClose}> <div className="task-postpone-modal-overlay" onClick={handlePostponeClose}>
@@ -1135,7 +1298,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
/> />
</div> </div>
<div className="task-postpone-quick-buttons"> <div className="task-postpone-quick-buttons">
{!isToday && ( {showTodayChip && (
<button <button
onClick={handleTodayClick} onClick={handleTodayClick}
className="task-postpone-quick-button" className="task-postpone-quick-button"
@@ -1153,6 +1316,24 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
Завтра Завтра
</button> </button>
)} )}
{!isCurrentDatePlanned && (
<button
onClick={() => handlePostponeSubmitWithDate(plannedDateStr)}
className="task-postpone-quick-button"
disabled={isPostponing}
>
По плану
</button>
)}
{selectedTaskForPostpone?.next_show_at && (
<button
onClick={handleWithoutDateClick}
className="task-postpone-quick-button"
disabled={isPostponing}
>
Без даты
</button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -242,7 +242,7 @@
align-content: start; align-content: start;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 4rem 1rem 1rem 1rem; padding: 4rem 1rem 5rem 1rem;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
min-height: 0; min-height: 0;
@@ -331,7 +331,7 @@
align-content: start; align-content: start;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 4rem 1rem 1rem 1rem; padding: 4rem 1rem 5rem 1rem;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
min-height: 0; min-height: 0;

Some files were not shown because too many files have changed in this diff Show More