commit ca3392157ab573b23ff87ba97a7bf6397e9caf4e Author: edadmin Date: Sun Oct 18 22:44:06 2026 +0300 feat: prod web image + Gitea Actions, demo chips removed diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..324ebf4 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,70 @@ +# CI/CD веба через Gitea Actions (встроенный в Gitea, раннер на сервере). +# +# push/PR: проверка синтаксиса JS + сборка прод-образа (локальные теги +# latest и короткий SHA, реестр не используется). +# Деплой — ТОЛЬКО ручной запуск workflow (кнопка Run workflow): job скачивает +# compose-файлы репо бэкенда по API (секреты GITEA_HOST/GITEA_USER/ +# GITEA_TOKEN) и перезапускает web-сервис стека свежим образом. +# +# Требования к раннеру (см. README бэкенда, раздел «CI/CD (Gitea Actions)»): +# runner.labels: [docker] — совпадает с runs-on ниже; +# container.valid_volumes: /var/run/docker.sock, /opt/building-mgmt — +# сокет docker для сборки/деплоя и каталог стека с .env. +name: web + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + check: + runs-on: docker + container: node:20-alpine + steps: + - uses: actions/checkout@v4 + - name: Синтаксис JS + run: | + node --check js/api.js + node --check js/app.js + + build-web: + runs-on: docker + container: docker:27-cli + steps: + - uses: actions/checkout@v4 + - name: Сборка прод-образа + run: | + short="$(echo "$GITHUB_SHA" | cut -c1-7)" + docker build -t building-mgmt-web:latest -t "building-mgmt-web:${short}" . + + deploy: + if: github.event_name == 'workflow_dispatch' + needs: build-web + runs-on: docker + container: docker:27-cli + env: + GITEA_HOST: ${{ secrets.GITEA_HOST }} + GITEA_USER: ${{ secrets.GITEA_USER }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + steps: + - name: Compose-файлы бэкенда (compose.prod.yaml) + run: | + # compose-CLI и утилиты в джоб-контейнере; docker — через + # смонтированный сокет хоста (valid_volumes у раннера). + apk add --no-cache docker-cli-compose + auth="$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')" + wget -q -O /tmp/axe.tar.gz \ + --header="Authorization: Basic ${auth}" \ + "http://${GITEA_HOST}/api/v1/repos/edadmin/axe/tarball/main" + mkdir -p /opt/building-mgmt + tar -xzf /tmp/axe.tar.gz -C /tmp + cp /tmp/axe-*/compose.prod.yaml /opt/building-mgmt/ + - name: Перезапуск web-сервиса стека + # --no-deps: трогаем только web, api/db/minio не пересоздаются. + run: | + docker compose -p building-mgmt \ + --env-file /opt/building-mgmt/.env \ + -f /opt/building-mgmt/compose.prod.yaml \ + up -d --no-deps web diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..925ac29 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# Прод-образ веба: статика копируется в образ (в dev- compose она +# монтируется bind-mount'ом — здесь иммутабельная копия). +# Сборка: docker build -t building-mgmt-web:latest . +# +# Образ входит в стек building-mgmt-backend (compose.prod.yaml): +# прокси API — на контейнер api (http://api:8080) внутри сети compose. + +FROM nginx:1.27-alpine + +# Подменяем дефолтный конфиг nginx: прод-прокси вместо dev-прокси +# на хост разработчика. +COPY nginx.prod.conf /etc/nginx/conf.d/default.conf + +COPY index.html /usr/share/nginx/html/index.html +COPY css/ /usr/share/nginx/html/css/ +COPY js/ /usr/share/nginx/html/js/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ebe7f7d --- /dev/null +++ b/README.md @@ -0,0 +1,171 @@ +# building-mgmt-web — веб приложения «Обслуживание зданий» + +Простой HTML+JS **без сборки и зависимостей** для +building-mgmt-backend: логин, заявки (фильтры/страницы/переходы/журнал), +проекты (объекты и мастера), приглашения, фотоотчёт (S3), самостоятельное +взятие заявки мастером, создание клиентов админом, разделы админа +«Мастера» и «Клиенты» с удалением/отключением аккаунтов и сбросом +пароля (ссылкой либо напрямую), удаление проекта админом. + +Стек: чистые ES-модули в два файла (`js/api.js` — транспорт, +`js/app.js` — экраны), статику отдаёт nginx в контейнере. + +## Запуск (3 шага) + +```powershell +# 1. БД + API (в репо building-mgmt-backend) +docker compose up -d # postgres на :5433 +go run ./cmd/migrate # миграции (0001..0016) +go run ./cmd/seed --force --yes # демо-данные (пароль demo-password-123) +go run ./cmd/api # API на :8080 + +# 2. Веб (здесь) +docker compose up -d # nginx на :3000 + +# 3. Браузер +# http://localhost:3000 +``` + +Фронт ходит в API **на том же origin** (`BASE=''` в `js/api.js`): +nginx проксирует API-пути (`/auth`, `/users`, `/tickets`, …) на бэкенд +`host.docker.internal:8080` — см. `nginx.conf`. Поэтому CORS не нужен, +но `CORS_ORIGINS` в `.env` бэкенда безвреден (страховка для прямых +обращений на :8080). Прокси перезаписывает `X-Forwarded-For` — для +бэкенда это значит: укажите `TRUSTED_PROXIES` (подсеть docker) в `.env` +бэкенда, и лимиты/логи увидят реальный IP. + +Правки `index.html`/`js/*`/`css/*` видны после **F5** — контейнер +монтирует каталог напрямую, пересборка не нужна. Правка `nginx.conf` +видна после перезапуска контейнера: `docker compose restart`. + +## Прод-образ и CI + +- `Dockerfile` — прод-образ: статика копируется внутрь (в деве она + монтируется bind-mount'ом), конфиг — `nginx.prod.conf`. Сборка: + `docker build -t building-mgmt-web:latest .` +- `nginx.prod.conf` — отличия от dev `nginx.conf`: прокси API на + контейнер `api` (`http://api:8080` внутри сети compose, вместо + `host.docker.internal:8080`) и security-заголовки + (nosniff/frame/referrer) поверх HSTS. Образ входит в стек + `compose.prod.yaml` репо бэкенда: наружу стек отдаёт только этот + nginx (порт 80, TLS-терминал перед ним), postgres/API/minio видны + только внутри сети. +- `.gitea/workflows/ci.yml` — CI/CD через **Gitea Actions** (встроенный в + Gitea): push/PR делают `node --check` и собирают образ + `building-mgmt-web:latest`+SHA (локальные теги, без реестра); + **деплой — только ручным запуском** workflow (кнопка Run workflow): + шаг скачивает compose-файлы репо бэкенда по API (секреты репо + `GITEA_HOST`/`GITEA_USER`/`GITEA_TOKEN`) и перезапускает web-сервис + стека свежим образом. Подробности — README бэкенда, раздел + «CI/CD (Gitea Actions)». + +## Демо-аккаунты + +| Роль | Email | Что проверять | +|---|---|---| +| клиент | `demo@example.com` | заявки, проекты, приглашение мастеров | +| мастер | `demo-worker@example.com` | назначенные заявки, переходы, приглашения | +| мастер 2 | `demo-worker2@example.com` | приглашение → принять/отклонить | +| админ | `demo-admin@example.com` | все проекты/заявки, сброс пароля мастерам и клиентам (ссылкой либо напрямую), разделы «Мастера» и «Клиенты» | + +Пароль у всех: `demo-password-123` (вводится на экране входа вручную). + +## Сценарий для проверки логики + +1. **Клиент** → «Проекты» → «Демо-проект» → в блоке «Пригласить мастера» + **поиск по имени/email** (например, `demo-worker2` — он уже в базе, но + свободен) → «Пригласить». Либо создать нового мастера прямо в форме. +2. **Мастер 2** → «Приглашения» → «Принять». +3. **Клиент** → «Заявки» → «+ Новая заявка» (компактное модальное окно + поверх списка: закрывается крестиком, Escape или кликом по фону; + галочка «Повторяющаяся заявка» открывает **отдельное окно условий + повтора** — дата-время первого запуска и период в днях/неделях, см. 11а) → в деталях «Назначить» + (мастер обязан быть принявшим приглашение — иначе 400 с объяснением). + В таблице «Мастера» проекта есть локальный фильтр по имени/email. +3а. **Мастер** видит в списке свободные заявки своих проектов (бейдж «новая») + и берёт их кнопкой «Взять заявку» — дальше «Начать работу» как обычно. +4. **Мастер** → «Начать работу» → «Работа выполнена». Фотоотчёт — только + в работе: после «Работа выполнена» загрузка и удаление закрыты + (после отклонения клиентом открываются снова со «Начать работу»). +5. **Клиент** → «Принять работу» (или «Отклонить» с причиной — тогда + мастер на заявке «на доработке» видит баннер с причиной и кнопку + «Начать работу»: берёт второй круг, после переделки снова «Работа + выполнена», и клиент принимает работу). +6. Журнал событий под заявкой показывает всю хронологию с авторами. +7. **Админ** → «Клиенты» → «+ Клиент»: создание аккаунта клиента — + единственный способ завести клиента (саморегистрации нет). +8. **Админ** → «Мастера»/«Клиенты» → в строке: «Сброс пароля» — окно с + двумя способами: «Выпустить ссылку» (одноразовый адрес + `#/reset-password?token=…`, живёт 24 часа, кнопка «Копировать» — + адрес передаётся пользователю сам) либо «Задать пароль напрямую» + (сервер ставит его сразу: все сессии пользователя закрываются, + неиспользованные токены сброса гаснут). Кнопку видит только админ — + раздел «Мастера» есть и у клиента, а эндпоинт закрыт ролью. + Дальше в строке: «Удалить» (чистый + аккаунт удаляется; есть заявки/проекты → диалог «Отключить вместо + удаления?»), «Отключить»/«Включить». Отключённые остаются в списке + с бейджем «отключён» и включаются обратно. +9. **Клиент** → «Проекты» → проект → «Мастера»: «Отключить» (мастер + теряет доступ во всех проектах) и «Убрать» (из проекта) — обе кнопки + с подтверждением; отключённый мастер виден с бейджем «отключён». +10. **Вход** → «Забыли пароль?» → email → письмо со ссылкой → + `#/reset-password?token=…` → новый пароль. Ответ сервера одинаков + для существующего и несуществующего адреса, так что перебор + ящиков не работает; 4 попытки подряд → 429. +11. **Админ** → «Проекты» → проект → «Удалить проект»: подтверждение + предупреждает, что вместе с проектом уйдут объекты, участия мастеров + и все заявки. У клиента кнопки нет (роут закрыт ролью `admin`), + повторное удаление — 404. +11а. **Клиент** → «Заявки» → «+ Новая заявка» → галочка + «Повторяющаяся заявка» открывает **отдельное окно с условиями + повтора**: **дата-время первого запуска** (нативный календарь, по + умолчанию «сейчас + 7 дней, 10:00»), **период** — «каждые N + дней/недель» (единицы согласуются: «1 день», «2 недели») и текст + условий. «Применить» закрывает окно и ставит галку — под ней строка + «Расписание: каждые 14 недель с 15.10.2026, 10:00» со ссылкой + «Изменить»; выход без применения (крестик, Escape, фон, «Отмена») + галку снимает. Заявка создаётся вместе с + правилом повторения — одной операцией, без отдельной секции в + проекте. Управление — на странице самой заявки, блок + «Повторяющаяся заявка»: расписание («каждые 14 дней с 15.10.2026, + 10:00»), ближайший запуск, «Выключить»/«Включить» (выключенное + правило планировщик пропускает) и «История» (модалка: какие заявки + создавались по правилу и когда, ссылки на заявки). Мастеру блок + виден без кнопок. Новая заявка по правилу появляется сама — + фоновый планировщик сервера бежит раз в сутки, создавая заявку + **по периоду от этой даты-времени** (точность до суток — выбранное + время ориентировочное) и **ждёт закрытия прошлой заявки**, + поэтому дубликатов не будет; клиенту при этом приходит уведомление + в Telegram, как при обычных напоминаниях. +12. **Любая роль** → «Настройки» → «Уведомления в Telegram»: заявка, + пролежавшая в статусе «новая» больше суток, уходит клиенту в Telegram. + Первым делом — блок «Свой бот»: вставляете токен от @BotFather, + сервер проверяет его и шифрует, дальше уведомления идут через вашего + бота (поле `type=password`, токен никогда не показывается обратно — + только имя бота и кнопка «Отключить бота»). Дальше две цели: + «Получить код привязки» (одноразовый код и ссылка + `t.me/?start=…` → бот привязывает этот чат) и поле + «Группа или канал» (бот должен состоять в группе). Если на сервере + нет ни `TELEGRAM_BOT_TOKEN`, ни `DATA_ENCRYPTION_KEY`, экран честно + пишет, что фича выключена; 400 по токену → «Токен не принят…», + 400 по цели → «Бот не видит этот чат…», 429 → «Слишком много…». + +Ошибки API (400/403/409/429) всплывают как есть — их короткий текст +и есть проверка серверных правил. + +## Структура + +``` +Dockerfile прод-образ: nginx:alpine + копия статики и конфига +compose.yaml nginx-контейнер на :3000 (лимиты: 256m памяти, 0.5 ядра) +nginx.conf статика + no-cache + rate-limit (10 r/s и 20 соединений + на IP → 429 сверху) + прокси API-путей на бэкенд +nginx.prod.conf прод-конфиг: прокси на контейнер api + security-заголовки +.gitea/workflows/ci.yml CI/CD: node --check, сборка образа, деплой по ручному запуску +index.html два экрана: вход и приложение +css/style.css минимум стилей +js/api.js fetch (BASE='' — тот же origin), хранение сессии, тихий refresh при 401 +js/app.js маршруты в hash, рендер секций, кнопки по ролям; «Настройки» + — свои боты и привязка Telegram (tgStatus/tgCode/tgSet/tgUnbind/ + tgSetBot/tgDelBot) +``` diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..7ca63f3 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,22 @@ +# Статика тестового веба в nginx — отдельный контейнер отдельного репо. +# Запуск: docker compose up -d Стоп: docker compose down +# +# Порт 3000: страница и API на одном origin — nginx.conf проксирует +# API-пути на бэкенд хоста (host.docker.internal:8080), CORS не нужен. +services: + web: + image: nginx:alpine + container_name: building-mgmt-web + ports: + - "3000:80" + # Ресурсные потолки: статика не должна сожрать больше половины + # ядра и четверти гигабайта — иначе случайный поток запросов + # (или gzip) мог бы тянуть память машины. + mem_limit: 256m + cpus: 0.5 + volumes: + # Файлы монтируются напрямую: правки HTML/JS/CSS видны после F5, + # пересборка не нужна. + - ./:/usr/share/nginx/html:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + restart: unless-stopped diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..540a8aa --- /dev/null +++ b/css/style.css @@ -0,0 +1,310 @@ +/* Минимальные стили: читаемость важнее красоты. */ +* { box-sizing: border-box; } + +body { + margin: 0; + font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; + background: #f2f4f7; + color: #1c2733; +} + +.hidden { display: none !important; } + +h1 { margin: 0 0 4px; font-size: 24px; } +h2 { margin: 0; font-size: 20px; } +h3 { margin: 0 0 10px; font-size: 17px; } + +.hint { margin: 0 0 16px; color: #64748b; } +/* Баннер второго круга на заявке «на доработке»: мастеру видно, что + делать дальше; цветом отличается от серых подсказок. */ +.notice { + margin: 0 0 12px; + padding: 10px 12px; + background: #fef3c7; + border: 1px solid #fcd34d; + border-radius: 8px; + color: #92400e; +} +/* Ссылка-кнопка внутри подсказки («Изменить расписание»). */ +button.link { + background: none; + border: 0; + padding: 0; + font: inherit; + color: #2563eb; + cursor: pointer; +} +button.link:hover { text-decoration: underline; } +.empty { color: #64748b; padding: 12px 0; } +.error { color: #b42318; min-height: 18px; font-size: 13px; } + +/* --- карточки --- */ +.card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 16px; + margin-bottom: 16px; +} + +/* --- модальное окно (создание заявки) --- */ +/* Затемнённый фон + узкий диалог по центру: форма не разворачивается + на всё окно и не уезжает под низ списка. */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.45); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 48px 16px 16px; + z-index: 20; + overflow-y: auto; +} +.modal { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 18px; + width: 100%; + max-width: 440px; + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18); +} +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} +.modal-head h3 { margin: 0; } +.modal-close { + border: 0; + background: transparent; + font-size: 20px; + line-height: 1; + color: #64748b; + cursor: pointer; + padding: 4px 6px; + border-radius: 6px; +} +.modal-close:hover { background: #f1f5f9; color: #1c2733; } +/* Диалог один: поля в столбик, чтобы не распирали его вширь. */ +.modal .form { flex-direction: column; align-items: stretch; gap: 10px; } +.modal .form label { flex: 1 1 auto; } +/* Строка «Каждые N [единица]» в окне условий: поля рядом, без растяжки. */ +#sched .row label { flex: 0 0 auto; } +/* Окно сброса пароля: два способа в одном диалоге — второй отделён + линией, блок со ссылкой раскрывается после выпуска токена. */ +#pr-wrap .form + .form { + margin-top: 14px; + border-top: 1px solid #e2e8f0; + padding-top: 14px; +} +#pr-link-box { margin-top: 4px; } + +/* --- экран входа --- */ +#login-view { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} +.login-card { width: 100%; max-width: 380px; padding: 24px; } + +/* --- шапка --- */ +.topbar { + display: flex; + align-items: center; + gap: 20px; + background: #0f172a; + color: #fff; + padding: 10px 16px; + position: sticky; + top: 0; + z-index: 5; +} +.brand { font-weight: 600; } +#nav { display: flex; gap: 4px; flex: 1; } +.nav-link { + color: #cbd5e1; + text-decoration: none; + padding: 6px 12px; + border-radius: 8px; +} +.nav-link:hover { background: #1e293b; color: #fff; } +.nav-link.active { background: #334155; color: #fff; } +.who { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #cbd5e1; } + +main { max-width: 1080px; margin: 20px auto; padding: 0 16px; } + +/* --- формы --- */ +label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #475569; } +input, select, textarea { + font: inherit; + padding: 7px 9px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #fff; + color: #1c2733; +} +textarea { min-height: 70px; resize: vertical; } +input:focus, select:focus, textarea:focus { outline: 2px solid #93c5fd; border-color: #60a5fa; } + +.form, .filters { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } +.form label, .filters label { flex: 1 1 160px; } +.filters { margin: 12px 0; } +.wide { flex-basis: 100% !important; } + +/* --- кнопки --- */ +button { + font: inherit; + padding: 7px 14px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #fff; + color: #1c2733; + cursor: pointer; +} +button:hover { background: #f1f5f9; } +button:disabled { opacity: .45; cursor: default; } +button.primary { background: #2563eb; border-color: #2563eb; color: #fff; } +button.primary:hover { background: #1d4ed8; } +button.danger { background: #fff; border-color: #fca5a5; color: #b42318; } +button.danger:hover { background: #fef2f2; } +button.ghost { background: transparent; border-color: #475569; color: inherit; } +button.ghost:hover { background: #1e293b; } + +.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.row.head { justify-content: space-between; margin-bottom: 8px; } +.row.actions { margin-top: 14px; } + +/* --- таблицы --- */ +.table { width: 100%; border-collapse: collapse; font-size: 14px; } +.table th, .table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #e2e8f0; } +/* Две кнопки действий в одной ячейке — разводим, иначе слипаются. */ +.table td button + button { margin-left: 6px; } +.table th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #64748b; } +.table tbody tr.link { cursor: pointer; } +.table tbody tr.link:hover { background: #f8fafc; } + +.pager { display: flex; gap: 12px; align-items: center; margin-top: 12px; font-size: 14px; } + +/* --- бейджи --- */ +.badge { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 12px; + background: #e2e8f0; +} +.s-new { background: #e0f2fe; color: #075985; } +.s-assigned { background: #ede9fe; color: #5b21b6; } +.s-in_progress { background: #fef3c7; color: #92400e; } +.s-done { background: #dcfce7; color: #166534; } +.s-rejected { background: #fee2e2; color: #991b1b; } +.s-closed { background: #e2e8f0; color: #475569; } +.m-invited { background: #fef3c7; color: #92400e; } +.m-active { background: #dcfce7; color: #166534; } +.m-declined { background: #fee2e2; color: #991b1b; } +.s-pending { background: #fef3c7; color: #92400e; } +.p-high { color: #b42318; font-weight: 600; } +.p-low { color: #64748b; } + +/* --- детали заявки --- */ +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 10px 20px; + margin: 12px 0; +} +.detail-grid .k { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: .04em; } +.desc-block { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; margin-top: 10px; } + +/* --- журнал событий --- */ +.events { list-style: none; margin: 8px 0 0; padding: 0; } +.events li { + display: flex; + gap: 10px; + flex-wrap: wrap; + padding: 7px 0; + border-bottom: 1px dashed #e2e8f0; + font-size: 14px; +} +.events .when { color: #64748b; white-space: nowrap; } +.events .who { color: #334155; font-weight: 600; } +.events .arrow { color: #64748b; } +.events .why { color: #b42318; } + +/* --- всплывающее сообщение --- */ +.flash { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + max-width: 90vw; + padding: 10px 18px; + border-radius: 10px; + font-size: 14px; + box-shadow: 0 8px 24px rgba(15, 23, 42, .18); + z-index: 50; +} +.flash.ok { background: #14532d; color: #fff; } +.flash.err { background: #7f1d1d; color: #fff; } + +/* --- блоки секций проекта --- */ +.section-head { display: flex; justify-content: space-between; align-items: center; margin: 18px 0 8px; } +.invite-box { background: #f8fafc; border: 1px dashed #cbd5e1; border-radius: 8px; padding: 12px; margin-top: 12px; } +code { background: #eef2f7; padding: 1px 5px; border-radius: 4px; } + +/* --- фотоотчёт --- */ +.photo-actions { margin-bottom: 12px; } +.photos { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; +} +.photo { + margin: 0; + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: hidden; + background: #f8fafc; +} +.photo a { display: block; } +.photo img { display: block; width: 100%; height: 140px; object-fit: cover; } +.photo-broken { + height: 140px; + display: flex; + align-items: center; + justify-content: center; + color: #64748b; + font-size: 13px; +} +.photo figcaption { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + font-size: 12px; +} +.photo figcaption .who { + flex: 1; + min-width: 0; + color: #334155; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.photo figcaption .when { color: #64748b; white-space: nowrap; } +.photo-del { + border: none; + background: transparent; + color: #94a3b8; + font-size: 16px; + line-height: 1; + padding: 2px 6px; +} +.photo-del:hover { color: #b42318; background: #fef2f2; } diff --git a/index.html b/index.html new file mode 100644 index 0000000..49c9e91 --- /dev/null +++ b/index.html @@ -0,0 +1,71 @@ + + + + + + Обслуживание зданий + + + + +
+ + + + + + + +
+ + + + + + + + + + + diff --git a/js/api.js b/js/api.js new file mode 100644 index 0000000..d8cbffc --- /dev/null +++ b/js/api.js @@ -0,0 +1,182 @@ +'use strict'; +// api.js — обёртка над HTTP к бэкенду: хранение сессии, тихий refresh, +// единый формат ошибок. Никакой бизнес-логики — только транспорт. + +const API = (() => { + // Пустой BASE = API на том же origin, что и страница: nginx (см. + // nginx.conf) проксирует API-пути на бэкенд. Фронту не нужны CORS и + // отдельный порт — один домен и локально, и в проде. + const BASE = ''; + const STORE = 'building-mgmt.session'; + + // Ошибка API: status + короткий `error` из ответа (или HTTP N). + class ApiError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } + } + + function load() { + try { return JSON.parse(localStorage.getItem(STORE)); } catch (e) { return null; } + } + function save(sess) { localStorage.setItem(STORE, JSON.stringify(sess)); } + function clear() { localStorage.removeItem(STORE); } + + // Текущий профиль или null (не залогинен). + function user() { + const s = load(); + return s ? s.user : null; + } + function session() { return load(); } + + // Запомнить результат входа/обновления: в localStorage живут только + // access-токен, срок и профиль. Refresh-токен сюда никогда не попадает — + // он в httpOnly-куке, из JavaScript недоступен (защита от XSS). + function store(data) { + save(Object.assign({}, data, { expires_at: Date.now() + data.expires_in * 1000 })); + } + + async function login(email, password) { + const data = await request('POST', '/auth/login', { email, password }, { auth: false }); + store(data); + return data.user; + } + + // «Забыли пароль»: сервер всегда отвечает одинаково ({ ok: true }) — + // ответ не перечисляет существующие ящики. 429 — лимит попыток (IP + // плюс 3 письма в сутки на один адрес). + async function forgotPassword(email) { + return request('POST', '/auth/forgot-password', { email }, { auth: false }); + } + + // Сброс по токену из письма. 400 — токен неизвестен, уже + // использован или истёк; слабый пароль тоже 400 с тем же кодом. + async function resetPassword(token, password) { + return request('POST', '/auth/reset-password', { token, password }, { auth: false }); + } + + // --- Telegram: уведомления о заявках, не взятых в работу за 24 часа --- + + // Состояние своей привязки: enabled (настроен ли бот), bound/target, + // имя бота для ссылки. Чужим это поле сервер не отдаёт. + const tgStatus = () => get('/auth/telegram'); + + // Одноразовый код привязки + ссылка t.me/?start=. + // 400 — бот не настроен, 429 — лимит (5 кодов за 5 минут). + const tgCode = () => post('/auth/telegram/code', {}); + + // Ручная цель (@группа или chat_id); пустая строка — отключить. + // 400 — формат не тот либо бот не видит чат. + const tgSet = (target) => request('PUT', '/auth/telegram', { target }); + + // То же, что tgSet(''): убрать цель и перестать слать уведомления. + const tgUnbind = () => del('/auth/telegram'); + + // Свой бот: токен от @BotFather. Сервер проверяет его через getMe, + // шифрует и запускает поллер. 400 — нет DATA_ENCRYPTION_KEY / формат + // не тот / getMe не принял, 429 — лимит попыток (5 за 5 минут). + const tgSetBot = (token) => post('/auth/telegram/bot', { token }); + + // Отключить своего бота: токен стирается, поллер гасится, дальше + // уведомления (если цель осталась) пойдут через бота сервера. + const tgDelBot = () => del('/auth/telegram/bot'); + + // Выход: гасим сессию на сервере (не даём ошибке сбить локальный выход). + // Тело не нужно: сервер берёт refresh-токен из куки и очищает её. + async function logout() { + clear(); + try { + await request('POST', '/auth/logout', null, { auth: false, noRetry: true }); + } catch (e) { /* локально уже вышли — серверная ошибка не мешает */ } + } + + // Принудительный refresh (когда access-токен протух). Токен в теле + // не шлём — сервер читает refresh-куку (same-origin, кука уходит сама). + async function refreshNow() { + const s = load(); + if (!s) throw new ApiError(401, 'no token'); + const data = await request('POST', '/auth/refresh', null, { auth: false, noRetry: true }); + store(data); + return data; + } + + // Тихий refresh до запроса, если токен вот-вот протухнет. + async function ensureFresh() { + const s = load(); + if (!s) throw new ApiError(401, 'no token'); + if (s.expires_at - Date.now() > 15000) return; + await refreshNow(); + } + + // request(method, path, body, opts) — основной вызов. + // opts.auth — слать ли Authorization (по умолчанию да) + // opts.noRetry — не пробовать refresh при 401 + async function request(method, path, body, opts) { + opts = opts || {}; + const withAuth = opts.auth !== false; + if (withAuth) await ensureFresh(); + + // FormData (multipart, загрузка фото) не оборачиваем в JSON и не + // задаём Content-Type сами: браузер сам поставит boundary. + const isForm = (typeof FormData !== 'undefined') && (body instanceof FormData); + const headers = {}; + if (!isForm) headers['Content-Type'] = 'application/json'; + if (withAuth) { + const s = load(); + if (s) headers.Authorization = 'Bearer ' + s.token; + } + + let res; + try { + res = await fetch(BASE + path, { + method, + headers, + body: (body === undefined || body === null) ? undefined : (isForm ? body : JSON.stringify(body)), + // Куки (refresh-токен, Path=/auth) уходят только на своих — + // same-origin и есть поведение по умолчанию, но помечаем явно: + // вся сессия держится на этой куке. + credentials: 'same-origin', + }); + } catch (e) { + // Это не ответ API, а обрыв связи (упал сервер/прокси): сырой + // "Failed to fetch" пользователю ничего не говорит. + throw new ApiError(0, 'сервер недоступен, попробуйте ещё раз'); + } + + if (res.status === 204) return null; + + const text = await res.text(); + let data = null; + if (text) { try { data = JSON.parse(text); } catch (e) { data = null; } } + + if (!res.ok) { + // 401 при живой сессии — пробуем refresh ровно один раз. + if (res.status === 401 && withAuth && !opts.noRetry && !opts.retried) { + try { + await refreshNow(); + } catch (e) { + clear(); // сессия мертва — на экран входа + throw new ApiError(401, 'no token'); + } + return request(method, path, body, Object.assign({}, opts, { retried: true })); + } + throw new ApiError(res.status, (data && data.error) || ('HTTP ' + res.status)); + } + return data; + } + + const get = (path) => request('GET', path); + const post = (path, body) => request('POST', path, body); + const patch = (path, body) => request('PATCH', path, body); + const del = (path) => request('DELETE', path); + // Загрузка файла: тело — FormData, остальное (refresh, формат ошибок) + // — те же пути, что у request. + const upload = (path, formData) => request('POST', path, formData); + + return { + user, login, forgotPassword, resetPassword, logout, + tgStatus, tgCode, tgSet, tgUnbind, tgSetBot, tgDelBot, + get, post, patch, del, upload, + }; +})(); diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..91fdf94 --- /dev/null +++ b/js/app.js @@ -0,0 +1,1944 @@ +'use strict'; +// app.js — экраны тестового веба: логин, заявки (+журнал), проекты +// (объекты и мастера), приглашения, настройки (Telegram). Никаких +// фреймворков: состояние → рендер, маршруты в hash (#/tickets?status=new&offset=20). + +// ============================== словари ============================== +const DICT = { + status: { + new: 'новая', assigned: 'назначена', in_progress: 'в работе', + done: 'сделана', rejected: 'на доработке', closed: 'закрыта', + }, + category: { + electric: 'электрика', plumbing: 'сантехника', climate: 'климат', + elevator: 'лифт', other: 'прочее', + }, + priority: { low: 'низкий', normal: 'обычный', high: 'высокий' }, + membership: { invited: 'приглашён', active: 'в составе', declined: 'отказался' }, + role: { client: 'клиент', worker: 'мастер', admin: 'админ' }, + action: { + created: 'создана', assigned: 'назначена', started: 'начата', + done: 'сделана', confirmed: 'принята', rejected: 'отклонена', + canceled: 'отменена', + }, +}; + +const PAGE_SIZE = 20; + +// ============================== утилиты ============================== +const $ = (sel, root) => (root || document).querySelector(sel); +const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel)); + +function esc(v) { + return String(v === null || v === undefined ? '' : v) + .replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +function fmtTime(iso) { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '—'; + return d.toLocaleString('ru-RU', { + day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', + }); +} + +// Русские числительные: 1 день / 2 дня / 5 дней (и то же для недель). +function plural(n, one, few, many) { + const m10 = n % 10; + const m100 = n % 100; + if (m10 === 1 && m100 !== 11) return one; + if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few; + return many; +} + +// Расписание правила человеческим языком: «каждые 14 дней», +// «каждые 3 недели» (период, кратный 7, показываем в неделях). +function periodText(days) { + if (days % 7 === 0) { + const n = days / 7; + if (n === 1) return 'каждую неделю'; + return `каждые ${n} ${plural(n, 'неделя', 'недели', 'недель')}`; + } + if (days === 1) return 'каждый день'; + return `каждые ${days} ${plural(days, 'день', 'дня', 'дней')}`; +} + +// Значение для input type="datetime-local" — локальная дата-время +// в формате YYYY-MM-DDTHH:MM (без секунд и зоны — так требует нативный +// контрол; на сервер уходит ISO-строка с зоной). +function localDT(d) { + const p = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; +} + +let flashTimer = null; +function flash(msg, kind) { + const el = $('#flash'); + el.textContent = msg; + el.className = 'flash ' + (kind || 'ok'); + if (flashTimer) clearTimeout(flashTimer); + flashTimer = setTimeout(() => el.classList.add('hidden'), 4000); +} + +// Единая обработка ошибок: 401 — сессия мертва, остальное — тост. +function onError(e) { + if (e && e.status === 401) { + showLogin(); + flash('Сессия истекла — войдите снова', 'err'); + return; + } + flash((e && e.message) || String(e), 'err'); +} + +function role() { + const u = API.user(); + return u ? u.role : null; +} + +function statusBadge(status) { + return `${esc(DICT.status[status] || status)}`; +} + +function selectOptions(dict, selected) { + return '' + Object.entries(dict) + .map(([v, l]) => ``) + .join(''); +} + +// ============================== сессия ============================== +// Экран входа — три карточки на одном view: вход, «забыли пароль» и +// сброс по ссылке из письма. Показана ровно одна из них. +function showAuthForm(sel) { + ['#login-form', '#forgot-form', '#reset-form'].forEach((id) => { + $(id).classList.toggle('hidden', id !== sel); + }); +} + +function showLogin() { + $('#app-view').classList.add('hidden'); + $('#login-view').classList.remove('hidden'); + $('#login-error').textContent = ''; + showAuthForm('#login-form'); +} + +// Форма сброса: токен приходит в хеш-маршруте (#/reset-password?token=…). +function showResetForm(token) { + $('#app-view').classList.add('hidden'); + $('#login-view').classList.remove('hidden'); + showAuthForm('#reset-form'); + $('#reset-error').textContent = token + ? '' + : 'Ссылка для сброса не найдена — запросите новую'; +} + +function showApp() { + $('#login-view').classList.add('hidden'); + $('#app-view').classList.remove('hidden'); + const u = API.user(); + $('#who').textContent = `${u.email}${DICT.role[u.role] ? ' · ' + DICT.role[u.role] : ''}`; + route(); +} + +// ============================== маршруты ============================== +function parseHash() { + const raw = (location.hash || '').replace(/^#/, '') || '/tickets'; + const i = raw.indexOf('?'); + const path = i === -1 ? raw : raw.slice(0, i); + const qs = i === -1 ? '' : raw.slice(i + 1); + return { path, params: new URLSearchParams(qs) }; +} + +// go(path, params) — переход; при совпадении хеша перерисовываем сами +// (hashchange в этом случае не сработает). +function go(path, params) { + const qs = params && params.toString() ? '?' + params.toString() : ''; + const target = '#' + path + qs; + if (location.hash === target || (target === '#/tickets' && !location.hash)) route(); + else location.hash = target; +} + +function renderNav() { + const r = role(); + const items = [['#/tickets', 'Заявки']]; + if (r === 'client' || r === 'admin') items.push(['#/projects', 'Проекты']); + // Мастера: админ — всех, клиент — «своих» (граница — на сервере). + if (r === 'admin') items.push(['#/workers', 'Мастера'], ['#/clients', 'Клиенты']); + else if (r === 'client') items.push(['#/workers', 'Мастера']); + if (r === 'worker') items.push(['#/invitations', 'Приглашения']); + // Настройки — всем ролям: уведомления в Telegram может включить + // любой, кому есть что напоминать (у клиента — свои заявки). + items.push(['#/settings', 'Настройки']); + const cur = location.hash || '#/tickets'; + $('#nav').innerHTML = items + .map(([href, label]) => `${label}`) + .join(''); +} + +async function route() { + const { path, params } = parseHash(); + // Ссылка из письма живёт без сессии: показываем её до проверки + // входа, иначе неавторизованный пользователь потерял бы токен. + if (path === '/reset-password') { showResetForm(params.get('token')); return; } + if (!API.user()) { showLogin(); return; } + renderNav(); + const main = $('#main'); + const r = role(); + try { + if (/^\/tickets\/\d+$/.test(path)) { + await ticketView(path.split('/')[2], params); + } else if (path === '/invitations') { + if (r !== 'worker') { location.hash = '#/tickets'; return; } + await invitationsView(params); + } else if (/^\/projects\/\d+$/.test(path)) { + if (r === 'worker') { location.hash = '#/tickets'; return; } + await projectView(path.split('/')[2], params); + } else if (path === '/projects') { + if (r === 'worker') { location.hash = '#/tickets'; return; } + await projectsView(params); + } else if (path === '/workers') { + if (r !== 'admin' && r !== 'client') { location.hash = '#/tickets'; return; } + await workersView(params); + } else if (path === '/clients') { + if (r !== 'admin') { location.hash = '#/tickets'; return; } + await clientsView(params); + } else if (path === '/settings') { + // Пункт есть у всех ролей — отдельной проверки не требует. + await settingsView(); + } else { + await ticketsView(params); + } + } catch (e) { + onError(e); + } +} + +// ============================== список заявок ============================== +async function ticketsView(params) { + const r = role(); + $('#main').innerHTML = ` +
+
+

