Compare commits
168 Commits
9345b5ab5c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60a0efafad | ||
|
|
48fa192cdc | ||
|
|
06cc1a4b3b | ||
|
|
e2966aedd1 | ||
|
|
d561683e12 | ||
|
|
fdc3e01443 | ||
|
|
d4f0064aa7 | ||
|
|
87126a480a | ||
|
|
837ddbe4ed | ||
|
|
44bbb46a1a | ||
|
|
1795a66ee1 | ||
|
|
84b5aa9390 | ||
|
|
e8a766205f | ||
|
|
6e7ebb9aa3 | ||
|
|
101f4e27ed | ||
|
|
f1c12fd81a | ||
|
|
664adcfaa5 | ||
|
|
8acfaf19ac | ||
|
|
2fde471076 | ||
|
|
5f05b77d36 | ||
|
|
eb68eca63f | ||
|
|
b82db8d80f | ||
|
|
b3403ff23a | ||
|
|
b8373eb986 | ||
|
|
df17ecf943 | ||
|
|
42ea241b7c | ||
|
|
3a06d9148c | ||
|
|
b1f4fdd449 | ||
|
|
01cd0e9003 | ||
|
|
6dc3ec828f | ||
|
|
0a8ff4dfab | ||
|
|
dff929c52c | ||
|
|
2104fea5e2 | ||
|
|
caa8ac6ebb | ||
|
|
49f67ec36d | ||
|
|
171befdf05 | ||
|
|
0c6ba5c8fb | ||
|
|
1b7e2cd887 | ||
|
|
53593fdc3d | ||
|
|
b888d056e4 | ||
|
|
cbf20ce679 | ||
|
|
91bba98b65 | ||
|
|
c927f55fd6 | ||
|
|
ae13b2bcac | ||
|
|
85e9a6f48b | ||
|
|
7309deb98f | ||
|
|
10f370b0da | ||
|
|
f59453783a | ||
|
|
5ea58476cb | ||
|
|
1876595005 | ||
|
|
a4dcc62a37 | ||
|
|
8749f21ac8 | ||
|
|
912ae7a857 | ||
|
|
7ec76ea59b | ||
|
|
4f69481efe | ||
|
|
b85b85a27f | ||
|
|
710adff385 | ||
|
|
9c915d4675 | ||
|
|
7e0f979ae3 | ||
|
|
d42535f36e | ||
|
|
95985f97f2 | ||
|
|
7889922d9b | ||
|
|
97753f4465 | ||
|
|
b1ffb7ba7d | ||
|
|
c232bb40a3 | ||
|
|
3bd864d41a | ||
|
|
193b4138d9 | ||
|
|
06b7c614ed | ||
|
|
b51b9421be | ||
|
|
0dca57964d | ||
|
|
95ed1b48fe | ||
|
|
6f76c4a25c | ||
|
|
c8a47ff408 | ||
|
|
4ce8ba66cc | ||
|
|
c42cdfe35b | ||
|
|
4971b2a305 | ||
|
|
64493b9c1f | ||
|
|
1df00bbefd | ||
|
|
0b5106458a | ||
|
|
02c8b7537a | ||
|
|
2a61b17187 | ||
|
|
3624cfffbd | ||
|
|
a35797a1f9 | ||
|
|
20778d6d39 | ||
|
|
ac1f6c3a47 | ||
|
|
25f193a061 | ||
|
|
ea1720506a | ||
|
|
dc50433eb1 | ||
|
|
4b28d90d68 | ||
|
|
fb1ccd7831 | ||
|
|
636f53eb04 | ||
|
|
786a03bf86 | ||
|
|
eb5e5a5476 | ||
|
|
ebd1398a81 | ||
|
|
3cac8d0452 | ||
|
|
e962f49407 | ||
|
|
79fa0538f9 | ||
|
|
99b0eba701 | ||
|
|
23f16a8bef | ||
|
|
a441a3d1e7 | ||
|
|
7957776f53 | ||
|
|
a693d3fa4b | ||
|
|
3a1b836ece | ||
|
|
17e6bbf9f1 | ||
|
|
c9a8b994eb | ||
|
|
b47d50f51c | ||
|
|
37d5c87a55 | ||
|
|
c911950cc1 | ||
|
|
2ec5860d78 | ||
|
|
5d257cd0f8 | ||
|
|
e7ce7b2092 | ||
|
|
81de26e586 | ||
|
|
4169285394 | ||
|
|
c8fead4034 | ||
|
|
01631ff13b | ||
|
|
60fca2d93c | ||
|
|
cd51b097c8 | ||
|
|
fd416b4bd5 | ||
|
|
537ad9e06e | ||
|
|
b0155e6cbe | ||
|
|
999fa15267 | ||
|
|
67334dde3c | ||
|
|
7e51b0cb9f | ||
|
|
3a6f223aac | ||
|
|
e1b6fcb918 | ||
|
|
f54a0fff14 | ||
|
|
80800da839 | ||
|
|
c7b684491c | ||
|
|
7f51411175 | ||
|
|
25317997e5 | ||
|
|
7c5b80b314 | ||
|
|
368f10bcdd | ||
|
|
ff16f98736 | ||
|
|
027063dfb9 | ||
|
|
7fdcbb75da | ||
|
|
2b7b056562 | ||
|
|
20773a29b7 | ||
|
|
6caed05c9f | ||
|
|
92453def91 | ||
|
|
9f3637113d | ||
|
|
98427f5d0e | ||
|
|
81dc23b501 | ||
|
|
91d4a7337c | ||
|
|
0f1f5e3943 | ||
|
|
8ea71ef95f | ||
|
|
e457113fc9 | ||
|
|
c04422ed69 | ||
|
|
7bbd732d72 | ||
|
|
ad52cf93ea | ||
|
|
ba6d823354 | ||
|
|
e41abb2bff | ||
|
|
2236f95ffa | ||
|
|
07c4deaf70 | ||
|
|
cea2c341a2 | ||
|
|
bff62c0b8f | ||
|
|
a5e3396017 | ||
|
|
41f8df36a9 | ||
|
|
76049b3da5 | ||
|
|
9cfb988960 | ||
|
|
242183a422 | ||
|
|
29bd50acab | ||
|
|
72da547b80 | ||
|
|
1fe3819be6 | ||
|
|
1f5f3299f8 | ||
|
|
7012f1c8ed | ||
|
|
2128e1b69c | ||
|
|
5b53615d1a | ||
|
|
b05bd51b5b |
8
.claude/settings.json
Normal file
8
.claude/settings.json
Normal 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 '[]')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "Создание дампа базы данных с продакшена",
|
||||||
|
|||||||
37
.cursor/plans/normalized_total_score_fix_migration.plan.md
Normal file
37
.cursor/plans/normalized_total_score_fix_migration.plan.md
Normal 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)
|
||||||
|
|
||||||
@@ -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
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"claudeCode.allowDangerouslySkipPermissions": true
|
||||||
|
}
|
||||||
19
.vscode/tasks.json
vendored
19
.vscode/tasks.json
vendored
@@ -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
65
CLAUDE.md
Normal 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
|
||||||
|
- "Подними версию и запушь" → спросить какой тип
|
||||||
|
- "Запушь изменения" → пушить без изменения версии
|
||||||
4
init.sh
4
init.sh
@@ -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 ""
|
||||||
|
|
||||||
|
|||||||
@@ -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|project_score_sample_mv/refresh)$ {
|
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;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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.';
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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.';
|
||||||
@@ -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;
|
||||||
@@ -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.';
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS shopping_items;
|
||||||
|
DROP TABLE IF EXISTS shopping_board_members;
|
||||||
|
DROP TABLE IF EXISTS shopping_boards;
|
||||||
50
play-life-backend/migrations/000026_shopping_list.up.sql
Normal file
50
play-life-backend/migrations/000026_shopping_list.up.sql
Normal 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);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE shopping_items DROP COLUMN description;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE shopping_items ADD COLUMN description TEXT;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS shopping_item_history;
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
24
play-life-backend/migrations/000029_purchase_tasks.up.sql
Normal file
24
play-life-backend/migrations/000029_purchase_tasks.up.sql
Normal 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);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN IF EXISTS priorities_confirmed_year,
|
||||||
|
DROP COLUMN IF EXISTS priorities_confirmed_week;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS shopping_volume_records;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE shopping_volume_records DROP COLUMN IF EXISTS next_show_at;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE shopping_volume_records ADD COLUMN next_show_at TIMESTAMP;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS board_archives;
|
||||||
10
play-life-backend/migrations/000033_board_archives.up.sql
Normal file
10
play-life-backend/migrations/000033_board_archives.up.sql
Normal 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);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE tasks DROP COLUMN default_auto_complete;
|
||||||
|
ALTER TABLE tasks DROP COLUMN default_progress;
|
||||||
@@ -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;
|
||||||
@@ -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.
@@ -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|project_score_sample_mv/refresh)$ {
|
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;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "5.0.0",
|
"version": "6.27.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
101
play-life-web/src/components/ArchivedBoards.css
Normal file
101
play-life-web/src/components/ArchivedBoards.css
Normal 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;
|
||||||
|
}
|
||||||
171
play-life-web/src/components/ArchivedBoards.jsx
Normal file
171
play-life-web/src/components/ArchivedBoards.jsx
Normal 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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
383
play-life-web/src/components/PurchaseScreen.jsx
Normal file
383
play-life-web/src/components/PurchaseScreen.jsx
Normal 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
|
||||||
460
play-life-web/src/components/ShoppingBoardForm.jsx
Normal file
460
play-life-web/src/components/ShoppingBoardForm.jsx
Normal 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
|
||||||
149
play-life-web/src/components/ShoppingBoardJoinPreview.jsx
Normal file
149
play-life-web/src/components/ShoppingBoardJoinPreview.jsx
Normal 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
|
||||||
395
play-life-web/src/components/ShoppingItemDetail.jsx
Normal file
395
play-life-web/src/components/ShoppingItemDetail.jsx
Normal 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
|
||||||
19
play-life-web/src/components/ShoppingItemForm.css
Normal file
19
play-life-web/src/components/ShoppingItemForm.css
Normal 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;
|
||||||
|
}
|
||||||
369
play-life-web/src/components/ShoppingItemForm.jsx
Normal file
369
play-life-web/src/components/ShoppingItemForm.jsx
Normal 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
|
||||||
157
play-life-web/src/components/ShoppingItemHistory.jsx
Normal file
157
play-life-web/src/components/ShoppingItemHistory.jsx
Normal 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
|
||||||
72
play-life-web/src/components/ShoppingList.css
Normal file
72
play-life-web/src/components/ShoppingList.css
Normal 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;
|
||||||
|
}
|
||||||
660
play-life-web/src/components/ShoppingList.jsx
Normal file
660
play-life-web/src/components/ShoppingList.jsx
Normal 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
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { useAuth } from './auth/AuthContext'
|
import { useAuth } from './auth/AuthContext'
|
||||||
import LoadingError from './LoadingError'
|
import LoadingError from './LoadingError'
|
||||||
import './TestWords.css'
|
import './TestWords.css'
|
||||||
@@ -8,7 +9,7 @@ const API_URL = '/api'
|
|||||||
|
|
||||||
const DEFAULT_TEST_WORD_COUNT = 10
|
const DEFAULT_TEST_WORD_COUNT = 10
|
||||||
|
|
||||||
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards, taskId: initialTaskId }) {
|
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards, taskId: initialTaskId, isActive }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
||||||
const configId = initialConfigId || null
|
const configId = initialConfigId || null
|
||||||
@@ -26,11 +27,15 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [showPreview, setShowPreview] = useState(false) // Показывать ли экран предпросмотра
|
const [showPreview, setShowPreview] = useState(false) // Показывать ли экран предпросмотра
|
||||||
const [showResults, setShowResults] = useState(false) // Показывать ли экран результатов
|
const [showResults, setShowResults] = useState(false) // Показывать ли экран результатов
|
||||||
|
const [isClosing, setIsClosing] = useState(false) // Ожидание завершения сохранения при нажатии «Закончить»
|
||||||
|
|
||||||
const isFinishingRef = useRef(false)
|
const isFinishingRef = useRef(false)
|
||||||
const wordStatsRef = useRef({})
|
const wordStatsRef = useRef({})
|
||||||
const processingRef = useRef(false)
|
const processingRef = useRef(false)
|
||||||
const cardsShownRef = useRef(0) // Синхронный счётчик для избежания race condition
|
const cardsShownRef = useRef(0) // Синхронный счётчик для избежания race condition
|
||||||
|
const saveProgressPromiseRef = useRef(null) // Промис сохранения прогресса (null = ещё не запущено)
|
||||||
|
const savePayloadRef = useRef(null) // Данные для сохранения при нажатии «Закончить» (если сохранение ещё не запускали)
|
||||||
|
const [currentSide, setCurrentSide] = useState(null) // Текущая случайная сторона карточки ('word' или 'translation')
|
||||||
|
|
||||||
// Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами
|
// Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами
|
||||||
// excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка)
|
// excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка)
|
||||||
@@ -210,6 +215,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
setWords([])
|
setWords([])
|
||||||
setTestWords([])
|
setTestWords([])
|
||||||
setCurrentWord(null)
|
setCurrentWord(null)
|
||||||
|
setCurrentSide(null)
|
||||||
setFlippedCards(new Set())
|
setFlippedCards(new Set())
|
||||||
setWordStats({})
|
setWordStats({})
|
||||||
wordStatsRef.current = {}
|
wordStatsRef.current = {}
|
||||||
@@ -221,6 +227,8 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
setShowResults(false) // Сбрасываем экран результатов при загрузке нового теста
|
setShowResults(false) // Сбрасываем экран результатов при загрузке нового теста
|
||||||
isFinishingRef.current = false
|
isFinishingRef.current = false
|
||||||
processingRef.current = false
|
processingRef.current = false
|
||||||
|
saveProgressPromiseRef.current = null
|
||||||
|
savePayloadRef.current = null
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
const loadWords = async () => {
|
const loadWords = async () => {
|
||||||
@@ -307,21 +315,12 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Завершение теста
|
// Завершение теста: показываем результаты сразу, сохранение на бэкенде идёт в фоне (промис в ref для ожидания в handleFinish)
|
||||||
const finishTest = async () => {
|
const finishTest = () => {
|
||||||
if (isFinishingRef.current) return
|
if (isFinishingRef.current) return
|
||||||
isFinishingRef.current = true
|
isFinishingRef.current = true
|
||||||
|
|
||||||
// Сразу показываем экран результатов, чтобы предотвратить показ новых карточек
|
|
||||||
setShowResults(true)
|
|
||||||
|
|
||||||
// Отправляем статистику на бэкенд
|
|
||||||
try {
|
|
||||||
// Получаем актуальные данные из состояния
|
|
||||||
const currentStats = wordStatsRef.current
|
const currentStats = wordStatsRef.current
|
||||||
|
|
||||||
// Отправляем все слова, которые были в тесте, с их текущими значениями
|
|
||||||
// Бэкенд сам обновит только измененные поля
|
|
||||||
const updates = words.map(word => {
|
const updates = words.map(word => {
|
||||||
const stats = currentStats[word.id] || {
|
const stats = currentStats[word.id] || {
|
||||||
success: word.success || 0,
|
success: word.success || 0,
|
||||||
@@ -339,7 +338,9 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
console.log('No words to send - empty test')
|
saveProgressPromiseRef.current = Promise.resolve()
|
||||||
|
savePayloadRef.current = null
|
||||||
|
setShowResults(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,12 +349,10 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
requestBody.config_id = configId
|
requestBody.config_id = configId
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Sending test progress to backend:', {
|
savePayloadRef.current = { requestBody, configId, taskId }
|
||||||
wordsCount: updates.length,
|
|
||||||
configId: configId,
|
|
||||||
requestBody
|
|
||||||
})
|
|
||||||
|
|
||||||
|
const doSave = async () => {
|
||||||
|
try {
|
||||||
const response = await authFetch(`${API_URL}/test/progress`, {
|
const response = await authFetch(`${API_URL}/test/progress`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -365,10 +364,8 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
throw new Error(`Server responded with status ${response.status}: ${errorText}`)
|
throw new Error(`Server responded with status ${response.status}: ${errorText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseData = await response.json().catch(() => ({}))
|
await response.json().catch(() => ({}))
|
||||||
console.log('Test progress saved successfully:', responseData)
|
|
||||||
|
|
||||||
// Если есть taskId, выполняем задачу
|
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
try {
|
try {
|
||||||
const completeResponse = await authFetch(`${API_URL}/tasks/${taskId}/complete`, {
|
const completeResponse = await authFetch(`${API_URL}/tasks/${taskId}/complete`, {
|
||||||
@@ -377,9 +374,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (completeResponse.ok) {
|
if (!completeResponse.ok) {
|
||||||
console.log('Task completed successfully')
|
|
||||||
} else {
|
|
||||||
console.error('Failed to complete task:', await completeResponse.text())
|
console.error('Failed to complete task:', await completeResponse.text())
|
||||||
}
|
}
|
||||||
} catch (taskErr) {
|
} catch (taskErr) {
|
||||||
@@ -388,10 +383,14 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save progress:', err)
|
console.error('Failed to save progress:', err)
|
||||||
// Можно показать уведомление пользователю, но не блокируем показ результатов
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const promise = doSave()
|
||||||
|
saveProgressPromiseRef.current = promise
|
||||||
|
setShowResults(true)
|
||||||
|
}
|
||||||
|
|
||||||
// Берём карточку из пула (getAndDelete) и показываем её
|
// Берём карточку из пула (getAndDelete) и показываем её
|
||||||
const showNextCard = () => {
|
const showNextCard = () => {
|
||||||
// Проверяем, не завершился ли тест
|
// Проверяем, не завершился ли тест
|
||||||
@@ -441,6 +440,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
cardsShownRef.current = nextCardsShown
|
cardsShownRef.current = nextCardsShown
|
||||||
|
|
||||||
setCurrentWord(validWord)
|
setCurrentWord(validWord)
|
||||||
|
setCurrentSide(Math.random() < 0.5 ? 'word' : 'translation')
|
||||||
setCardsShown(nextCardsShown)
|
setCardsShown(nextCardsShown)
|
||||||
setFlippedCards(new Set())
|
setFlippedCards(new Set())
|
||||||
|
|
||||||
@@ -454,6 +454,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
|
|
||||||
// showCard: показываем карточку
|
// showCard: показываем карточку
|
||||||
setCurrentWord(nextWord)
|
setCurrentWord(nextWord)
|
||||||
|
setCurrentSide(Math.random() < 0.5 ? 'word' : 'translation')
|
||||||
setCardsShown(nextCardsShown)
|
setCardsShown(nextCardsShown)
|
||||||
setFlippedCards(new Set())
|
setFlippedCards(new Set())
|
||||||
|
|
||||||
@@ -566,15 +567,51 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
showNextCard()
|
showNextCard()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFinish = () => {
|
const runSaveFromPayload = useCallback(async (payload) => {
|
||||||
|
const { requestBody, taskId: payloadTaskId } = payload
|
||||||
|
const progressRes = await authFetch(`${API_URL}/test/progress`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
})
|
||||||
|
if (!progressRes.ok) {
|
||||||
|
const text = await progressRes.text()
|
||||||
|
throw new Error(`Progress save failed: ${progressRes.status} ${text}`)
|
||||||
|
}
|
||||||
|
if (payloadTaskId) {
|
||||||
|
const completeRes = await authFetch(`${API_URL}/tasks/${payloadTaskId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
if (!completeRes.ok) {
|
||||||
|
const text = await completeRes.text()
|
||||||
|
throw new Error(`Task complete failed: ${completeRes.status} ${text}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [authFetch])
|
||||||
|
|
||||||
|
const handleFinish = async () => {
|
||||||
|
if (isClosing) return
|
||||||
|
setIsClosing(true)
|
||||||
|
try {
|
||||||
|
let promise = saveProgressPromiseRef.current
|
||||||
|
if (promise == null && savePayloadRef.current) {
|
||||||
|
promise = runSaveFromPayload(savePayloadRef.current)
|
||||||
|
saveProgressPromiseRef.current = promise
|
||||||
|
}
|
||||||
|
await (promise ?? Promise.resolve())
|
||||||
onNavigate?.('tasks')
|
onNavigate?.('tasks')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save before close:', err)
|
||||||
|
} finally {
|
||||||
|
setIsClosing(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRandomSide = (word) => {
|
|
||||||
return word.id % 2 === 0 ? 'word' : 'translation'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="test-container test-container-fullscreen">
|
<div className="test-container test-container-fullscreen">
|
||||||
<button className="close-x-button" onClick={handleClose}>
|
<button className="close-x-button" onClick={handleClose}>
|
||||||
✕
|
✕
|
||||||
@@ -601,11 +638,6 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="preview-actions">
|
|
||||||
<button className="test-start-button" onClick={handleStartTest}>
|
|
||||||
Начать
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : showResults ? (
|
) : showResults ? (
|
||||||
<div className="test-results">
|
<div className="test-results">
|
||||||
@@ -629,11 +661,6 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="results-actions">
|
|
||||||
<button className="test-finish-button" onClick={handleFinish}>
|
|
||||||
Закончить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="test-screen">
|
<div className="test-screen">
|
||||||
@@ -703,10 +730,9 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
loadWords()
|
loadWords()
|
||||||
}} />
|
}} />
|
||||||
)}
|
)}
|
||||||
{!loading && !error && !isFinishingRef.current && currentWord && (() => {
|
{!loading && !error && !isFinishingRef.current && currentWord && currentSide && (() => {
|
||||||
const word = currentWord
|
const word = currentWord
|
||||||
const isFlipped = flippedCards.has(word.id)
|
const isFlipped = flippedCards.has(word.id)
|
||||||
const showSide = getRandomSide(word)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="test-card-container" key={word.id}>
|
<div className="test-card-container" key={word.id}>
|
||||||
@@ -716,7 +742,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
>
|
>
|
||||||
<div className="test-card-front">
|
<div className="test-card-front">
|
||||||
<div className="test-card-content">
|
<div className="test-card-content">
|
||||||
{showSide === 'word' ? (
|
{currentSide === 'word' ? (
|
||||||
<div className="test-word">{word.name}</div>
|
<div className="test-word">{word.name}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="test-translation">{word.translation}</div>
|
<div className="test-translation">{word.translation}</div>
|
||||||
@@ -725,7 +751,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
</div>
|
</div>
|
||||||
<div className="test-card-back">
|
<div className="test-card-back">
|
||||||
<div className="test-card-content">
|
<div className="test-card-content">
|
||||||
{showSide === 'word' ? (
|
{currentSide === 'word' ? (
|
||||||
<div className="test-translation">{word.translation}</div>
|
<div className="test-translation">{word.translation}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="test-word">{word.name}</div>
|
<div className="test-word">{word.name}</div>
|
||||||
@@ -766,6 +792,43 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isActive && (showPreview || showResults) ? 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={showPreview ? handleStartTest : handleFinish}
|
||||||
|
disabled={showResults && isClosing}
|
||||||
|
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: (showResults && isClosing) ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: (showResults && isClosing) ? 0.6 : 1,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showPreview ? 'Начать' : (isClosing ? 'Завершение…' : 'Завершить')}
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import LoadingError from './LoadingError'
|
import LoadingError from './LoadingError'
|
||||||
import { useAuth } from './auth/AuthContext'
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import TaskDetail from './TaskDetail'
|
||||||
|
|
||||||
// Функция для форматирования скорa (аналогично formatScore из TaskDetail)
|
// Функция для форматирования скорa (аналогично formatScore из TaskDetail)
|
||||||
const formatScore = (num) => {
|
const formatScore = (num) => {
|
||||||
@@ -131,9 +133,295 @@ const formatEntryText = (text, nodes) => {
|
|||||||
return result.length > 0 ? result : currentText
|
return result.length > 0 ? result : currentText
|
||||||
}
|
}
|
||||||
|
|
||||||
function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
// Модальное окно редактирования записи
|
||||||
|
function EditEntryModal({ entry, onClose, onSuccess, authFetch }) {
|
||||||
|
const [message, setMessage] = useState(() => {
|
||||||
|
// Заменяем ${N} на $N для удобного отображения
|
||||||
|
return (entry.text || '').replace(/\$\{(\d+)\}/g, '$$$1')
|
||||||
|
})
|
||||||
|
const [rewards, setRewards] = useState(() => {
|
||||||
|
if (!entry.nodes || entry.nodes.length === 0) return []
|
||||||
|
const sorted = [...entry.nodes].sort((a, b) => a.index - b.index)
|
||||||
|
return sorted.map((node, idx) => ({
|
||||||
|
position: idx,
|
||||||
|
project_name: node.project_name,
|
||||||
|
value: String(node.score)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
const [projects, setProjects] = useState([])
|
||||||
|
const [isSending, setIsSending] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const debounceTimer = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
const response = await authFetch('/projects')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setProjects(Array.isArray(data) ? data : [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading projects:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadProjects()
|
||||||
|
}, [authFetch])
|
||||||
|
|
||||||
|
const findMaxPlaceholderIndex = (msg) => {
|
||||||
|
if (!msg) return -1
|
||||||
|
const indices = []
|
||||||
|
const matchesCurly = msg.match(/\$\{(\d+)\}/g) || []
|
||||||
|
matchesCurly.forEach(match => {
|
||||||
|
const numMatch = match.match(/\d+/)
|
||||||
|
if (numMatch) indices.push(parseInt(numMatch[0]))
|
||||||
|
})
|
||||||
|
let searchIndex = 0
|
||||||
|
while (true) {
|
||||||
|
const index = msg.indexOf('$', searchIndex)
|
||||||
|
if (index === -1) break
|
||||||
|
if (index === 0 || msg[index - 1] !== '\\') {
|
||||||
|
const afterDollar = msg.substring(index + 1)
|
||||||
|
const digitMatch = afterDollar.match(/^(\d+)/)
|
||||||
|
if (digitMatch) {
|
||||||
|
indices.push(parseInt(digitMatch[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
searchIndex = index + 1
|
||||||
|
}
|
||||||
|
return indices.length > 0 ? Math.max(...indices) : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересчет rewards при изменении сообщения
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceTimer.current) clearTimeout(debounceTimer.current)
|
||||||
|
debounceTimer.current = setTimeout(() => {
|
||||||
|
const maxIndex = findMaxPlaceholderIndex(message)
|
||||||
|
setRewards(prevRewards => {
|
||||||
|
const currentRewards = [...prevRewards]
|
||||||
|
while (currentRewards.length > maxIndex + 1) currentRewards.pop()
|
||||||
|
while (currentRewards.length < maxIndex + 1) {
|
||||||
|
currentRewards.push({ position: currentRewards.length, project_name: '', value: '0' })
|
||||||
|
}
|
||||||
|
return currentRewards
|
||||||
|
})
|
||||||
|
}, 500)
|
||||||
|
return () => { if (debounceTimer.current) clearTimeout(debounceTimer.current) }
|
||||||
|
}, [message])
|
||||||
|
|
||||||
|
const handleRewardChange = (index, field, value) => {
|
||||||
|
const newRewards = [...rewards]
|
||||||
|
newRewards[index] = { ...newRewards[index], [field]: value }
|
||||||
|
setRewards(newRewards)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildFinalMessage = () => {
|
||||||
|
let result = message
|
||||||
|
const rewardStrings = {}
|
||||||
|
rewards.forEach((reward, index) => {
|
||||||
|
const score = parseFloat(reward.value) || 0
|
||||||
|
const projectName = reward.project_name.trim()
|
||||||
|
if (!projectName) return
|
||||||
|
const scoreStr = score >= 0
|
||||||
|
? `**${projectName}+${score}**`
|
||||||
|
: `**${projectName}${score}**`
|
||||||
|
rewardStrings[index] = scoreStr
|
||||||
|
})
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const placeholder = `\${${i}}`
|
||||||
|
if (rewardStrings[i]) result = result.split(placeholder).join(rewardStrings[i])
|
||||||
|
}
|
||||||
|
for (let i = 99; i >= 0; i--) {
|
||||||
|
if (rewardStrings[i]) {
|
||||||
|
const regex = new RegExp(`\\$${i}(?!\\d)`, 'g')
|
||||||
|
result = result.replace(regex, rewardStrings[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
if (rewards.length === 0) return true
|
||||||
|
return rewards.every(reward => {
|
||||||
|
const projectName = reward.project_name?.trim() || ''
|
||||||
|
const value = reward.value?.toString().trim() || ''
|
||||||
|
return projectName !== '' && value !== ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
for (const reward of rewards) {
|
||||||
|
if (!reward.project_name.trim()) {
|
||||||
|
setError('Заполните все проекты')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const finalMessage = buildFinalMessage()
|
||||||
|
if (!finalMessage.trim()) {
|
||||||
|
setError('Введите сообщение')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSending(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`/api/entries/${entry.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: finalMessage })
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Ошибка при сохранении')
|
||||||
|
onSuccess()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating entry:', err)
|
||||||
|
setError(err.message || 'Ошибка при сохранении')
|
||||||
|
} finally {
|
||||||
|
setIsSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalContent = (
|
||||||
|
<div className="add-entry-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="add-entry-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="add-entry-modal-header">
|
||||||
|
<h2 className="add-entry-modal-title">Редактировать запись</h2>
|
||||||
|
<button onClick={onClose} className="add-entry-close-button">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="add-entry-modal-content">
|
||||||
|
<div className="add-entry-field">
|
||||||
|
<label className="add-entry-label">Сообщение</label>
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Используйте $0, $1 для указания проектов"
|
||||||
|
className="add-entry-textarea"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{rewards.length > 0 && (
|
||||||
|
<div className="add-entry-rewards">
|
||||||
|
{rewards.map((reward, index) => (
|
||||||
|
<div key={index} className="add-entry-reward-item">
|
||||||
|
<span className="add-entry-reward-number">{index}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={reward.project_name}
|
||||||
|
onChange={(e) => handleRewardChange(index, 'project_name', e.target.value)}
|
||||||
|
placeholder="Проект"
|
||||||
|
className="add-entry-input add-entry-project-input"
|
||||||
|
list={`edit-entry-projects-${index}`}
|
||||||
|
/>
|
||||||
|
<datalist id={`edit-entry-projects-${index}`}>
|
||||||
|
{projects.map(p => (
|
||||||
|
<option key={p.project_id} value={p.project_name} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={reward.value}
|
||||||
|
onChange={(e) => handleRewardChange(index, 'value', e.target.value)}
|
||||||
|
placeholder="Баллы"
|
||||||
|
className="add-entry-input add-entry-score-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div style={{ color: '#ef4444', fontSize: '0.875rem', marginBottom: '0.5rem' }}>{error}</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSending || !isFormValid()}
|
||||||
|
className="add-entry-submit-button"
|
||||||
|
>
|
||||||
|
{isSending ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return typeof document !== 'undefined'
|
||||||
|
? createPortal(modalContent, document.body)
|
||||||
|
: modalContent
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodayEntriesList({ data, loading, error, onRetry, onDelete, onNavigate }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||||
|
const [selectedTaskId, setSelectedTaskId] = useState(null)
|
||||||
|
const [editingEntry, setEditingEntry] = useState(null)
|
||||||
|
const [removingAutoCompleteId, setRemovingAutoCompleteId] = useState(null)
|
||||||
|
const selectedTaskIdRef = useRef(null)
|
||||||
|
const historyPushedRef = useRef(false)
|
||||||
|
|
||||||
|
// Обновляем ref при изменении selectedTaskId
|
||||||
|
useEffect(() => {
|
||||||
|
selectedTaskIdRef.current = selectedTaskId
|
||||||
|
}, [selectedTaskId])
|
||||||
|
|
||||||
|
// Управление историей браузера при открытии/закрытии TaskDetail
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTaskId && !historyPushedRef.current) {
|
||||||
|
window.history.pushState({ modalOpen: true, type: 'task-detail-statistics' }, '', window.location.href)
|
||||||
|
historyPushedRef.current = true
|
||||||
|
} else if (!selectedTaskId) {
|
||||||
|
historyPushedRef.current = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedTaskId) return
|
||||||
|
|
||||||
|
const handlePopState = () => {
|
||||||
|
if (selectedTaskIdRef.current) {
|
||||||
|
setSelectedTaskId(null)
|
||||||
|
historyPushedRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('popstate', handlePopState)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('popstate', handlePopState)
|
||||||
|
}
|
||||||
|
}, [selectedTaskId])
|
||||||
|
|
||||||
|
const handleOpenTaskDetail = (taskId) => {
|
||||||
|
setSelectedTaskId(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseTaskDetail = (skipHistoryBack = false) => {
|
||||||
|
if (!skipHistoryBack && historyPushedRef.current) {
|
||||||
|
window.history.back()
|
||||||
|
} else {
|
||||||
|
historyPushedRef.current = false
|
||||||
|
setSelectedTaskId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTaskSaved = () => {
|
||||||
|
if (onDelete) onDelete()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveAutoComplete = async (taskId) => {
|
||||||
|
if (!window.confirm('Убрать автовыполнение этой задачи в конце дня?')) return
|
||||||
|
|
||||||
|
setRemovingAutoCompleteId(taskId)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`/api/tasks/${taskId}/draft`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ auto_complete: false })
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Ошибка при обновлении')
|
||||||
|
if (onDelete) onDelete()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Remove auto-complete failed:', err)
|
||||||
|
alert(err.message || 'Не удалось убрать автовыполнение')
|
||||||
|
} finally {
|
||||||
|
setRemovingAutoCompleteId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = async (entryId) => {
|
const handleDelete = async (entryId) => {
|
||||||
if (deletingIds.has(entryId)) return
|
if (deletingIds.has(entryId)) return
|
||||||
@@ -147,9 +435,7 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
|||||||
try {
|
try {
|
||||||
const response = await authFetch(`/api/entries/${entryId}`, {
|
const response = await authFetch(`/api/entries/${entryId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -158,10 +444,7 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
|||||||
throw new Error(`Ошибка при удалении записи: ${response.status}`)
|
throw new Error(`Ошибка при удалении записи: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вызываем callback для обновления данных
|
if (onDelete) onDelete()
|
||||||
if (onDelete) {
|
|
||||||
onDelete()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete failed:', err)
|
console.error('Delete failed:', err)
|
||||||
alert(err.message || 'Не удалось удалить запись')
|
alert(err.message || 'Не удалось удалить запись')
|
||||||
@@ -195,15 +478,68 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="mt-2 mb-6">
|
<div className="mt-2 mb-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{data.map((entry) => (
|
{data.map((entry) => {
|
||||||
|
const isDraft = entry.is_draft === true
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={isDraft ? `draft-${entry.task_id}` : entry.id}
|
||||||
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200 relative group"
|
className={`bg-white rounded-lg p-4 shadow-sm border relative group ${
|
||||||
|
isDraft ? 'border-blue-400 cursor-pointer' : 'border-gray-200 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (isDraft) {
|
||||||
|
handleOpenTaskDetail(entry.task_id)
|
||||||
|
} else {
|
||||||
|
setEditingEntry(entry)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
{isDraft ? (
|
||||||
|
// Кнопка-молния с зачёркиванием — убрать автовыполнение
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(entry.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleRemoveAutoComplete(entry.task_id)
|
||||||
|
}}
|
||||||
|
disabled={removingAutoCompleteId === entry.task_id}
|
||||||
|
className="absolute top-4 right-4"
|
||||||
|
style={{
|
||||||
|
color: '#3b82f6',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0.25rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
opacity: removingAutoCompleteId === entry.task_id ? 0.5 : 1,
|
||||||
|
zIndex: 10
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#eff6ff' }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||||
|
title="Убрать автовыполнение"
|
||||||
|
>
|
||||||
|
{/* Молния с зачёркиванием */}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24">
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="currentColor" />
|
||||||
|
{/* Зачёркивающая линия: белая с синей обводкой */}
|
||||||
|
<line x1="3" y1="21" x2="21" y2="3" stroke="white" strokeWidth="4" strokeLinecap="round" />
|
||||||
|
<line x1="3" y1="21" x2="21" y2="3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(entry.id)
|
||||||
|
}}
|
||||||
disabled={deletingIds.has(entry.id)}
|
disabled={deletingIds.has(entry.id)}
|
||||||
className="absolute top-4 right-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="absolute top-4 right-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
style={{
|
style={{
|
||||||
@@ -249,21 +585,43 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<div className="text-gray-800 whitespace-pre-wrap pr-8">
|
<div className="text-gray-800 whitespace-pre-wrap pr-8">
|
||||||
{formatEntryText(entry.text, entry.nodes)}
|
{formatEntryText(entry.text, entry.nodes)}
|
||||||
</div>
|
</div>
|
||||||
{entry.created_date && (
|
|
||||||
<div className="text-xs text-gray-500 mt-2">
|
<div className="text-xs text-gray-500 mt-2">
|
||||||
{new Date(entry.created_date).toLocaleTimeString('ru-RU', {
|
{isDraft ? 'в конце дня' : (
|
||||||
|
entry.created_date && new Date(entry.created_date).toLocaleTimeString('ru-RU', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
})}
|
})
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedTaskId && (
|
||||||
|
<TaskDetail
|
||||||
|
taskId={selectedTaskId}
|
||||||
|
onClose={handleCloseTaskDetail}
|
||||||
|
onRefresh={handleTaskSaved}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editingEntry && (
|
||||||
|
<EditEntryModal
|
||||||
|
entry={editingEntry}
|
||||||
|
onClose={() => setEditingEntry(null)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setEditingEntry(null)
|
||||||
|
if (onDelete) onDelete()
|
||||||
|
}}
|
||||||
|
authFetch={authFetch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ function getLastFiveWeeks() {
|
|||||||
return weeks.reverse() // От старой к новой
|
return weeks.reverse() // От старой к новой
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяет, закончилась ли уже данная ISO-неделя (текущая неделя не закончилась)
|
||||||
|
|
||||||
function Tracking({ onNavigate, activeTab }) {
|
function Tracking({ onNavigate, activeTab }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [weeks, setWeeks] = useState(() => getLastFiveWeeks())
|
const [weeks, setWeeks] = useState(() => getLastFiveWeeks())
|
||||||
@@ -147,7 +149,7 @@ function Tracking({ onNavigate, activeTab }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="users-list">
|
<div className="users-list">
|
||||||
{data?.users.map(user => (
|
{data?.users.map(user => (
|
||||||
<UserTrackingCard key={user.user_id} user={user} />
|
<UserTrackingCard key={user.user_id} user={user} selectedWeek={selectedWeek} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -156,7 +158,7 @@ function Tracking({ onNavigate, activeTab }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Карточка пользователя с прогрессом
|
// Карточка пользователя с прогрессом
|
||||||
function UserTrackingCard({ user }) {
|
function UserTrackingCard({ user, selectedWeek }) {
|
||||||
// Сортируем проекты по priority (1, 2, остальные)
|
// Сортируем проекты по priority (1, 2, остальные)
|
||||||
const sortedProjects = [...user.projects].sort((a, b) => {
|
const sortedProjects = [...user.projects].sort((a, b) => {
|
||||||
const pa = a.priority ?? 99
|
const pa = a.priority ?? 99
|
||||||
@@ -169,10 +171,27 @@ function UserTrackingCard({ user }) {
|
|||||||
return percent >= 100 ? 'percent-green' : 'percent-blue'
|
return percent >= 100 ? 'percent-green' : 'percent-blue'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Показываем (черновик) если выбранная неделя позже недели подтверждения
|
||||||
|
const showDraft = selectedWeek && (() => {
|
||||||
|
const cy = user.priorities_confirmed_year || 0
|
||||||
|
const cw = user.priorities_confirmed_week || 0
|
||||||
|
const sy = selectedWeek.year
|
||||||
|
const sw = selectedWeek.week
|
||||||
|
// Неделя не подтверждена вообще (0,0) или выбранная неделя позже подтверждённой
|
||||||
|
return sy > cy || (sy === cy && sw > cw)
|
||||||
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`user-tracking-card ${user.is_current_user ? 'current-user' : ''}`}>
|
<div className={`user-tracking-card ${user.is_current_user ? 'current-user' : ''}`}>
|
||||||
<div className="user-header">
|
<div className="user-header">
|
||||||
<span className="user-name">{user.user_name}</span>
|
<span className="user-name">
|
||||||
|
{user.user_name}
|
||||||
|
{showDraft && (
|
||||||
|
<span style={{ color: '#9ca3af', fontWeight: 'normal', fontSize: '0.85em', marginLeft: '4px' }}>
|
||||||
|
(черновик)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className={`user-total ${getPercentColorClass(totalPercent)}`}>{totalPercent.toFixed(0)}%</span>
|
<span className={`user-total ${getPercentColorClass(totalPercent)}`}>{totalPercent.toFixed(0)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="projects-list">
|
<div className="projects-list">
|
||||||
|
|||||||
@@ -184,10 +184,43 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wishlist-card.faded {
|
.wishlist-card-content {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-card.faded .wishlist-card-content {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-status-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.25rem;
|
||||||
|
left: 0.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
z-index: 20;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-status-completed {
|
||||||
|
background: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-status-rejected {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.wishlist .card-menu-button {
|
.wishlist .card-menu-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.25rem;
|
top: 0.25rem;
|
||||||
@@ -445,3 +478,11 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.wishlist .board-pill {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist .board-pill {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { useAuth } from './auth/AuthContext'
|
import { useAuth } from './auth/AuthContext'
|
||||||
import BoardSelector from './BoardSelector'
|
import BoardSelector from './BoardSelector'
|
||||||
import LoadingError from './LoadingError'
|
import LoadingError from './LoadingError'
|
||||||
@@ -47,13 +48,16 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
const [selectedItem, setSelectedItem] = useState(null)
|
const [selectedItem, setSelectedItem] = useState(null)
|
||||||
const [selectedWishlistForDetail, setSelectedWishlistForDetail] = useState(null)
|
const [selectedWishlistForDetail, setSelectedWishlistForDetail] = useState(null)
|
||||||
const [currentWeekData, setCurrentWeekData] = useState(null)
|
const [currentWeekData, setCurrentWeekData] = useState(null)
|
||||||
|
const [showBoardActionMenu, setShowBoardActionMenu] = useState(false)
|
||||||
const fetchingRef = useRef(false)
|
const fetchingRef = useRef(false)
|
||||||
const fetchingCompletedRef = useRef(false)
|
const fetchingCompletedRef = useRef(false)
|
||||||
const initialFetchDoneRef = useRef(false)
|
const initialFetchDoneRef = useRef(false)
|
||||||
const prevIsActiveRef = useRef(isActive)
|
const prevIsActiveRef = useRef(isActive)
|
||||||
|
const selectedBoardIdRef = useRef(getInitialBoardId())
|
||||||
|
|
||||||
// Обёртка для setSelectedBoardId с сохранением в localStorage
|
// Обёртка для setSelectedBoardId с сохранением в localStorage
|
||||||
const setSelectedBoardId = (boardId) => {
|
const setSelectedBoardId = (boardId) => {
|
||||||
|
selectedBoardIdRef.current = boardId
|
||||||
setSelectedBoardIdState(boardId)
|
setSelectedBoardIdState(boardId)
|
||||||
try {
|
try {
|
||||||
if (boardId) {
|
if (boardId) {
|
||||||
@@ -146,20 +150,20 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
setBoards(data || [])
|
setBoards(data || [])
|
||||||
saveBoardsToCache(data || [])
|
saveBoardsToCache(data || [])
|
||||||
|
|
||||||
|
const firstActive = data?.find(b => !b.is_archived) || (data?.length > 0 ? data[0] : null)
|
||||||
// Проверяем, что выбранная доска существует в списке
|
// Проверяем, что выбранная доска существует в списке
|
||||||
if (selectedBoardId) {
|
if (selectedBoardId) {
|
||||||
const boardExists = data?.some(b => b.id === selectedBoardId)
|
const boardExists = data?.some(b => b.id === selectedBoardId)
|
||||||
if (!boardExists && data?.length > 0) {
|
if (!boardExists && firstActive) {
|
||||||
// Сохранённая доска не существует, выбираем первую
|
setSelectedBoardId(firstActive.id)
|
||||||
setSelectedBoardId(data[0].id)
|
|
||||||
}
|
}
|
||||||
} else if (data?.length > 0) {
|
} else if (firstActive) {
|
||||||
// Пытаемся восстановить из localStorage
|
// Пытаемся восстановить из localStorage
|
||||||
const savedBoardId = getSavedBoardId()
|
const savedBoardId = getSavedBoardId()
|
||||||
if (savedBoardId && data.some(b => b.id === savedBoardId)) {
|
if (savedBoardId && data.some(b => b.id === savedBoardId)) {
|
||||||
setSelectedBoardId(savedBoardId)
|
setSelectedBoardId(savedBoardId)
|
||||||
} else {
|
} else {
|
||||||
setSelectedBoardId(data[0].id)
|
setSelectedBoardId(firstActive.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,20 +175,20 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка желаний выбранной доски
|
// Загрузка желаний выбранной доски
|
||||||
const fetchItems = async () => {
|
const fetchItems = async (boardId) => {
|
||||||
if (!selectedBoardId || fetchingRef.current) return
|
if (!boardId || fetchingRef.current) return
|
||||||
fetchingRef.current = true
|
fetchingRef.current = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hasDataInState = items.length > 0 || completedCount > 0
|
const hasDataInState = items.length > 0 || completedCount > 0
|
||||||
if (!hasDataInState) {
|
if (!hasDataInState) {
|
||||||
const cacheLoaded = loadItemsFromCache(selectedBoardId)
|
const cacheLoaded = loadItemsFromCache(boardId)
|
||||||
if (!cacheLoaded) {
|
if (!cacheLoaded) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/items`)
|
const response = await authFetch(`${API_URL}/boards/${boardId}/items`)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке желаний')
|
throw new Error('Ошибка при загрузке желаний')
|
||||||
@@ -194,44 +198,60 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
|
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
|
||||||
const count = data.completed_count || 0
|
const count = data.completed_count || 0
|
||||||
|
|
||||||
|
// Проверяем, что пользователь не переключился на другую доску пока шёл запрос
|
||||||
|
if (selectedBoardIdRef.current !== boardId) return
|
||||||
|
|
||||||
setItems(allItems)
|
setItems(allItems)
|
||||||
setCompletedCount(count)
|
setCompletedCount(count)
|
||||||
saveItemsToCache(selectedBoardId, allItems, count)
|
saveItemsToCache(boardId, allItems, count)
|
||||||
setError('')
|
setError('')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (selectedBoardIdRef.current !== boardId) return
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
if (!loadItemsFromCache(selectedBoardId)) {
|
if (!loadItemsFromCache(boardId)) {
|
||||||
setItems([])
|
setItems([])
|
||||||
setCompletedCount(0)
|
setCompletedCount(0)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (selectedBoardIdRef.current === boardId) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
}
|
||||||
fetchingRef.current = false
|
fetchingRef.current = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка завершённых для текущей доски
|
// Загрузка завершённых для текущей доски
|
||||||
const fetchCompleted = async () => {
|
const fetchCompleted = async (boardId) => {
|
||||||
if (fetchingCompletedRef.current || !selectedBoardId) return
|
if (fetchingCompletedRef.current || !boardId) return
|
||||||
fetchingCompletedRef.current = true
|
fetchingCompletedRef.current = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setCompletedLoading(true)
|
setCompletedLoading(true)
|
||||||
// Используем новый API для получения завершённых на доске
|
// Используем новый API для получения завершённых на доске
|
||||||
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/completed`)
|
const response = await authFetch(`${API_URL}/boards/${boardId}/completed`)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке завершённых желаний')
|
const errText = await response.text()
|
||||||
|
const msg = errText || 'Ошибка при загрузке завершённых желаний'
|
||||||
|
throw new Error(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
const completedData = Array.isArray(data) ? data : []
|
const completedData = Array.isArray(data) ? data : []
|
||||||
|
|
||||||
|
// Проверяем, что пользователь не переключился на другую доску пока шёл запрос
|
||||||
|
if (selectedBoardIdRef.current !== boardId) return
|
||||||
|
|
||||||
setCompleted(completedData)
|
setCompleted(completedData)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching completed items:', err)
|
console.error('Error fetching completed items:', err)
|
||||||
|
if (selectedBoardIdRef.current === boardId) {
|
||||||
setCompleted([])
|
setCompleted([])
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (selectedBoardIdRef.current === boardId) {
|
||||||
setCompletedLoading(false)
|
setCompletedLoading(false)
|
||||||
|
}
|
||||||
fetchingCompletedRef.current = false
|
fetchingCompletedRef.current = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,7 +310,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем свежие данные
|
// Загружаем свежие данные
|
||||||
fetchItems()
|
fetchItems(selectedBoardId)
|
||||||
}
|
}
|
||||||
}, [selectedBoardId])
|
}, [selectedBoardId])
|
||||||
|
|
||||||
@@ -304,7 +324,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
if (isActive && !wasActive) {
|
if (isActive && !wasActive) {
|
||||||
fetchBoards()
|
fetchBoards()
|
||||||
if (selectedBoardId) {
|
if (selectedBoardId) {
|
||||||
fetchItems()
|
fetchItems(selectedBoardId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isActive])
|
}, [isActive])
|
||||||
@@ -319,9 +339,9 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
console.error('Error clearing cache:', err)
|
console.error('Error clearing cache:', err)
|
||||||
}
|
}
|
||||||
fetchBoards()
|
fetchBoards()
|
||||||
fetchItems()
|
fetchItems(selectedBoardId)
|
||||||
if (completedExpanded && completedCount > 0) {
|
if (completedExpanded) {
|
||||||
fetchCompleted()
|
fetchCompleted(selectedBoardId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [refreshTrigger, selectedBoardId])
|
}, [refreshTrigger, selectedBoardId])
|
||||||
@@ -359,12 +379,13 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
}
|
}
|
||||||
}, [boardDeleted])
|
}, [boardDeleted])
|
||||||
|
|
||||||
// Если текущая доска больше не существует в списке - выбираем первую
|
// Если текущая доска больше не существует в списке - выбираем первую неархивную
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (boards.length > 0 && selectedBoardId) {
|
if (boards.length > 0 && selectedBoardId) {
|
||||||
const boardExists = boards.some(b => b.id === selectedBoardId)
|
const boardExists = boards.some(b => b.id === selectedBoardId)
|
||||||
if (!boardExists) {
|
if (!boardExists) {
|
||||||
setSelectedBoardId(boards[0].id)
|
const firstActive = boards.find(b => !b.is_archived) || boards[0]
|
||||||
|
setSelectedBoardId(firstActive.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [boards, selectedBoardId])
|
}, [boards, selectedBoardId])
|
||||||
@@ -373,53 +394,84 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
setSelectedBoardId(boardId)
|
setSelectedBoardId(boardId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBoardEdit = () => {
|
const handleBoardEdit = (boardId) => {
|
||||||
const board = boards.find(b => b.id === selectedBoardId)
|
const id = boardId || selectedBoardId
|
||||||
if (board?.is_owner) {
|
const board = boards.find(b => b.id === id)
|
||||||
onNavigate?.('board-form', { boardId: selectedBoardId })
|
if (board && !board.is_owner) {
|
||||||
} else {
|
openBoardActionMenu()
|
||||||
// Показать подтверждение выхода
|
return
|
||||||
handleLeaveBoard()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLeaveBoard = async () => {
|
|
||||||
if (!window.confirm('Отвязаться от этой доски? Вы больше не будете видеть её желания.')) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/leave`, {
|
|
||||||
method: 'POST'
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Убираем доску из списка
|
|
||||||
const newBoards = boards.filter(b => b.id !== selectedBoardId)
|
|
||||||
setBoards(newBoards)
|
|
||||||
saveBoardsToCache(newBoards)
|
|
||||||
|
|
||||||
// Выбираем первую доску
|
|
||||||
if (newBoards.length > 0) {
|
|
||||||
setSelectedBoardId(newBoards[0].id)
|
|
||||||
} else {
|
|
||||||
setSelectedBoardId(null)
|
|
||||||
setItems([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error leaving board:', err)
|
|
||||||
}
|
}
|
||||||
|
onNavigate?.('board-form', { boardId: id })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddBoard = () => {
|
const handleAddBoard = () => {
|
||||||
onNavigate?.('board-form', { boardId: null })
|
onNavigate?.('board-form', { boardId: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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/wishlist/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/wishlist/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/wishlist/boards/${selectedBoardId}/leave`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
fetchBoards()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error leaving board:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleToggleCompleted = () => {
|
const handleToggleCompleted = () => {
|
||||||
const newExpanded = !completedExpanded
|
const newExpanded = !completedExpanded
|
||||||
setCompletedExpanded(newExpanded)
|
setCompletedExpanded(newExpanded)
|
||||||
|
|
||||||
if (newExpanded && completedCount > 0) {
|
if (newExpanded && completedCount > 0) {
|
||||||
fetchCompleted()
|
fetchCompleted(selectedBoardId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,9 +515,9 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSelectedItem(null)
|
setSelectedItem(null)
|
||||||
await fetchItems()
|
await fetchItems(selectedBoardId)
|
||||||
if (completedExpanded) {
|
if (completedExpanded) {
|
||||||
await fetchCompleted()
|
await fetchCompleted(selectedBoardId)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
@@ -500,7 +552,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем список
|
// Обновляем список
|
||||||
await fetchItems()
|
await fetchItems(selectedBoardId)
|
||||||
|
|
||||||
// Открываем форму редактирования для нового желания
|
// Открываем форму редактирования для нового желания
|
||||||
onNavigate?.('wishlist-form', { wishlistId: newItem.id, boardId: selectedBoardId })
|
onNavigate?.('wishlist-form', { wishlistId: newItem.id, boardId: selectedBoardId })
|
||||||
@@ -614,12 +666,41 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
className={`wishlist-card ${isFaded ? 'faded' : ''}`}
|
className={`wishlist-card ${isFaded ? 'faded' : ''}`}
|
||||||
onClick={() => handleItemClick(item)}
|
onClick={() => handleItemClick(item)}
|
||||||
>
|
>
|
||||||
|
{item.completed && (
|
||||||
|
<div className={`card-status-indicator ${item.rejected ? 'card-status-rejected' : 'card-status-completed'}`}>
|
||||||
|
{item.rejected ? (
|
||||||
|
<svg width="16" height="16" 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>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="wishlist-card-content">
|
||||||
<button
|
<button
|
||||||
className="card-menu-button"
|
className="card-menu-button"
|
||||||
onClick={(e) => handleMenuClick(item, e)}
|
onClick={(e) => {
|
||||||
title="Меню"
|
e.stopPropagation()
|
||||||
|
onNavigate?.('wishlist-form', { wishlistId: item.id, boardId: selectedBoardId })
|
||||||
|
}}
|
||||||
|
title="Редактировать"
|
||||||
>
|
>
|
||||||
⋮
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
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>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="card-image">
|
<div className="card-image">
|
||||||
@@ -649,6 +730,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
return null
|
return null
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,8 +744,9 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
onBoardEdit={handleBoardEdit}
|
onBoardEdit={handleBoardEdit}
|
||||||
onAddBoard={handleAddBoard}
|
onAddBoard={handleAddBoard}
|
||||||
loading={boardsLoading}
|
loading={boardsLoading}
|
||||||
|
showBoardAction={false}
|
||||||
/>
|
/>
|
||||||
<LoadingError onRetry={() => fetchItems()} />
|
<LoadingError onRetry={() => fetchItems(selectedBoardId)} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -677,7 +760,10 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
onBoardChange={handleBoardChange}
|
onBoardChange={handleBoardChange}
|
||||||
onBoardEdit={handleBoardEdit}
|
onBoardEdit={handleBoardEdit}
|
||||||
onAddBoard={handleAddBoard}
|
onAddBoard={handleAddBoard}
|
||||||
|
archivedApiUrl="/api/wishlist/boards/archived"
|
||||||
|
onBoardUnarchived={() => fetchBoards()}
|
||||||
loading={boardsLoading}
|
loading={boardsLoading}
|
||||||
|
showBoardAction={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Основной список */}
|
{/* Основной список */}
|
||||||
@@ -773,15 +859,41 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
|||||||
wishlistId={selectedWishlistForDetail}
|
wishlistId={selectedWishlistForDetail}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
boardId={selectedBoardId}
|
boardId={selectedBoardId}
|
||||||
|
refreshTrigger={refreshTrigger}
|
||||||
onRefresh={async () => {
|
onRefresh={async () => {
|
||||||
await fetchItems()
|
await fetchItems(selectedBoardId)
|
||||||
if (completedExpanded) {
|
if (completedExpanded) {
|
||||||
await fetchCompleted()
|
await fetchCompleted(selectedBoardId)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClose={handleCloseDetail}
|
onClose={handleCloseDetail}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,7 @@
|
|||||||
.wishlist-detail-edit-button,
|
.wishlist-detail-edit-button,
|
||||||
.wishlist-detail-complete-button,
|
.wishlist-detail-complete-button,
|
||||||
.wishlist-detail-uncomplete-button,
|
.wishlist-detail-uncomplete-button,
|
||||||
|
.wishlist-detail-reject-button,
|
||||||
.wishlist-detail-delete-button {
|
.wishlist-detail-delete-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
@@ -267,6 +268,12 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-action-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.wishlist-detail-complete-button {
|
.wishlist-detail-complete-button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background-color: #27ae60;
|
background-color: #27ae60;
|
||||||
@@ -283,6 +290,22 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-reject-button {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-reject-button:hover:not(:disabled) {
|
||||||
|
background-color: #c0392b;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-reject-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.wishlist-detail-create-task-button {
|
.wishlist-detail-create-task-button {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import './TaskList.css'
|
|||||||
|
|
||||||
const API_URL = '/api/wishlist'
|
const API_URL = '/api/wishlist'
|
||||||
|
|
||||||
function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, previousTab }) {
|
function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, previousTab, refreshTrigger = 0 }) {
|
||||||
const { authFetch, user } = useAuth()
|
const { authFetch, user } = useAuth()
|
||||||
const [wishlistItem, setWishlistItem] = useState(null)
|
const [wishlistItem, setWishlistItem] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -51,6 +51,12 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, p
|
|||||||
}
|
}
|
||||||
}, [wishlistId, fetchWishlistDetail])
|
}, [wishlistId, fetchWishlistDetail])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshTrigger > 0 && wishlistId) {
|
||||||
|
fetchWishlistDetail()
|
||||||
|
}
|
||||||
|
}, [refreshTrigger])
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
// Сбрасываем флаг, чтобы handleClose не вызвал history.back()
|
// Сбрасываем флаг, чтобы handleClose не вызвал history.back()
|
||||||
// handleTabChange заменит запись модального окна через replaceState
|
// handleTabChange заменит запись модального окна через replaceState
|
||||||
@@ -75,9 +81,7 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, p
|
|||||||
if (onRefresh) {
|
if (onRefresh) {
|
||||||
onRefresh()
|
onRefresh()
|
||||||
}
|
}
|
||||||
if (onNavigate) {
|
closeWithHistoryCleanup()
|
||||||
onNavigate('wishlist')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error completing wishlist:', err)
|
console.error('Error completing wishlist:', err)
|
||||||
setToastMessage({ text: err.message || 'Ошибка при завершении', type: 'error' })
|
setToastMessage({ text: err.message || 'Ошибка при завершении', type: 'error' })
|
||||||
@@ -86,6 +90,31 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReject = async () => {
|
||||||
|
if (!wishlistItem || !wishlistItem.unlocked) return
|
||||||
|
|
||||||
|
setIsCompleting(true)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/${wishlistId}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при отклонении')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
closeWithHistoryCleanup()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error rejecting wishlist:', err)
|
||||||
|
setToastMessage({ text: err.message || 'Ошибка при отклонении', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setIsCompleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleUncomplete = async () => {
|
const handleUncomplete = async () => {
|
||||||
if (!wishlistItem || !wishlistItem.completed) return
|
if (!wishlistItem || !wishlistItem.completed) return
|
||||||
|
|
||||||
@@ -102,7 +131,7 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, p
|
|||||||
if (onRefresh) {
|
if (onRefresh) {
|
||||||
onRefresh()
|
onRefresh()
|
||||||
}
|
}
|
||||||
fetchWishlistDetail()
|
closeWithHistoryCleanup()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error uncompleting wishlist:', err)
|
console.error('Error uncompleting wishlist:', err)
|
||||||
setToastMessage({ text: err.message || 'Ошибка при возобновлении желания', type: 'error' })
|
setToastMessage({ text: err.message || 'Ошибка при возобновлении желания', type: 'error' })
|
||||||
@@ -131,9 +160,7 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, p
|
|||||||
if (onRefresh) {
|
if (onRefresh) {
|
||||||
onRefresh()
|
onRefresh()
|
||||||
}
|
}
|
||||||
if (onNavigate) {
|
closeWithHistoryCleanup()
|
||||||
onNavigate('wishlist')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error deleting wishlist:', err)
|
console.error('Error deleting wishlist:', err)
|
||||||
setToastMessage({ text: err.message || 'Ошибка при удалении', type: 'error' })
|
setToastMessage({ text: err.message || 'Ошибка при удалении', type: 'error' })
|
||||||
@@ -144,6 +171,11 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, p
|
|||||||
|
|
||||||
const handleCreateTask = () => {
|
const handleCreateTask = () => {
|
||||||
if (!wishlistItem || !wishlistItem.unlocked || wishlistItem.completed) return
|
if (!wishlistItem || !wishlistItem.unlocked || wishlistItem.completed) return
|
||||||
|
// Сбрасываем refs чтобы popstate handler и cleanup не мешали навигации
|
||||||
|
// history.back() не вызываем — App.jsx заменит запись модала через replaceState
|
||||||
|
historyPushedForWishlistRef.current = false
|
||||||
|
wishlistIdRef.current = null
|
||||||
|
onClose?.()
|
||||||
onNavigate?.('task-form', { wishlistId: wishlistId })
|
onNavigate?.('task-form', { wishlistId: wishlistId })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,14 +232,16 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, p
|
|||||||
// закрываем модальные окна без удаления записей из истории
|
// закрываем модальные окна без удаления записей из истории
|
||||||
// App.jsx сам обработает навигацию и заменит запись task-detail на task-form через replaceState
|
// App.jsx сам обработает навигацию и заменит запись task-detail на task-form через replaceState
|
||||||
// Запись wishlist-detail останется в истории, но экран будет закрыт
|
// Запись wishlist-detail останется в истории, но экран будет закрыт
|
||||||
if (skipHistoryBack) {
|
if (skipHistoryBack === true) {
|
||||||
// Сохраняем флаг перед сбросом
|
// Сохраняем флаг перед сбросом
|
||||||
const hadWishlistHistory = historyPushedForWishlistRef.current
|
const hadWishlistHistory = historyPushedForWishlistRef.current
|
||||||
|
|
||||||
// Закрываем модальные окна
|
// Закрываем модальные окна
|
||||||
historyPushedForTaskRef.current = false
|
historyPushedForTaskRef.current = false
|
||||||
|
selectedTaskForDetailRef.current = null
|
||||||
setSelectedTaskForDetail(null)
|
setSelectedTaskForDetail(null)
|
||||||
historyPushedForWishlistRef.current = false
|
historyPushedForWishlistRef.current = false
|
||||||
|
wishlistIdRef.current = null
|
||||||
|
|
||||||
// Закрываем экран желания через onClose
|
// Закрываем экран желания через onClose
|
||||||
// Навигация на task-form уже происходит в TaskDetail, поэтому не вызываем onNavigate здесь
|
// Навигация на task-form уже происходит в TaskDetail, поэтому не вызываем onNavigate здесь
|
||||||
@@ -219,6 +253,7 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, p
|
|||||||
window.history.back()
|
window.history.back()
|
||||||
} else {
|
} else {
|
||||||
historyPushedForTaskRef.current = false
|
historyPushedForTaskRef.current = false
|
||||||
|
selectedTaskForDetailRef.current = null
|
||||||
setSelectedTaskForDetail(null)
|
setSelectedTaskForDetail(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,27 +290,24 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, p
|
|||||||
if (!wishlistId && !selectedTaskForDetail) return
|
if (!wishlistId && !selectedTaskForDetail) return
|
||||||
|
|
||||||
const handlePopState = (event) => {
|
const handlePopState = (event) => {
|
||||||
// Проверяем наличие модальных окон в DOM
|
|
||||||
const taskDetailModal = document.querySelector('.task-detail-modal-overlay')
|
|
||||||
const wishlistDetailModal = document.querySelector('.wishlist-detail-modal-overlay')
|
|
||||||
|
|
||||||
// Используем refs для получения актуального состояния
|
// Используем refs для получения актуального состояния
|
||||||
|
// (refs обновляются сразу в обработчике, в отличие от DOM который обновляется после рендера)
|
||||||
const currentTaskDetail = selectedTaskForDetailRef.current
|
const currentTaskDetail = selectedTaskForDetailRef.current
|
||||||
const currentWishlistId = wishlistIdRef.current
|
const currentWishlistId = wishlistIdRef.current
|
||||||
|
|
||||||
// Сначала проверяем вложенное модальное окно TaskDetail
|
// Сначала проверяем вложенное модальное окно TaskDetail
|
||||||
if (currentTaskDetail || taskDetailModal) {
|
if (currentTaskDetail) {
|
||||||
setSelectedTaskForDetail(null)
|
setSelectedTaskForDetail(null)
|
||||||
|
selectedTaskForDetailRef.current = null
|
||||||
historyPushedForTaskRef.current = false
|
historyPushedForTaskRef.current = false
|
||||||
// Возвращаем запись для WishlistDetail
|
// НЕ пушим запись для WishlistDetail — оригинальная запись wishlist-detail
|
||||||
if (currentWishlistId || wishlistDetailModal) {
|
// уже находится на текущей позиции в history stack (мы вернулись на неё после back).
|
||||||
window.history.pushState({ modalOpen: true, type: 'wishlist-detail' }, '', window.location.href)
|
// Следующий back от wishlist-detail закроет диалог желания.
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если открыто модальное окно WishlistDetail, закрываем его
|
// Если открыто модальное окно WishlistDetail, закрываем его
|
||||||
if (currentWishlistId || wishlistDetailModal) {
|
if (currentWishlistId) {
|
||||||
if (onClose) {
|
if (onClose) {
|
||||||
onClose()
|
onClose()
|
||||||
} else {
|
} else {
|
||||||
@@ -293,9 +325,11 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
historyPushedForWishlistRef.current = false
|
historyPushedForWishlistRef.current = false
|
||||||
|
wishlistIdRef.current = null
|
||||||
// Следующее нажатие "назад" обработается App.jsx нормально
|
// Следующее нажатие "назад" обработается App.jsx нормально
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('popstate', handlePopState)
|
window.addEventListener('popstate', handlePopState)
|
||||||
@@ -304,6 +338,28 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, p
|
|||||||
}
|
}
|
||||||
}, [wishlistId, selectedTaskForDetail, onClose, onNavigate, previousTab, boardId])
|
}, [wishlistId, selectedTaskForDetail, onClose, onNavigate, previousTab, boardId])
|
||||||
|
|
||||||
|
// Закрытие модального окна с очисткой записей из истории
|
||||||
|
// Используется при программном закрытии (завершение, отклонение, возобновление)
|
||||||
|
const closeWithHistoryCleanup = () => {
|
||||||
|
// Считаем сколько записей нужно убрать из истории
|
||||||
|
let stepsBack = 0
|
||||||
|
if (historyPushedForTaskRef.current) stepsBack++
|
||||||
|
if (historyPushedForWishlistRef.current) stepsBack++
|
||||||
|
|
||||||
|
// Сбрасываем refs чтобы popstate handler не реагировал
|
||||||
|
historyPushedForTaskRef.current = false
|
||||||
|
historyPushedForWishlistRef.current = false
|
||||||
|
selectedTaskForDetailRef.current = null
|
||||||
|
wishlistIdRef.current = null
|
||||||
|
setSelectedTaskForDetail(null)
|
||||||
|
|
||||||
|
// Убираем записи из истории
|
||||||
|
if (stepsBack > 0) {
|
||||||
|
window.history.go(-stepsBack)
|
||||||
|
}
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
// Если была добавлена запись в историю, удаляем её через history.back()
|
// Если была добавлена запись в историю, удаляем её через history.back()
|
||||||
// Обработчик popstate закроет модальное окно
|
// Обработчик popstate закроет модальное окно
|
||||||
@@ -334,9 +390,7 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, p
|
|||||||
if (onRefresh) {
|
if (onRefresh) {
|
||||||
onRefresh()
|
onRefresh()
|
||||||
}
|
}
|
||||||
if (onNavigate) {
|
closeWithHistoryCleanup()
|
||||||
onNavigate('wishlist')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteTask = async (e) => {
|
const handleDeleteTask = async (e) => {
|
||||||
@@ -687,13 +741,22 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, p
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div className="wishlist-detail-action-buttons">
|
||||||
<button
|
<button
|
||||||
onClick={handleComplete}
|
onClick={handleComplete}
|
||||||
disabled={isCompleting}
|
disabled={isCompleting}
|
||||||
className="wishlist-detail-complete-button"
|
className="wishlist-detail-complete-button"
|
||||||
>
|
>
|
||||||
{isCompleting ? 'Завершение...' : 'Завершить'}
|
{isCompleting ? '...' : 'Завершить'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={isCompleting}
|
||||||
|
className="wishlist-detail-reject-button"
|
||||||
|
>
|
||||||
|
{isCompleting ? '...' : 'Отклонить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateTask}
|
onClick={handleCreateTask}
|
||||||
|
|||||||
@@ -71,6 +71,14 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Одинаковая высота всех инпутов на экране */
|
||||||
|
.wishlist-form .form-input {
|
||||||
|
height: 2.75rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.image-preview {
|
.image-preview {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -108,6 +116,105 @@
|
|||||||
background: rgba(192, 57, 43, 1);
|
background: rgba(192, 57, 43, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-url-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-url-row + .image-url-row {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-url-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6b7280;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 3.5rem;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-url-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-form .file-input-label {
|
||||||
|
height: 2.75rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-label {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-label:hover {
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-hidden {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-button {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: #9ca3af;
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-url-load-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-url-load-button svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-url-load-button:hover:not(:disabled) {
|
||||||
|
background: #2980b9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-url-load-button:disabled {
|
||||||
|
background: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.cropper-modal {
|
.cropper-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -323,12 +430,52 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.condition-form form {
|
.condition-form form {
|
||||||
padding: 1.5rem 1.5rem 0.75rem 1.5rem;
|
padding: 0 1.5rem 0.75rem 1.5rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.condition-tabs-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-tabs-inner {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-bottom: 2px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-tabs-close {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-tab-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
transition: color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-tab-button:hover {
|
||||||
|
color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-tab-button.active {
|
||||||
|
color: #3498db;
|
||||||
|
border-bottom-color: #3498db;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -342,38 +489,14 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
min-height: 1.2em;
|
|
||||||
line-height: 1.2em;
|
line-height: 1.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-button {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: #3498db;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.condition-form-submit-button {
|
.condition-form-submit-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-button:hover:not(:disabled) {
|
|
||||||
background: #2980b9;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-button {
|
.cancel-button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
@@ -476,7 +599,9 @@
|
|||||||
|
|
||||||
.date-selector-display-date {
|
.date-selector-display-date {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.75rem;
|
height: 2.75rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 0.75rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -485,6 +610,8 @@
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-selector-display-date:hover {
|
.date-selector-display-date:hover {
|
||||||
@@ -540,7 +667,9 @@
|
|||||||
|
|
||||||
.task-autocomplete-input {
|
.task-autocomplete-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 36px 12px 14px;
|
height: 2.75rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 36px 0 14px;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import Cropper from 'react-easy-crop'
|
import Cropper from 'react-easy-crop'
|
||||||
import { useAuth } from './auth/AuthContext'
|
import { useAuth } from './auth/AuthContext'
|
||||||
import Toast from './Toast'
|
import Toast from './Toast'
|
||||||
|
import './Wishlist.css'
|
||||||
import './WishlistForm.css'
|
import './WishlistForm.css'
|
||||||
|
|
||||||
|
// Извлекает первый URL из текста
|
||||||
|
function extractUrl(text) {
|
||||||
|
if (!text) return ''
|
||||||
|
const match = text.match(/https?:\/\/[^\s<>"'`,;!)\]]+/i)
|
||||||
|
return match ? match[0] : text
|
||||||
|
}
|
||||||
|
|
||||||
const API_URL = '/api/wishlist'
|
const API_URL = '/api/wishlist'
|
||||||
const TASKS_API_URL = '/api/tasks'
|
const TASKS_API_URL = '/api/tasks'
|
||||||
const PROJECTS_API_URL = '/projects'
|
const PROJECTS_API_URL = '/projects'
|
||||||
const WISHLIST_FORM_STATE_KEY = 'wishlistFormPendingState'
|
const WISHLIST_FORM_STATE_KEY = 'wishlistFormPendingState'
|
||||||
|
|
||||||
function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, boardId }) {
|
function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId: newTaskIdProp, boardId, isActive }) {
|
||||||
const { authFetch, user } = useAuth()
|
const { authFetch, user } = useAuth()
|
||||||
|
|
||||||
|
// newTaskId может прийти из props (через onNavigate) или из sessionStorage (через history.back)
|
||||||
|
const [newTaskId] = useState(() => {
|
||||||
|
if (newTaskIdProp) return newTaskIdProp
|
||||||
|
const stored = sessionStorage.getItem('wishlistFormNewTaskId')
|
||||||
|
if (stored) {
|
||||||
|
sessionStorage.removeItem('wishlistFormNewTaskId')
|
||||||
|
return parseInt(stored, 10)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [price, setPrice] = useState('')
|
const [price, setPrice] = useState('')
|
||||||
const [link, setLink] = useState('')
|
const [link, setLink] = useState('')
|
||||||
@@ -33,8 +54,18 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
const [toastMessage, setToastMessage] = useState(null)
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
const [loadingWishlist, setLoadingWishlist] = useState(false)
|
const [loadingWishlist, setLoadingWishlist] = useState(false)
|
||||||
const [fetchingMetadata, setFetchingMetadata] = useState(false)
|
const [fetchingMetadata, setFetchingMetadata] = useState(false)
|
||||||
const [restoredFromSession, setRestoredFromSession] = useState(false) // Флаг восстановления из sessionStorage
|
const [restoredFromSession, setRestoredFromSession] = useState(() => {
|
||||||
|
// Инициализируем флаг сразу, чтобы loadWishlist не запустился до восстановления
|
||||||
|
return !!(newTaskId && sessionStorage.getItem(WISHLIST_FORM_STATE_KEY))
|
||||||
|
})
|
||||||
const [loadedWishlistData, setLoadedWishlistData] = useState(null) // Данные желания для последующего маппинга условий
|
const [loadedWishlistData, setLoadedWishlistData] = useState(null) // Данные желания для последующего маппинга условий
|
||||||
|
const [imageUrlInput, setImageUrlInput] = useState('') // Ссылка на картинку для загрузки по URL
|
||||||
|
const [loadingImageFromUrl, setLoadingImageFromUrl] = useState(false)
|
||||||
|
const [newTaskConsumed, setNewTaskConsumed] = useState(false) // Флаг что newTaskId уже добавлен как цель
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [isCopying, setIsCopying] = useState(false)
|
||||||
|
const [showActionMenu, setShowActionMenu] = useState(false)
|
||||||
|
const actionMenuHistoryRef = useRef(false)
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
// Загрузка задач, проектов и саджестов групп
|
// Загрузка задач, проектов и саджестов групп
|
||||||
@@ -137,6 +168,24 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
}
|
}
|
||||||
}, [editConditionIndex, unlockConditions])
|
}, [editConditionIndex, unlockConditions])
|
||||||
|
|
||||||
|
// Обработка кнопки "назад" для диалога ConditionForm
|
||||||
|
const showConditionFormRef = useRef(false)
|
||||||
|
const conditionClosedByPopStateRef = useRef(false)
|
||||||
|
showConditionFormRef.current = showConditionForm
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePopState = () => {
|
||||||
|
if (showConditionFormRef.current) {
|
||||||
|
// Закрываем диалог — popstate уже убрал запись из стека
|
||||||
|
conditionClosedByPopStateRef.current = true
|
||||||
|
setShowConditionForm(false)
|
||||||
|
setEditingConditionIndex(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('popstate', handlePopState)
|
||||||
|
return () => window.removeEventListener('popstate', handlePopState)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Восстановление состояния при возврате с создания задачи
|
// Восстановление состояния при возврате с создания задачи
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedState = sessionStorage.getItem(WISHLIST_FORM_STATE_KEY)
|
const savedState = sessionStorage.getItem(WISHLIST_FORM_STATE_KEY)
|
||||||
@@ -159,6 +208,9 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
const restoredConditions = state.unlockConditions || []
|
const restoredConditions = state.unlockConditions || []
|
||||||
console.log('[WishlistForm] Restored conditions:', restoredConditions)
|
console.log('[WishlistForm] Restored conditions:', restoredConditions)
|
||||||
|
|
||||||
|
// Устанавливаем флаг синхронно, чтобы loadWishlist не перезаписал восстановленное состояние
|
||||||
|
setRestoredFromSession(true)
|
||||||
|
|
||||||
// Перезагружаем задачи, чтобы новая задача была в списке
|
// Перезагружаем задачи, чтобы новая задача была в списке
|
||||||
const reloadTasks = async () => {
|
const reloadTasks = async () => {
|
||||||
console.log('[WishlistForm] Reloading tasks...')
|
console.log('[WishlistForm] Reloading tasks...')
|
||||||
@@ -197,12 +249,10 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
console.log('[WishlistForm] Adding new condition, final conditions:', finalConditions)
|
console.log('[WishlistForm] Adding new condition, final conditions:', finalConditions)
|
||||||
setUnlockConditions(finalConditions)
|
setUnlockConditions(finalConditions)
|
||||||
}
|
}
|
||||||
|
setNewTaskConsumed(true)
|
||||||
} else {
|
} else {
|
||||||
setUnlockConditions(restoredConditions)
|
setUnlockConditions(restoredConditions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Устанавливаем флаг, что состояние восстановлено
|
|
||||||
setRestoredFromSession(true)
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[WishlistForm] Error reloading tasks:', err)
|
console.error('[WishlistForm] Error reloading tasks:', err)
|
||||||
@@ -237,6 +287,8 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
setCrop({ x: 0, y: 0 })
|
setCrop({ x: 0, y: 0 })
|
||||||
setZoom(1)
|
setZoom(1)
|
||||||
setCroppedAreaPixels(null)
|
setCroppedAreaPixels(null)
|
||||||
|
setImageUrlInput('')
|
||||||
|
setLoadingImageFromUrl(false)
|
||||||
setShowConditionForm(false)
|
setShowConditionForm(false)
|
||||||
setEditingConditionIndex(null)
|
setEditingConditionIndex(null)
|
||||||
setToastMessage(null)
|
setToastMessage(null)
|
||||||
@@ -300,6 +352,8 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
setImageUrl(null)
|
setImageUrl(null)
|
||||||
setImageFile(null)
|
setImageFile(null)
|
||||||
setImageRemoved(false)
|
setImageRemoved(false)
|
||||||
|
setImageUrlInput('')
|
||||||
|
setLoadingImageFromUrl(false)
|
||||||
setUnlockConditions([])
|
setUnlockConditions([])
|
||||||
setGroupName('')
|
setGroupName('')
|
||||||
setError('')
|
setError('')
|
||||||
@@ -319,9 +373,12 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extracted = extractUrl(link)
|
||||||
|
setLink(extracted)
|
||||||
|
|
||||||
// Проверяем валидность URL
|
// Проверяем валидность URL
|
||||||
try {
|
try {
|
||||||
new URL(link)
|
new URL(extracted)
|
||||||
} catch {
|
} catch {
|
||||||
setToastMessage({ text: 'Некорректная ссылка', type: 'error' })
|
setToastMessage({ text: 'Некорректная ссылка', type: 'error' })
|
||||||
return
|
return
|
||||||
@@ -334,7 +391,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ url: link.trim() }),
|
body: JSON.stringify({ url: extracted.trim() }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -434,6 +491,55 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Загрузка картинки по ссылке с последующим кропом
|
||||||
|
const loadImageFromUrl = async () => {
|
||||||
|
const extracted = extractUrl(imageUrlInput)
|
||||||
|
setImageUrlInput(extracted)
|
||||||
|
const url = extracted?.trim()
|
||||||
|
if (!url) {
|
||||||
|
setToastMessage({ text: 'Введите ссылку на картинку', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new URL(url)
|
||||||
|
} catch {
|
||||||
|
setToastMessage({ text: 'Некорректная ссылка на картинку', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingImageFromUrl(true)
|
||||||
|
try {
|
||||||
|
const proxyUrl = `${API_URL}/proxy-image?url=${encodeURIComponent(url)}`
|
||||||
|
const imgResponse = await authFetch(proxyUrl)
|
||||||
|
if (!imgResponse.ok) {
|
||||||
|
const errData = await imgResponse.json().catch(() => ({}))
|
||||||
|
throw new Error(errData.message || errData.error || 'Не удалось загрузить картинку')
|
||||||
|
}
|
||||||
|
const blob = await imgResponse.blob()
|
||||||
|
if (blob.size > 5 * 1024 * 1024) {
|
||||||
|
setToastMessage({ text: 'Картинка слишком большая (максимум 5MB)', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!blob.type.startsWith('image/')) {
|
||||||
|
setToastMessage({ text: 'По ссылке не изображение', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
setImageUrl(reader.result)
|
||||||
|
setImageFile(blob)
|
||||||
|
setImageRemoved(false)
|
||||||
|
setImageUrlInput('')
|
||||||
|
setShowCropper(true)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: err.message || 'Ошибка при загрузке картинки по ссылке', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setLoadingImageFromUrl(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onCropComplete = (croppedArea, croppedAreaPixels) => {
|
const onCropComplete = (croppedArea, croppedAreaPixels) => {
|
||||||
setCroppedAreaPixels(croppedAreaPixels)
|
setCroppedAreaPixels(croppedAreaPixels)
|
||||||
}
|
}
|
||||||
@@ -490,9 +596,25 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openConditionForm = () => {
|
||||||
|
setShowConditionForm(true)
|
||||||
|
conditionClosedByPopStateRef.current = false
|
||||||
|
window.history.pushState({ conditionForm: true }, '', window.location.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeConditionForm = () => {
|
||||||
|
setShowConditionForm(false)
|
||||||
|
setEditingConditionIndex(null)
|
||||||
|
// Если закрытие через popstate — запись уже убрана, не делаем back
|
||||||
|
if (!conditionClosedByPopStateRef.current) {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
conditionClosedByPopStateRef.current = false
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddCondition = () => {
|
const handleAddCondition = () => {
|
||||||
setEditingConditionIndex(null)
|
setEditingConditionIndex(null)
|
||||||
setShowConditionForm(true)
|
openConditionForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditCondition = (index) => {
|
const handleEditCondition = (index) => {
|
||||||
@@ -503,7 +625,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setEditingConditionIndex(index)
|
setEditingConditionIndex(index)
|
||||||
setShowConditionForm(true)
|
openConditionForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConditionSubmit = (condition) => {
|
const handleConditionSubmit = (condition) => {
|
||||||
@@ -516,13 +638,11 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
// Добавление нового условия
|
// Добавление нового условия
|
||||||
setUnlockConditions([...unlockConditions, { ...condition, display_order: unlockConditions.length }])
|
setUnlockConditions([...unlockConditions, { ...condition, display_order: unlockConditions.length }])
|
||||||
}
|
}
|
||||||
setShowConditionForm(false)
|
closeConditionForm()
|
||||||
setEditingConditionIndex(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConditionCancel = () => {
|
const handleConditionCancel = () => {
|
||||||
setShowConditionForm(false)
|
closeConditionForm()
|
||||||
setEditingConditionIndex(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveCondition = (index) => {
|
const handleRemoveCondition = (index) => {
|
||||||
@@ -537,6 +657,9 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
|
|
||||||
// Обработчик для создания задачи из ConditionForm
|
// Обработчик для создания задачи из ConditionForm
|
||||||
const handleCreateTaskFromCondition = () => {
|
const handleCreateTaskFromCondition = () => {
|
||||||
|
// Закрываем диалог цели перед переходом
|
||||||
|
setShowConditionForm(false)
|
||||||
|
|
||||||
// Сохранить текущее состояние формы
|
// Сохранить текущее состояние формы
|
||||||
const stateToSave = {
|
const stateToSave = {
|
||||||
name,
|
name,
|
||||||
@@ -559,6 +682,77 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
onNavigate?.('task-form', navParams)
|
onNavigate?.('task-form', navParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (!wishlistId) return
|
||||||
|
|
||||||
|
setShowActionMenu(false)
|
||||||
|
if (actionMenuHistoryRef.current) {
|
||||||
|
actionMenuHistoryRef.current = false
|
||||||
|
window.history.go(-2)
|
||||||
|
} else {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/${wishlistId}`, { method: 'DELETE' })
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при удалении')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting wishlist item:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!wishlistId) return
|
||||||
|
|
||||||
|
setShowActionMenu(false)
|
||||||
|
if (actionMenuHistoryRef.current) {
|
||||||
|
actionMenuHistoryRef.current = false
|
||||||
|
window.history.go(-2)
|
||||||
|
} else {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCopying(true)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/${wishlistId}/copy`, { method: 'POST' })
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => '')
|
||||||
|
throw new Error(errorText || 'Ошибка при копировании')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error copying wishlist item:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -659,12 +853,8 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetForm()
|
resetForm()
|
||||||
// Возвращаемся на доску, если она была указана
|
// Возврат назад по стеку истории
|
||||||
if (boardId) {
|
window.history.back()
|
||||||
onNavigate?.('wishlist', { boardId })
|
|
||||||
} else {
|
|
||||||
onNavigate?.('wishlist')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -678,6 +868,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="wishlist-form">
|
<div className="wishlist-form">
|
||||||
<button className="close-x-button" onClick={handleCancel}>
|
<button className="close-x-button" onClick={handleCancel}>
|
||||||
✕
|
✕
|
||||||
@@ -693,15 +884,16 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
<>
|
<>
|
||||||
<h2>{wishlistId ? 'Редактировать желание' : 'Новое желание'}</h2>
|
<h2>{wishlistId ? 'Редактировать желание' : 'Новое желание'}</h2>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form id="wishlist-form-element" onSubmit={handleSubmit}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="link">Ссылка</label>
|
<label htmlFor="link">Ссылка</label>
|
||||||
<div className="link-input-wrapper">
|
<div className="link-input-wrapper">
|
||||||
<input
|
<input
|
||||||
id="link"
|
id="link"
|
||||||
type="url"
|
type="text"
|
||||||
value={link}
|
value={link}
|
||||||
onChange={(e) => setLink(e.target.value)}
|
onChange={(e) => setLink(e.target.value)}
|
||||||
|
onBlur={() => setLink(extractUrl(link))}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
className="form-input"
|
className="form-input"
|
||||||
disabled={fetchingMetadata}
|
disabled={fetchingMetadata}
|
||||||
@@ -788,14 +980,52 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!imageUrl && (
|
||||||
|
<>
|
||||||
|
<div className="image-url-row">
|
||||||
|
<span className="image-url-label">Файл:</span>
|
||||||
|
<label className="file-input-label">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={handleImageSelect}
|
onChange={handleImageSelect}
|
||||||
className="form-input"
|
className="file-input-hidden"
|
||||||
style={{ display: imageUrl ? 'none' : 'block' }}
|
|
||||||
/>
|
/>
|
||||||
|
<span className="file-input-button">Выбрать</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="image-url-row">
|
||||||
|
<span className="image-url-label">Ссылка:</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={imageUrlInput}
|
||||||
|
onChange={(e) => setImageUrlInput(e.target.value)}
|
||||||
|
onBlur={() => setImageUrlInput(extractUrl(imageUrlInput))}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="form-input image-url-input"
|
||||||
|
disabled={loadingImageFromUrl}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="image-url-load-button"
|
||||||
|
onClick={loadImageFromUrl}
|
||||||
|
disabled={loadingImageFromUrl || !imageUrlInput.trim()}
|
||||||
|
title="Загрузить картинку по ссылке и обрезать"
|
||||||
|
>
|
||||||
|
{loadingImageFromUrl ? (
|
||||||
|
<div className="mini-spinner"></div>
|
||||||
|
) : (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCropper && (
|
{showCropper && (
|
||||||
@@ -886,11 +1116,6 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
<div className="form-actions">
|
|
||||||
<button type="submit" disabled={loading} className="submit-button">
|
|
||||||
{loading ? 'Сохранение...' : 'Сохранить'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{showConditionForm && (
|
{showConditionForm && (
|
||||||
@@ -901,7 +1126,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
onCancel={handleConditionCancel}
|
onCancel={handleConditionCancel}
|
||||||
editingCondition={editingConditionIndex !== null ? unlockConditions[editingConditionIndex] : null}
|
editingCondition={editingConditionIndex !== null ? unlockConditions[editingConditionIndex] : null}
|
||||||
onCreateTask={handleCreateTaskFromCondition}
|
onCreateTask={handleCreateTaskFromCondition}
|
||||||
preselectedTaskId={newTaskId}
|
preselectedTaskId={newTaskConsumed ? undefined : newTaskId}
|
||||||
authFetch={authFetch}
|
authFetch={authFetch}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -916,6 +1141,93 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isActive ? createPortal(
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: '2rem 1rem 0.75rem',
|
||||||
|
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
|
||||||
|
background: 'linear-gradient(to top, white 70%, rgba(255,255,255,0))',
|
||||||
|
zIndex: 1500,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="wishlist-form-element"
|
||||||
|
disabled={loading || isDeleting || isCopying}
|
||||||
|
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>
|
||||||
|
{wishlistId && (
|
||||||
|
<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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1319,10 +1631,19 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition,
|
|||||||
const [projectId, setProjectId] = useState(editingCondition?.project_id?.toString() || '')
|
const [projectId, setProjectId] = useState(editingCondition?.project_id?.toString() || '')
|
||||||
const [requiredPoints, setRequiredPoints] = useState(editingCondition?.required_points?.toString() || '')
|
const [requiredPoints, setRequiredPoints] = useState(editingCondition?.required_points?.toString() || '')
|
||||||
const [startDate, setStartDate] = useState(editingCondition?.start_date || '')
|
const [startDate, setStartDate] = useState(editingCondition?.start_date || '')
|
||||||
const [calculatedWeeksText, setCalculatedWeeksText] = useState(null)
|
const [calculatedWeeksText, setCalculatedWeeksText] = useState(
|
||||||
|
editingCondition?.type === 'project_points' ? (editingCondition?.weeks_text ?? null) : null
|
||||||
|
)
|
||||||
|
|
||||||
const isEditing = editingCondition !== null
|
const isEditing = editingCondition !== null
|
||||||
|
|
||||||
|
// Показываем срок разблокировки из редактируемого условия до прихода ответа API
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingCondition?.type === 'project_points' && editingCondition?.weeks_text) {
|
||||||
|
setCalculatedWeeksText(editingCondition.weeks_text)
|
||||||
|
}
|
||||||
|
}, [editingCondition?.id, editingCondition?.type, editingCondition?.weeks_text])
|
||||||
|
|
||||||
// Автоподстановка новой задачи
|
// Автоподстановка новой задачи
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preselectedTaskId && !editingCondition) {
|
if (preselectedTaskId && !editingCondition) {
|
||||||
@@ -1383,6 +1704,11 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition,
|
|||||||
project_id: type === 'project_points' ? parseInt(projectId) : null,
|
project_id: type === 'project_points' ? parseInt(projectId) : null,
|
||||||
required_points: type === 'project_points' ? parseFloat(requiredPoints) : null,
|
required_points: type === 'project_points' ? parseFloat(requiredPoints) : null,
|
||||||
start_date: type === 'project_points' && startDate ? startDate : null,
|
start_date: type === 'project_points' && startDate ? startDate : null,
|
||||||
|
...(type === 'project_points' && {
|
||||||
|
weeks_text: calculatedWeeksText || editingCondition?.weeks_text || null,
|
||||||
|
}),
|
||||||
|
...(editingCondition?.id != null && { id: editingCondition.id }),
|
||||||
|
...(editingCondition?.user_id != null && { user_id: editingCondition.user_id }),
|
||||||
}
|
}
|
||||||
onSubmit(condition)
|
onSubmit(condition)
|
||||||
// Сброс формы
|
// Сброс формы
|
||||||
@@ -1396,23 +1722,27 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition,
|
|||||||
return (
|
return (
|
||||||
<div className="condition-form-overlay" onClick={onCancel}>
|
<div className="condition-form-overlay" onClick={onCancel}>
|
||||||
<div className="condition-form" onClick={(e) => e.stopPropagation()}>
|
<div className="condition-form" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="condition-form-header">
|
<form onSubmit={handleSubmit}>
|
||||||
<h3>{isEditing ? 'Редактировать цель' : 'Добавить цель'}</h3>
|
<div className="condition-tabs-container">
|
||||||
<button onClick={onCancel} className="condition-form-close-button">
|
<div className="condition-tabs-inner">
|
||||||
✕
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`condition-tab-button ${type === 'project_points' ? 'active' : ''}`}
|
||||||
|
onClick={() => setType('project_points')}
|
||||||
|
>
|
||||||
|
Баллы
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`condition-tab-button ${type === 'task_completion' ? 'active' : ''}`}
|
||||||
|
onClick={() => setType('task_completion')}
|
||||||
|
>
|
||||||
|
Задача
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit}>
|
<button type="button" onClick={onCancel} className="condition-form-close-button condition-tabs-close">
|
||||||
<div className="form-group">
|
✕
|
||||||
<label>Тип условия</label>
|
</button>
|
||||||
<select
|
|
||||||
value={type}
|
|
||||||
onChange={(e) => setType(e.target.value)}
|
|
||||||
className="form-input"
|
|
||||||
>
|
|
||||||
<option value="project_points">Баллы</option>
|
|
||||||
<option value="task_completion">Задача</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{type === 'task_completion' && (
|
{type === 'task_completion' && (
|
||||||
@@ -1473,14 +1803,10 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition,
|
|||||||
{isEditing ? 'Сохранить' : 'Добавить'}
|
{isEditing ? 'Сохранить' : 'Добавить'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{type === 'project_points' && (
|
{type === 'project_points' && calculatedWeeksText && (
|
||||||
<div className="calculated-weeks-info">
|
<div className="calculated-weeks-info">
|
||||||
{calculatedWeeksText && (
|
|
||||||
<>
|
|
||||||
<span>Срок: </span>
|
<span>Срок: </span>
|
||||||
<span style={{ fontWeight: '600' }}>{calculatedWeeksText}</span>
|
<span style={{ fontWeight: '600' }}>{calculatedWeeksText}</span>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -187,8 +187,8 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
await fetchDictionary(newDictionaryId)
|
await fetchDictionary(newDictionaryId)
|
||||||
await fetchWordsForDictionary(newDictionaryId)
|
await fetchWordsForDictionary(newDictionaryId)
|
||||||
|
|
||||||
// Update navigation to use the new dictionary ID and name
|
// Update navigation to use the new dictionary ID and name (replace history entry so back goes to dictionaries)
|
||||||
onNavigate?.('words', { dictionaryId: newDictionaryId, dictionaryName: newDict.name })
|
onNavigate?.('words', { dictionaryId: newDictionaryId, dictionaryName: newDict.name }, { replace: true })
|
||||||
} else if (hasValidDictionary(currentDictionaryId)) {
|
} else if (hasValidDictionary(currentDictionaryId)) {
|
||||||
// Update existing dictionary (rename)
|
// Update existing dictionary (rename)
|
||||||
const response = await authFetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
|
const response = await authFetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
|
||||||
|
|||||||
30
run.sh
30
run.sh
@@ -5,6 +5,10 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# Включаем BuildKit для надёжного отслеживания изменений файлов
|
||||||
|
export DOCKER_BUILDKIT=1
|
||||||
|
export COMPOSE_DOCKER_CLI_BUILD=1
|
||||||
|
|
||||||
# Цвета для вывода
|
# Цвета для вывода
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
@@ -21,12 +25,6 @@ if [ ! -f ".env" ]; then
|
|||||||
echo " Создайте файл .env на основе env.example"
|
echo " Создайте файл .env на основе env.example"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Создаём play-life-llm/.env из env.example при отсутствии (для сервиса llm)
|
|
||||||
if [ ! -f "play-life-llm/.env" ]; then
|
|
||||||
echo -e "${YELLOW}Создаём play-life-llm/.env из env.example...${NC}"
|
|
||||||
cp play-life-llm/env.example play-life-llm/.env
|
|
||||||
echo " Задайте TAVILY_API_KEY и OLLAMA_HOST (для Docker: http://host.docker.internal:11434) в play-life-llm/.env"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Загружаем переменные окружения
|
# Загружаем переменные окружения
|
||||||
export $(cat .env | grep -v '^#' | grep -v '^$' | xargs)
|
export $(cat .env | grep -v '^#' | grep -v '^$' | xargs)
|
||||||
@@ -47,20 +45,11 @@ if docker-compose ps | grep -q "Up"; then
|
|||||||
echo -e "${YELLOW}Перезапуск существующих контейнеров...${NC}"
|
echo -e "${YELLOW}Перезапуск существующих контейнеров...${NC}"
|
||||||
echo " - Backend сервер (с пересборкой)"
|
echo " - Backend сервер (с пересборкой)"
|
||||||
echo " - Frontend приложение (с пересборкой)"
|
echo " - Frontend приложение (с пересборкой)"
|
||||||
echo " - LLM сервис (с пересборкой)"
|
|
||||||
echo " - База данных"
|
echo " - База данных"
|
||||||
# Пересобираем и перезапускаем веб-приложение
|
# Пересобираем без кэша и перезапускаем
|
||||||
echo -e "${BLUE}Пересборка веб-приложения...${NC}"
|
echo -e "${BLUE}Пересборка и перезапуск сервисов...${NC}"
|
||||||
docker-compose build --no-cache play-life-web
|
docker-compose build --no-cache play-life-web backend
|
||||||
docker-compose up -d --force-recreate play-life-web
|
docker-compose up -d --force-recreate play-life-web backend
|
||||||
# Пересобираем и перезапускаем бэкенд
|
|
||||||
echo -e "${BLUE}Пересборка бэкенда...${NC}"
|
|
||||||
docker-compose build --no-cache backend
|
|
||||||
docker-compose up -d --force-recreate backend
|
|
||||||
# Пересобираем и перезапускаем LLM сервис
|
|
||||||
echo -e "${BLUE}Пересборка LLM сервиса...${NC}"
|
|
||||||
docker-compose build --no-cache llm
|
|
||||||
docker-compose up -d --force-recreate llm
|
|
||||||
# Перезапускаем базу данных
|
# Перезапускаем базу данных
|
||||||
docker-compose restart db
|
docker-compose restart db
|
||||||
echo -e "${GREEN}✅ Контейнеры перезапущены${NC}"
|
echo -e "${GREEN}✅ Контейнеры перезапущены${NC}"
|
||||||
@@ -69,8 +58,7 @@ else
|
|||||||
echo " - База данных PostgreSQL 15 (порт: $DB_PORT)"
|
echo " - База данных PostgreSQL 15 (порт: $DB_PORT)"
|
||||||
echo " - Backend сервер (порт: $PORT)"
|
echo " - Backend сервер (порт: $PORT)"
|
||||||
echo " - Frontend приложение (порт: $WEB_PORT)"
|
echo " - Frontend приложение (порт: $WEB_PORT)"
|
||||||
echo " - LLM сервис (порт: 8090)"
|
docker-compose up -d --build --force-recreate db backend play-life-web
|
||||||
docker-compose up -d --build --force-recreate
|
|
||||||
echo -e "${GREEN}✅ Контейнеры запущены${NC}"
|
echo -e "${GREEN}✅ Контейнеры запущены${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
42
runLLM.sh
Executable file
42
runLLM.sh
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для запуска play-life-llm (обычно на отдельной машине)
|
||||||
|
# Использование: ./runLLM.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Цвета для вывода
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Создаём play-life-llm/.env из env.example при отсутствии
|
||||||
|
if [ ! -f "play-life-llm/.env" ]; then
|
||||||
|
echo -e "${YELLOW}Создаём play-life-llm/.env из env.example...${NC}"
|
||||||
|
cp play-life-llm/env.example play-life-llm/.env
|
||||||
|
echo " Задайте TAVILY_API_KEY и OLLAMA_HOST (для Docker: http://host.docker.internal:11434) в play-life-llm/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}🔄 Запуск play-life-llm...${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if docker-compose ps llm 2>/dev/null | grep -q "Up"; then
|
||||||
|
echo -e "${YELLOW}Перезапуск LLM сервиса (с пересборкой)...${NC}"
|
||||||
|
docker-compose build --no-cache llm
|
||||||
|
docker-compose up -d --force-recreate llm
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Запуск LLM сервиса (порт: 8090)...${NC}"
|
||||||
|
docker-compose up -d --build llm
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✅ play-life-llm запущен${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}📋 Статус:${NC}"
|
||||||
|
docker-compose ps llm
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✅ Готово!${NC}"
|
||||||
Reference in New Issue
Block a user