Заявки

+ ${r === 'client' ? '' : ''} +
+
+ + + + + + + +
+

Загрузка…

+
+
`; + + $('#filters').addEventListener('submit', (e) => { + e.preventDefault(); + const p = new URLSearchParams(); + for (const [k, v] of new FormData(e.target)) if (v) p.set(k, v); + go('/tickets', p); + }); + $('#reset').addEventListener('click', () => go('/tickets', new URLSearchParams())); + if (r === 'client') $('#new-ticket').addEventListener('click', openCreateTicket); + + await loadTickets(params); +} + +async function loadTickets(params) { + const q = new URLSearchParams(params); + q.set('limit', String(PAGE_SIZE)); + if (!q.get('offset')) q.set('offset', '0'); + const page = await API.get('/tickets?' + q.toString()); + const items = page.items || []; + + $('#list').innerHTML = items.length ? ` + + + + + + ${items.map((t) => ` + + + + + + + + + `).join('')} + +
№СтатусКатегорияПриоритетАдресМастерСоздана
+ ${pagerHTML(page)}` : '

Ничего не найдено. Попробуйте сбросить фильтры.

'; + + $$('#list tr.link').forEach((tr) => { + tr.addEventListener('click', () => go('/tickets/' + tr.dataset.id)); + }); + wirePager(page, '/tickets', params); +} + +function pagerHTML(page) { + const total = page.total || 0; + const from = total === 0 ? 0 : page.offset + 1; + const to = Math.min(page.offset + (page.items ? page.items.length : 0), total); + const hasPrev = page.offset > 0; + const hasNext = page.offset + (page.items ? page.items.length : 0) < total; + return `
+ + ${from}–${to} из ${total} + +
`; +} + +function wirePager(page, basePath, params) { + const set = (off) => { + const p = new URLSearchParams(params); + p.set('offset', String(Math.max(0, off))); + go(basePath, p); + }; + const prev = $('#prev'); + const next = $('#next'); + if (prev) prev.addEventListener('click', () => set(page.offset - PAGE_SIZE)); + if (next) next.addEventListener('click', () => set(page.offset + PAGE_SIZE)); +} + +// --- создание заявки (только клиент) --- +// Форма — компактное модальное окно поверх списка (не карточка на всё +// окно): затемнённый фон, узкий диалог по центру. Закрывается крестиком, +// Escape и кликом по фону; фон не кликается «насквозь». +async function openCreateTicket() { + const wrap = $('#create-wrap'); + const projects = (await API.get('/projects?limit=100&sort=name')).items + .filter((p) => p.is_active); + if (!projects.length) { + flash('Нет активных проектов — создайте проект на вкладке «Проекты»', 'err'); + return; + } + + wrap.innerHTML = ` + +
`; + + // Состояние повтора: recurring — применённые в окне условия + // {dt: 'YYYY-MM-DDTHH:MM', n, unit}, draft — последние значения, + // чтобы окно предзаполнялось при повторном открытии; schedOpen — + // открыто ли окно условий (Escape сначала закрывает его, а не форму). + let recurring = null; + let draft = null; + let schedOpen = false; + + // Закрытие: общая точка выхода — убрать оверлей и снять Escape. + function close() { + wrap.innerHTML = ''; + document.removeEventListener('keydown', onKey); + } + function onKey(e) { + if (e.key !== 'Escape') return; + if (schedOpen) closeSched(); + else close(); + } + + async function loadObjects(projectId) { + const objs = (await API.get('/projects/' + projectId + '/objects?limit=100&sort=name')).items + .filter((o) => o.is_active); + $('#ct [name=object_id]').innerHTML = objs.length + ? objs.map((o) => ``).join('') + : ''; + } + + const projectSelect = $('#ct [name=project_id]'); + projectSelect.addEventListener('change', () => loadObjects(projectSelect.value)); + await loadObjects(projectSelect.value); + + // Галочка «Повторяющаяся заявка» открывает отдельное окно с условиями + // повтора: дата-время первого запуска (нативный datetime-local) плюс + // период в днях/неделях. Галка ставится только после «Применить»; + // закрытие окна без применения (крестик, Escape, фон, «Отмена») + // возвращает форму в состояние «без повтора». + const recChk = $('#ct [name=recurring]'); + + // Строка под галочкой: применённые условия + ссылка «Изменить». + function renderSummary() { + const el = $('#ct-rec'); + if (!recurring) { + el.hidden = true; + el.innerHTML = ''; + return; + } + const sched = periodText(recurring.n * recurring.unit) + ' с ' + fmtTime(recurring.dt); + el.hidden = false; + el.innerHTML = `Расписание: ${sched}. `; + $('#ct-rec-edit').addEventListener('click', openSched); + } + + // Окно условий закрыто: если применено — галка остаётся, нет — снимаем. + function closeSched() { + $('#sched-wrap').innerHTML = ''; + schedOpen = false; + if (!recurring) recChk.checked = false; + renderSummary(); + } + + function openSched() { + // Дефолт — сейчас + 7 дней, 10:00: время ориентировочное, + // планировщик проверяет расписание раз в сутки. + const first = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + first.setHours(10, 0, 0, 0); + const src = recurring || draft; + const dt = src ? src.dt : localDT(first); + const n = src ? src.n : 1; + const unit = src ? src.unit : 1; + + $('#sched-wrap').innerHTML = ` + `; + schedOpen = true; + + $('#sched-x').addEventListener('click', closeSched); + $('#sched-cancel').addEventListener('click', closeSched); + // Клик ровно по фону (не по диалогу) — тоже закрыть. + $('#sched-overlay').addEventListener('click', (e) => { + if (e.target === e.currentTarget) closeSched(); + }); + $('#sched').addEventListener('submit', (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const dtv = fd.get('anchor_at'); + let cnt = Number(fd.get('period_n')); + let u = Number(fd.get('period_unit')); + const period = cnt * u; + if (!dtv || Number.isNaN(new Date(dtv).getTime())) { + flash('Укажите дату и время первого запуска', 'err'); + return; + } + if (!Number.isInteger(cnt) || cnt < 1 || !Number.isInteger(period) + || period < 1 || period > 3650) { + flash('Период — от 1 до 3650 дней (максимум 521 неделя)', 'err'); + return; + } + // Показываем период так же, как в подсказке под галочкой: + // кратное 7 — в неделях, иначе в днях. + if (period % 7 === 0) { + u = 7; + cnt = period / 7; + } + recurring = draft = { dt: dtv, n: cnt, unit: u }; + closeSched(); + }); + + // Единицы с числительным согласуются («1 день», «2 недели»), + // иначе в селекте соседствуют «1 дней» и «1 недель». + const nInp = $('#sched [name=period_n]'); + const unitSel = $('#sched [name=period_unit]'); + function syncUnitLabels() { + const cnt = Math.max(1, Number(nInp.value) || 1); + unitSel.options[0].text = plural(cnt, 'день', 'дня', 'дней'); + unitSel.options[1].text = plural(cnt, 'неделю', 'недели', 'недель'); + } + nInp.addEventListener('input', syncUnitLabels); + unitSel.addEventListener('change', syncUnitLabels); + syncUnitLabels(); + + $('#sched [name=anchor_at]').focus(); + } + + recChk.addEventListener('change', () => { + if (recChk.checked) { + openSched(); + return; + } + // Снятие галки — без окна: условия гасим, значения оставляем, + // чтобы следующее включение начиналось с прошлого выбора. + recurring = null; + renderSummary(); + }); + + document.addEventListener('keydown', onKey); + $('#ct-x').addEventListener('click', close); + $('#ct-cancel').addEventListener('click', close); + // Клик ровно по фону (не по диалогу) — тоже закрыть. + $('#ct-overlay').addEventListener('click', (e) => { + if (e.target === e.currentTarget) close(); + }); + $('#ct').addEventListener('submit', async (e) => { + e.preventDefault(); + try { + const fd = new FormData(e.target); + const body = { + object_id: Number(fd.get('object_id')), + floor: String(fd.get('floor') || ''), + category: fd.get('category'), + priority: fd.get('priority'), + description: String(fd.get('description') || '').trim(), + }; + // Галочка повтора: сервер той же операцией создаёт заявку и + // правило (период от выбранной даты-времени); условия пользователь + // задал в отдельном окне — берём применённое расписание. + if (fd.get('recurring')) { + if (!recurring) { + flash('Задайте условия повторяющейся заявки', 'err'); + openSched(); + return; + } + body.recurring = { + anchor_at: new Date(recurring.dt).toISOString(), + period_days: recurring.n * recurring.unit, + }; + } + const t = await API.post('/tickets', body); + close(); + flash(t.recurring + ? 'Заявка №' + t.id + ' создана — правило повторения включено' + : 'Заявка №' + t.id + ' создана'); + go('/tickets/' + t.id); + } catch (err) { + onError(err); + } + }); +} + +// ============================== детали заявки ============================== +async function ticketView(id, params) { + const t = await API.get('/tickets/' + id); + const r = role(); + const me = API.user().id; + + const can = { + // Назначение — только из статуса new (проверка в самом SQL). + assign: (r === 'admin' || r === 'client') && t.status === 'new', + // Мастер берёт свободную заявку сам — тоже только new. + take: r === 'worker' && t.status === 'new', + // «Начать работу» — и назначенная, и ушедшая на доработку: после + // отклонения (rejected) сервер принимает перезапуск второго круга. + start: r === 'worker' && (t.status === 'assigned' || t.status === 'rejected') && t.worker_id === me, + done: r === 'worker' && t.status === 'in_progress' && t.worker_id === me, + confirm: r === 'client' && t.status === 'done', + reject: r === 'client' && t.status === 'done', + cancel: (r === 'client' || r === 'admin') && t.status !== 'closed', + }; + + $('#main').innerHTML = ` +
+
+

Заявка №${t.id} ${statusBadge(t.status)}

+ ← к списку +
+
+
Адрес
${esc(t.address)}${t.floor ? ', этаж ' + esc(t.floor) : ''}
+
Категория
${esc(DICT.category[t.category] || t.category)}
+
Приоритет
${esc(DICT.priority[t.priority] || t.priority)}
+
Проект
${esc(t.project_name || t.project_id)}
+
Объект
${esc(t.object_name || t.object_id)}
+
Клиент
${esc(t.client_name || '—')}
+
Мастер
${esc(t.worker_name || 'не назначен')}
+
Создана
${fmtTime(t.created_at)}
+ ${t.assigned_at ? `
Назначена
${fmtTime(t.assigned_at)}
` : ''} + ${t.started_at ? `
Начата
${fmtTime(t.started_at)}
` : ''} + ${t.done_at ? `
Сделана
${fmtTime(t.done_at)}
` : ''} + ${t.closed_at ? `
Закрыта
${fmtTime(t.closed_at)}
` : ''} + ${t.rework_count ? `
Доработок
${t.rework_count}
` : ''} +
+
${esc(t.description)}
+ ${t.reject_reason ? `
Причина отклонения: ${esc(t.reject_reason)}
` : ''} + ${t.cancel_reason ? `
Причина отмены: ${esc(t.cancel_reason)}
` : ''} + ${r === 'worker' && t.status === 'rejected' ? ` +
Клиент отклонил работу${t.rework_count ? ' — доработка №' + t.rework_count : ''}. Причина отклонения — выше. Нажмите «Начать работу», выполните переделку и снова отметьте «Работа выполнена» — после этого клиент примет заявку.
` : ''} +
+
+ ${t.recurring ? recurringBlock(t) : ''} +
+
+

Фотоотчёт

+
+
+

Загрузка…

+
+
+

Журнал событий

+

Загрузка…

+
`; + + await renderActions(t, can); + if (t.recurring) wireRecurring(t, r); + await renderPhotos(t, params); + await loadEvents(t.id, params); +} + +// --- блок «Повторяющаяся заявка» на странице заявки --- +// Заявка связана с правилом (создана вместе с ним или самим +// планировщиком) — здесь её расписание и управление им: вкл/выкл +// (PATCH правила) и история срабатываний. Мастеру показываем текст +// без кнопок: API управления закрыт для его роли. +function recurringBlock(t) { + const rc = t.recurring; + const r = role(); + const manage = r === 'client' || r === 'admin'; + const sched = `${periodText(rc.period_days)} с ${fmtTime(rc.anchor_at)}`; + const next = rc.is_active ? fmtTime(rc.next_run_at) : '—'; + const state = rc.is_active + ? 'включено' + : 'выключено'; + return ` +
+
+

Повторяющаяся заявка ${state}

+
+
+
Расписание
${sched}
+
Следующий запуск
${next}
+
+

Правило создаст такую же заявку ${periodText(rc.period_days)} — но только если прошлая по нему уже закрыта, дублей не будет. Период идёт от ${fmtTime(rc.anchor_at)}, точность до суток: планировщик проверяет расписание раз в сутки.

+ ${manage ? ` +
+ + +
` : ` +

Включением и историей правила управляют клиент и админ.

`} +
+
`; +} + +// Обработчики кнопок блока повтора: переключение is_active правилом +// (после — перерисовка страницы, чтобы состояние и кнопка сошлись) +// и модалка истории срабатываний (общая openRuns). +function wireRecurring(t, r) { + const rc = t.recurring; + const manage = r === 'client' || r === 'admin'; + if (!manage) return; + + $('#rr-toggle').addEventListener('click', async () => { + try { + await API.patch('/recurring/' + rc.rule_id, { is_active: !rc.is_active }); + flash(rc.is_active ? 'Правило выключено' : 'Правило включено'); + route(); + } catch (e) { onError(e); } + }); + $('#rr-runs').addEventListener('click', () => openRuns(rc.rule_id, t.description)); +} + +async function renderActions(t, can) { + const box = $('#actions'); + const parts = []; + + if (can.assign) parts.push('
'); + if (can.take) parts.push(''); + if (can.start) parts.push(''); + if (can.done) parts.push(''); + if (can.confirm) parts.push(''); + if (can.reject) parts.push(''); + if (can.cancel) parts.push(''); + + if (!parts.length) { + box.innerHTML = 'Нет действий, доступных вашей роли в этом статусе.'; + return; + } + box.innerHTML = parts.join(''); + + // Назначение: выбор из мастеров-участников проекта (только active). + if (can.assign) { + const workers = (await API.get('/projects/' + t.project_id + '/workers?limit=100&sort=name')).items + .filter((w) => w.status === 'active'); + $('#assign-box').innerHTML = ` + + `; + if (workers.length) { + $('#assign-go').addEventListener('click', async () => { + try { + await API.patch('/tickets/' + t.id + '/assign', { worker_id: $('#assignee').value }); + flash('Заявка назначена'); + route(); + } catch (e) { onError(e); } + }); + } + } + + // Простые переходы без тела. + const simple = { take: 'Заявка взята', start: 'Начата', done: 'Выполнена', confirm: 'Принята клиентом' }; + $$('#actions button[data-act]').forEach((btn) => { + const act = btn.dataset.act; + if (simple[act]) { + btn.addEventListener('click', async () => { + try { + await API.patch('/tickets/' + t.id + '/' + act, {}); + flash(simple[act]); + route(); + } catch (e) { onError(e); } + }); + } else if (act === 'reject' || act === 'cancel') { + // Отклонение и отмена требуют причины (min 5 символов). + btn.addEventListener('click', () => { + const label = act === 'reject' ? 'Причина отклонения' : 'Причина отмены'; + box.insertAdjacentHTML('beforeend', ` +
+ +
+ + +
+
`); + $('#reason-cancel').addEventListener('click', () => $('#reason-form').remove()); + $('#reason-form').addEventListener('submit', async (e) => { + e.preventDefault(); + try { + await API.patch('/tickets/' + t.id + '/' + act, + { reason: String(new FormData(e.target).get('reason')).trim() }); + flash(act === 'reject' ? 'Отправлено на доработку' : 'Заявка отменена'); + route(); + } catch (err) { onError(err); } + }); + }); + } + }); +} + +// ============================== фотоотчёт ============================== +// Снимок белого списка форматов сервера — клиентская проверка только +// ради скорости; настоящий арбитр — сервер (сигнатура байтов). +const PHOTO_ACCEPT = ['image/jpeg', 'image/png', 'image/webp']; +const PHOTO_MAX_BYTES = 10 * 1024 * 1024; // дефолт PHOTO_MAX_BYTES + +async function renderPhotos(t, params) { + const r = role(); + const me = API.user().id; + // Грузить может назначенный мастер в работе или админ — после «Работа + // выполнена» отчёт заморожен; сервер проверяет это в SQL, здесь + // только видимость кнопки. + const canUpload = (r === 'admin' || (r === 'worker' && t.worker_id === me)) + && t.status === 'in_progress'; + + if (canUpload) { + $('#photo-upload').innerHTML = ` +
+ + + jpeg, png или webp — до 10 МБ на файл +
`; + $('#photo-go').addEventListener('click', () => uploadPhotos(t, r, me, params)); + } + await loadPhotos(t, r, me, params); +} + +async function uploadPhotos(t, r, me, params) { + const input = $('#photo-input'); + const files = Array.from(input.files || []); + if (!files.length) { flash('Выберите файлы', 'err'); return; } + + const bad = files.find((f) => PHOTO_ACCEPT.indexOf(f.type) === -1 || f.size > PHOTO_MAX_BYTES); + if (bad) { + flash(PHOTO_ACCEPT.indexOf(bad.type) === -1 + ? 'Нужны jpeg, png или webp' : 'Файл больше 10 МБ', 'err'); + return; + } + + const btn = $('#photo-go'); + btn.disabled = true; + let done = 0; + try { + for (const f of files) { + const fd = new FormData(); + fd.append('file', f, f.name); + await API.upload('/tickets/' + t.id + '/photos', fd); + done++; + } + } catch (e) { + onError(e); // серверная ошибка (лимит, статус) — тост с текстом API + } finally { + btn.disabled = false; + input.value = ''; + } + if (done) { + flash(done === files.length ? 'Фото загружены' : 'Загружено ' + done + ' из ' + files.length); + await loadEvents(t.id, params); // журнал — сразу, без F5 + } + await loadPhotos(t, r, me, params); +} + +async function loadPhotos(t, r, me, params) { + const box = $('#photos'); + const count = $('#photos-count'); + let page; + try { + page = await API.get('/tickets/' + t.id + '/photos?limit=100'); + } catch (e) { + box.innerHTML = '

Не удалось загрузить список фото.

'; + return; + } + const items = page.items || []; + count.textContent = items.length + ' / ' + (page.max_per_ticket || '?'); + + if (!items.length) { box.innerHTML = '

Фото пока нет.

'; return; } + + // Удаление: админу — любое, мастеру — своё, пока заявка в работе + // (in_progress); в done отчёт заморожен (сервер проверяет это в SQL — + // здесь видимость крестика). + const canDel = (p) => r === 'admin' + || (r === 'worker' && p.uploader_id === me && t.status === 'in_progress'); + + box.innerHTML = `
${items.map((p) => ` +
+ ${p.url + ? ` + ${esc(p.original_name || 'фото')} + ` + : '
ссылка недоступна
'} +
+ ${esc(p.uploader_name || 'аккаунт удалён')} + ${fmtTime(p.created_at)} + ${canDel(p) ? '' : ''} +
+
`).join('')}
`; + + $$('#photos .photo-del').forEach((btn) => { + btn.addEventListener('click', async () => { + const id = btn.closest('.photo').dataset.id; + if (!window.confirm('Удалить фото без возможности вернуть?')) return; + btn.disabled = true; + try { + await API.del('/tickets/' + t.id + '/photos/' + id); + flash('Фото удалено'); + } catch (e) { onError(e); } + await loadPhotos(t, r, me, params); + await loadEvents(t.id, params); // журнал — сразу, без F5 + }); + }); +} + +// Текст события в журнале: у фото-действий статус не менялся — +// важнее «что сделали», а не «куда перешли». +function eventText(e) { + if (e.action === 'photo_added') return 'добавлено фото'; + if (e.action === 'photo_removed') return 'удалено фото'; + const from = e.from_status ? `${esc(DICT.status[e.from_status] || e.from_status)} → ` : ''; + return `${from}${esc(DICT.status[e.to_status] || e.to_status)}`; +} + +async function loadEvents(ticketId, params) { + const q = new URLSearchParams(params); + q.set('limit', '50'); + q.set('offset', '0'); + const page = await API.get('/tickets/' + ticketId + '/events?' + q.toString()); + const items = page.items || []; + $('#events').innerHTML = items.length ? ` +
    ${items.map((e) => ` +
  • + ${fmtTime(e.created_at)} + ${esc(e.actor_name || 'система')} + ${eventText(e)} + ${e.comment ? `«${esc(e.comment)}»` : ''} +
  • `).join('')}
` : '

Событий пока нет.

'; +} + +// ============================== проекты ============================== +async function projectsView(params) { + const r = role(); + $('#main').innerHTML = ` +
+
+

Проекты

+ +
+
+

Загрузка…

+
`; + + $('#new-project').addEventListener('click', () => openCreateProject(r)); + + const q = new URLSearchParams(params); + q.set('limit', String(PAGE_SIZE)); + if (!q.get('offset')) q.set('offset', '0'); + const page = await API.get('/projects?' + q.toString()); + const items = page.items || []; + + $('#list').innerHTML = items.length ? ` + + ${r === 'admin' ? '' : ''} + ${items.map((p) => ` + + + ${r === 'admin' ? `` : ''} + + + `).join('')} + +
НазваниеКлиентСтатусСоздан
+ ${pagerHTML(page)}` : '

Проектов нет — создайте первый.

'; + + $$('#list tr.link').forEach((tr) => { + tr.addEventListener('click', () => go('/projects/' + tr.dataset.id)); + }); + wirePager(page, '/projects', params); +} + +async function openCreateProject(r) { + const wrap = $('#projects-create'); + // Админ создаёт проект для клиента — ему нужен список клиентов. + let clientSelect = ''; + if (r === 'admin') { + const clients = (await API.get('/users?role=client&limit=100&sort=name')).items; + clientSelect = ``; + } + wrap.innerHTML = ` +
+ + ${clientSelect} + + +
`; + $('#np-cancel').addEventListener('click', () => { wrap.innerHTML = ''; }); + $('#np').addEventListener('submit', async (e) => { + e.preventDefault(); + try { + const fd = new FormData(e.target); + const body = { name: String(fd.get('name')).trim() }; + if (fd.get('client_id')) body.client_id = fd.get('client_id'); + const p = await API.post('/projects', body); + flash('Проект «' + p.name + '» создан'); + go('/projects/' + p.id); + } catch (err) { onError(err); } + }); +} + +// ============ действия админа над пользователями (общие) ============ +// Для разделов «Клиенты» и «Мастера»: включение/отключение и удаление +// с фолбэком. Отключение гасит все сессии пользователя; у мастера флаг +// общий на все проекты. Удаляется только «чистый» аккаунт — при 409 +// (есть заявки/проекты/участия) предлагаем отключить вместо удаления. + +// Ячейка статуса: отдельный бейдж подтверждения (0010) — до подтверждения +// вход закрыт, поэтому он виден даже у активного аккаунта. +function userStatusCell(u) { + const status = u.is_active + ? 'активен' + : 'отключён'; + const confirm = u.confirmed_at + ? '' + : 'не подтверждён '; + return confirm + status; +} + +// Ячейка действий строки: «Подтвердить» (неподтверждённому), затем +// «Отключить/Включить», «Сброс пароля» и «Удалить». Сброс — только у +// админа: эндпоинт за RequireRole("admin"), а раздел «Мастера» видит +// и клиент (своих мастеров) — ему сервер вернул бы 403. +function userActionsCell(u) { + const confirmBtn = u.confirmed_at + ? '' + : ``; + const toggle = u.is_active + ? `` + : ``; + const reset = role() === 'admin' + ? `` + : ''; + return `${confirmBtn}${toggle}${reset}`; +} + +function wireUserActions() { + $$('.u-toggle').forEach((btn) => btn.addEventListener('click', () => { + toggleUser(btn.dataset.uid, btn.dataset.on === '1', btn.dataset.name); + })); + $$('.u-del').forEach((btn) => btn.addEventListener('click', () => { + deleteUser(btn.dataset.uid, btn.dataset.name); + })); + $$('.u-confirm').forEach((btn) => btn.addEventListener('click', () => { + confirmUser(btn.dataset.uid, btn.dataset.name); + })); + $$('.u-reset').forEach((btn) => btn.addEventListener('click', () => { + openPasswordReset(btn.dataset.uid, btn.dataset.name, btn.dataset.email); + })); +} + +// Подтверждение аккаунта: сервер шлёт пользователю письмо «аккаунт +// подтверждён» — после этого он сможет войти. Границы прав (чужой, +// уже подтверждён) держит сервер: 404/409 показываем текстом. +async function confirmUser(id, name) { + try { + await API.post('/users/' + id + '/confirm', {}); + flash('Аккаунт «' + name + '» подтверждён — пользователю отправлено письмо'); + route(); + } catch (e) { onError(e); } +} + +// Удаление: сначала DELETE; 409 «есть история» — диалог «отключить +// вместо удаления», история заявок при этом остаётся. Текст первого +// подтверждения зависит от роли: клиенту вместе с аккаунтом уходят +// членства в его проектах, админу удаляется только «чистый» аккаунт. +async function deleteUser(id, name) { + const ask = role() === 'client' + ? 'Удалить «' + name + '»? Аккаунт и участие мастера в ваших проектах будут удалены.' + : 'Удалить «' + name + '»? Удаляется только аккаунт без истории.'; + if (!confirm(ask)) return; + try { + await API.del('/users/' + id); + flash('Пользователь «' + name + '» удалён'); + route(); + } catch (e) { + if (e.status !== 409) { onError(e); return; } + if (!confirm('У «' + name + '» есть связанные записи (заявки, проекты). Отключить аккаунт вместо удаления?')) return; + try { + await API.patch('/users/' + id, { is_active: false }); + flash('Аккаунт «' + name + '» отключён — история сохранена'); + route(); + } catch (e2) { onError(e2); } + } +} + +// Включение/отключение аккаунта (админ — любой, клиент — свой мастер +// в проекте; границу держит сервер). +async function toggleUser(id, on, name) { + if (!on && !confirm('Выключить «' + name + '»? Он потеряет доступ, все сессии закроются.')) return; + try { + await API.patch('/users/' + id, { is_active: on }); + flash(on ? '«' + name + '» включён' : '«' + name + '» отключён'); + route(); + } catch (e) { onError(e); } +} + +// ==================== сброс пароля админом ==================== +// Окно с двумя способами (кнопка есть только у админа): +// 1) выпустить одноразовую ссылку — пользователь сам откроет её и +// задаст пароль (токен живёт сутки, копируется кнопкой); +// 2) задать пароль напрямую — сервер ставит его сразу. +// Оба варианта гасят refresh-сессии пользователя, прямая установка +// заодно отменяет неиспользованные токены сброса — старые ссылки +// перестают работать. +async function openPasswordReset(id, name, email) { + const wrap = $('#pr-wrap'); + wrap.innerHTML = ` + `; + + function close() { + wrap.innerHTML = ''; + document.removeEventListener('keydown', onKey); + } + function onKey(e) { if (e.key === 'Escape') close(); } + + $('#pr-x').addEventListener('click', close); + $('#pr-cancel').addEventListener('click', close); + $('#pr-overlay').addEventListener('click', (e) => { + if (e.target === e.currentTarget) close(); + }); + document.addEventListener('keydown', onKey); + + // Способ 1: адрес страницы сброса собираем сами — в ответе только токен. + $('#pr-link').addEventListener('submit', async (e) => { + e.preventDefault(); + try { + const r = await API.post('/users/' + id + '/password-reset', {}); + $('#pr-link-url').value = location.origin + '/#/reset-password?token=' + encodeURIComponent(r.token); + $('#pr-link-exp').textContent = 'Действует до ' + fmtTime(r.expires_at) + '. Передайте ссылку пользователю любым каналом.'; + $('#pr-link-box').hidden = false; + } catch (err) { onError(err); } + }); + + $('#pr-copy').addEventListener('click', async () => { + const input = $('#pr-link-url'); + try { + await navigator.clipboard.writeText(input.value); + flash('Ссылка скопирована'); + } catch (_) { + // Без HTTPS/разрешения буфера — выделяем текст, копирует Ctrl+C. + input.select(); + flash('Ссылка выделена — Ctrl+C'); + } + }); + + // Способ 2: пароль должен совпасть в двух полях до ухода на сервер. + $('#pr-set').addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const pass = String(fd.get('password')); + if (pass !== String(fd.get('password2'))) { + flash('Пароли не совпадают', 'err'); + return; + } + try { + await API.post('/users/' + id + '/password-set', { password: pass }); + flash('Пароль «' + name + '» обновлён — все его сессии закрыты'); + close(); + } catch (err) { onError(err); } + }); + + $('#pr-set [name=password]').focus(); +} + +// ============================== клиенты ============================== +// Раздел только для админа: список клиентов и создание аккаунта — +// единственный способ завести клиента (саморегистрации нет). +// include_inactive=1 — отключённые остаются в списке (иначе «Включить» +// после отключения оказалось бы тупиком). +async function clientsView(params) { + $('#main').innerHTML = ` +
+
+

Клиенты

+ +
+
+

Загрузка…

+
+
`; + + $('#new-client').addEventListener('click', () => openCreateClient()); + + const q = new URLSearchParams(params); + q.set('role', 'client'); + q.set('include_inactive', '1'); + q.set('limit', String(PAGE_SIZE)); + if (!q.get('offset')) q.set('offset', '0'); + const page = await API.get('/users?' + q.toString()); + const items = page.items || []; + + $('#list').innerHTML = items.length ? ` + + + ${items.map((u) => ` + + + + + + + ${userActionsCell(u)} + `).join('')} + +
ИмяEmailТелефонСтатусСоздан
${esc(u.name)}${esc(u.email)}${esc(u.phone || '—')}${userStatusCell(u)}${fmtTime(u.created_at)}
+ ${pagerHTML(page)}` : '

Клиентов пока нет.

'; + + wirePager(page, '/clients', params); + wireUserActions(); +} + +// ============================== мастера ============================== +// Раздел только для админа: список мастеров, серверный поиск &q= +// (мин. 2 символа), пагинация и те же действия, что у клиентов. +async function workersView(params) { + // Поиск по всем мастерам системы (инструмент приглашения) — только + // у админа: у клиента раздел = «мои мастера», глобальная выдача с + // кнопками действий вводила бы в заблуждение — сервер для чужого + // вернёт 404 (граница «состоит в моём проекте» — в SQL). + const canSearch = role() === 'admin'; + $('#main').innerHTML = ` +
+
+

Мастера

+
+ ${canSearch ? ` +
+ +
+ + ${params.get('q') ? '' : ''} +
+
` : ''} +

Загрузка…

+
+
`; + + if ($('#wq')) { + $('#wq').addEventListener('submit', (e) => { + e.preventDefault(); + const qv = String(new FormData(e.target).get('q') || '').trim(); + const p = new URLSearchParams(); + if (qv) p.set('q', qv); + go('/workers', p); + }); + const reset = $('#wq-reset'); + if (reset) reset.addEventListener('click', () => go('/workers')); + } + + const q = new URLSearchParams(params); + q.set('role', 'worker'); + q.set('include_inactive', '1'); + q.set('limit', String(PAGE_SIZE)); + if (!q.get('offset')) q.set('offset', '0'); + const page = await API.get('/users?' + q.toString()); + const items = page.items || []; + + $('#list').innerHTML = items.length ? ` + + + ${items.map((u) => ` + + + + + + + ${userActionsCell(u)} + `).join('')} + +
ИмяEmailТелефонСтатусСоздан
${esc(u.name)}${esc(u.email)}${esc(u.phone || '—')}${userStatusCell(u)}${fmtTime(u.created_at)}
+ ${pagerHTML(page)}` : '

Мастеры не найдены.

'; + + wirePager(page, '/workers', params); + wireUserActions(); +} + +function openCreateClient() { + const wrap = $('#clients-create'); + wrap.innerHTML = ` +
+ + + + + + +
`; + $('#nc-cancel').addEventListener('click', () => { wrap.innerHTML = ''; }); + $('#nc').addEventListener('submit', async (e) => { + e.preventDefault(); + try { + const fd = new FormData(e.target); + const body = { + name: String(fd.get('name')).trim(), + email: String(fd.get('email')).trim(), + password: String(fd.get('password')), + role: 'client', + }; + if (fd.get('phone')) body.phone = String(fd.get('phone')).trim(); + const u = await API.post('/users', body); + flash('Клиент «' + u.name + '» создан'); + route(); + } catch (err) { onError(err); } + }); +} + +// --- детали проекта: правка, объекты, мастера --- +async function projectView(id, params) { + const p = await API.get('/projects/' + id); + const r = role(); + + $('#main').innerHTML = ` +
+
+

${esc(p.name)}

+ ← к списку +
+
+ + + +
+ ${r === 'admin' ? ` +

Клиент-владелец: ${esc(p.client_name || p.client_id)}

+ ` : ''} +
+ +
+

Объекты

+
+ + + +
+

Загрузка…

+
+ +
+

Мастера

+

Загрузка…

+
+
`; + + $('#pf').addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + try { + await API.patch('/projects/' + id, { + name: String(fd.get('name')).trim(), + is_active: fd.get('is_active') === 'true', + }); + flash('Проект сохранён'); + route(); + } catch (err) { onError(err); } + }); + + $('#no').addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + try { + await API.post('/projects/' + id + '/objects', { + name: String(fd.get('name')).trim(), + address: String(fd.get('address')).trim(), + }); + flash('Объект добавлен'); + route(); + } catch (err) { onError(err); } + }); + + // Удаление проекта — только у админа (роут закрыт RequireRole): + // вместе с проектом уходят объекты, участия и все заявки — предупреждаем честно. + const pDel = $('#p-del'); + if (pDel) { + pDel.addEventListener('click', async () => { + const ask = 'Удалить проект «' + p.name + '»? Вместе с ним будут удалены все объекты, участия мастеров и заявки — без возможности вернуть.'; + if (!confirm(ask)) return; + try { + await API.del('/projects/' + id); + flash('Проект удалён'); + go('/projects'); + } catch (err) { onError(err); } + }); + } + + await Promise.all([loadObjects(id), loadWorkers(id, r)]); +} + +async function loadObjects(projectId) { + const page = await API.get('/projects/' + projectId + '/objects?limit=100&sort=name'); + const items = page.items || []; + $('#objects').innerHTML = items.length ? ` + + + ${items.map((o) => ` + + + + + + `).join('')} + +
НазваниеАдресСтатус
${esc(o.name)}${esc(o.address)}${o.is_active ? 'активен' : 'выключен'}
` : '

Объектов нет.

'; + + $$('.obj-toggle').forEach((btn) => { + btn.addEventListener('click', async () => { + try { + await API.patch('/objects/' + btn.dataset.oid, { is_active: btn.dataset.active !== 'true' }); + flash('Статус объекта изменён'); + loadObjects(projectId); + } catch (e) { onError(e); } + }); + }); +} + +// История срабатываний правила — модалка по образцу «Новая заявка»: +// какие заявки создавались по расписанию и когда. Заявку показываем +// ссылкой: она живёт, пока её не заберёт автоочистка — тогда строка +// истории уходит следом за заявкой. +async function openRuns(ruleId, desc) { + const wrap = $('#runs-wrap'); + if (!wrap) return; + const page = await API.get('/recurring/' + ruleId + '/runs?limit=50&sort=run_at'); + const items = page.items || []; + + wrap.innerHTML = ` + `; + + function close() { + wrap.innerHTML = ''; + document.removeEventListener('keydown', onKey); + } + function onKey(e) { if (e.key === 'Escape') close(); } + + document.addEventListener('keydown', onKey); + $('#runs-x').addEventListener('click', close); + $('#runs-overlay').addEventListener('click', (e) => { + if (e.target === e.currentTarget) close(); + }); + // Переход к заявке — модалка больше не нужна. + $$('#runs-wrap a').forEach((a) => a.addEventListener('click', close)); +} + +async function loadWorkers(projectId, r) { + const page = await API.get('/projects/' + projectId + '/workers?limit=100&sort=name'); + const items = page.items || []; + + $('#workers').innerHTML = items.length ? ` + + + + ${items.map((w) => ` + + + + + + + `).join('')} + +
ИмяEmailСтатусОтвет до
${esc(w.name)}${esc(w.email)}${esc(DICT.membership[w.status] || w.status)}${w.is_active ? '' : ' отключён'}${w.status === 'invited' ? fmtTime(w.expires_at) : '—'} + ${w.status === 'active' ? (w.is_active + ? `` + : ``) : ''} + + +
` : '

Мастеров пока нет — пригласите первого ниже.

'; + + // Фильтр — локальный: мастеров в проекте десятки, сетевой запрос + // на каждое нажатие клавиши был бы избыточен. + const wFilter = $('#w-filter'); + if (wFilter) { + wFilter.addEventListener('input', () => { + const q = wFilter.value.trim().toLowerCase(); + $$('#workers tbody tr').forEach((tr) => { + tr.style.display = !q || (tr.dataset.hay || '').includes(q) ? '' : 'none'; + }); + }); + } + + $$('.w-del').forEach((btn) => { + btn.addEventListener('click', async () => { + const invited = btn.dataset.status === 'invited'; + const ask = invited + ? 'Отозвать приглашение для «' + btn.dataset.name + '»?' + : 'Убрать «' + btn.dataset.name + '» из проекта? Он больше не будет получать назначения проекта.'; + if (!confirm(ask)) return; + try { + await API.del('/projects/' + projectId + '/workers/' + btn.dataset.wid); + flash(invited ? 'Приглашение отозвано' : 'Мастер убран из проекта'); + loadWorkers(projectId, r); + } catch (e) { onError(e); } + }); + }); + + // Отключение аккаунта мастера прямо из проекта (доступно и клиенту — + // сервер разрешает только активных участников своих проектов). + $$('.w-off').forEach((btn) => btn.addEventListener('click', () => { + toggleUser(btn.dataset.wid, false, btn.dataset.name); + })); + $$('.w-on').forEach((btn) => btn.addEventListener('click', () => { + toggleUser(btn.dataset.wid, true, btn.dataset.name); + })); + + // Удаление аккаунта (не просто выход из проекта) — то же действие, + // что и в разделе «Мастера»: границы и 409 с предложением отключить + // вместо удаления решает сервер. + $$('.w-userdel').forEach((btn) => { + btn.addEventListener('click', () => deleteUser(btn.dataset.wid, btn.dataset.name)); + }); + + await renderInviteBox(projectId, r, items); +} + +// Последний введённый поиск приглашения: переживает перерисовку бокса +// (после приглашения loadWorkers собирает его заново). +let inviteQuery = ''; + +// Приглашение: поиск по имени/email среди активных мастеров — GET /users +// с &q=. Без запроса API клиенту список не отдаётся (только «свои»), +// поэтому существующего мастера клиент находит именно так, а админ не +// листает вручную всех мастеров системы. Дубликаты в проекте отфильтровываем. +async function renderInviteBox(projectId, r, current) { + const box = $('#invite'); + const inProject = new Set(current.map((w) => w.id)); + + box.innerHTML = ` +

Пригласить мастера

+ +
+

Введите имя или email — покажем мастеров, которых ещё нет в проекте.

+
+
+ + + + +
+

Мастер появится в проекте со статусом «приглашён» — работать сможет + только после того, как сам примет приглашение (учетка мастера → вкладка «Приглашения»).

`; + + const results = $('#w-results'); + const idleHint = '

Введите имя или email — покажем мастеров, которых ещё нет в проекте.

'; + let timer = null; + let seq = 0; // номер запроса: ответ устаревшего — мимо + + function renderRows(itemsFound, q) { + const fresh = itemsFound.filter((w) => !inProject.has(w.id)); + if (!itemsFound.length) { + results.innerHTML = `

По запросу «${esc(q)}» ничего не найдено.

`; + return; + } + if (!fresh.length) { + results.innerHTML = `

Все найденные уже в этом проекте.

`; + return; + } + results.innerHTML = `${fresh.map((w) => ` + + + + + `).join('')}
${esc(w.name)}${esc(w.email)}
`; + + $$('.inv-go', results).forEach((btn) => { + btn.addEventListener('click', async () => { + try { + await API.post('/projects/' + projectId + '/workers', { worker_id: btn.dataset.wid }); + flash('Приглашение отправлено'); + await loadWorkers(projectId, r); // перерисует бокс и вернёт запрос из inviteQuery + } catch (e) { onError(e); } + }); + }); + } + + async function search(q) { + const my = ++seq; + try { + const page = await API.get('/users?role=worker&limit=20&q=' + encodeURIComponent(q)); + if (my !== seq) return; // уже ввели что-то другое + renderRows(page.items || [], q); + } catch (e) { onError(e); } + } + + const input = $('#w-search'); + input.addEventListener('input', () => { + const q = input.value.trim(); + inviteQuery = q; + clearTimeout(timer); + if (q.length < 2) { + seq++; // гасим запрос в полёте + results.innerHTML = idleHint; + return; + } + timer = setTimeout(() => search(q), 300); + }); + + // Восстановленный после перерисовки запрос — сразу выполняем снова, + // чтобы в списке не оставался только что приглашённый мастер. + if (inviteQuery.length >= 2) search(inviteQuery); + + $('#nw').addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + try { + const w = await API.post('/users', { + email: String(fd.get('email')).trim(), + name: String(fd.get('name')).trim(), + password: String(fd.get('password')), + role: 'worker', + }); + await API.post('/projects/' + projectId + '/workers', { worker_id: w.id }); + flash('Мастер создан и приглашён'); + inviteQuery = ''; // в боксе снова пустой поиск — таблица обновится через loadWorkers + loadWorkers(projectId, r); + } catch (err) { onError(err); } + }); +} + +// ============================== приглашения (мастер) ============================== +async function invitationsView() { + $('#main').innerHTML = ` +
+

Мои приглашения

+

Загрузка…

+
`; + + const page = await API.get('/invitations?limit=50'); + const items = page.items || []; + + $('#inv').innerHTML = items.length ? ` + + + ${items.map((v) => ` + + + + + + + `).join('')} + +
ПроектКто пригласилПриглашеноОтвет до
${esc(v.project_name)}${esc(v.invited_name || '—')}${fmtTime(v.invited_at)}${fmtTime(v.expires_at)} + + +
` : '

Приглашений нет. Как только клиент пригласит вас в проект, оно появится здесь.

'; + + $$('.inv-go').forEach((btn) => { + btn.addEventListener('click', async () => { + try { + await API.post('/projects/' + btn.dataset.pid + '/invitation/' + btn.dataset.act, {}); + flash(btn.dataset.act === 'accept' + ? 'Вы приняли приглашение — проект может назначать вам заявки' + : 'Приглашение отклонено'); + invitationsView(); + } catch (e) { onError(e); } + }); + }); +} + +// ============================== настройки ============================== +// Экран «Настройки»: уведомления в Telegram о заявках, пролежавших в +// статусе «новая» больше суток. Три вещи, что тут можно сделать: +// 1. подключить СВОЕГО бота (токен от @BotFather) — уведомления пойдут +// через него, токен хранится на сервере зашифрованным; +// 2. указать, куда слать: личная привязка (одноразовый код боту) либо +// ручная группа/канал; +// 3. отключить уведомления. Пункт меню виден всем ролям. +async function settingsView() { + const st = await API.tgStatus(); + + // Свой бот. Токен никогда не показывается обратно — сервер отдаёт + // только факт его наличия и имя бота. Без DATA_ENCRYPTION_KEY + // (token_configurable=false) поля нет: хранить токен некуда. + const botBlock = !st.token_configurable ? '' : (st.has_bot_token ? ` +
+ Ваш бот +

Уведомления идут через @${esc(st.bot_username || '')}. Токен лежит на сервере зашифрованным и никогда не показывается.

+
+ +
+
+
+ +
+ +
+
+ ` : ` +
+ Свой бот для уведомлений +

Заведите бота у @BotFather и вставьте его токен: сообщения о заявках придут через вашего бота. Токен шифруется и хранится на сервере.

+
+ +
+ +
+
+
+ `); + + $('#main').innerHTML = ` +
+

Настройки

+

Уведомления в Telegram

+

Если заявку не взяли в работу за 24 часа, придёт сообщение в Telegram — и повторно раз в сутки, пока её не возьмут.

+ + ${st.enabled ? ` +

${st.bound + ? `Куда слать: ${esc(st.target)}` + : 'Привязки нет — уведомления не приходят.'}

+ ` : ''} + + ${botBlock} + + ${st.enabled ? ` +
+ Личные уведомления +

Возьмите код и отправьте его боту: сообщения начнут приходить в этот чат. Код одноразовый и живёт 15 минут.

+
+ +
+ +
+ +
+ +
+ + ${st.bound ? '' : ''} +
+
+

Группа или канал работают, только если бот добавлен туда участником.

+ ` : ` +

${st.token_configurable + ? 'Уведомления не настроены: подключите своего бота токеном выше.' + : 'Уведомления не настроены: на сервере не заданы ни TELEGRAM_BOT_TOKEN, ни DATA_ENCRYPTION_KEY.'}

+ `} +
`; + + // Свой бот: 400 — токен не прошёл (формат или getMe), 429 — лимит. + const botForm = $('#tg-bot-form'); + if (botForm) botForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const token = $('#tg-bot-token').value.trim(); + if (!token) { flash('Вставьте токен бота', 'err'); return; } + try { + await API.tgSetBot(token); + flash('Бот подключён — уведомления пойдут через него'); + settingsView(); + } catch (err) { + if (err.status === 400) flash('Токен не принят: проверьте, что он от @BotFather', 'err'); + else if (err.status === 429) flash('Слишком много попыток — попробуйте позже', 'err'); + else onError(err); + } + }); + + const botDel = $('#tg-bot-del'); + if (botDel) botDel.addEventListener('click', async () => { + try { + await API.tgDelBot(); + flash('Свой бот отключён'); + settingsView(); + } catch (err) { onError(err); } + }); + + // Код привязки: 429 — лимит выдачи, 400 — бот вообще не настроен. + const codeBtn = $('#tg-code-btn'); + if (codeBtn) codeBtn.addEventListener('click', async () => { + try { + const r = await API.tgCode(); + $('#tg-code-value').textContent = r.code; + $('#tg-code-link').href = r.link; + $('#tg-code-box').classList.remove('hidden'); + } catch (e) { + if (e.status === 429) flash('Слишком много кодов — попробуйте позже', 'err'); + else if (e.status === 400) flash('Уведомления не настроены на сервере', 'err'); + else onError(e); + } + }); + + // Ручная цель: 400 — формат не тот либо бот не видит чат + // (не добавлен в группу / человек не писал боту). + const form = $('#tg-form'); + if (form) form.addEventListener('submit', async (e) => { + e.preventDefault(); + try { + await API.tgSet($('#tg-target').value.trim()); + flash('Сохранено — уведомления будут приходить по этой цели'); + settingsView(); + } catch (err) { + if (err.status === 400) { + flash('Бот не видит этот чат: добавьте бота в группу или напишите ему /start', 'err'); + } else onError(err); + } + }); + + const unbind = $('#tg-unbind'); + if (unbind) unbind.addEventListener('click', async () => { + try { + await API.tgUnbind(); + flash('Уведомления отключены'); + settingsView(); + } catch (e) { onError(e); } + }); +} + +// ============================== старт приложения ============================== +function boot() { + // Переключение карточек «вход ↔ забыли пароль» на одном экране. + $('#show-forgot').addEventListener('click', () => { + showAuthForm('#forgot-form'); + $('#forgot-error').textContent = ''; + $('#forgot-email').value = $('#login-email').value; + $('#forgot-email').focus(); + }); + $('#back-login').addEventListener('click', () => showLogin()); + + $('#login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + $('#login-error').textContent = ''; + try { + await API.login($('#login-email').value.trim(), $('#login-password').value); + showApp(); + } catch (err) { + // 403 — два разных состояний: «отключён» и «ждём подтверждения» + // (сервер отдаёт разные тексты, не путаем их). + $('#login-error').textContent = err.status === 401 + ? 'Неверный email или пароль' + : err.message === 'account not confirmed' + ? 'Аккаунт не подтверждён — подтвердите его в приложении' + : err.message === 'account disabled' + ? 'Аккаунт отключён — обратитесь к администратору' + : (err.message || 'Ошибка входа'); + } + }); + + // «Забыли пароль»: ответ одинаковый в любом случае (не перечисляем + // ящики), поэтому после успеха просто возвращаемся ко входу. + $('#forgot-form').addEventListener('submit', async (e) => { + e.preventDefault(); + $('#forgot-error').textContent = ''; + try { + await API.forgotPassword($('#forgot-email').value.trim()); + e.target.reset(); + showLogin(); + flash('Если аккаунт существует, письмо со ссылкой уже отправлено'); + } catch (err) { + $('#forgot-error').textContent = err.status === 429 + ? 'Слишком много попыток — попробуйте позже' + : (err.message || 'Не удалось отправить письмо'); + } + }); + + // Смена пароля по токену из письма: 400 — либо пароль слабый, либо + // ссылка сгорела/использована (сервер не различает их наружу). + $('#reset-form').addEventListener('submit', async (e) => { + e.preventDefault(); + $('#reset-error').textContent = ''; + const pass = $('#reset-password').value; + if (pass !== $('#reset-password2').value) { + $('#reset-error').textContent = 'Пароли не совпадают'; + return; + } + try { + const { params } = parseHash(); + await API.resetPassword(params.get('token') || '', pass); + e.target.reset(); + location.hash = ''; + showLogin(); + flash('Пароль обновлён — войдите с новым'); + } catch (err) { + $('#reset-error').textContent = err.status === 429 + ? 'Слишком много попыток — попробуйте позже' + : err.status === 400 + ? 'Ссылка недействительна или устарела — запросите новую' + : (err.message || 'Не удалось сменить пароль'); + } + }); + + $('#logout').addEventListener('click', async () => { + await API.logout(); + location.hash = ''; + showLogin(); + }); + + window.addEventListener('hashchange', () => { route(); }); + + // Уже есть сессия — сразу в приложение; ссылка из письма + // (#/reset-password?token=…) показывается и без входа. + const start = parseHash(); + if (API.user() && start.path !== '/reset-password') showApp(); + else route(); +} + +boot(); diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..81594bf --- /dev/null +++ b/nginx.conf @@ -0,0 +1,69 @@ +# Отдача статики тестового веба. +# Роутинг у нас hash-based (#/tickets), но на всякий случай любой +# несуществующий путь ведём на index.html — случайный 404 мешает отладке. + +# Зоны лимитов лежат здесь (файл подключается в http-контексте через +# conf.d): 10 запросов/сек на IP и до 20 одновременных соединений. +# Браузеру с запасом хватает, а зациклившийся retry фронта упрётся +# в потолок, а не будет долбить статику бесконечно. +limit_req_zone $binary_remote_addr zone=perip:1m rate=10r/s; +limit_conn_zone $binary_remote_addr zone=addr:1m; + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/css application/javascript application/json; + gzip_min_length 1024; + + # HSTS: по HTTP браузер заголовок игнорирует, поэтому локально он + # безвреден, а с TLS (домен и сертификат — при деплое) заставит + # браузер держать сайт только по https. includeSubDomains — потому + # что на одном домене будут и API, и фото-прокси. + # ВНИМАНИЕ: add_header на уровне server перекрывается ЛЮБЫМ + # add_header внутри location — поэтому в location / заголовки + # продублированы явно. + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # burst — на пачку файлов при первой загрузке страницы (~5 запросов), + # сверх лимита — сразу 429, а не неопределённое ожидание. + limit_req zone=perip burst=30 nodelay; + limit_req_status 429; + limit_conn addr 20; + limit_conn_status 429; + + # API на том же origin (BASE='' в js/api.js): проксируем API-пути + # бэкенда, остальное — статика. Regex-локация важнее prefix /. + # X-Forwarded-For ПЕРЕЗАПИСЫВАЕМ ($remote_addr), а не дополняем: + # gin берёт левый элемент и по нему считает лимиты и client_ip в + # логах — дописанный клиентом мусор останется левым элементом. + # local dev: бэкенд на хосту (host.docker.internal:8080); + # в проде адрес подставит app-nginx.prod. + location ~ ^/(auth|users|projects|objects|tickets|invitations|recurring|ping|db-ping|swagger)(/|$) { + proxy_pass http://host.docker.internal:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + # Фотоотчёт: до 10 МБ + запас на multipart-обвязку. + client_max_body_size 12m; + } + + location / { + try_files $uri $uri/ /index.html; + # Кэш мешает отладке: отдаём всегда свежее. + # add_header в этой локации перекрывает серверный (nginx не + # смешивает уровни) — заголовки дублируем здесь явно. + add_header Cache-Control "no-cache"; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + } +} diff --git a/nginx.prod.conf b/nginx.prod.conf new file mode 100644 index 0000000..83cadd3 --- /dev/null +++ b/nginx.prod.conf @@ -0,0 +1,69 @@ +# Прод-конфиг веба: статика + прокси API на контейнер api внутри сети +# compose (строка api резолвится docker- DNS). Порт наружу — 80, TLS +# терминалирует перед этим nginx (балансировщик / Caddy / Traefik). +# +# Отличия от dev nginx.conf: +# - proxy_pass http://api:8080 вместо host.docker.internal:8080; +# - security-заголовки (nosniff / frame / referrer) поверх HSTS. +# +# Хост, который не относится к нашему origin (Host-заголовок чужой), +# nginx отдаёт сюда же — server_name _; выделенного виртуального хоста +# под чужой домен нет, это осознанно. + +# Зоны лимитов: 10 запросов/сек на IP и до 20 одновременных соединений. +limit_req_zone $binary_remote_addr zone=perip:1m rate=10r/s; +limit_conn_zone $binary_remote_addr zone=addr:1m; + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/css application/javascript application/json; + gzip_min_length 1024; + + # HSTS браузер игнорирует по HTTP, а с TLS заставит держать сайт + # только по https; includeSubDomains — API и статика на одном домене. + # ВНИМАНИЕ: add_header на уровне server перекрывается ЛЮБЫМ + # add_header внутри location — поэтому в location / заголовки + # продублированы явно. + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # burst — на пачку файлов при первой загрузке страницы, сверх + # лимита — сразу 429, а не неопределённое ожидание. + limit_req zone=perip burst=30 nodelay; + limit_req_status 429; + limit_conn addr 20; + limit_conn_status 429; + + # API на том же origin (BASE='' в js/api.js): проксируем API-пути + # бэкенда, остальное — статика. Regex-локация важнее prefix /. + # X-Forwarded-For ПЕРЕЗАПИСЫВАЕМ ($remote_addr), а не дополняем: + # gin берёт левый элемент и по нему считает лимиты и client_ip в + # логах — дописанный клиентом мусор останется левым элементом. + location ~ ^/(auth|users|projects|objects|tickets|invitations|recurring|ping|db-ping|swagger)(/|$) { + proxy_pass http://api:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + # Фотоотчёт: до 10 МБ + запас на multipart-обвязку. + client_max_body_size 12m; + } + + location / { + try_files $uri $uri/ /index.html; + # Кэш мешает отладке: отдаём всегда свежее. + # Дублируем заголовки: add_header в location перекрывает серверный. + add_header Cache-Control "no-cache"; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + } +}